agent-Specialization/static/src/app/methods/ui.ts
JOJO e2bbd767f7 feat(host-security+ui): unify host execution/approval behavior, runtime notices, and approval overlay UX
This commit consolidates a large set of host-mode security and UX fixes across backend and frontend.

Security / execution model:
- Make permission/execution mode switches apply immediately (no deferred pending-only behavior).
- Keep runtime mode change notices durable and visible via user-message guidance path.
- Add explicit runtime message source taxonomy support and persistence:
  user / presend / guidance / notify / sub_agent / background_command.
- Ensure guidance/notify insertion is batched per tool-cycle so mid-run inserts are committed together.
- Separate notify from guidance semantics for mode-change notices.
- Add direct-mode auto-fallback handling visibility on frontend.

Approval and command gating:
- Align run_command approval pre-check logic with docker behavior for approval/auto_approval modes in host execution paths.
- Keep approval retry flow and final-result semantics intact when permission errors trigger approval.
- Externalize forbidden command keyword list into JSON config:
  config/forbidden_commands.json.
- Update forbidden command rejection message to user-facing wording:
  用户不允许执行包含“... ”的指令.

Prompt/runtime guidance:
- Refine execution-mode runtime rule text to avoid endless workaround loops:
  one safe alternative first, then request switching to direct mode when truly required.

Chat rendering / message UX:
- Prevent injected guidance/notify user-messages from incorrectly triggering assistant work header/timer chain.
- Restore correct visibility after history reload while preserving runtime-source behavior.
- Update user-header icon/label rendering by message source:
  - guidance -> 引导 + navigation icon
  - notify/sub_agent/background_command -> 通知 + bell icon
  - user/presend -> keep normal user header.
- Add new icons: static/icons/navigation.svg, static/icons/bell.svg.

Approval panel UX:
- Convert desktop approval panel from layout-compress sidebar to overlay + blur sheet.
- Fix panel transition behavior (remove unintended vertical motion; use dedicated desktop overlay transition).
- Improve approval result display:
  - decision and reason split lines,
  - green/red status styling,
  - multiline reason rendering,
  - auto-collapse delay adjusted to 3s.

Execution mode TTL feedback:
- When direct mode auto-expires to sandbox, frontend menu state is updated from status snapshot.
- Show non-auto-dismiss warning toast to explicitly notify auto-fallback.

Misc consistency updates:
- Execution mode label text cleanup in composer.
- Runtime notice wording normalization (权限模式修改为..., 执行环境修改为...).

Build/test notes:
- Frontend rebuilt after UI changes.
- Smoke tests passed for server refactor path.
2026-05-12 19:16:38 +08:00

2812 lines
89 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 = {
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 payloadText = JSON.stringify({ content });
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;
}
const content = this.normalizeComposerDraftContent(payload?.data?.content || '');
this.composerDraftLastSyncedContent = content;
this.composerDraftDirty = false;
this.inputSetMessage(content);
this.$nextTick(() => {
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.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
return;
}
try {
const resp = await fetch('/api/host/workspaces');
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
}));
this.currentHostWorkspaceId = String(data.current_workspace_id || '');
this.defaultHostWorkspaceId = String(data.default_workspace_id || '');
} catch (error) {
console.warn('加载宿主机工作区失败:', error);
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
}
},
async handleHostWorkspaceSwitch(workspaceId) {
const targetId = String(workspaceId || '').trim();
if (!targetId || this.hostWorkspaceSwitching) {
return;
}
if (!this.versioningHostMode) {
return;
}
if (targetId === this.currentHostWorkspaceId) {
return;
}
this.hostWorkspaceSwitching = true;
try {
// 先在“当前工作区作用域”落盘草稿,再切换工作区。
// 否则会把旧工作区草稿误写到目标工作区作用域里。
await this.persistComposerDraftNow({
reason: 'switch-host-workspace:before-select',
force: true,
keepalive: true
}).catch(() => {});
const query = encodeURIComponent(targetId);
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '切换宿主机工作区失败');
}
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.uiPushToast({
title: '工作区已切换',
message: '正在刷新页面…',
type: 'success'
});
window.setTimeout(() => {
window.location.reload();
}, 260);
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '切换失败');
this.uiPushToast({
title: '切换工作区失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceSwitching = false;
}
},
async handleCreateHostWorkspace() {
if (!this.versioningHostMode) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateDialogOpen = true;
},
closeHostWorkspaceCreateDialog() {
if (this.hostWorkspaceCreateSubmitting) {
return;
}
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
async submitHostWorkspaceCreate() {
if (!this.versioningHostMode) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
const workspacePath = String(this.hostWorkspaceCreatePath || '').trim();
if (!workspacePath) {
this.hostWorkspaceCreateError = '路径不能为空';
return;
}
const label = String(this.hostWorkspaceCreateLabel || '').trim();
this.hostWorkspaceCreateSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const resp = await fetch('/api/host/workspaces/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
path: workspacePath,
label
})
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '新建宿主机工作区失败');
}
await this.fetchHostWorkspaces();
const createdLabel = payload?.data?.workspace?.label || label || workspacePath;
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.uiPushToast({
title: '工作区已创建',
message: createdLabel,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '新建失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: '新建工作区失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceCreateSubmitting = 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;
},
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.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 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;
}
} catch (_error) {
// ignore
}
},
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
}
},
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;
}
if (!this.conversations.length && !this.conversationsLoading) {
this.loadConversationsList();
}
const fallback = this.conversations.find((c) => c.id !== this.currentConversationId);
if (!fallback) {
this.uiPushToast({
title: '暂无可用对话',
message: '没有可供回顾的其他对话记录',
type: 'info'
});
return;
}
this.reviewSelectedConversationId = fallback.id;
this.reviewDialogOpen = true;
this.reviewPreviewLines = [];
this.reviewPreviewError = null;
this.reviewGeneratedPath = null;
this.loadReviewPreview(fallback.id);
this.closeQuickMenu();
},
closeHeaderMenu() {
this.headerMenuOpen = false;
},
handleReviewSelect(id) {
if (id === this.currentConversationId) {
this.uiPushToast({
title: '无法引用当前对话',
message: '请选择其他对话生成回顾',
type: 'warning'
});
return;
}
this.reviewSelectedConversationId = id;
this.loadReviewPreview(id);
},
async handleConfirmReview() {
if (this.reviewSubmitting) return;
if (!this.reviewSelectedConversationId) {
this.uiPushToast({
title: '请选择对话',
message: '请选择要生成回顾的对话记录',
type: 'info'
});
return;
}
if (this.reviewSelectedConversationId === this.currentConversationId) {
this.uiPushToast({
title: '无法引用当前对话',
message: '请选择其他对话生成回顾',
type: 'warning'
});
return;
}
if (!this.currentConversationId) {
this.uiPushToast({
title: '无法发送',
message: '当前没有活跃对话,无法自动发送提示消息',
type: 'warning'
});
return;
}
this.reviewSubmitting = true;
try {
const { path, char_count } = await this.generateConversationReview(
this.reviewSelectedConversationId
);
if (!path) {
throw new Error('未获取到生成的文件路径');
}
const count = typeof char_count === 'number' ? char_count : 0;
this.reviewGeneratedPath = path;
const suggestion =
count && count <= 10000 ? '建议直接完整阅读。' : '建议使用 read 工具进行搜索或分段阅读。';
if (this.reviewSendToModel) {
const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`;
const sent = 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
);
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;
if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces();
} else {
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateSubmitting = false;
treePromise = this.fileFetchTree();
}
// 获取当前对话信息
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;
}
};