feat(draft): 输入草稿后端持久化并按作用域恢复
This commit is contained in:
parent
91739840ee
commit
aa3b9bbfe3
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user