- 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列 - 使用 xterm.js + Grid 布局,支持三主题配色适配 - 上下拖拽分割条,动态调整终端/Git 面板比例 - 输入栏上方新增终端数量按钮,点击快捷打开 - 个人空间新增「自动打开终端面板」开关(外观与显示分类) - REST API /api/terminals 获取终端列表,多时机自动刷新 - 有终端自动打开面板,无终端自动关闭
781 lines
28 KiB
TypeScript
781 lines
28 KiB
TypeScript
// @ts-nocheck
|
||
import { debugLog, traceLog } from './common';
|
||
import { usePersonalizationStore } from '../../stores/personalization';
|
||
|
||
export const conversationMethods = {
|
||
// 完整重置所有状态
|
||
resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) {
|
||
console.log('[DEBUG_AWAITING] ===== resetAllStates =====', { reason });
|
||
|
||
// 如果正在等待子智能体完成,不重置任务状态
|
||
if (this.waitingForSubAgent) {
|
||
debugLog('跳过状态重置:正在等待子智能体完成', { reason });
|
||
return;
|
||
}
|
||
|
||
debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId });
|
||
this.logMessageState('resetAllStates:before-cleanup', { reason });
|
||
this.fileHideContextMenu();
|
||
this.monitorResetVisual({
|
||
preserveBubble: true,
|
||
preservePointer: true,
|
||
preserveWindows: !!options?.preserveMonitorWindows,
|
||
preserveQueue: !!options?.preserveMonitorWindows
|
||
});
|
||
|
||
// 重置消息和流状态
|
||
this.streamingMessage = false;
|
||
this.currentMessageIndex = -1;
|
||
this.stopRequested = false;
|
||
this.taskInProgress = false;
|
||
this.dropToolEvents = false;
|
||
|
||
// 清理工具状态
|
||
this.toolResetTracking();
|
||
|
||
// 新增:将所有未完成的工具标记为已完成,并清理awaitingFirstContent状态
|
||
const assistantMsgsBefore = this.messages
|
||
.filter((m) => m.role === 'assistant')
|
||
.map((m) => ({
|
||
awaitingFirstContent: m.awaitingFirstContent,
|
||
generatingLabel: m.generatingLabel
|
||
}));
|
||
|
||
this.messages.forEach((msg) => {
|
||
if (msg.role === 'assistant') {
|
||
// 清理等待动画状态
|
||
if (msg.awaitingFirstContent) {
|
||
msg.awaitingFirstContent = false;
|
||
}
|
||
if (msg.generatingLabel) {
|
||
msg.generatingLabel = '';
|
||
}
|
||
|
||
// 清理工具状态
|
||
if (msg.actions) {
|
||
msg.actions.forEach((action) => {
|
||
if (
|
||
action.type === 'tool' &&
|
||
(action.tool.status === 'preparing' || action.tool.status === 'running')
|
||
) {
|
||
action.tool.status = 'completed';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
const assistantMsgsAfter = this.messages
|
||
.filter((m) => m.role === 'assistant')
|
||
.map((m) => ({
|
||
awaitingFirstContent: m.awaitingFirstContent,
|
||
generatingLabel: m.generatingLabel
|
||
}));
|
||
|
||
console.log('[DEBUG_AWAITING] resetAllStates 清理完成', {
|
||
before: assistantMsgsBefore,
|
||
after: assistantMsgsAfter
|
||
});
|
||
|
||
// 清理Markdown缓存
|
||
if (this.markdownCache) {
|
||
this.markdownCache.clear();
|
||
}
|
||
this.chatClearThinkingLocks();
|
||
|
||
// 强制更新视图
|
||
this.$forceUpdate();
|
||
|
||
this.inputSetSettingsOpen(false);
|
||
this.inputSetToolMenuOpen(false);
|
||
this.inputSetQuickMenuOpen(false);
|
||
this.modeMenuOpen = false;
|
||
this.permissionMenuOpen = false;
|
||
this.inputSetLineCount(1);
|
||
this.inputSetMultiline(false);
|
||
this.inputClearMessage();
|
||
this.runtimeQueuedMessages = [];
|
||
this.runtimeGuidanceFallbackQueue = [];
|
||
this.runtimeQueueAutoSendInProgress = false;
|
||
this.runtimeQueueSyncLockKey = '';
|
||
this.runtimeQueueSyncLockUntil = 0;
|
||
if (typeof this.clearRuntimeQueueSuppressionState === 'function') {
|
||
this.clearRuntimeQueueSuppressionState();
|
||
} else {
|
||
this.runtimeQueueSuppressedMessageIds = new Set();
|
||
this.runtimeGuidanceSuppressedTextCounts = {};
|
||
}
|
||
this.composerReservedHeight = 80;
|
||
this.inputClearSelectedImages();
|
||
this.inputSetImagePickerOpen(false);
|
||
this.imageEntries = [];
|
||
this.imageLoading = false;
|
||
this.conversationHasImages = false;
|
||
this.toolSetSettingsLoading(false);
|
||
this.toolSetSettings([]);
|
||
this.pendingToolApprovals = [];
|
||
this.decidingApprovalIds = [];
|
||
this.autoApprovalTitle = '自动审批记录';
|
||
// 切换对话/工作区/新建视图时,清理上一轮目标模式的本地完成提示。
|
||
// 如果目标任务仍在运行,后续 restoreTaskState 会重新恢复运行态。
|
||
this.goalModeArmed = false;
|
||
this.goalRunning = false;
|
||
this.goalProgress = null;
|
||
this.goalDialogOpen = false;
|
||
|
||
debugLog('前端状态重置完成');
|
||
this._scrollListenerReady = false;
|
||
this._manualScrollSuppressUntil = 0;
|
||
this._escapedByUserScroll = false;
|
||
this._autoRelockCooldownUntil = 0;
|
||
this.$nextTick(() => {
|
||
this.ensureScrollListener();
|
||
const composerRef = typeof this.getInputComposerRef === 'function' ? this.getInputComposerRef() : null;
|
||
if (composerRef && typeof composerRef.emitComposerHeight === 'function') {
|
||
composerRef.emitComposerHeight();
|
||
}
|
||
});
|
||
|
||
// 重置已加载对话标记,便于后续重新加载新对话历史
|
||
this.lastHistoryLoadedConversationId = null;
|
||
|
||
this.logMessageState('resetAllStates:after-cleanup', { reason });
|
||
},
|
||
|
||
scheduleResetAfterTask(
|
||
reason = 'unspecified',
|
||
options: { preserveMonitorWindows?: boolean } = {}
|
||
) {
|
||
const start = Date.now();
|
||
const maxWait = 4000;
|
||
const interval = 200;
|
||
const tryReset = () => {
|
||
if (!this.monitorIsLocked || Date.now() - start >= maxWait) {
|
||
this.resetAllStates(reason, options);
|
||
return;
|
||
}
|
||
setTimeout(tryReset, interval);
|
||
};
|
||
tryReset();
|
||
},
|
||
|
||
resetTokenStatistics() {
|
||
this.resourceResetTokenStatistics();
|
||
},
|
||
|
||
// ==========================================
|
||
// 对话管理核心功能
|
||
// ==========================================
|
||
|
||
async loadConversationsList() {
|
||
const queryOffset = this.conversationsOffset;
|
||
const queryLimit = this.conversationsLimit;
|
||
const refreshToken =
|
||
queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken;
|
||
const requestSeq = ++this.conversationListRequestSeq;
|
||
this.conversationsLoading = true;
|
||
try {
|
||
const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}`);
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (refreshToken !== this.conversationListRefreshToken) {
|
||
debugLog('忽略已过期的对话列表响应', {
|
||
requestSeq,
|
||
responseOffset: queryOffset
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (queryOffset === 0) {
|
||
this.conversations = data.data.conversations;
|
||
} else {
|
||
this.conversations.push(...data.data.conversations);
|
||
}
|
||
if (this.currentConversationId) {
|
||
this.promoteConversationToTop(this.currentConversationId);
|
||
}
|
||
this.hasMoreConversations = data.data.has_more;
|
||
debugLog(`已加载 ${this.conversations.length} 个对话`);
|
||
|
||
if (
|
||
this.conversationsOffset === 0 &&
|
||
!this.currentConversationId &&
|
||
this.conversations.length > 0 &&
|
||
!this.isExplicitNewConversationRoute()
|
||
) {
|
||
// 只有在初始化完成后,才自动加载第一个对话
|
||
// 避免与 bootstrapRoute 冲突
|
||
if (this.initialRouteResolved) {
|
||
const latestConversation = this.conversations[0];
|
||
if (latestConversation && latestConversation.id) {
|
||
await this.loadConversation(latestConversation.id);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
console.error('加载对话列表失败:', data.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载对话列表异常:', error);
|
||
} finally {
|
||
if (refreshToken === this.conversationListRefreshToken) {
|
||
this.conversationsLoading = false;
|
||
}
|
||
}
|
||
},
|
||
|
||
async loadMoreConversations() {
|
||
if (this.loadingMoreConversations || !this.hasMoreConversations) return;
|
||
|
||
this.loadingMoreConversations = true;
|
||
this.conversationsOffset += this.conversationsLimit;
|
||
await this.loadConversationsList();
|
||
this.loadingMoreConversations = false;
|
||
},
|
||
|
||
async loadConversation(conversationId, options = {}) {
|
||
const force = Boolean(options.force);
|
||
const preserveListPosition = Boolean(options.preserveListPosition);
|
||
debugLog('加载对话:', conversationId);
|
||
traceLog('loadConversation:start', {
|
||
conversationId,
|
||
currentConversationId: this.currentConversationId,
|
||
force
|
||
});
|
||
this.logMessageState('loadConversation:start', { conversationId, force });
|
||
this.suppressTitleTyping = true;
|
||
this.titleReady = false;
|
||
this.currentConversationTitle = '';
|
||
this.titleTypingText = '';
|
||
|
||
if (!force && conversationId === this.currentConversationId) {
|
||
debugLog('已是当前对话,跳过加载');
|
||
traceLog('loadConversation:skip-same', { conversationId });
|
||
this.suppressTitleTyping = false;
|
||
this.titleReady = true;
|
||
return;
|
||
}
|
||
|
||
if ((this.compressionInProgress || this.compressing) && !force) {
|
||
const confirmed = await this.confirmAction({
|
||
title: '压缩进行中',
|
||
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
|
||
confirmText: '确认',
|
||
cancelText: '取消'
|
||
});
|
||
if (!confirmed) {
|
||
this.suppressTitleTyping = false;
|
||
this.titleReady = true;
|
||
return;
|
||
}
|
||
try {
|
||
if (this.currentConversationId) {
|
||
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
|
||
method: 'POST'
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('取消压缩失败:', e);
|
||
}
|
||
this.compressionInProgress = false;
|
||
this.compressing = false;
|
||
this.compressionMode = '';
|
||
this.compressionStage = '';
|
||
if (this.compressionToastId) {
|
||
this.uiDismissToast(this.compressionToastId);
|
||
this.compressionToastId = null;
|
||
}
|
||
}
|
||
|
||
// 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。
|
||
|
||
// 多工作区并行后,切换对话只切换视图,不再取消后台任务;停止当前轮询避免事件写入新对话界面。
|
||
try {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
if (taskStore.hasActiveTask || this.taskInProgress) {
|
||
taskStore.clearTask();
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
}
|
||
this.clearLocalTaskUiState?.(`switch-conversation:${conversationId}`);
|
||
} catch (error) {
|
||
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`, {
|
||
method: 'PUT'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
debugLog('对话加载API成功:', result);
|
||
traceLog('loadConversation:api-success', { conversationId, title: result.title });
|
||
if (typeof result.run_mode === 'string') {
|
||
this.runMode = result.run_mode;
|
||
this.thinkingMode =
|
||
typeof result.thinking_mode === 'boolean'
|
||
? result.thinking_mode
|
||
: result.run_mode !== 'fast';
|
||
} else if (typeof result.thinking_mode === 'boolean') {
|
||
this.thinkingMode = result.thinking_mode;
|
||
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
|
||
}
|
||
if (typeof result.model_key === 'string' && result.model_key) {
|
||
this.modelSet(result.model_key);
|
||
}
|
||
|
||
// 2. 更新当前对话信息
|
||
this.skipConversationHistoryReload = true;
|
||
this.currentConversationId = conversationId;
|
||
this.refreshProjectGitSummary?.();
|
||
this.fetchTerminalCount();
|
||
this.currentConversationTitle = result.title;
|
||
this.titleReady = true;
|
||
this.suppressTitleTyping = false;
|
||
this.startTitleTyping(this.currentConversationTitle, { animate: false });
|
||
if (!preserveListPosition) {
|
||
this.promoteConversationToTop(conversationId);
|
||
}
|
||
history.pushState(
|
||
{ conversationId },
|
||
'',
|
||
`/${this.stripConversationPrefix(conversationId)}`
|
||
);
|
||
this.skipConversationLoadedEvent = true;
|
||
|
||
// 3. 重置UI状态
|
||
this.resetAllStates(`loadConversation:${conversationId}`);
|
||
this.subAgentFetch();
|
||
this.fetchTodoList();
|
||
|
||
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
|
||
await this.fetchAndDisplayHistory();
|
||
await this.refreshRunningWorkspaceTasks?.();
|
||
const visibleTask =
|
||
typeof this.getVisibleWorkspaceTaskForConversation === 'function'
|
||
? this.getVisibleWorkspaceTaskForConversation(conversationId)
|
||
: null;
|
||
const visibleTaskStatus = String(visibleTask?.status || '');
|
||
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
|
||
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']);
|
||
if (visibleTask && activeStatuses.has(visibleTaskStatus)) {
|
||
// 切回正在运行的对话时,必须恢复该任务的 REST 轮询和输入区忙碌态;
|
||
// 否则只切换了历史视图,后续输出不会续接到当前对话,发送按钮也不会变成停止按钮。
|
||
await this.restoreTaskState?.();
|
||
} else if (visibleTask && terminalStatuses.has(visibleTaskStatus)) {
|
||
// 用户已点进完成后的“待查看”对话,清除列表里的完成标记。
|
||
this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id);
|
||
}
|
||
this.fetchPermissionMode();
|
||
this.fetchExecutionMode();
|
||
await this.fetchVersioningStatus(conversationId, { silent: true });
|
||
await this.maybePromptVersioningMismatch(result.versioning);
|
||
this.fetchPendingToolApprovals();
|
||
this.fetchConversationTokenStatistics();
|
||
this.updateCurrentContextTokens();
|
||
traceLog('loadConversation:after-history', {
|
||
conversationId,
|
||
messagesLen: Array.isArray(this.messages) ? this.messages.length : 'n/a'
|
||
});
|
||
} else {
|
||
console.error('对话加载失败:', result.message);
|
||
this.suppressTitleTyping = false;
|
||
this.titleReady = true;
|
||
this.uiPushToast({
|
||
title: '加载对话失败',
|
||
message: result.message || '服务器未返回成功状态',
|
||
type: 'error'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('加载对话异常:', error);
|
||
traceLog('loadConversation:error', {
|
||
conversationId,
|
||
error: error?.message || String(error)
|
||
});
|
||
this.suppressTitleTyping = false;
|
||
this.titleReady = true;
|
||
this.uiPushToast({
|
||
title: '加载对话异常',
|
||
message: error.message || String(error),
|
||
type: 'error'
|
||
});
|
||
}
|
||
},
|
||
|
||
promoteConversationToTop(conversationId) {
|
||
if (!Array.isArray(this.conversations) || !conversationId) {
|
||
return;
|
||
}
|
||
const index = this.conversations.findIndex((conv) => conv && conv.id === conversationId);
|
||
if (index > 0) {
|
||
const [selected] = this.conversations.splice(index, 1);
|
||
this.conversations.unshift(selected);
|
||
}
|
||
},
|
||
|
||
async createNewConversation() {
|
||
console.log('[DEBUG_AWAITING] ===== createNewConversation =====');
|
||
if (this.compressionInProgress || this.compressing) {
|
||
const confirmed = await this.confirmAction({
|
||
title: '压缩进行中',
|
||
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
|
||
confirmText: '确认',
|
||
cancelText: '取消'
|
||
});
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
try {
|
||
if (this.currentConversationId) {
|
||
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
|
||
method: 'POST'
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('取消压缩失败:', e);
|
||
}
|
||
this.compressionInProgress = false;
|
||
this.compressing = false;
|
||
this.compressionMode = '';
|
||
this.compressionStage = '';
|
||
if (this.compressionToastId) {
|
||
this.uiDismissToast(this.compressionToastId);
|
||
this.compressionToastId = null;
|
||
}
|
||
}
|
||
|
||
debugLog('创建新对话...');
|
||
traceLog('createNewConversation:start', {
|
||
currentConversationId: this.currentConversationId,
|
||
convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
|
||
});
|
||
this.logMessageState('createNewConversation:start');
|
||
|
||
await this.persistComposerDraftNow({
|
||
reason: 'create-new-conversation',
|
||
force: true,
|
||
keepalive: true
|
||
}).catch(() => {});
|
||
|
||
// 应用个性化设置中的默认模型和思考模式
|
||
try {
|
||
const personalizationStore = usePersonalizationStore();
|
||
|
||
if (personalizationStore.loaded) {
|
||
const defaultRunMode = personalizationStore.form.default_run_mode;
|
||
const defaultModel = personalizationStore.form.default_model;
|
||
|
||
if (defaultRunMode) {
|
||
this.runMode = defaultRunMode;
|
||
debugLog('应用默认运行模式:', defaultRunMode);
|
||
}
|
||
|
||
if (defaultModel) {
|
||
this.currentModelKey = defaultModel;
|
||
debugLog('应用默认模型:', defaultModel);
|
||
}
|
||
|
||
// 根据默认运行模式设置思考模式
|
||
if (defaultRunMode === 'thinking') {
|
||
this.thinkingMode = true;
|
||
} else if (defaultRunMode === 'fast') {
|
||
this.thinkingMode = false;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('应用个性化默认设置失败:', error);
|
||
}
|
||
|
||
// 创建新对话只切换视图,不再取消后台任务;停止当前轮询避免事件串写。
|
||
try {
|
||
const { useTaskStore } = await import('../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
if (taskStore.hasActiveTask || this.taskInProgress) {
|
||
taskStore.clearTask();
|
||
if (typeof this.clearProcessedEvents === 'function') {
|
||
this.clearProcessedEvents();
|
||
}
|
||
}
|
||
this.clearLocalTaskUiState?.('create-new-conversation');
|
||
} catch (error) {
|
||
console.error('[创建新对话] 停止本地轮询失败:', error);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/conversations', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
thinking_mode: this.thinkingMode,
|
||
mode: this.runMode
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const newConversationId = result.conversation_id;
|
||
const waitForAnimation = (durationMs) =>
|
||
new Promise((resolve) => {
|
||
window.setTimeout(resolve, durationMs);
|
||
});
|
||
debugLog('新对话创建成功:', newConversationId);
|
||
traceLog('createNewConversation:created', { newConversationId });
|
||
|
||
// 在本地列表插入占位,避免等待刷新
|
||
const placeholder = {
|
||
id: newConversationId,
|
||
title: '新对话',
|
||
updated_at: new Date().toISOString(),
|
||
total_messages: 0,
|
||
total_tools: 0
|
||
};
|
||
this.conversationInsertAnimations = {
|
||
...this.conversationInsertAnimations,
|
||
[newConversationId]: 'create'
|
||
};
|
||
this.conversationListAnimationMode = 'create';
|
||
this.conversations = [
|
||
placeholder,
|
||
...this.conversations.filter((conv) => conv && conv.id !== newConversationId)
|
||
];
|
||
window.setTimeout(() => {
|
||
const rest = { ...this.conversationInsertAnimations };
|
||
delete rest[newConversationId];
|
||
this.conversationInsertAnimations = rest;
|
||
this.conversationListAnimationMode = 'idle';
|
||
}, 700);
|
||
|
||
await this.applyPendingVersioningToConversation(newConversationId);
|
||
|
||
// 直接加载新对话,确保状态一致
|
||
// 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态
|
||
await this.loadConversation(newConversationId, { force: true });
|
||
traceLog('createNewConversation:after-load', {
|
||
newConversationId,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
|
||
// 等待顶部插入动画完成,避免刷新列表时打断“整体下移 + 从左到右出现”。
|
||
await waitForAnimation(540);
|
||
|
||
// 刷新对话列表获取最新统计
|
||
this.conversationsOffset = 0;
|
||
await this.loadConversationsList();
|
||
traceLog('createNewConversation:after-refresh', {
|
||
newConversationId,
|
||
conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
|
||
});
|
||
|
||
await this.refreshRunningWorkspaceTasks?.();
|
||
} else {
|
||
console.error('创建对话失败:', result.message);
|
||
this.uiPushToast({
|
||
title: '创建对话失败',
|
||
message: result.message || '服务器未返回成功状态',
|
||
type: 'error'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('创建对话异常:', error);
|
||
this.uiPushToast({
|
||
title: '创建对话异常',
|
||
message: error.message || String(error),
|
||
type: 'error'
|
||
});
|
||
}
|
||
},
|
||
|
||
async deleteConversation(conversationId) {
|
||
const confirmed = await this.confirmAction({
|
||
title: '删除对话',
|
||
message: '确定要删除这个对话吗?删除后无法恢复。',
|
||
confirmText: '删除',
|
||
cancelText: '取消'
|
||
});
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
const waitForAnimation = (durationMs) =>
|
||
new Promise((resolve) => {
|
||
window.setTimeout(resolve, durationMs);
|
||
});
|
||
|
||
// 确认弹窗自身有 250ms 退出动画;等弹窗完全消失后,再开始列表删除动画。
|
||
await waitForAnimation(270);
|
||
|
||
debugLog('删除对话:', conversationId);
|
||
this.logMessageState('deleteConversation:start', { conversationId });
|
||
|
||
try {
|
||
const response = await fetch(`/api/conversations/${conversationId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
debugLog('对话删除成功');
|
||
|
||
// 如果删除的是当前对话,清空界面
|
||
if (conversationId === this.currentConversationId) {
|
||
this.logMessageState('deleteConversation:before-clear', { conversationId });
|
||
this.messages = [];
|
||
this.logMessageState('deleteConversation:after-clear', { conversationId });
|
||
this.currentConversationId = null;
|
||
this.currentConversationTitle = '';
|
||
this.resetAllStates(`deleteConversation:${conversationId}`);
|
||
this.resetTokenStatistics();
|
||
history.replaceState({}, '', '/new');
|
||
}
|
||
|
||
// 先给目标项加 deleting 类执行左滑动画,再从本地列表移除。
|
||
// 这比完全依赖 transition-group leave 更稳定:即使列表即将为空或分支切换,也能先播放离场动画。
|
||
this.conversationListAnimationMode = 'delete';
|
||
const deletingIds = Array.isArray(this.pendingDeletingConversationIds)
|
||
? this.pendingDeletingConversationIds
|
||
: [];
|
||
if (!deletingIds.includes(conversationId)) {
|
||
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
|
||
}
|
||
await this.$nextTick();
|
||
await waitForAnimation(430);
|
||
|
||
this.conversations = this.conversations.filter(
|
||
(conversation) => conversation.id !== conversationId
|
||
);
|
||
this.searchResults = this.searchResults.filter(
|
||
(conversation) => conversation.id !== conversationId
|
||
);
|
||
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
|
||
? this.pendingDeletingConversationIds
|
||
: []
|
||
).filter((id) => id !== conversationId);
|
||
|
||
// 等待“左滑离场 + 0.1s 后集体上移”动画结束,避免刷新列表打断过渡。
|
||
await waitForAnimation(180);
|
||
|
||
// 刷新对话列表
|
||
this.conversationsOffset = 0;
|
||
await this.loadConversationsList();
|
||
this.conversationListAnimationMode = 'idle';
|
||
} else {
|
||
console.error('删除对话失败:', result.message);
|
||
this.uiPushToast({
|
||
title: '删除对话失败',
|
||
message: result.message || '服务器未返回成功状态',
|
||
type: 'error'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
|
||
? this.pendingDeletingConversationIds
|
||
: []
|
||
).filter((id) => id !== conversationId);
|
||
this.conversationListAnimationMode = 'idle';
|
||
console.error('删除对话异常:', error);
|
||
this.uiPushToast({
|
||
title: '删除对话异常',
|
||
message: error.message || String(error),
|
||
type: 'error'
|
||
});
|
||
}
|
||
},
|
||
|
||
async duplicateConversation(conversationId) {
|
||
debugLog('复制对话:', conversationId);
|
||
try {
|
||
const response = await fetch(`/api/conversations/${conversationId}/duplicate`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.success) {
|
||
const newId = result.duplicate_conversation_id;
|
||
const waitForAnimation = (durationMs) =>
|
||
new Promise((resolve) => {
|
||
window.setTimeout(resolve, durationMs);
|
||
});
|
||
if (newId) {
|
||
const sourceIndex = this.conversations.findIndex(
|
||
(conv) => conv && conv.id === conversationId
|
||
);
|
||
const sourceConversation = sourceIndex >= 0 ? this.conversations[sourceIndex] : null;
|
||
const duplicateTitle =
|
||
result.load_result?.title || sourceConversation?.title || '复制的对话';
|
||
const duplicatePlaceholder = {
|
||
id: newId,
|
||
title: duplicateTitle,
|
||
updated_at: new Date().toISOString(),
|
||
total_messages: sourceConversation?.total_messages || 0,
|
||
total_tools: sourceConversation?.total_tools || 0
|
||
};
|
||
|
||
this.conversationInsertAnimations = {
|
||
...this.conversationInsertAnimations,
|
||
[conversationId]: 'duplicateSource',
|
||
[newId]: 'duplicate'
|
||
};
|
||
this.conversationListAnimationMode = 'duplicate';
|
||
|
||
const withoutDuplicate = this.conversations.filter((conv) => conv && conv.id !== newId);
|
||
const insertIndex = withoutDuplicate.findIndex(
|
||
(conv) => conv && conv.id === conversationId
|
||
);
|
||
if (insertIndex >= 0) {
|
||
this.conversations = [
|
||
...withoutDuplicate.slice(0, insertIndex + 1),
|
||
duplicatePlaceholder,
|
||
...withoutDuplicate.slice(insertIndex + 1)
|
||
];
|
||
} else {
|
||
this.conversations = [duplicatePlaceholder, ...withoutDuplicate];
|
||
}
|
||
|
||
window.setTimeout(() => {
|
||
const rest = { ...this.conversationInsertAnimations };
|
||
delete rest[conversationId];
|
||
delete rest[newId];
|
||
this.conversationInsertAnimations = rest;
|
||
this.conversationListAnimationMode = 'idle';
|
||
}, 860);
|
||
|
||
// 先让“目标下方钻出 + 下方整体下移”动画播完,再加载复制出的对话。
|
||
await waitForAnimation(860);
|
||
await this.loadConversation(newId, { force: true, preserveListPosition: true });
|
||
}
|
||
} else {
|
||
const message = result.message || result.error || '复制失败';
|
||
this.uiPushToast({
|
||
title: '复制对话失败',
|
||
message,
|
||
type: 'error'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('复制对话异常:', error);
|
||
this.uiPushToast({
|
||
title: '复制对话异常',
|
||
message: error.message || String(error),
|
||
type: 'error'
|
||
});
|
||
}
|
||
}
|
||
};
|