核心引擎修复: - 状态转换表补全 THINKING/PARSING/EXECUTING -> COMPRESSING,修复紧急压缩成为死代码的 P1 问题 - 新增跨轮次死循环检测器(软性提示 + 硬性熔断),防止模型陷入重复工具调用死循环 - handleCompressing 空响应回到 THINKING 而非 REFLECTING,避免错误终止 - executeHooks 添加 .catch() 防止未处理的 Promise 拒绝 - ALWAYS_PARALLEL 移除 git 和 browser_evaluate(有副作用的工具不应并行) - thinking fallback:content 为空但有 thinking 时,用 [推理过程] 作为 content 保留上下文 - 8个写类工具添加专用格式化器(含 success + message 字段) - 清理死代码:3个未使用函数 + 3个未使用 import 工具面板统一: - 10个工具独立下拉框统一为1个全局执行模式选择器 - FIFO 队列防止并行 showToolConfirm 导致静默取消 - delete_file 支持 paths 数组参数批量删除 工具定义与实现一致性修复: - run_command 移除未使用的 timeout 参数,描述改为"超时可配置" - list_directory 添加 2000 条截断逻辑 + filter_extension 参数 - calculator 正则移除 ^ 字符(parser 用 ** 替代) - search_files/tree/web_search/fetch_top 描述与实现对齐 消息传递修复: - trimByTokenLimit 改为原子组选择(assistant+tool_calls 与后续 tool 消息作为一组) - 历史工具结果复用 formatToolResultForModel,与当前格式一致 AGENT.md 加载策略变更: - 删除内置 AGENT.md 文件 - 仅从工作空间加载:有则注入,无则跳过 其他修复: - 修复初始化失败 "Cannot convert undefined or null to object"(saveSetting null 导致 JSON.parse 陷阱) - 修复工作空间命令行标签页 idle 状态残留导致样式错乱 版本号: 0.16.5 -> 0.16.7
545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
/**
|
||
* App - 主入口模块
|
||
* 负责初始化所有组件、协调事件流、管理生命周期
|
||
*/
|
||
|
||
import './styles/style.css';
|
||
|
||
import { ChatDB } from './db/chat-db.js';
|
||
import { OllamaAPI } from './api/ollama.js';
|
||
import { state, KEYS } from './state/state.js';
|
||
import { generateId } from './utils/utils.js';
|
||
|
||
// 组件
|
||
import { initToast, showToast } from './components/toast.js';
|
||
import { initLightbox, closeLightbox } from './components/lightbox.js';
|
||
import { initHeader, checkConnection } from './components/header.js';
|
||
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
|
||
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
|
||
import { initInputArea } from './components/input-area.js';
|
||
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
||
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
||
import { initMemoryModal } from './components/memory-modal.js';
|
||
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
|
||
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
|
||
import { initGlobalToolMode, refreshMCPTools } from './services/tool-registry.js';
|
||
import { startAllMCPServers } from './services/mcp-client.js';
|
||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
|
||
import { initLogPanel, addLog } from './services/log-service.js';
|
||
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
||
import { initGlobalErrorHandler, validateConfig } from './services/infra-service.js';
|
||
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
|
||
import { initHarnessHooks } from './services/hooks.js';
|
||
import { setAppVersion } from './services/agent-metrics.js';
|
||
import type { ChatSession } from './types.js';
|
||
|
||
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
||
|
||
async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
||
const bridge = window.metonaDesktop;
|
||
if (!bridge?.isDesktop || !bridge?.db) {
|
||
logInit('非桌面环境,跳过迁移');
|
||
return;
|
||
}
|
||
|
||
// 检查是否已迁移
|
||
const migrated = await bridge.db.getSetting('_indexeddb_migrated', false);
|
||
if (migrated) {
|
||
logInit('SQLite 数据库已连接(已迁移)');
|
||
return;
|
||
}
|
||
|
||
// 检查 IndexedDB 中是否有数据
|
||
const idb = db.getRawIDB();
|
||
if (!idb) {
|
||
logInit('SQLite 数据库已连接(无旧数据)');
|
||
return;
|
||
}
|
||
|
||
// 读取 IndexedDB 数据
|
||
try {
|
||
const sessions: ChatSession[] = await new Promise((resolve, reject) => {
|
||
const tx = idb.transaction('sessions', 'readonly');
|
||
const store = tx.objectStore('sessions');
|
||
const req = store.getAll();
|
||
req.onsuccess = () => resolve(req.result || []);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
|
||
const memories: Record<string, unknown>[] = await new Promise((resolve, reject) => {
|
||
const tx = idb.transaction('memories', 'readonly');
|
||
const store = tx.objectStore('memories');
|
||
const req = store.getAll();
|
||
req.onsuccess = () => resolve(req.result || []);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
|
||
const settings: Record<string, unknown>[] = await new Promise((resolve, reject) => {
|
||
const tx = idb.transaction('settings', 'readonly');
|
||
const store = tx.objectStore('settings');
|
||
const req = store.getAll();
|
||
req.onsuccess = () => resolve(req.result || []);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
|
||
if (sessions.length === 0 && memories.length === 0 && settings.length === 0) {
|
||
logInit('SQLite 数据库已连接(无旧数据)');
|
||
await bridge.db.saveSetting('_indexeddb_migrated', true);
|
||
return;
|
||
}
|
||
|
||
logInit(`发现旧数据: ${sessions.length} 会话, ${memories.length} 记忆, ${settings.length} 设置`);
|
||
|
||
// 构造导入数据
|
||
const importData = {
|
||
sessions: sessions.map(s => ({
|
||
id: s.id,
|
||
title: s.title,
|
||
model: s.model,
|
||
system_prompt: null as string | null,
|
||
parent_id: null as string | null,
|
||
status: 'active',
|
||
created_at: s.createdAt,
|
||
updated_at: s.updatedAt
|
||
})),
|
||
messages: (sessions as any[]).flatMap((s: any) =>
|
||
(s.messages || []).map((m: any, idx: number) => ({
|
||
id: `${s.id}_msg_${idx}_${m.timestamp}`,
|
||
session_id: s.id,
|
||
role: m.role,
|
||
content: m.content || null,
|
||
thinking: m.think || null,
|
||
images: m.images?.length ? JSON.stringify(m.images) : null,
|
||
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
|
||
tool_name: null,
|
||
eval_count: m.eval_count || null,
|
||
total_duration: m.total_duration || null,
|
||
created_at: m.timestamp || Date.now()
|
||
}))
|
||
),
|
||
memories: memories.map((m: Record<string, unknown>) => ({
|
||
id: m.id,
|
||
type: m.type,
|
||
content: m.content,
|
||
importance: m.importance || 5,
|
||
tags: (m.tags as any[])?.length ? JSON.stringify(m.tags) : null,
|
||
source: m.source || null,
|
||
session_id: m.sessionId || null,
|
||
use_count: m.useCount || 0,
|
||
embedding: m.embedding ? JSON.stringify(m.embedding) : null,
|
||
created_at: m.createdAt,
|
||
updated_at: m.updatedAt,
|
||
last_used_at: m.lastUsedAt
|
||
})),
|
||
settings: settings.map((s: Record<string, unknown>) => ({ key: s.key, value: s.value })),
|
||
exportedAt: Date.now()
|
||
};
|
||
|
||
const result = await bridge.db.importSessions(importData);
|
||
logInit(`迁移完成: 导入 ${result.imported} 个会话, 跳过 ${result.skipped} 个`);
|
||
|
||
// 标记迁移完成
|
||
await bridge.db.saveSetting('_indexeddb_migrated', true);
|
||
logSuccess('数据迁移完成');
|
||
} catch (err) {
|
||
logError('数据迁移失败', (err as Error).message);
|
||
// 迁移失败不阻塞启动
|
||
}
|
||
}
|
||
|
||
function initHelpModal(): void {
|
||
const helpModal = document.querySelector('#helpModal') as HTMLElement;
|
||
document.querySelector('#btnHelp')!.addEventListener('click', () => {
|
||
helpModal.style.display = '';
|
||
});
|
||
document.querySelector('#btnCloseHelp')!.addEventListener('click', () => {
|
||
helpModal.style.display = 'none';
|
||
});
|
||
helpModal.addEventListener('click', (e) => {
|
||
if (e.target === helpModal) helpModal.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
function setupDesktopIntegration(): void {
|
||
const bridge = window.metonaDesktop;
|
||
if (!bridge || !bridge.isDesktop) {
|
||
logDebug('非桌面环境,跳过桌面集成');
|
||
return;
|
||
}
|
||
|
||
logInit('桌面集成...');
|
||
|
||
bridge.onMenuAction((action: string) => {
|
||
logInfo(`菜单操作: ${action}`);
|
||
switch (action) {
|
||
case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break;
|
||
case 'import': document.querySelector('#btnImportSessions')?.dispatchEvent(new Event('click')); break;
|
||
case 'export': document.querySelector('#btnExportAll')?.dispatchEvent(new Event('click')); break;
|
||
case 'help': document.querySelector('#btnHelp')?.dispatchEvent(new Event('click')); break;
|
||
}
|
||
});
|
||
|
||
bridge.onTrayAction((action: string) => {
|
||
logInfo(`托盘操作: ${action}`);
|
||
switch (action) {
|
||
case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break;
|
||
}
|
||
});
|
||
|
||
// 标题更新
|
||
window.addEventListener('metona:model-changed', ((e: CustomEvent) => {
|
||
if (e.detail?.model) {
|
||
document.title = `${e.detail.model} - Metona Ollama`;
|
||
} else {
|
||
document.title = 'Metona Ollama';
|
||
}
|
||
}) as EventListener);
|
||
|
||
// 主进程日志转发到日志面板
|
||
bridge.onMainLog((data: { level: string; message: string; detail?: string }) => {
|
||
addLog(data.level as 'info' | 'success' | 'warn' | 'error' | 'debug', data.message, data.detail);
|
||
});
|
||
|
||
// 退出时释放显存
|
||
bridge.onAppQuit(() => {
|
||
const api = state.get<OllamaAPI | null>(KEYS.API);
|
||
const model = state.get<string>('_defaultModel', '');
|
||
if (api && model) {
|
||
api.chat({ model, messages: [], keep_alive: 0 }).catch(() => {});
|
||
}
|
||
});
|
||
|
||
// 桌面端快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.key === 'n') {
|
||
e.preventDefault();
|
||
document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click'));
|
||
}
|
||
});
|
||
|
||
logSuccess('桌面集成完成');
|
||
}
|
||
|
||
function createNewSession(): ChatSession {
|
||
const selectedModel = (document.querySelector('#modelSelect') as HTMLSelectElement)?.value || '';
|
||
const defaultModel = selectedModel || state.get<string>('_defaultModel', '');
|
||
return {
|
||
id: generateId(),
|
||
title: '新会话',
|
||
model: defaultModel,
|
||
messages: [],
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
};
|
||
}
|
||
|
||
async function startNewSession(): Promise<void> {
|
||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||
if (db && currentSession && currentSession.messages.length > 0) {
|
||
currentSession.updatedAt = Date.now();
|
||
await db.saveSession(currentSession);
|
||
}
|
||
|
||
const api = state.get<OllamaAPI>(KEYS.API);
|
||
const model = currentSession?.model || state.get<string>('_defaultModel', '');
|
||
if (api && model) {
|
||
try {
|
||
await api.chat({ model, messages: [], keep_alive: 0 });
|
||
logDebug('释放旧模型显存');
|
||
} catch (e) {
|
||
logWarn('释放显存失败', (e as Error).message);
|
||
}
|
||
}
|
||
|
||
const session = createNewSession();
|
||
state.set(KEYS.CURRENT_SESSION, session);
|
||
logInfo('新建会话');
|
||
|
||
// ── 强制中断正在进行的生成 ──
|
||
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
|
||
if (abortController) {
|
||
abortController.abort();
|
||
}
|
||
state.set(KEYS.IS_STREAMING, false);
|
||
state.set(KEYS.ABORT_CONTROLLER, null);
|
||
|
||
// ── 恢复发送按钮 ──
|
||
const btnSend = document.querySelector('#btnSend') as HTMLButtonElement;
|
||
if (btnSend) {
|
||
btnSend.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
|
||
btnSend.classList.remove('stop-btn', 'disabled');
|
||
btnSend.title = '发送';
|
||
}
|
||
|
||
state.set(KEYS.IS_HISTORY_VIEW, false);
|
||
|
||
(document.querySelector('#historyBar') as HTMLElement).style.display = 'none';
|
||
(document.querySelector('#inputArea') as HTMLElement).style.display = '';
|
||
|
||
clearMessages();
|
||
enableAutoScroll();
|
||
renderMessages();
|
||
|
||
// ── 重置工作空间到空闲状态 ──
|
||
clearToolCardsExternal();
|
||
clearTerminalExternal();
|
||
switchToTab('tools');
|
||
|
||
(document.querySelector('#chatInput') as HTMLElement)?.focus();
|
||
}
|
||
|
||
async function init(): Promise<void> {
|
||
let db: ChatDB | undefined;
|
||
let api: OllamaAPI;
|
||
|
||
logInit('启动...');
|
||
|
||
// ── UI 组件初始化(不依赖数据库,先初始化以避免 catch 路径重复绑定)──
|
||
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
|
||
initGlobalErrorHandler(); // R42: 全局错误处理器(紧随日志面板之后)
|
||
initToast();
|
||
initLightbox();
|
||
initHeader();
|
||
initModelBar();
|
||
initChatArea();
|
||
initInputArea();
|
||
initSettingsModal();
|
||
initHistoryModal();
|
||
initHelpModal();
|
||
initToolConfirmModal();
|
||
initMemoryModal();
|
||
initToolsModal();
|
||
initTokenDashboard();
|
||
initWorkspacePanel();
|
||
setupDesktopIntegration();
|
||
initSearxngModal();
|
||
|
||
bindGlobalEvents();
|
||
|
||
// ── Harness Engineering 系统初始化(幂等,UI 就绪后即可注册)──
|
||
initHarnessHooks();
|
||
// Agent Metrics 无需显式初始化(按需启动)
|
||
// Context Indexer 在 Agent INIT 状态按需构建
|
||
|
||
// ── 状态默认值(不依赖数据库)──
|
||
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
||
state.set(KEYS.IS_STREAMING, false);
|
||
state.set(KEYS.IS_HISTORY_VIEW, false);
|
||
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
|
||
|
||
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
|
||
state.set('toolCallingEnabled', true);
|
||
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
|
||
state.set('thinkEnabled', false);
|
||
|
||
// ── 注入应用版本号到 Agent Metrics ──
|
||
const _bridge = window.metonaDesktop;
|
||
if (_bridge?.info) {
|
||
_bridge.info().then((info: { version?: string }) => {
|
||
if (info?.version) setAppVersion(info.version);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
try {
|
||
db = new ChatDB();
|
||
await db.init();
|
||
state.set(KEYS.DB, db);
|
||
|
||
// v4.0 数据迁移:检测 IndexedDB 是否有旧数据需要迁移到 SQLite
|
||
await migrateIndexedDBToSQLite(db);
|
||
|
||
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
|
||
api = new OllamaAPI(serverUrl);
|
||
state.set(KEYS.API, api);
|
||
logInit(`Ollama 地址: ${serverUrl}`);
|
||
|
||
await checkConnection();
|
||
await loadModels();
|
||
await loadSearxngConfig(db);
|
||
|
||
// ── MEMORY.md 初始化 ──
|
||
import('./services/memory-service.js').then(({ initMemoryFile }) => {
|
||
initMemoryFile().catch(() => {});
|
||
});
|
||
|
||
logInit('所有组件已就绪(含 Harness)');
|
||
|
||
const savedModel = await db.getSetting('selectedModel', '');
|
||
if (savedModel) {
|
||
setSelectedModel(savedModel);
|
||
state.set('_defaultModel', savedModel);
|
||
await db.saveSetting('selectedModel', savedModel);
|
||
}
|
||
|
||
const numCtx = await db.getSetting<number>('numCtx', 131072);
|
||
const temperature = await db.getSetting('temperature', 0.7);
|
||
|
||
// 如果首次使用(数据库中无此设置),保存默认 128K
|
||
const hasNumCtx = await db.getSetting<number | null>('numCtx', null);
|
||
if (hasNumCtx === null) {
|
||
await db.saveSetting('numCtx', 131072);
|
||
}
|
||
|
||
state.set(KEYS.NUM_CTX, numCtx);
|
||
state.set('temperature', temperature);
|
||
|
||
// 反显设置面板的下拉框
|
||
const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement;
|
||
if (ctxSelect) ctxSelect.value = String(numCtx);
|
||
|
||
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
|
||
document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1);
|
||
|
||
// ── v5.1.1 Agent 最大轮数 ──
|
||
const maxTurns = await db.getSetting('maxTurns', 85);
|
||
state.set('maxTurns', maxTurns);
|
||
(document.querySelector('#inputMaxTurns') as HTMLInputElement).value = String(maxTurns);
|
||
|
||
// ── v5.2 超时设置:-1=默认, 0=禁用, 正数=自定义 ──
|
||
let httpTimeout = await db.getSetting<number>('httpTimeout', -1);
|
||
let streamTimeout = await db.getSetting<number>('streamTimeout', -1);
|
||
let mcpTimeout = await db.getSetting<number>('mcpTimeout', -1);
|
||
|
||
// 兼容旧版:首次升级时,旧版把"未设置"存为0,一次性迁移为-1
|
||
const migrated = await db.getSetting<boolean>('_timeout_v2_migrated', false);
|
||
if (!migrated) {
|
||
if (httpTimeout === 0) { httpTimeout = -1; await db.saveSetting('httpTimeout', -1); }
|
||
if (streamTimeout === 0) { streamTimeout = -1; await db.saveSetting('streamTimeout', -1); }
|
||
if (mcpTimeout === 0) { mcpTimeout = -1; await db.saveSetting('mcpTimeout', -1); }
|
||
await db.saveSetting('_timeout_v2_migrated', true);
|
||
}
|
||
|
||
// 应用流式超时到 state(agent-engine 读取,0=禁用)
|
||
state.set('streamTimeout', streamTimeout >= 0 ? streamTimeout * 1000 : 300_000);
|
||
|
||
// 应用 HTTP 和 MCP 超时到主进程
|
||
const bridge = window.metonaDesktop;
|
||
if (bridge?.tool?.setTimeouts) {
|
||
await bridge.tool.setTimeouts({
|
||
http: httpTimeout >= 0 ? httpTimeout * 1000 : 30_000,
|
||
mcp: mcpTimeout >= 0 ? mcpTimeout * 1000 : 60_000,
|
||
});
|
||
}
|
||
|
||
// 恢复输入框显示(-1显示空, 0显示0, 正数显示数字)
|
||
(document.querySelector('#inputHttpTimeout') as HTMLInputElement).value = httpTimeout >= 0 ? String(httpTimeout) : '';
|
||
(document.querySelector('#inputStreamTimeout') as HTMLInputElement).value = streamTimeout >= 0 ? String(streamTimeout) : '';
|
||
(document.querySelector('#inputMCPTimeout') as HTMLInputElement).value = mcpTimeout >= 0 ? String(mcpTimeout) : '';
|
||
logInit(`超时设置: HTTP=${httpTimeout>=0?httpTimeout:30}s 流式=${streamTimeout>=0?streamTimeout:300}s MCP=${mcpTimeout>=0?mcpTimeout:60}s`);
|
||
|
||
// ── 子代理参数 ──
|
||
let subAgentMaxLoops = await db.getSetting<number>('subAgentMaxLoops', 10);
|
||
let subAgentTimeout = await db.getSetting<number>('subAgentTimeout', -1); // -1=默认
|
||
state.set('subAgentMaxLoops', subAgentMaxLoops);
|
||
state.set('subAgentTimeout', subAgentTimeout >= 0 ? subAgentTimeout * 1000 : 300_000);
|
||
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
|
||
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
|
||
|
||
// ── 看门狗超时 ──
|
||
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
||
state.set('loopWatchdogMs', loopWatchdogMs);
|
||
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
|
||
const elWatchdog = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
|
||
if (elWatchdog) elWatchdog.value = loopWatchdogMs > 0 ? String(watchdogMin) : (loopWatchdogMs === 0 ? '0' : '');
|
||
|
||
// ── Tool Calling 设置(永久开启,不可关闭)──
|
||
state.set('toolCallingEnabled', true);
|
||
// 全局工具执行模式(统一管理所有工具,不区分工具)
|
||
{
|
||
// 读取全局模式,默认 confirm
|
||
let globalMode = await db.getSetting<'auto' | 'confirm'>('globalToolMode', 'confirm');
|
||
// 兼容旧版:从旧的 toolModes 迁移(如果存在)
|
||
// 注意:之前用 saveSetting('toolModes', null) 清除旧键,JSON.parse("null") 返回 null
|
||
// 所以这里必须做 null 防御,否则 Object.keys(null) 会抛出 "Cannot convert undefined or null to object"
|
||
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {}) ?? {};
|
||
if (savedModes && Object.keys(savedModes).length > 0) {
|
||
// 如果旧 toolModes 中所有被管理工具都是 auto,则迁移为 auto;否则 confirm
|
||
const allAuto = [
|
||
'run_command', 'write_file', 'create_directory', 'delete_file',
|
||
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||
'download_file', 'compress',
|
||
].every(t => savedModes[t] === 'auto');
|
||
globalMode = allAuto ? 'auto' : 'confirm';
|
||
await db.saveSetting('globalToolMode', globalMode);
|
||
// 清除旧键:用空对象而非 null,避免 JSON.parse("null") 返回 null 的陷阱
|
||
await db.saveSetting('toolModes', {});
|
||
}
|
||
state.set('globalToolMode', globalMode);
|
||
// 应用全局模式到所有被管理工具
|
||
initGlobalToolMode(globalMode);
|
||
}
|
||
|
||
// ── Plan 自动确认设置 ──
|
||
{
|
||
const planAutoConfirm = await db.getSetting<boolean>('planAutoConfirm', false);
|
||
state.set('planAutoConfirm', planAutoConfirm);
|
||
}
|
||
|
||
// ── MCP 工具注册 ──
|
||
await startAllMCPServers();
|
||
await refreshMCPTools();
|
||
|
||
|
||
(document.querySelector('#chatInput') as HTMLElement)?.focus();
|
||
|
||
logSuccess('应用初始化完成');
|
||
} catch (err) {
|
||
logError('初始化失败', (err as Error).message);
|
||
// UI 组件已在 try 块之前初始化,此处不重复调用以避免重复绑定事件监听器
|
||
if (!api) {
|
||
api = new OllamaAPI();
|
||
state.set(KEYS.API, api);
|
||
}
|
||
// ── 防御性重置:catch 路径也确保关键状态干净 ──
|
||
state.set(KEYS.IS_STREAMING, false);
|
||
state.set(KEYS.IS_HISTORY_VIEW, false);
|
||
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
||
// ── MEMORY.md 初始化(fallback 路径)──
|
||
import('./services/memory-service.js').then(({ initMemoryFile }) => {
|
||
initMemoryFile().catch(() => {});
|
||
});
|
||
checkConnection().then(loadModels);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function bindGlobalEvents(): void {
|
||
document.querySelector('#btnNewChat')?.addEventListener('click', startNewSession);
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeSettingsModal();
|
||
closeHistoryModal();
|
||
closeLightbox();
|
||
closeToolsModal();
|
||
closeTokenDashboard();
|
||
closeSearxngModal();
|
||
(document.querySelector('#helpModal') as HTMLElement).style.display = 'none';
|
||
(document.querySelector('#memoryModal') as HTMLElement).style.display = 'none';
|
||
}
|
||
|
||
// Ctrl+M — 记忆管理
|
||
if (e.ctrlKey && e.key === 'm') {
|
||
e.preventDefault();
|
||
document.querySelector('#btnMemory')?.dispatchEvent(new Event('click'));
|
||
}
|
||
});
|
||
|
||
window.addEventListener('error', (e) => {
|
||
logError('未捕获错误', (e.error as Error)?.message || e.message);
|
||
showToast(`发生错误: ${e.message}`, 'error', 5000);
|
||
});
|
||
|
||
window.addEventListener('unhandledrejection', (e) => {
|
||
logError('未处理 Promise 拒绝', (e.reason as Error)?.message || String(e.reason));
|
||
const msg = e.reason?.message || String(e.reason);
|
||
showToast(`操作失败: ${msg}`, 'error', 5000);
|
||
});
|
||
|
||
}
|
||
|
||
init();
|