2271 lines
70 KiB
TypeScript
2271 lines
70 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 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 {
|
||
return parseSubAgentDoneLabel(rawContent) || parseBackgroundRunCommandDoneLabel(rawContent);
|
||
}
|
||
|
||
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);
|
||
},
|
||
|
||
refreshCurrentPage() {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
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 {
|
||
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 || '未知';
|
||
},
|
||
|
||
togglePermissionMenu() {
|
||
if (!this.isConnected) {
|
||
return;
|
||
}
|
||
this.permissionMenuOpen = !this.permissionMenuOpen;
|
||
},
|
||
|
||
closePermissionMenu() {
|
||
this.permissionMenuOpen = false;
|
||
},
|
||
|
||
async changePermissionMode(mode) {
|
||
const target = String(mode || '')
|
||
.trim()
|
||
.toLowerCase();
|
||
if (!target || target === this.currentPermissionMode) {
|
||
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 || '切换权限失败');
|
||
}
|
||
this.currentPermissionMode = payload.mode || target;
|
||
this.uiPushToast({
|
||
title: '权限已切换',
|
||
message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`,
|
||
type: 'success',
|
||
duration: 1800
|
||
});
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : String(error || '切换权限失败');
|
||
this.uiPushToast({
|
||
title: '切换权限失败',
|
||
message: msg,
|
||
type: 'error'
|
||
});
|
||
} finally {
|
||
this.closePermissionMenu();
|
||
}
|
||
},
|
||
|
||
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;
|
||
}
|
||
} catch (_error) {
|
||
// ignore
|
||
}
|
||
},
|
||
|
||
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();
|
||
},
|
||
|
||
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;
|
||
},
|
||
|
||
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();
|
||
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;
|
||
}
|
||
},
|
||
|
||
handlePopState(event) {
|
||
const state = event.state || {};
|
||
const convId = state.conversationId;
|
||
if (!convId) {
|
||
this.currentConversationId = null;
|
||
this.currentConversationTitle = '新对话';
|
||
this.logMessageState('handlePopState:clear-messages-no-conversation');
|
||
this.messages = [];
|
||
this.logMessageState('handlePopState:after-clear-no-conversation');
|
||
this.resetAllStates('handlePopState:no-conversation');
|
||
this.resetTokenStatistics();
|
||
return;
|
||
}
|
||
this.loadConversation(convId);
|
||
},
|
||
|
||
stripConversationPrefix(conversationId) {
|
||
if (!conversationId) return '';
|
||
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;
|
||
},
|
||
|
||
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() {
|
||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||
const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null;
|
||
try {
|
||
const response = await fetch('/api/status', {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
signal: controller?.signal
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
this.isConnected = true;
|
||
this.connectionHeartbeatFailCount = 0;
|
||
} catch (error) {
|
||
this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1;
|
||
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导
|
||
this.isConnected = false;
|
||
} finally {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
},
|
||
|
||
startConnectionHeartbeat() {
|
||
if (this.connectionHeartbeatActive) {
|
||
return;
|
||
}
|
||
this.connectionHeartbeatActive = true;
|
||
this.connectionHeartbeatFailCount = 0;
|
||
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;
|
||
this.connectionHeartbeatTimer = window.setTimeout(() => {
|
||
runHeartbeat();
|
||
}, nextInterval);
|
||
};
|
||
|
||
// 先做一次即时探活,再根据连接状态动态设置下次轮询间隔
|
||
runHeartbeat();
|
||
},
|
||
|
||
stopConnectionHeartbeat() {
|
||
this.connectionHeartbeatActive = false;
|
||
if (this.connectionHeartbeatTimer) {
|
||
clearTimeout(this.connectionHeartbeatTimer);
|
||
this.connectionHeartbeatTimer = null;
|
||
}
|
||
},
|
||
|
||
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.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;
|
||
}
|
||
};
|