feat(draft): 输入草稿后端持久化并按作用域恢复

This commit is contained in:
JOJO 2026-05-06 19:19:27 +08:00
parent 91739840ee
commit aa3b9bbfe3
5 changed files with 198 additions and 1 deletions

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -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);

View File

@ -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;
}