From 53e2fb6dc6ba7da24de1f8b4ddd7ddce0677acee Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Sat, 15 Aug 2026 15:35:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20per-topic=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E8=AE=B0=E5=BF=86=EF=BC=88=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=9A=82=E5=AD=98/=E6=81=A2=E5=A4=8D=20+=20localStorage=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=20+=20=E5=88=A0=E9=99=A4=E6=B8=85?= =?UTF-8?q?=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文本草稿 debounce 持久化到 localStorage(key 按 topicId),附件仅存内存 - 切换话题时暂存当前草稿并恢复目标话题草稿,组件卸载时补齐落盘 - 删除话题时同步清除其持久化草稿,避免孤儿 key --- web/src/components/Chat/MessageInput.tsx | 108 ++++++++++++++++++++++- web/src/hooks/chat/useTopics.ts | 3 + 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/web/src/components/Chat/MessageInput.tsx b/web/src/components/Chat/MessageInput.tsx index 9352a16..96f8286 100644 --- a/web/src/components/Chat/MessageInput.tsx +++ b/web/src/components/Chat/MessageInput.tsx @@ -15,6 +15,40 @@ import type { Attachment } from '../../types/protocol'; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +/** per-topic 文本草稿的 localStorage key(附件 File 对象不可序列化,仅存内存) */ +export function draftStorageKey(topicId: string): string { + return `picobot:draft:${topicId}`; +} + +function loadDraftText(topicId: string): string { + try { + return localStorage.getItem(draftStorageKey(topicId)) ?? ''; + } catch { + return ''; + } +} + +function saveDraftText(topicId: string, text: string): void { + try { + if (text.trim()) { + localStorage.setItem(draftStorageKey(topicId), text); + } else { + localStorage.removeItem(draftStorageKey(topicId)); + } + } catch { + // 隐私模式/配额满:静默降级为仅内存草稿 + } +} + +/** 删除话题时调用:清除其持久化草稿 */ +export function clearTopicDraft(topicId: string): void { + try { + localStorage.removeItem(draftStorageKey(topicId)); + } catch { + // ignore + } +} + interface MessageInputProps { onSend: (content: string, attachments: Attachment[]) => void; onStop?: () => void; @@ -24,7 +58,7 @@ interface MessageInputProps { isReadOnly?: boolean; channelName?: string; selectedExpert?: { name: string; description: string } | null; - /** 当前话题 ID,切换话题时自动清空草稿 */ + /** 当前话题 ID:切换话题时保留各自草稿(文本持久化到 localStorage,附件仅内存) */ topicId?: string | null; } @@ -65,16 +99,77 @@ export function MessageInput({ const fileInputRef = useRef(null); const wasLoadingRef = useRef(false); const prevTopicIdRef = useRef(topicId); + // per-topic 草稿缓存:切换话题时暂存/恢复(附件 File 对象仅内存,刷新丢失) + const draftsRef = useRef>( + new Map(), + ); + const draftDebounceRef = useRef(null); - // 切换话题时清空草稿(替代原来通过 key remount 的重置机制) + // 切换话题时:暂存当前草稿 → 恢复目标话题草稿(内存 miss 时读 localStorage) useEffect(() => { - if (prevTopicIdRef.current !== topicId) { - prevTopicIdRef.current = topicId; + if (prevTopicIdRef.current === topicId) return; + const prevTopicId = prevTopicIdRef.current; + prevTopicIdRef.current = topicId; + + // 离开话题:立即持久化(补齐 debounce 未落盘部分)+ 暂存到内存(含附件) + if (prevTopicId != null) { + saveDraftText(prevTopicId, content); + draftsRef.current.set(prevTopicId, { content, attachments }); + } + + // 恢复目标话题草稿 + if (topicId == null) { setContent(''); setAttachments([]); + return; } + const cached = draftsRef.current.get(topicId); + if (cached) { + setContent(cached.content); + setAttachments(cached.attachments); + } else { + setContent(loadDraftText(topicId)); + setAttachments([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [topicId]); + // 文本草稿 debounce 持久化(300ms) + useEffect(() => { + if (topicId == null) return; + if (draftDebounceRef.current != null) { + window.clearTimeout(draftDebounceRef.current); + } + draftDebounceRef.current = window.setTimeout(() => { + saveDraftText(topicId, content); + }, 300); + return () => { + if (draftDebounceRef.current != null) { + window.clearTimeout(draftDebounceRef.current); + } + }; + }, [content, topicId]); + + // 卸载时补齐持久化(debounce 可能未落盘)+ 暂存内存草稿(含附件,供同 SPA 会话恢复) + const latestContentRef = useRef(content); + const latestAttachmentsRef = useRef(attachments); + latestContentRef.current = content; + latestAttachmentsRef.current = attachments; + useEffect(() => { + const drafts = draftsRef.current; + return () => { + const tid = prevTopicIdRef.current; + if (tid != null) { + saveDraftText(tid, latestContentRef.current); + drafts.set(tid, { + content: latestContentRef.current, + attachments: latestAttachmentsRef.current, + }); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { const textarea = textareaRef.current; if (textarea) { @@ -250,6 +345,11 @@ export function MessageInput({ content.trim(), attachments.map((a) => a.attachment), ); + // 发送成功:清除该话题的草稿(内存 + localStorage) + if (topicId != null) { + draftsRef.current.delete(topicId); + saveDraftText(topicId, ''); + } setContent(''); setAttachments([]); setError(null); diff --git a/web/src/hooks/chat/useTopics.ts b/web/src/hooks/chat/useTopics.ts index 098769a..7d93ae2 100644 --- a/web/src/hooks/chat/useTopics.ts +++ b/web/src/hooks/chat/useTopics.ts @@ -8,6 +8,7 @@ import { type MutableRefObject, } from 'react'; import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol'; +import { clearTopicDraft } from '../../components/Chat/MessageInput'; export interface UseTopicsReturn { topics: Topic[]; @@ -101,6 +102,8 @@ export function useTopics(): UseTopicsReturn { }, []); const deleteTopic = useCallback((topicId: string): Command => { + // 同步清除该话题的持久化草稿(删除后端话题成功与否均无碍:残留草稿 key 无话题可挂载) + clearTopicDraft(topicId); return { type: 'delete_topic', topic_id: topicId }; }, []);