feat: release chat and mindspace UI updates

This commit is contained in:
john
2026-06-27 21:57:21 +08:00
parent c924b2bb77
commit 8264e71f9e
24 changed files with 2042 additions and 209 deletions
+21
View File
@@ -726,6 +726,25 @@ export function buildChatSaveThumbnailUrl(input: {
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
}
export async function quickShareFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ publicUrl: string; filename: string }> {
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
'/mindspace/v1/pages/quick-share-from-chat',
{
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
},
);
return result.data;
}
export async function fetchPreviewAsset(path: string): Promise<string> {
let res: Response;
try {
@@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: {
categoryCode?: MindSpaceSaveCategory;
selectedLinkIndex?: number;
acknowledgedFindingIds?: string[];
replacePageId?: string;
}): Promise<ChatSaveResult> {
const result = await apiFetch<{ data: ChatSaveResult }>(
'/mindspace/v1/pages/save-from-chat',
@@ -769,6 +789,7 @@ export async function saveChatMessageAsPage(input: {
selected_link_index: input.selectedLinkIndex ?? 0,
acknowledged_finding_ids: input.acknowledgedFindingIds,
page_type: input.templateId === 'report' ? 'report' : 'article',
replace_page_id: input.replacePageId,
}),
},
);
+56 -17
View File
@@ -12,6 +12,38 @@ import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfir
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
@@ -76,6 +108,9 @@ export function ChatPanel({
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input);
@@ -105,6 +140,7 @@ export function ChatPanel({
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
@@ -112,7 +148,7 @@ export function ChatPanel({
? '正在连接会话…'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
: randomPrompt;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -321,7 +357,7 @@ export function ChatPanel({
return (
<>
<main className={compact ? 'space-chat-panel-body' : 'main'}>
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
@@ -329,6 +365,7 @@ export function ChatPanel({
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
@@ -369,7 +406,7 @@ export function ChatPanel({
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
@@ -399,8 +436,8 @@ export function ChatPanel({
)}
</div>
)}
<div className="chat-input-row">
<div className="chat-input-shell">
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
@@ -449,7 +486,7 @@ export function ChatPanel({
stopSignal={voiceStopSignal}
/>
</div>
{chatSkills.length > 0 && (
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
@@ -457,16 +494,18 @@ export function ChatPanel({
onPrefill={setInput}
/>
)}
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
{!showHomeWelcome && (
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
)}
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
@@ -483,7 +522,7 @@ export function ChatPanel({
) : (
<button
type="button"
className="send-btn chat-input-action"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
type SharePhase =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'done'; publicUrl: string }
| { kind: 'error'; message: string };
export function ChatSharePreviewModal({
sessionId,
messageId,
selectedLinkIndex = 0,
onClose,
onSave,
}: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
onClose: () => void;
onSave?: () => void;
}) {
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
const share = async () => {
if (phase.kind === 'loading') return;
setPhase({ kind: 'loading' });
try {
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
setPhase({ kind: 'done', publicUrl: result.publicUrl });
} catch (err) {
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
}
};
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const handleSave = () => {
onClose();
onSave?.();
};
return createPortal(
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
<div className="chat-share-preview-header">
<span className="chat-share-preview-title"></span>
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭"></button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
<div className="chat-share-result-card">
<p className="chat-share-result-label"> </p>
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
{copied ? '已复制 ✓' : '复制链接'}
</button>
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
</a>
</div>
) : (
<button
type="button"
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
disabled={phase.kind === 'loading'}
onClick={() => void share()}
title="将页面转为公开链接,私有图片自动转为公开地址"
>
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
</button>
)}
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
{onSave && (
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
📄
</button>
)}
</div>
</div>
</div>,
document.body,
);
}
+4 -3
View File
@@ -72,6 +72,7 @@ export function ChatView({
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
@@ -85,7 +86,7 @@ export function ChatView({
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'connecting'
: chatState === 'idle'
? '新对话'
: '连接中…';
@@ -128,8 +129,8 @@ export function ChatView({
onDelete={(sessionId) => void deleteSession(sessionId)}
/>
<div className="app">
<header className="header">
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
+110
View File
@@ -0,0 +1,110 @@
import { motion } from 'framer-motion';
import {
PenSquare,
Code2,
BarChart3,
FileText,
Bot,
} from 'lucide-react';
import { TKMindAvatar } from './TKMindAvatar';
const features = [
{ icon: PenSquare, text: '写作创作' },
{ icon: Code2, text: '编程开发' },
{ icon: BarChart3, text: '数据分析' },
{ icon: FileText, text: '文档总结' },
{ icon: Bot, text: '自动执行' },
];
export function ChatWelcomePanel({ compact }: { compact?: boolean }) {
if (compact) {
return (
<div className="empty-state empty-state-compact">
<TKMindAvatar />
<h2>TKMind</h2>
<p> Agent </p>
</div>
);
}
return (
<div className="welcome-panel">
<div className="welcome-panel-content">
<motion.div
initial={{ opacity: 0, y: -15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="welcome-panel-avatar-wrap"
>
<TKMindAvatar size="lg" className="welcome-panel-avatar" />
</motion.div>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="welcome-panel-brand"
>
TKMind智趣
</motion.h2>
<motion.h1
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="welcome-panel-headline"
>
<span className="welcome-panel-headline-base"></span>
<span className="welcome-panel-headline-gradient">AI </span>
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="welcome-panel-subtitle"
>
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="welcome-panel-description"
>
TKMind
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="welcome-panel-actions"
>
{features.map((item) => {
const Icon = item.icon;
return (
<div key={item.text} className="welcome-panel-pill">
<span className="welcome-panel-pill-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</span>
<span>{item.text}</span>
</div>
);
})}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.95 }}
className="welcome-panel-hint"
>
<div className="welcome-panel-hint-arrow"></div>
<div className="text-base"></div>
</motion.div>
</div>
</div>
);
}
+43 -13
View File
@@ -58,10 +58,12 @@ export function HistorySidebar({
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
setPendingDeleteId(null);
}
}, [open, sessions.length]);
@@ -74,14 +76,22 @@ export function HistorySidebar({
}
}, [visibleCount, sessions.length]);
const handleDelete = useCallback(
(sessionId: string, label: string) => {
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingDeleteId(sessionId);
}, []);
const handleConfirmDelete = useCallback(
(sessionId: string) => {
setPendingDeleteId(null);
onDelete(sessionId);
},
[onDelete],
);
const handleCancelDelete = useCallback(() => {
setPendingDeleteId(null);
}, []);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
@@ -119,26 +129,46 @@ export function HistorySidebar({
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
const isPending = pendingDeleteId === item.id;
return (
<li key={item.id} className="sidebar-item-wrap">
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
onClick={() => { if (!isPending) onSelect(item.id); }}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDelete(item.id, label)}
>
<CloseIcon />
</button>
{isPending ? (
<div className="sidebar-item-confirm">
<button
type="button"
className="sidebar-confirm-yes"
onClick={() => handleConfirmDelete(item.id)}
>
</button>
<button
type="button"
className="sidebar-confirm-no"
onClick={handleCancelDelete}
>
</button>
</div>
) : (
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDeleteClick(item.id)}
>
<CloseIcon />
</button>
)}
</li>
);
})}
+74 -26
View File
@@ -8,6 +8,8 @@ import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
return (
@@ -37,18 +39,58 @@ function CheckIcon() {
);
}
function ToolSpinnerIcon() {
return (
<span className="tool-badge-spinner" aria-hidden="true">
<span />
</span>
);
}
function ToolDoneIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
const completed = tools.filter((t) => t.type === 'toolResponse').length;
const total = tools.length;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<span key={`${message.id}-${i}`} className="tool-badge">
{tool.type === 'toolRequest'
? `🔧 ${tool.toolCall.functionName}`
: `✓ 工具完成`}
</span>
))}
{tools.map((tool, i) => {
const isRequest = tool.type === 'toolRequest';
const toolName = isRequest ? String(tool.toolCall.functionName ?? '').trim() : '';
let label: string;
if (isRequest && !toolName) {
label = `正在加载 tools (${completed}/${total})`;
} else {
label = isRequest ? toolName : '工具完成';
}
return (
<span
key={`${message.id}-${i}`}
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
>
{isRequest ? <ToolSpinnerIcon /> : <ToolDoneIcon />}
<span>{label}</span>
</span>
);
})}
</div>
);
}
@@ -126,6 +168,7 @@ function MessageRow({
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
message: Message;
@@ -135,9 +178,11 @@ function MessageRow({
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
@@ -227,15 +272,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
</a>
</button>
)}
</>
)}
@@ -254,15 +298,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
</a>
</button>
)}
</>
)}
@@ -278,6 +321,14 @@ function MessageRow({
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
{previewOpen && sessionId && message.id && (
<ChatSharePreviewModal
sessionId={sessionId}
messageId={message.id}
onClose={() => setPreviewOpen(false)}
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
/>
)}
</div>
);
}
@@ -289,6 +340,7 @@ export function MessageList({
onSaveAsPage,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
@@ -297,19 +349,14 @@ export function MessageList({
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && (
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
<TKMindAvatar />
<h2>TKMind</h2>
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
</div>
)}
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
@@ -324,6 +371,7 @@ export function MessageList({
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
/>
</div>
+17
View File
@@ -54,11 +54,15 @@ export function MindSpaceFeedCard({
previewMode = false,
onClick,
actions,
checked,
onCheckChange,
}: {
page: MindSpacePage;
previewMode?: boolean;
onClick: () => void;
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
checked?: boolean;
onCheckChange?: (checked: boolean) => void;
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
@@ -68,6 +72,19 @@ export function MindSpaceFeedCard({
<div className="mindspace-feed-card-wrap">
<button type="button" className="mindspace-feed-card" onClick={onClick}>
<div className="mindspace-feed-cover">
{onCheckChange !== undefined && (
<label
className="mindspace-feed-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={checked ?? false}
onChange={(e) => onCheckChange(e.target.checked)}
/>
<span>{checked ? '已选' : '选择'}</span>
</label>
)}
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
+93 -16
View File
@@ -476,6 +476,8 @@ export function MindSpaceView({
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
@@ -1335,6 +1337,55 @@ export function MindSpaceView({
}
};
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
const allDraftPagesSelected =
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
const toggleDraftPageSelected = (pageId: string) => {
setSelectedDraftPageIds((ids) =>
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
);
};
const toggleAllDraftPagesSelected = () => {
if (allDraftPagesSelected) {
setSelectedDraftPageIds([]);
} else {
setSelectedDraftPageIds(pages.map((p) => p.id));
}
};
const removeDraftPages = async () => {
if (selectedDraftPageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkDeletingDraftPages(true);
setError(null);
const deletingIds = [...selectedDraftPageIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const pageId of deletingIds) {
try {
await deleteMindSpacePage(pageId);
if (selectedPageId === pageId) closePage();
} catch {
failedIds.push(pageId);
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
}
}
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
if (failedNames.length > 0) {
setError(`${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeletingDraftPages(false);
}
};
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
@@ -1362,11 +1413,6 @@ export function MindSpaceView({
广
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-asset-share"
@@ -1936,7 +1982,7 @@ export function MindSpaceView({
{!agentJobsLoading && agentJobsList.length === 0 && (
<div className="mindspace-empty">
<h2> AI </h2>
<p> OA AI </p>
<p> OA </p>
</div>
)}
{!agentJobsLoading && agentJobsList.length > 0 && (
@@ -2004,16 +2050,47 @@ export function MindSpaceView({
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
<>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleAllDraftPagesSelected}
disabled={bulkDeletingDraftPages}
>
{allDraftPagesSelected ? '取消全选' : '全选'}
</button>
<span> {selectedDraftPageIds.length} </span>
{selectedDraftPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedDraftPageIds([])}
disabled={bulkDeletingDraftPages}
>
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeDraftPages()}
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
>
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
checked={selectedDraftPageSet.has(page.id)}
onCheckChange={() => toggleDraftPageSelected(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-empty">
<h2>稿</h2>
+64 -24
View File
@@ -68,6 +68,7 @@ export function PageSaveDialog({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
@@ -151,7 +152,10 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const save = async () => {
const existingPage = analysis?.existingPage ?? null;
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
const save = async (replacePageId?: string) => {
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
@@ -168,10 +172,11 @@ export function PageSaveDialog({
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
replacePageId,
});
if (result.kind === 'page') {
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode, pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -202,7 +207,29 @@ export function PageSaveDialog({
{analysisLoading && <div className="page-save-state"></div>}
{analysisError && <div className="page-save-error">{analysisError}</div>}
{!analysisLoading && analysis && (
{!analysisLoading && showDuplicatePrompt && (
<div className="page-save-duplicate">
<p>{existingPage!.title}</p>
<div className="page-save-duplicate-actions">
<button
type="button"
className="page-save-duplicate-btn"
onClick={() => setDuplicateResolved('replace')}
>
</button>
<button
type="button"
className="page-save-duplicate-btn page-save-duplicate-btn-new"
onClick={() => setDuplicateResolved('new')}
>
</button>
</div>
</div>
)}
{!analysisLoading && analysis && !showDuplicatePrompt && (
<div className="page-save-layout">
<div className="page-save-form">
{isStaticHtml && analysis && (
@@ -225,6 +252,7 @@ export function PageSaveDialog({
onChange={(event) => {
const nextIndex = Number(event.target.value);
setSelectedLinkIndex(nextIndex);
setDuplicateResolved(null);
void loadAnalysis(nextIndex);
}}
>
@@ -362,26 +390,38 @@ export function PageSaveDialog({
{saveError && <div className="page-save-error">{saveError}</div>}
</div>
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
</button>
<button
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
</div>
{!showDuplicatePrompt && (
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
</button>
<button
type="button"
className="page-save-primary"
onClick={() =>
void save(
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
)
}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving
? '正在保存…'
: duplicateResolved === 'replace'
? '替换并进入编辑'
: categoryCode === 'draft'
? '保存并进入编辑'
: '保存到空间'}
</button>
</div>
)}
</section>
</div>,
document.body,
+2 -2
View File
@@ -2,11 +2,11 @@ import tkmindAvatar from '../assets/tkmind-avatar.png';
type TKMindAvatarProps = {
className?: string;
size?: 'sm' | 'md';
size?: 'sm' | 'md' | 'lg';
};
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
const sizeClass = size === 'lg' ? 'tkmind-avatar-lg' : size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
return (
<div
+73 -53
View File
@@ -52,6 +52,7 @@ import {
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
mergeConversationSnapshot,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
@@ -60,7 +61,12 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
import {
mergeSessionLists,
prependUnique,
shouldShowNewChatTitle,
touchSession,
} from '../utils/sessions';
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
@@ -338,7 +344,7 @@ export function useTKMindChat(
setSessionsLoading(true);
try {
const items = await listSessions();
setSessions(sortAndTrim(items));
setSessions((prev) => mergeSessionLists(prev, items));
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
@@ -385,6 +391,7 @@ export function useTKMindChat(
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
setSessions((prev) => prependUnique(prev, detail.session));
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
setMessages(detail.messages);
@@ -467,12 +474,14 @@ export function useTKMindChat(
}
return;
}
case 'UpdateConversation':
messagesRef.current = normalizeConversationMessages(
case 'UpdateConversation': {
const snapshot = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot);
setMessages(messagesRef.current);
return;
}
case 'Error':
setError(event.error);
setChatState('error');
@@ -519,11 +528,22 @@ export function useTKMindChat(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (rid) processEvent(event, rid, sessionId);
else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) {
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
} else if (rid && !event.request_ids.includes(rid)) {
// Our request is no longer active — agent finished while SSE was paused.
// Sync the final state from the server.
activeRequestId.current = null;
setChatState('idle');
setPendingTool(null);
void syncSessionMessages(sessionId);
}
return;
}
if (rid) processEvent(event, rid, sessionId);
},
(err) => {
if (err instanceof ApiError && err.status === 401) return;
@@ -531,6 +551,8 @@ export function useTKMindChat(
notifyInsufficientBalance();
return;
}
// SSE 断连时如果 agent 还在跑,不打断 chatState,等重连后事件恢复
if (activeRequestId.current) return;
setError(err.message);
},
{
@@ -643,7 +665,6 @@ export function useTKMindChat(
setError(null);
const previousSessionId = readStoredSessionId(userRef.current?.id);
let sessionId: string | null = null;
let staleSession = false;
if (previousSessionId) {
@@ -667,20 +688,12 @@ export function useTKMindChat(
}
}
const freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
await connectSessionRef.current(sessionId, {
skipResume: true,
seedSession: freshSession,
});
if (cancelled) return;
void loadProjectMemory(sessionId, false);
setChatState('idle');
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -745,7 +758,7 @@ export function useTKMindChat(
imageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!session || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
@@ -764,15 +777,41 @@ export function useTKMindChat(
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setSessions((prev) => touchSession(prev, session.id, 1));
setChatState('streaming');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (!activeSessionId) {
try {
const created = await startSession();
activeSessionId = created.id;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
subscribeToSession(created.id);
void ensureProvider(created.id);
void loadProjectMemory(created.id, false);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
return;
}
} else {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
await sendReply(session.id, requestId, userMessage);
await sendReply(activeSessionId, requestId, userMessage);
} catch (err) {
setSessions((prev) => touchSession(prev, session.id, -1));
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -782,7 +821,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState, grantedSkills],
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
);
const stop = useCallback(async () => {
@@ -808,37 +847,18 @@ export function useTKMindChat(
const newSession = useCallback(() => {
if (chatState === 'streaming') return;
resetSessionView();
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
messagesRef.current = [];
setMessages([]);
setSession(null);
const token = connectTokenRef.current;
void (async () => {
try {
const created = await startSession();
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
void loadProjectMemory(created.id, false);
void refreshSessions();
await ensureProvider(created.id);
if (token !== connectTokenRef.current) return;
await connectSession(created.id, { showLoading: false });
} catch (err) {
if (token !== connectTokenRef.current) return;
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
})();
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
setError(null);
setPendingTool(null);
setChatState('idle');
}, [chatState]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming') return;
+880 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -515,6 +515,7 @@ export type ChatSaveAnalysis = {
filename: string | null;
hasHtmlContent: boolean;
privacyScan: MindSpacePrivacyScan;
existingPage: { id: string; title: string; categoryCode: string } | null;
};
export type ChatSaveResult =
+20
View File
@@ -113,6 +113,26 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
);
}
/**
* Merge an authoritative server conversation snapshot into the currently
* displayed messages without ever erasing what the user can already see.
*
* `UpdateConversation` snapshots arrive on every SSE (re)connect which on
* mobile happens constantly via the page visibility cycle. A mid-turn snapshot
* can be shorter than the live view (e.g. assistant messages not yet flagged
* userVisible), so a blind replace would blank an active conversation even
* though the backend session is alive and persisted. We therefore treat the
* snapshot as authoritative for content/order but keep any locally-displayed
* messages the snapshot hasn't caught up to yet (optimistic / in-flight tail).
* The authoritative full reload on `Finish` corrects any drift afterwards.
*/
export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean));
const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id));
return localOnly.length ? [...incoming, ...localOnly] : incoming;
}
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
+46 -5
View File
@@ -3,9 +3,18 @@ import type { Session } from '../types';
export const DEFAULT_CHAT_TITLE = 'New Chat';
function isGeneratedSessionName(name: string): boolean {
return /^20\d{6}(?:[_-]\d+)?$/.test(name.trim());
}
function isDefaultSessionName(name: string): boolean {
const trimmed = name.trim();
return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
export function shouldShowNewChatTitle(session: Session): boolean {
if (session.recipe) return false;
return !session.user_set_name && session.message_count === 0;
return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name);
}
export function sortAndTrim(sessions: Session[]): Session[] {
@@ -18,9 +27,39 @@ export function sortAndTrim(sessions: Session[]): Session[] {
.slice(0, appConfig.sessionMaxCount);
}
export function hasMeaningfulSessionTitle(session: Session): boolean {
if (session.user_set_name) return Boolean(session.name.trim());
if (session.recipe?.title?.trim()) return true;
return !isDefaultSessionName(session.name);
}
export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session {
if (!existing) return incoming;
const keepExistingTitle =
hasMeaningfulSessionTitle(existing) && !hasMeaningfulSessionTitle(incoming);
if (!keepExistingTitle) return { ...existing, ...incoming, id: incoming.id };
return {
...existing,
...incoming,
id: incoming.id,
name: existing.name,
user_set_name: existing.user_set_name,
recipe: existing.recipe ?? incoming.recipe,
};
}
export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)));
}
export function prependUnique(prev: Session[], session: Session): Session[] {
if (prev.some((s) => s.id === session.id)) return sortAndTrim(prev);
return sortAndTrim([session, ...prev.filter((s) => s.id !== session.id)]);
return sortAndTrim([
mergeSessionRecord(prev.find((s) => s.id === session.id), session),
...prev.filter((s) => s.id !== session.id),
]);
}
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
@@ -102,9 +141,11 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
return groups;
}
const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']);
export function getSessionListLabel(session: Session): string {
const displayName = getSessionDisplayName(session);
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
const displayName = getSessionDisplayName(session).trim();
if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) {
return displayName;
}
if (session.message_count > 0) {