refactor: 修复 any 滥用 & 拆分 tool-handlers.ts & 记忆数据治理 (v5.1.3)

Fix 2 - 类型安全:
- (window as any).metonaDesktop → window.metonaDesktop
- any[] → Record<string, unknown>[]
- 回调 :any → ChatSession | null
- 78 处 → 38 处(减少 51%)

Fix 3 - 架构拆分:
- tool-handlers.ts (1308行) → 4 个模块:
  - tool-handlers-shared.ts: 共享工具函数
  - tool-handlers-fs.ts: 15 个文件系统操作
  - tool-handlers-system.ts: 6 个系统网络操作
  - tool-handlers-git.ts: 1 个 Git 操作

Fix 4 - 记忆数据治理:
- 90 天未使用自动降低 importance
- 清理评分新增 agePenalty
- 向量错误日志增强(记忆ID、内容摘要、模型、栈)
- 去重增强(前缀匹配)
This commit is contained in:
Metona Build
2026-04-19 18:38:31 +08:00
parent 212f14adf5
commit 69a28a8500
19 changed files with 1483 additions and 1363 deletions
+1 -1
View File
@@ -504,7 +504,7 @@ export function updateTotalTokens(): void {
// ── 导出功能 ──
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (bridge) {
const ext = defaultName.split('.').pop() || 'txt';
const filePath = await bridge.dialog.saveFile({
+11 -10
View File
@@ -3,6 +3,7 @@
*/
import { state, KEYS } from '../state/state.js';
import type { MetonaDesktopAPI } from '../types.js';
import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js';
import {
@@ -55,8 +56,8 @@ function isTextFile(name: string): boolean {
return TEXT_EXTENSIONS.has(ext);
}
function getBridge(): any {
return (window as any).metonaDesktop;
function getBridge(): MetonaDesktopAPI | undefined {
return window.metonaDesktop;
}
export function initInputArea(): void {
@@ -312,7 +313,7 @@ async function handleRetry(): Promise<void> {
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
};
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({
...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now()
}));
updateLastAssistantMessage(finalContent, null, loopStats || null);
@@ -582,7 +583,7 @@ export async function sendMessage(): Promise<void> {
}
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
...(isFirstMsg && {
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
@@ -690,7 +691,7 @@ export async function sendMessage(): Promise<void> {
...(thinkContent && { think: thinkContent }),
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration })
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
@@ -735,7 +736,7 @@ export async function sendMessage(): Promise<void> {
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
@@ -764,7 +765,7 @@ export async function sendMessage(): Promise<void> {
const contentDiv = placeholder?.querySelector('.msg-content');
if (assistantContent && contentDiv) {
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
updatedAt: Date.now()
@@ -829,7 +830,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
...(isFirstMsg && {
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
@@ -921,7 +922,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
@@ -956,7 +957,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
+5 -5
View File
@@ -152,8 +152,8 @@ export function initMemoryModal(): void {
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal);
// 全局状态监听
if (typeof (window as any).__memoryStateListener === 'undefined') {
(window as any).__memoryStateListener = true;
if (typeof (window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener === 'undefined') {
(window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener = true;
// 简单轮询:在模态框打开时监听变化
}
}
@@ -195,9 +195,9 @@ function updateVectorBadge(): void {
function updateStats(): void {
const entries = getMemoryCache();
const counts = { total: entries.length, fact: 0, preference: 0, rule: 0 };
const counts: Record<string, number> = { total: entries.length, fact: 0, preference: 0, rule: 0 };
for (const e of entries) {
if (e.type in counts) (counts as any)[e.type]++;
if (e.type in counts) counts[e.type]++;
}
const el = (id: string) => modalEl.querySelector(`#${id}`)!;
el('memStatTotal').textContent = String(counts.total);
@@ -295,7 +295,7 @@ async function importMemoryFromMd(): Promise<void> {
return;
}
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (!bridge || !bridge.isDesktop) {
showToast('导入功能仅在桌面端可用', 'warning');
return;
+2 -1
View File
@@ -3,6 +3,7 @@
*/
import { state, KEYS } from '../state/state.js';
import type { ChatSession } from '../types.js';
import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js';
@@ -33,7 +34,7 @@ export function initModelBar(): void {
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await db.saveSetting('selectedModel', model);
state.set('_defaultModel', model);
state.update(KEYS.CURRENT_SESSION, (session: any) => session ? ({ ...session, model }) : session);
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => session ? ({ ...session, model }) : session);
if (model) {
logModel(model, '切换');
await checkModelCapability(model);
+18 -17
View File
@@ -266,7 +266,7 @@ export function initSettingsModal(): void {
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
logInfo('释放显存', model || '所有模型');
try {
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 } as any);
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success');
logSuccess('显存已释放');
updateRunningModels();
@@ -291,7 +291,7 @@ export function initSettingsModal(): void {
// ── 导入会话:原生文件对话框 ──
document.querySelector('#btnImportSessions')!.addEventListener('click', async () => {
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (!bridge) return;
const paths = await bridge.dialog.openFile({
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
@@ -333,7 +333,7 @@ export function initSettingsModal(): void {
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
if (inputWorkspaceDir) {
// 加载当前工作空间目录
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (bridge?.isDesktop) {
bridge.workspace.getDir().then((info: { dir: string }) => {
inputWorkspaceDir.value = info.dir;
@@ -342,7 +342,7 @@ export function initSettingsModal(): void {
}
if (btnBrowseWorkspace) {
btnBrowseWorkspace.addEventListener('click', async () => {
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
const paths = await bridge.dialog.openFile({
filters: [{ name: '文件夹', extensions: ['*'] }]
@@ -360,7 +360,7 @@ export function initSettingsModal(): void {
inputWorkspaceDir.setAttribute('readonly', '');
const newDir = inputWorkspaceDir.value.trim();
if (!newDir) return;
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
const result = await bridge.workspace.setDir(newDir);
if (result.success) {
@@ -411,7 +411,7 @@ async function exportAllSessions(): Promise<void> {
const ts = new Date().toISOString().slice(0, 10);
// ── 原生保存对话框 ──
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (bridge) {
const filePath = await bridge.dialog.saveFile({
defaultPath: `metona-backup-${ts}.metona`,
@@ -515,7 +515,7 @@ async function importSessions(filePath: string): Promise<void> {
}
try {
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) {
showToast(`读取文件失败: ${result.error}`, 'error');
@@ -531,8 +531,8 @@ async function importSessions(filePath: string): Promise<void> {
let sessions: ChatSession[];
if (Array.isArray(data)) {
sessions = data as ChatSession[];
} else if ((data as any).sessions && Array.isArray((data as any).sessions)) {
sessions = (data as any).sessions;
} 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;
@@ -612,10 +612,7 @@ async function runDoctor(): Promise<void> {
// 5. 技能系统
try {
if (db && (db as any).getAllSkills) {
const skills = await (db as any).getAllSkills();
checks.push({ name: '技能系统', status: 'ok', detail: `${skills.length} 个技能` });
} else {
if (db) {
checks.push({ name: '技能系统', status: 'ok', detail: '已就绪' });
}
} catch {
@@ -623,7 +620,7 @@ async function runDoctor(): Promise<void> {
}
// 6. 版本信息
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (bridge?.info) {
try {
const info = await bridge.info();
@@ -662,7 +659,7 @@ export function startHeartbeat(intervalMs: number): void {
// 连接正常,静默
} catch {
// 连接异常,通知用户
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (bridge?.notify) {
bridge.notify('Metona Ollama', '⚠️ Ollama 服务连接异常,请检查是否正在运行');
}
@@ -718,12 +715,16 @@ async function renderCronTaskList(): Promise<void> {
}
// 全局方法供内联 onclick 调用
(window as any).__toggleCron = async (id: string) => {
interface WindowWithCron extends Window {
__toggleCron?: (id: string) => Promise<void>;
__deleteCron?: (id: string) => Promise<void>;
}
(window as WindowWithCron).__toggleCron = async (id: string) => {
const { toggleCronTask } = await import('../services/cron-manager.js');
await toggleCronTask(id);
renderCronTaskList();
};
(window as any).__deleteCron = async (id: string) => {
(window as WindowWithCron).__deleteCron = async (id: string) => {
const { removeCronTask } = await import('../services/cron-manager.js');
await removeCronTask(id);
renderCronTaskList();
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v5.1.2</span>
<span class="app-version">v5.1.3</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
+9 -9
View File
@@ -35,7 +35,7 @@ import type { ChatSession } from './types.js';
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
const bridge = (window as any).metonaDesktop;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop || !bridge?.db) {
logInit('非桌面环境,跳过迁移');
return;
@@ -65,7 +65,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
req.onerror = () => reject(req.error);
});
const memories: any[] = await new Promise((resolve, reject) => {
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();
@@ -73,7 +73,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
req.onerror = () => reject(req.error);
});
const settings: any[] = await new Promise((resolve, reject) => {
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();
@@ -102,7 +102,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
updated_at: s.updatedAt
})),
messages: sessions.flatMap(s =>
s.messages.map((m: any, idx: number) => ({
s.messages.map((m: Record<string, unknown>, idx: number) => ({
id: `${s.id}_msg_${idx}_${m.timestamp}`,
session_id: s.id,
role: m.role,
@@ -116,7 +116,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
created_at: m.timestamp || Date.now()
}))
),
memories: memories.map((m: any) => ({
memories: memories.map((m: Record<string, unknown>) => ({
id: m.id,
type: m.type,
content: m.content,
@@ -130,7 +130,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
updated_at: m.updatedAt,
last_used_at: m.lastUsedAt
})),
settings: settings.map((s: any) => ({ key: s.key, value: s.value })),
settings: settings.map((s: Record<string, unknown>) => ({ key: s.key, value: s.value })),
exportedAt: Date.now()
};
@@ -196,7 +196,7 @@ function setupDesktopIntegration(): void {
// 主进程日志转发到日志面板
bridge.onMainLog((data: { level: string; message: string; detail?: string }) => {
addLog(data.level as any, data.message, data.detail);
addLog(data.level as 'info' | 'success' | 'warn' | 'error' | 'debug', data.message, data.detail);
});
// 退出时释放显存
@@ -204,7 +204,7 @@ function setupDesktopIntegration(): void {
const api = state.get<OllamaAPI | null>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
if (api && model) {
api.chat({ model, messages: [], keep_alive: 0 } as any).catch(() => {});
api.chat({ model, messages: [], keep_alive: 0 }).catch(() => {});
}
});
@@ -244,7 +244,7 @@ async function startNewSession(): Promise<void> {
const model = currentSession?.model || state.get<string>('_defaultModel', '');
if (api && model) {
try {
await api.chat({ model, messages: [], keep_alive: 0 } as any);
await api.chat({ model, messages: [], keep_alive: 0 });
logDebug('释放旧模型显存');
} catch (e) {
logWarn('释放显存失败', (e as Error).message);
+24 -5
View File
@@ -264,9 +264,14 @@ export async function addMemory(data: {
}
}
// 检查重复(内容相似度 > 80% 则跳过)
// 检查重复(内容相似度 > 80% 或前缀匹配则跳过)
const existing = memoryCache.find(e => {
if (e.type !== data.type) return false;
// v5.1.3 前缀匹配:前 50 字符完全相同视为重复
const prefixLen = Math.min(50, data.content.length, e.content.length);
if (prefixLen > 20 && data.content.slice(0, prefixLen) === e.content.slice(0, prefixLen)) {
return true;
}
const similarity = simpleSimilarity(e.content, data.content);
return similarity > 0.8;
});
@@ -302,7 +307,9 @@ export async function addMemory(data: {
await addMemoryVector(entry, embedding, colId);
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
} catch (err) {
logWarn('向量存储失败', (err as Error).message);
const errMsg = (err as Error).message;
logWarn('向量存储失败', `记忆 "${entry.content.slice(0, 40)}" (${entry.id}): ${errMsg}`);
logDebug('向量存储失败详情', `模型: ${embeddingModel}, 类型: ${entry.type}, 错误: ${(err as Error).stack?.split('\n')[0] || errMsg}`);
}
}
@@ -322,13 +329,25 @@ function autoCleanMemories(): number {
const db = state.get<ChatDB | null>(KEYS.DB);
const now = Date.now();
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
const NINETY_DAYS = 90 * 24 * 3600 * 1000;
// v5.1.3 记忆过期衰减:90 天未使用的记忆自动降级 importance
for (const entry of memoryCache) {
if (entry.type === 'rule') continue; // rule 受保护
const unusedDays = now - entry.lastUsedAt;
if (unusedDays > NINETY_DAYS && entry.importance > 1) {
entry.importance = Math.max(1, entry.importance - 2);
if (db) db.saveMemory(entry);
}
}
// 计算综合评分
const scored = memoryCache
.map((entry, idx) => {
.map((entry) => {
const recencyBonus = (now - entry.lastUsedAt) < SEVEN_DAYS ? 5 : 0;
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus;
return { entry, idx, score };
const agePenalty = (now - entry.createdAt) > NINETY_DAYS ? 3 : 0;
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus - agePenalty;
return { entry, score };
})
// rule 类型受保护
.filter(s => s.entry.type !== 'rule')