This commit bundles a coordinated fix set across frontend and backend runtime flows:\n\n1) Markdown table overflow UX\n- Wrap rendered markdown tables in a dedicated horizontal-scroll container.\n- Preserve wrapper attributes through sanitize so styles always apply.\n- Keep page-level horizontal scroll disabled while allowing in-container table scroll.\n- Improve table container visual consistency (border/radius/shadow/scrollbar behavior).\n\n2) Code block horizontal jitter\n- Adjust code/pre scrollbar gutter and box model handling to remove left-right jump.\n\n3) Execution mode auto-fallback sync + toast behavior\n- Add expiry-driven frontend sync timer for direct->sandbox fallback state refresh.\n- Ensure permission/execution UI state updates at expiry time.\n- Keep fallback warning toast persistent but avoid false-positive toasts when simply switching to sandbox conversations.\n- Trigger fallback toast only when a real direct-expiry transition is detected.\n\n4) Runtime guidance/notify display consistency\n- Unify live polling rendering behavior with history replay behavior.\n- Restore real-time display of guidance/notify/sub-agent/background-command messages.\n- Keep runtime_mode_notice hidden only in idle state as requested.\n\n5) Backend runtime notice emission policy\n- Suppress runtime mode notice insertion when no task is running (idle path).\n- Preserve notice injection when task is actively running.\n\n6) Tool-call ordering safety for injected completion notices\n- Delay inline sub-agent/background-command completion message insertion until all parallel tool calls in the same assistant turn are finished.\n- Prevent invalid assistant/tool_call ordering that causes provider 400 errors for missing tool_call_id responses.\n\n7) Left panel scrolling behavior\n- Enable vertical scrolling for project files / todo / sub-agent / background-command panels.\n- Hide panel scrollbars while preserving scroll capability.\n\n8) Tavily multi-key selection support\n- Introduce config/search.py with selectable env variable name for Tavily key resolution.\n- Keep existing AGENT_TAVILY_API_KEY naming compatible.\n- Export search config through config package.\n\n9) Dedicated conversation-title model config\n- Add AGENT_TITLE_API_BASE_URL / AGENT_TITLE_API_KEY / AGENT_TITLE_MODEL_ID.\n- Route title generation calls to these dedicated credentials/model with fallback defaults.\n\n10) Supporting updates\n- Update .env.example to document new Tavily and title-generation env vars.\n- Include current custom model profile tweak (kimi-k2.6).\n\nValidated with:\n- npm run build\n- python3 -m py_compile (affected backend/config modules)\n- python3 -m unittest test.test_server_refactor_smoke
2882 lines
92 KiB
TypeScript
2882 lines
92 KiB
TypeScript
// @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.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
|
||
}
|
||
},
|
||
|
||
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;
|
||
}
|
||
};
|