402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
// @ts-nocheck
|
||
import { debugLog } from '../common';
|
||
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 {
|
||
SUB_AGENT_DONE_PREFIX_RE,
|
||
BG_RUN_COMMAND_DONE_PREFIX_RE,
|
||
userMDebug,
|
||
UI_BOUNCE_TRACE_MAX,
|
||
uiBounceTraceLastTsByKey,
|
||
isUiBounceTraceEnabled,
|
||
uiBounceTrace,
|
||
isConnectionDiagEnabled,
|
||
pushConnectionDiagRecord,
|
||
connectionDiag,
|
||
parseSubAgentDoneLabel,
|
||
parseBackgroundRunCommandDoneLabel,
|
||
parseSystemNoticeLabel,
|
||
} from './shared';
|
||
|
||
export const socketMethods = {
|
||
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 });
|
||
},
|
||
async loadInitialData() {
|
||
try {
|
||
debugLog('加载初始数据...');
|
||
|
||
const statusResponse = await fetch('/api/status');
|
||
if (!statusResponse.ok) {
|
||
throw new Error(`状态接口请求失败: ${statusResponse.status}`);
|
||
}
|
||
const statusData = await statusResponse.json();
|
||
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.fetchNetworkPermission();
|
||
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;
|
||
}
|
||
}
|
||
this.isConnected = true;
|
||
|
||
const focusPromise = this.focusFetchFiles();
|
||
let treePromise: Promise<any> | null = null;
|
||
const isHostMode = statusData?.container?.mode === 'host';
|
||
this.versioningHostMode = !!isHostMode;
|
||
this.dockerProjectMode = !isHostMode;
|
||
if (isHostMode) {
|
||
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
||
await this.fetchHostWorkspaces();
|
||
} else {
|
||
this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
|
||
await this.fetchHostWorkspaces();
|
||
this.hostWorkspaceCreateDialogOpen = false;
|
||
this.hostWorkspaceCreatePath = '';
|
||
this.hostWorkspaceCreateLabel = '';
|
||
this.hostWorkspaceCreateError = '';
|
||
this.hostWorkspaceCreateSubmitting = false;
|
||
}
|
||
|
||
// 获取当前对话信息
|
||
const isExplicitNewRoute = this.isExplicitNewConversationRoute();
|
||
const runningConversationId = await this.getRunningTaskConversationId();
|
||
const statusConversationId = statusData.conversation && statusData.conversation.current_id;
|
||
const resumeConversationId = statusConversationId || runningConversationId;
|
||
const shouldResumeOnNewRoute = isExplicitNewRoute && !!runningConversationId;
|
||
|
||
if (
|
||
resumeConversationId &&
|
||
!this.currentConversationId &&
|
||
(!isExplicitNewRoute || shouldResumeOnNewRoute)
|
||
) {
|
||
this.skipConversationHistoryReload = true;
|
||
// 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载
|
||
this.skipConversationLoadedEvent = true;
|
||
this.suppressTitleTyping = true;
|
||
this.titleReady = false;
|
||
this.currentConversationTitle = '';
|
||
this.titleTypingText = '';
|
||
this.currentConversationId = resumeConversationId;
|
||
const pathFragment = this.stripConversationPrefix(resumeConversationId);
|
||
const currentPath = window.location.pathname.replace(/^\/+/, '');
|
||
if (currentPath !== pathFragment) {
|
||
history.replaceState({ conversationId: resumeConversationId }, '', `/${pathFragment}`);
|
||
}
|
||
|
||
// 如果有当前对话,尝试获取标题和历史
|
||
try {
|
||
const convResponse = await fetch(`/api/conversations/current`);
|
||
const convData = await convResponse.json();
|
||
if (convData.success && convData.data) {
|
||
this.currentConversationTitle = convData.data.title;
|
||
this.titleReady = true;
|
||
this.suppressTitleTyping = false;
|
||
this.startTitleTyping(this.currentConversationTitle, { animate: false });
|
||
} else {
|
||
this.titleReady = true;
|
||
this.suppressTitleTyping = false;
|
||
const fallbackTitle = this.currentConversationTitle || '新对话';
|
||
this.currentConversationTitle = fallbackTitle;
|
||
this.startTitleTyping(fallbackTitle, { animate: false });
|
||
}
|
||
// 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发
|
||
if (
|
||
this.lastHistoryLoadedConversationId !== this.currentConversationId ||
|
||
!Array.isArray(this.messages) ||
|
||
this.messages.length === 0
|
||
) {
|
||
await this.fetchAndDisplayHistory();
|
||
}
|
||
// 获取当前对话的Token统计
|
||
this.fetchConversationTokenStatistics();
|
||
this.updateCurrentContextTokens();
|
||
await this.fetchVersioningStatus(this.currentConversationId, { silent: true });
|
||
} catch (e) {
|
||
console.warn('获取当前对话标题失败:', e);
|
||
this.titleReady = true;
|
||
this.suppressTitleTyping = false;
|
||
this.startTitleTyping(this.currentConversationTitle || '新对话', '');
|
||
}
|
||
}
|
||
|
||
// 待办数据依赖当前会话上下文,放在会话恢复/切换之后拉取,避免初始化早期拿到空快照
|
||
const todoPromise = this.fileFetchTodoList();
|
||
|
||
// 等待其他加载项完成(允许部分失败不阻塞模式切换)
|
||
const pendingPromises = [focusPromise, todoPromise];
|
||
if (treePromise) {
|
||
pendingPromises.push(treePromise);
|
||
}
|
||
await Promise.allSettled(pendingPromises);
|
||
await this.loadToolSettings(true);
|
||
|
||
// 加载对话列表(修复:初始化时未加载对话列表的bug)
|
||
this.conversationsOffset = 0;
|
||
await this.loadConversationsList();
|
||
|
||
debugLog('初始数据加载完成');
|
||
} catch (error) {
|
||
console.error('加载初始数据失败:', error);
|
||
this.isConnected = false;
|
||
}
|
||
}
|
||
};
|