feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Memind CI / Test, build, and release guards (push) Has been cancelled

Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-10 08:06:12 +08:00
parent 7c65540366
commit fde6503bdf
158 changed files with 9194 additions and 270 deletions
+196 -46
View File
@@ -1,10 +1,12 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type MutableRefObject } from 'react';
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import {
applyPageTemplatePrefill,
CHAT_SKILL_OPTIONS,
filterChatSkills,
isPageTemplateChatSkillId,
mergeChatSkillPromptWithInput,
} from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
@@ -34,6 +36,8 @@ import {
} from '../utils/chatAttachment';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatSkillIcon } from './ChatSkillIcons';
import { PageTemplateShopModal } from './PageTemplateShopModal';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
@@ -103,9 +107,9 @@ function formatUploadMegabytes(bytes: number) {
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
}
function UploadIcon() {
function UploadIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M8.5 12.5 12 9l3.5 3.5"
stroke="currentColor"
@@ -175,6 +179,10 @@ export function ChatPanel({
onApproveTool,
onPageSaved,
onClose,
balanceCents = 0,
onBalanceUpdate,
onGrantedSkillsUpdate,
onOpenRecharge,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
@@ -219,6 +227,10 @@ export function ChatPanel({
categoryCode?: MindSpaceSaveCategory;
}) => void;
onClose?: () => void;
balanceCents?: number;
onBalanceUpdate?: (balanceCents: number) => void;
onGrantedSkillsUpdate?: (skills: string[]) => void;
onOpenRecharge?: () => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
@@ -231,7 +243,15 @@ export function ChatPanel({
const [pgRequired, setPgRequired] = useState(false);
const [imageGenerationMode, setImageGenerationMode] = useState<ImageGenerationMode>('auto');
const [imageGenerationTipVisible, setImageGenerationTipVisible] = useState(false);
const [pgTipVisible, setPgTipVisible] = useState(false);
const [deepReasoningTipVisible, setDeepReasoningTipVisible] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [templateShopOpen, setTemplateShopOpen] = useState(false);
const [activeTemplatePrefill, setActiveTemplatePrefill] = useState<{
skillId: string;
label: string;
prompt: string;
} | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
@@ -240,7 +260,10 @@ export function ChatPanel({
const [fileError, setFileError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const pendingSkillRef = useRef<string | null>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const imageGenerationTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pgTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const deepReasoningTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
@@ -324,18 +347,66 @@ export function ChatPanel({
if (imageGenerationTipTimerRef.current) {
clearTimeout(imageGenerationTipTimerRef.current);
}
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
}
if (deepReasoningTipTimerRef.current) {
clearTimeout(deepReasoningTipTimerRef.current);
}
}, []);
const showTransientControlTip = useCallback(
(
setVisible: (visible: boolean) => void,
timerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>,
) => {
setVisible(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setVisible(false);
timerRef.current = null;
}, 5_000);
},
[],
);
const showImageGenerationSlowTip = useCallback(() => {
setImageGenerationTipVisible(true);
if (imageGenerationTipTimerRef.current) {
clearTimeout(imageGenerationTipTimerRef.current);
}
imageGenerationTipTimerRef.current = setTimeout(() => {
setImageGenerationTipVisible(false);
imageGenerationTipTimerRef.current = null;
}, 5_000);
}, []);
showTransientControlTip(setImageGenerationTipVisible, imageGenerationTipTimerRef);
}, [showTransientControlTip]);
const handlePgToggle = useCallback(() => {
setPgRequired((current) => {
const next = !current;
if (next) {
showTransientControlTip(setPgTipVisible, pgTipTimerRef);
} else {
setPgTipVisible(false);
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
pgTipTimerRef.current = null;
}
}
return next;
});
}, [showTransientControlTip]);
const handleDeepReasoningToggle = useCallback(() => {
setForceDeepReasoning((current) => {
const next = !current;
if (next) {
showTransientControlTip(setDeepReasoningTipVisible, deepReasoningTipTimerRef);
} else {
setDeepReasoningTipVisible(false);
if (deepReasoningTipTimerRef.current) {
clearTimeout(deepReasoningTipTimerRef.current);
deepReasoningTipTimerRef.current = null;
}
}
return next;
});
}, [showTransientControlTip]);
const handleImageGenerationClick = useCallback(() => {
setImageGenerationMode((current) => nextImageGenerationMode(current));
@@ -432,9 +503,11 @@ export function ChatPanel({
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
: activeTemplatePrefill
? '描述页面主题,例如「仙居玩水攻略」或「儿童饮食偏好调查」'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const connectStatusText =
uploadingImage || uploadingFile
? '正在上传,发出后会自动继续…'
@@ -456,6 +529,23 @@ export function ChatPanel({
? '提交中…'
: null;
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
pendingSkillRef.current = skillId;
// 输入框只保留用户主题;模板指令存 state,发送时再拼接。
setInput((current) => applyPageTemplatePrefill('', current));
setActiveTemplatePrefill({ skillId, label, prompt });
window.requestAnimationFrame(() => {
chatInputRef.current?.focus();
chatInputRef.current?.setSelectionRange(0, 0);
});
}, []);
const clearTemplatePrefill = useCallback(() => {
setInput((current) => applyPageTemplatePrefill('', current));
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
}, []);
const canUpload = Boolean(onUploadImage || onUploadFile);
const uploadFieldClass = canUpload ? ' chat-input-field-with-image-upload' : '';
@@ -620,9 +710,15 @@ export function ChatPanel({
const submitText = useCallback(async (textOverride?: string, skillIdOverride?: string) => {
const baseText = typeof textOverride === 'string' ? textOverride : input;
const trimmed = baseText.trim();
const selectedChatSkill = skillIdOverride ?? pendingSkillRef.current ?? undefined;
const userTheme = baseText.trim();
const templateSelection = activeTemplatePrefill;
const trimmed = templateSelection?.prompt
? applyPageTemplatePrefill(templateSelection.prompt, userTheme).trim()
: userTheme;
const selectedChatSkill =
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
@@ -789,7 +885,7 @@ export function ChatPanel({
setPendingFiles([]);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed);
setInput(userTheme);
setPendingImages(uploadedImages.map((item) => ({
...item,
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
@@ -808,6 +904,7 @@ export function ChatPanel({
setUploadingFile(false);
suppressVoiceUpdateRef.current = false;
}, [
activeTemplatePrefill,
forceDeepReasoning,
pgRequired,
imageGenerationMode,
@@ -1087,6 +1184,21 @@ export function ChatPanel({
/>
)}
{onGrantedSkillsUpdate && onBalanceUpdate && onOpenRecharge && (
<PageTemplateShopModal
open={templateShopOpen}
balanceCents={balanceCents}
onClose={() => setTemplateShopOpen(false)}
onBalanceUpdate={onBalanceUpdate}
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
onOpenRecharge={onOpenRecharge}
onUseTemplate={(prompt, skillId, label) => {
applyTemplatePrefill(prompt, skillId, label);
setTemplateShopOpen(false);
}}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
@@ -1172,6 +1284,16 @@ export function ChatPanel({
)}
</div>
)}
{activeTemplatePrefill && !taskInputStatusText && (
<div className="chat-template-prefill-banner" role="status">
<span>
已选「{activeTemplatePrefill.label}」· 在下方描述页面主题(行业、受众、要展示的内容),Enter 或点发送即可按模板生成
</span>
<button type="button" className="chat-template-prefill-clear" onClick={clearTemplatePrefill}>
取消
</button>
</div>
)}
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{canUpload && (
@@ -1183,7 +1305,7 @@ export function ChatPanel({
disabled={uploadDisabled}
onClick={() => uploadInputRef.current?.click()}
>
<UploadIcon />
<UploadIcon className="chat-image-upload-trigger-icon" />
</button>
<input
ref={uploadInputRef}
@@ -1211,6 +1333,7 @@ export function ChatPanel({
</div>
) : (
<textarea
ref={chatInputRef}
className={`input chat-input-field${uploadFieldClass}`}
rows={1}
placeholder={placeholder}
@@ -1245,30 +1368,55 @@ export function ChatPanel({
onboardingActive={chatControlOnboardingStep === 0}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
if (skillId && isPageTemplateChatSkillId(skillId)) {
const label =
CHAT_SKILL_OPTIONS.find((item) => item.id === skillId || item.skillName === skillId)?.label ??
'页面模板';
applyTemplatePrefill(prompt, skillId, label);
return;
}
pendingSkillRef.current = skillId ?? null;
setActiveTemplatePrefill(null);
setInput((current) => mergeChatSkillPromptWithInput(prompt, current));
}}
/>
)}
<label
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
>
<input
type="checkbox"
checked={pgRequired}
{onGrantedSkillsUpdate && (
<button
type="button"
className="chat-template-shop-trigger"
disabled={voiceDisabled}
aria-label="使用 PostgreSQL 数据空间"
onChange={(event) => setPgRequired(event.target.checked)}
/>
title="购买或管理页面模板"
onClick={() => setTemplateShopOpen(true)}
>
<ChatSkillIcon id="page" className="chat-template-shop-trigger-icon" />
<span className="chat-template-shop-trigger-label">模板</span>
</button>
)}
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${pgRequired ? ' is-active' : ''}${pgTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={pgRequired}
aria-label="使用 PostgreSQL 数据空间"
title="点击开启后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
onClick={handlePgToggle}
>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
<span className="chat-icon-toggle-label">db</span>
{pgTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
数据将写入你的 PostgreSQL 专属空间
</span>
)}
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">需要长期使用的数据,放进专属空间</span>
)}
</label>
</button>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}${imageGenerationTipVisible ? ' chat-image-generation-tip-active' : ''}`}
className={`chat-deep-reasoning-toggle chat-icon-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}${imageGenerationTipVisible ? ' chat-icon-toggle-tip-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={imageGenerationMode === 'auto' ? 'mixed' : imageGenerationMode === 'required'}
@@ -1283,31 +1431,33 @@ export function ChatPanel({
) : (
<Image className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
)}
<span className="chat-image-generation-state" aria-hidden="true">
{imageGenerationMode === 'auto' ? 'A' : imageGenerationMode === 'required' ? '✓' : '×'}
</span>
{imageGenerationTipVisible && (
<span className="chat-control-onboarding-tip chat-image-generation-tip" role="status">
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
图片生成较慢,请耐心等待
</span>
)}
</button>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${forceDeepReasoning ? ' is-active' : ''}${deepReasoningTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={forceDeepReasoning}
aria-label="使用深度推理"
title="点击开启后,本轮消息使用深度推理完成"
onClick={handleDeepReasoningToggle}
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
aria-label="使用深度推理"
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
<span className="chat-icon-toggle-label">推理</span>
{deepReasoningTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
深度推理耗时较长,请耐心等待
</span>
)}
{chatControlOnboardingStep === 2 && (
<span className="chat-control-onboarding-tip" role="status">复杂问题,开启深度推理</span>
)}
</label>
</button>
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
停止