核心引擎修复: - 状态转换表补全 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
521 lines
22 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
|