agent-Specialization/static/src/app/methods/conversation/action.ts
JOJO 5244768494 feat(sidebar): 侧边栏按工作区/项目分组显示对话
- 新增个人化设置 group_sidebar_by_workspace / sidebar_pinned_workspaces /
  sidebar_workspace_order,支持分组视图、置顶、排序
- 后端 conversation API 支持 workspace_id 参数,用于获取/创建/加载/删除
  指定工作区的对话,避免切换视图时 session 漂移
- server/context.py 新增 update_session 参数,临时解析目标 terminal 时不污染
  当前会话
- 前端 ConversationSidebar 实现分组折叠、运行中指示器、新建/更多操作菜单、
  动画同步
- config/terminal.py 默认 TERMINAL_SANDBOX_MODE 由 docker 改为 host
2026-07-11 01:51:01 +08:00

545 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
import { debugLog, traceLog } from '../common';
import { usePersonalizationStore } from '../../../stores/personalization';
import {
} from './shared';
export const actionMethods = {
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);
}
let backupToastId = null;
try {
const personalizationStore = usePersonalizationStore();
const shouldShowBackupToast =
this.versioningHostMode &&
personalizationStore.form.versioning_enabled_by_default &&
personalizationStore.form.versioning_backup_mode === 'full';
if (shouldShowBackupToast) {
backupToastId = this.uiPushToast({
title: '正在初始化备份',
message: '正在创建完整工作区快照,请稍候…',
type: 'info',
duration: null,
closable: false
});
this.versioningInitializingBackupToastId = backupToastId;
}
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)
];
// 分组视图下同步在当前工作区分组顶部插入占位
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const currentWorkspaceId = this.currentHostWorkspaceId;
if (currentWorkspaceId) {
conversationStore.ensureWorkspaceGroup(currentWorkspaceId);
const group = conversationStore.workspaceGroups.find(
(g: any) => g.workspaceId === currentWorkspaceId
);
if (group) {
group.conversations = [
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
group.expanded = true;
group.visibleOffset = 0;
}
}
} catch (_err) {
// ignore
}
window.setTimeout(() => {
const rest = { ...this.conversationInsertAnimations };
delete rest[newConversationId];
this.conversationInsertAnimations = rest;
this.conversationListAnimationMode = 'idle';
}, 700);
// 等待插入动画先完成一帧,避免立即加载对话导致列表闪烁/卡顿。
// 工作区旁新建不切换对话,所以不会触发此处的状态风暴;顶部新建需要切对话,
// 但把 loadConversation 放到入场动画基本结束后再执行,动画就和工作区旁一致。
await waitForAnimation(540);
// 直接加载新对话,确保状态一致
// 如果 socket 事件已把 currentConversationId 设置为新ID则强制加载一次以同步状态
await this.loadConversation(newConversationId, { force: true });
traceLog('createNewConversation:after-load', {
newConversationId,
currentConversationId: this.currentConversationId
});
// 刷新对话列表获取最新统计
this.conversationsOffset = 0;
await this.loadConversationsList();
traceLog('createNewConversation:after-refresh', {
newConversationId,
conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
});
// 分组视图下刷新当前工作区数据,用 refresh 模式避免清空已加载内容导致闪烁
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const currentWorkspaceId = this.currentHostWorkspaceId;
if (currentWorkspaceId) {
await conversationStore.loadWorkspaceConversations(currentWorkspaceId, { refresh: true });
}
} catch (_err) {
// ignore
}
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'
});
} finally {
if (backupToastId) {
this.uiDismissToast(backupToastId);
this.versioningInitializingBackupToastId = null;
}
}
},
async createWorkspaceConversation(workspaceId: string) {
if (!workspaceId) return;
debugLog('在指定工作区创建新对话:', workspaceId);
try {
const response = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspace_id: workspaceId })
});
const result = await response.json();
if (result.success) {
const newConversationId = result.conversation_id;
debugLog('工作区新对话创建成功:', 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';
window.setTimeout(() => {
const rest = { ...this.conversationInsertAnimations };
delete rest[newConversationId];
this.conversationInsertAnimations = rest;
this.conversationListAnimationMode = 'idle';
}, 700);
// 立即在分组侧边栏插入占位,保证用户能立刻看到
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.ensureWorkspaceGroup(workspaceId);
const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (group) {
group.conversations = [
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
group.expanded = true;
group.visibleOffset = 0;
}
// 延迟刷新该工作区列表以获取真实数据,用 refresh 模式保留占位避免闪烁
window.setTimeout(() => {
conversationStore.loadWorkspaceConversations(workspaceId, { refresh: true }).catch(() => {});
}, 300);
} catch (_err) {
// ignore
}
} 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(payload) {
const conversationId = typeof payload === 'string' ? payload : payload?.id;
const workspaceId = typeof payload === 'object' ? payload?.workspaceId : undefined;
if (!conversationId) return;
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 deleteUrl = workspaceId
? `/api/conversations/${conversationId}?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/conversations/${conversationId}`;
const response = await fetch(deleteUrl, {
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();
// 分组侧边栏使用 transition-group 离场动画,立即从列表移除
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.workspaceGroups.forEach((group: any) => {
group.conversations = group.conversations.filter((conv: any) => conv.id !== conversationId);
const maxVisibleOffset = Math.max(0, group.conversations.length - group.visibleLimit);
if (group.visibleOffset > maxVisibleOffset) {
group.visibleOffset = maxVisibleOffset;
}
});
} catch (_err) {
// ignore
}
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);
// 同步插入分组侧边栏对应工作区
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const group = conversationStore.workspaceGroups.find((g: any) =>
g.conversations.some((conv: any) => conv.id === conversationId)
);
if (group) {
const sourceGroupIndex = group.conversations.findIndex((conv: any) => conv.id === conversationId);
const withoutDuplicate = group.conversations.filter((conv: any) => conv.id !== newId);
if (sourceGroupIndex >= 0) {
group.conversations = [
...withoutDuplicate.slice(0, sourceGroupIndex + 1),
duplicatePlaceholder,
...withoutDuplicate.slice(sourceGroupIndex + 1)
];
} else {
group.conversations = [duplicatePlaceholder, ...withoutDuplicate];
}
group.expanded = true;
}
} catch (_err) {
// ignore
}
// 先让“目标下方钻出 + 下方整体下移”动画播完,再加载复制出的对话。
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'
});
}
}
};