From c2f32473a7623655d1e14a173795ce3c306167f5 Mon Sep 17 00:00:00 2001 From: Dennis-Huangm <2509562097@qq.com> Date: Thu, 5 Mar 2026 12:38:30 +0800 Subject: [PATCH 1/3] feat(ui): add persistent chat conversation history store chat/agent conversations per project in local storage and restore the latest thread by mode on load add conversation controls for new, history, clear, rename, and delete to make thread management easier add assistant message actions for copy and regenerate, plus enter-to-send and in-flight guards to prevent duplicate requests update i18n strings and styles to support the new history and action UI (cherry picked from commit e79c66442c32a4696a5f96ad0f0462475b36fa11) --- .gitignore | 3 + apps/frontend/src/app/App.css | 229 ++++++++++ apps/frontend/src/app/EditorPage.tsx | 504 +++++++++++++++++++++- apps/frontend/src/i18n/locales/en-US.json | 10 +- apps/frontend/src/i18n/locales/zh-CN.json | 10 +- 5 files changed, 740 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 0f40381..f2e4d32 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ apps/backend/.cache/ data/ AGENTS.md + +.playwright-mcp/ +.claude/settings.local.json \ No newline at end of file diff --git a/apps/frontend/src/app/App.css b/apps/frontend/src/app/App.css index ec09d64..1a25050 100644 --- a/apps/frontend/src/app/App.css +++ b/apps/frontend/src/app/App.css @@ -3739,3 +3739,232 @@ textarea.input { .transfer-widget-log > div { margin-bottom: 2px; } + +/* ── Conversation history ── */ + +.conv-actions { + display: flex; + gap: 2px; + margin-left: 6px; +} + +.conv-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + width: 24px; + height: 24px; + font-size: 13px; + line-height: 1; + cursor: pointer; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.conv-btn:hover, +.conv-btn.active { + background: var(--accent-soft); + color: var(--accent); +} + +.conversation-title-bar { + padding: 2px 14px 4px; + font-size: 11px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid var(--border); + background: var(--panel-muted); +} + +.history-dropdown { + max-height: 240px; + overflow-y: auto; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.history-empty { + padding: 16px 14px; + text-align: center; + color: var(--muted); + font-size: 12px; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 14px; + cursor: pointer; + transition: background 0.12s; + border-bottom: 1px solid rgba(120, 98, 83, 0.08); +} + +.history-item:hover { + background: var(--accent-soft); +} + +.history-item.active { + background: rgba(180, 74, 47, 0.10); +} + +.history-item-info { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.history-item-title { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-item-time { + font-size: 10px; + color: var(--muted); +} + +.history-item-delete { + background: none; + border: none; + color: var(--muted); + font-size: 14px; + cursor: pointer; + padding: 0 2px; + margin-left: 6px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} + +.history-item:hover .history-item-delete { + opacity: 1; +} + +@media (pointer: coarse) { + .history-item-delete { + opacity: 1; + } +} + +.history-item-delete:hover { + color: var(--accent); +} + +.history-item-rename { + background: none; + border: none; + color: var(--muted); + font-size: 13px; + cursor: pointer; + padding: 0 2px; + margin-left: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} + +.history-item:hover .history-item-rename { + opacity: 1; +} + +.history-item-rename:hover { + color: var(--accent); +} + +.conv-rename-input { + width: 100%; + border: 1px solid var(--accent); + border-radius: 3px; + background: var(--paper); + color: var(--text); + font-size: 12px; + padding: 1px 4px; + outline: none; + font-family: inherit; +} + +.conversation-title-bar:hover { + background: rgba(120, 98, 83, 0.06); +} + +.conversation-title-bar { + display: flex; + align-items: center; + gap: 4px; + cursor: default; +} + +.conversation-title-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conv-title-edit-btn { + background: none; + border: none; + color: var(--muted); + font-size: 12px; + cursor: pointer; + padding: 0 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} + +.conversation-title-bar:hover .conv-title-edit-btn { + opacity: 1; +} + +.conv-title-edit-btn:hover { + color: var(--accent); +} + +/* ── Message action buttons (copy / retry) ── */ + +.msg-actions { + display: flex; + gap: 4px; + margin-top: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.chat-msg:hover .msg-actions { + opacity: 1; +} + +.msg-action-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + width: 26px; + height: 22px; + font-size: 13px; + line-height: 1; + cursor: pointer; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.msg-action-btn:hover { + background: var(--accent-soft); + color: var(--accent); +} diff --git a/apps/frontend/src/app/EditorPage.tsx b/apps/frontend/src/app/EditorPage.tsx index f924bd7..0ae1863 100644 --- a/apps/frontend/src/app/EditorPage.tsx +++ b/apps/frontend/src/app/EditorPage.tsx @@ -155,6 +155,67 @@ function persistSettings(settings: AppSettings) { } } +/* ── Conversation history persistence ── */ + +interface Conversation { + id: string; + title: string; + mode: 'chat' | 'agent'; + messages: Message[]; + createdAt: string; + updatedAt: string; +} + +const HISTORY_KEY_PREFIX = 'openprism-chat-history-'; +const MAX_CONVERSATIONS = 50; +const MAX_MESSAGES = 100; + +function loadConversations(pid: string): Conversation[] { + if (typeof window === 'undefined' || !pid) return []; + try { + const raw = window.localStorage.getItem(HISTORY_KEY_PREFIX + pid); + if (!raw) return []; + return JSON.parse(raw) as Conversation[]; + } catch { + return []; + } +} + +function persistConversations(pid: string, convs: Conversation[]) { + if (typeof window === 'undefined' || !pid) return; + try { + const trimmed = convs.slice(0, MAX_CONVERSATIONS).map((c) => ({ + ...c, + messages: c.messages.slice(-MAX_MESSAGES) + })); + window.localStorage.setItem(HISTORY_KEY_PREFIX + pid, JSON.stringify(trimmed)); + } catch { + // ignore + } +} + +function generateConversationTitle(messages: Message[], fallbackTitle: string): string { + const first = messages.find((m) => m.role === 'user'); + if (!first) return fallbackTitle; + const text = first.content.replace(/\s+/g, ' ').trim(); + return text.length > 30 ? text.slice(0, 30) + '\u2026' : text; +} + +function relativeTime(iso: string, t: (key: string, opts?: Record) => string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return t('\u521a\u521a'); + if (minutes < 60) return t('{{n}} \u5206\u949f\u524d', { n: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t('{{n}} \u5c0f\u65f6\u524d', { n: hours }); + const days = Math.floor(hours / 24); + return t('{{n}} \u5929\u524d', { n: days }); +} + +function createLocalId(): string { + return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; +} + function loadCollabName() { if (typeof window === 'undefined') return ''; try { @@ -1230,6 +1291,9 @@ export default function EditorPage() { const [assistantMode, setAssistantMode] = useState<'chat' | 'agent'>('agent'); const [chatMessages, setChatMessages] = useState([]); const [agentMessages, setAgentMessages] = useState([]); + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); const [prompt, setPrompt] = useState(''); const [task, setTask] = useState(DEFAULT_TASKS(t)[0].value); const [mode, setMode] = useState<'direct' | 'tools'>('direct'); @@ -1347,6 +1411,12 @@ export default function EditorPage() { const applyingSuggestionRef = useRef(false); const suppressDirtyRef = useRef(false); const typewriterTimerRef = useRef(null); + const activeConvIdRef = useRef(null); + const sendPromptRef = useRef<(() => void) | null>(null); + const assistantModeRef = useRef<'chat' | 'agent'>(assistantMode); + const conversationsRef = useRef([]); + const pendingRequestRef = useRef<{ mode: 'chat' | 'agent'; conversationId: string } | null>(null); + const sendInFlightRef = useRef(false); const requestSuggestionRef = useRef<() => void>(() => {}); const acceptSuggestionRef = useRef<() => void>(() => {}); const acceptChunkRef = useRef<() => void>(() => {}); @@ -1382,6 +1452,204 @@ export default function EditorPage() { persistSettings(settings); }, [settings]); + useEffect(() => { + assistantModeRef.current = assistantMode; + }, [assistantMode]); + + useEffect(() => { + conversationsRef.current = conversations; + }, [conversations]); + + /* ── Conversation history lifecycle ── */ + useEffect(() => { + if (!projectId) return; + const loaded = loadConversations(projectId); + setConversations(loaded); + const latest = loaded.find((c) => c.mode === assistantMode); + if (latest) { + setActiveConversationId(latest.id); + activeConvIdRef.current = latest.id; + if (latest.mode === 'chat') setChatMessages(latest.messages); + else setAgentMessages(latest.messages); + } else { + setActiveConversationId(null); + activeConvIdRef.current = null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const persistCurrentConversation = useCallback( + ( + msgs: Message[], + mode: 'chat' | 'agent', + options?: { conversationId?: string | null; defaultTitle?: string; requestConversationId?: string } + ) => { + if (!projectId || msgs.length === 0) return; + const originConversationId = options?.conversationId ?? null; + const requestConversationId = options?.requestConversationId; + const defaultTitle = options?.defaultTitle ?? t('新对话'); + const now = new Date().toISOString(); + const baseConversations = conversationsRef.current; + const existing = originConversationId + ? baseConversations.find((c) => c.id === originConversationId && c.mode === mode) + : null; + + let nextConversations: Conversation[]; + let nextActiveConversationId: string | null = null; + + if (existing) { + nextConversations = baseConversations.map((c) => + c.id === existing.id + ? { + ...c, + messages: msgs.slice(-MAX_MESSAGES), + title: generateConversationTitle(msgs, defaultTitle), + updatedAt: now + } + : c + ); + } else { + const id = requestConversationId ?? createLocalId(); + const conv: Conversation = { + id, + title: generateConversationTitle(msgs, defaultTitle), + mode, + messages: msgs.slice(-MAX_MESSAGES), + createdAt: now, + updatedAt: now + }; + const canActivate = + assistantModeRef.current === mode && + (originConversationId ? activeConvIdRef.current === originConversationId : activeConvIdRef.current === null); + if (canActivate) { + nextActiveConversationId = id; + } + nextConversations = [conv, ...baseConversations]; + } + + const sorted = [...nextConversations] + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, MAX_CONVERSATIONS); + persistConversations(projectId, sorted); + setConversations(sorted); + conversationsRef.current = sorted; + + if (nextActiveConversationId) { + setActiveConversationId(nextActiveConversationId); + activeConvIdRef.current = nextActiveConversationId; + if (pendingRequestRef.current?.mode === mode && pendingRequestRef.current?.conversationId === requestConversationId) { + pendingRequestRef.current = { ...pendingRequestRef.current, conversationId: nextActiveConversationId }; + } + } + }, + [projectId, t] + ); + + const handleNewConversation = useCallback(() => { + if (assistantMode === 'chat') setChatMessages([]); + else setAgentMessages([]); + setActiveConversationId(null); + activeConvIdRef.current = null; + pendingRequestRef.current = null; + setHistoryOpen(false); + }, [assistantMode]); + + const handleLoadConversation = useCallback( + (conv: Conversation) => { + if (conv.mode === 'chat') { + setChatMessages(conv.messages); + setAssistantMode('chat'); + } else { + setAgentMessages(conv.messages); + setAssistantMode('agent'); + } + setActiveConversationId(conv.id); + activeConvIdRef.current = conv.id; + pendingRequestRef.current = null; + setHistoryOpen(false); + }, + [] + ); + + const handleDeleteConversation = useCallback( + (convId: string) => { + const next = conversationsRef.current.filter((c) => c.id !== convId); + persistConversations(projectId, next); + setConversations(next); + conversationsRef.current = next; + if (activeConvIdRef.current === convId) { + if (assistantMode === 'chat') setChatMessages([]); + else setAgentMessages([]); + setActiveConversationId(null); + activeConvIdRef.current = null; + pendingRequestRef.current = null; + } + }, + [projectId, assistantMode] + ); + + const [renamingConvId, setRenamingConvId] = useState(null); + const [renamingValue, setRenamingValue] = useState(''); + const [copiedMsgIdx, setCopiedMsgIdx] = useState(null); + + const handleRenameConversation = useCallback( + (convId: string, newTitle: string) => { + const trimmed = newTitle.trim(); + setRenamingConvId(null); + if (!trimmed) return; + const current = conversationsRef.current; + const target = current.find((c) => c.id === convId); + if (!target || target.title === trimmed) return; + const updated = current.map((c) => + c.id === convId ? { ...c, title: trimmed } : c + ); + persistConversations(projectId, updated); + setConversations(updated); + conversationsRef.current = updated; + }, + [projectId] + ); + + const handleCopyMessage = useCallback((content: string, idx: number) => { + navigator.clipboard.writeText(content).then(() => { + setCopiedMsgIdx(idx); + setTimeout(() => setCopiedMsgIdx(null), 1500); + }).catch(() => { /* ignore */ }); + }, []); + + const handleRetryMessage = useCallback((idx: number) => { + const msgs = assistantMode === 'chat' ? chatMessages : agentMessages; + const setHistory = assistantMode === 'chat' ? setChatMessages : setAgentMessages; + // Find the user message that precedes this assistant message + let userMsgIdx = idx - 1; + while (userMsgIdx >= 0 && msgs[userMsgIdx].role !== 'user') userMsgIdx--; + if (userMsgIdx < 0) return; + const userPrompt = msgs[userMsgIdx].content; + // Remove everything from userMsgIdx onward + const trimmed = msgs.slice(0, userMsgIdx); + setHistory(trimmed); + // Set prompt and trigger send on next tick + setPrompt(userPrompt); + setTimeout(() => { + sendPromptRef.current?.(); + }, 0); + }, [assistantMode, chatMessages, agentMessages]); + + const handleClearConversation = useCallback(() => { + if (!window.confirm(t('确定清空当前对话?'))) return; + if (assistantMode === 'chat') setChatMessages([]); + else setAgentMessages([]); + if (activeConvIdRef.current) { + const next = conversationsRef.current.filter((c) => c.id !== activeConvIdRef.current); + persistConversations(projectId, next); + setConversations(next); + conversationsRef.current = next; + } + setActiveConversationId(null); + activeConvIdRef.current = null; + pendingRequestRef.current = null; + }, [assistantMode, projectId, t]); + useEffect(() => { if (collabServer) { setCollabServer(collabServer); @@ -3386,12 +3654,19 @@ export default function EditorPage() { setPdfFitScale((prev) => (prev && Math.abs(prev - value) < 0.005 ? prev : value)); }, []); - const startTypewriter = useCallback((setHistory: Dispatch>, text: string) => { + const startTypewriter = useCallback(( + setHistory: Dispatch>, + text: string, + guard: { mode: 'chat' | 'agent'; conversationId: string } + ) => { if (typewriterTimerRef.current) { window.clearTimeout(typewriterTimerRef.current); typewriterTimerRef.current = null; } if (!text) { + if (assistantModeRef.current !== guard.mode || activeConvIdRef.current !== guard.conversationId) { + return; + } setHistory((prev) => { if (prev.length === 0) return prev; const next = [...prev]; @@ -3405,6 +3680,10 @@ export default function EditorPage() { } let idx = 0; const step = () => { + if (assistantModeRef.current !== guard.mode || activeConvIdRef.current !== guard.conversationId) { + typewriterTimerRef.current = null; + return; + } idx = Math.min(text.length, idx + 2); const slice = text.slice(0, idx); setHistory((prev) => { @@ -3431,19 +3710,31 @@ export default function EditorPage() { }, []); const sendPrompt = async () => { + if (sendInFlightRef.current) return; + sendInFlightRef.current = true; const isChat = assistantMode === 'chat'; - if (!activePath && !isChat) return; + const requestMode: 'chat' | 'agent' = isChat ? 'chat' : 'agent'; + const requestConversationId = activeConvIdRef.current; + const requestTargetConversationId = requestConversationId ?? createLocalId(); + pendingRequestRef.current = { mode: requestMode, conversationId: requestTargetConversationId }; + const defaultConversationTitle = t('新对话'); + if (!activePath && !isChat) { + sendInFlightRef.current = false; + return; + } if (isChat === false && task === 'translate') { if (translateScope === 'selection' && !selectionText) { setStatus(t('请选择要翻译的文本。')); + sendInFlightRef.current = false; return; } } const userMsg: Message = { role: 'user', content: prompt || t('(empty)') }; const setHistory = isChat ? setChatMessages : setAgentMessages; const history = isChat ? chatMessages : agentMessages; - const nextHistory = [...history, userMsg]; + const nextHistory = [...history, userMsg].slice(-MAX_MESSAGES); setHistory(nextHistory); + setPrompt(''); try { let effectivePrompt = prompt; let effectiveSelection = selectionText; @@ -3499,8 +3790,27 @@ export default function EditorPage() { history: nextHistory.slice(-8) }); const replyText = res.reply || t('已生成建议。'); - setHistory((prev) => [...prev, { role: 'assistant', content: '' }]); - window.setTimeout(() => startTypewriter(setHistory, replyText), 0); + const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: replyText }]; + persistCurrentConversation(persistedHistory, isChat ? 'chat' : 'agent', { + conversationId: requestConversationId, + requestConversationId: requestTargetConversationId, + defaultTitle: defaultConversationTitle + }); + if ( + pendingRequestRef.current?.mode === requestMode && + pendingRequestRef.current?.conversationId === requestTargetConversationId && + assistantModeRef.current === requestMode && + activeConvIdRef.current === requestTargetConversationId + ) { + setHistory((prev) => [...prev, { role: 'assistant' as const, content: '' }].slice(-MAX_MESSAGES)); + window.setTimeout(() => startTypewriter(setHistory, replyText, { mode: requestMode, conversationId: requestTargetConversationId }), 0); + } + if ( + pendingRequestRef.current?.mode === requestMode && + pendingRequestRef.current?.conversationId === requestTargetConversationId + ) { + pendingRequestRef.current = null; + } if (!isChat && res.patches && res.patches.length > 0) { const nextPending = res.patches.map((patch) => ({ @@ -3520,10 +3830,34 @@ export default function EditorPage() { setRightView('diff'); } } catch (err) { - setHistory((prev) => [...prev, { role: 'assistant', content: t('请求失败: {{error}}', { error: String(err) }) }]); + const errorMessage = t('请求失败: {{error}}', { error: String(err) }); + const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: errorMessage }]; + persistCurrentConversation(persistedHistory, isChat ? 'chat' : 'agent', { + conversationId: requestConversationId, + requestConversationId: requestTargetConversationId, + defaultTitle: defaultConversationTitle + }); + if ( + pendingRequestRef.current?.mode === requestMode && + pendingRequestRef.current?.conversationId === requestTargetConversationId && + assistantModeRef.current === requestMode && + activeConvIdRef.current === requestTargetConversationId + ) { + setHistory((prev) => [...prev, { role: 'assistant' as const, content: errorMessage }].slice(-MAX_MESSAGES)); + } + if ( + pendingRequestRef.current?.mode === requestMode && + pendingRequestRef.current?.conversationId === requestTargetConversationId + ) { + pendingRequestRef.current = null; + } + } finally { + sendInFlightRef.current = false; } }; + sendPromptRef.current = sendPrompt; + const diagnoseCompile = async () => { if (!compileLog) { setStatus(t('暂无编译日志可诊断。')); @@ -3531,8 +3865,11 @@ export default function EditorPage() { } if (!activePath) return; setDiagnoseBusy(true); + const requestConversationId = activeConvIdRef.current; + const requestTargetConversationId = requestConversationId ?? createLocalId(); + const defaultConversationTitle = t('新对话'); const userMsg: Message = { role: 'user', content: t('诊断并修复编译错误') }; - const nextHistory = [...agentMessages, userMsg]; + const nextHistory = [...agentMessages, userMsg].slice(-MAX_MESSAGES); setAgentMessages(nextHistory); try { const res = await runAgent({ @@ -3552,7 +3889,15 @@ export default function EditorPage() { role: 'assistant', content: res.reply || t('已生成编译修复建议。') }; - setAgentMessages((prev) => [...prev, assistant]); + const persistedHistory = [...nextHistory, assistant]; + persistCurrentConversation(persistedHistory, 'agent', { + conversationId: requestConversationId, + requestConversationId: requestTargetConversationId, + defaultTitle: defaultConversationTitle + }); + if (assistantModeRef.current === 'agent' && activeConvIdRef.current === requestTargetConversationId) { + setAgentMessages((prev) => [...prev, assistant].slice(-MAX_MESSAGES)); + } if (res.patches && res.patches.length > 0) { const nextPending = res.patches.map((patch) => ({ filePath: patch.path, @@ -3564,7 +3909,16 @@ export default function EditorPage() { setRightView('diff'); } } catch (err) { - setAgentMessages((prev) => [...prev, { role: 'assistant', content: t('请求失败: {{error}}', { error: String(err) }) }]); + const errorMessage = t('请求失败: {{error}}', { error: String(err) }); + const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: errorMessage }]; + persistCurrentConversation(persistedHistory, 'agent', { + conversationId: requestConversationId, + requestConversationId: requestTargetConversationId, + defaultTitle: defaultConversationTitle + }); + if (assistantModeRef.current === 'agent' && activeConvIdRef.current === requestTargetConversationId) { + setAgentMessages((prev) => [...prev, { role: 'assistant' as const, content: errorMessage }].slice(-MAX_MESSAGES)); + } } finally { setDiagnoseBusy(false); } @@ -4016,19 +4370,115 @@ export default function EditorPage() {
+
+ + + +
+ {(() => { + const activeConv = conversations.find((c) => c.id === activeConversationId); + return activeConv ? ( +
{ setRenamingConvId(activeConv.id); setRenamingValue(activeConv.title); }} + > + {renamingConvId === activeConv.id && !historyOpen ? ( + setRenamingValue(e.target.value)} + onBlur={() => handleRenameConversation(activeConv.id, renamingValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.currentTarget.blur(); } + if (e.key === 'Escape') setRenamingConvId(null); + }} + /> + ) : ( + <> + {activeConv.title} + + + )} +
+ ) : null; + })()} + {historyOpen && ( +
+ {conversations.filter((c) => c.mode === assistantMode).length === 0 ? ( +
{t('无历史记录')}
+ ) : ( + conversations.filter((c) => c.mode === assistantMode).map((conv) => ( +
{ if (renamingConvId) return; handleLoadConversation(conv); }} + > +
+ {renamingConvId === conv.id ? ( + e.stopPropagation()} + onChange={(e) => setRenamingValue(e.target.value)} + onBlur={() => handleRenameConversation(conv.id, renamingValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.currentTarget.blur(); } + if (e.key === 'Escape') { e.stopPropagation(); setRenamingConvId(null); } + }} + /> + ) : ( + { e.stopPropagation(); setRenamingConvId(conv.id); setRenamingValue(conv.title); }} + >{conv.title} + )} + {relativeTime(conv.updatedAt, t)} +
+ + +
+ )) + )} +
+ )} {assistantMode === 'chat' && (
{t('只读当前文件')} @@ -4043,7 +4493,10 @@ export default function EditorPage() { {assistantMode === 'agent' && agentMessages.length === 0 && (
{t('输入任务描述,生成修改建议。')}
)} - {(assistantMode === 'chat' ? chatMessages : agentMessages).map((msg, idx) => ( + {(() => { + const msgs = assistantMode === 'chat' ? chatMessages : agentMessages; + const lastAssistantIdx = (() => { for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'assistant') return i; } return -1; })(); + return msgs.map((msg, idx) => (
{msg.role}
@@ -4053,8 +4506,25 @@ export default function EditorPage() { msg.content )}
+ {msg.role === 'assistant' && msg.content && ( +
+ + {idx === lastAssistantIdx && ( + + )} +
+ )}
- ))} + )); + })()}
@@ -4240,9 +4710,15 @@ export default function EditorPage() { className="chat-input" value={prompt} onChange={(e) => setPrompt(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendPrompt(); + } + }} placeholder={assistantMode === 'chat' ? t('例如:帮我解释这一段的实验设计。') : t('例如:润色这个段落,使其更符合 ACL 风格。')} /> - {selectionText && assistantMode === 'agent' && ( diff --git a/apps/frontend/src/i18n/locales/en-US.json b/apps/frontend/src/i18n/locales/en-US.json index 2647233..c104e72 100644 --- a/apps/frontend/src/i18n/locales/en-US.json +++ b/apps/frontend/src/i18n/locales/en-US.json @@ -497,5 +497,13 @@ "LLM API Endpoint": "LLM API Endpoint", "LLM API Key": "LLM API Key", "LLM Model": "LLM Model", - "展开": "Expand" + "展开": "Expand", + "新建对话": "New Chat", + "历史记录": "History", + "清空对话": "Clear", + "确定清空当前对话?": "Clear current conversation?", + "无历史记录": "No history", + "新对话": "New conversation", + "重命名对话": "Rename conversation", + "重新生成": "Regenerate" } diff --git a/apps/frontend/src/i18n/locales/zh-CN.json b/apps/frontend/src/i18n/locales/zh-CN.json index 8dacd0e..48ad25e 100644 --- a/apps/frontend/src/i18n/locales/zh-CN.json +++ b/apps/frontend/src/i18n/locales/zh-CN.json @@ -497,5 +497,13 @@ "LLM API Endpoint": "LLM API Endpoint", "LLM API Key": "LLM API Key", "LLM Model": "LLM Model", - "展开": "展开" + "展开": "展开", + "新建对话": "新建对话", + "历史记录": "历史记录", + "清空对话": "清空对话", + "确定清空当前对话?": "确定清空当前对话?", + "无历史记录": "无历史记录", + "新对话": "新对话", + "重命名对话": "重命名对话", + "重新生成": "重新生成" } From 8e5452bb5340ab2acc62fef7dfbfe525946c8751 Mon Sep 17 00:00:00 2001 From: Dennis-Huangm <2509562097@qq.com> Date: Sun, 8 Mar 2026 17:25:56 +0800 Subject: [PATCH 2/3] fix(chat): avoid stale state when retrying assistant messages --- apps/frontend/src/app/EditorPage.tsx | 68 ++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/app/EditorPage.tsx b/apps/frontend/src/app/EditorPage.tsx index 0ae1863..0c99dd0 100644 --- a/apps/frontend/src/app/EditorPage.tsx +++ b/apps/frontend/src/app/EditorPage.tsx @@ -70,6 +70,12 @@ interface PendingChange { diff: string; } +interface SendPromptOptions { + promptOverride?: string; + historyOverride?: Message[]; + modeOverride?: 'chat' | 'agent'; +} + type InlineEdit = | { kind: 'new-file' | 'new-folder'; parent: string; value: string } | { kind: 'rename'; path: string; value: string }; @@ -1412,7 +1418,7 @@ export default function EditorPage() { const suppressDirtyRef = useRef(false); const typewriterTimerRef = useRef(null); const activeConvIdRef = useRef(null); - const sendPromptRef = useRef<(() => void) | null>(null); + const sendPromptRef = useRef<((options?: SendPromptOptions) => void) | null>(null); const assistantModeRef = useRef<'chat' | 'agent'>(assistantMode); const conversationsRef = useRef([]); const pendingRequestRef = useRef<{ mode: 'chat' | 'agent'; conversationId: string } | null>(null); @@ -1618,22 +1624,22 @@ export default function EditorPage() { }, []); const handleRetryMessage = useCallback((idx: number) => { - const msgs = assistantMode === 'chat' ? chatMessages : agentMessages; - const setHistory = assistantMode === 'chat' ? setChatMessages : setAgentMessages; - // Find the user message that precedes this assistant message + const currentMode = assistantModeRef.current; + const msgs = currentMode === 'chat' ? chatMessages : agentMessages; + const setHistory = currentMode === 'chat' ? setChatMessages : setAgentMessages; let userMsgIdx = idx - 1; while (userMsgIdx >= 0 && msgs[userMsgIdx].role !== 'user') userMsgIdx--; if (userMsgIdx < 0) return; const userPrompt = msgs[userMsgIdx].content; - // Remove everything from userMsgIdx onward const trimmed = msgs.slice(0, userMsgIdx); setHistory(trimmed); - // Set prompt and trigger send on next tick setPrompt(userPrompt); - setTimeout(() => { - sendPromptRef.current?.(); - }, 0); - }, [assistantMode, chatMessages, agentMessages]); + sendPromptRef.current?.({ + modeOverride: currentMode, + promptOverride: userPrompt, + historyOverride: trimmed + }); + }, [agentMessages, chatMessages]); const handleClearConversation = useCallback(() => { if (!window.confirm(t('确定清空当前对话?'))) return; @@ -3709,10 +3715,11 @@ export default function EditorPage() { }; }, []); - const sendPrompt = async () => { + const sendPrompt = useCallback(async (options?: SendPromptOptions) => { if (sendInFlightRef.current) return; sendInFlightRef.current = true; - const isChat = assistantMode === 'chat'; + const currentMode = options?.modeOverride ?? assistantModeRef.current; + const isChat = currentMode === 'chat'; const requestMode: 'chat' | 'agent' = isChat ? 'chat' : 'agent'; const requestConversationId = activeConvIdRef.current; const requestTargetConversationId = requestConversationId ?? createLocalId(); @@ -3729,21 +3736,22 @@ export default function EditorPage() { return; } } - const userMsg: Message = { role: 'user', content: prompt || t('(empty)') }; + const requestPrompt = options?.promptOverride ?? prompt; + const userMsg: Message = { role: 'user', content: requestPrompt || t('(empty)') }; const setHistory = isChat ? setChatMessages : setAgentMessages; - const history = isChat ? chatMessages : agentMessages; + const history = options?.historyOverride ?? (isChat ? chatMessages : agentMessages); const nextHistory = [...history, userMsg].slice(-MAX_MESSAGES); setHistory(nextHistory); setPrompt(''); try { - let effectivePrompt = prompt; + let effectivePrompt = requestPrompt; let effectiveSelection = selectionText; let effectiveContent = editorValue; let effectiveMode = mode; let effectiveTask = task; if (!isChat && task === 'translate') { - const note = prompt ? `\n${t('User note')}: ${prompt}` : ''; + const note = requestPrompt ? `\n${t('User note')}: ${requestPrompt}` : ''; if (translateScope === 'project') { effectiveMode = 'tools'; effectiveSelection = ''; @@ -3762,8 +3770,8 @@ export default function EditorPage() { effectiveMode = 'tools'; effectiveSelection = ''; effectiveContent = ''; - effectivePrompt = prompt - ? t('Search arXiv and return 3-5 relevant papers with BibTeX entries. User query: {{query}}', { query: prompt }) + effectivePrompt = requestPrompt + ? t('Search arXiv and return 3-5 relevant papers with BibTeX entries. User query: {{query}}', { query: requestPrompt }) : t('Search arXiv and return 3-5 relevant papers with BibTeX entries.'); effectiveTask = 'websearch'; } @@ -3854,7 +3862,29 @@ export default function EditorPage() { } finally { sendInFlightRef.current = false; } - }; + }, [ + activePath, + agentMessages, + assistantMode, + buildProjectContext, + chatMessages, + compileLog, + editorValue, + files, + llmConfig, + mode, + persistCurrentConversation, + projectId, + prompt, + searchLlmConfig, + selectionRange, + selectionText, + startTypewriter, + t, + task, + translateScope, + translateTarget + ]); sendPromptRef.current = sendPrompt; From 16b034886ff3452f64ad847e40cec7ef633cdfc0 Mon Sep 17 00:00:00 2001 From: Dennis-Huangm <2509562097@qq.com> Date: Sun, 8 Mar 2026 22:00:37 +0800 Subject: [PATCH 3/3] fix(chat): guard conversation state against project switch races --- apps/frontend/src/app/EditorPage.tsx | 57 +++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/app/EditorPage.tsx b/apps/frontend/src/app/EditorPage.tsx index 0c99dd0..2c917ef 100644 --- a/apps/frontend/src/app/EditorPage.tsx +++ b/apps/frontend/src/app/EditorPage.tsx @@ -76,6 +76,11 @@ interface SendPromptOptions { modeOverride?: 'chat' | 'agent'; } +interface ProjectScopedState { + projectId: string | null; + token: number; +} + type InlineEdit = | { kind: 'new-file' | 'new-folder'; parent: string; value: string } | { kind: 'rename'; path: string; value: string }; @@ -1420,6 +1425,7 @@ export default function EditorPage() { const activeConvIdRef = useRef(null); const sendPromptRef = useRef<((options?: SendPromptOptions) => void) | null>(null); const assistantModeRef = useRef<'chat' | 'agent'>(assistantMode); + const projectStateRef = useRef({ projectId, token: 0 }); const conversationsRef = useRef([]); const pendingRequestRef = useRef<{ mode: 'chat' | 'agent'; conversationId: string } | null>(null); const sendInFlightRef = useRef(false); @@ -1462,24 +1468,36 @@ export default function EditorPage() { assistantModeRef.current = assistantMode; }, [assistantMode]); + projectStateRef.current = { + projectId, + token: projectStateRef.current.projectId === projectId ? projectStateRef.current.token : projectStateRef.current.token + 1 + }; + useEffect(() => { conversationsRef.current = conversations; }, [conversations]); /* ── Conversation history lifecycle ── */ useEffect(() => { - if (!projectId) return; + setChatMessages([]); + setAgentMessages([]); + setActiveConversationId(null); + activeConvIdRef.current = null; + pendingRequestRef.current = null; + if (!projectId) { + setConversations([]); + conversationsRef.current = []; + return; + } const loaded = loadConversations(projectId); setConversations(loaded); + conversationsRef.current = loaded; const latest = loaded.find((c) => c.mode === assistantMode); if (latest) { setActiveConversationId(latest.id); activeConvIdRef.current = latest.id; if (latest.mode === 'chat') setChatMessages(latest.messages); else setAgentMessages(latest.messages); - } else { - setActiveConversationId(null); - activeConvIdRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); @@ -1509,7 +1527,7 @@ export default function EditorPage() { ? { ...c, messages: msgs.slice(-MAX_MESSAGES), - title: generateConversationTitle(msgs, defaultTitle), + title: c.title, updatedAt: now } : c @@ -3736,6 +3754,7 @@ export default function EditorPage() { return; } } + const requestProjectState = projectStateRef.current; const requestPrompt = options?.promptOverride ?? prompt; const userMsg: Message = { role: 'user', content: requestPrompt || t('(empty)') }; const setHistory = isChat ? setChatMessages : setAgentMessages; @@ -3799,6 +3818,12 @@ export default function EditorPage() { }); const replyText = res.reply || t('已生成建议。'); const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: replyText }]; + if ( + projectStateRef.current.projectId !== requestProjectState.projectId || + projectStateRef.current.token !== requestProjectState.token + ) { + return; + } persistCurrentConversation(persistedHistory, isChat ? 'chat' : 'agent', { conversationId: requestConversationId, requestConversationId: requestTargetConversationId, @@ -3840,6 +3865,12 @@ export default function EditorPage() { } catch (err) { const errorMessage = t('请求失败: {{error}}', { error: String(err) }); const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: errorMessage }]; + if ( + projectStateRef.current.projectId !== requestProjectState.projectId || + projectStateRef.current.token !== requestProjectState.token + ) { + return; + } persistCurrentConversation(persistedHistory, isChat ? 'chat' : 'agent', { conversationId: requestConversationId, requestConversationId: requestTargetConversationId, @@ -3895,6 +3926,7 @@ export default function EditorPage() { } if (!activePath) return; setDiagnoseBusy(true); + const requestProjectState = projectStateRef.current; const requestConversationId = activeConvIdRef.current; const requestTargetConversationId = requestConversationId ?? createLocalId(); const defaultConversationTitle = t('新对话'); @@ -3920,6 +3952,12 @@ export default function EditorPage() { content: res.reply || t('已生成编译修复建议。') }; const persistedHistory = [...nextHistory, assistant]; + if ( + projectStateRef.current.projectId !== requestProjectState.projectId || + projectStateRef.current.token !== requestProjectState.token + ) { + return; + } persistCurrentConversation(persistedHistory, 'agent', { conversationId: requestConversationId, requestConversationId: requestTargetConversationId, @@ -3941,6 +3979,12 @@ export default function EditorPage() { } catch (err) { const errorMessage = t('请求失败: {{error}}', { error: String(err) }); const persistedHistory = [...nextHistory, { role: 'assistant' as const, content: errorMessage }]; + if ( + projectStateRef.current.projectId !== requestProjectState.projectId || + projectStateRef.current.token !== requestProjectState.token + ) { + return; + } persistCurrentConversation(persistedHistory, 'agent', { conversationId: requestConversationId, requestConversationId: requestTargetConversationId, @@ -4445,6 +4489,7 @@ export default function EditorPage() { onChange={(e) => setRenamingValue(e.target.value)} onBlur={() => handleRenameConversation(activeConv.id, renamingValue)} onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return; if (e.key === 'Enter') { e.currentTarget.blur(); } if (e.key === 'Escape') setRenamingConvId(null); }} @@ -4483,6 +4528,7 @@ export default function EditorPage() { onChange={(e) => setRenamingValue(e.target.value)} onBlur={() => handleRenameConversation(conv.id, renamingValue)} onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return; if (e.key === 'Enter') { e.currentTarget.blur(); } if (e.key === 'Escape') { e.stopPropagation(); setRenamingConvId(null); } }} @@ -4741,6 +4787,7 @@ export default function EditorPage() { value={prompt} onChange={(e) => setPrompt(e.target.value)} onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendPrompt();