agent-Specialization/static/src/app/methods/ui.ts
JOJO fc70179b02 feat(web): 统一前端设计风格并重写回顾/版本管理弹窗
- 将 8 条前端设计风格原则写入 CLAUDE.md 与 AGENTS.md
- 重写对话回顾弹窗:扁平化去圆角套娃、移除彩色光晕、固定高度、
  自定义关闭按钮、滚动条隐藏、颜色全走三模式变量
- 回顾弹窗改用独立列表 state,加载更多改为追加不刷新、不复位滚动,
  按钮样式对齐侧边栏(全宽/文字靠左/固定高度)
- 后端 get_conversation_list 新增 non_empty 参数,回顾弹窗仅显示
  有内容的对话且分页数量一致(侧边栏不受影响)
- 重写版本管理弹窗:原生 select/checkbox 换自定义下拉与开关、
  拆圆角套娃、固定高度、颜色走变量(diff 红绿语义色保留)
- 版本管理开关旁文字随状态显示开启/关闭
- icons 注册 refreshCw 键

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:30:08 +08:00

3489 lines
116 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// @ts-nocheck
import { usePolicyStore } from '../../stores/policy';
import { useModelStore } from '../../stores/model';
import { usePersonalizationStore } from '../../stores/personalization';
import { useTutorialStore } from '../../stores/tutorial';
import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer';
import {
scrollToBottom as scrollToBottomHelper,
conditionalScrollToBottom as conditionalScrollToBottomHelper,
scrollThinkingToBottom as scrollThinkingToBottomHelper
} from '../../composables/useScrollControl';
import {
startResize as startPanelResize,
handleResize as handlePanelResize,
stopResize as stopPanelResize
} from '../../composables/usePanelResize';
import { debugLog } from './common';
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;
const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/;
const userMDebug = (...args: any[]) => {
console.log('[USERMDEBUG]', ...args);
};
let uiBounceTraceCount = 0;
const UI_BOUNCE_TRACE_MAX = 140;
const uiBounceTraceLastTsByKey = new Map<string, number>();
function isUiBounceTraceEnabled() {
if (typeof window === 'undefined') return true;
try {
const explicit = (window as any).__SCROLL_BOUNCE_TRACE__;
if (explicit === false || explicit === '0') return false;
if (explicit === true || explicit === '1') return true;
const localFlag = window.localStorage?.getItem('scrollBounceTrace');
if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true;
return true;
} catch {
return true;
}
}
function uiBounceTrace(
event: string,
payload: Record<string, any> = {},
key = event,
throttleMs = 140
) {
if (!isUiBounceTraceEnabled()) return;
if (uiBounceTraceCount >= UI_BOUNCE_TRACE_MAX) return;
const now = Date.now();
const last = uiBounceTraceLastTsByKey.get(key) || 0;
if (throttleMs > 0 && now - last < throttleMs) return;
uiBounceTraceLastTsByKey.set(key, now);
uiBounceTraceCount += 1;
if (uiBounceTraceCount === UI_BOUNCE_TRACE_MAX) {
console.warn('[SCROLL_BOUNCE_TRACE_UI]', 'log-limit-reached', { max: UI_BOUNCE_TRACE_MAX });
return;
}
console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload);
}
function isConnectionDiagEnabled() {
if (typeof window === 'undefined') return true;
try {
const explicit = (window as any).__CONN_DIAG__;
if (explicit === false || explicit === '0') return false;
if (explicit === true || explicit === '1') return true;
const localFlag = window.localStorage?.getItem('connDiag');
if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true;
// 默认常开:无需手动启动
return true;
} catch {
return true;
}
}
function pushConnectionDiagRecord(record: Record<string, any>) {
if (typeof window === 'undefined') return;
try {
const w = window as any;
if (!Array.isArray(w.__CONN_DIAG_LOGS__)) {
w.__CONN_DIAG_LOGS__ = [];
}
w.__CONN_DIAG_LOGS__.push(record);
const max = 400;
if (w.__CONN_DIAG_LOGS__.length > max) {
w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - max);
}
} catch {
// ignore
}
}
function connectionDiag(
level: 'log' | 'warn' | 'error',
event: string,
payload: Record<string, any> = {},
options: { force?: boolean } = {}
) {
const force = !!options.force;
const enabled = isConnectionDiagEnabled();
if (!enabled && !force) {
return;
}
const record = {
ts: new Date().toISOString(),
event,
...payload
};
pushConnectionDiagRecord(record);
const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
logger('[CONN_DIAG]', event, record);
}
function parseSubAgentDoneLabel(rawContent: any): string | null {
const content = (rawContent || '').toString().trim();
if (!content) {
console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel: 空内容');
return null;
}
const match = content.match(SUB_AGENT_DONE_PREFIX_RE);
const isCompleted = /已完成/.test(content);
console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel', {
content,
regex: SUB_AGENT_DONE_PREFIX_RE.toString(),
matched: !!match,
matchAgentId: match?.[1],
isCompleted
});
if (!match || !isCompleted) {
return null;
}
const agentId = match[1];
return `子智能体${agentId} 任务完成`;
}
function parseBackgroundRunCommandDoneLabel(rawContent: any): string | null {
const content = (rawContent || '').toString().trim();
if (!content) {
return null;
}
if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) {
return null;
}
return '后台 run_command 完成';
}
function parseSystemNoticeLabel(rawContent: any): string | null {
const content = (rawContent || '').toString().trim();
if (!content) return null;
return parseSubAgentDoneLabel(content) || parseBackgroundRunCommandDoneLabel(content);
}
export const uiMethods = {
clearLocalTaskUiState(reason = 'safe-navigation') {
// 只清理当前浏览器视图里的运行态/工具态,不取消后端任务。
// 用于切换对话/工作区后,避免旧任务残留让新视图的发送按钮显示为“停止”。
try {
if (typeof this.stopWaitingTaskProbe === 'function') {
this.stopWaitingTaskProbe();
}
} catch (_) {}
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
this.dropToolEvents = false;
this.runtimeQueuedMessages = [];
this.runtimeGuidanceFallbackQueue = [];
this.runtimeQueueAutoSendInProgress = false;
this.runtimeQueueSyncLockKey = '';
this.runtimeQueueSyncLockUntil = 0;
this.goalModeArmed = false;
this.goalRunning = false;
this.goalProgress = null;
this.goalDialogOpen = false;
if (typeof this.clearRuntimeQueueSuppressionState === 'function') {
this.clearRuntimeQueueSuppressionState();
} else {
this.runtimeQueueSuppressedMessageIds = new Set();
this.runtimeGuidanceSuppressedTextCounts = {};
}
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools(reason);
} else {
this.preparingTools?.clear?.();
this.activeTools?.clear?.();
this.toolActionIndex?.clear?.();
this.toolStacks?.clear?.();
}
if (typeof this.chatClearThinkingLocks === 'function') {
this.chatClearThinkingLocks();
}
this.$forceUpdate?.();
},
ensureScrollListener() {
if (this._scrollListenerReady) {
return;
}
const area = this.getMessagesAreaElement();
if (!area) {
return;
}
this.initScrollListener();
this._scrollListenerReady = true;
},
handleStickStateChange(payload) {
const escaped = !!payload?.escapedFromLock;
const isAtBottom = !!payload?.isAtBottom;
const isNearBottom = !!payload?.isNearBottom;
const nearBottom = isAtBottom || isNearBottom;
const userEscaped = !!this._escapedByUserScroll;
uiBounceTrace(
'stick-state-change',
{
isAtBottom,
isNearBottom,
escapedFromLock: escaped,
userEscaped,
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
'stick-state-change',
180
);
this.stickIsAtBottom = isAtBottom;
this.stickIsNearBottom = isNearBottom;
// 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态
if (userEscaped) {
if (nearBottom) {
this._escapedByUserScroll = false;
this.chatSetScrollState({ userScrolling: false });
} else {
this.chatSetScrollState({ userScrolling: true });
}
return;
}
// 非用户触发的 escaped例如高度突增导致的短暂锚点漂移不应解除锁定
this.chatSetScrollState({ userScrolling: false });
if (!escaped) {
return;
}
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
if (!this.autoScrollEnabled || !active) {
return;
}
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') {
chatArea.conditionalStickToBottom({ force: false });
return;
}
conditionalScrollToBottomHelper(this);
},
handleUserScrollIntent(payload) {
const ts = Number(payload?.ts || Date.now());
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.stopStickScroll === 'function') {
chatArea.stopStickScroll();
}
// 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态
this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600);
this._escapedByUserScroll = true;
this.chatSetScrollState({ userScrolling: true });
uiBounceTrace(
'ui.user-scroll-intent',
{
ts,
delta: Number(payload?.delta || 0),
top: Number(payload?.top || 0),
suppressUntil: this._manualScrollSuppressUntil
},
'ui.user-scroll-intent',
80
);
},
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) {
console.log(
'[UI_DEBUG] openMobileOverlay called, target:',
target,
'isMobileViewport:',
this.isMobileViewport,
'activeMobileOverlay:',
this.activeMobileOverlay
);
if (!this.isMobileViewport) {
console.log('[UI_DEBUG] openMobileOverlay: blocked - not mobile viewport');
return;
}
if (target === 'approval') {
this.fetchPendingToolApprovals();
}
if (this.activeMobileOverlay === target) {
console.log('[UI_DEBUG] openMobileOverlay: target already active, closing');
this.closeMobileOverlay('same-target-click');
return;
}
if (this.activeMobileOverlay === 'conversation') {
this.uiSetSidebarCollapsed(true);
}
if (target === 'conversation') {
this.uiSetSidebarCollapsed(false);
}
console.log('[UI_DEBUG] openMobileOverlay: opening target:', target);
this.uiSetActiveMobileOverlay(target);
this.uiSetMobileOverlayMenuOpen(false);
},
normalizeComposerDraftContent(rawValue) {
let text = '';
if (typeof rawValue === 'string') {
text = rawValue;
} else if (rawValue === null || rawValue === undefined) {
text = '';
} else {
text = String(rawValue);
}
if (text.length > 40000) {
text = text.slice(0, 40000);
}
return text;
},
scheduleComposerDraftPersist(reason = 'input') {
const current = this.normalizeComposerDraftContent(this.inputMessage);
const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent);
const dirty = current !== lastSynced;
this.composerDraftDirty = dirty;
if (this.composerDraftSaveTimer) {
clearTimeout(this.composerDraftSaveTimer);
this.composerDraftSaveTimer = null;
}
if (!dirty) {
return;
}
this.composerDraftSaveTimer = window.setTimeout(() => {
this.persistComposerDraftNow({ reason: `debounce:${reason}` }).catch(() => {});
}, 1000);
},
async persistComposerDraftNow(options = {}) {
const reason = String(options?.reason || 'manual');
const force = !!options?.force;
const useBeacon = !!options?.useBeacon;
const content = this.normalizeComposerDraftContent(
Object.prototype.hasOwnProperty.call(options || {}, 'content')
? options.content
: this.inputMessage
);
const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent);
if (!force && content === lastSynced) {
this.composerDraftDirty = false;
return { success: true, skipped: true };
}
if (this.composerDraftSaveTimer) {
clearTimeout(this.composerDraftSaveTimer);
this.composerDraftSaveTimer = null;
}
const composerRef = typeof this.getInputComposerRef === 'function' ? this.getInputComposerRef() : null;
const composerMeta =
composerRef && typeof composerRef.getComposerDraftMeta === 'function'
? composerRef.getComposerDraftMeta()
: {};
const payloadText = JSON.stringify({
content,
editor_json:
composerMeta?.editor_json && typeof composerMeta.editor_json === 'object'
? composerMeta.editor_json
: null,
skill_refs: Array.isArray(composerMeta?.skill_refs) ? composerMeta.skill_refs : []
});
if (
useBeacon &&
typeof navigator !== 'undefined' &&
typeof navigator.sendBeacon === 'function'
) {
try {
const blob = new Blob([payloadText], { type: 'application/json' });
navigator.sendBeacon('/api/input-draft', blob);
this.composerDraftLastSyncedContent = content;
this.composerDraftDirty = false;
return { success: true, queued: true, reason };
} catch (error) {
// sendBeacon 失败时回落到 fetch
}
}
const response = await fetch('/api/input-draft', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: payloadText,
keepalive: !!options?.keepalive
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success) {
throw new Error(data?.error || '保存输入草稿失败');
}
this.composerDraftLastSyncedContent = content;
this.composerDraftDirty = false;
return { success: true, saved: true, reason };
},
async restoreComposerDraftState(reason = 'manual') {
const fetchSeq = Number(this.composerDraftFetchSeq || 0) + 1;
this.composerDraftFetchSeq = fetchSeq;
try {
const response = await fetch('/api/input-draft', {
method: 'GET',
credentials: 'same-origin'
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || '获取输入草稿失败');
}
if (Number(this.composerDraftFetchSeq || 0) !== fetchSeq) {
return;
}
if (this.taskInProgress || this.composerBusy) {
debugLog('[UI] 跳过输入草稿恢复(任务运行中)', {
reason,
taskInProgress: !!this.taskInProgress,
composerBusy: !!this.composerBusy
});
return;
}
const content = this.normalizeComposerDraftContent(payload?.data?.content || '');
const skillRefs = Array.isArray(payload?.data?.skill_refs) ? payload.data.skill_refs : [];
const editorJson =
payload?.data?.editor_json && typeof payload.data.editor_json === 'object'
? payload.data.editor_json
: null;
this.composerDraftLastSyncedContent = content;
this.composerDraftDirty = false;
this.inputSetMessage(content);
this.$nextTick(() => {
const composerRef =
typeof this.getInputComposerRef === 'function' ? this.getInputComposerRef() : null;
if (composerRef && typeof composerRef.restoreComposerDraftMeta === 'function') {
composerRef.restoreComposerDraftMeta({ skill_refs: skillRefs, editor_json: editorJson });
}
if (typeof this.autoResizeInput === 'function') {
this.autoResizeInput();
}
});
debugLog('[UI] 输入草稿已恢复', { reason, length: content.length });
} catch (error) {
console.warn('[UI] 恢复输入草稿失败:', error);
}
},
handleBeforeUnloadDraftPersist() {
this.persistComposerDraftNow({
reason: 'beforeunload',
force: true,
useBeacon: true
}).catch(() => {});
},
clearComposerDraftState(reason = 'manual') {
debugLog('[UI] 清理输入草稿状态', { reason });
this.inputClearMessage();
this.composerDraftLastSyncedContent = '';
this.composerDraftDirty = false;
if (this.composerDraftSaveTimer) {
clearTimeout(this.composerDraftSaveTimer);
this.composerDraftSaveTimer = null;
}
this.inputSetLineCount(1);
this.inputSetMultiline(false);
this.inputClearSelectedImages();
this.inputClearSelectedVideos();
this.inputSetImagePickerOpen(false);
this.inputSetVideoPickerOpen(false);
this.imageEntries = [];
this.videoEntries = [];
this.imageLoading = false;
this.videoLoading = false;
this.mediaUploading = false;
this.$nextTick(() => {
if (typeof this.autoResizeInput === 'function') {
this.autoResizeInput();
}
});
},
refreshCurrentPage() {
if (typeof window === 'undefined') {
return;
}
this.persistComposerDraftNow({
reason: 'refresh-page',
force: true,
keepalive: true
})
.catch(() => {})
.finally(() => {
window.location.reload();
});
},
handleMobileRefreshClick() {
this.uiSetMobileOverlayMenuOpen(false);
this.refreshCurrentPage();
},
closeMobileOverlay(source = 'unknown') {
console.log(
'[UI_DEBUG] closeMobileOverlay called from:',
source,
'activeMobileOverlay:',
this.activeMobileOverlay
);
if (!this.activeMobileOverlay) {
this.uiCloseMobileOverlay();
return;
}
if (this.activeMobileOverlay === 'conversation') {
this.uiSetSidebarCollapsed(true);
}
this.uiCloseMobileOverlay();
},
applyPolicyUiLocks() {
const policyStore = usePolicyStore();
const blocks = policyStore.uiBlocks;
if (blocks.collapse_workspace) {
this.uiSetWorkspaceCollapsed(true);
}
if (blocks.block_virtual_monitor && this.chatDisplayMode === 'monitor') {
this.uiSetChatDisplayMode('chat');
}
},
isPolicyBlocked(key: string, message?: string) {
const policyStore = usePolicyStore();
if (policyStore.uiBlocks[key]) {
this.uiPushToast({
title: '已被管理员禁用',
message: message || '被管理员强制禁用',
type: 'warning'
});
return true;
}
return false;
},
handleClickOutsideMobileMenu(event) {
if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) {
return;
}
const trigger = this.$refs.mobilePanelTrigger;
if (trigger && typeof trigger.contains === 'function' && trigger.contains(event.target)) {
return;
}
this.uiSetMobileOverlayMenuOpen(false);
},
handleWorkspaceToggle() {
if (this.isMobileViewport) {
return;
}
if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) {
this.uiSetWorkspaceCollapsed(true);
return;
}
const nextState = !this.workspaceCollapsed;
this.uiSetWorkspaceCollapsed(nextState);
if (nextState) {
this.uiSetPanelMenuOpen(false);
}
},
handleDisplayModeToggle() {
if (this.displayModeSwitchDisabled) {
// 显式提示管理员禁用
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用');
return;
}
if (
this.chatDisplayMode === 'chat' &&
this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用')
) {
return;
}
const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat';
this.uiSetChatDisplayMode(next);
},
handleMobileOverlayEscape(event) {
if (event.key !== 'Escape' || !this.isMobileViewport) {
return;
}
if (this.mobileOverlayMenuOpen) {
this.uiSetMobileOverlayMenuOpen(false);
return;
}
if (this.activeMobileOverlay) {
this.closeMobileOverlay();
}
},
handleMobileOverlaySelect(conversationId) {
this.loadConversation(conversationId);
this.closeMobileOverlay();
},
handleMobilePersonalClick() {
this.closeMobileOverlay();
this.uiSetMobileOverlayMenuOpen(false);
this.openPersonalPage();
},
toggleSidebar() {
if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') {
this.closeMobileOverlay();
return;
}
this.uiToggleSidebar();
},
togglePanelMenu() {
this.uiTogglePanelMenu();
},
selectPanelMode(mode) {
this.uiSetPanelMode(mode);
this.uiSetPanelMenuOpen(false);
if (mode === 'subAgents') {
// 切换到子智能体面板时立即刷新,避免等待轮询间隔
this.subAgentFetch();
this.subAgentStartPolling();
return;
}
if (mode === 'backgroundCommands') {
// 切换到后台指令面板时立即刷新,避免等待轮询间隔
this.backgroundCommandFetch();
this.backgroundCommandStartPolling();
}
},
openPersonalPage() {
if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) {
return;
}
this.personalizationOpenDrawer();
},
async checkTutorialPrompt() {
this.tutorialPromptVisible = false;
this.tutorialPromptUsername = '';
try {
const resp = await fetch('/api/tutorial-status', { credentials: 'same-origin' });
const data = await resp.json().catch(() => ({}));
console.info('[tutorial-prompt] status response:', {
ok: resp.ok,
status: resp.status,
data
});
if (!resp.ok || !data?.success) {
return;
}
const payload = data.data || {};
if (!payload.applicable || !payload.should_prompt) {
return;
}
this.tutorialPromptUsername = payload.username || '';
this.tutorialPromptVisible = true;
} catch (error) {
console.warn('获取新手教程提示状态失败:', error);
}
},
async updateTutorialPromptStatus(completed = true) {
this.tutorialPromptLoading = true;
try {
const resp = await fetch('/api/tutorial-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ tutorial_completed: !!completed })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '更新新手教程状态失败');
}
const tutorialStore = useTutorialStore();
tutorialStore.markCompleted();
this.tutorialPromptVisible = false;
return true;
} catch (error) {
console.warn('更新新手教程提示状态失败:', error);
this.uiPushToast({
title: '提示',
message: '保存新手教程状态失败,请稍后重试',
type: 'warning'
});
return false;
} finally {
this.tutorialPromptLoading = false;
}
},
async handleNewUserTutorialStart() {
const ok = await this.updateTutorialPromptStatus(true);
if (!ok) {
return;
}
const personalStore = usePersonalizationStore();
personalStore.closeDrawer();
const tutorialStore = useTutorialStore();
window.setTimeout(() => {
tutorialStore.startTutorial();
}, 220);
},
async handleNewUserTutorialSkip() {
await this.updateTutorialPromptStatus(true);
},
async fetchHostWorkspaces() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
return;
}
try {
const resp = await fetch(this.versioningHostMode ? '/api/host/workspaces' : '/api/projects');
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '获取项目列表失败');
}
const data = payload.data || {};
const items = Array.isArray(data.workspaces) ? data.workspaces : [];
this.hostWorkspaces = items.map((item: any) => ({
workspace_id: String(item.workspace_id || ''),
label: String(item.label || item.workspace_id || '未命名项目'),
path: String(item.path || ''),
is_current: !!item.is_current,
running_task_count: Number(item.running_task_count || 0)
}));
this.currentHostWorkspaceId = String(data.current_workspace_id || '');
this.defaultHostWorkspaceId = String(data.default_workspace_id || '');
await this.refreshRunningWorkspaceTasks();
} catch (error) {
console.warn('加载工作区/项目失败:', error);
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
this.runningWorkspaceTasks = [];
}
},
async refreshRunningWorkspaceTasks() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
this.runningWorkspaceTasks = [];
if (this.runningWorkspaceTasksRefreshTimer) {
clearTimeout(this.runningWorkspaceTasksRefreshTimer);
this.runningWorkspaceTasksRefreshTimer = null;
}
return;
}
try {
const resp = await fetch('/api/tasks');
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '获取运行任务失败');
}
const tasks = Array.isArray(payload.data) ? payload.data : [];
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']);
const trackedTaskIds = this.getStoredWorkspaceTaskIdSet?.('tracked') || new Set();
const acknowledged = new Set(
Array.isArray(this.acknowledgedCompletedTaskIds) ? this.acknowledgedCompletedTaskIds : []
);
const storedAcknowledged = this.getStoredWorkspaceTaskIdSet?.('acknowledged') || new Set();
storedAcknowledged.forEach((id: string) => acknowledged.add(id));
this.acknowledgedCompletedTaskIds = Array.from(acknowledged);
const previousVisibleIds = new Set(
(Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [])
.map((task: any) => String(task?.task_id || ''))
.filter(Boolean)
);
const visibleTasks = tasks.filter((task: any) => {
const taskId = String(task?.task_id || '');
const status = String(task?.status || '');
const conversationId = String(task?.conversation_id || '');
if (!taskId) return false;
if (activeStatuses.has(status)) {
trackedTaskIds.add(taskId);
return true;
}
// 如果完成的是当前正在查看的对话,用户已经“看到了”,不要再进入待查看/显示勾。
if (terminalStatuses.has(status) && conversationId === this.currentConversationId) {
if (!acknowledged.has(taskId)) {
acknowledged.add(taskId);
this.acknowledgedCompletedTaskIds = [
...(Array.isArray(this.acknowledgedCompletedTaskIds)
? this.acknowledgedCompletedTaskIds
: []),
taskId
];
}
trackedTaskIds.delete(taskId);
return false;
}
// 刚完成的任务保留为“待查看”状态,直到用户点击进入该对话。
return (
terminalStatuses.has(status) &&
(previousVisibleIds.has(taskId) || trackedTaskIds.has(taskId)) &&
!acknowledged.has(taskId)
);
});
this.runningWorkspaceTasks = visibleTasks;
visibleTasks.forEach((task: any) => {
const taskId = String(task?.task_id || '');
if (taskId) trackedTaskIds.add(taskId);
});
acknowledged.forEach((id: string) => trackedTaskIds.delete(id));
this.setStoredWorkspaceTaskIdSet?.('tracked', trackedTaskIds);
this.setStoredWorkspaceTaskIdSet?.('acknowledged', acknowledged);
const hasActiveVisibleTask = visibleTasks.some((task: any) =>
activeStatuses.has(String(task?.status || ''))
);
if (this.runningWorkspaceTasksRefreshTimer) {
clearTimeout(this.runningWorkspaceTasksRefreshTimer);
this.runningWorkspaceTasksRefreshTimer = null;
}
// 如果用户已经切到其他对话/工作区,当前运行任务没有本地轮询事件可触发列表更新。
// 这里轻量轮询任务列表,使 loader 能在后台任务完成后自动切成“待查看”的勾。
if (hasActiveVisibleTask) {
this.runningWorkspaceTasksRefreshTimer = window.setTimeout(() => {
this.runningWorkspaceTasksRefreshTimer = null;
void this.refreshRunningWorkspaceTasks?.();
}, 1500);
}
if (Array.isArray(this.hostWorkspaces)) {
const counts = visibleTasks.reduce((acc: Record<string, number>, task: any) => {
const ws = String(task?.workspace_id || '');
const status = String(task?.status || '');
if (ws && activeStatuses.has(status)) acc[ws] = (acc[ws] || 0) + 1;
return acc;
}, {});
this.hostWorkspaces = this.hostWorkspaces.map((item: any) => ({
...item,
running_task_count: Number(counts[item.workspace_id] || 0)
}));
}
} catch (error) {
console.warn('刷新运行中任务失败:', error);
this.runningWorkspaceTasks = [];
if (this.runningWorkspaceTasksRefreshTimer) {
clearTimeout(this.runningWorkspaceTasksRefreshTimer);
this.runningWorkspaceTasksRefreshTimer = null;
}
}
},
getVisibleWorkspaceTaskForConversation(conversationId) {
const id = String(conversationId || '').trim();
if (!id) return null;
const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [];
return (
tasks.find((task: any) => String(task?.conversation_id || '') === id) || null
);
},
getStoredWorkspaceTaskIdSet(kind = 'tracked') {
if (typeof window === 'undefined') return new Set();
const key =
kind === 'acknowledged'
? 'agents.hostWorkspaceTasks.acknowledged'
: 'agents.hostWorkspaceTasks.tracked';
try {
const raw = window.localStorage?.getItem(key);
const values = JSON.parse(raw || '[]');
return new Set(
(Array.isArray(values) ? values : [])
.map((item: any) => String(item || '').trim())
.filter(Boolean)
);
} catch {
return new Set();
}
},
setStoredWorkspaceTaskIdSet(kind = 'tracked', values) {
if (typeof window === 'undefined') return;
const key =
kind === 'acknowledged'
? 'agents.hostWorkspaceTasks.acknowledged'
: 'agents.hostWorkspaceTasks.tracked';
try {
window.localStorage?.setItem(
key,
JSON.stringify(Array.from(values || []).map((item: any) => String(item)))
);
} catch {
// ignore
}
},
acknowledgeCompletedWorkspaceTask(taskId) {
const id = String(taskId || '').trim();
if (!id) return;
const current = Array.isArray(this.acknowledgedCompletedTaskIds)
? this.acknowledgedCompletedTaskIds
: [];
if (!current.includes(id)) {
this.acknowledgedCompletedTaskIds = [...current, id];
}
const acknowledged = this.getStoredWorkspaceTaskIdSet?.('acknowledged') || new Set();
acknowledged.add(id);
this.setStoredWorkspaceTaskIdSet?.('acknowledged', acknowledged);
const tracked = this.getStoredWorkspaceTaskIdSet?.('tracked') || new Set();
tracked.delete(id);
this.setStoredWorkspaceTaskIdSet?.('tracked', tracked);
this.runningWorkspaceTasks = (Array.isArray(this.runningWorkspaceTasks)
? this.runningWorkspaceTasks
: []
).filter((item: any) => String(item?.task_id || '') !== id);
},
async openRunningWorkspaceTask(task) {
const workspaceId = String(task?.workspace_id || '').trim();
const conversationId = String(task?.conversation_id || '').trim();
if (!workspaceId || !conversationId) {
return;
}
if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) {
await this.handleHostWorkspaceSwitch(workspaceId);
}
if (this.currentConversationId === conversationId) {
return;
}
await this.loadConversation(conversationId, { force: true });
const taskId = String(task?.task_id || '');
if (taskId && !['pending', 'running', 'cancel_requested'].includes(String(task?.status || ''))) {
this.acknowledgeCompletedWorkspaceTask(taskId);
return;
}
// 复用刷新页面时的运行任务恢复逻辑:它会等待历史加载、按事件重建未完成的 assistant
// 消息,并重新注册思考/工具块,避免重复加载和块分裂。
await this.restoreTaskState();
},
async handleHostWorkspaceSwitch(workspaceId) {
const targetId = String(workspaceId || '').trim();
if (!targetId || this.hostWorkspaceSwitching) {
return;
}
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (targetId === this.currentHostWorkspaceId) {
return;
}
this.hostWorkspaceSwitching = true;
const previousConversationList = Array.isArray(this.conversations) ? [...this.conversations] : [];
const previousSearchActive = this.searchActive;
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;
try {
// 先在“当前工作区作用域”落盘草稿,再切换工作区。
// 否则会把旧工作区草稿误写到目标工作区作用域里。
await this.persistComposerDraftNow({
reason: 'switch-host-workspace:before-select',
force: true,
keepalive: true
}).catch(() => {});
const query = encodeURIComponent(targetId);
const selectEndpoint = this.versioningHostMode ? '/api/host/workspaces/select' : '/api/projects/select';
const resp = await fetch(`${selectEndpoint}?workspace_id=${query}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || (this.versioningHostMode ? '切换宿主机工作区失败' : '切换项目失败'));
}
const data = payload.data || {};
this.currentHostWorkspaceId = String(data.current_workspace_id || targetId);
if (data.project_path) {
this.projectPath = String(data.project_path);
}
if (data.default_workspace_id) {
this.defaultHostWorkspaceId = String(data.default_workspace_id);
}
this.messages = [];
this.currentConversationId = null;
this.currentConversationTitle = '新对话';
this.titleReady = true;
this.suppressTitleTyping = false;
try {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
taskStore.clearTask();
} catch (_) {}
this.clearLocalTaskUiState?.('switch-host-workspace');
await this.fetchHostWorkspaces();
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [])
.filter((task: any) => String(task?.workspace_id || '') === this.currentHostWorkspaceId)
.find((task: any) => activeStatuses.has(String(task?.status || '')) && task?.conversation_id);
if (activeTask?.conversation_id) {
await this.loadConversation(String(activeTask.conversation_id), { force: true });
this.conversationsOffset = 0;
await this.loadConversationsList();
} else {
this.startTitleTyping('新对话', { animate: false });
history.replaceState({}, '', '/new');
await this.loadConversationsList();
}
const switchedWorkspace = (Array.isArray(this.hostWorkspaces) ? this.hostWorkspaces : [])
.find((item: any) => String(item?.workspace_id || '') === this.currentHostWorkspaceId);
const switchedLabel = String(switchedWorkspace?.label || '').trim();
this.uiPushToast({
title: this.versioningHostMode ? '工作区已切换' : '项目已切换',
message: this.versioningHostMode
? (data.project_path || switchedLabel || targetId)
: (switchedLabel || targetId),
type: 'success'
});
} catch (error) {
this.conversations = previousConversationList;
this.searchActive = previousSearchActive;
this.conversationsLoading = false;
const message = error instanceof Error ? error.message : String(error || '切换失败');
this.uiPushToast({
title: this.versioningHostMode ? '切换工作区失败' : '切换项目失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceSwitching = false;
}
},
async handleCreateHostWorkspace() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateDialogOpen = true;
},
async openHostWorkspaceManageDialog() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
this.hostWorkspaceCreateError = '';
await this.fetchHostWorkspaces();
this.hostWorkspaceManageDialogOpen = true;
},
closeHostWorkspaceManageDialog() {
if (this.hostWorkspaceCreateSubmitting || this.hostWorkspaceManageSubmitting) {
return;
}
this.hostWorkspaceManageDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
closeHostWorkspaceCreateDialog() {
if (this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
async submitHostWorkspaceCreate() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
const workspacePath = String(this.hostWorkspaceCreatePath || '').trim();
const label = String(this.hostWorkspaceCreateLabel || '').trim();
if (this.versioningHostMode && !workspacePath) {
this.hostWorkspaceCreateError = '路径不能为空';
return;
}
if (this.dockerProjectMode && !label) {
this.hostWorkspaceCreateError = '项目名称不能为空';
return;
}
this.hostWorkspaceCreateSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const resp = await fetch(this.versioningHostMode ? '/api/host/workspaces/create' : '/api/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
path: this.versioningHostMode ? workspacePath : undefined,
label
})
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || (this.versioningHostMode ? '新建宿主机工作区失败' : '新建项目失败'));
}
await this.fetchHostWorkspaces();
const createdLabel = payload?.data?.workspace?.label || label || workspacePath;
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.uiPushToast({
title: this.versioningHostMode ? '工作区已创建' : '项目已创建',
message: createdLabel,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '新建失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: this.versioningHostMode ? '新建工作区失败' : '新建项目失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceCreateSubmitting = false;
}
},
async submitHostWorkspaceCreateFromManage(payload = {}) {
this.hostWorkspaceCreatePath = String(payload?.path || '').trim();
this.hostWorkspaceCreateLabel = String(payload?.label || '').trim();
await this.submitHostWorkspaceCreate();
},
async renameHostWorkspace(payload = {}) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const workspaceId = String(payload?.workspace_id || '').trim();
const label = String(payload?.label || '').trim();
if (!workspaceId || !label) {
this.hostWorkspaceCreateError = this.dockerProjectMode ? '项目名称不能为空' : '工作区名称不能为空';
return;
}
this.hostWorkspaceManageSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const endpoint = this.versioningHostMode
? '/api/host/workspaces/rename'
: '/api/projects/rename';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ workspace_id: workspaceId, label })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || '重命名失败');
}
await this.fetchHostWorkspaces();
this.uiPushToast({
title: this.versioningHostMode ? '工作区已重命名' : '项目已重命名',
message: label,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '重命名失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: this.versioningHostMode ? '重命名工作区失败' : '重命名项目失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceManageSubmitting = false;
}
},
async deleteHostWorkspace(item) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const workspaceId = String(item?.workspace_id || '').trim();
if (!workspaceId) return;
const kindLabel = this.versioningHostMode ? '工作区' : '项目';
const wasCurrent = workspaceId === this.currentHostWorkspaceId;
const ok = await this.confirmAction({
title: `删除${kindLabel}`,
message: this.versioningHostMode
? `确定要从列表中删除“${item?.label || workspaceId}”吗?不会删除磁盘上的工作区文件夹。`
: `确定要删除项目“${item?.label || workspaceId}”吗?该项目文件夹和对话记录会被一并删除。`,
confirmText: '删除',
cancelText: '取消',
confirmVariant: 'danger'
});
if (!ok) return;
this.hostWorkspaceManageSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const endpoint = this.versioningHostMode
? '/api/host/workspaces/delete'
: '/api/projects/delete';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ workspace_id: workspaceId })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || '删除失败');
}
const data = result.data || {};
this.currentHostWorkspaceId = String(data.current_workspace_id || this.currentHostWorkspaceId || '');
if (data.default_workspace_id) {
this.defaultHostWorkspaceId = String(data.default_workspace_id);
}
await this.fetchHostWorkspaces();
if (wasCurrent) {
this.messages = [];
this.currentConversationId = null;
this.currentConversationTitle = '新对话';
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;
history.replaceState({}, '', '/new');
await this.loadConversationsList();
}
this.uiPushToast({
title: `${kindLabel}已删除`,
message: item?.label || workspaceId,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '删除失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: `删除${kindLabel}失败`,
message,
type: 'error'
});
} finally {
this.hostWorkspaceManageSubmitting = false;
}
},
fetchTodoList() {
return this.fileFetchTodoList();
},
fetchSubAgents() {
return this.subAgentFetch();
},
async toggleThinkingMode() {
await this.handleCycleRunMode();
},
handleQuickModeToggle() {
if (!this.isConnected || this.streamingMessage) {
return;
}
this.handleCycleRunMode();
},
toggleQuickMenu() {
if (!this.isConnected) {
return;
}
const opened = this.inputToggleQuickMenu();
if (!opened) {
this.modeMenuOpen = false;
this.modelMenuOpen = false;
}
},
closeQuickMenu() {
this.inputCloseMenus();
this.modeMenuOpen = false;
this.modelMenuOpen = false;
},
// 目标模式:在 + 菜单点击“目标模式”——切换“已就绪”,运行中不可切换
handleToggleGoalMode() {
if (!this.isConnected) {
return;
}
if (this.goalRunning) {
// 运行中点击 → 打开进度弹窗,而非切换
this.goalDialogOpen = true;
this.inputCloseMenus();
return;
}
const goalStatus = String(this.goalProgress?.status || '').toLowerCase();
if (goalStatus === 'done' || goalStatus === 'stopped') {
this.goalProgress = null;
this.goalDialogOpen = false;
}
this.inputToggleGoalArmed();
this.inputCloseMenus();
this.modeMenuOpen = false;
this.modelMenuOpen = false;
},
toggleModeMenu() {
if (!this.isConnected || this.streamingMessage) {
return;
}
const next = !this.modeMenuOpen;
this.modeMenuOpen = next;
if (next) {
this.modelMenuOpen = false;
}
if (next) {
this.inputSetToolMenuOpen(false);
this.inputSetSettingsOpen(false);
if (!this.quickMenuOpen) {
this.inputOpenQuickMenu();
}
}
},
toggleModelMenu() {
if (!this.isConnected || this.streamingMessage) {
return;
}
const next = !this.modelMenuOpen;
this.modelMenuOpen = next;
if (next) {
this.modeMenuOpen = false;
this.inputSetToolMenuOpen(false);
this.inputSetSettingsOpen(false);
if (!this.quickMenuOpen) {
this.inputOpenQuickMenu();
}
}
},
toggleHeaderMenu() {
if (!this.isConnected) return;
this.headerMenuOpen = !this.headerMenuOpen;
if (this.headerMenuOpen) {
this.closeQuickMenu();
this.inputCloseMenus();
}
},
async handleModeSelect(mode) {
if (!this.isConnected || this.streamingMessage) {
return;
}
await this.setRunMode(mode);
},
async handleHeaderRunModeSelect(mode) {
await this.handleModeSelect(mode);
this.closeHeaderMenu();
},
async handleModelSelect(key) {
if (!this.isConnected || this.streamingMessage) {
return;
}
const policyStore = usePolicyStore();
if (policyStore.isModelDisabled(key)) {
this.uiPushToast({
title: '模型被禁用',
message: '被管理员强制禁用',
type: 'warning'
});
return;
}
const modelStore = useModelStore();
const targetModel = modelStore.models.find((m) => m.key === key);
if (this.conversationHasImages && !targetModel?.supportsImage) {
this.uiPushToast({
title: '切换失败',
message: '当前对话包含图片,目标模型不支持图片输入',
type: 'error'
});
return;
}
if (this.conversationHasVideos && !targetModel?.supportsVideo) {
this.uiPushToast({
title: '切换失败',
message: '当前对话包含视频,目标模型不支持视频输入',
type: 'error'
});
return;
}
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();
}
},
getPermissionModeLabel(mode) {
const options = Array.isArray(this.permissionModeOptions) ? this.permissionModeOptions : [];
const hit = options.find((item) => item.value === mode);
return hit ? hit.label : mode || '未知';
},
getExecutionModeLabel(mode) {
const options = Array.isArray(this.executionModeOptions) ? this.executionModeOptions : [];
const hit = options.find((item) => item.value === mode);
return hit ? hit.label : mode || '未知';
},
togglePermissionMenu() {
if (!this.isConnected) {
return;
}
this.permissionMenuOpen = !this.permissionMenuOpen;
},
closePermissionMenu() {
this.permissionMenuOpen = false;
},
async changePermissionMode(mode) {
const target = String(mode || '')
.trim()
.toLowerCase();
if (!target) {
this.closePermissionMenu();
return;
}
try {
const response = await fetch('/api/permission-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ mode: target })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '切换权限失败');
}
if (typeof payload?.mode === 'string') {
this.currentPermissionMode = payload.mode;
}
this.pendingPermissionMode = '';
this.uiPushToast({
title: '权限已更新',
message: payload?.message || '已立即生效',
type: 'info',
duration: 1800
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '切换权限失败');
this.uiPushToast({
title: '切换权限失败',
message: msg,
type: 'error'
});
} finally {
this.closePermissionMenu();
}
},
async changeExecutionMode(mode) {
const target = String(mode || '').trim().toLowerCase();
if (!this.executionModeEnabled || !target) {
return;
}
try {
const response = await fetch('/api/execution-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: target })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '切换执行环境失败');
}
const state = payload?.state || {};
if (typeof state.mode === 'string') {
this.currentExecutionMode = state.mode;
}
this.pendingExecutionMode = '';
this.executionModeDirectUntil = state.direct_until ?? null;
this.executionModeTtlSeconds =
typeof state.ttl_seconds === 'number' ? state.ttl_seconds : this.executionModeTtlSeconds;
this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil);
this.uiPushToast({
title: '执行环境已更新',
message: payload?.message || '已立即生效',
type: this.currentExecutionMode === 'direct' ? 'warning' : 'info',
duration: 1800
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '切换执行环境失败');
this.uiPushToast({
title: '切换执行环境失败',
message: msg,
type: 'error'
});
}
},
async handleSwitchPermissionToUnrestricted(approvalId) {
await this.changePermissionMode('unrestricted');
if (approvalId) {
await this.approveToolApproval(approvalId);
}
},
async fetchPermissionMode() {
try {
const response = await fetch('/api/permission-mode');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
if (typeof payload.mode === 'string') {
this.currentPermissionMode = payload.mode;
}
this.pendingPermissionMode = typeof payload.pending_mode === 'string' ? payload.pending_mode : '';
} catch (_error) {
// ignore
}
},
async fetchExecutionMode() {
try {
const prevExecutionMode = this.currentExecutionMode;
const prevDirectUntil = this.executionModeDirectUntil;
const response = await fetch('/api/execution-mode');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
this.executionModeEnabled = !!payload.enabled;
const state = payload.state || {};
if (typeof state.mode === 'string') {
this.currentExecutionMode = state.mode;
}
this.pendingExecutionMode = typeof payload.pending_mode === 'string' ? payload.pending_mode : '';
this.executionModeDirectUntil = state.direct_until ?? null;
if (typeof state.ttl_seconds === 'number') {
this.executionModeTtlSeconds = state.ttl_seconds;
}
this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil);
const parseDirectUntilMs = (value) => {
if (!value) return 0;
if (typeof value === 'number') {
return value > 1e12 ? value : value * 1000;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
const prevDirectUntilMs = parseDirectUntilMs(prevDirectUntil);
const fallbackLikelyByExpiry = !!prevDirectUntilMs && Date.now() >= prevDirectUntilMs - 1200;
const directExpiredToSandbox =
prevExecutionMode === 'direct' &&
this.currentExecutionMode === 'sandbox' &&
fallbackLikelyByExpiry;
if (directExpiredToSandbox) {
if (this.currentPermissionMode === 'unrestricted') {
this.currentPermissionMode = 'approval';
}
Promise.resolve(this.fetchPermissionMode()).catch(() => {});
try {
if (this.executionModeAutoFallbackToastId) {
this.uiDismissToast(this.executionModeAutoFallbackToastId);
}
} catch (_err) {}
this.executionModeAutoFallbackToastId = this.uiPushToast({
title: '执行环境已自动回退',
message: '完全访问权限已到期,已自动切回受限权限并切换为沙箱执行环境。',
type: 'warning',
duration: null
});
}
} catch (_error) {
// ignore
}
},
clearExecutionModeExpirySyncTimer() {
if (this.executionModeExpirySyncTimer) {
clearTimeout(this.executionModeExpirySyncTimer);
this.executionModeExpirySyncTimer = null;
}
},
scheduleExecutionModeExpirySync(directUntil) {
this.clearExecutionModeExpirySyncTimer();
if (!this.executionModeEnabled || this.currentExecutionMode !== 'direct' || !directUntil) {
return;
}
let untilMs = 0;
if (typeof directUntil === 'number') {
untilMs = directUntil > 1e12 ? directUntil : directUntil * 1000;
} else if (typeof directUntil === 'string') {
const parsed = Date.parse(directUntil);
if (!Number.isNaN(parsed)) {
untilMs = parsed;
}
}
if (!untilMs) {
return;
}
const delayMs = Math.max(250, untilMs - Date.now() + 250);
this.executionModeExpirySyncTimer = window.setTimeout(async () => {
this.executionModeExpirySyncTimer = null;
await this.fetchExecutionMode();
await this.fetchPermissionMode();
}, delayMs);
},
async openPathAuthorizationDialog() {
if (!this.executionModeEnabled) return;
this.pathAuthorizationDialogOpen = true;
try {
const response = await fetch('/api/path-authorization');
const payload = await response.json().catch(() => ({}));
if (response.ok && payload?.success) {
const writablePaths = Array.isArray(payload.writable_paths) ? payload.writable_paths : [];
const readableExtraPaths = Array.isArray(payload.readable_extra_paths)
? payload.readable_extra_paths
: [];
this.pathAuthorizationWritableDraft = writablePaths.join('\n');
this.pathAuthorizationReadableDraft = readableExtraPaths.join('\n');
this.pathAuthorizationMode = 'writable';
this.pathAuthorizationDraft = this.pathAuthorizationWritableDraft;
}
} catch (_error) {
// ignore
}
},
setPathAuthorizationMode(mode) {
if (this.pathAuthorizationMode === 'readable') {
this.pathAuthorizationReadableDraft = String(this.pathAuthorizationDraft || '');
} else {
this.pathAuthorizationWritableDraft = String(this.pathAuthorizationDraft || '');
}
const next = mode === 'readable' ? 'readable' : 'writable';
this.pathAuthorizationMode = next;
this.pathAuthorizationDraft =
next === 'readable' ? this.pathAuthorizationReadableDraft : this.pathAuthorizationWritableDraft;
},
closePathAuthorizationDialog() {
this.pathAuthorizationDialogOpen = false;
},
async savePathAuthorization() {
const currentLines = String(this.pathAuthorizationDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
if (this.pathAuthorizationMode === 'readable') {
this.pathAuthorizationReadableDraft = currentLines.join('\n');
} else {
this.pathAuthorizationWritableDraft = currentLines.join('\n');
}
const writableLines = String(this.pathAuthorizationWritableDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
const readableLines = String(this.pathAuthorizationReadableDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
this.pathAuthorizationSaving = true;
try {
const response = await fetch('/api/path-authorization', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
writable_paths: writableLines,
readable_extra_paths: readableLines
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || '保存失败');
}
const savedWritable = Array.isArray(payload.writable_paths) ? payload.writable_paths : writableLines;
const savedReadable = Array.isArray(payload.readable_extra_paths)
? payload.readable_extra_paths
: readableLines;
this.pathAuthorizationWritableDraft = savedWritable.join('\n');
this.pathAuthorizationReadableDraft = savedReadable.join('\n');
this.pathAuthorizationDraft =
this.pathAuthorizationMode === 'readable'
? this.pathAuthorizationReadableDraft
: this.pathAuthorizationWritableDraft;
this.uiPushToast({
title: '路径授权已保存',
message: '命令工具立即生效;终端会话请重开后生效',
type: 'success'
});
this.pathAuthorizationDialogOpen = false;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '保存失败');
this.uiPushToast({ title: '保存路径授权失败', message: msg, type: 'error' });
} finally {
this.pathAuthorizationSaving = false;
}
},
async fetchPendingToolApprovals() {
if (!this.currentConversationId) {
this.pendingToolApprovals = [];
return;
}
try {
const response = await fetch(
`/api/tool-approvals/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
const items = Array.isArray(payload.items) ? payload.items : [];
this.pendingToolApprovals = items;
// 电脑端:有审批时自动展开面板
if (items.length > 0 && !this.isMobileViewport) {
this.rightCollapsed = false;
if (this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
}
} catch (_error) {
// ignore
}
},
minimizeUserQuestionDialog() {
if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return;
}
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = true;
},
restoreUserQuestionDialog() {
if (!Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return;
}
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
},
async submitUserQuestionAnswers(answers) {
const list = Array.isArray(answers) ? answers : [];
if (!list.length) {
return;
}
const ids = list.map((item) => String(item?.question_id || '')).filter(Boolean);
this.answeringUserQuestionIds = ids;
try {
for (const answer of list) {
const questionId = String(answer?.question_id || '').trim();
if (!questionId) {
continue;
}
const response = await fetch(`/api/user-questions/${encodeURIComponent(questionId)}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
selected_option_id: answer?.selected_option_id || undefined,
text: answer?.text || ''
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '提交回答失败');
}
}
this.pendingUserQuestions = (this.pendingUserQuestions || []).filter(
(item) => item && !ids.includes(String(item.question_id || ''))
);
if (!this.pendingUserQuestions.length) {
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = false;
this.userQuestionActiveIndex = 0;
if (typeof this.restoreUserQuestionTitle === 'function') {
this.restoreUserQuestionTitle();
}
} else {
this.userQuestionActiveIndex = Math.min(this.userQuestionActiveIndex, this.pendingUserQuestions.length - 1);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '提交回答失败');
this.uiPushToast({ title: '提交回答失败', message: msg, type: 'error' });
} finally {
this.answeringUserQuestionIds = [];
}
},
async answerUserQuestionFromComposer(text) {
const clean = String(text || '').trim();
if (!clean || !Array.isArray(this.pendingUserQuestions) || !this.pendingUserQuestions.length) {
return false;
}
const index = Math.min(
Math.max(0, Number(this.userQuestionActiveIndex || 0)),
Math.max(0, this.pendingUserQuestions.length - 1)
);
const question = this.pendingUserQuestions[index] || this.pendingUserQuestions[0];
if (!question?.question_id) {
return false;
}
await this.submitUserQuestionAnswers([{ question_id: question.question_id, text: clean }]);
if (this.pendingUserQuestions.length > 0) {
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
}
return true;
},
async decideToolApproval(approvalId, decision) {
const id = String(approvalId || '').trim();
if (!id) {
return;
}
if (!Array.isArray(this.decidingApprovalIds)) {
this.decidingApprovalIds = [];
}
if (!this.decidingApprovalIds.includes(id)) {
this.decidingApprovalIds.push(id);
}
try {
const response = await fetch(`/api/tool-approvals/${encodeURIComponent(id)}/decision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ decision })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.message || payload?.error || '提交审批失败');
}
this.pendingToolApprovals = (this.pendingToolApprovals || []).filter(
(item) => item && item.approval_id !== id
);
if (!this.pendingToolApprovals.length) {
this.rightCollapsed = true;
if (this.isMobileViewport && this.activeMobileOverlay === 'approval') {
this.closeMobileOverlay();
}
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '审批失败');
this.uiPushToast({
title: '审批失败',
message: msg,
type: 'error'
});
} finally {
this.decidingApprovalIds = (this.decidingApprovalIds || []).filter((item) => item !== id);
}
},
approveToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'approved');
},
rejectToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'rejected');
},
handleInputChange() {
this.autoResizeInput();
this.scheduleComposerDraftPersist('input-change');
},
handleInputFocus() {
this.inputSetFocused(true);
this.closeQuickMenu();
this.closePermissionMenu();
},
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();
},
handleApprovalPanelToggleClick() {
console.log(
'[UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport:',
this.isMobileViewport,
'currentConversationId:',
this.currentConversationId,
'activeMobileOverlay:',
this.activeMobileOverlay
);
if (!this.currentConversationId) {
console.log('[UI_DEBUG] handleApprovalPanelToggleClick: blocked - no conversation');
return;
}
if (this.isMobileViewport) {
console.log('[UI_DEBUG] handleApprovalPanelToggleClick: mobile path, toggling overlay');
// 手机端:获取审批列表(不自动关闭),然后切换遮罩
this.fetchPendingToolApprovals();
this.openMobileOverlay('approval');
return;
}
console.log('[UI_DEBUG] handleApprovalPanelToggleClick: desktop path, toggling panel');
this.toggleApprovalPanel();
},
handleTokenPanelToggleClick(fromSettingsMenu = false) {
console.log(
'[UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu:',
fromSettingsMenu,
'isMobileViewport:',
this.isMobileViewport,
'tokenPanelCollapsed:',
this.tokenPanelCollapsed,
'currentConversationId:',
this.currentConversationId
);
if (!this.currentConversationId) {
console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - no conversation');
return;
}
if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) {
console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - policy');
return;
}
// 移动端禁用“点击展开顶部用量面板”,仅允许在已展开时点击收起
if (this.isMobileViewport && this.tokenPanelCollapsed && !fromSettingsMenu) {
console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction');
return;
}
console.log('[UI_DEBUG] handleTokenPanelToggleClick: calling toggleTokenPanel');
this.toggleTokenPanel();
},
handleCompressConversationClick() {
if (this.compressing || this.streamingMessage || !this.isConnected) {
return;
}
if (this.compressionInProgress) {
this.uiPushToast({
title: '对话自动压缩中',
message: '当前对话正在压缩,请稍后再试',
type: 'warning'
});
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;
}
this.reviewDialogOpen = true;
this.reviewSelectedConversationId = null;
this.reviewPreviewLines = [];
this.reviewPreviewError = null;
this.reviewGeneratedPath = null;
this.closeQuickMenu();
// 弹窗使用独立列表(仅含有内容的对话),加载完成后自动选中首个可用项
this.loadReviewConversations();
},
async loadReviewConversations() {
if (this.reviewListLoading) return;
this.reviewListLoading = true;
this.reviewListOffset = 0;
try {
const limit = this.reviewListLimit;
const resp = await fetch(`/api/conversations?limit=${limit}&offset=0&non_empty=1`);
const data = await resp.json().catch(() => ({}));
if (data?.success) {
this.reviewConversations = data.data.conversations || [];
this.reviewListHasMore = Boolean(data.data.has_more);
this.autoSelectReviewConversation();
} else {
this.reviewConversations = [];
this.reviewListHasMore = false;
}
} catch (error) {
console.error('加载回顾对话列表异常:', error);
this.reviewConversations = [];
this.reviewListHasMore = false;
} finally {
this.reviewListLoading = false;
}
},
async loadMoreReviewConversations() {
if (this.reviewListLoadingMore || !this.reviewListHasMore) return;
this.reviewListLoadingMore = true;
try {
const limit = this.reviewListLimit;
const offset = this.reviewListOffset + limit;
const resp = await fetch(`/api/conversations?limit=${limit}&offset=${offset}&non_empty=1`);
const data = await resp.json().catch(() => ({}));
if (data?.success) {
// 追加到现有列表,不整体替换,避免滚动位置复位
this.reviewConversations.push(...(data.data.conversations || []));
this.reviewListHasMore = Boolean(data.data.has_more);
this.reviewListOffset = offset;
this.autoSelectReviewConversation();
}
} catch (error) {
console.error('加载更多回顾对话异常:', error);
} finally {
this.reviewListLoadingMore = false;
}
},
autoSelectReviewConversation() {
if (this.reviewSelectedConversationId) return;
const fallback = this.reviewConversations.find((c) => c.id !== this.currentConversationId);
if (fallback && fallback.id) {
this.reviewSelectedConversationId = fallback.id;
this.loadReviewPreview(fallback.id);
}
},
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 = await this.sendAutoUserMessage(message);
if (sent) {
this.reviewDialogOpen = false;
}
} else {
this.uiPushToast({
title: '回顾文件已生成',
message: path,
type: 'success'
});
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '生成失败');
this.uiPushToast({
title: '生成回顾失败',
message: msg,
type: 'error'
});
} finally {
this.reviewSubmitting = false;
}
},
async loadReviewPreview(conversationId) {
this.reviewPreviewLoading = true;
this.reviewPreviewError = null;
this.reviewPreviewLines = [];
try {
const resp = await fetch(
`/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}`
);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
const msg = payload?.message || payload?.error || '获取预览失败';
throw new Error(msg);
}
this.reviewPreviewLines = payload?.data?.preview || [];
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '获取预览失败');
this.reviewPreviewError = msg;
} finally {
this.reviewPreviewLoading = false;
}
},
async generateConversationReview(conversationId) {
const response = await fetch(`/api/conversations/${conversationId}/review`, {
method: 'POST'
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
const msg = payload?.message || payload?.error || '生成失败';
throw new Error(msg);
}
const data = payload.data || payload;
return {
path: data.path || data.file_path || data.relative_path,
char_count: data.char_count ?? data.length ?? data.size ?? 0
};
},
handleClickOutsideQuickMenu(event) {
if (!this.quickMenuOpen && !this.permissionMenuOpen) {
return;
}
const shell =
this.getComposerElement('stadiumShellOuter') || this.getComposerElement('compactInputShell');
if (shell && shell.contains(event.target)) {
return;
}
this.closeQuickMenu();
this.closePermissionMenu();
},
handleClickOutsideHeaderMenu(event) {
if (!this.headerMenuOpen) return;
const ribbon = this.$refs.titleRibbon as HTMLElement | undefined;
const menu = this.$refs.headerMenu as HTMLElement | undefined;
if ((ribbon && ribbon.contains(event.target)) || (menu && menu.contains(event.target))) {
return;
}
this.closeHeaderMenu();
},
handleClickOutsidePanelMenu(event) {
if (!this.panelMenuOpen) {
return;
}
const leftPanel = this.$refs.leftPanel;
const wrapper = leftPanel && leftPanel.panelMenuWrapper ? leftPanel.panelMenuWrapper : null;
if (wrapper && wrapper.contains(event.target)) {
return;
}
this.uiSetPanelMenuOpen(false);
},
isConversationBlank() {
if (!Array.isArray(this.messages) || !this.messages.length) return true;
return !this.messages.some((msg) => msg && (msg.role === 'user' || msg.role === 'assistant'));
},
pickWelcomeText() {
const pool = this.blankWelcomePool;
if (!Array.isArray(pool) || !pool.length) {
this.blankWelcomeText = '有什么可以帮忙的?';
return;
}
const idx = Math.floor(Math.random() * pool.length);
this.blankWelcomeText = pool[idx];
},
refreshBlankHeroState() {
const isBlank = this.isConversationBlank();
const currentConv = this.currentConversationId || 'temp';
const needNewWelcome = !this.blankHeroActive || this.lastBlankConversationId !== currentConv;
if (isBlank) {
if (needNewWelcome && !this.blankHeroExiting) {
this.pickWelcomeText();
}
this.blankHeroActive = true;
this.lastBlankConversationId = currentConv;
} else {
this.blankHeroActive = false;
this.blankHeroExiting = false;
this.lastBlankConversationId = null;
}
},
toggleSettings() {
if (!this.isConnected) {
return;
}
this.modeMenuOpen = false;
this.modelMenuOpen = false;
const nextState = this.inputToggleSettingsMenu();
if (nextState) {
this.inputSetToolMenuOpen(false);
if (!this.quickMenuOpen) {
this.inputOpenQuickMenu();
}
}
},
toggleFocusPanel() {
this.rightCollapsed = !this.rightCollapsed;
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
},
toggleApprovalPanel() {
console.log(
'[UI_DEBUG] toggleApprovalPanel called, current rightCollapsed:',
this.rightCollapsed,
'new state:',
!this.rightCollapsed
);
this.rightCollapsed = !this.rightCollapsed;
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth;
}
if (!this.rightCollapsed) {
this.fetchPendingToolApprovals();
}
},
addSystemMessage(content) {
userMDebug('ui.addSystemMessage:incoming', { content });
const systemNoticeLabel = parseSystemNoticeLabel(content);
if (!systemNoticeLabel) {
// 其他 system 消息全部隐藏,且不进入渲染链路,避免出现空白块
userMDebug('ui.addSystemMessage:blocked', { content });
return;
}
userMDebug('ui.addSystemMessage:accepted', {
original: content,
normalizedLabel: systemNoticeLabel
});
this.chatAddSystemMessage(systemNoticeLabel, { variant: 'sub_agent_done' });
this.$forceUpdate();
this.conditionalScrollToBottom();
},
appendSystemAction(content) {
this.addSystemMessage(content);
},
startTitleTyping(title: string, options: { animate?: boolean } = {}) {
if (this.titleTypingTimer) {
clearInterval(this.titleTypingTimer);
this.titleTypingTimer = null;
}
const target = (title || '').trim();
const animate = options.animate ?? true;
if (!animate) {
this.titleTypingTarget = target;
this.titleTypingText = target;
return;
}
const previous = (this.titleTypingText || '').trim();
if (previous === target) {
this.titleTypingTarget = target;
this.titleTypingText = target;
return;
}
this.titleTypingTarget = target;
this.titleTypingText = previous;
const frames: string[] = [];
for (let i = previous.length; i >= 0; i--) {
frames.push(previous.slice(0, i));
}
for (let j = 1; j <= target.length; j++) {
frames.push(target.slice(0, j));
}
let index = 0;
this.titleTypingTimer = window.setInterval(() => {
if (index >= frames.length) {
clearInterval(this.titleTypingTimer!);
this.titleTypingTimer = null;
this.titleTypingText = target;
return;
}
this.titleTypingText = frames[index];
index += 1;
}, 32);
},
toggleBlock(blockId) {
if (!blockId) {
return;
}
if (this.expandedBlocks && this.expandedBlocks.has(blockId)) {
this.chatCollapseBlock(blockId);
} else {
this.chatExpandBlock(blockId);
}
},
handleThinkingScroll(blockId, event) {
if (!blockId || !event || !event.target) {
return;
}
const el = event.target;
const threshold = 12;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
this.chatSetThinkingLock(blockId, atBottom);
},
scrollToBottom() {
uiBounceTrace(
'ui.scrollToBottom:called',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
'ui.scrollToBottom:called',
80
);
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.scrollToBottom === 'function') {
chatArea.scrollToBottom({
behavior: 'auto',
force: this.autoScrollEnabled
});
this.chatSetScrollState({ userScrolling: false });
return;
}
scrollToBottomHelper(this);
},
conditionalScrollToBottom() {
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
const chatArea = this.getChatAreaController();
const stickState =
chatArea && typeof chatArea.getStickState === 'function' ? chatArea.getStickState() : null;
uiBounceTrace(
'ui.conditionalScrollToBottom:called',
{
active,
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling,
stickIsNearBottom: this.stickIsNearBottom,
escapedFromLock: !!stickState?.escapedFromLock
},
'ui.conditionalScrollToBottom:called',
120
);
if (!active) {
return;
}
const now = Date.now();
if (now <= (this._manualScrollSuppressUntil || 0)) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-manual-cooldown',
{
now,
suppressUntil: this._manualScrollSuppressUntil
},
'ui.conditionalScrollToBottom:skip-manual-cooldown',
120
);
return;
}
// 仅在“确认是用户主动脱离锁定”时才阻断自动追底;
// 对于内容高度突变导致的 escapedFromLock不应长期卡住锁定。
if (stickState?.escapedFromLock && this._escapedByUserScroll) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-escaped-lock',
{
escapedFromLock: !!stickState?.escapedFromLock,
escapedByUser: !!this._escapedByUserScroll,
isNearBottom: !!stickState?.isNearBottom,
isAtBottom: !!stickState?.isAtBottom
},
'ui.conditionalScrollToBottom:skip-escaped-lock',
120
);
return;
}
// 只要用户处于手动滚动状态,就停止自动追底,避免“被弹回”
// stickIsNearBottom 在 show_html 动态重排时可能短暂失真,不可作为阻断前置条件)
if (this.userScrolling) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-user-scrolling',
{
userScrolling: this.userScrolling,
stickIsNearBottom: this.stickIsNearBottom
},
'ui.conditionalScrollToBottom:skip-user-scrolling',
120
);
return;
}
if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') {
chatArea.conditionalStickToBottom({ force: false });
return;
}
conditionalScrollToBottomHelper(this);
},
toggleScrollLock() {
uiBounceTrace(
'ui.scrollToBottomButton:clicked',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling,
stickNearBottom: this.stickIsNearBottom
},
'ui.scrollToBottomButton:clicked',
0
);
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.scrollToBottom === 'function') {
chatArea.scrollToBottom({ behavior: 'smooth', force: true });
} else {
scrollToBottomHelper(this, {
ignoreUserScrolling: true,
resetUserScrolling: true,
behavior: 'smooth',
force: true
});
}
this._escapedByUserScroll = false;
this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false });
return true;
},
scrollThinkingToBottom(blockId) {
scrollThinkingToBottomHelper(this, blockId);
},
// 面板调整方法
startResize(panel, event) {
startPanelResize(this, panel, event);
},
handleResize(event) {
handlePanelResize(this, event);
},
stopResize() {
stopPanelResize(this);
},
getInputComposerRef() {
return this.$refs.inputComposer || null;
},
handleComposerHeightChange(payload = {}) {
const raw =
Number(payload?.reservedHeight || 0) ||
Number(payload?.height || 0) ||
Number(payload?.composerHeight || 0);
if (!Number.isFinite(raw) || raw <= 0) {
return;
}
const next = Math.max(80, Math.min(560, Math.round(raw)));
if (Math.abs(next - Number(this.composerReservedHeight || 0)) < 1) {
return;
}
this.composerReservedHeight = next;
},
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;
},
getChatAreaController() {
const ref = this.$refs.messagesArea;
if (!ref) return null;
if (typeof ref === 'object') return ref;
return null;
},
getThinkingContentElement(blockId) {
const chatArea = this.$refs.messagesArea;
if (chatArea && typeof chatArea.getThinkingRef === 'function') {
const el = chatArea.getThinkingRef(blockId);
if (el) {
return el;
}
}
const refName = `thinkingContent-${blockId}`;
const elRef = this.$refs[refName];
if (Array.isArray(elRef)) {
return elRef[0] || null;
}
return elRef || null;
},
isOutputActive() {
return !!(this.streamingMessage || this.taskInProgress || this.hasPendingToolActions());
},
logMessageState(action, extra = {}) {
const count = Array.isArray(this.messages) ? this.messages.length : 'N/A';
debugLog('[Messages]', {
action,
count,
conversationId: this.currentConversationId,
streaming: this.streamingMessage,
...extra
});
},
iconStyle(iconKey, size) {
const iconPath = this.icons ? this.icons[iconKey] : null;
if (!iconPath) {
return {};
}
const style = { '--icon-src': `url(${iconPath})` };
if (size) {
style['--icon-size'] = size;
}
return style;
},
openGuiFileManager() {
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
return;
}
window.open('/file-manager', '_blank');
},
renderMarkdown(content, isStreaming = false) {
return renderMarkdownHelper(content, isStreaming);
},
isExplicitNewConversationRoute() {
const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, '');
return normalizedPath === 'new';
},
async getRunningTaskConversationId() {
try {
const resp = await fetch('/api/tasks');
if (!resp.ok) {
return null;
}
const payload = await resp.json();
const tasks = Array.isArray(payload?.data) ? payload.data : [];
const runningTask = tasks.find(
(task: any) =>
task?.status === 'running' &&
task?.conversation_id &&
(!(this.versioningHostMode || this.dockerProjectMode) ||
!this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId)
);
return runningTask?.conversation_id || null;
} catch (error) {
console.warn('获取运行中任务失败:', error);
return null;
}
},
async bootstrapRoute() {
// 在路由解析期间抑制标题动画,避免预置"新对话"闪烁
this.suppressTitleTyping = true;
this.titleReady = false;
this.currentConversationTitle = '';
this.titleTypingText = '';
const path = window.location.pathname.replace(/^\/+/, '');
if (!path || this.isExplicitNewConversationRoute()) {
this.currentConversationId = null;
this.currentConversationTitle = '新对话';
this.logMessageState('bootstrapRoute:clear-messages-for-new');
this.messages = [];
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping('新对话', { animate: false });
this.initialRouteResolved = true;
this.refreshBlankHeroState();
await this.restoreComposerDraftState('bootstrap-route:new');
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) {
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode =
typeof result.thinking_mode === 'boolean'
? result.thinking_mode
: result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) {
this.modelSet(result.model_key);
}
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;
}
await this.restoreComposerDraftState('bootstrap-route:conversation');
},
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();
this.restoreComposerDraftState('popstate:new').catch(() => {});
return;
}
this.loadConversation(convId);
},
stripConversationPrefix(conversationId) {
if (!conversationId) return '';
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;
},
initScrollListener() {
const chatArea = this.getChatAreaController();
if (
chatArea &&
typeof chatArea.isUsingStickToBottom === 'function' &&
chatArea.isUsingStickToBottom()
) {
this._scrollListenerReady = true;
return;
}
const messagesArea = this.getMessagesAreaElement();
if (!messagesArea) {
console.warn('消息区域未找到');
return;
}
this._scrollListenerReady = true;
let scrollDebugCount = 0;
const SCROLL_DEBUG_MAX = 1200;
let scrollDebugLastTs = 0;
const isScrollDebugEnabled = () => {
if (typeof window === 'undefined') return false;
const until = Number((window as any).__SHOW_TAG_DRAWING_UNTIL__ || 0);
return Date.now() <= until;
};
const scrollDebug = (event, payload = {}, throttleMs = 80) => {
if (!isScrollDebugEnabled()) {
return;
}
if (scrollDebugCount >= SCROLL_DEBUG_MAX) {
return;
}
const now = Date.now();
if (throttleMs > 0 && now - scrollDebugLastTs < throttleMs) {
return;
}
scrollDebugLastTs = now;
scrollDebugCount += 1;
if (scrollDebugCount === SCROLL_DEBUG_MAX) {
console.warn('[SCROLL_DEBUG]', 'listener:log-limit-reached', { max: SCROLL_DEBUG_MAX });
return;
}
console.log('[SCROLL_DEBUG]', event, payload);
};
scrollDebug(
'listener:setup',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
0
);
let isProgrammaticScroll = false;
const bottomThreshold = 12;
this._setScrollingFlag = (flag) => {
isProgrammaticScroll = !!flag;
scrollDebug('listener:programmatic-flag', { flag: !!flag }, 0);
};
messagesArea.addEventListener('scroll', () => {
if (isProgrammaticScroll) {
scrollDebug(
'listener:scroll:ignore-programmatic',
{
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
120
);
return;
}
const scrollTop = messagesArea.scrollTop;
const scrollHeight = messagesArea.scrollHeight;
const clientHeight = messagesArea.clientHeight;
const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold;
const nearBottomThreshold = 48;
const isNearBottom = scrollHeight - scrollTop - clientHeight < nearBottomThreshold;
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
if (this.autoScrollEnabled && active) {
// 锁定模式下不让 userScrolling 状态来回抖动
this.chatSetScrollState({ userScrolling: false });
scrollDebug('listener:scroll:locked-keep-bottom-state', {
top: scrollTop,
height: scrollHeight,
client: clientHeight,
remain: scrollHeight - scrollTop - clientHeight,
isAtBottom,
isNearBottom
});
return;
}
// 仅维护状态,不在 scroll 事件中强制写 scrollTop避免上下抖动
this.chatSetScrollState({ userScrolling: !(isAtBottom || isNearBottom) });
scrollDebug('listener:scroll:update-state', {
top: scrollTop,
height: scrollHeight,
client: clientHeight,
remain: scrollHeight - scrollTop - clientHeight,
isAtBottom,
isNearBottom,
autoScrollEnabled: this.autoScrollEnabled,
userScrollingBefore: this.userScrolling,
userScrollingNext: !(isAtBottom || isNearBottom)
});
});
},
async initSocket() {
// 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用
this.socket = null;
},
async checkConnectionHealth() {
if (this.connectionHeartbeatInFlight) {
connectionDiag('log', 'health-skip-inflight', {
seq: this.connectionHeartbeatSeq,
isConnected: !!this.isConnected,
failCount: this.connectionHeartbeatFailCount
});
return;
}
this.connectionHeartbeatInFlight = true;
const seq = Number(this.connectionHeartbeatSeq || 0) + 1;
this.connectionHeartbeatSeq = seq;
const requestId = `${Date.now()}-${seq}`;
const startedAt = Date.now();
const diagEnabled = isConnectionDiagEnabled();
const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health';
const wasConnected = !!this.isConnected;
let responseStatus: number | null = null;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeoutMs =
typeof this.connectionHeartbeatRequestTimeoutMs === 'number' &&
this.connectionHeartbeatRequestTimeoutMs > 0
? this.connectionHeartbeatRequestTimeoutMs
: 5000;
const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null;
try {
const response = await fetch(healthUrl, {
method: 'GET',
cache: 'no-store',
headers: {
'X-Connection-Heartbeat': requestId
},
signal: controller?.signal
});
responseStatus = response.status;
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0;
this.isConnected = true;
this.connectionHeartbeatFailCount = 0;
this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt;
this.connectionHeartbeatLastStatusCode = responseStatus;
this.connectionHeartbeatLastError = '';
if (!wasConnected) {
this.connectionHeartbeatLastChangeAt = Date.now();
connectionDiag(
'warn',
'health-recovered',
{
requestId,
seq,
elapsedMs: this.connectionHeartbeatLastLatencyMs,
failCountBeforeRecover,
status: responseStatus,
diagEnabled,
endpoint: healthUrl,
conversationId: this.currentConversationId || null,
taskInProgress: !!this.taskInProgress,
streamingMessage: !!this.streamingMessage,
visibility: document?.visibilityState || 'unknown',
online: typeof navigator !== 'undefined' ? navigator.onLine : null
},
{ force: true }
);
} else if (
isConnectionDiagEnabled() &&
(this.connectionHeartbeatLastLatencyMs >= 700 || seq <= 3 || seq % 60 === 0)
) {
connectionDiag('log', 'health-ok', {
requestId,
seq,
elapsedMs: this.connectionHeartbeatLastLatencyMs,
status: responseStatus,
endpoint: healthUrl,
visibility: document?.visibilityState || 'unknown'
});
}
} catch (error) {
const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1;
this.connectionHeartbeatFailCount = nextFailCount;
this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt;
this.connectionHeartbeatLastStatusCode = responseStatus;
const errName = error?.name || 'Error';
const errMessage = error?.message || String(error);
this.connectionHeartbeatLastError = `${errName}: ${errMessage}`;
const failThreshold =
typeof this.connectionHeartbeatFailThreshold === 'number' &&
this.connectionHeartbeatFailThreshold > 0
? this.connectionHeartbeatFailThreshold
: 3;
const shouldDisconnect = nextFailCount >= failThreshold;
// 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连”
if (shouldDisconnect) {
this.isConnected = false;
}
if (wasConnected && shouldDisconnect) {
this.connectionHeartbeatLastChangeAt = Date.now();
}
const shouldLogFail =
wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0;
connectionDiag(
shouldLogFail ? 'warn' : 'log',
'health-failed',
{
requestId,
seq,
elapsedMs: this.connectionHeartbeatLastLatencyMs,
failCount: nextFailCount,
status: responseStatus,
endpoint: healthUrl,
diagEnabled,
wasConnected,
nowConnected: !!this.isConnected,
failThreshold,
shouldDisconnect,
errorName: errName,
errorMessage: errMessage,
timeout: errName === 'AbortError',
visibility: document?.visibilityState || 'unknown',
online: typeof navigator !== 'undefined' ? navigator.onLine : null,
conversationId: this.currentConversationId || null,
taskInProgress: !!this.taskInProgress,
streamingMessage: !!this.streamingMessage,
hasPendingTools:
typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null
},
{ force: shouldLogFail }
);
} finally {
this.connectionHeartbeatInFlight = false;
if (timeoutId) {
clearTimeout(timeoutId);
}
}
},
startConnectionHeartbeat() {
if (this.connectionHeartbeatActive) {
return;
}
this.connectionHeartbeatActive = true;
this.connectionHeartbeatFailCount = 0;
connectionDiag(
'log',
'heartbeat-start',
{
connectedIntervalMs: this.connectionHeartbeatIntervalMs,
disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs
},
{ force: true }
);
const runHeartbeat = async () => {
if (!this.connectionHeartbeatActive) {
return;
}
await this.checkConnectionHealth();
if (!this.connectionHeartbeatActive) {
return;
}
const connectedInterval =
typeof this.connectionHeartbeatIntervalMs === 'number' &&
this.connectionHeartbeatIntervalMs > 0
? this.connectionHeartbeatIntervalMs
: 8000;
const disconnectedInterval =
typeof this.connectionHeartbeatDisconnectedIntervalMs === 'number' &&
this.connectionHeartbeatDisconnectedIntervalMs > 0
? this.connectionHeartbeatDisconnectedIntervalMs
: 1000;
const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval;
if (
isConnectionDiagEnabled() &&
(!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3)
) {
connectionDiag('log', 'heartbeat-next', {
seq: this.connectionHeartbeatSeq,
isConnected: !!this.isConnected,
failCount: this.connectionHeartbeatFailCount,
nextInterval
});
}
this.connectionHeartbeatTimer = window.setTimeout(() => {
runHeartbeat();
}, nextInterval);
};
// 先做一次即时探活,再根据连接状态动态设置下次轮询间隔
runHeartbeat();
},
stopConnectionHeartbeat() {
this.connectionHeartbeatActive = false;
if (this.connectionHeartbeatTimer) {
clearTimeout(this.connectionHeartbeatTimer);
this.connectionHeartbeatTimer = null;
}
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
},
openRealtimeTerminal() {
const { protocol, hostname, port } = window.location;
const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`;
window.open(target, '_blank');
},
async loadInitialData() {
try {
debugLog('加载初始数据...');
const statusResponse = await fetch('/api/status');
if (!statusResponse.ok) {
throw new Error(`状态接口请求失败: ${statusResponse.status}`);
}
const statusData = await statusResponse.json();
this.isConnected = true;
this.socket = null;
this.projectPath = statusData.project_path || '';
this.agentVersion = statusData.version || this.agentVersion;
this.thinkingMode = !!statusData.thinking_mode;
this.applyStatusSnapshot(statusData);
this.fetchPermissionMode();
this.fetchExecutionMode();
this.fetchPendingToolApprovals();
// 立即更新配额和运行模式,避免等待其他慢接口
this.fetchUsageQuota();
const modelStore = useModelStore();
await modelStore.fetchModels().catch((err) => {
console.warn('加载模型列表失败,使用前端默认列表:', err);
});
if (statusData && typeof statusData.model_key === 'string') {
modelStore.setModel(statusData.model_key);
this.currentModelKey = modelStore.currentModelKey;
}
// 拉取管理员策略
const policyStore = usePolicyStore();
await policyStore.fetchPolicy();
this.applyPolicyUiLocks();
// 加载个性化设置
const personalizationStore = usePersonalizationStore();
if (!personalizationStore.loaded && !personalizationStore.loading) {
await personalizationStore.fetchPersonalization().catch((err) => {
console.warn('加载个性化设置失败:', err);
});
}
// 仅在“无当前对话”的新会话入口应用默认模型/模式;
// 对于已存在对话(含刷新恢复/URL指定对话必须保留对话自身设置。
const statusConversationIdForDefaults = statusData?.conversation?.current_id;
const hasActiveConversation =
typeof statusConversationIdForDefaults === 'string' &&
statusConversationIdForDefaults.length > 0 &&
!statusConversationIdForDefaults.startsWith('temp_');
if (personalizationStore.loaded && !hasActiveConversation && !this.currentConversationId) {
const defaultRunMode = personalizationStore.form.default_run_mode;
const defaultModel = personalizationStore.form.default_model;
if (defaultRunMode) {
this.runMode = defaultRunMode;
debugLog('应用默认运行模式:', defaultRunMode);
}
if (defaultModel) {
modelStore.setModel(defaultModel);
this.currentModelKey = modelStore.currentModelKey;
debugLog('应用默认模型:', defaultModel);
}
// 根据默认运行模式设置思考模式
if (defaultRunMode === 'thinking') {
this.thinkingMode = true;
} else if (defaultRunMode === 'fast') {
this.thinkingMode = false;
}
}
const focusPromise = this.focusFetchFiles();
let treePromise: Promise<any> | null = null;
const isHostMode = statusData?.container?.mode === 'host';
this.versioningHostMode = !!isHostMode;
this.dockerProjectMode = !isHostMode;
if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces();
} else {
this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
await this.fetchHostWorkspaces();
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateSubmitting = false;
}
// 获取当前对话信息
const isExplicitNewRoute = this.isExplicitNewConversationRoute();
const runningConversationId = await this.getRunningTaskConversationId();
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
const resumeConversationId = statusConversationId || runningConversationId;
const shouldResumeOnNewRoute = isExplicitNewRoute && !!runningConversationId;
if (
resumeConversationId &&
!this.currentConversationId &&
(!isExplicitNewRoute || shouldResumeOnNewRoute)
) {
this.skipConversationHistoryReload = true;
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载
this.skipConversationLoadedEvent = true;
this.suppressTitleTyping = true;
this.titleReady = false;
this.currentConversationTitle = '';
this.titleTypingText = '';
this.currentConversationId = resumeConversationId;
const pathFragment = this.stripConversationPrefix(resumeConversationId);
const currentPath = window.location.pathname.replace(/^\/+/, '');
if (currentPath !== pathFragment) {
history.replaceState({ conversationId: resumeConversationId }, '', `/${pathFragment}`);
}
// 如果有当前对话,尝试获取标题和历史
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();
await this.fetchVersioningStatus(this.currentConversationId, { silent: true });
} catch (e) {
console.warn('获取当前对话标题失败:', e);
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle || '新对话', '');
}
}
// 待办数据依赖当前会话上下文,放在会话恢复/切换之后拉取,避免初始化早期拿到空快照
const todoPromise = this.fileFetchTodoList();
// 等待其他加载项完成(允许部分失败不阻塞模式切换)
const pendingPromises = [focusPromise, todoPromise];
if (treePromise) {
pendingPromises.push(treePromise);
}
await Promise.allSettled(pendingPromises);
await this.loadToolSettings(true);
// 加载对话列表修复初始化时未加载对话列表的bug
this.conversationsOffset = 0;
await this.loadConversationsList();
debugLog('初始数据加载完成');
} catch (error) {
console.error('加载初始数据失败:', error);
this.isConnected = false;
}
},
confirmAction(options = {}) {
return this.uiRequestConfirm(options);
},
async handleCopyCodeClick(event) {
const target = event.target;
if (!target || !target.classList || !target.classList.contains('copy-code-btn')) {
return;
}
const blockId = target.getAttribute('data-code');
if (!blockId) {
return;
}
// 防止重复点击
if (target.classList.contains('copied')) {
return;
}
const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`;
const codeEl = document.querySelector(selector);
if (!codeEl) {
return;
}
const encoded = codeEl.getAttribute('data-original-code');
const content = encoded ? this.decodeHtmlEntities(encoded) : codeEl.textContent || '';
if (!content.trim()) {
return;
}
try {
await navigator.clipboard.writeText(content);
// 添加 copied 类,切换为对勾图标
target.classList.add('copied');
// 5秒后恢复
setTimeout(() => {
target.classList.remove('copied');
}, 5000);
} catch (error) {
console.warn('复制失败:', error);
}
},
decodeHtmlEntities(text) {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
};