diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 165e242..d05d3a4 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -71,6 +71,7 @@ export async function mounted() { document.addEventListener('click', this.handleCopyCodeClick); window.addEventListener('popstate', this.handlePopState); window.addEventListener('keydown', this.handleMobileOverlayEscape); + window.addEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); this.setupMobileViewportWatcher(); this.subAgentFetch(); @@ -112,6 +113,7 @@ export function beforeUnmount() { document.removeEventListener('click', this.handleCopyCodeClick); window.removeEventListener('popstate', this.handlePopState); window.removeEventListener('keydown', this.handleMobileOverlayEscape); + window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); this.teardownMobileViewportWatcher(); this.subAgentStopPolling(); this.backgroundCommandStopPolling(); diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index cfa85a9..22da0e0 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -309,6 +309,12 @@ export const conversationMethods = { console.error('[切换对话] 检查/停止任务失败:', error); } + await this.persistComposerDraftNow({ + reason: `switch-conversation:${conversationId}`, + force: true, + keepalive: true + }).catch(() => {}); + try { // 1. 调用加载API const response = await fetch(`/api/conversations/${conversationId}/load`, { @@ -440,6 +446,12 @@ export const conversationMethods = { }); this.logMessageState('createNewConversation:start'); + await this.persistComposerDraftNow({ + reason: 'create-new-conversation', + force: true, + keepalive: true + }).catch(() => {}); + // 应用个性化设置中的默认模型和思考模式 try { const personalizationStore = usePersonalizationStore(); diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 7037ecd..b0e861d 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -315,6 +315,11 @@ export const messageMethods = { this.inputSetVideoPickerOpen(false); this.inputSetLineCount(1); this.inputSetMultiline(false); + this.persistComposerDraftNow({ + reason: 'send-message-cleared', + force: true, + keepalive: true + }).catch(() => {}); if (hasImages) { this.conversationHasImages = true; this.conversationHasVideos = false; diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 1dba2f6..ba6fc23 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -317,11 +317,167 @@ export const uiMethods = { this.uiSetMobileOverlayMenuOpen(false); }, + normalizeComposerDraftContent(rawValue) { + let text = ''; + if (typeof rawValue === 'string') { + text = rawValue; + } else if (rawValue === null || rawValue === undefined) { + text = ''; + } else { + text = String(rawValue); + } + if (text.length > 40000) { + text = text.slice(0, 40000); + } + return text; + }, + + scheduleComposerDraftPersist(reason = 'input') { + const current = this.normalizeComposerDraftContent(this.inputMessage); + const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); + const dirty = current !== lastSynced; + this.composerDraftDirty = dirty; + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + if (!dirty) { + return; + } + this.composerDraftSaveTimer = window.setTimeout(() => { + this.persistComposerDraftNow({ reason: `debounce:${reason}` }).catch(() => {}); + }, 1000); + }, + + async persistComposerDraftNow(options = {}) { + const reason = String(options?.reason || 'manual'); + const force = !!options?.force; + const useBeacon = !!options?.useBeacon; + const content = this.normalizeComposerDraftContent( + Object.prototype.hasOwnProperty.call(options || {}, 'content') + ? options.content + : this.inputMessage + ); + const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); + if (!force && content === lastSynced) { + this.composerDraftDirty = false; + return { success: true, skipped: true }; + } + + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + + const payloadText = JSON.stringify({ content }); + if (useBeacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + try { + const blob = new Blob([payloadText], { type: 'application/json' }); + navigator.sendBeacon('/api/input-draft', blob); + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + return { success: true, queued: true, reason }; + } catch (error) { + // sendBeacon 失败时回落到 fetch + } + } + + const response = await fetch('/api/input-draft', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: payloadText, + keepalive: !!options?.keepalive + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + throw new Error(data?.error || '保存输入草稿失败'); + } + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + return { success: true, saved: true, reason }; + }, + + async restoreComposerDraftState(reason = 'manual') { + const fetchSeq = Number(this.composerDraftFetchSeq || 0) + 1; + this.composerDraftFetchSeq = fetchSeq; + try { + const response = await fetch('/api/input-draft', { + method: 'GET', + credentials: 'same-origin' + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '获取输入草稿失败'); + } + if (Number(this.composerDraftFetchSeq || 0) !== fetchSeq) { + return; + } + const content = this.normalizeComposerDraftContent(payload?.data?.content || ''); + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + this.inputSetMessage(content); + this.$nextTick(() => { + if (typeof this.autoResizeInput === 'function') { + this.autoResizeInput(); + } + }); + debugLog('[UI] 输入草稿已恢复', { reason, length: content.length }); + } catch (error) { + console.warn('[UI] 恢复输入草稿失败:', error); + } + }, + + handleBeforeUnloadDraftPersist() { + this.persistComposerDraftNow({ + reason: 'beforeunload', + force: true, + useBeacon: true + }).catch(() => {}); + }, + + clearComposerDraftState(reason = 'manual') { + debugLog('[UI] 清理输入草稿状态', { reason }); + this.inputClearMessage(); + this.composerDraftLastSyncedContent = ''; + this.composerDraftDirty = false; + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.inputSetImagePickerOpen(false); + this.inputSetVideoPickerOpen(false); + this.imageEntries = []; + this.videoEntries = []; + this.imageLoading = false; + this.videoLoading = false; + this.mediaUploading = false; + this.$nextTick(() => { + if (typeof this.autoResizeInput === 'function') { + this.autoResizeInput(); + } + }); + }, + refreshCurrentPage() { if (typeof window === 'undefined') { return; } - window.location.reload(); + this.persistComposerDraftNow({ + reason: 'refresh-page', + force: true, + keepalive: true + }) + .catch(() => {}) + .finally(() => { + window.location.reload(); + }); }, handleMobileRefreshClick() { @@ -588,6 +744,14 @@ export const uiMethods = { this.hostWorkspaceSwitching = true; try { + // 先在“当前工作区作用域”落盘草稿,再切换工作区。 + // 否则会把旧工作区草稿误写到目标工作区作用域里。 + await this.persistComposerDraftNow({ + reason: 'switch-host-workspace:before-select', + force: true, + keepalive: true + }).catch(() => {}); + const query = encodeURIComponent(targetId); const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`); const payload = await resp.json().catch(() => ({})); @@ -1107,6 +1271,7 @@ export const uiMethods = { handleInputChange() { this.autoResizeInput(); + this.scheduleComposerDraftPersist('input-change'); }, handleInputFocus() { @@ -1836,6 +2001,7 @@ export const uiMethods = { this.startTitleTyping('新对话', { animate: false }); this.initialRouteResolved = true; this.refreshBlankHeroState(); + await this.restoreComposerDraftState('bootstrap-route:new'); return; } @@ -1886,6 +2052,7 @@ export const uiMethods = { } finally { this.initialRouteResolved = true; } + await this.restoreComposerDraftState('bootstrap-route:conversation'); }, handlePopState(event) { @@ -1899,6 +2066,7 @@ export const uiMethods = { this.logMessageState('handlePopState:after-clear-no-conversation'); this.resetAllStates('handlePopState:no-conversation'); this.resetTokenStatistics(); + this.restoreComposerDraftState('popstate:new').catch(() => {}); return; } this.loadConversation(convId); diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index aa6e60b..0f02f44 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -4,6 +4,9 @@ import { debugLog, traceLog } from './methods/common'; export const watchers = { inputMessage() { this.autoResizeInput(); + if (typeof this.scheduleComposerDraftPersist === 'function') { + this.scheduleComposerDraftPersist('watch-input-message'); + } }, messages: { deep: true, @@ -47,8 +50,15 @@ export const watchers = { newValue, skipConversationHistoryReload: this.skipConversationHistoryReload }); + if (oldValue !== newValue && typeof this.restoreComposerDraftState === 'function') { + this.restoreComposerDraftState( + `watch-conversation-id:${oldValue || 'none'}->${newValue || 'none'}` + ); + } if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) { this.versioningEnabled = !!this.newConversationVersioningEnabled; + this.versioningTrackingMode = + this.newConversationVersioningTrackingMode || 'workspace_and_conversation'; this.versioningMismatch = false; return; }