Files
metona-ollama-desktop/src/renderer/components/settings-modal.ts
T
紫影233 bd3a06bfaf v0.16.7: 引擎修复 + 工具面板统一 + AGENT.md 改为仅工作空间加载
核心引擎修复:
- 状态转换表补全 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
2026-07-14 16:26:43 +08:00

521 lines
22 KiB
TypeScript

/**
* SettingsModal - 设置面板组件
*/
import { state, KEYS } from '../state/state.js';
import { debounce } from '../utils/utils.js';
import { encryptData, decryptData } from '../services/crypto.js';
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels, formatCtxLen } from './model-bar.js';
import { showToast } from './toast.js';
import { showConfirm } from './prompt-modal.js';
import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js';
import type { ChatSession } from '../types.js';
let settingsModalEl: HTMLElement;
export function initSettingsModal(): void {
settingsModalEl = document.querySelector('#settingsModal')!;
document.querySelector('#btnSettings')!.addEventListener('click', openSettingsModal);
document.querySelector('#btnCloseSettings')!.addEventListener('click', closeSettingsModal);
settingsModalEl.addEventListener('click', (e) => {
if (e.target === settingsModalEl) closeSettingsModal();
});
const saveServerUrl = debounce(async () => {
const url = (document.querySelector('#inputServerUrl') as HTMLInputElement).value.trim();
if (!url) return;
const db = state.get<ChatDB | null>(KEYS.DB);
const api = new OllamaAPI(url);
state.set(KEYS.API, api);
if (db) await db.saveSetting('serverUrl', url);
updateConnectionInfo();
checkConnection();
loadModels();
}, 500);
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
const tempDisplay = document.querySelector('#tempValue')!;
tempSlider.addEventListener('input', () => {
tempDisplay.textContent = parseFloat(tempSlider.value).toFixed(1);
});
const saveTemperature = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = parseFloat(tempSlider.value);
state.set('temperature', val);
if (db) await db.saveSetting('temperature', val);
logSetting('温度', val.toFixed(1));
}, 300);
tempSlider.addEventListener('change', saveTemperature);
// ── v5.1.1 Agent 最大轮数 ──
const saveMaxTurns = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputMaxTurns') as HTMLInputElement).value.trim();
const maxTurns = val ? parseInt(val) : 85;
state.set('maxTurns', maxTurns);
if (db) await db.saveSetting('maxTurns', maxTurns);
logSetting('Agent 最大轮数', `${maxTurns} 轮`);
}, 500);
document.querySelector('#inputMaxTurns')!.addEventListener('input', saveMaxTurns);
// ── 上下文长度选择 ──
const selectCtxLen = document.querySelector('#selectContextLength') as HTMLSelectElement;
selectCtxLen.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = parseInt(selectCtxLen.value);
if (!val) return;
state.set(KEYS.NUM_CTX, val);
if (db) await db.saveSetting('numCtx', val);
logSetting('上下文长度', formatCtxLen(val));
});
// ── v5.2 超时设置 ──
const inputHttpTimeout = document.querySelector('#inputHttpTimeout') as HTMLInputElement;
const inputStreamTimeout = document.querySelector('#inputStreamTimeout') as HTMLInputElement;
const inputMCPTimeout = document.querySelector('#inputMCPTimeout') as HTMLInputElement;
// 辅助:解析输入值,空→-1(默认),数字→该值(含0=禁用)
const parseSec = (val: string): number => (val !== '' ? parseInt(val) : -1);
const saveHttpTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const sec = parseSec(inputHttpTimeout.value.trim());
const ms = sec >= 0 ? sec * 1000 : 30_000;
if (db) await db.saveSetting('httpTimeout', sec);
const bridge = window.metonaDesktop;
if (bridge?.tool?.setTimeouts) {
await bridge.tool.setTimeouts({ http: ms });
}
logSetting('HTTP 超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(30s)');
}, 500);
const saveStreamTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const sec = parseSec(inputStreamTimeout.value.trim());
const ms = sec >= 0 ? sec * 1000 : 300_000;
state.set('streamTimeout', ms);
if (db) await db.saveSetting('streamTimeout', sec);
logSetting('流式超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
}, 500);
const saveMCPTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const sec = parseSec(inputMCPTimeout.value.trim());
const ms = sec >= 0 ? sec * 1000 : 60_000;
if (db) await db.saveSetting('mcpTimeout', sec);
const bridge = window.metonaDesktop;
if (bridge?.tool?.setTimeouts) {
await bridge.tool.setTimeouts({ mcp: ms });
}
logSetting('MCP 超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(60s)');
}, 500);
inputHttpTimeout.addEventListener('input', saveHttpTimeout);
inputStreamTimeout.addEventListener('input', saveStreamTimeout);
inputMCPTimeout.addEventListener('input', saveMCPTimeout);
// ── v5.0 MCP 服务器管理 ──
async function renderMCPServerList(): Promise<void> {
const { getMCPServers, getMCPServerStatuses, toggleMCPServer, removeMCPServer } = await import('../services/mcp-client.js');
const { refreshMCPTools } = await import('../services/tool-registry.js');
const container = document.querySelector('#mcpServerList')!;
const servers = await getMCPServers();
const statuses = await getMCPServerStatuses();
if (servers.length === 0) {
container.innerHTML = '<p class="text-muted" style="margin:4px 0;font-size:11px;">未配置 MCP 服务器。MCP 允许连接外部工具服务。</p>';
return;
}
container.innerHTML = servers.map(s => {
const status = statuses.find(st => st.name === s.name);
const running = status?.running || false;
const toolCount = status?.toolCount || 0;
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border-subtle);">
<span style="width:8px;height:8px;border-radius:50%;background:${running ? 'var(--success)' : 'var(--text-muted)'};" title="${running ? '已连接' : '未连接'}"></span>
<span style="flex:1;font-weight:500;">${s.name}</span>
<span class="text-muted" style="font-size:11px;">${s.command} ${s.args.join(' ')}${running ? ` (${toolCount} 工具)` : ''}</span>
<button class="btn btn-sm btn-outline mcp-toggle" data-name="${s.name}" style="padding:2px 8px;font-size:11px;">${s.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-outline mcp-delete" data-name="${s.name}" style="padding:2px 8px;font-size:11px;color:var(--critical);">删除</button>
</div>`;
}).join('');
container.querySelectorAll('.mcp-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const name = (btn as HTMLElement).dataset.name!;
await toggleMCPServer(name);
await refreshMCPTools();
await renderMCPServerList();
});
});
container.querySelectorAll('.mcp-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const name = (btn as HTMLElement).dataset.name!;
await removeMCPServer(name);
await refreshMCPTools();
await renderMCPServerList();
});
});
}
document.querySelector('#btnAddMCPServer')!.addEventListener('click', async () => {
const { addMCPServer } = await import('../services/mcp-client.js');
const { refreshMCPTools } = await import('../services/tool-registry.js');
const name = (document.querySelector('#inputMCPName') as HTMLInputElement).value.trim();
const command = (document.querySelector('#inputMCPCommand') as HTMLInputElement).value.trim();
const argsStr = (document.querySelector('#inputMCPArgs') as HTMLInputElement).value.trim();
if (!name || !command) {
showToast('请填写名称和命令', 'warning');
return;
}
const args = argsStr ? argsStr.split(/\s+/) : [];
await addMCPServer({ name, command, args, enabled: true });
await refreshMCPTools();
(document.querySelector('#inputMCPName') as HTMLInputElement).value = '';
(document.querySelector('#inputMCPCommand') as HTMLInputElement).value = '';
(document.querySelector('#inputMCPArgs') as HTMLInputElement).value = '';
showToast(`MCP 服务器已添加: ${name}`, 'success');
await renderMCPServerList();
});
// 设置面板打开时刷新 MCP 列表
document.querySelector('#btnSettings')!.addEventListener('click', () => {
setTimeout(renderMCPServerList, 100);
});
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
const api = state.get<OllamaAPI | null>(KEYS.API, null);
if (!api) { showToast('Ollama API 未连接', 'warning'); return; }
const sel = document.querySelector('#modelSelect') as HTMLSelectElement | null;
const model = sel?.value || '';
logInfo('释放显存', model || '所有模型');
try {
await api.chat({ model: model || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success');
logSuccess('显存已释放');
updateRunningModels();
} catch (err) {
showToast(`释放失败: ${(err as Error).message}`, 'error');
logError('释放显存失败', (err as Error).message);
}
});
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) { showToast('数据库未就绪', 'warning'); return; }
if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) {
logWarn('清空所有历史记录');
await db.clearAll();
(document.querySelector('#btnNewChat') as HTMLElement).click();
showToast('已清空所有历史记录', 'success');
logSuccess('历史记录已清空');
}
});
document.querySelector('#btnExportAll')!.addEventListener('click', exportAllSessions);
// ── 导入会话:原生文件对话框 ──
document.querySelector('#btnImportSessions')!.addEventListener('click', async () => {
const bridge = window.metonaDesktop;
if (!bridge) return;
const paths = await bridge.dialog.openFile({
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
});
if (!paths || paths.length === 0) return;
await importSessions(paths[0]);
});
// ── 工作空间目录设置 ──
const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
if (inputWorkspaceDir) {
// 加载当前工作空间目录
const bridge = window.metonaDesktop;
if (bridge?.isDesktop) {
bridge.workspace.getDir().then((info: { dir: string }) => {
inputWorkspaceDir.value = info.dir;
}).catch(() => {});
}
}
if (btnBrowseWorkspace) {
btnBrowseWorkspace.addEventListener('click', async () => {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
const paths = await bridge.dialog.openDirectory();
if (paths && paths.length > 0) {
inputWorkspaceDir.value = paths[0];
inputWorkspaceDir.dispatchEvent(new Event('blur'));
}
});
// 双击输入框解锁编辑
inputWorkspaceDir.addEventListener('dblclick', () => {
inputWorkspaceDir.removeAttribute('readonly');
inputWorkspaceDir.focus();
});
inputWorkspaceDir.addEventListener('blur', async () => {
inputWorkspaceDir.setAttribute('readonly', '');
const newDir = inputWorkspaceDir.value.trim();
if (!newDir) return;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
const result = await bridge.workspace.setDir(newDir);
if (result.success) {
showToast('工作空间目录已更新', 'success');
logSetting('工作空间目录', result.dir!);
// ── 通知工作空间面板刷新目录 ──
window.dispatchEvent(new CustomEvent('workspaceDirChanged', { detail: { dir: result.dir } }));
// ── 初始化新工作空间的 MEMORY.md ──
import('../services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
} else {
showToast(`设置失败: ${result.error!}`, 'error');
// 恢复原值
const info = await bridge.workspace.getDir();
inputWorkspaceDir.value = info.dir;
}
});
}
// ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)──
// 子代理模型设置保存
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
await db.saveSetting('subAgentModel', val);
logSetting('子代理默认模型', val || '跟随当前模型');
});
// 子代理最大轮次
const saveSubAgentMaxLoops = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
const maxLoops = val ? parseInt(val) : 10;
state.set('subAgentMaxLoops', maxLoops);
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
logSetting('子代理最大轮次', `${maxLoops} 轮`);
}, 500);
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
// 子代理超时(秒)
const saveSubAgentTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
const sec = val ? parseInt(val) : -1; // -1=默认
const ms = sec >= 0 ? sec * 1000 : 300_000;
state.set('subAgentTimeout', ms);
if (db) await db.saveSetting('subAgentTimeout', sec);
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
}, 500);
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
// ── 看门狗超时(分钟,0=禁用)──
const saveLoopWatchdog = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
const minutes = val ? parseInt(val) : -1;
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
state.set('loopWatchdogMs', ms);
if (db) await db.saveSetting('loopWatchdogMs', ms);
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
}, 500);
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
}
export function openSettingsModal(): void {
settingsModalEl.style.display = '';
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
updateConnectionInfo();
updateRunningModels();
loadTimeoutSettings();
loadWatchdogSetting();
// 刷新工作空间目录显示
const bridge = window.metonaDesktop;
if (bridge?.isDesktop) {
const wsInput = document.querySelector('#inputWorkspaceDir') as HTMLInputElement;
if (wsInput) {
bridge.workspace.getDir().then((info: { dir: string }) => {
wsInput.value = info.dir;
}).catch(() => {});
}
}
}
/** 加载看门狗设置到输入框 */
async function loadWatchdogSetting(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const ms = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
const el = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
if (!el) return;
if (ms === 0) el.value = '0';
else if (ms > 0) el.value = String(Math.round(ms / 60_000));
else el.value = '';
}
export function closeSettingsModal(): void {
settingsModalEl.style.display = 'none';
}
/** 加载已保存的超时设置到输入框:-1=默认(显示空), 0=禁用, 正数=自定义 */
async function loadTimeoutSettings(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const httpTimeout = await db.getSetting<number>('httpTimeout', -1);
const streamTimeout = await db.getSetting<number>('streamTimeout', -1);
const mcpTimeout = await db.getSetting<number>('mcpTimeout', -1);
const inputHttp = document.querySelector('#inputHttpTimeout') as HTMLInputElement;
const inputStream = document.querySelector('#inputStreamTimeout') as HTMLInputElement;
const inputMCP = document.querySelector('#inputMCPTimeout') as HTMLInputElement;
if (inputHttp) inputHttp.value = httpTimeout >= 0 ? String(httpTimeout) : '';
if (inputStream) inputStream.value = streamTimeout >= 0 ? String(streamTimeout) : '';
if (inputMCP) inputMCP.value = mcpTimeout >= 0 ? String(mcpTimeout) : '';
}
async function exportAllSessions(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const sessions = await db.getAllSessions();
if (sessions.length === 0) {
showToast('没有可导出的会话', 'warning');
return;
}
const backup = {
app: 'Metona Ollama Client',
version: 1,
exportedAt: new Date().toISOString(),
count: sessions.length,
sessions
};
try {
const blob = await encryptData(backup);
const ts = new Date().toISOString().slice(0, 10);
// ── 原生保存对话框 ──
const bridge = window.metonaDesktop;
if (bridge) {
const filePath = await bridge.dialog.saveFile({
defaultPath: `metona-backup-${ts}.metona`,
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
});
if (!filePath) return;
// blob → ArrayBuffer → base64 字符串,通过 base64 编码写入二进制文件
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
const b64 = btoa(binary);
const result = await bridge.fs.writeFile(filePath, b64, 'base64');
if (!result.success) {
showToast(`导出失败: ${result.error}`, 'error');
logError('导出失败', result.error);
return;
}
} else {
// 回退:浏览器下载
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `metona-backup-${ts}.metona`;
a.click();
}
showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
logSuccess(`导出完成: ${sessions.length} 个会话`);
} catch (err) {
showToast(`导出失败: ${(err as Error).message}`, 'error');
logError('导出失败', (err as Error).message);
}
}
async function populateSubAgentModels(): Promise<void> {
const select = document.querySelector('#selectSubAgentModel') as HTMLSelectElement;
if (!select) return;
const api = state.get<OllamaAPI>(KEYS.API);
if (!api) return;
try {
const data = await api.listModels();
if (!data.models?.length) return;
// 保留第一个 "跟随当前模型" 选项,追加所有模型
while (select.options.length > 1) select.remove(1);
data.models
.sort((a, b) => (a.name < b.name ? -1 : 1))
.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
opt.textContent = m.name;
select.appendChild(opt);
});
// 恢复已保存的选择
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) {
const saved = await db.getSetting('subAgentModel', '');
if (saved) select.value = saved;
}
} catch (err) {
logWarn('加载子代理模型列表失败', (err as Error).message);
}
}
async function importSessions(filePath: string): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const fileName = filePath.split(/[/\\]/).pop() || filePath;
if (!fileName.endsWith('.metona')) {
showToast('仅支持导入 .metona 格式文件', 'error');
return;
}
try {
const bridge = window.metonaDesktop;
if (!bridge) { showToast('仅桌面端支持导入', 'warning'); return; }
const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) {
showToast(`读取文件失败: ${result.error}`, 'error');
return;
}
// 主进程返回 base64,直接解码为二进制
const binary = atob(result.content as string);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const data = await decryptData(bytes.buffer);
logInfo('导入会话', `文件: ${fileName}`);
let sessions: ChatSession[];
if (Array.isArray(data)) {
sessions = data as ChatSession[];
} else if (typeof data === 'object' && data !== null && 'sessions' in data && Array.isArray((data as Record<string, unknown>)['sessions'])) {
sessions = (data as Record<string, unknown>)['sessions'] as ChatSession[];
} else {
showToast('文件内容格式错误:未找到会话数据', 'error');
return;
}
const importResult = await db.importSessions(sessions);
showToast(`导入完成:${importResult.imported} 个会话${importResult.skipped > 0 ? `,跳过 ${importResult.skipped} 个` : ''}`, 'success', 4000);
logSuccess(`导入完成: ${importResult.imported} 个, 跳过 ${importResult.skipped} 个`);
} catch (err) {
showToast(`导入失败: ${(err as Error).message}`, 'error');
logError('导入失败', (err as Error).message);
}
}