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 }; }, []);