P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制
P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义
P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃
文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
583 lines
24 KiB
TypeScript
583 lines
24 KiB
TypeScript
/**
|
||
* SettingsModal - 设置面板组件
|
||
*/
|
||
|
||
import { state, KEYS } from '../state/state.js';
|
||
import { debounce } from '../utils/utils.js';
|
||
import { encryptData, decryptData } from '../services/crypto.js';
|
||
import { setMemoryEnabled, setEmbeddingModel, getEmbeddingModel, getMemoryCache } from '../services/memory-manager.js';
|
||
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
|
||
import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
|
||
import { loadModels } 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);
|
||
|
||
// ── 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]);
|
||
});
|
||
|
||
|
||
// ── Agent 记忆设置 ──
|
||
const toggleMemory = document.querySelector('#toggleMemoryEnabled') as HTMLInputElement;
|
||
if (toggleMemory) {
|
||
toggleMemory.addEventListener('change', () => {
|
||
setMemoryEnabled(toggleMemory.checked);
|
||
showToast(toggleMemory.checked ? 'Agent 记忆已开启' : 'Agent 记忆已关闭', 'info');
|
||
logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭');
|
||
});
|
||
}
|
||
|
||
// ── 向量记忆引擎设置 ──
|
||
const selectEmbedModel = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
|
||
if (selectEmbedModel) {
|
||
selectEmbedModel.addEventListener('change', async () => {
|
||
const model = selectEmbedModel.value;
|
||
await setEmbeddingModel(model);
|
||
if (model) {
|
||
showToast(`向量记忆已启用(${model})`, 'success');
|
||
logSetting('向量记忆引擎', `启用: ${model}`);
|
||
} else {
|
||
showToast('向量记忆已关闭,仅使用关键词匹配', 'info');
|
||
logSetting('向量记忆引擎', '关闭');
|
||
}
|
||
updateMemoryVectorStatus();
|
||
});
|
||
}
|
||
|
||
// ── 工作空间目录设置 ──
|
||
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.openFile({
|
||
filters: [{ name: '文件夹', extensions: ['*'] }]
|
||
});
|
||
// Electron openFile 对话框不能直接选文件夹,这里用 saveFile 代替
|
||
// 或者让用户手动输入路径
|
||
showToast('请在输入框中直接修改路径', 'info');
|
||
});
|
||
// 双击输入框解锁编辑
|
||
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!);
|
||
} else {
|
||
showToast(`设置失败: ${result.error!}`, 'error');
|
||
// 恢复原值
|
||
const info = await bridge.workspace.getDir();
|
||
inputWorkspaceDir.value = info.dir;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export function openSettingsModal(): void {
|
||
settingsModalEl.style.display = '';
|
||
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||
updateConnectionInfo();
|
||
updateRunningModels();
|
||
populateMemoryEmbedModels();
|
||
populateSubAgentModels();
|
||
updateMemoryVectorStatus();
|
||
loadTimeoutSettings();
|
||
loadWatchdogSetting();
|
||
}
|
||
|
||
/** 加载看门狗设置到输入框 */
|
||
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 populateMemoryEmbedModels(): Promise<void> {
|
||
const select = document.querySelector('#selectMemoryEmbedModel') as HTMLSelectElement;
|
||
if (!select) return;
|
||
const api = state.get<OllamaAPI>(KEYS.API);
|
||
if (!api) return;
|
||
|
||
try {
|
||
const data = await api.listModels();
|
||
select.innerHTML = '<option value="">未选择(仅关键词模式)</option>';
|
||
if (!data.models || data.models.length === 0) return;
|
||
|
||
// 获取每个模型的能力信息,筛选嵌入模型
|
||
const checks = await Promise.allSettled(
|
||
data.models.map(async (m) => {
|
||
const info = await api.showModel(m.name);
|
||
const caps = info.capabilities || [];
|
||
return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') };
|
||
})
|
||
);
|
||
|
||
const embedModels = checks
|
||
.filter(r => r.status === 'fulfilled' && r.value.isEmbed)
|
||
.map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value);
|
||
|
||
embedModels.forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.name;
|
||
opt.textContent = m.name;
|
||
select.appendChild(opt);
|
||
});
|
||
|
||
if (embedModels.length === 0) {
|
||
select.innerHTML = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
|
||
return;
|
||
}
|
||
|
||
// 恢复已保存的选择
|
||
const saved = getEmbeddingModel();
|
||
if (saved) select.value = saved;
|
||
} catch (err) {
|
||
logWarn('加载嵌入模型列表失败', (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);
|
||
}
|
||
}
|
||
|
||
// 子代理模型设置保存
|
||
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);
|
||
|
||
// ── v0.12.1 看门狗超时(分钟,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);
|
||
|
||
function updateMemoryVectorStatus(): void {
|
||
const statusEl = document.querySelector('#memoryVectorStatus');
|
||
if (!statusEl) return;
|
||
const model = getEmbeddingModel();
|
||
if (model) {
|
||
const memCount = getMemoryCache().length;
|
||
statusEl.textContent = `✅ 向量搜索已启用(${model})· ${memCount} 条记忆`;
|
||
(statusEl as HTMLElement).style.color = 'var(--success)';
|
||
} else {
|
||
statusEl.textContent = 'ℹ️ 未选择嵌入模型,仅使用关键词匹配';
|
||
(statusEl as HTMLElement).style.color = '';
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|