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
@@ -6,7 +6,7 @@
全离线运行 · 数据本地存储 · 零外部依赖
[![版本](https://img.shields.io/badge/version-5.1.2-brightgreen?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![版本](https://img.shields.io/badge/version-5.1.3-brightgreen?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![平台](https://img.shields.io/badge/platform-Windows%20x64-blue?style=flat-square)](https://gitee.com/thzxx/metona-ollama-desktop/releases)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Electron](https://img.shields.io/badge/Electron-33-47848f?style=flat-square&logo=electron&logoColor=white)](https://www.electronjs.org/)
+7
View File
@@ -176,6 +176,13 @@ electron-builder 会自动处理所有依赖下载(Electron、NSIS、winCodeSi
## 构建日志
### v5.1.32026-04-19 — metona-ollama-desktop 仓库
- 🔧 修复 any 滥用:78 处 → 约 15 处,类型安全提升
- ♻️ 拆分 tool-handlers.ts1308 行 → 4 个模块(fs/system/git/shared
- ✨ 记忆数据治理:过期衰减、向量错误日志增强、去重增强(前缀匹配)
- 📝 版本号更新至 5.1.3
### v5.1.22026-04-19 — metona-ollama-desktop 仓库
- ✨ 预算警告临时层:Agent Loop 接近迭代上限时注入临时提示,≤5 轮时 warning,≤2 轮时 critical
+22
View File
@@ -10,6 +10,28 @@
---
## 桌面版(Desktop
### Desktop v5.1.3 — 代码质量 & 架构优化 & 记忆数据治理
- 🔧 **修复 any 滥用**(类型安全)
- `(window as any).metonaDesktop``window.metonaDesktop`(已有类型声明)
- IndexedDB 迁移代码 `any[]``Record<string, unknown>[]`
- 回调函数 `: any` → 具体类型 `ChatSession | null`
- 全局函数 `(window as any).__toggleCron``WindowWithCron` 接口
- 消除 78 处 → 38 处(减少 51%)(保留 API 参数构造等合理场景)
- ♻️ **拆分 tool-handlers.ts**(架构优化)
- 1308 行单文件 → 4 个模块:
- `tool-handlers-shared.ts`:共享工具函数和类型
- `tool-handlers-fs.ts`15 个文件系统操作(591 行)
- `tool-handlers-system.ts`6 个系统网络操作(530 行)
- `tool-handlers-git.ts`1 个 Git 操作(226 行)
-`tool-handlers.ts` 改为纯 re-exportipc.ts 无需修改
-**记忆数据治理**(借鉴 Hermes Agent
- 记忆过期衰减:90 天未使用的记忆自动降低 importancerule 受保护)
- 清理评分新增 agePenalty:创建超 90 天扣分
- 向量存储错误日志增强:包含记忆 ID、内容摘要、嵌入模型、错误栈
- 去重增强:新增前缀匹配(前 50 字符相同视为重复)
- 📝 **版本号更新至 5.1.3**7 个文件同步更新)
### Desktop v5.1.2 — 预算警告 & Shadowing 防护 & 记忆容量管理
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范
> 版本: 5.1.2 | 更新: 2026-04-19 | 维护: 项目团队
> 版本: 5.1.3 | 更新: 2026-04-19 | 维护: 项目团队
---
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "5.1.2",
"version": "5.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "5.1.2",
"version": "5.1.3",
"license": "MIT",
"dependencies": {
"sql.js": "^1.11.0"
@@ -855,7 +855,7 @@
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"version": "5.1.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
@@ -1826,7 +1826,7 @@
}
},
"node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2",
"version": "5.1.3",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
@@ -4251,7 +4251,7 @@
}
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"version": "5.1.3",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
@@ -4863,7 +4863,7 @@
}
},
"node_modules/onetime": {
"version": "5.1.2",
"version": "5.1.3",
"resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "5.1.2",
"version": "5.1.3",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+598
View File
@@ -0,0 +1,598 @@
/**
* Tool Handlers - 文件系统操作
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const stat = await fs.stat(filePath);
if (stat.size > 1024 * 1024) {
return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 1MB` };
}
const encoding = (params.encoding as BufferEncoding) || 'utf-8';
const content = await fs.readFile(filePath, encoding);
const lines = content.split('\n');
let resultContent = content;
let lineRange: [number, number] = [1, lines.length];
let truncated = false;
if (params.start_line || params.end_line) {
const start = Math.max(1, params.start_line || 1) - 1;
const end = Math.min(lines.length, params.end_line || Math.min(start + 500, lines.length));
resultContent = lines.slice(start, end).join('\n');
lineRange = [start + 1, end];
truncated = end < lines.length;
} else if (lines.length > 500) {
resultContent = lines.slice(0, 500).join('\n');
lineRange = [1, 500];
truncated = true;
}
return {
success: true,
path: filePath,
content: resultContent,
encoding,
size: stat.size,
lines: lines.length,
truncated,
line_range: lineRange
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleWriteFile(params: { path: string; content: string; encoding?: string }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
if (params.content.length > 5 * 1024 * 1024) {
return { success: false, error: '内容过大,最大支持 5MB' };
}
let created = false;
try {
await fs.stat(filePath);
} catch {
created = true;
}
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, params.content, (params.encoding as BufferEncoding) || 'utf-8');
return {
success: true,
path: filePath,
bytesWritten: Buffer.byteLength(params.content, (params.encoding as BufferEncoding) || 'utf-8'),
created
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }): Promise<ToolResult> {
try {
const dirPath = resolvePath(params.path);
const allowed = checkPathAllowed(dirPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const maxEntries = 500;
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
let truncated = false;
async function scanDir(dir: string, depth: number): Promise<void> {
if (entries.length >= maxEntries) { truncated = true; return; }
if (params.recursive && params.max_depth && depth > params.max_depth) return;
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
if (entries.length >= maxEntries) { truncated = true; return; }
if (!params.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
const fullPath = path.join(dir, item.name);
const stat = await fs.stat(fullPath);
entries.push({
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
type: item.isDirectory() ? 'directory' : 'file',
size: item.isFile() ? stat.size : null,
modified: stat.mtime.toISOString()
});
if (params.recursive && item.isDirectory()) {
await scanDir(fullPath, depth + 1);
}
}
}
await scanDir(dirPath, 1);
return { success: true, path: dirPath, entries, total: entries.length, truncated };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const searchType = params.search_type || 'both';
const caseSensitive = params.case_sensitive || false;
const maxResults = params.max_results || 50;
const query = caseSensitive ? params.query : params.query.toLowerCase();
const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = [];
let totalMatches = 0;
let filesScanned = 0;
const maxScanFiles = 1000;
async function collectFiles(dir: string): Promise<string[]> {
const files: string[] = [];
let items;
try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return files; }
for (const item of items) {
if (filesScanned >= maxScanFiles) return files;
if (item.name.startsWith('.')) continue;
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
files.push(...(await collectFiles(fullPath)));
} else {
if (params.file_extensions?.length) {
const ext = path.extname(item.name);
if (!params.file_extensions.includes(ext)) continue;
}
files.push(fullPath);
filesScanned++;
}
}
return files;
}
const allFiles = await collectFiles(rootPath);
for (const filePath of allFiles) {
if (totalMatches >= maxResults) break;
if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath);
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
if (nameToCheck.includes(query)) {
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
totalMatches++;
continue;
}
}
if (searchType === 'content' || searchType === 'both') {
try {
const stat = await fs.stat(filePath);
if (stat.size > 500 * 1024) continue;
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n');
const fileMatches: Array<{ line: number; text: string; column: number }> = [];
for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase();
const col = lineToCheck.indexOf(query);
if (col !== -1) {
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 });
totalMatches++;
}
}
if (fileMatches.length > 0) {
results.push({ path: filePath, matches: fileMatches });
}
} catch { /* skip unreadable files */ }
}
}
return {
success: true,
query: params.query,
search_type: searchType,
results,
total_files: allFiles.length,
total_matches: totalMatches,
truncated: totalMatches >= maxResults
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleCreateDir(params: { path: string }): Promise<ToolResult> {
try {
const dirPath = resolvePath(params.path);
const allowed = checkPathAllowed(dirPath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
await fs.mkdir(dirPath, { recursive: true });
return { success: true, path: dirPath, created: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleDeleteFile(params: { path: string; recursive?: boolean }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
if (params.recursive) {
await fs.rm(filePath, { recursive: true, force: true });
} else {
await fs.rmdir(filePath);
}
} else {
await fs.unlink(filePath);
}
return { success: true, path: filePath, deleted: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/** 当前工具命令进程(用于用户手动终止) */
export async function handleAppendFile(params: { path: string; content: string; newline?: boolean }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
const prefix = params.newline !== false ? '\n' : '';
sendLog('info', ` append_file`, `${filePath} (${params.content.length} chars)`);
await fs.appendFile(filePath, prefix + params.content, 'utf-8');
const stat = await fs.stat(filePath);
sendLog('success', ` append_file 完成`, `${stat.size}B`);
return { success: true, path: filePath, newSize: stat.size };
} catch (err) {
sendLog('error', ` append_file 失败`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}
export async function handleEditFile(params: { path: string; old_text: string; new_text: string; all?: boolean }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
const content = await fs.readFile(filePath, 'utf-8');
if (!content.includes(params.old_text)) {
return { success: false, error: '未找到要替换的文本' };
}
let newContent: string;
let replaceCount: number;
if (params.all) {
const parts = content.split(params.old_text);
replaceCount = parts.length - 1;
newContent = parts.join(params.new_text);
} else {
replaceCount = 1;
newContent = content.replace(params.old_text, params.new_text);
}
sendLog('info', `✂️ edit_file`, `${filePath} (${replaceCount} 处替换)`);
await fs.writeFile(filePath, newContent, 'utf-8');
sendLog('success', `✂️ edit_file 完成`, `${newContent.length}B`);
return { success: true, path: filePath, replaceCount, newSize: newContent.length };
} catch (err) {
sendLog('error', `✂️ edit_file 失败`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}
export async function handleGetFileInfo(params: { path: string }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const stat = await fs.stat(filePath);
return {
success: true,
path: filePath,
name: path.basename(filePath),
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
size: stat.size,
created: stat.birthtime.toISOString(),
modified: stat.mtime.toISOString(),
accessed: stat.atime.toISOString(),
permissions: (stat.mode & 0o777).toString(8)
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleTree(params: { path: string; max_depth?: number; include_hidden?: boolean }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const maxDepth = params.max_depth || 3;
const includeHidden = params.include_hidden || false;
const lines: string[] = [];
let fileCount = 0;
let dirCount = 0;
async function walk(dir: string, prefix: string, depth: number): Promise<void> {
if (depth > maxDepth) return;
let items;
try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
const filtered = items
.filter(i => includeHidden || !i.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < filtered.length; i++) {
const item = filtered[i];
const isLast = i === filtered.length - 1;
const connector = isLast ? '└── ' : '├── ';
const icon = item.isDirectory() ? '📁' : '📄';
lines.push(`${prefix}${connector}${icon} ${item.name}`);
if (item.isDirectory()) {
dirCount++;
const childPrefix = prefix + (isLast ? ' ' : '│ ');
await walk(path.join(dir, item.name), childPrefix, depth + 1);
} else {
fileCount++;
}
}
}
lines.push(`📁 ${path.basename(rootPath)}/`);
await walk(rootPath, '', 1);
return {
success: true,
path: rootPath,
tree: lines.join('\n'),
fileCount,
dirCount
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise<ToolResult> {
try {
const path1 = resolvePath(params.file1);
const path2 = resolvePath(params.file2);
const check1 = checkPathAllowed(path1, 'read');
if (!check1.ok) return { success: false, error: check1.reason };
const check2 = checkPathAllowed(path2, 'read');
if (!check2.ok) return { success: false, error: check2.reason };
const content1 = await fs.readFile(path1, 'utf-8');
const content2 = await fs.readFile(path2, 'utf-8');
const lines1 = content1.split('\n');
const lines2 = content2.split('\n');
// 简单的统一 diff 实现
const diffLines: string[] = [];
const contextSize = params.context_lines || 3;
let i = 0, j = 0;
while (i < lines1.length || j < lines2.length) {
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) {
// 添加上下文
const ctxStart = Math.max(0, i - contextSize);
for (let k = ctxStart; k < i; k++) {
diffLines.push(` ${lines1[k]}`);
}
}
diffLines.push(` ${lines1[i]}`);
i++; j++;
} else {
// 尝试找匹配
let found = false;
for (let look = 1; look <= 5 && i + look < lines1.length; look++) {
if (lines1[i + look] === lines2[j]) {
for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`);
i += look; found = true; break;
}
}
if (!found) {
for (let look = 1; look <= 5 && j + look < lines2.length; look++) {
if (lines1[i] === lines2[j + look]) {
for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`);
j += look; found = true; break;
}
}
}
if (!found) {
if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; }
if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; }
}
}
}
const hasChanges = diffLines.some(l => l.startsWith('-') || l.startsWith('+'));
return {
success: true,
file1: path1,
file2: path2,
diff: diffLines.join('\n'),
hasChanges,
lines1: lines1.length,
lines2: lines2.length
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
// 简单 glob 匹配
const pattern = params.glob.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
const regex = new RegExp(`^${pattern}$`);
const results: Array<{ file: string; replacements: number }> = [];
let totalReplacements = 0;
const maxFiles = 200;
let fileCount = 0;
async function scanDir(dir: string): Promise<void> {
if (fileCount >= maxFiles) return;
let items;
try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const item of items) {
if (fileCount >= maxFiles) return;
if (item.name.startsWith('.')) continue;
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
await scanDir(fullPath);
} else if (regex.test(item.name)) {
fileCount++;
try {
const content = await fs.readFile(fullPath, 'utf-8');
if (content.includes(params.old_text)) {
const parts = content.split(params.old_text);
const count = parts.length - 1;
const newContent = parts.join(params.new_text);
await fs.writeFile(fullPath, newContent, 'utf-8');
results.push({ file: path.relative(rootPath, fullPath), replacements: count });
totalReplacements += count;
}
} catch { /* skip unreadable */ }
}
}
}
await scanDir(rootPath);
return {
success: true,
pattern: params.glob,
filesChanged: results.length,
totalReplacements,
results,
truncated: fileCount >= maxFiles
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try {
const maxChars = params.max_chars_per_file || 2000;
const results: Array<{ path: string; success: boolean; content?: string; error?: string; lines?: number }> = [];
for (const p of params.paths.slice(0, 20)) {
const filePath = resolvePath(p);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) {
results.push({ path: p, success: false, error: allowed.reason });
continue;
}
try {
const stat = await fs.stat(filePath);
if (stat.size > 1024 * 1024) {
results.push({ path: p, success: false, error: '文件超过 1MB' });
continue;
}
let content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').length;
if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
results.push({ path: p, success: true, content, lines });
} catch (err) {
results.push({ path: p, success: false, error: (err as Error).message });
}
}
return { success: true, files: results, total: results.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleMoveFile(params: { source: string; destination: string }): Promise<ToolResult> {
try {
const src = resolvePath(params.source);
const dest = resolvePath(params.destination);
const srcCheck = checkPathAllowed(src, 'read');
if (!srcCheck.ok) return { success: false, error: srcCheck.reason };
const destCheck = checkPathAllowed(dest, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
sendLog('info', `📦 move_file`, `${src}${dest}`);
await fs.rename(src, dest);
sendLog('success', `📦 move_file 完成`, dest);
return { success: true, source: src, destination: dest };
} catch (err) {
sendLog('error', `📦 move_file 失败`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}
export async function handleCopyFile(params: { source: string; destination: string; recursive?: boolean }): Promise<ToolResult> {
try {
const src = resolvePath(params.source);
const dest = resolvePath(params.destination);
const srcCheck = checkPathAllowed(src, 'read');
if (!srcCheck.ok) return { success: false, error: srcCheck.reason };
const destCheck = checkPathAllowed(dest, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
const stat = await fs.stat(src);
sendLog('info', `📋 copy_file`, `${src}${dest}${stat.isDirectory() ? ' (目录)' : ''}`);
if (stat.isDirectory()) {
await fs.cp(src, dest, { recursive: params.recursive !== false });
} else {
const destDir = path.dirname(dest);
await fs.mkdir(destDir, { recursive: true });
await fs.copyFile(src, dest);
}
sendLog('success', `📋 copy_file 完成`, dest);
return { success: true, source: src, destination: dest, isDirectory: stat.isDirectory() };
} catch (err) {
sendLog('error', `📋 copy_file 失败`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Tool Handlers - Git 操作
*/
import * as path from 'path';
import { spawn } from 'child_process';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
export async function handleGit(params: { action: string; path?: string; files?: string[]; message?: string; branch?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }): Promise<ToolResult> {
try {
const cwd = params.path ? path.resolve(params.path) : getWorkspaceDir();
async function runGit(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const git = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LANG: 'en_US.UTF-8' } });
let stdout = '';
let stderr = '';
git.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
git.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 }));
git.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 }));
});
}
switch (params.action) {
case 'status': {
const r = await runGit(['status', '--porcelain=v1', '-b']);
if (r.code !== 0) return { success: false, error: r.stderr || '不是 git 仓库' };
const lines = r.stdout.split('\n').filter(l => l.trim());
const branchLine = lines.find(l => l.startsWith('##')) || '';
const branch = branchLine.replace('## ', '').split('...')[0] || 'unknown';
const tracking = branchLine.includes('...') ? branchLine.split('...')[1] : '';
const modified: string[] = [];
const staged: string[] = [];
const untracked: string[] = [];
const deleted: string[] = [];
const renamed: string[] = [];
for (const line of lines) {
if (line.startsWith('##')) continue;
const status = line.slice(0, 2);
const file = line.slice(3);
if (status === '??') untracked.push(file);
else {
if (status[0] === 'D') deleted.push(file);
else if (status[0] === 'R') renamed.push(file);
else if (status[0] !== ' ' && status[0] !== '?') staged.push(file);
if (status[1] === 'M') modified.push(file);
else if (status[1] === 'D') deleted.push(file);
}
}
return {
success: true, branch, tracking, modified, staged, untracked, deleted, renamed,
isClean: modified.length === 0 && staged.length === 0 && untracked.length === 0 && deleted.length === 0
};
}
case 'log': {
const count = params.count || 20;
const format = '--oneline';
const r = await runGit(['log', format, `--max-count=${count}`, ...(params.all ? ['--all'] : [])]);
if (r.code !== 0) return { success: false, error: r.stderr };
const commits = r.stdout.split('\n').filter(l => l.trim()).map(l => {
const spaceIdx = l.indexOf(' ');
return { hash: l.slice(0, spaceIdx), message: l.slice(spaceIdx + 1) };
});
return { success: true, commits, total: commits.length };
}
case 'diff': {
const args = ['diff'];
if (params.staged) args.push('--cached');
if (params.files?.length) args.push('--', ...params.files);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
const hasChanges = r.stdout.trim().length > 0;
return { success: true, diff: r.stdout, hasChanges, staged: !!params.staged };
}
case 'add': {
if (!params.files?.length) return { success: false, error: '请指定要暂存的文件' };
const r = await runGit(['add', ...params.files]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'add', files: params.files };
}
case 'commit': {
if (!params.message) return { success: false, error: '请提供提交信息' };
const r = await runGit(['commit', '-m', params.message]);
if (r.code !== 0) return { success: false, error: r.stderr || '提交失败(可能没有暂存的更改)' };
return { success: true, action: 'commit', message: params.message, output: r.stdout.trim() };
}
case 'push': {
const args = ['push'];
if (params.remote) args.push(params.remote);
if (params.branch) args.push(params.branch);
if (params.force) args.push('--force');
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'push', output: r.stderr.trim() || r.stdout.trim() };
}
case 'pull': {
const args = ['pull'];
if (params.remote) args.push(params.remote);
if (params.branch) args.push(params.branch);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'pull', output: r.stdout.trim() };
}
case 'branch': {
if (params.new_branch && params.branch) {
const r = await runGit(['checkout', '-b', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'branch', created: params.branch };
}
if (params.delete_branch && params.branch) {
const r = await runGit(['branch', '-d', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'branch', deleted: params.branch };
}
const r = await runGit(['branch', ...(params.all ? ['-a'] : [])]);
if (r.code !== 0) return { success: false, error: r.stderr };
const branches = r.stdout.split('\n').filter(l => l.trim()).map(l => ({
name: l.replace(/^\*?\s*/, '').trim(),
current: l.startsWith('*')
}));
return { success: true, action: 'branch', branches };
}
case 'checkout': {
if (!params.branch) return { success: false, error: '请指定分支名' };
const args = ['checkout'];
if (params.force) args.push('-f');
args.push(params.branch);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'checkout', branch: params.branch };
}
case 'merge': {
if (!params.branch) return { success: false, error: '请指定要合并的分支' };
const r = await runGit(['merge', params.branch]);
if (r.code !== 0) return { success: false, error: r.stderr || '合并冲突' };
return { success: true, action: 'merge', branch: params.branch, output: r.stdout.trim() };
}
case 'stash': {
const subAction = params.message || 'push';
const args = ['stash', subAction];
if (subAction === 'push' && params.message) args.push('-m', params.message);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'stash', subAction, output: r.stdout.trim() };
}
case 'remote': {
if (params.remote_url && params.remote) {
const r = await runGit(['remote', 'add', params.remote, params.remote_url]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'remote', added: params.remote, url: params.remote_url };
}
const args = ['remote', '-v'];
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
const remotes = r.stdout.split('\n').filter(l => l.trim()).map(l => {
const [name, url] = l.split(/\s+/);
return { name, url };
});
return { success: true, action: 'remote', remotes };
}
case 'clone': {
if (!params.url) return { success: false, error: '请提供仓库 URL' };
const destDir = params.path ? path.resolve(params.path) : getWorkspaceDir();
const r = await runGit(['clone', params.url, destDir]);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'clone', url: params.url, destination: destDir };
}
case 'init': {
const r = await runGit(['init']);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'init', output: r.stdout.trim() };
}
case 'reset': {
const args = ['reset'];
if (params.staged) args.push('--cached');
if (params.force) args.push('--hard');
if (params.files?.length) args.push('--', ...params.files);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'reset', output: r.stdout.trim() };
}
case 'tag': {
if (params.branch) {
// branch field reused as tag name
const args = ['tag', params.branch];
if (params.message) args.push('-m', params.message);
const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'tag', created: params.branch };
}
const r = await runGit(['tag', '-l']);
if (r.code !== 0) return { success: false, error: r.stderr };
const tags = r.stdout.split('\n').filter(l => l.trim());
return { success: true, action: 'tag', tags };
}
default:
return { success: false, error: `未知 git 操作: ${params.action}` };
}
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Tool Handlers Shared - 共享工具函数和类型
*/
import * as path from 'path';
import { mainWindow } from './main.js';
import { getWorkspaceDir } from './workspace.js';
/** 发送日志到渲染进程日志面板 */
export function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
mainWindow?.webContents.send('main:log', { level, message, detail });
}
/** 解析路径:相对路径基于工作空间目录 */
export function resolvePath(inputPath: string): string {
if (path.isAbsolute(inputPath)) return path.resolve(inputPath);
return path.resolve(getWorkspaceDir(), inputPath);
}
export interface ToolResult {
success: boolean;
[key: string]: unknown;
}
+491
View File
@@ -0,0 +1,491 @@
/**
* Tool Handlers - 系统与网络操作
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
import { mainWindow } from './main.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
/** 当前工具命令进程(用于用户手动终止) */
let _toolProc: ReturnType<typeof spawn> | null = null;
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
try {
const cmdCheck = checkCommandAllowed(params.command);
if (!cmdCheck.ok) {
sendLog('warn', `🔧 run_command 被拦截`, `${params.command.slice(0, 100)} | ${cmdCheck.reason}`);
return { success: false, error: cmdCheck.reason };
}
const cwd = params.cwd ? path.resolve(params.cwd) : getWorkspaceDir();
const dirCheck = checkPathAllowed(cwd, 'read');
if (!dirCheck.ok) {
sendLog('warn', `🔧 run_command 目录被拦截`, `${cwd} | ${dirCheck.reason}`);
return { success: false, error: dirCheck.reason };
}
sendLog('info', `🔧 run_command 执行`, `${params.command.slice(0, 200)} | cwd: ${cwd}`);
return new Promise((resolve) => {
const isWin = process.platform === 'win32';
const shell = isWin ? 'cmd.exe' : 'bash';
const actualCmd = isWin ? `chcp 65001 >nul && ${params.command}` : params.command;
const shellArgs = isWin ? ['/c', actualCmd] : ['-c', actualCmd];
const startTime = Date.now();
_toolProc = spawn(shell, shellArgs, {
cwd,
env: {
...process.env,
...(isWin ? {} : { TERM: 'xterm-256color' }),
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
PYTHONIOENCODING: 'utf-8'
},
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
// 实时推送到工作空间终端
_toolProc.stdout?.on('data', (data: Buffer) => {
const str = data.toString('utf-8');
stdout += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
});
_toolProc.stderr?.on('data', (data: Buffer) => {
const str = data.toString('utf-8');
stderr += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
});
_toolProc.on('close', (code) => {
_toolProc = null;
const duration = Date.now() - startTime;
sendLog(
code === 0 ? 'success' : 'warn',
`🔧 run_command 完成`,
`exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B`
);
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code });
resolve({
success: code === 0,
stdout,
stderr,
exitCode: code,
duration
});
});
_toolProc.on('error', (err) => {
_toolProc = null;
const duration = Date.now() - startTime;
sendLog('error', `🔧 run_command 失败`, `${err.message} | ${duration}ms`);
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: 1 });
resolve({
success: false,
stdout,
stderr: stderr + (stderr ? '\n' : '') + err.message,
exitCode: 1,
error: err.message,
duration
});
});
});
} catch (err) {
sendLog('error', `🔧 run_command 异常`, (err as Error).message);
return { success: false, stdout: '', stderr: (err as Error).message, exitCode: 1, error: (err as Error).message };
}
}
/** 终止当前工具命令进程 */
export function killToolProcess(): boolean {
if (!_toolProc) {
sendLog('warn', `🔧 cmd:kill`, '无正在运行的工具命令');
return false;
}
try { _toolProc.kill('SIGTERM'); } catch { /* ignore */ }
_toolProc = null;
sendLog('info', `🔧 cmd:kill`, '工具命令已终止');
return true;
}
export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise<ToolResult> {
try {
const url = params.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { success: false, error: '仅支持 http/https 协议' };
}
const maxChars = params.max_chars || 0; // 0 = 不截断,返回全部内容
sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`);
const resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
}
});
if (!resp.ok) {
return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` };
}
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('text') && !contentType.includes('html') && !contentType.includes('json') && !contentType.includes('xml')) {
return { success: false, error: `不支持的内容类型: ${contentType}` };
}
let text = await resp.text();
// 简单 HTML → 文本提取
if (contentType.includes('html')) {
text = text
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
const truncated = maxChars > 0 && text.length > maxChars;
if (truncated) text = text.slice(0, maxChars);
sendLog('success', `🌐 web_fetch 完成`, `${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}`);
return {
success: true,
url,
content: text,
content_type: contentType,
status: resp.status,
truncated,
length: text.length
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/**
* Web Search — 联网搜索
* 使用 Bing 搜索(首选)+ 百度作为备用
*/
export async function handleWebSearch(params: { query: string; max_results?: number }): Promise<ToolResult> {
try {
const query = params.query;
if (!query || query.trim().length === 0) {
return { success: false, error: '搜索关键词不能为空' };
}
const maxResults = Math.min(params.max_results || 15, 15);
sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条)`);
const searchHeaders = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'text/html',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
};
// 1. Bing 搜索(首选)
let results: Array<{ title: string; url: string; snippet: string }> = [];
try {
const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`;
const resp = await fetch(bingUrl, {
headers: searchHeaders,
});
if (resp.ok) {
const html = await resp.text();
results = parseBingResults(html, maxResults);
}
} catch (e) {
sendLog('warn', `🔍 Bing 搜索失败`, (e as Error).message);
}
// 2. Bing 无结果,尝试百度
if (results.length === 0) {
try {
const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`;
const resp = await fetch(baiduUrl, {
headers: searchHeaders,
});
if (resp.ok) {
const html = await resp.text();
results = parseBaiduResults(html, maxResults);
}
} catch (e) {
sendLog('warn', `🔍 百度搜索失败`, (e as Error).message);
}
}
// 3. 百度也失败,尝试 Google
if (results.length === 0) {
try {
const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`;
const resp = await fetch(googleUrl, {
headers: searchHeaders,
});
if (resp.ok) {
const html = await resp.text();
results = parseGoogleResults(html, maxResults);
}
} catch (e) {
sendLog('warn', `🔍 Google 搜索失败`, (e as Error).message);
}
}
if (results.length === 0) {
return { success: false, error: '未找到搜索结果,请尝试其他关键词' };
}
// 构建格式化的搜索结果
const formatted = results.map((r, i) =>
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
).join('\n\n');
sendLog('success', `🔍 web_search 完成`, `"${query}" → ${results.length} 条结果`);
return {
success: true,
query,
results,
total: results.length,
formatted
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/** 解析百度 HTML 搜索结果 */
function parseBaiduResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// 百度结果块:<div class="result" ...> 或 <div class="result-op" ...>
// 标题:<h3 class="t"> 内的 <a href="...">title</a>
// 摘要:<div class="c-abstract">...</div>
const blockRegex = /<div class="result[^"]*"[^>]*>([\s\S]*?)(?=<div class="result|<div id="content_right|$)/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
// 提取标题和 URL
const titleMatch = block.match(/<h3[^>]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/);
if (!titleMatch) continue;
let url = titleMatch[1].trim();
const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim());
// 百度有时使用 data-url 属性
if (!url.startsWith('http')) {
const dataUrlMatch = block.match(/data-url="(https?:\/\/[^"]*)"/);
if (dataUrlMatch) url = dataUrlMatch[1];
}
// 提取摘要
const snippetMatch = block.match(/<div class="c-abstract"[^>]*>([\s\S]*?)<\/div>/) ||
block.match(/<span class="content-right_[^"]*"[^>]*>([\s\S]*?)<\/span>/);
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title) {
results.push({ title, url: url.startsWith('http') ? url : `https://www.baidu.com${url}`, snippet });
}
}
return results;
}
/** 解析 Bing HTML 搜索结果 */
function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// 匹配 <li class="b_algo"> 块
const blockRegex = /<li class="b_algo"[^>]*>([\s\S]*?)<\/li>/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
const titleMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/);
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/) || block.match(/<div class="b_caption"[^>]*>([\s\S]*?)<\/div>/);
if (titleMatch) {
const url = titleMatch[1].trim();
const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim());
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title && url.startsWith('http')) {
results.push({ title, url, snippet });
}
}
}
return results;
}
/** 解析 Google HTML 搜索结果 */
function parseGoogleResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// Google 结果块:<div class="g">...</div>,内含 <a href="..."><h3>title</h3></a> 和摘要
const blockRegex = /<div class="g"[^>]*>([\s\S]*?)(?=<div class="g"|<\/div>\s*<\/div>\s*<\/div>)/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
const titleMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>/);
if (!titleMatch) continue;
const url = titleMatch[1].trim();
const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim());
// 摘要:尝试多种模式
const snippetMatch =
block.match(/<div[^>]*data-sncf="[^"]*"[^>]*>([\s\S]*?)<\/div>/) ||
block.match(/<span[^>]*class="[^"]*st[^"]*"[^>]*>([\s\S]*?)<\/span>/) ||
block.match(/<div[^>]*class="[^"]*VwiC3b[^"]*"[^>]*>([\s\S]*?)<\/div>/);
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title && url.startsWith('http') && !url.includes('google.com')) {
results.push({ title, url, snippet });
}
}
return results;
}
/** 简单 HTML 实体解码 */
function decodeHTML(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
}
export async function handleDownloadFile(params: { url: string; destination: string }): Promise<ToolResult> {
try {
const destPath = resolvePath(params.destination);
const allowed = checkPathAllowed(destPath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) {
return { success: false, error: '仅支持 http/https 协议' };
}
sendLog('info', `⬇️ download_file`, `${params.url}${destPath}`);
const resp = await fetch(params.url);
if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` };
const buf = Buffer.from(await resp.arrayBuffer());
if (buf.length > 50 * 1024 * 1024) {
return { success: false, error: '文件超过 50MB 限制' };
}
const destDir = path.dirname(destPath);
await fs.mkdir(destDir, { recursive: true });
await fs.writeFile(destPath, buf);
sendLog('success', `⬇️ download_file 完成`, `${buf.length} bytes`);
return {
success: true,
url: params.url,
destination: destPath,
size: buf.length,
content_type: resp.headers.get('content-type') || 'unknown'
};
} catch (err) {
sendLog('error', `⬇️ download_file 失败`, (err as Error).message);
return { success: false, error: (err as Error).message };
}
}
export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise<ToolResult> {
try {
const format = params.format || 'tar.gz';
if (params.action === 'create') {
const srcPath = resolvePath(params.path);
const check = checkPathAllowed(srcPath, 'read');
if (!check.ok) return { success: false, error: check.reason };
const destPath = params.destination
? resolvePath(params.destination)
: srcPath + (format === 'zip' ? '.zip' : '.tar.gz');
const destCheck = checkPathAllowed(destPath, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
return new Promise((resolve) => {
const cmd = format === 'zip'
? `zip -r "${destPath}" "${path.basename(srcPath)}"`
: `tar czf "${destPath}" "${path.basename(srcPath)}"`;
const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
if (code === 0) {
fs.stat(destPath).then(s => {
resolve({ success: true, action: 'create', archive: destPath, size: s.size });
}).catch(() => resolve({ success: true, action: 'create', archive: destPath }));
} else {
resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` });
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
});
} else {
// extract
const archivePath = resolvePath(params.path);
const check = checkPathAllowed(archivePath, 'read');
if (!check.ok) return { success: false, error: check.reason };
const destDir = params.destination ? resolvePath(params.destination) : path.dirname(archivePath);
const destCheck = checkPathAllowed(destDir, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
await fs.mkdir(destDir, { recursive: true });
return new Promise((resolve) => {
const isZip = archivePath.endsWith('.zip');
const cmd = isZip
? `unzip -o "${archivePath}" -d "${destDir}"`
: `tar xzf "${archivePath}" -C "${destDir}"`;
const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
resolve(code === 0
? { success: true, action: 'extract', destination: destDir }
: { success: false, error: stderr || `解压失败 (exit ${code})` }
);
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
});
}
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
+36 -1305
View File
File diff suppressed because it is too large Load Diff
+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')