agent-Specialization/static/src/app.ts

4114 lines
177 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
// static/app-enhanced.js - 修复版正确实现Token实时更新
import katex from 'katex';
import { mapActions, mapState, mapWritableState } from 'pinia';
import ChatArea from './components/chat/ChatArea.vue';
import ConversationSidebar from './components/sidebar/ConversationSidebar.vue';
import LeftPanel from './components/panels/LeftPanel.vue';
import FocusPanel from './components/panels/FocusPanel.vue';
import TokenDrawer from './components/token/TokenDrawer.vue';
import PersonalizationDrawer from './components/personalization/PersonalizationDrawer.vue';
import LiquidGlassWidget from './components/experiments/LiquidGlassWidget.vue';
import QuickMenu from './components/input/QuickMenu.vue';
import InputComposer from './components/input/InputComposer.vue';
import AppShell from './components/shell/AppShell.vue';
import ImagePicker from './components/overlay/ImagePicker.vue';
import ConversationReviewDialog from './components/overlay/ConversationReviewDialog.vue';
import { useUiStore } from './stores/ui';
import { useConversationStore } from './stores/conversation';
import { useChatStore } from './stores/chat';
import { useInputStore } from './stores/input';
import { useToolStore } from './stores/tool';
import { useResourceStore } from './stores/resource';
import { useUploadStore } from './stores/upload';
import { useFileStore } from './stores/file';
import { useSubAgentStore } from './stores/subAgent';
import { useFocusStore } from './stores/focus';
import { usePersonalizationStore } from './stores/personalization';
import { useModelStore } from './stores/model';
import { useChatActionStore } from './stores/chatActions';
import { useMonitorStore } from './stores/monitor';
import { usePolicyStore } from './stores/policy';
import { ICONS, TOOL_CATEGORY_ICON_MAP } from './utils/icons';
import { initializeLegacySocket } from './composables/useLegacySocket';
import { useConnectionStore } from './stores/connection';
import { useEasterEgg } from './composables/useEasterEgg';
import { renderMarkdown as renderMarkdownHelper } from './composables/useMarkdownRenderer';
import {
formatTokenCount,
formatBytes,
formatPercentage,
formatRate,
formatResetTime,
formatQuotaValue,
quotaTypeLabel,
buildQuotaResetSummary,
isQuotaExceeded as isQuotaExceededUtil,
buildQuotaToastMessage
} from './utils/formatters';
import {
getToolIcon,
getToolAnimationClass,
getToolStatusText as baseGetToolStatusText,
getToolDescription,
cloneToolArguments,
buildToolLabel,
formatSearchTopic,
formatSearchTime,
formatSearchDomains,
getLanguageClass
} from './utils/chatDisplay';
import {
scrollToBottom as scrollToBottomHelper,
conditionalScrollToBottom as conditionalScrollToBottomHelper,
toggleScrollLock as toggleScrollLockHelper,
normalizeScrollLock,
scrollThinkingToBottom as scrollThinkingToBottomHelper
} from './composables/useScrollControl';
import {
startResize as startPanelResize,
handleResize as handlePanelResize,
stopResize as stopPanelResize
} from './composables/usePanelResize';
function normalizeShowImageSrc(src: string) {
if (!src) return '';
const trimmed = src.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
}
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
return trimmed;
}
return '';
}
function isSafeImageSrc(src: string) {
return !!normalizeShowImageSrc(src);
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderShowImages(root: ParentNode | null = document) {
if (!root) return;
// 处理因自闭合解析导致的嵌套:把子 show_image 平铺到父后面
const nested = Array.from(root.querySelectorAll('show_image show_image')).reverse();
nested.forEach(child => {
const parent = child.parentElement;
if (parent && parent !== root) {
parent.after(child);
}
});
const nodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])')).reverse();
nodes.forEach(node => {
// 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序
if (node.parentNode && node.firstChild) {
const parent = node.parentNode;
const ref = node.nextSibling; // 可能为 nullinsertBefore 会当 append
const children = Array.from(node.childNodes);
children.forEach(child => parent.insertBefore(child, ref));
}
const rawSrc = node.getAttribute('src') || '';
const mappedSrc = normalizeShowImageSrc(rawSrc);
if (!mappedSrc) {
node.setAttribute('data-rendered', '1');
node.setAttribute('data-rendered-error', 'invalid-src');
return;
}
const alt = node.getAttribute('alt') || '';
const safeAlt = escapeHtml(alt.trim());
const figure = document.createElement('figure');
figure.className = 'chat-inline-image';
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mappedSrc;
img.alt = safeAlt;
img.onerror = () => {
figure.classList.add('chat-inline-image--error');
const tip = document.createElement('div');
tip.className = 'chat-inline-image__error';
tip.textContent = '图片加载失败';
figure.appendChild(tip);
};
figure.appendChild(img);
if (safeAlt) {
const caption = document.createElement('figcaption');
caption.innerHTML = safeAlt;
figure.appendChild(caption);
}
node.replaceChildren(figure);
node.setAttribute('data-rendered', '1');
});
}
let showImageObserver: MutationObserver | null = null;
function setupShowImageObserver() {
if (showImageObserver) return;
const container = document.querySelector('.messages-area') || document.body;
if (!container) return;
renderShowImages(container);
showImageObserver = new MutationObserver(() => renderShowImages(container));
showImageObserver.observe(container, { childList: true, subtree: true });
}
function teardownShowImageObserver() {
if (showImageObserver) {
showImageObserver.disconnect();
showImageObserver = null;
}
}
window.katex = katex;
function updateViewportHeightVar() {
const docEl = document.documentElement;
const visualViewport = window.visualViewport;
if (visualViewport) {
const vh = visualViewport.height;
const bottomInset = Math.max(
0,
(window.innerHeight || docEl.clientHeight || vh) - visualViewport.height - visualViewport.offsetTop
);
docEl.style.setProperty('--app-viewport', `${vh}px`);
docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`);
} else {
const height = window.innerHeight || docEl.clientHeight;
if (height) {
docEl.style.setProperty('--app-viewport', `${height}px`);
}
docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)');
}
}
updateViewportHeightVar();
window.addEventListener('resize', updateViewportHeightVar);
window.addEventListener('orientationchange', updateViewportHeightVar);
window.addEventListener('pageshow', updateViewportHeightVar);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateViewportHeightVar);
window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
}
const ENABLE_APP_DEBUG_LOGS = true;
function debugLog(...args) {
if (!ENABLE_APP_DEBUG_LOGS) return;
try {
console.log('[app]', ...args);
} catch (e) {
/* ignore logging errors */
}
}
// 临时排查对话切换问题的调试输出
const TRACE_CONV = true;
const traceLog = (...args) => {
if (!TRACE_CONV) return;
try {
console.log('[conv-trace]', ...args);
} catch (e) {
// ignore
}
};
const SEARCH_INITIAL_BATCH = 100;
const SEARCH_MORE_BATCH = 50;
const SEARCH_PREVIEW_LIMIT = 20;
const appOptions = {
data() {
return {
// 路由相关
initialRouteResolved: false,
dropToolEvents: false,
// 工具状态跟踪
preparingTools: new Map(),
activeTools: new Map(),
toolActionIndex: new Map(),
toolStacks: new Map(),
// 当前任务是否仍在进行中(用于保持输入区的“停止”状态)
taskInProgress: false,
// 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次
lastHistoryLoadedConversationId: null,
// ==========================================
// 对话管理相关状态
// ==========================================
// 搜索功能
// ==========================================
searchRequestSeq: 0,
searchActiveQuery: '',
searchResultIdSet: new Set(),
searchPreviewCache: {},
// Token统计相关状态修复版
// ==========================================
// 对话压缩状态
compressing: false,
skipConversationLoadedEvent: false,
skipConversationHistoryReload: false,
_scrollListenerReady: false,
historyLoading: false,
historyLoadingFor: null,
historyLoadSeq: 0,
blankHeroActive: false,
blankHeroExiting: false,
blankWelcomeText: '',
lastBlankConversationId: null,
// 对话标题打字效果
titleTypingText: '',
titleTypingTarget: '',
titleTypingTimer: null,
titleReady: false,
suppressTitleTyping: false,
headerMenuOpen: false,
blankWelcomePool: [
'有什么可以帮忙的?',
'想了解些热点吗?',
'要我帮你完成作业吗?',
'整点代码?',
'随便聊点什么?',
'想让我帮你整理一下思路吗?',
'要不要我帮你写个小工具?',
'发我一句话,我来接着做。'
],
mobileViewportQuery: null,
modeMenuOpen: false,
modelMenuOpen: false,
imageEntries: [],
imageLoading: false,
videoEntries: [],
videoLoading: false,
conversationHasImages: false,
conversationHasVideos: false,
conversationListRequestSeq: 0,
conversationListRefreshToken: 0,
// 工具控制菜单
icons: ICONS,
toolCategoryIcons: TOOL_CATEGORY_ICON_MAP,
// 对话回顾
reviewDialogOpen: false,
reviewSelectedConversationId: null,
reviewSubmitting: false,
reviewPreviewLines: [],
reviewPreviewLoading: false,
reviewPreviewError: null,
reviewPreviewLimit: 20,
reviewSendToModel: true,
reviewGeneratedPath: null
}
},
created() {
const actionStore = useChatActionStore();
actionStore.registerDependencies({
pushToast: (payload) => this.uiPushToast(payload),
autoResizeInput: () => this.autoResizeInput(),
focusComposer: () => {
const inputEl = this.getComposerElement('stadiumInput');
if (inputEl && typeof inputEl.focus === 'function') {
inputEl.focus();
}
},
isConnected: () => this.isConnected,
getSocket: () => this.socket,
downloadResource: (url, filename) => this.downloadResource(url, filename)
});
},
async mounted() {
debugLog('Vue应用已挂载');
if (window.ensureCsrfToken) {
window.ensureCsrfToken().catch((err) => {
console.warn('CSRF token 初始化失败:', err);
});
}
// 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步
const routePromise = this.bootstrapRoute();
const socketPromise = this.initSocket();
await routePromise;
await socketPromise;
this.$nextTick(() => {
this.ensureScrollListener();
// 刷新后若无输出,自动解锁滚动锁定
normalizeScrollLock(this);
});
setupShowImageObserver();
// 立即加载初始数据(并行获取状态,优先同步运行模式)
this.loadInitialData();
document.addEventListener('click', this.handleClickOutsideQuickMenu);
document.addEventListener('click', this.handleClickOutsidePanelMenu);
document.addEventListener('click', this.handleClickOutsideHeaderMenu);
document.addEventListener('click', this.handleClickOutsideMobileMenu);
window.addEventListener('popstate', this.handlePopState);
window.addEventListener('keydown', this.handleMobileOverlayEscape);
this.setupMobileViewportWatcher();
this.subAgentFetch();
this.subAgentStartPolling();
this.$nextTick(() => {
this.autoResizeInput();
});
this.resourceBindContainerVisibilityWatcher();
this.resourceStartContainerStatsPolling();
this.resourceStartProjectStoragePolling();
this.resourceStartUsageQuotaPolling();
},
computed: {
...mapWritableState(useConnectionStore, [
'isConnected',
'socket',
'stopRequested',
'projectPath',
'agentVersion',
'thinkingMode',
'runMode'
]),
...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']),
...mapWritableState(useUiStore, [
'sidebarCollapsed',
'workspaceCollapsed',
'chatDisplayMode',
'panelMode',
'panelMenuOpen',
'leftWidth',
'rightWidth',
'rightCollapsed',
'isResizing',
'resizingPanel',
'minPanelWidth',
'maxPanelWidth',
'quotaToast',
'toastQueue',
'confirmDialog',
'easterEgg',
'isMobileViewport',
'mobileOverlayMenuOpen',
'activeMobileOverlay'
]),
...mapWritableState(useConversationStore, [
'conversations',
'conversationsLoading',
'hasMoreConversations',
'loadingMoreConversations',
'currentConversationId',
'currentConversationTitle',
'searchQuery',
'searchTimer',
'searchResults',
'searchActive',
'searchInProgress',
'searchMoreAvailable',
'searchOffset',
'searchTotal',
'conversationsOffset',
'conversationsLimit'
]),
...mapWritableState(useModelStore, ['currentModelKey']),
...mapState(useModelStore, ['models']),
...mapWritableState(useChatStore, [
'messages',
'currentMessageIndex',
'streamingMessage',
'expandedBlocks',
'autoScrollEnabled',
'userScrolling',
'thinkingScrollLocks'
]),
...mapWritableState(useInputStore, [
'inputMessage',
'inputLineCount',
'inputIsMultiline',
'inputIsFocused',
'quickMenuOpen',
'toolMenuOpen',
'settingsOpen',
'imagePickerOpen',
'videoPickerOpen',
'selectedImages',
'selectedVideos'
]),
resolvedRunMode() {
const allowed = ['fast', 'thinking', 'deep'];
if (allowed.includes(this.runMode)) {
return this.runMode;
}
return this.thinkingMode ? 'thinking' : 'fast';
},
headerRunModeOptions() {
return [
{ value: 'fast', label: '快速模式', desc: '低思考,响应更快' },
{ value: 'thinking', label: '思考模式', desc: '更长思考,综合回答' },
{ value: 'deep', label: '深度思考', desc: '持续推理,适合复杂任务' }
];
},
headerRunModeLabel() {
const current = this.headerRunModeOptions.find((o) => o.value === this.resolvedRunMode);
return current ? current.label : '快速模式';
},
currentModelLabel() {
const modelStore = useModelStore();
return modelStore.currentModel?.label || 'Kimi-k2.5';
},
policyUiBlocks() {
const store = usePolicyStore();
return store.uiBlocks || {};
},
adminDisabledModels() {
const store = usePolicyStore();
return store.disabledModelSet;
},
modelOptions() {
const disabledSet = this.adminDisabledModels || new Set();
const options = this.models || [];
return options.map((opt) => ({
...opt,
disabled: disabledSet.has(opt.key)
})).filter((opt) => true);
},
titleRibbonVisible() {
return !this.isMobileViewport && this.chatDisplayMode === 'chat';
},
...mapWritableState(useToolStore, [
'preparingTools',
'activeTools',
'toolActionIndex',
'toolStacks',
'toolSettings',
'toolSettingsLoading'
]),
...mapWritableState(useResourceStore, [
'tokenPanelCollapsed',
'currentContextTokens',
'currentConversationTokens',
'projectStorage',
'containerStatus',
'containerNetRate',
'usageQuota'
]),
...mapWritableState(useFocusStore, ['focusedFiles']),
...mapWritableState(useUploadStore, ['uploading', 'mediaUploading'])
,
...mapState(useMonitorStore, {
monitorIsLocked: (store) => store.isLocked
})
,
displayModeSwitchDisabled() {
return !!this.policyUiBlocks.block_virtual_monitor;
},
displayLockEngaged() {
return false;
},
streamingUi() {
return this.streamingMessage || this.hasPendingToolActions();
},
composerBusy() {
const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor';
return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested;
},
composerHeroActive() {
return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive;
},
composerInteractionActive() {
const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0);
const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0;
return this.quickMenuOpen || hasText || hasImages;
}
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutsideQuickMenu);
document.removeEventListener('click', this.handleClickOutsidePanelMenu);
document.removeEventListener('click', this.handleClickOutsideHeaderMenu);
document.removeEventListener('click', this.handleClickOutsideMobileMenu);
window.removeEventListener('popstate', this.handlePopState);
window.removeEventListener('keydown', this.handleMobileOverlayEscape);
this.teardownMobileViewportWatcher();
this.subAgentStopPolling();
this.resourceStopContainerStatsPolling();
this.resourceStopProjectStoragePolling();
this.resourceStopUsageQuotaPolling();
teardownShowImageObserver();
if (this.titleTypingTimer) {
clearInterval(this.titleTypingTimer);
this.titleTypingTimer = null;
}
const cleanup = this.destroyEasterEggEffect(true);
if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {});
}
},
watch: {
inputMessage() {
this.autoResizeInput();
},
messages: {
deep: true,
handler() {
this.refreshBlankHeroState();
}
},
currentConversationTitle(newVal, oldVal) {
const target = (newVal && newVal.trim()) || '';
if (this.suppressTitleTyping) {
this.titleTypingText = target;
this.titleTypingTarget = target;
return;
}
const previous = (oldVal && oldVal.trim()) || (this.titleTypingText && this.titleTypingText.trim()) || '';
const placeholderPrev = !previous || previous === '新对话';
const placeholderTarget = !target || target === '新对话';
const animate = placeholderPrev && !placeholderTarget; // 仅从空/占位切换到真实标题时动画
this.startTitleTyping(target, { animate });
},
currentConversationId: {
immediate: false,
handler(newValue, oldValue) {
debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
traceLog('watch:currentConversationId', {
oldValue,
newValue,
skipConversationHistoryReload: this.skipConversationHistoryReload,
historyLoading: this.historyLoading,
historyLoadingFor: this.historyLoadingFor,
historyLoadSeq: this.historyLoadSeq
});
this.refreshBlankHeroState();
this.logMessageState('watch:currentConversationId', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
return;
}
if (this.skipConversationHistoryReload) {
this.skipConversationHistoryReload = false;
return;
}
if (oldValue && newValue === oldValue) {
return;
}
this.fetchAndDisplayHistory();
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
}
}
,
fileTree: {
immediate: true,
handler(newValue) {
this.monitorSyncDesktop(newValue);
}
}
},
methods: {
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();
}
},
...mapActions(useUiStore, {
uiToggleSidebar: 'toggleSidebar',
uiSetSidebarCollapsed: 'setSidebarCollapsed',
uiSetWorkspaceCollapsed: 'setWorkspaceCollapsed',
uiToggleWorkspaceCollapsed: 'toggleWorkspaceCollapsed',
uiSetChatDisplayMode: 'setChatDisplayMode',
uiSetPanelMode: 'setPanelMode',
uiSetPanelMenuOpen: 'setPanelMenuOpen',
uiTogglePanelMenu: 'togglePanelMenu',
uiSetMobileViewport: 'setIsMobileViewport',
uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen',
uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu',
uiSetActiveMobileOverlay: 'setActiveMobileOverlay',
uiCloseMobileOverlay: 'closeMobileOverlay',
uiPushToast: 'pushToast',
uiUpdateToast: 'updateToast',
uiDismissToast: 'dismissToast',
uiShowQuotaToastMessage: 'showQuotaToastMessage',
uiDismissQuotaToast: 'dismissQuotaToast',
uiRequestConfirm: 'requestConfirm',
uiResolveConfirm: 'resolveConfirm'
}),
...mapActions(useModelStore, {
modelSet: 'setModel'
}),
...mapActions(useChatStore, {
chatExpandBlock: 'expandBlock',
chatCollapseBlock: 'collapseBlock',
chatClearExpandedBlocks: 'clearExpandedBlocks',
chatSetThinkingLock: 'setThinkingLock',
chatClearThinkingLocks: 'clearThinkingLocks',
chatSetScrollState: 'setScrollState',
chatEnableAutoScroll: 'enableAutoScroll',
chatDisableAutoScroll: 'disableAutoScroll',
chatToggleScrollLockState: 'toggleScrollLockState',
chatAddUserMessage: 'addUserMessage',
chatStartAssistantMessage: 'startAssistantMessage',
chatStartThinkingAction: 'startThinkingAction',
chatAppendThinkingChunk: 'appendThinkingChunk',
chatCompleteThinkingAction: 'completeThinking',
chatStartTextAction: 'startTextAction',
chatAppendTextChunk: 'appendTextChunk',
chatCompleteTextAction: 'completeText',
chatAddSystemMessage: 'addSystemMessage',
chatAddAppendPayloadAction: 'addAppendPayloadAction',
chatAddModifyPayloadAction: 'addModifyPayloadAction',
chatEnsureAssistantMessage: 'ensureAssistantMessage'
}),
...mapActions(useInputStore, {
inputSetFocused: 'setInputFocused',
inputToggleQuickMenu: 'toggleQuickMenu',
inputCloseMenus: 'closeMenus',
inputOpenQuickMenu: 'openQuickMenu',
inputSetQuickMenuOpen: 'setQuickMenuOpen',
inputToggleToolMenu: 'toggleToolMenu',
inputSetToolMenuOpen: 'setToolMenuOpen',
inputToggleSettingsMenu: 'toggleSettingsMenu',
inputSetSettingsOpen: 'setSettingsOpen',
inputSetMessage: 'setInputMessage',
inputClearMessage: 'clearInputMessage',
inputSetLineCount: 'setInputLineCount',
inputSetMultiline: 'setInputMultiline',
inputSetImagePickerOpen: 'setImagePickerOpen',
inputSetSelectedImages: 'setSelectedImages',
inputAddSelectedImage: 'addSelectedImage',
inputClearSelectedImages: 'clearSelectedImages',
inputRemoveSelectedImage: 'removeSelectedImage',
inputSetVideoPickerOpen: 'setVideoPickerOpen',
inputSetSelectedVideos: 'setSelectedVideos',
inputAddSelectedVideo: 'addSelectedVideo',
inputClearSelectedVideos: 'clearSelectedVideos',
inputRemoveSelectedVideo: 'removeSelectedVideo'
}),
...mapActions(useToolStore, {
toolRegisterAction: 'registerToolAction',
toolUnregisterAction: 'unregisterToolAction',
toolFindAction: 'findToolAction',
toolTrackAction: 'trackToolAction',
toolReleaseAction: 'releaseToolAction',
toolGetLatestAction: 'getLatestActiveToolAction',
toolResetTracking: 'resetToolTracking',
toolSetSettings: 'setToolSettings',
toolSetSettingsLoading: 'setToolSettingsLoading'
}),
...mapActions(useResourceStore, {
resourceUpdateCurrentContextTokens: 'updateCurrentContextTokens',
resourceFetchConversationTokenStatistics: 'fetchConversationTokenStatistics',
resourceSetCurrentContextTokens: 'setCurrentContextTokens',
resourceToggleTokenPanel: 'toggleTokenPanel',
resourceApplyStatusSnapshot: 'applyStatusSnapshot',
resourceUpdateContainerStatus: 'updateContainerStatus',
resourceStartContainerStatsPolling: 'startContainerStatsPolling',
resourceStopContainerStatsPolling: 'stopContainerStatsPolling',
resourceStartProjectStoragePolling: 'startProjectStoragePolling',
resourceStopProjectStoragePolling: 'stopProjectStoragePolling',
resourceStartUsageQuotaPolling: 'startUsageQuotaPolling',
resourceStopUsageQuotaPolling: 'stopUsageQuotaPolling',
resourcePollContainerStats: 'pollContainerStats',
resourceBindContainerVisibilityWatcher: 'bindContainerVisibilityWatcher',
resourcePollProjectStorage: 'pollProjectStorage',
resourceFetchUsageQuota: 'fetchUsageQuota',
resourceResetTokenStatistics: 'resetTokenStatistics',
resourceSetUsageQuota: 'setUsageQuota'
}),
...mapActions(useUploadStore, {
uploadHandleSelected: 'handleSelectedFiles',
uploadBatchFiles: 'uploadFiles'
}),
...mapActions(useFileStore, {
fileFetchTree: 'fetchFileTree',
fileSetTreeFromResponse: 'setFileTreeFromResponse',
fileFetchTodoList: 'fetchTodoList',
fileSetTodoList: 'setTodoList',
fileHideContextMenu: 'hideContextMenu',
fileMarkTreeUnavailable: 'markFileTreeUnavailable'
}),
...mapActions(useMonitorStore, {
monitorSyncDesktop: 'syncDesktopFromTree',
monitorResetVisual: 'resetVisualState',
monitorResetSpeech: 'resetSpeechBuffer',
monitorShowSpeech: 'enqueueModelSpeech',
monitorShowThinking: 'enqueueModelThinking',
monitorEndModelOutput: 'endModelOutput',
monitorShowPendingReply: 'showPendingReply',
monitorPreviewTool: 'previewToolIntent',
monitorQueueTool: 'enqueueToolEvent',
monitorResolveTool: 'resolveToolResult'
}),
...mapActions(useSubAgentStore, {
subAgentFetch: 'fetchSubAgents',
subAgentStartPolling: 'startPolling',
subAgentStopPolling: 'stopPolling'
}),
...mapActions(useFocusStore, {
focusFetchFiles: 'fetchFocusedFiles',
focusSetFiles: 'setFocusedFiles'
}),
...mapActions(usePersonalizationStore, {
personalizationOpenDrawer: 'openDrawer'
}),
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;
},
toolCategoryIcon(categoryId) {
return this.toolCategoryIcons[categoryId] || 'settings';
},
openGuiFileManager() {
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
return;
}
window.open('/file-manager', '_blank');
},
findMessageByAction(action) {
if (!action) {
return null;
}
for (const message of this.messages) {
if (!message.actions) {
continue;
}
if (message.actions.includes(action)) {
return message;
}
}
return null;
},
renderMarkdown(content, isStreaming = false) {
return renderMarkdownHelper(content, isStreaming);
},
hasContainerStats() {
return !!(
this.containerStatus &&
this.containerStatus.mode === 'docker' &&
this.containerStatus.stats
);
},
containerStatusClass() {
if (!this.containerStatus) {
return 'status-pill--host';
}
if (this.containerStatus.mode !== 'docker') {
return 'status-pill--host';
}
const rawStatus = (
this.containerStatus.state &&
(this.containerStatus.state.status || this.containerStatus.state.Status)
) || '';
const status = String(rawStatus).toLowerCase();
if (status.includes('running')) {
return 'status-pill--running';
}
if (status.includes('paused')) {
return 'status-pill--stopped';
}
if (status.includes('exited') || status.includes('dead')) {
return 'status-pill--stopped';
}
return 'status-pill--running';
},
containerStatusText() {
if (!this.containerStatus) {
return '未知';
}
if (this.containerStatus.mode !== 'docker') {
return '宿主机模式';
}
const rawStatus = (
this.containerStatus.state &&
(this.containerStatus.state.status || this.containerStatus.state.Status)
) || '';
const status = String(rawStatus).toLowerCase();
if (status.includes('running')) {
return '运行中';
}
if (status.includes('paused')) {
return '已暂停';
}
if (status.includes('exited') || status.includes('dead')) {
return '已停止';
}
return rawStatus || '容器模式';
},
formatTime(value) {
if (!value) {
return '未知时间';
}
let date;
if (typeof value === 'number') {
date = new Date(value);
} else if (typeof value === 'string') {
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
date = new Date(parsed);
} else {
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
date = new Date(numeric);
}
}
} else if (value instanceof Date) {
date = value;
}
if (!date || Number.isNaN(date.getTime())) {
return String(value);
}
const now = Date.now();
const diff = now - date.getTime();
if (diff < 60000) {
return '刚刚';
}
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
return `${mins} 分钟前`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours} 小时前`;
}
const formatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return formatter.format(date);
},
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;
},
async downloadFile(path) {
if (!path) {
this.fileHideContextMenu();
return;
}
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
const name = path.split('/').pop() || 'file';
await this.downloadResource(url, name);
},
async downloadFolder(path) {
if (!path) {
this.fileHideContextMenu();
return;
}
const url = `/api/download/folder?path=${encodeURIComponent(path)}`;
const segments = path.split('/').filter(Boolean);
const folderName = segments.length ? segments.pop() : 'folder';
await this.downloadResource(url, `${folderName}.zip`);
},
async downloadResource(url, filename) {
try {
const response = await fetch(url);
if (!response.ok) {
let message = response.statusText;
try {
const errorData = await response.json();
message = errorData.error || errorData.message || message;
} catch (err) {
message = await response.text();
}
this.uiPushToast({
title: '下载失败',
message: message || '无法完成下载',
type: 'error'
});
return;
}
const blob = await response.blob();
const downloadName = filename || 'download';
const link = document.createElement('a');
const href = URL.createObjectURL(blob);
link.href = href;
link.download = downloadName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
} catch (error) {
console.error('下载失败:', error);
this.uiPushToast({
title: '下载失败',
message: error.message || String(error),
type: 'error'
});
} finally {
this.fileHideContextMenu();
}
},
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);
},
cleanupStaleToolActions() {
this.messages.forEach(msg => {
if (!msg.actions) {
return;
}
msg.actions.forEach(action => {
if (action.type !== 'tool' || !action.tool) {
return;
}
if (['running', 'preparing'].includes(action.tool.status)) {
action.tool.status = 'stale';
action.tool.message = action.tool.message || '已被新的响应中断';
this.toolUnregisterAction(action);
}
});
});
this.preparingTools.clear();
this.toolActionIndex.clear();
},
hasPendingToolActions() {
const mapHasEntries = map => map && typeof map.size === 'number' && map.size > 0;
if (mapHasEntries(this.preparingTools) || mapHasEntries(this.activeTools)) {
return true;
}
if (!Array.isArray(this.messages)) {
return false;
}
return this.messages.some(msg => {
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) {
return false;
}
return msg.actions.some(action => {
if (!action || action.type !== 'tool' || !action.tool) {
return false;
}
if (action.tool.awaiting_content) {
return true;
}
const status =
typeof action.tool.status === 'string'
? action.tool.status.toLowerCase()
: '';
return !status || ['preparing', 'running', 'pending', 'queued'].includes(status);
});
});
},
maybeResetStreamingState(reason = 'unspecified') {
if (!this.streamingMessage) {
return false;
}
if (this.hasPendingToolActions()) {
return false;
}
this.streamingMessage = false;
this.stopRequested = false;
debugLog('流式状态已结束', { reason });
return true;
},
// 完整重置所有状态
resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) {
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();
// 新增:将所有未完成的工具标记为已完成
this.messages.forEach(msg => {
if (msg.role === 'assistant' && msg.actions) {
msg.actions.forEach(action => {
if (action.type === 'tool' &&
(action.tool.status === 'preparing' || action.tool.status === 'running')) {
action.tool.status = 'completed';
}
});
}
});
// 清理Markdown缓存
if (this.markdownCache) {
this.markdownCache.clear();
}
this.chatClearThinkingLocks();
// 强制更新视图
this.$forceUpdate();
this.inputSetSettingsOpen(false);
this.inputSetToolMenuOpen(false);
this.inputSetQuickMenuOpen(false);
this.modeMenuOpen = false;
this.inputSetLineCount(1);
this.inputSetMultiline(false);
this.inputClearMessage();
this.inputClearSelectedImages();
this.inputSetImagePickerOpen(false);
this.imageEntries = [];
this.imageLoading = false;
this.conversationHasImages = false;
this.toolSetSettingsLoading(false);
this.toolSetSettings([]);
debugLog('前端状态重置完成');
this._scrollListenerReady = false;
this.$nextTick(() => {
this.ensureScrollListener();
});
// 重置已加载对话标记,便于后续重新加载新对话历史
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();
},
// 重置Token统计
openRealtimeTerminal() {
const { protocol, hostname, port } = window.location;
const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`;
window.open(target, '_blank');
},
resetTokenStatistics() {
this.resourceResetTokenStatistics();
},
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);
}
},
// ==========================================
// Token / 资源状态封装Pinia store
// ==========================================
async updateCurrentContextTokens() {
await this.resourceUpdateCurrentContextTokens(this.currentConversationId);
},
async fetchConversationTokenStatistics() {
await this.resourceFetchConversationTokenStatistics(this.currentConversationId);
},
toggleTokenPanel() {
this.resourceToggleTokenPanel();
},
applyStatusSnapshot(status) {
this.resourceApplyStatusSnapshot(status);
if (status && typeof status.thinking_mode !== 'undefined') {
this.thinkingMode = !!status.thinking_mode;
}
if (status && typeof status.run_mode === 'string') {
this.runMode = status.run_mode;
} else if (status && typeof status.thinking_mode !== 'undefined') {
this.runMode = status.thinking_mode ? 'thinking' : 'fast';
}
if (status && typeof status.model_key === 'string') {
this.modelSet(status.model_key);
}
if (status && typeof status.has_images !== 'undefined') {
this.conversationHasImages = !!status.has_images;
}
if (status && typeof status.has_videos !== 'undefined') {
this.conversationHasVideos = !!status.has_videos;
}
},
updateContainerStatus(status) {
this.resourceUpdateContainerStatus(status);
},
pollContainerStats() {
return this.resourcePollContainerStats();
},
startContainerStatsPolling() {
this.resourceStartContainerStatsPolling();
},
stopContainerStatsPolling() {
this.resourceStopContainerStatsPolling();
},
pollProjectStorage() {
return this.resourcePollProjectStorage();
},
startProjectStoragePolling() {
this.resourceStartProjectStoragePolling();
},
stopProjectStoragePolling() {
this.resourceStopProjectStoragePolling();
},
fetchUsageQuota() {
return this.resourceFetchUsageQuota();
},
startUsageQuotaPolling() {
this.resourceStartUsageQuotaPolling();
},
stopUsageQuotaPolling() {
this.resourceStopUsageQuotaPolling();
},
// ==========================================
// 对话管理核心功能
// ==========================================
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) {
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);
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;
}
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 });
// 2. 更新当前对话信息
this.skipConversationHistoryReload = true;
this.currentConversationId = conversationId;
this.currentConversationTitle = result.title;
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
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();
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 fetchAndDisplayHistory(options = {}) {
const { force = false } = options as { force?: boolean };
const targetConversationId = this.currentConversationId;
if (!targetConversationId || targetConversationId.startsWith('temp_')) {
debugLog('没有当前对话ID跳过历史加载');
this.refreshBlankHeroState();
return;
}
// 若同一对话正在加载,直接复用;若是切换对话则允许并发但后来的请求会赢
if (this.historyLoading && this.historyLoadingFor === targetConversationId) {
debugLog('同一对话历史正在加载,跳过重复请求');
return;
}
// 已经有完整历史且非强制刷新时,避免重复加载导致动画播放两次
const alreadyHydrated =
!force &&
this.lastHistoryLoadedConversationId === targetConversationId &&
Array.isArray(this.messages) &&
this.messages.length > 0;
if (alreadyHydrated) {
debugLog('历史已加载,跳过重复请求');
this.logMessageState('fetchAndDisplayHistory:skip-duplicate', {
conversationId: targetConversationId
});
return;
}
const loadSeq = ++this.historyLoadSeq;
this.historyLoading = true;
this.historyLoadingFor = targetConversationId;
try {
debugLog('开始获取历史对话内容...');
this.logMessageState('fetchAndDisplayHistory:start', { conversationId: this.currentConversationId });
try {
// 使用专门的API获取对话消息历史
const messagesResponse = await fetch(`/api/conversations/${targetConversationId}/messages`);
if (!messagesResponse.ok) {
console.warn('无法获取消息历史,尝试备用方法');
// 备用方案通过状态API获取
const statusResponse = await fetch('/api/status');
const status = await statusResponse.json();
debugLog('系统状态:', status);
this.applyStatusSnapshot(status);
// 如果状态中有对话历史字段
if (status.conversation_history && Array.isArray(status.conversation_history)) {
this.renderHistoryMessages(status.conversation_history);
return;
}
debugLog('备用方案也无法获取历史消息');
return;
}
// 如果在等待期间用户已切换到其他对话,则丢弃结果
if (loadSeq !== this.historyLoadSeq || this.currentConversationId !== targetConversationId) {
debugLog('检测到对话已切换,丢弃过期的历史加载结果');
return;
}
const messagesData = await messagesResponse.json();
debugLog('获取到消息数据:', messagesData);
if (messagesData.success && messagesData.data && messagesData.data.messages) {
const messages = messagesData.data.messages;
debugLog(`发现 ${messages.length} 条历史消息`);
if (messages.length > 0) {
// 清空当前显示的消息
this.logMessageState('fetchAndDisplayHistory:before-clear-existing');
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:after-clear-existing');
// 渲染历史消息 - 这是关键功能
this.renderHistoryMessages(messages);
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
debugLog('历史对话内容显示完成');
} else {
debugLog('对话存在但没有历史消息');
this.logMessageState('fetchAndDisplayHistory:no-history-clear');
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:no-history-cleared');
}
} else {
debugLog('消息数据格式不正确:', messagesData);
this.logMessageState('fetchAndDisplayHistory:invalid-data-clear');
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:invalid-data-cleared');
}
} catch (error) {
console.error('获取历史对话失败:', error);
debugLog('尝试不显示错误弹窗,仅在控制台记录');
// 不显示alert避免打断用户体验
this.logMessageState('fetchAndDisplayHistory:error-clear', { error: error?.message || String(error) });
this.messages = [];
this.logMessageState('fetchAndDisplayHistory:error-cleared');
}
} finally {
// 仅在本次加载仍是最新请求时清除 loading 状态
if (loadSeq === this.historyLoadSeq) {
this.historyLoading = false;
this.historyLoadingFor = null;
}
this.refreshBlankHeroState();
}
},
// ==========================================
// 关键功能:渲染历史消息
// ==========================================
renderHistoryMessages(historyMessages) {
debugLog('开始渲染历史消息...', historyMessages);
debugLog('历史消息数量:', historyMessages.length);
this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length });
if (!Array.isArray(historyMessages)) {
console.error('历史消息不是数组格式');
return;
}
let currentAssistantMessage = null;
let historyHasImages = false;
let historyHasVideos = false;
historyMessages.forEach((message, index) => {
debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
const meta = message.metadata || {};
if (message.role === 'user' && (meta.system_injected_image || meta.system_injected_video)) {
debugLog('跳过系统代发的图片/视频消息(仅用于模型查看,不在前端展示)');
return;
}
if (message.role === 'user') {
// 用户消息 - 先结束之前的assistant消息
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
this.messages.push(currentAssistantMessage);
currentAssistantMessage = null;
}
const images = message.images || (message.metadata && message.metadata.images) || [];
const videos = message.videos || (message.metadata && message.metadata.videos) || [];
if (Array.isArray(images) && images.length) {
historyHasImages = true;
}
if (Array.isArray(videos) && videos.length) {
historyHasVideos = true;
}
this.messages.push({
role: 'user',
content: message.content || '',
images,
videos
});
debugLog('添加用户消息:', message.content?.substring(0, 50) + '...');
} else if (message.role === 'assistant') {
// AI消息 - 如果没有当前assistant消息创建一个
if (!currentAssistantMessage) {
currentAssistantMessage = {
role: 'assistant',
actions: [],
streamingThinking: '',
streamingText: '',
currentStreamingType: null,
activeThinkingId: null,
awaitingFirstContent: false,
generatingLabel: ''
};
}
const content = message.content || '';
const reasoningText = (message.reasoning_content || '').trim();
if (reasoningText) {
const blockId = `history-thinking-${Date.now()}-${Math.random().toString(36).slice(2)}`;
currentAssistantMessage.actions.push({
id: `history-think-${Date.now()}-${Math.random()}`,
type: 'thinking',
content: reasoningText,
streaming: false,
timestamp: Date.now(),
blockId
});
debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...');
}
// 处理普通文本内容(移除思考标签后的内容)
const metadata = message.metadata || {};
const appendPayloadMeta = metadata.append_payload;
const modifyPayloadMeta = metadata.modify_payload;
const isAppendMessage = message.name === 'append_to_file';
const isModifyMessage = message.name === 'modify_file';
const containsAppendMarkers = /<<<\s*(APPEND|MODIFY)/i.test(content || '') || /<<<END_\s*(APPEND|MODIFY)>>>/i.test(content || '');
const textContent = content.trim();
if (appendPayloadMeta) {
currentAssistantMessage.actions.push({
id: `history-append-payload-${Date.now()}-${Math.random()}`,
type: 'append_payload',
append: {
path: appendPayloadMeta.path || '未知文件',
forced: !!appendPayloadMeta.forced,
success: appendPayloadMeta.success === undefined ? true : !!appendPayloadMeta.success,
lines: appendPayloadMeta.lines ?? null,
bytes: appendPayloadMeta.bytes ?? null
},
timestamp: Date.now()
});
debugLog('添加append占位信息:', appendPayloadMeta.path);
} else if (modifyPayloadMeta) {
currentAssistantMessage.actions.push({
id: `history-modify-payload-${Date.now()}-${Math.random()}`,
type: 'modify_payload',
modify: {
path: modifyPayloadMeta.path || '未知文件',
total: modifyPayloadMeta.total_blocks ?? null,
completed: modifyPayloadMeta.completed || [],
failed: modifyPayloadMeta.failed || [],
forced: !!modifyPayloadMeta.forced,
details: modifyPayloadMeta.details || []
},
timestamp: Date.now()
});
debugLog('添加modify占位信息:', modifyPayloadMeta.path);
}
if (textContent && !appendPayloadMeta && !modifyPayloadMeta && !isAppendMessage && !isModifyMessage && !containsAppendMarkers) {
currentAssistantMessage.actions.push({
id: `history-text-${Date.now()}-${Math.random()}`,
type: 'text',
content: textContent,
streaming: false,
timestamp: Date.now()
});
debugLog('添加文本内容:', textContent.substring(0, 50) + '...');
}
// 处理工具调用
if (message.tool_calls && Array.isArray(message.tool_calls)) {
message.tool_calls.forEach((toolCall, tcIndex) => {
let arguments_obj = {};
try {
arguments_obj = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments || '{}')
: (toolCall.function.arguments || {});
} catch (e) {
console.warn('解析工具参数失败:', e);
arguments_obj = {};
}
const action = {
id: `history-tool-${toolCall.id || Date.now()}-${tcIndex}`,
type: 'tool',
tool: {
id: toolCall.id,
name: toolCall.function.name,
arguments: arguments_obj,
intent_full: arguments_obj.intent || '',
intent_rendered: arguments_obj.intent || '',
status: 'preparing',
result: null
},
timestamp: Date.now()
};
// 如果是历史加载的动作且状态仍为进行中,标记为 stale避免刷新后按钮卡死
if (['preparing', 'running', 'awaiting_content'].includes(action.tool.status)) {
action.tool.status = 'stale';
action.tool.awaiting_content = false;
action.streaming = false;
}
currentAssistantMessage.actions.push(action);
debugLog('添加工具调用:', toolCall.function.name);
});
}
} else if (message.role === 'tool') {
// 工具结果 - 更新当前assistant消息中对应的工具
if (currentAssistantMessage) {
// 查找对应的工具action - 使用更灵活的匹配
let toolAction = null;
// 优先按tool_call_id匹配
if (message.tool_call_id) {
toolAction = currentAssistantMessage.actions.find(action =>
action.type === 'tool' &&
action.tool.id === message.tool_call_id
);
}
// 如果找不到按name匹配最后一个同名工具
if (!toolAction && message.name) {
const sameNameTools = currentAssistantMessage.actions.filter(action =>
action.type === 'tool' &&
action.tool.name === message.name
);
toolAction = sameNameTools[sameNameTools.length - 1]; // 取最后一个
}
if (toolAction) {
// 解析工具结果优先使用JSON其次使用元数据的 tool_payload以保证搜索结果在刷新后仍可展示
let result;
try {
result = JSON.parse(message.content);
} catch (e) {
if (message.metadata && message.metadata.tool_payload) {
result = message.metadata.tool_payload;
} else {
result = {
output: message.content,
success: true
};
}
}
toolAction.tool.status = 'completed';
toolAction.tool.result = result;
if (result && typeof result === 'object') {
if (result.error) {
toolAction.tool.message = result.error;
} else if (result.message && !toolAction.tool.message) {
toolAction.tool.message = result.message;
}
}
if (message.name === 'append_to_file' && result && result.message) {
toolAction.tool.message = result.message;
}
debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`);
// append_to_file 的摘要在 append_payload 占位中呈现,此处无需重复
} else {
console.warn('找不到对应的工具调用:', message.name, message.tool_call_id);
}
}
} else {
// 其他类型消息如system- 先结束当前assistant消息
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
this.messages.push(currentAssistantMessage);
currentAssistantMessage = null;
}
debugLog('处理其他类型消息:', message.role);
this.messages.push({
role: message.role,
content: message.content || ''
});
}
});
// 处理最后一个assistant消息
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
this.messages.push(currentAssistantMessage);
}
this.conversationHasImages = historyHasImages;
this.conversationHasVideos = historyHasVideos;
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
this.logMessageState('renderHistoryMessages:after-render');
this.lastHistoryLoadedConversationId = this.currentConversationId || null;
// 强制更新视图
this.$forceUpdate();
// 确保滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
setTimeout(() => {
const blockCount = this.$el && this.$el.querySelectorAll
? this.$el.querySelectorAll('.message-block').length
: 'N/A';
debugLog('[Messages] DOM 渲染统计', {
blocks: blockCount,
conversationId: this.currentConversationId
});
}, 0);
});
},
async createNewConversation() {
debugLog('创建新对话...');
traceLog('createNewConversation:start', {
currentConversationId: this.currentConversationId,
convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a'
});
this.logMessageState('createNewConversation:start');
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;
debugLog('新对话创建成功:', newConversationId);
traceLog('createNewConversation:created', { newConversationId });
// 在本地列表插入占位,避免等待刷新
const placeholder = {
id: newConversationId,
title: '新对话',
updated_at: new Date().toISOString(),
total_messages: 0,
total_tools: 0
};
this.conversations = [
placeholder,
...this.conversations.filter(conv => conv && conv.id !== newConversationId)
];
// 直接加载新对话,确保状态一致
// 如果 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'
});
} 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;
}
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');
}
// 刷新对话列表
this.conversationsOffset = 0;
await this.loadConversationsList();
} 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 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;
if (newId) {
this.currentConversationId = newId;
}
this.conversationsOffset = 0;
await this.loadConversationsList();
} 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'
});
}
},
handleSidebarSearchInput(value) {
this.searchQuery = value;
},
handleSidebarSearchSubmit(value) {
this.searchQuery = value;
const trimmed = String(value || '').trim();
if (!trimmed) {
this.exitConversationSearch();
return;
}
this.startConversationSearch(trimmed);
},
exitConversationSearch() {
this.searchActive = false;
this.searchInProgress = false;
this.searchMoreAvailable = false;
this.searchOffset = 0;
this.searchTotal = 0;
this.searchResults = [];
this.searchActiveQuery = '';
this.searchResultIdSet = new Set();
this.conversationsOffset = 0;
this.loadConversationsList();
},
async startConversationSearch(query) {
const trimmed = String(query || '').trim();
if (!trimmed) {
return;
}
const requestSeq = ++this.searchRequestSeq;
this.searchActiveQuery = trimmed;
this.searchActive = true;
this.searchInProgress = true;
this.searchMoreAvailable = false;
this.searchOffset = 0;
this.searchTotal = 0;
this.searchResults = [];
this.searchResultIdSet = new Set();
await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq);
},
async loadMoreSearchResults() {
if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) {
return;
}
const requestSeq = this.searchRequestSeq;
this.searchInProgress = true;
await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq);
},
async searchNextConversationBatch(batchSize, requestSeq) {
const query = this.searchActiveQuery;
if (!query) {
if (requestSeq === this.searchRequestSeq) {
this.searchInProgress = false;
}
return;
}
try {
const response = await fetch(`/api/conversations?limit=${batchSize}&offset=${this.searchOffset}`);
const payload = await response.json();
if (requestSeq !== this.searchRequestSeq) {
return;
}
if (!payload.success) {
console.error('搜索对话失败:', payload.error || payload.message);
this.searchInProgress = false;
return;
}
const data = payload.data || {};
const conversations = data.conversations || [];
if (!this.searchTotal) {
this.searchTotal = data.total || 0;
}
for (const conv of conversations) {
if (requestSeq !== this.searchRequestSeq) {
return;
}
await this.matchConversation(conv, query, requestSeq);
}
this.searchOffset += conversations.length;
this.searchMoreAvailable = this.searchOffset < (this.searchTotal || 0);
} catch (error) {
console.error('搜索对话异常:', error);
} finally {
if (requestSeq === this.searchRequestSeq) {
this.searchInProgress = false;
}
}
},
async matchConversation(conversation, query, requestSeq) {
if (!conversation || !conversation.id) {
return;
}
if (this.searchResultIdSet && this.searchResultIdSet.has(conversation.id)) {
return;
}
const firstSentence = await this.getConversationFirstUserSentence(conversation.id, requestSeq);
if (requestSeq !== this.searchRequestSeq) {
return;
}
const queryLower = String(query || '').toLowerCase();
const combined = `${conversation.title || ''} ${firstSentence || ''}`.toLowerCase();
if (queryLower && combined.includes(queryLower)) {
this.searchResults.push(conversation);
this.searchResultIdSet.add(conversation.id);
}
},
async getConversationFirstUserSentence(conversationId, requestSeq) {
if (!conversationId) {
return '';
}
if (this.searchPreviewCache && Object.prototype.hasOwnProperty.call(this.searchPreviewCache, conversationId)) {
return this.searchPreviewCache[conversationId];
}
try {
const resp = await fetch(`/api/conversations/${conversationId}/review_preview?limit=${SEARCH_PREVIEW_LIMIT}`);
const payload = await resp.json();
if (requestSeq !== this.searchRequestSeq) {
return '';
}
const lines = payload?.data?.preview || [];
let firstUserLine = '';
for (const line of lines) {
if (typeof line === 'string' && line.startsWith('user')) {
firstUserLine = line.slice('user'.length).trim();
break;
}
}
const firstSentence = this.extractFirstSentence(firstUserLine);
const cached = firstSentence || firstUserLine || '';
if (!this.searchPreviewCache) {
this.searchPreviewCache = {};
}
this.searchPreviewCache[conversationId] = cached;
return cached;
} catch (error) {
console.error('获取对话预览失败:', error);
return '';
}
},
extractFirstSentence(text) {
if (!text) {
return '';
}
const normalized = String(text).replace(/\s+/g, ' ').trim();
const match = normalized.match(/(.+?[。!?.!?])/);
if (match) {
return match[1];
}
return normalized;
},
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();
},
triggerFileUpload() {
if (this.uploading) {
return;
}
const input = this.getComposerElement('fileUploadInput');
if (input) {
input.click();
}
},
handleFileSelected(files) {
const policyStore = usePolicyStore();
if (policyStore.uiBlocks?.block_upload) {
this.uiPushToast({
title: '上传被禁用',
message: '已被管理员禁用上传功能',
type: 'warning'
});
return;
}
this.uploadHandleSelected(files);
},
normalizeLocalFiles(files) {
if (!files) return [];
const list = Array.isArray(files) ? files : Array.from(files);
return list.filter(Boolean);
},
isImageFile(file) {
const name = file?.name || '';
const type = file?.type || '';
return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name);
},
isVideoFile(file) {
const name = file?.name || '';
const type = file?.type || '';
return type.startsWith('video/') || /\.(mp4|mov|m4v|webm|avi|mkv|flv|mpg|mpeg)$/i.test(name);
},
upsertImageEntry(path, filename) {
if (!path) return;
const name = filename || path.split('/').pop() || path;
const list = Array.isArray(this.imageEntries) ? this.imageEntries : [];
if (list.some((item) => item.path === path)) {
return;
}
this.imageEntries = [{ name, path }, ...list];
},
upsertVideoEntry(path, filename) {
if (!path) return;
const name = filename || path.split('/').pop() || path;
const list = Array.isArray(this.videoEntries) ? this.videoEntries : [];
if (list.some((item) => item.path === path)) {
return;
}
this.videoEntries = [{ name, path }, ...list];
},
async handleLocalImageFiles(files) {
if (!this.isConnected) {
return;
}
if (this.mediaUploading) {
this.uiPushToast({
title: '上传中',
message: '请等待当前图片上传完成',
type: 'info'
});
return;
}
const list = this.normalizeLocalFiles(files);
if (!list.length) {
return;
}
const existingCount = Array.isArray(this.selectedImages) ? this.selectedImages.length : 0;
const remaining = Math.max(0, 9 - existingCount);
if (!remaining) {
this.uiPushToast({
title: '已达上限',
message: '最多只能选择 9 张图片',
type: 'warning'
});
return;
}
const valid = list.filter((file) => this.isImageFile(file));
if (!valid.length) {
this.uiPushToast({
title: '无法上传',
message: '仅支持图片文件',
type: 'warning'
});
return;
}
if (valid.length < list.length) {
this.uiPushToast({
title: '已忽略',
message: '已跳过非图片文件',
type: 'info'
});
}
const limited = valid.slice(0, remaining);
if (valid.length > remaining) {
this.uiPushToast({
title: '已超出数量',
message: `最多还能添加 ${remaining} 张图片,已自动截断`,
type: 'warning'
});
}
const uploaded = await this.uploadBatchFiles(limited, {
markUploading: true,
markMediaUploading: true
});
if (!uploaded.length) {
return;
}
uploaded.forEach((item) => {
if (!item?.path) return;
this.inputAddSelectedImage(item.path);
this.upsertImageEntry(item.path, item.filename);
});
},
async handleLocalVideoFiles(files) {
if (!this.isConnected) {
return;
}
if (this.mediaUploading) {
this.uiPushToast({
title: '上传中',
message: '请等待当前视频上传完成',
type: 'info'
});
return;
}
const list = this.normalizeLocalFiles(files);
if (!list.length) {
return;
}
const valid = list.filter((file) => this.isVideoFile(file));
if (!valid.length) {
this.uiPushToast({
title: '无法上传',
message: '仅支持视频文件',
type: 'warning'
});
return;
}
if (valid.length < list.length) {
this.uiPushToast({
title: '已忽略',
message: '已跳过非视频文件',
type: 'info'
});
}
if (valid.length > 1) {
this.uiPushToast({
title: '视频数量过多',
message: '一次只能选择 1 个视频,已使用第一个',
type: 'warning'
});
}
const [file] = valid;
if (!file) {
return;
}
const uploaded = await this.uploadBatchFiles([file], {
markUploading: true,
markMediaUploading: true
});
const [item] = uploaded;
if (!item?.path) {
return;
}
this.inputSetSelectedVideos([item.path]);
this.inputClearSelectedImages();
this.upsertVideoEntry(item.path, item.filename);
},
handleSendOrStop() {
if (this.composerBusy) {
this.stopTask();
} else {
this.sendMessage();
}
},
sendMessage() {
if (this.streamingUi || !this.isConnected) {
return;
}
if (this.mediaUploading) {
this.uiPushToast({
title: '上传中',
message: '请等待图片/视频上传完成后再发送',
type: 'info'
});
return;
}
const text = (this.inputMessage || '').trim();
const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : [];
const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : [];
const hasText = text.length > 0;
const hasImages = images.length > 0;
const hasVideos = videos.length > 0;
if (!hasText && !hasImages && !hasVideos) {
return;
}
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
if (this.isQuotaExceeded(quotaType)) {
this.showQuotaToast({ type: quotaType });
return;
}
if (hasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
this.uiPushToast({
title: '当前模型不支持图片',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 再发送图片',
type: 'error'
});
return;
}
if (hasVideos && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
this.uiPushToast({
title: '当前模型不支持视频',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
type: 'error'
});
return;
}
if (hasVideos && hasImages) {
this.uiPushToast({
title: '请勿同时发送',
message: '视频与图片需分开发送,每条仅包含一种媒体',
type: 'warning'
});
return;
}
if (hasVideos) {
this.uiPushToast({
title: '视频处理中',
message: '读取视频需要较长时间,请耐心等待',
type: 'info',
duration: 5000
});
}
const message = text;
const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/');
if (isCommand) {
this.socket.emit('send_command', { command: message });
this.inputClearMessage();
this.inputClearSelectedImages();
this.inputClearSelectedVideos();
this.autoResizeInput();
return;
}
const wasBlank = this.isConversationBlank();
if (wasBlank) {
this.blankHeroExiting = true;
this.blankHeroActive = true;
setTimeout(() => {
this.blankHeroExiting = false;
this.blankHeroActive = false;
}, 320);
}
// 标记任务进行中,直到任务完成或用户手动停止
this.taskInProgress = true;
this.chatAddUserMessage(message, images, videos);
this.socket.emit('send_message', { message: message, images, videos, conversation_id: this.currentConversationId });
if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply();
}
this.inputClearMessage();
this.inputClearSelectedImages();
this.inputClearSelectedVideos();
this.inputSetImagePickerOpen(false);
this.inputSetVideoPickerOpen(false);
this.inputSetLineCount(1);
this.inputSetMultiline(false);
if (hasImages) {
this.conversationHasImages = true;
this.conversationHasVideos = false;
}
if (hasVideos) {
this.conversationHasVideos = true;
this.conversationHasImages = false;
}
if (this.autoScrollEnabled) {
this.scrollToBottom();
}
this.autoResizeInput();
// 发送消息后延迟更新当前上下文Token关键修复恢复原逻辑
setTimeout(() => {
if (this.currentConversationId) {
this.updateCurrentContextTokens();
}
}, 1000);
},
// 新增:停止任务方法
stopTask() {
const canStop = this.composerBusy && !this.stopRequested;
if (!canStop) {
return;
}
const shouldDropToolEvents = this.streamingUi;
this.stopRequested = true;
this.dropToolEvents = shouldDropToolEvents;
if (this.socket) {
this.socket.emit('stop_task');
debugLog('发送停止请求');
}
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态
this.clearPendingTools('user_stop');
this.streamingMessage = false;
this.taskInProgress = false;
this.forceUnlockMonitor('user_stop');
},
forceUnlockMonitor(reason = 'unspecified') {
try {
this.monitorResetVisual({
preserveBubble: true,
preservePointer: true,
preserveWindows: true,
preserveQueue: false,
preservePendingResults: false,
preserveAwaitingTools: false
});
debugLog('Monitor unlocked', { reason });
} catch (error) {
console.warn('强制解锁监控面板失败', error);
}
},
clearPendingTools(reason = 'unspecified') {
debugLog('清理未完成工具', { reason });
if (Array.isArray(this.messages)) {
this.messages.forEach(msg => {
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) {
return;
}
msg.actions.forEach(action => {
if (action && action.type === 'tool' && action.tool) {
action.tool.status = action.tool.status || 'cancelled';
action.tool.awaiting_content = false;
action.streaming = false;
}
});
});
}
this.toolResetTracking();
this.preparingTools.clear();
this.activeTools.clear();
this.toolActionIndex.clear();
this.toolStacks.clear();
this.stopRequested = false;
this.taskInProgress = false;
},
async clearChat() {
const confirmed = await this.confirmAction({
title: '清除对话',
message: '确定要清除所有对话记录吗?该操作不可撤销。',
confirmText: '清除',
cancelText: '取消'
});
if (confirmed) {
this.socket.emit('send_command', { command: '/clear' });
}
},
async compressConversation() {
if (!this.currentConversationId) {
this.uiPushToast({
title: '无法压缩',
message: '当前没有可压缩的对话。',
type: 'info'
});
return;
}
if (this.compressing) {
return;
}
const confirmed = await this.confirmAction({
title: '压缩对话',
message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。',
confirmText: '压缩',
cancelText: '取消'
});
if (!confirmed) {
return;
}
this.compressing = true;
try {
const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.success) {
const newId = result.compressed_conversation_id;
if (newId) {
this.currentConversationId = newId;
}
debugLog('对话压缩完成:', result);
} else {
const message = result.message || result.error || '压缩失败';
this.uiPushToast({
title: '压缩失败',
message,
type: 'error'
});
}
} catch (error) {
console.error('压缩对话异常:', error);
this.uiPushToast({
title: '压缩对话异常',
message: error.message || '请稍后重试',
type: 'error'
});
} finally {
this.compressing = false;
}
},
toggleToolMenu() {
if (!this.isConnected) {
return;
}
if (this.isPolicyBlocked('block_tool_toggle', '工具启用/禁用已被管理员锁定')) {
return;
}
this.modeMenuOpen = false;
this.modelMenuOpen = false;
const nextState = this.inputToggleToolMenu();
if (nextState) {
this.inputSetSettingsOpen(false);
if (!this.quickMenuOpen) {
this.inputOpenQuickMenu();
}
this.loadToolSettings(true);
} else {
this.inputSetToolMenuOpen(false);
}
},
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;
},
async openImagePicker() {
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
this.uiPushToast({
title: '当前模型不支持图片',
message: '请选择 Qwen3.5 或 Kimi-k2.5 后再发送图片',
type: 'error'
});
return;
}
this.closeQuickMenu();
this.inputSetImagePickerOpen(true);
await this.loadWorkspaceImages();
},
closeImagePicker() {
this.inputSetImagePickerOpen(false);
},
async openVideoPicker() {
if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) {
this.uiPushToast({
title: '当前模型不支持视频',
message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频',
type: 'error'
});
return;
}
this.closeQuickMenu();
this.inputSetVideoPickerOpen(true);
await this.loadWorkspaceVideos();
},
closeVideoPicker() {
this.inputSetVideoPickerOpen(false);
},
async loadWorkspaceImages() {
this.imageLoading = true;
try {
const entries = await this.fetchAllImageEntries('');
this.imageEntries = entries;
if (!entries.length) {
this.uiPushToast({
title: '未找到图片',
message: '工作区内没有可用的图片文件',
type: 'info'
});
}
} catch (error) {
console.error('加载图片列表失败', error);
this.uiPushToast({
title: '加载图片失败',
message: error?.message || '请稍后重试',
type: 'error'
});
} finally {
this.imageLoading = false;
}
},
async fetchAllImageEntries(startPath = '') {
const queue: string[] = [startPath || ''];
const visited = new Set<string>();
const results: Array<{ name: string; path: string }> = [];
const exts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg']);
const maxFolders = 120;
while (queue.length && visited.size < maxFolders) {
const path = queue.shift() || '';
if (visited.has(path)) {
continue;
}
visited.add(path);
try {
const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
});
const data = await resp.json().catch(() => null);
if (!data?.success) {
continue;
}
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
for (const item of items) {
const rawPath =
item?.path ||
[path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
const type = String(item?.type || '').toLowerCase();
if (type === 'directory' || type === 'folder') {
queue.push(rawPath);
continue;
}
const ext =
String(item?.extension || '').toLowerCase() ||
(rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : '');
if (exts.has(ext)) {
results.push({
name: item?.name || rawPath.split('/').pop() || rawPath,
path: rawPath
});
if (results.length >= 400) {
return results;
}
}
}
} catch (error) {
console.warn('遍历文件夹失败', path, error);
}
}
return results;
},
async fetchAllVideoEntries(startPath = '') {
const queue: string[] = [startPath || ''];
const visited = new Set<string>();
const results: Array<{ name: string; path: string }> = [];
const exts = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm']);
const maxFolders = 120;
while (queue.length && visited.size < maxFolders) {
const path = queue.shift() || '';
if (visited.has(path)) {
continue;
}
visited.add(path);
try {
const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
});
const data = await resp.json().catch(() => null);
if (!data?.success) {
continue;
}
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
for (const item of items) {
const rawPath =
item?.path ||
[path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
const type = String(item?.type || '').toLowerCase();
if (type === 'directory' || type === 'folder') {
queue.push(rawPath);
continue;
}
const ext =
String(item?.extension || '').toLowerCase() ||
(rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : '');
if (exts.has(ext)) {
results.push({
name: item?.name || rawPath.split('/').pop() || rawPath,
path: rawPath
});
if (results.length >= 200) {
return results;
}
}
}
} catch (error) {
console.warn('遍历文件夹失败', path, error);
}
}
return results;
},
async loadWorkspaceVideos() {
this.videoLoading = true;
try {
const entries = await this.fetchAllVideoEntries('');
this.videoEntries = entries;
if (!entries.length) {
this.uiPushToast({
title: '未找到视频',
message: '工作区内没有可用的视频文件',
type: 'info'
});
}
} catch (error) {
console.error('加载视频列表失败', error);
this.uiPushToast({
title: '加载视频失败',
message: error?.message || '请稍后重试',
type: 'error'
});
} finally {
this.videoLoading = false;
}
},
handleImagesConfirmed(list) {
this.inputSetSelectedImages(Array.isArray(list) ? list : []);
this.inputSetImagePickerOpen(false);
},
handleRemoveImage(path) {
this.inputRemoveSelectedImage(path);
},
handleVideosConfirmed(list) {
const arr = Array.isArray(list) ? list.slice(0, 1) : [];
this.inputSetSelectedVideos(arr);
this.inputSetVideoPickerOpen(false);
if (arr.length) {
this.inputClearSelectedImages();
}
},
handleRemoveVideo(path) {
this.inputRemoveSelectedVideo(path);
},
handleQuickUpload() {
if (this.uploading || !this.isConnected) {
return;
}
if (this.isPolicyBlocked('block_upload', '上传功能已被管理员禁用')) {
return;
}
this.triggerFileUpload();
},
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
};
},
sendAutoUserMessage(text) {
const message = (text || '').trim();
if (!message || !this.isConnected) {
return false;
}
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
if (this.isQuotaExceeded(quotaType)) {
this.showQuotaToast({ type: quotaType });
return false;
}
this.taskInProgress = true;
this.chatAddUserMessage(message, []);
if (this.socket) {
this.socket.emit('send_message', {
message,
images: [],
conversation_id: this.currentConversationId
});
}
if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply();
}
if (this.autoScrollEnabled) {
this.scrollToBottom();
}
this.autoResizeInput();
setTimeout(() => {
if (this.currentConversationId) {
this.updateCurrentContextTokens();
}
}, 1000);
return true;
},
autoResizeInput() {
this.$nextTick(() => {
const textarea = this.getComposerElement('stadiumInput');
if (!textarea || !(textarea instanceof HTMLTextAreaElement)) {
return;
}
const previousHeight = textarea.offsetHeight;
textarea.style.height = 'auto';
const computedStyle = window.getComputedStyle(textarea);
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
const maxHeight = lineHeight * 6;
const targetHeight = Math.min(textarea.scrollHeight, maxHeight);
this.inputSetLineCount(Math.max(1, Math.round(targetHeight / lineHeight)));
this.inputSetMultiline(targetHeight > lineHeight * 1.4);
if (Math.abs(targetHeight - previousHeight) <= 0.5) {
textarea.style.height = `${targetHeight}px`;
return;
}
textarea.style.height = `${previousHeight}px`;
void textarea.offsetHeight;
requestAnimationFrame(() => {
textarea.style.height = `${targetHeight}px`;
});
});
},
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;
}
},
applyToolSettingsSnapshot(categories) {
if (!Array.isArray(categories)) {
console.warn('[ToolSettings] Snapshot skipped: categories not array', categories);
return;
}
const normalized = categories.map((item) => ({
id: item.id,
label: item.label || item.id,
enabled: !!item.enabled,
tools: Array.isArray(item.tools) ? item.tools : [],
locked: !!item.locked,
locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null
}));
debugLog('[ToolSettings] Snapshot applied', {
received: categories.length,
normalized,
anyEnabled: normalized.some(cat => cat.enabled),
toolExamples: normalized.slice(0, 3)
});
this.toolSetSettings(normalized);
this.toolSetSettingsLoading(false);
},
async loadToolSettings(force = false) {
if (!this.isConnected && !force) {
debugLog('[ToolSettings] Skip load: disconnected & not forced');
return;
}
if (this.toolSettingsLoading) {
debugLog('[ToolSettings] Skip load: already loading');
return;
}
if (!force && this.toolSettings.length > 0) {
debugLog('[ToolSettings] Skip load: already have settings');
return;
}
debugLog('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected });
this.toolSetSettingsLoading(true);
try {
const response = await fetch('/api/tool-settings');
const data = await response.json();
debugLog('[ToolSettings] Fetch response', { status: response.status, data });
if (response.ok && data.success && Array.isArray(data.categories)) {
this.applyToolSettingsSnapshot(data.categories);
} else {
console.warn('获取工具设置失败:', data);
this.toolSetSettingsLoading(false);
}
} catch (error) {
console.error('获取工具设置异常:', error);
this.toolSetSettingsLoading(false);
}
},
async updateToolCategory(categoryId, enabled) {
if (!this.isConnected) {
return;
}
if (this.toolSettingsLoading) {
return;
}
const policyStore = usePolicyStore();
if (policyStore.isCategoryLocked(categoryId)) {
this.uiPushToast({
title: '无法修改',
message: '该工具类别被管理员强制设置',
type: 'warning'
});
return;
}
const previousSnapshot = this.toolSettings.map((item) => ({ ...item }));
const updatedSettings = this.toolSettings.map((item) => {
if (item.id === categoryId) {
return { ...item, enabled };
}
return item;
});
this.toolSetSettings(updatedSettings);
try {
const response = await fetch('/api/tool-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
category: categoryId,
enabled
})
});
const data = await response.json();
if (response.ok && data.success && Array.isArray(data.categories)) {
this.applyToolSettingsSnapshot(data.categories);
} else {
console.warn('更新工具设置失败:', data);
if (data && (data.message || data.error)) {
this.uiPushToast({
title: '无法切换工具',
message: data.message || data.error,
type: 'warning'
});
}
this.toolSetSettings(previousSnapshot);
}
} catch (error) {
console.error('更新工具设置异常:', error);
this.toolSetSettings(previousSnapshot);
}
this.toolSetSettingsLoading(false);
},
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);
},
getToolIcon,
getToolAnimationClass,
getToolStatusText(tool: any) {
const personalization = usePersonalizationStore();
const intentEnabled =
personalization?.form?.tool_intent_enabled ??
personalization?.tool_intent_enabled ??
true;
return baseGetToolStatusText(tool, { intentEnabled });
},
getToolDescription,
cloneToolArguments,
buildToolLabel,
formatSearchTopic,
formatSearchTime,
formatSearchDomains,
getLanguageClass,
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);
},
async handleEasterEggPayload(payload) {
const controller = useEasterEgg();
await controller.handlePayload(payload, this);
},
async startEasterEggEffect(effectName, payload = {}) {
const controller = useEasterEgg();
await controller.startEffect(effectName, payload, this);
},
destroyEasterEggEffect(forceImmediate = false) {
const controller = useEasterEgg();
return controller.destroyEffect(forceImmediate);
},
finishEasterEggCleanup() {
const controller = useEasterEgg();
controller.finishCleanup();
},
formatTokenCount,
formatBytes,
formatPercentage,
formatRate,
quotaTypeLabel,
formatResetTime,
formatQuotaValue,
quotaResetSummary() {
return buildQuotaResetSummary(this.usageQuota);
},
isQuotaExceeded(type) {
return isQuotaExceededUtil(this.usageQuota, type);
},
showQuotaToast(payload) {
if (!payload) {
return;
}
const type = payload.type || 'fast';
const message = buildQuotaToastMessage(type, this.usageQuota, payload.reset_at);
this.uiShowQuotaToastMessage(message, type);
},
confirmAction(options = {}) {
return this.uiRequestConfirm(options);
}
}
};
(appOptions as any).components = {
ChatArea,
ConversationSidebar,
LeftPanel,
FocusPanel,
TokenDrawer,
PersonalizationDrawer,
LiquidGlassWidget,
QuickMenu,
InputComposer,
AppShell,
ImagePicker,
ConversationReviewDialog
};
export default appOptions;