2360 lines
100 KiB
TypeScript
2360 lines
100 KiB
TypeScript
// @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 QuickMenu from './components/input/QuickMenu.vue';
|
||
import InputComposer from './components/input/InputComposer.vue';
|
||
import AppShell from './components/shell/AppShell.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 { useChatActionStore } from './stores/chatActions';
|
||
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,
|
||
getToolDescription,
|
||
cloneToolArguments,
|
||
buildToolLabel,
|
||
formatSearchTopic,
|
||
formatSearchTime,
|
||
getLanguageClass
|
||
} from './utils/chatDisplay';
|
||
import {
|
||
scrollToBottom as scrollToBottomHelper,
|
||
conditionalScrollToBottom as conditionalScrollToBottomHelper,
|
||
toggleScrollLock as toggleScrollLockHelper,
|
||
scrollThinkingToBottom as scrollThinkingToBottomHelper
|
||
} from './composables/useScrollControl';
|
||
import {
|
||
startResize as startPanelResize,
|
||
handleResize as handlePanelResize,
|
||
stopResize as stopPanelResize
|
||
} from './composables/usePanelResize';
|
||
|
||
|
||
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 = false;
|
||
function debugLog(...args) {
|
||
if (!ENABLE_APP_DEBUG_LOGS) {
|
||
return;
|
||
}
|
||
debugLog(...args);
|
||
}
|
||
|
||
const appOptions = {
|
||
data() {
|
||
return {
|
||
// 路由相关
|
||
initialRouteResolved: false,
|
||
|
||
// 工具状态跟踪
|
||
preparingTools: new Map(),
|
||
activeTools: new Map(),
|
||
toolActionIndex: new Map(),
|
||
toolStacks: new Map(),
|
||
|
||
// ==========================================
|
||
// 对话管理相关状态
|
||
// ==========================================
|
||
|
||
// 搜索功能
|
||
// ==========================================
|
||
// Token统计相关状态(修复版)
|
||
// ==========================================
|
||
|
||
// 对话压缩状态
|
||
compressing: false,
|
||
skipConversationLoadedEvent: false,
|
||
skipConversationHistoryReload: false,
|
||
_scrollListenerReady: false,
|
||
historyLoading: false,
|
||
mobileViewportQuery: null,
|
||
|
||
// 工具控制菜单
|
||
icons: ICONS,
|
||
toolCategoryIcons: TOOL_CATEGORY_ICON_MAP
|
||
}
|
||
},
|
||
|
||
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);
|
||
});
|
||
}
|
||
await this.bootstrapRoute();
|
||
await this.initSocket();
|
||
this.$nextTick(() => {
|
||
this.ensureScrollListener();
|
||
});
|
||
|
||
// 延迟加载初始数据
|
||
setTimeout(() => {
|
||
this.loadInitialData();
|
||
}, 500);
|
||
|
||
document.addEventListener('click', this.handleClickOutsideQuickMenu);
|
||
document.addEventListener('click', this.handleClickOutsidePanelMenu);
|
||
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.resourceStartContainerStatsPolling();
|
||
this.resourceStartProjectStoragePolling();
|
||
this.resourceStartUsageQuotaPolling();
|
||
},
|
||
|
||
computed: {
|
||
...mapWritableState(useConnectionStore, [
|
||
'isConnected',
|
||
'socket',
|
||
'stopRequested',
|
||
'projectPath',
|
||
'agentVersion',
|
||
'thinkingMode'
|
||
]),
|
||
...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']),
|
||
...mapWritableState(useUiStore, [
|
||
'sidebarCollapsed',
|
||
'workspaceCollapsed',
|
||
'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',
|
||
'conversationsOffset',
|
||
'conversationsLimit'
|
||
]),
|
||
...mapWritableState(useChatStore, [
|
||
'messages',
|
||
'currentMessageIndex',
|
||
'streamingMessage',
|
||
'expandedBlocks',
|
||
'autoScrollEnabled',
|
||
'userScrolling',
|
||
'thinkingScrollLocks'
|
||
]),
|
||
...mapWritableState(useInputStore, [
|
||
'inputMessage',
|
||
'inputLineCount',
|
||
'inputIsMultiline',
|
||
'inputIsFocused',
|
||
'quickMenuOpen',
|
||
'toolMenuOpen',
|
||
'settingsOpen'
|
||
]),
|
||
...mapWritableState(useToolStore, [
|
||
'preparingTools',
|
||
'activeTools',
|
||
'toolActionIndex',
|
||
'toolStacks',
|
||
'toolSettings',
|
||
'toolSettingsLoading'
|
||
]),
|
||
...mapWritableState(useResourceStore, [
|
||
'tokenPanelCollapsed',
|
||
'currentContextTokens',
|
||
'currentConversationTokens',
|
||
'projectStorage',
|
||
'containerStatus',
|
||
'containerNetRate',
|
||
'usageQuota'
|
||
]),
|
||
...mapWritableState(useFocusStore, ['focusedFiles']),
|
||
...mapWritableState(useUploadStore, ['uploading'])
|
||
},
|
||
|
||
beforeUnmount() {
|
||
document.removeEventListener('click', this.handleClickOutsideQuickMenu);
|
||
document.removeEventListener('click', this.handleClickOutsidePanelMenu);
|
||
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();
|
||
const cleanup = this.destroyEasterEggEffect(true);
|
||
if (cleanup && typeof cleanup.catch === 'function') {
|
||
cleanup.catch(() => {});
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
inputMessage() {
|
||
this.autoResizeInput();
|
||
},
|
||
currentConversationId: {
|
||
immediate: false,
|
||
handler(newValue, oldValue) {
|
||
debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload });
|
||
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();
|
||
}
|
||
}
|
||
},
|
||
|
||
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();
|
||
},
|
||
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;
|
||
}
|
||
const nextState = !this.workspaceCollapsed;
|
||
this.uiSetWorkspaceCollapsed(nextState);
|
||
if (nextState) {
|
||
this.uiSetPanelMenuOpen(false);
|
||
}
|
||
},
|
||
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',
|
||
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(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'
|
||
}),
|
||
...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',
|
||
resourcePollProjectStorage: 'pollProjectStorage',
|
||
resourceFetchUsageQuota: 'fetchUsageQuota',
|
||
resourceResetTokenStatistics: 'resetTokenStatistics',
|
||
resourceSetUsageQuota: 'setUsageQuota'
|
||
}),
|
||
...mapActions(useUploadStore, {
|
||
uploadHandleSelected: 'handleSelectedFiles'
|
||
}),
|
||
...mapActions(useFileStore, {
|
||
fileFetchTree: 'fetchFileTree',
|
||
fileSetTreeFromResponse: 'setFileTreeFromResponse',
|
||
fileFetchTodoList: 'fetchTodoList',
|
||
fileSetTodoList: 'setTodoList',
|
||
fileHideContextMenu: 'hideContextMenu'
|
||
}),
|
||
...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;
|
||
},
|
||
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() {
|
||
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() {
|
||
const path = window.location.pathname.replace(/^\/+/, '');
|
||
if (!path || path === 'new') {
|
||
this.currentConversationId = null;
|
||
this.currentConversationTitle = '新对话';
|
||
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 || '对话';
|
||
history.replaceState({ conversationId: convId }, '', `/${this.stripConversationPrefix(convId)}`);
|
||
} else {
|
||
history.replaceState({}, '', '/new');
|
||
this.currentConversationId = null;
|
||
this.currentConversationTitle = '新对话';
|
||
}
|
||
} catch (error) {
|
||
console.warn('初始化路由失败:', error);
|
||
history.replaceState({}, '', '/new');
|
||
this.currentConversationId = null;
|
||
this.currentConversationTitle = '新对话';
|
||
} 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;
|
||
|
||
if (isAtBottom) {
|
||
this.chatSetScrollState({ userScrolling: false, autoScrollEnabled: true });
|
||
} else {
|
||
this.chatSetScrollState({ userScrolling: true, autoScrollEnabled: false });
|
||
}
|
||
});
|
||
},
|
||
|
||
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') {
|
||
debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId });
|
||
this.logMessageState('resetAllStates:before-cleanup', { reason });
|
||
this.fileHideContextMenu();
|
||
|
||
// 重置消息和流状态
|
||
this.streamingMessage = false;
|
||
this.currentMessageIndex = -1;
|
||
this.stopRequested = 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';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 重置滚动状态
|
||
this.chatEnableAutoScroll();
|
||
|
||
// 清理Markdown缓存
|
||
if (this.markdownCache) {
|
||
this.markdownCache.clear();
|
||
}
|
||
this.chatClearThinkingLocks();
|
||
|
||
// 强制更新视图
|
||
this.$forceUpdate();
|
||
|
||
this.inputSetSettingsOpen(false);
|
||
this.inputSetToolMenuOpen(false);
|
||
this.inputSetQuickMenuOpen(false);
|
||
this.inputSetLineCount(1);
|
||
this.inputSetMultiline(false);
|
||
this.inputClearMessage();
|
||
this.toolSetSettingsLoading(false);
|
||
this.toolSetSettings([]);
|
||
|
||
debugLog('前端状态重置完成');
|
||
this._scrollListenerReady = false;
|
||
this.$nextTick(() => {
|
||
this.ensureScrollListener();
|
||
});
|
||
|
||
this.logMessageState('resetAllStates:after-cleanup', { reason });
|
||
},
|
||
|
||
// 重置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('加载初始数据...');
|
||
|
||
await this.fileFetchTree();
|
||
await this.focusFetchFiles();
|
||
await this.fileFetchTodoList();
|
||
|
||
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);
|
||
await this.fetchUsageQuota();
|
||
|
||
// 获取当前对话信息
|
||
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
|
||
if (statusConversationId) {
|
||
if (!this.currentConversationId) {
|
||
this.skipConversationHistoryReload = true;
|
||
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;
|
||
}
|
||
// 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发
|
||
await this.fetchAndDisplayHistory();
|
||
// 获取当前对话的Token统计
|
||
this.fetchConversationTokenStatistics();
|
||
this.updateCurrentContextTokens();
|
||
} catch (e) {
|
||
console.warn('获取当前对话标题失败:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
},
|
||
|
||
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() {
|
||
this.conversationsLoading = true;
|
||
try {
|
||
const response = await fetch(`/api/conversations?limit=${this.conversationsLimit}&offset=${this.conversationsOffset}`);
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (this.conversationsOffset === 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 {
|
||
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) {
|
||
debugLog('加载对话:', conversationId);
|
||
this.logMessageState('loadConversation:start', { conversationId });
|
||
|
||
if (conversationId === this.currentConversationId) {
|
||
debugLog('已是当前对话,跳过加载');
|
||
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);
|
||
|
||
// 2. 更新当前对话信息
|
||
this.skipConversationHistoryReload = true;
|
||
this.currentConversationId = conversationId;
|
||
this.currentConversationTitle = result.title;
|
||
this.promoteConversationToTop(conversationId);
|
||
history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`);
|
||
this.skipConversationLoadedEvent = true;
|
||
|
||
// 3. 重置UI状态
|
||
this.resetAllStates(`loadConversation:${conversationId}`);
|
||
this.subAgentFetch();
|
||
this.fetchTodoList();
|
||
|
||
// 4. 历史对话内容和Token统计由后端的 conversation_loaded 事件触发
|
||
// 不在此处重复调用,避免双重加载
|
||
// Socket.IO 的 conversation_loaded 事件会处理:
|
||
// - fetchAndDisplayHistory()
|
||
// - fetchConversationTokenStatistics()
|
||
// - updateCurrentContextTokens()
|
||
|
||
} 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'
|
||
});
|
||
}
|
||
},
|
||
|
||
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() {
|
||
if (this.historyLoading) {
|
||
debugLog('历史消息正在加载,跳过重复请求');
|
||
return;
|
||
}
|
||
this.historyLoading = true;
|
||
try {
|
||
debugLog('开始获取历史对话内容...');
|
||
this.logMessageState('fetchAndDisplayHistory:start', { conversationId: this.currentConversationId });
|
||
|
||
if (!this.currentConversationId || this.currentConversationId.startsWith('temp_')) {
|
||
debugLog('没有当前对话ID,跳过历史加载');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 使用专门的API获取对话消息历史
|
||
const messagesResponse = await fetch(`/api/conversations/${this.currentConversationId}/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;
|
||
}
|
||
|
||
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 {
|
||
this.historyLoading = false;
|
||
}
|
||
},
|
||
|
||
// ==========================================
|
||
// 关键功能:渲染历史消息
|
||
// ==========================================
|
||
renderHistoryMessages(historyMessages) {
|
||
debugLog('开始渲染历史消息...', historyMessages);
|
||
debugLog('历史消息数量:', historyMessages.length);
|
||
this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length });
|
||
|
||
if (!Array.isArray(historyMessages)) {
|
||
console.error('历史消息不是数组格式');
|
||
return;
|
||
}
|
||
|
||
let currentAssistantMessage = null;
|
||
|
||
historyMessages.forEach((message, index) => {
|
||
debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message);
|
||
|
||
if (message.role === 'user') {
|
||
// 用户消息 - 先结束之前的assistant消息
|
||
if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) {
|
||
this.messages.push(currentAssistantMessage);
|
||
currentAssistantMessage = null;
|
||
}
|
||
|
||
this.messages.push({
|
||
role: 'user',
|
||
content: message.content || ''
|
||
});
|
||
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 || '';
|
||
let reasoningText = (message.reasoning_content || '').trim();
|
||
|
||
if (!reasoningText) {
|
||
const thinkPatterns = [
|
||
/<think>([\s\S]*?)<\/think>/g,
|
||
/<thinking>([\s\S]*?)<\/thinking>/g
|
||
];
|
||
|
||
let extracted = '';
|
||
for (const pattern of thinkPatterns) {
|
||
let match;
|
||
while ((match = pattern.exec(content)) !== null) {
|
||
extracted += (match[1] || '').trim() + '\n';
|
||
}
|
||
}
|
||
reasoningText = extracted.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 || '');
|
||
|
||
let textContent = content;
|
||
if (!message.reasoning_content) {
|
||
textContent = textContent
|
||
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||
.replace(/<thinking>[\s\S]*?<\/thinking>/g, '')
|
||
.trim();
|
||
} else {
|
||
textContent = textContent.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 = {};
|
||
}
|
||
|
||
currentAssistantMessage.actions.push({
|
||
id: `history-tool-${toolCall.id || Date.now()}-${tcIndex}`,
|
||
type: 'tool',
|
||
tool: {
|
||
id: toolCall.id,
|
||
name: toolCall.function.name,
|
||
arguments: arguments_obj,
|
||
status: 'preparing',
|
||
result: null
|
||
},
|
||
timestamp: Date.now()
|
||
});
|
||
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) {
|
||
// 解析工具结果
|
||
let result;
|
||
try {
|
||
// 尝试解析为JSON
|
||
result = JSON.parse(message.content);
|
||
} catch (e) {
|
||
// 如果不是JSON,就作为纯文本
|
||
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);
|
||
}
|
||
|
||
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
|
||
this.logMessageState('renderHistoryMessages:after-render');
|
||
|
||
// 强制更新视图
|
||
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('创建新对话...');
|
||
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
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
debugLog('新对话创建成功:', result.conversation_id);
|
||
|
||
// 清空当前消息
|
||
this.logMessageState('createNewConversation:before-clear');
|
||
this.messages = [];
|
||
this.logMessageState('createNewConversation:after-clear');
|
||
this.currentConversationId = result.conversation_id;
|
||
this.currentConversationTitle = '新对话';
|
||
history.pushState({ conversationId: this.currentConversationId }, '', `/${this.stripConversationPrefix(this.currentConversationId)}`);
|
||
|
||
// 重置Token统计
|
||
this.resetTokenStatistics();
|
||
|
||
// 重置状态
|
||
this.resetAllStates('createNewConversation');
|
||
|
||
// 刷新对话列表
|
||
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 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'
|
||
});
|
||
}
|
||
},
|
||
|
||
searchConversations() {
|
||
// 简单的搜索功能,实际实现可以调用搜索API
|
||
if (this.searchTimer) {
|
||
clearTimeout(this.searchTimer);
|
||
}
|
||
|
||
this.searchTimer = setTimeout(() => {
|
||
if (this.searchQuery.trim()) {
|
||
debugLog('搜索对话:', this.searchQuery);
|
||
// TODO: 实现搜索API调用
|
||
// this.searchConversationsAPI(this.searchQuery);
|
||
} else {
|
||
// 清空搜索,重新加载全部对话
|
||
this.conversationsOffset = 0;
|
||
this.loadConversationsList();
|
||
}
|
||
}, 300);
|
||
},
|
||
|
||
handleSidebarSearch(value) {
|
||
this.searchQuery = value;
|
||
this.searchConversations();
|
||
},
|
||
|
||
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() {
|
||
this.personalizationOpenDrawer();
|
||
},
|
||
|
||
fetchTodoList() {
|
||
return this.fileFetchTodoList();
|
||
},
|
||
|
||
fetchSubAgents() {
|
||
return this.subAgentFetch();
|
||
},
|
||
|
||
async toggleThinkingMode() {
|
||
const nextMode = !this.thinkingMode;
|
||
try {
|
||
const response = await fetch('/api/thinking-mode', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ thinking_mode: nextMode })
|
||
});
|
||
const data = await response.json();
|
||
if (response.ok && data.success) {
|
||
const actual = typeof data.data === 'boolean' ? data.data : nextMode;
|
||
this.thinkingMode = actual;
|
||
return;
|
||
}
|
||
throw new Error(data.message || data.error || '切换失败');
|
||
} catch (error) {
|
||
console.error('切换思考模式失败:', error);
|
||
this.uiPushToast({
|
||
title: '切换思考模式失败',
|
||
message: error.message || '请稍后重试',
|
||
type: 'error'
|
||
});
|
||
}
|
||
},
|
||
|
||
triggerFileUpload() {
|
||
if (this.uploading) {
|
||
return;
|
||
}
|
||
const input = this.getComposerElement('fileUploadInput');
|
||
if (input) {
|
||
input.click();
|
||
}
|
||
},
|
||
|
||
handleFileSelected(files) {
|
||
this.uploadHandleSelected(files);
|
||
},
|
||
|
||
handleSendOrStop() {
|
||
if (this.streamingMessage) {
|
||
this.stopTask();
|
||
} else {
|
||
this.sendMessage();
|
||
}
|
||
},
|
||
|
||
sendMessage() {
|
||
if (this.streamingMessage || !this.isConnected) {
|
||
return;
|
||
}
|
||
|
||
if (!this.inputMessage.trim()) {
|
||
return;
|
||
}
|
||
|
||
const quotaType = this.thinkingMode ? 'thinking' : 'fast';
|
||
if (this.isQuotaExceeded(quotaType)) {
|
||
this.showQuotaToast({ type: quotaType });
|
||
return;
|
||
}
|
||
|
||
const message = this.inputMessage;
|
||
|
||
if (message.startsWith('/')) {
|
||
this.socket.emit('send_command', { command: message });
|
||
this.inputClearMessage();
|
||
this.autoResizeInput();
|
||
return;
|
||
}
|
||
|
||
this.chatAddUserMessage(message);
|
||
this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId });
|
||
this.inputClearMessage();
|
||
this.inputSetLineCount(1);
|
||
this.inputSetMultiline(false);
|
||
this.chatEnableAutoScroll();
|
||
this.scrollToBottom();
|
||
this.autoResizeInput();
|
||
|
||
// 发送消息后延迟更新当前上下文Token(关键修复:恢复原逻辑)
|
||
setTimeout(() => {
|
||
if (this.currentConversationId) {
|
||
this.updateCurrentContextTokens();
|
||
}
|
||
}, 1000);
|
||
},
|
||
|
||
// 新增:停止任务方法
|
||
stopTask() {
|
||
if (this.streamingMessage && !this.stopRequested) {
|
||
this.socket.emit('stop_task');
|
||
this.stopRequested = true;
|
||
debugLog('发送停止请求');
|
||
}
|
||
},
|
||
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
this.inputToggleQuickMenu();
|
||
},
|
||
|
||
closeQuickMenu() {
|
||
this.inputCloseMenus();
|
||
},
|
||
|
||
handleQuickUpload() {
|
||
if (this.uploading || !this.isConnected) {
|
||
return;
|
||
}
|
||
this.triggerFileUpload();
|
||
},
|
||
|
||
handleQuickModeToggle() {
|
||
if (!this.isConnected || this.streamingMessage) {
|
||
return;
|
||
}
|
||
this.toggleThinkingMode();
|
||
},
|
||
|
||
handleInputChange() {
|
||
this.autoResizeInput();
|
||
},
|
||
|
||
handleInputFocus() {
|
||
this.inputSetFocused(true);
|
||
this.closeQuickMenu();
|
||
},
|
||
|
||
handleInputBlur() {
|
||
this.inputSetFocused(false);
|
||
},
|
||
|
||
handleRealtimeTerminalClick() {
|
||
if (!this.isConnected) {
|
||
return;
|
||
}
|
||
this.openRealtimeTerminal();
|
||
},
|
||
|
||
handleFocusPanelToggleClick() {
|
||
if (!this.isConnected) {
|
||
return;
|
||
}
|
||
this.toggleFocusPanel();
|
||
},
|
||
|
||
handleTokenPanelToggleClick() {
|
||
if (!this.currentConversationId) {
|
||
return;
|
||
}
|
||
this.toggleTokenPanel();
|
||
},
|
||
|
||
handleCompressConversationClick() {
|
||
if (this.compressing || this.streamingMessage || !this.isConnected) {
|
||
return;
|
||
}
|
||
this.compressConversation();
|
||
},
|
||
|
||
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();
|
||
},
|
||
|
||
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);
|
||
},
|
||
|
||
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 : []
|
||
}));
|
||
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 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);
|
||
this.toolSetSettings(previousSnapshot);
|
||
}
|
||
} catch (error) {
|
||
console.error('更新工具设置异常:', error);
|
||
this.toolSetSettings(previousSnapshot);
|
||
}
|
||
this.toolSetSettingsLoading(false);
|
||
},
|
||
|
||
toggleSettings() {
|
||
if (!this.isConnected) {
|
||
return;
|
||
}
|
||
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();
|
||
},
|
||
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,
|
||
getToolDescription,
|
||
cloneToolArguments,
|
||
buildToolLabel,
|
||
formatSearchTopic,
|
||
formatSearchTime,
|
||
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,
|
||
QuickMenu,
|
||
InputComposer,
|
||
AppShell
|
||
};
|
||
|
||
export default appOptions;
|