主要修复: 1. 移除前端"取消跳转到正在运行的对话"的错误逻辑 - 删除 switchConversation 中的任务检查和确认提示 - 删除 createNewConversation 中的跳转回运行对话逻辑 - 删除 loadConversation 中对未定义变量 hasActiveTask 的引用 2. 修复后端工具执行返回值问题 - 修复 execute_tool_calls 在用户停止时返回 None 的 bug - 确保所有返回路径都返回包含 stopped 和 last_tool_call_time 的字典 3. 其他改进 - 添加代码复制功能 (handleCopyCodeClick) - 移除 FocusPanel 相关代码 - 更新个性化配置 (enhanced_tool_display) - 样式和主题优化 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1225 lines
41 KiB
TypeScript
1225 lines
41 KiB
TypeScript
// @ts-nocheck
|
|
import { usePolicyStore } from '../../stores/policy';
|
|
import { useModelStore } from '../../stores/model';
|
|
import { initializeLegacySocket } from '../../composables/useLegacySocket';
|
|
import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer';
|
|
import {
|
|
scrollToBottom as scrollToBottomHelper,
|
|
conditionalScrollToBottom as conditionalScrollToBottomHelper,
|
|
toggleScrollLock as toggleScrollLockHelper,
|
|
scrollThinkingToBottom as scrollThinkingToBottomHelper
|
|
} from '../../composables/useScrollControl';
|
|
import {
|
|
startResize as startPanelResize,
|
|
handleResize as handlePanelResize,
|
|
stopResize as stopPanelResize
|
|
} from '../../composables/usePanelResize';
|
|
import { debugLog } from './common';
|
|
|
|
export const uiMethods = {
|
|
ensureScrollListener() {
|
|
if (this._scrollListenerReady) {
|
|
return;
|
|
}
|
|
const area = this.getMessagesAreaElement();
|
|
if (!area) {
|
|
return;
|
|
}
|
|
this.initScrollListener();
|
|
this._scrollListenerReady = true;
|
|
},
|
|
|
|
setupMobileViewportWatcher() {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
this.updateMobileViewportState(false);
|
|
return;
|
|
}
|
|
const query = window.matchMedia('(max-width: 768px)');
|
|
this.mobileViewportQuery = query;
|
|
this.updateMobileViewportState(query.matches);
|
|
if (typeof query.addEventListener === 'function') {
|
|
query.addEventListener('change', this.handleMobileViewportQueryChange);
|
|
} else if (typeof query.addListener === 'function') {
|
|
query.addListener(this.handleMobileViewportQueryChange);
|
|
}
|
|
},
|
|
|
|
teardownMobileViewportWatcher() {
|
|
const query = this.mobileViewportQuery;
|
|
if (!query) {
|
|
return;
|
|
}
|
|
if (typeof query.removeEventListener === 'function') {
|
|
query.removeEventListener('change', this.handleMobileViewportQueryChange);
|
|
} else if (typeof query.removeListener === 'function') {
|
|
query.removeListener(this.handleMobileViewportQueryChange);
|
|
}
|
|
this.mobileViewportQuery = null;
|
|
},
|
|
|
|
handleMobileViewportQueryChange(event) {
|
|
this.updateMobileViewportState(event.matches);
|
|
},
|
|
|
|
updateMobileViewportState(isMobile) {
|
|
this.uiSetMobileViewport(!!isMobile);
|
|
if (!isMobile) {
|
|
this.uiSetMobileOverlayMenuOpen(false);
|
|
this.closeMobileOverlay();
|
|
}
|
|
},
|
|
|
|
toggleMobileOverlayMenu() {
|
|
if (!this.isMobileViewport) {
|
|
return;
|
|
}
|
|
this.uiToggleMobileOverlayMenu();
|
|
},
|
|
|
|
openMobileOverlay(target) {
|
|
if (!this.isMobileViewport) {
|
|
return;
|
|
}
|
|
if (this.activeMobileOverlay === target) {
|
|
this.closeMobileOverlay();
|
|
return;
|
|
}
|
|
if (this.activeMobileOverlay === 'conversation') {
|
|
this.uiSetSidebarCollapsed(true);
|
|
}
|
|
if (target === 'conversation') {
|
|
this.uiSetSidebarCollapsed(false);
|
|
}
|
|
this.uiSetActiveMobileOverlay(target);
|
|
this.uiSetMobileOverlayMenuOpen(false);
|
|
},
|
|
|
|
closeMobileOverlay() {
|
|
if (!this.activeMobileOverlay) {
|
|
this.uiCloseMobileOverlay();
|
|
return;
|
|
}
|
|
if (this.activeMobileOverlay === 'conversation') {
|
|
this.uiSetSidebarCollapsed(true);
|
|
}
|
|
this.uiCloseMobileOverlay();
|
|
},
|
|
|
|
applyPolicyUiLocks() {
|
|
const policyStore = usePolicyStore();
|
|
const blocks = policyStore.uiBlocks;
|
|
if (blocks.collapse_workspace) {
|
|
this.uiSetWorkspaceCollapsed(true);
|
|
}
|
|
if (blocks.block_virtual_monitor && this.chatDisplayMode === 'monitor') {
|
|
this.uiSetChatDisplayMode('chat');
|
|
}
|
|
},
|
|
|
|
isPolicyBlocked(key: string, message?: string) {
|
|
const policyStore = usePolicyStore();
|
|
if (policyStore.uiBlocks[key]) {
|
|
this.uiPushToast({
|
|
title: '已被管理员禁用',
|
|
message: message || '被管理员强制禁用',
|
|
type: 'warning'
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
handleClickOutsideMobileMenu(event) {
|
|
if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) {
|
|
return;
|
|
}
|
|
const trigger = this.$refs.mobilePanelTrigger;
|
|
if (trigger && typeof trigger.contains === 'function' && trigger.contains(event.target)) {
|
|
return;
|
|
}
|
|
this.uiSetMobileOverlayMenuOpen(false);
|
|
},
|
|
|
|
handleWorkspaceToggle() {
|
|
if (this.isMobileViewport) {
|
|
return;
|
|
}
|
|
if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) {
|
|
this.uiSetWorkspaceCollapsed(true);
|
|
return;
|
|
}
|
|
const nextState = !this.workspaceCollapsed;
|
|
this.uiSetWorkspaceCollapsed(nextState);
|
|
if (nextState) {
|
|
this.uiSetPanelMenuOpen(false);
|
|
}
|
|
},
|
|
|
|
handleDisplayModeToggle() {
|
|
if (this.displayModeSwitchDisabled) {
|
|
// 显式提示管理员禁用
|
|
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用');
|
|
return;
|
|
}
|
|
if (this.chatDisplayMode === 'chat' && this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用')) {
|
|
return;
|
|
}
|
|
const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat';
|
|
this.uiSetChatDisplayMode(next);
|
|
},
|
|
|
|
handleMobileOverlayEscape(event) {
|
|
if (event.key !== 'Escape' || !this.isMobileViewport) {
|
|
return;
|
|
}
|
|
if (this.mobileOverlayMenuOpen) {
|
|
this.uiSetMobileOverlayMenuOpen(false);
|
|
return;
|
|
}
|
|
if (this.activeMobileOverlay) {
|
|
this.closeMobileOverlay();
|
|
}
|
|
},
|
|
|
|
handleMobileOverlaySelect(conversationId) {
|
|
this.loadConversation(conversationId);
|
|
this.closeMobileOverlay();
|
|
},
|
|
|
|
handleMobilePersonalClick() {
|
|
this.closeMobileOverlay();
|
|
this.uiSetMobileOverlayMenuOpen(false);
|
|
this.openPersonalPage();
|
|
},
|
|
|
|
toggleSidebar() {
|
|
if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') {
|
|
this.closeMobileOverlay();
|
|
return;
|
|
}
|
|
this.uiToggleSidebar();
|
|
},
|
|
|
|
togglePanelMenu() {
|
|
this.uiTogglePanelMenu();
|
|
},
|
|
|
|
selectPanelMode(mode) {
|
|
this.uiSetPanelMode(mode);
|
|
this.uiSetPanelMenuOpen(false);
|
|
},
|
|
|
|
openPersonalPage() {
|
|
if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) {
|
|
return;
|
|
}
|
|
this.personalizationOpenDrawer();
|
|
},
|
|
|
|
fetchTodoList() {
|
|
return this.fileFetchTodoList();
|
|
},
|
|
|
|
fetchSubAgents() {
|
|
return this.subAgentFetch();
|
|
},
|
|
|
|
async toggleThinkingMode() {
|
|
await this.handleCycleRunMode();
|
|
},
|
|
|
|
handleQuickModeToggle() {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
return;
|
|
}
|
|
this.handleCycleRunMode();
|
|
},
|
|
|
|
toggleQuickMenu() {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
const opened = this.inputToggleQuickMenu();
|
|
if (!opened) {
|
|
this.modeMenuOpen = false;
|
|
this.modelMenuOpen = false;
|
|
}
|
|
},
|
|
|
|
closeQuickMenu() {
|
|
this.inputCloseMenus();
|
|
this.modeMenuOpen = false;
|
|
this.modelMenuOpen = false;
|
|
},
|
|
|
|
toggleModeMenu() {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
return;
|
|
}
|
|
const next = !this.modeMenuOpen;
|
|
this.modeMenuOpen = next;
|
|
if (next) {
|
|
this.modelMenuOpen = false;
|
|
}
|
|
if (next) {
|
|
this.inputSetToolMenuOpen(false);
|
|
this.inputSetSettingsOpen(false);
|
|
if (!this.quickMenuOpen) {
|
|
this.inputOpenQuickMenu();
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleModelMenu() {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
return;
|
|
}
|
|
const next = !this.modelMenuOpen;
|
|
this.modelMenuOpen = next;
|
|
if (next) {
|
|
this.modeMenuOpen = false;
|
|
this.inputSetToolMenuOpen(false);
|
|
this.inputSetSettingsOpen(false);
|
|
if (!this.quickMenuOpen) {
|
|
this.inputOpenQuickMenu();
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleHeaderMenu() {
|
|
if (!this.isConnected) return;
|
|
this.headerMenuOpen = !this.headerMenuOpen;
|
|
if (this.headerMenuOpen) {
|
|
this.closeQuickMenu();
|
|
this.inputCloseMenus();
|
|
}
|
|
},
|
|
|
|
async handleModeSelect(mode) {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
return;
|
|
}
|
|
await this.setRunMode(mode);
|
|
},
|
|
|
|
async handleHeaderRunModeSelect(mode) {
|
|
await this.handleModeSelect(mode);
|
|
this.closeHeaderMenu();
|
|
},
|
|
|
|
async handleModelSelect(key) {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
return;
|
|
}
|
|
const policyStore = usePolicyStore();
|
|
if (policyStore.isModelDisabled(key)) {
|
|
this.uiPushToast({
|
|
title: '模型被禁用',
|
|
message: '被管理员强制禁用',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
if (this.conversationHasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(key)) {
|
|
this.uiPushToast({
|
|
title: '切换失败',
|
|
message: '当前对话包含图片,仅支持 Qwen3.5 或 Kimi-k2.5',
|
|
type: 'error'
|
|
});
|
|
return;
|
|
}
|
|
const modelStore = useModelStore();
|
|
const prev = this.currentModelKey;
|
|
try {
|
|
const resp = await fetch('/api/model', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model_key: key })
|
|
});
|
|
const payload = await resp.json();
|
|
if (!resp.ok || !payload.success) {
|
|
throw new Error(payload.error || payload.message || '切换失败');
|
|
}
|
|
const data = payload.data || {};
|
|
modelStore.setModel(data.model_key || key);
|
|
if (data.run_mode) {
|
|
this.runMode = data.run_mode;
|
|
this.thinkingMode = data.thinking_mode ?? (data.run_mode !== 'fast');
|
|
} else {
|
|
// 前端兼容策略:根据模型特性自动调整运行模式
|
|
const currentModel = modelStore.currentModel;
|
|
if (currentModel?.deepOnly) {
|
|
this.runMode = 'deep';
|
|
this.thinkingMode = true;
|
|
} else if (currentModel?.fastOnly) {
|
|
this.runMode = 'fast';
|
|
this.thinkingMode = false;
|
|
} else {
|
|
this.thinkingMode = this.runMode !== 'fast';
|
|
}
|
|
}
|
|
this.uiPushToast({
|
|
title: '模型已切换',
|
|
message: modelStore.currentModel?.label || key,
|
|
type: 'success'
|
|
});
|
|
} catch (error) {
|
|
modelStore.setModel(prev);
|
|
const msg = error instanceof Error ? error.message : String(error || '切换失败');
|
|
this.uiPushToast({
|
|
title: '切换模型失败',
|
|
message: msg,
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
this.modelMenuOpen = false;
|
|
this.inputCloseMenus();
|
|
this.inputSetQuickMenuOpen(false);
|
|
}
|
|
},
|
|
|
|
async handleHeaderModelSelect(key, disabled) {
|
|
if (disabled) return;
|
|
await this.handleModelSelect(key);
|
|
this.closeHeaderMenu();
|
|
},
|
|
|
|
async handleCycleRunMode() {
|
|
const modes: Array<'fast' | 'thinking' | 'deep'> = ['fast', 'thinking', 'deep'];
|
|
const currentMode = this.resolvedRunMode;
|
|
const currentIndex = modes.indexOf(currentMode);
|
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
|
await this.setRunMode(nextMode);
|
|
},
|
|
|
|
async setRunMode(mode, options = {}) {
|
|
if (!this.isConnected || this.streamingMessage) {
|
|
this.modeMenuOpen = false;
|
|
return;
|
|
}
|
|
const modelStore = useModelStore();
|
|
const fastOnly = modelStore.currentModel?.fastOnly;
|
|
const deepOnly = modelStore.currentModel?.deepOnly;
|
|
if (fastOnly && mode !== 'fast') {
|
|
if (!options.suppressToast) {
|
|
this.uiPushToast({
|
|
title: '模式不可用',
|
|
message: '当前模型仅支持快速模式',
|
|
type: 'warning'
|
|
});
|
|
}
|
|
this.modeMenuOpen = false;
|
|
this.inputCloseMenus();
|
|
return;
|
|
}
|
|
if (deepOnly && mode !== 'deep') {
|
|
if (!options.suppressToast) {
|
|
this.uiPushToast({
|
|
title: '模式不可用',
|
|
message: '当前模型仅支持深度思考模式',
|
|
type: 'warning'
|
|
});
|
|
}
|
|
this.modeMenuOpen = false;
|
|
this.inputCloseMenus();
|
|
return;
|
|
}
|
|
if (mode === this.resolvedRunMode) {
|
|
this.modeMenuOpen = false;
|
|
this.closeQuickMenu();
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch('/api/thinking-mode', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ mode })
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.success) {
|
|
throw new Error(payload.message || payload.error || '切换失败');
|
|
}
|
|
const data = payload.data || {};
|
|
this.thinkingMode = typeof data.thinking_mode === 'boolean' ? data.thinking_mode : mode !== 'fast';
|
|
this.runMode = data.mode || mode;
|
|
} catch (error) {
|
|
console.error('切换运行模式失败:', error);
|
|
const message = error instanceof Error ? error.message : String(error || '未知错误');
|
|
this.uiPushToast({
|
|
title: '切换思考模式失败',
|
|
message: message || '请稍后重试',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
this.modeMenuOpen = false;
|
|
this.inputCloseMenus();
|
|
}
|
|
},
|
|
|
|
handleInputChange() {
|
|
this.autoResizeInput();
|
|
},
|
|
|
|
handleInputFocus() {
|
|
this.inputSetFocused(true);
|
|
this.closeQuickMenu();
|
|
},
|
|
|
|
handleInputBlur() {
|
|
this.inputSetFocused(false);
|
|
},
|
|
|
|
handleRealtimeTerminalClick() {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) {
|
|
return;
|
|
}
|
|
this.openRealtimeTerminal();
|
|
},
|
|
|
|
handleFocusPanelToggleClick() {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
if (this.isPolicyBlocked('block_focus_panel', '聚焦面板已被管理员禁用')) {
|
|
return;
|
|
}
|
|
this.toggleFocusPanel();
|
|
},
|
|
|
|
handleTokenPanelToggleClick() {
|
|
if (!this.currentConversationId) {
|
|
return;
|
|
}
|
|
if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) {
|
|
return;
|
|
}
|
|
this.toggleTokenPanel();
|
|
},
|
|
|
|
handleCompressConversationClick() {
|
|
if (this.compressing || this.streamingMessage || !this.isConnected) {
|
|
return;
|
|
}
|
|
if (this.isPolicyBlocked('block_compress_conversation', '压缩对话已被管理员禁用')) {
|
|
return;
|
|
}
|
|
this.compressConversation();
|
|
},
|
|
|
|
openReviewDialog() {
|
|
if (this.isPolicyBlocked('block_conversation_review', '对话引用已被管理员禁用')) {
|
|
return;
|
|
}
|
|
if (!this.isConnected) {
|
|
this.uiPushToast({
|
|
title: '无法使用',
|
|
message: '当前未连接,无法生成回顾文件',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
if (!this.conversations.length && !this.conversationsLoading) {
|
|
this.loadConversationsList();
|
|
}
|
|
const fallback = this.conversations.find((c) => c.id !== this.currentConversationId);
|
|
if (!fallback) {
|
|
this.uiPushToast({
|
|
title: '暂无可用对话',
|
|
message: '没有可供回顾的其他对话记录',
|
|
type: 'info'
|
|
});
|
|
return;
|
|
}
|
|
this.reviewSelectedConversationId = fallback.id;
|
|
this.reviewDialogOpen = true;
|
|
this.reviewPreviewLines = [];
|
|
this.reviewPreviewError = null;
|
|
this.reviewGeneratedPath = null;
|
|
this.loadReviewPreview(fallback.id);
|
|
this.closeQuickMenu();
|
|
},
|
|
|
|
closeHeaderMenu() {
|
|
this.headerMenuOpen = false;
|
|
},
|
|
|
|
handleReviewSelect(id) {
|
|
if (id === this.currentConversationId) {
|
|
this.uiPushToast({
|
|
title: '无法引用当前对话',
|
|
message: '请选择其他对话生成回顾',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
this.reviewSelectedConversationId = id;
|
|
this.loadReviewPreview(id);
|
|
},
|
|
|
|
async handleConfirmReview() {
|
|
if (this.reviewSubmitting) return;
|
|
if (!this.reviewSelectedConversationId) {
|
|
this.uiPushToast({
|
|
title: '请选择对话',
|
|
message: '请选择要生成回顾的对话记录',
|
|
type: 'info'
|
|
});
|
|
return;
|
|
}
|
|
if (this.reviewSelectedConversationId === this.currentConversationId) {
|
|
this.uiPushToast({
|
|
title: '无法引用当前对话',
|
|
message: '请选择其他对话生成回顾',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
if (!this.currentConversationId) {
|
|
this.uiPushToast({
|
|
title: '无法发送',
|
|
message: '当前没有活跃对话,无法自动发送提示消息',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.reviewSubmitting = true;
|
|
try {
|
|
const { path, char_count } = await this.generateConversationReview(this.reviewSelectedConversationId);
|
|
if (!path) {
|
|
throw new Error('未获取到生成的文件路径');
|
|
}
|
|
const count = typeof char_count === 'number' ? char_count : 0;
|
|
this.reviewGeneratedPath = path;
|
|
const suggestion =
|
|
count && count <= 10000
|
|
? '建议直接完整阅读。'
|
|
: '建议使用 read 工具进行搜索或分段阅读。';
|
|
if (this.reviewSendToModel) {
|
|
const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`;
|
|
const sent = this.sendAutoUserMessage(message);
|
|
if (sent) {
|
|
this.reviewDialogOpen = false;
|
|
}
|
|
} else {
|
|
this.uiPushToast({
|
|
title: '回顾文件已生成',
|
|
message: path,
|
|
type: 'success'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error || '生成失败');
|
|
this.uiPushToast({
|
|
title: '生成回顾失败',
|
|
message: msg,
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
this.reviewSubmitting = false;
|
|
}
|
|
},
|
|
|
|
async loadReviewPreview(conversationId) {
|
|
this.reviewPreviewLoading = true;
|
|
this.reviewPreviewError = null;
|
|
this.reviewPreviewLines = [];
|
|
try {
|
|
const resp = await fetch(`/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}`);
|
|
const payload = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !payload?.success) {
|
|
const msg = payload?.message || payload?.error || '获取预览失败';
|
|
throw new Error(msg);
|
|
}
|
|
this.reviewPreviewLines = payload?.data?.preview || [];
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error || '获取预览失败');
|
|
this.reviewPreviewError = msg;
|
|
} finally {
|
|
this.reviewPreviewLoading = false;
|
|
}
|
|
},
|
|
|
|
async generateConversationReview(conversationId) {
|
|
const response = await fetch(`/api/conversations/${conversationId}/review`, {
|
|
method: 'POST'
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok || !payload?.success) {
|
|
const msg = payload?.message || payload?.error || '生成失败';
|
|
throw new Error(msg);
|
|
}
|
|
const data = payload.data || payload;
|
|
return {
|
|
path: data.path || data.file_path || data.relative_path,
|
|
char_count: data.char_count ?? data.length ?? data.size ?? 0
|
|
};
|
|
},
|
|
|
|
handleClickOutsideQuickMenu(event) {
|
|
if (!this.quickMenuOpen) {
|
|
return;
|
|
}
|
|
const shell = this.getComposerElement('stadiumShellOuter') || this.getComposerElement('compactInputShell');
|
|
if (shell && shell.contains(event.target)) {
|
|
return;
|
|
}
|
|
this.closeQuickMenu();
|
|
},
|
|
|
|
handleClickOutsideHeaderMenu(event) {
|
|
if (!this.headerMenuOpen) return;
|
|
const ribbon = this.$refs.titleRibbon as HTMLElement | undefined;
|
|
const menu = this.$refs.headerMenu as HTMLElement | undefined;
|
|
if ((ribbon && ribbon.contains(event.target)) || (menu && menu.contains(event.target))) {
|
|
return;
|
|
}
|
|
this.closeHeaderMenu();
|
|
},
|
|
|
|
handleClickOutsidePanelMenu(event) {
|
|
if (!this.panelMenuOpen) {
|
|
return;
|
|
}
|
|
const leftPanel = this.$refs.leftPanel;
|
|
const wrapper = leftPanel && leftPanel.panelMenuWrapper ? leftPanel.panelMenuWrapper : null;
|
|
if (wrapper && wrapper.contains(event.target)) {
|
|
return;
|
|
}
|
|
this.uiSetPanelMenuOpen(false);
|
|
},
|
|
|
|
isConversationBlank() {
|
|
if (!Array.isArray(this.messages) || !this.messages.length) return true;
|
|
return !this.messages.some(
|
|
(msg) => msg && (msg.role === 'user' || msg.role === 'assistant')
|
|
);
|
|
},
|
|
|
|
pickWelcomeText() {
|
|
const pool = this.blankWelcomePool;
|
|
if (!Array.isArray(pool) || !pool.length) {
|
|
this.blankWelcomeText = '有什么可以帮忙的?';
|
|
return;
|
|
}
|
|
const idx = Math.floor(Math.random() * pool.length);
|
|
this.blankWelcomeText = pool[idx];
|
|
},
|
|
|
|
refreshBlankHeroState() {
|
|
const isBlank = this.isConversationBlank();
|
|
const currentConv = this.currentConversationId || 'temp';
|
|
const needNewWelcome =
|
|
!this.blankHeroActive ||
|
|
this.lastBlankConversationId !== currentConv;
|
|
|
|
if (isBlank) {
|
|
if (needNewWelcome && !this.blankHeroExiting) {
|
|
this.pickWelcomeText();
|
|
}
|
|
this.blankHeroActive = true;
|
|
this.lastBlankConversationId = currentConv;
|
|
} else {
|
|
this.blankHeroActive = false;
|
|
this.blankHeroExiting = false;
|
|
this.lastBlankConversationId = null;
|
|
}
|
|
},
|
|
|
|
toggleSettings() {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
this.modeMenuOpen = false;
|
|
this.modelMenuOpen = false;
|
|
const nextState = this.inputToggleSettingsMenu();
|
|
if (nextState) {
|
|
this.inputSetToolMenuOpen(false);
|
|
if (!this.quickMenuOpen) {
|
|
this.inputOpenQuickMenu();
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleFocusPanel() {
|
|
this.rightCollapsed = !this.rightCollapsed;
|
|
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
|
|
this.rightWidth = this.minPanelWidth;
|
|
}
|
|
},
|
|
|
|
addSystemMessage(content) {
|
|
this.chatAddSystemMessage(content);
|
|
this.$forceUpdate();
|
|
this.conditionalScrollToBottom();
|
|
},
|
|
|
|
startTitleTyping(title: string, options: { animate?: boolean } = {}) {
|
|
if (this.titleTypingTimer) {
|
|
clearInterval(this.titleTypingTimer);
|
|
this.titleTypingTimer = null;
|
|
}
|
|
const target = (title || '').trim();
|
|
const animate = options.animate ?? true;
|
|
if (!animate) {
|
|
this.titleTypingTarget = target;
|
|
this.titleTypingText = target;
|
|
return;
|
|
}
|
|
const previous = (this.titleTypingText || '').trim();
|
|
if (previous === target) {
|
|
this.titleTypingTarget = target;
|
|
this.titleTypingText = target;
|
|
return;
|
|
}
|
|
this.titleTypingTarget = target;
|
|
this.titleTypingText = previous;
|
|
|
|
const frames: string[] = [];
|
|
for (let i = previous.length; i >= 0; i--) {
|
|
frames.push(previous.slice(0, i));
|
|
}
|
|
for (let j = 1; j <= target.length; j++) {
|
|
frames.push(target.slice(0, j));
|
|
}
|
|
|
|
let index = 0;
|
|
this.titleTypingTimer = window.setInterval(() => {
|
|
if (index >= frames.length) {
|
|
clearInterval(this.titleTypingTimer!);
|
|
this.titleTypingTimer = null;
|
|
this.titleTypingText = target;
|
|
return;
|
|
}
|
|
this.titleTypingText = frames[index];
|
|
index += 1;
|
|
}, 32);
|
|
},
|
|
|
|
toggleBlock(blockId) {
|
|
if (!blockId) {
|
|
return;
|
|
}
|
|
if (this.expandedBlocks && this.expandedBlocks.has(blockId)) {
|
|
this.chatCollapseBlock(blockId);
|
|
} else {
|
|
this.chatExpandBlock(blockId);
|
|
}
|
|
},
|
|
|
|
handleThinkingScroll(blockId, event) {
|
|
if (!blockId || !event || !event.target) {
|
|
return;
|
|
}
|
|
const el = event.target;
|
|
const threshold = 12;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
this.chatSetThinkingLock(blockId, atBottom);
|
|
},
|
|
|
|
scrollToBottom() {
|
|
scrollToBottomHelper(this);
|
|
},
|
|
|
|
conditionalScrollToBottom() {
|
|
conditionalScrollToBottomHelper(this);
|
|
},
|
|
|
|
toggleScrollLock() {
|
|
toggleScrollLockHelper(this);
|
|
},
|
|
|
|
scrollThinkingToBottom(blockId) {
|
|
scrollThinkingToBottomHelper(this, blockId);
|
|
},
|
|
|
|
// 面板调整方法
|
|
startResize(panel, event) {
|
|
startPanelResize(this, panel, event);
|
|
},
|
|
|
|
handleResize(event) {
|
|
handlePanelResize(this, event);
|
|
},
|
|
|
|
stopResize() {
|
|
stopPanelResize(this);
|
|
},
|
|
|
|
getInputComposerRef() {
|
|
return this.$refs.inputComposer || null;
|
|
},
|
|
|
|
getComposerElement(field) {
|
|
const composer = this.getInputComposerRef();
|
|
const unwrap = (value: any) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (value instanceof HTMLElement) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'object' && 'value' in value && !(value instanceof Window)) {
|
|
return value.value;
|
|
}
|
|
return value;
|
|
};
|
|
if (composer && composer[field]) {
|
|
return unwrap(composer[field]);
|
|
}
|
|
if (this.$refs && this.$refs[field]) {
|
|
return unwrap(this.$refs[field]);
|
|
}
|
|
return null;
|
|
},
|
|
|
|
getMessagesAreaElement() {
|
|
const ref = this.$refs.messagesArea;
|
|
if (!ref) {
|
|
return null;
|
|
}
|
|
if (ref instanceof HTMLElement) {
|
|
return ref;
|
|
}
|
|
if (ref.rootEl) {
|
|
return ref.rootEl.value || ref.rootEl;
|
|
}
|
|
if (ref.$el && ref.$el.querySelector) {
|
|
const el = ref.$el.querySelector('.messages-area');
|
|
if (el) {
|
|
return el;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
getThinkingContentElement(blockId) {
|
|
const chatArea = this.$refs.messagesArea;
|
|
if (chatArea && typeof chatArea.getThinkingRef === 'function') {
|
|
const el = chatArea.getThinkingRef(blockId);
|
|
if (el) {
|
|
return el;
|
|
}
|
|
}
|
|
const refName = `thinkingContent-${blockId}`;
|
|
const elRef = this.$refs[refName];
|
|
if (Array.isArray(elRef)) {
|
|
return elRef[0] || null;
|
|
}
|
|
return elRef || null;
|
|
},
|
|
|
|
isOutputActive() {
|
|
return !!(this.streamingMessage || this.taskInProgress || this.hasPendingToolActions());
|
|
},
|
|
|
|
logMessageState(action, extra = {}) {
|
|
const count = Array.isArray(this.messages) ? this.messages.length : 'N/A';
|
|
debugLog('[Messages]', {
|
|
action,
|
|
count,
|
|
conversationId: this.currentConversationId,
|
|
streaming: this.streamingMessage,
|
|
...extra
|
|
});
|
|
},
|
|
|
|
iconStyle(iconKey, size) {
|
|
const iconPath = this.icons ? this.icons[iconKey] : null;
|
|
if (!iconPath) {
|
|
return {};
|
|
}
|
|
const style = { '--icon-src': `url(${iconPath})` };
|
|
if (size) {
|
|
style['--icon-size'] = size;
|
|
}
|
|
return style;
|
|
},
|
|
|
|
openGuiFileManager() {
|
|
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
|
|
return;
|
|
}
|
|
window.open('/file-manager', '_blank');
|
|
},
|
|
|
|
renderMarkdown(content, isStreaming = false) {
|
|
return renderMarkdownHelper(content, isStreaming);
|
|
},
|
|
|
|
async bootstrapRoute() {
|
|
// 在路由解析期间抑制标题动画,避免预置“新对话”闪烁
|
|
this.suppressTitleTyping = true;
|
|
this.titleReady = false;
|
|
this.currentConversationTitle = '';
|
|
this.titleTypingText = '';
|
|
const path = window.location.pathname.replace(/^\/+/, '');
|
|
if (!path || path === 'new') {
|
|
this.currentConversationId = null;
|
|
this.currentConversationTitle = '新对话';
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping('新对话', { animate: false });
|
|
this.initialRouteResolved = true;
|
|
return;
|
|
}
|
|
|
|
const convId = path.startsWith('conv_') ? path : `conv_${path}`;
|
|
try {
|
|
const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' });
|
|
const result = await resp.json();
|
|
if (result.success) {
|
|
this.currentConversationId = convId;
|
|
this.currentConversationTitle = result.title || '';
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping(this.currentConversationTitle, { animate: false });
|
|
history.replaceState({ conversationId: convId }, '', `/${this.stripConversationPrefix(convId)}`);
|
|
} else {
|
|
history.replaceState({}, '', '/new');
|
|
this.currentConversationId = null;
|
|
this.currentConversationTitle = '新对话';
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping('新对话', { animate: false });
|
|
}
|
|
} catch (error) {
|
|
console.warn('初始化路由失败:', error);
|
|
history.replaceState({}, '', '/new');
|
|
this.currentConversationId = null;
|
|
this.currentConversationTitle = '新对话';
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping('新对话', { animate: false });
|
|
} finally {
|
|
this.initialRouteResolved = true;
|
|
}
|
|
},
|
|
|
|
handlePopState(event) {
|
|
const state = event.state || {};
|
|
const convId = state.conversationId;
|
|
if (!convId) {
|
|
this.currentConversationId = null;
|
|
this.currentConversationTitle = '新对话';
|
|
this.logMessageState('handlePopState:clear-messages-no-conversation');
|
|
this.messages = [];
|
|
this.logMessageState('handlePopState:after-clear-no-conversation');
|
|
this.resetAllStates('handlePopState:no-conversation');
|
|
this.resetTokenStatistics();
|
|
return;
|
|
}
|
|
this.loadConversation(convId);
|
|
},
|
|
|
|
stripConversationPrefix(conversationId) {
|
|
if (!conversationId) return '';
|
|
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;
|
|
},
|
|
|
|
initScrollListener() {
|
|
const messagesArea = this.getMessagesAreaElement();
|
|
if (!messagesArea) {
|
|
console.warn('消息区域未找到');
|
|
return;
|
|
}
|
|
this._scrollListenerReady = true;
|
|
|
|
let isProgrammaticScroll = false;
|
|
const bottomThreshold = 12;
|
|
|
|
this._setScrollingFlag = (flag) => {
|
|
isProgrammaticScroll = !!flag;
|
|
};
|
|
|
|
messagesArea.addEventListener('scroll', () => {
|
|
if (isProgrammaticScroll) {
|
|
return;
|
|
}
|
|
|
|
const scrollTop = messagesArea.scrollTop;
|
|
const scrollHeight = messagesArea.scrollHeight;
|
|
const clientHeight = messagesArea.clientHeight;
|
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold;
|
|
|
|
const activeLock = this.autoScrollEnabled && this.isOutputActive();
|
|
|
|
// 锁定且当前有输出时,强制贴底
|
|
if (activeLock) {
|
|
if (!isAtBottom) {
|
|
if (typeof this._setScrollingFlag === 'function') {
|
|
this._setScrollingFlag(true);
|
|
}
|
|
messagesArea.scrollTop = messagesArea.scrollHeight;
|
|
if (typeof this._setScrollingFlag === 'function') {
|
|
setTimeout(() => this._setScrollingFlag && this._setScrollingFlag(false), 16);
|
|
}
|
|
}
|
|
// 保持锁定状态下 userScrolling 为 false
|
|
this.chatSetScrollState({ userScrolling: false });
|
|
return;
|
|
}
|
|
|
|
// 未锁定或无输出:允许自由滚动,仅记录位置
|
|
this.chatSetScrollState({ userScrolling: !isAtBottom });
|
|
});
|
|
},
|
|
|
|
async initSocket() {
|
|
await initializeLegacySocket(this);
|
|
},
|
|
|
|
openRealtimeTerminal() {
|
|
const { protocol, hostname, port } = window.location;
|
|
const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`;
|
|
window.open(target, '_blank');
|
|
},
|
|
|
|
async loadInitialData() {
|
|
try {
|
|
debugLog('加载初始数据...');
|
|
|
|
const statusResponse = await fetch('/api/status');
|
|
const statusData = await statusResponse.json();
|
|
this.projectPath = statusData.project_path || '';
|
|
this.agentVersion = statusData.version || this.agentVersion;
|
|
this.thinkingMode = !!statusData.thinking_mode;
|
|
this.applyStatusSnapshot(statusData);
|
|
// 立即更新配额和运行模式,避免等待其他慢接口
|
|
this.fetchUsageQuota();
|
|
// 拉取管理员策略
|
|
const policyStore = usePolicyStore();
|
|
await policyStore.fetchPolicy();
|
|
this.applyPolicyUiLocks();
|
|
|
|
const focusPromise = this.focusFetchFiles();
|
|
const todoPromise = this.fileFetchTodoList();
|
|
let treePromise: Promise<any> | null = null;
|
|
const isHostMode = statusData?.container?.mode === 'host';
|
|
if (isHostMode) {
|
|
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
|
} else {
|
|
treePromise = this.fileFetchTree();
|
|
}
|
|
|
|
// 获取当前对话信息
|
|
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
|
|
if (statusConversationId && !this.currentConversationId) {
|
|
this.skipConversationHistoryReload = true;
|
|
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载
|
|
this.skipConversationLoadedEvent = true;
|
|
this.suppressTitleTyping = true;
|
|
this.titleReady = false;
|
|
this.currentConversationTitle = '';
|
|
this.titleTypingText = '';
|
|
this.currentConversationId = statusConversationId;
|
|
|
|
// 如果有当前对话,尝试获取标题和历史
|
|
try {
|
|
const convResponse = await fetch(`/api/conversations/current`);
|
|
const convData = await convResponse.json();
|
|
if (convData.success && convData.data) {
|
|
this.currentConversationTitle = convData.data.title;
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping(this.currentConversationTitle, { animate: false });
|
|
} else {
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
const fallbackTitle = this.currentConversationTitle || '新对话';
|
|
this.currentConversationTitle = fallbackTitle;
|
|
this.startTitleTyping(fallbackTitle, { animate: false });
|
|
}
|
|
// 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发
|
|
if (
|
|
this.lastHistoryLoadedConversationId !== this.currentConversationId ||
|
|
!Array.isArray(this.messages) ||
|
|
this.messages.length === 0
|
|
) {
|
|
await this.fetchAndDisplayHistory();
|
|
}
|
|
// 获取当前对话的Token统计
|
|
this.fetchConversationTokenStatistics();
|
|
this.updateCurrentContextTokens();
|
|
} catch (e) {
|
|
console.warn('获取当前对话标题失败:', e);
|
|
this.titleReady = true;
|
|
this.suppressTitleTyping = false;
|
|
this.startTitleTyping(this.currentConversationTitle || '新对话', '');
|
|
}
|
|
}
|
|
|
|
// 等待其他加载项完成(允许部分失败不阻塞模式切换)
|
|
const pendingPromises = [focusPromise, todoPromise];
|
|
if (treePromise) {
|
|
pendingPromises.push(treePromise);
|
|
}
|
|
await Promise.allSettled(pendingPromises);
|
|
await this.loadToolSettings(true);
|
|
|
|
debugLog('初始数据加载完成');
|
|
} catch (error) {
|
|
console.error('加载初始数据失败:', error);
|
|
}
|
|
},
|
|
|
|
confirmAction(options = {}) {
|
|
return this.uiRequestConfirm(options);
|
|
},
|
|
|
|
async handleCopyCodeClick(event) {
|
|
const target = event.target;
|
|
if (!target || !target.classList || !target.classList.contains('copy-code-btn')) {
|
|
return;
|
|
}
|
|
|
|
const blockId = target.getAttribute('data-code');
|
|
if (!blockId) {
|
|
return;
|
|
}
|
|
|
|
// 防止重复点击
|
|
if (target.classList.contains('copied')) {
|
|
return;
|
|
}
|
|
|
|
const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`;
|
|
const codeEl = document.querySelector(selector);
|
|
if (!codeEl) {
|
|
return;
|
|
}
|
|
|
|
const encoded = codeEl.getAttribute('data-original-code');
|
|
const content = encoded ? this.decodeHtmlEntities(encoded) : codeEl.textContent || '';
|
|
|
|
if (!content.trim()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(content);
|
|
|
|
// 添加 copied 类,切换为对勾图标
|
|
target.classList.add('copied');
|
|
|
|
// 5秒后恢复
|
|
setTimeout(() => {
|
|
target.classList.remove('copied');
|
|
}, 5000);
|
|
} catch (error) {
|
|
console.warn('复制失败:', error);
|
|
}
|
|
},
|
|
|
|
decodeHtmlEntities(text) {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.innerHTML = text;
|
|
return textarea.value;
|
|
}
|
|
};
|