Files
metona-ollama-desktop/src/renderer/components/settings-modal.ts
T
紫影233 042e00e4a6 v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新
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
2026-06-23 17:06:43 +08:00

583 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}