feat(draft): 输入草稿后端持久化并按作用域恢复
This commit is contained in:
parent
91739840ee
commit
aa3b9bbfe3
@ -71,6 +71,7 @@ export async function mounted() {
|
|||||||
document.addEventListener('click', this.handleCopyCodeClick);
|
document.addEventListener('click', this.handleCopyCodeClick);
|
||||||
window.addEventListener('popstate', this.handlePopState);
|
window.addEventListener('popstate', this.handlePopState);
|
||||||
window.addEventListener('keydown', this.handleMobileOverlayEscape);
|
window.addEventListener('keydown', this.handleMobileOverlayEscape);
|
||||||
|
window.addEventListener('beforeunload', this.handleBeforeUnloadDraftPersist);
|
||||||
this.setupMobileViewportWatcher();
|
this.setupMobileViewportWatcher();
|
||||||
|
|
||||||
this.subAgentFetch();
|
this.subAgentFetch();
|
||||||
@ -112,6 +113,7 @@ export function beforeUnmount() {
|
|||||||
document.removeEventListener('click', this.handleCopyCodeClick);
|
document.removeEventListener('click', this.handleCopyCodeClick);
|
||||||
window.removeEventListener('popstate', this.handlePopState);
|
window.removeEventListener('popstate', this.handlePopState);
|
||||||
window.removeEventListener('keydown', this.handleMobileOverlayEscape);
|
window.removeEventListener('keydown', this.handleMobileOverlayEscape);
|
||||||
|
window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist);
|
||||||
this.teardownMobileViewportWatcher();
|
this.teardownMobileViewportWatcher();
|
||||||
this.subAgentStopPolling();
|
this.subAgentStopPolling();
|
||||||
this.backgroundCommandStopPolling();
|
this.backgroundCommandStopPolling();
|
||||||
|
|||||||
@ -309,6 +309,12 @@ export const conversationMethods = {
|
|||||||
console.error('[切换对话] 检查/停止任务失败:', error);
|
console.error('[切换对话] 检查/停止任务失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.persistComposerDraftNow({
|
||||||
|
reason: `switch-conversation:${conversationId}`,
|
||||||
|
force: true,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 调用加载API
|
// 1. 调用加载API
|
||||||
const response = await fetch(`/api/conversations/${conversationId}/load`, {
|
const response = await fetch(`/api/conversations/${conversationId}/load`, {
|
||||||
@ -440,6 +446,12 @@ export const conversationMethods = {
|
|||||||
});
|
});
|
||||||
this.logMessageState('createNewConversation:start');
|
this.logMessageState('createNewConversation:start');
|
||||||
|
|
||||||
|
await this.persistComposerDraftNow({
|
||||||
|
reason: 'create-new-conversation',
|
||||||
|
force: true,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// 应用个性化设置中的默认模型和思考模式
|
// 应用个性化设置中的默认模型和思考模式
|
||||||
try {
|
try {
|
||||||
const personalizationStore = usePersonalizationStore();
|
const personalizationStore = usePersonalizationStore();
|
||||||
|
|||||||
@ -315,6 +315,11 @@ export const messageMethods = {
|
|||||||
this.inputSetVideoPickerOpen(false);
|
this.inputSetVideoPickerOpen(false);
|
||||||
this.inputSetLineCount(1);
|
this.inputSetLineCount(1);
|
||||||
this.inputSetMultiline(false);
|
this.inputSetMultiline(false);
|
||||||
|
this.persistComposerDraftNow({
|
||||||
|
reason: 'send-message-cleared',
|
||||||
|
force: true,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {});
|
||||||
if (hasImages) {
|
if (hasImages) {
|
||||||
this.conversationHasImages = true;
|
this.conversationHasImages = true;
|
||||||
this.conversationHasVideos = false;
|
this.conversationHasVideos = false;
|
||||||
|
|||||||
@ -317,11 +317,167 @@ export const uiMethods = {
|
|||||||
this.uiSetMobileOverlayMenuOpen(false);
|
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() {
|
refreshCurrentPage() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.reload();
|
this.persistComposerDraftNow({
|
||||||
|
reason: 'refresh-page',
|
||||||
|
force: true,
|
||||||
|
keepalive: true
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
handleMobileRefreshClick() {
|
handleMobileRefreshClick() {
|
||||||
@ -588,6 +744,14 @@ export const uiMethods = {
|
|||||||
|
|
||||||
this.hostWorkspaceSwitching = true;
|
this.hostWorkspaceSwitching = true;
|
||||||
try {
|
try {
|
||||||
|
// 先在“当前工作区作用域”落盘草稿,再切换工作区。
|
||||||
|
// 否则会把旧工作区草稿误写到目标工作区作用域里。
|
||||||
|
await this.persistComposerDraftNow({
|
||||||
|
reason: 'switch-host-workspace:before-select',
|
||||||
|
force: true,
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
const query = encodeURIComponent(targetId);
|
const query = encodeURIComponent(targetId);
|
||||||
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
|
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
|
||||||
const payload = await resp.json().catch(() => ({}));
|
const payload = await resp.json().catch(() => ({}));
|
||||||
@ -1107,6 +1271,7 @@ export const uiMethods = {
|
|||||||
|
|
||||||
handleInputChange() {
|
handleInputChange() {
|
||||||
this.autoResizeInput();
|
this.autoResizeInput();
|
||||||
|
this.scheduleComposerDraftPersist('input-change');
|
||||||
},
|
},
|
||||||
|
|
||||||
handleInputFocus() {
|
handleInputFocus() {
|
||||||
@ -1836,6 +2001,7 @@ export const uiMethods = {
|
|||||||
this.startTitleTyping('新对话', { animate: false });
|
this.startTitleTyping('新对话', { animate: false });
|
||||||
this.initialRouteResolved = true;
|
this.initialRouteResolved = true;
|
||||||
this.refreshBlankHeroState();
|
this.refreshBlankHeroState();
|
||||||
|
await this.restoreComposerDraftState('bootstrap-route:new');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1886,6 +2052,7 @@ export const uiMethods = {
|
|||||||
} finally {
|
} finally {
|
||||||
this.initialRouteResolved = true;
|
this.initialRouteResolved = true;
|
||||||
}
|
}
|
||||||
|
await this.restoreComposerDraftState('bootstrap-route:conversation');
|
||||||
},
|
},
|
||||||
|
|
||||||
handlePopState(event) {
|
handlePopState(event) {
|
||||||
@ -1899,6 +2066,7 @@ export const uiMethods = {
|
|||||||
this.logMessageState('handlePopState:after-clear-no-conversation');
|
this.logMessageState('handlePopState:after-clear-no-conversation');
|
||||||
this.resetAllStates('handlePopState:no-conversation');
|
this.resetAllStates('handlePopState:no-conversation');
|
||||||
this.resetTokenStatistics();
|
this.resetTokenStatistics();
|
||||||
|
this.restoreComposerDraftState('popstate:new').catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadConversation(convId);
|
this.loadConversation(convId);
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { debugLog, traceLog } from './methods/common';
|
|||||||
export const watchers = {
|
export const watchers = {
|
||||||
inputMessage() {
|
inputMessage() {
|
||||||
this.autoResizeInput();
|
this.autoResizeInput();
|
||||||
|
if (typeof this.scheduleComposerDraftPersist === 'function') {
|
||||||
|
this.scheduleComposerDraftPersist('watch-input-message');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
deep: true,
|
deep: true,
|
||||||
@ -47,8 +50,15 @@ export const watchers = {
|
|||||||
newValue,
|
newValue,
|
||||||
skipConversationHistoryReload: this.skipConversationHistoryReload
|
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_')) {
|
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
|
||||||
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
||||||
|
this.versioningTrackingMode =
|
||||||
|
this.newConversationVersioningTrackingMode || 'workspace_and_conversation';
|
||||||
this.versioningMismatch = false;
|
this.versioningMismatch = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user