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:
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
全离线运行 · 数据本地存储 · 零外部依赖
|
全离线运行 · 数据本地存储 · 零外部依赖
|
||||||
|
|
||||||
[](https://gitee.com/thzxx/metona-ollama-desktop/releases)
|
[](https://gitee.com/thzxx/metona-ollama-desktop/releases)
|
||||||
[](https://gitee.com/thzxx/metona-ollama-desktop/releases)
|
[](https://gitee.com/thzxx/metona-ollama-desktop/releases)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ electron-builder 会自动处理所有依赖下载(Electron、NSIS、winCodeSi
|
|||||||
|
|
||||||
## 构建日志
|
## 构建日志
|
||||||
|
|
||||||
|
### v5.1.3(2026-04-19) — metona-ollama-desktop 仓库
|
||||||
|
|
||||||
|
- 🔧 修复 any 滥用:78 处 → 约 15 处,类型安全提升
|
||||||
|
- ♻️ 拆分 tool-handlers.ts:1308 行 → 4 个模块(fs/system/git/shared)
|
||||||
|
- ✨ 记忆数据治理:过期衰减、向量错误日志增强、去重增强(前缀匹配)
|
||||||
|
- 📝 版本号更新至 5.1.3
|
||||||
|
|
||||||
### v5.1.2(2026-04-19) — metona-ollama-desktop 仓库
|
### v5.1.2(2026-04-19) — metona-ollama-desktop 仓库
|
||||||
|
|
||||||
- ✨ 预算警告临时层:Agent Loop 接近迭代上限时注入临时提示,≤5 轮时 warning,≤2 轮时 critical
|
- ✨ 预算警告临时层:Agent Loop 接近迭代上限时注入临时提示,≤5 轮时 warning,≤2 轮时 critical
|
||||||
|
|||||||
@@ -10,6 +10,28 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 桌面版(Desktop)
|
## 桌面版(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-export,ipc.ts 无需修改
|
||||||
|
- ✨ **记忆数据治理**(借鉴 Hermes Agent)
|
||||||
|
- 记忆过期衰减:90 天未使用的记忆自动降低 importance(rule 受保护)
|
||||||
|
- 清理评分新增 agePenalty:创建超 90 天扣分
|
||||||
|
- 向量存储错误日志增强:包含记忆 ID、内容摘要、嵌入模型、错误栈
|
||||||
|
- 去重增强:新增前缀匹配(前 50 字符相同视为重复)
|
||||||
|
- 📝 **版本号更新至 5.1.3**(7 个文件同步更新)
|
||||||
|
|
||||||
|
|
||||||
### Desktop v5.1.2 — 预算警告 & Shadowing 防护 & 记忆容量管理
|
### Desktop v5.1.2 — 预算警告 & Shadowing 防护 & 记忆容量管理
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Metona Ollama Desktop — 开发规范
|
# Metona Ollama Desktop — 开发规范
|
||||||
|
|
||||||
> 版本: 5.1.2 | 更新: 2026-04-19 | 维护: 项目团队
|
> 版本: 5.1.3 | 更新: 2026-04-19 | 维护: 项目团队
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "5.1.2",
|
"version": "5.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "5.1.2",
|
"version": "5.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sql.js": "^1.11.0"
|
"sql.js": "^1.11.0"
|
||||||
@@ -855,7 +855,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
|
||||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1826,7 +1826,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4251,7 +4251,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4863,7 +4863,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
|
||||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "5.1.2",
|
"version": "5.1.3",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /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
File diff suppressed because it is too large
Load Diff
@@ -504,7 +504,7 @@ export function updateTotalTokens(): void {
|
|||||||
// ── 导出功能 ──
|
// ── 导出功能 ──
|
||||||
|
|
||||||
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {
|
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (bridge) {
|
if (bridge) {
|
||||||
const ext = defaultName.split('.').pop() || 'txt';
|
const ext = defaultName.split('.').pop() || 'txt';
|
||||||
const filePath = await bridge.dialog.saveFile({
|
const filePath = await bridge.dialog.saveFile({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
|
import type { MetonaDesktopAPI } from '../types.js';
|
||||||
import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
|
import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
|
||||||
import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js';
|
import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js';
|
||||||
import {
|
import {
|
||||||
@@ -55,8 +56,8 @@ function isTextFile(name: string): boolean {
|
|||||||
return TEXT_EXTENSIONS.has(ext);
|
return TEXT_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBridge(): any {
|
function getBridge(): MetonaDesktopAPI | undefined {
|
||||||
return (window as any).metonaDesktop;
|
return window.metonaDesktop;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initInputArea(): void {
|
export function initInputArea(): void {
|
||||||
@@ -312,7 +313,7 @@ async function handleRetry(): Promise<void> {
|
|||||||
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(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()
|
...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
updateLastAssistantMessage(finalContent, null, loopStats || null);
|
updateLastAssistantMessage(finalContent, null, loopStats || null);
|
||||||
@@ -582,7 +583,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirstMsg = currentSession.messages.length === 0;
|
const isFirstMsg = currentSession.messages.length === 0;
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
...(isFirstMsg && {
|
...(isFirstMsg && {
|
||||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
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 }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration })
|
...(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,
|
...session,
|
||||||
messages: [...session.messages, assistantMsg],
|
messages: [...session.messages, assistantMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -735,7 +736,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
stopped: true
|
stopped: true
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, partialMsg],
|
messages: [...session.messages, partialMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -764,7 +765,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
const contentDiv = placeholder?.querySelector('.msg-content');
|
const contentDiv = placeholder?.querySelector('.msg-content');
|
||||||
|
|
||||||
if (assistantContent && contentDiv) {
|
if (assistantContent && contentDiv) {
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
|
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -829,7 +830,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirstMsg = currentSession.messages.length === 0;
|
const isFirstMsg = currentSession.messages.length === 0;
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
...(isFirstMsg && {
|
...(isFirstMsg && {
|
||||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
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?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, assistantMsg],
|
messages: [...session.messages, assistantMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -956,7 +957,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
stopped: true
|
stopped: true
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, partialMsg],
|
messages: [...session.messages, partialMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ export function initMemoryModal(): void {
|
|||||||
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal);
|
document.querySelector('#btnMemory')?.addEventListener('click', openMemoryModal);
|
||||||
|
|
||||||
// 全局状态监听
|
// 全局状态监听
|
||||||
if (typeof (window as any).__memoryStateListener === 'undefined') {
|
if (typeof (window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener === 'undefined') {
|
||||||
(window as any).__memoryStateListener = true;
|
(window as unknown as { __memoryStateListener?: boolean }).__memoryStateListener = true;
|
||||||
// 简单轮询:在模态框打开时监听变化
|
// 简单轮询:在模态框打开时监听变化
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,9 +195,9 @@ function updateVectorBadge(): void {
|
|||||||
|
|
||||||
function updateStats(): void {
|
function updateStats(): void {
|
||||||
const entries = getMemoryCache();
|
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) {
|
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}`)!;
|
const el = (id: string) => modalEl.querySelector(`#${id}`)!;
|
||||||
el('memStatTotal').textContent = String(counts.total);
|
el('memStatTotal').textContent = String(counts.total);
|
||||||
@@ -295,7 +295,7 @@ async function importMemoryFromMd(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge || !bridge.isDesktop) {
|
if (!bridge || !bridge.isDesktop) {
|
||||||
showToast('导入功能仅在桌面端可用', 'warning');
|
showToast('导入功能仅在桌面端可用', 'warning');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
|
import type { ChatSession } from '../types.js';
|
||||||
import { formatSize } from '../utils/utils.js';
|
import { formatSize } from '../utils/utils.js';
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
import { ChatDB } from '../db/chat-db.js';
|
import { ChatDB } from '../db/chat-db.js';
|
||||||
@@ -33,7 +34,7 @@ export function initModelBar(): void {
|
|||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) await db.saveSetting('selectedModel', model);
|
if (db) await db.saveSetting('selectedModel', model);
|
||||||
state.set('_defaultModel', 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) {
|
if (model) {
|
||||||
logModel(model, '切换');
|
logModel(model, '切换');
|
||||||
await checkModelCapability(model);
|
await checkModelCapability(model);
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export function initSettingsModal(): void {
|
|||||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||||
logInfo('释放显存', model || '所有模型');
|
logInfo('释放显存', model || '所有模型');
|
||||||
try {
|
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');
|
showToast('显存已释放', 'success');
|
||||||
logSuccess('显存已释放');
|
logSuccess('显存已释放');
|
||||||
updateRunningModels();
|
updateRunningModels();
|
||||||
@@ -291,7 +291,7 @@ export function initSettingsModal(): void {
|
|||||||
|
|
||||||
// ── 导入会话:原生文件对话框 ──
|
// ── 导入会话:原生文件对话框 ──
|
||||||
document.querySelector('#btnImportSessions')!.addEventListener('click', async () => {
|
document.querySelector('#btnImportSessions')!.addEventListener('click', async () => {
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge) return;
|
if (!bridge) return;
|
||||||
const paths = await bridge.dialog.openFile({
|
const paths = await bridge.dialog.openFile({
|
||||||
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
|
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
|
||||||
@@ -333,7 +333,7 @@ export function initSettingsModal(): void {
|
|||||||
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
|
const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement;
|
||||||
if (inputWorkspaceDir) {
|
if (inputWorkspaceDir) {
|
||||||
// 加载当前工作空间目录
|
// 加载当前工作空间目录
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (bridge?.isDesktop) {
|
if (bridge?.isDesktop) {
|
||||||
bridge.workspace.getDir().then((info: { dir: string }) => {
|
bridge.workspace.getDir().then((info: { dir: string }) => {
|
||||||
inputWorkspaceDir.value = info.dir;
|
inputWorkspaceDir.value = info.dir;
|
||||||
@@ -342,7 +342,7 @@ export function initSettingsModal(): void {
|
|||||||
}
|
}
|
||||||
if (btnBrowseWorkspace) {
|
if (btnBrowseWorkspace) {
|
||||||
btnBrowseWorkspace.addEventListener('click', async () => {
|
btnBrowseWorkspace.addEventListener('click', async () => {
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.isDesktop) return;
|
if (!bridge?.isDesktop) return;
|
||||||
const paths = await bridge.dialog.openFile({
|
const paths = await bridge.dialog.openFile({
|
||||||
filters: [{ name: '文件夹', extensions: ['*'] }]
|
filters: [{ name: '文件夹', extensions: ['*'] }]
|
||||||
@@ -360,7 +360,7 @@ export function initSettingsModal(): void {
|
|||||||
inputWorkspaceDir.setAttribute('readonly', '');
|
inputWorkspaceDir.setAttribute('readonly', '');
|
||||||
const newDir = inputWorkspaceDir.value.trim();
|
const newDir = inputWorkspaceDir.value.trim();
|
||||||
if (!newDir) return;
|
if (!newDir) return;
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.isDesktop) return;
|
if (!bridge?.isDesktop) return;
|
||||||
const result = await bridge.workspace.setDir(newDir);
|
const result = await bridge.workspace.setDir(newDir);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -411,7 +411,7 @@ async function exportAllSessions(): Promise<void> {
|
|||||||
const ts = new Date().toISOString().slice(0, 10);
|
const ts = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
// ── 原生保存对话框 ──
|
// ── 原生保存对话框 ──
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (bridge) {
|
if (bridge) {
|
||||||
const filePath = await bridge.dialog.saveFile({
|
const filePath = await bridge.dialog.saveFile({
|
||||||
defaultPath: `metona-backup-${ts}.metona`,
|
defaultPath: `metona-backup-${ts}.metona`,
|
||||||
@@ -515,7 +515,7 @@ async function importSessions(filePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
const result = await bridge.fs.readFileBase64(filePath);
|
const result = await bridge.fs.readFileBase64(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showToast(`读取文件失败: ${result.error}`, 'error');
|
showToast(`读取文件失败: ${result.error}`, 'error');
|
||||||
@@ -531,8 +531,8 @@ async function importSessions(filePath: string): Promise<void> {
|
|||||||
let sessions: ChatSession[];
|
let sessions: ChatSession[];
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
sessions = data as ChatSession[];
|
sessions = data as ChatSession[];
|
||||||
} else if ((data as any).sessions && Array.isArray((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 any).sessions;
|
sessions = (data as Record<string, unknown>)['sessions'] as ChatSession[];
|
||||||
} else {
|
} else {
|
||||||
showToast('文件内容格式错误:未找到会话数据', 'error');
|
showToast('文件内容格式错误:未找到会话数据', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -612,10 +612,7 @@ async function runDoctor(): Promise<void> {
|
|||||||
|
|
||||||
// 5. 技能系统
|
// 5. 技能系统
|
||||||
try {
|
try {
|
||||||
if (db && (db as any).getAllSkills) {
|
if (db) {
|
||||||
const skills = await (db as any).getAllSkills();
|
|
||||||
checks.push({ name: '技能系统', status: 'ok', detail: `${skills.length} 个技能` });
|
|
||||||
} else {
|
|
||||||
checks.push({ name: '技能系统', status: 'ok', detail: '已就绪' });
|
checks.push({ name: '技能系统', status: 'ok', detail: '已就绪' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -623,7 +620,7 @@ async function runDoctor(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. 版本信息
|
// 6. 版本信息
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (bridge?.info) {
|
if (bridge?.info) {
|
||||||
try {
|
try {
|
||||||
const info = await bridge.info();
|
const info = await bridge.info();
|
||||||
@@ -662,7 +659,7 @@ export function startHeartbeat(intervalMs: number): void {
|
|||||||
// 连接正常,静默
|
// 连接正常,静默
|
||||||
} catch {
|
} catch {
|
||||||
// 连接异常,通知用户
|
// 连接异常,通知用户
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (bridge?.notify) {
|
if (bridge?.notify) {
|
||||||
bridge.notify('Metona Ollama', '⚠️ Ollama 服务连接异常,请检查是否正在运行');
|
bridge.notify('Metona Ollama', '⚠️ Ollama 服务连接异常,请检查是否正在运行');
|
||||||
}
|
}
|
||||||
@@ -718,12 +715,16 @@ async function renderCronTaskList(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 全局方法供内联 onclick 调用
|
// 全局方法供内联 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');
|
const { toggleCronTask } = await import('../services/cron-manager.js');
|
||||||
await toggleCronTask(id);
|
await toggleCronTask(id);
|
||||||
renderCronTaskList();
|
renderCronTaskList();
|
||||||
};
|
};
|
||||||
(window as any).__deleteCron = async (id: string) => {
|
(window as WindowWithCron).__deleteCron = async (id: string) => {
|
||||||
const { removeCronTask } = await import('../services/cron-manager.js');
|
const { removeCronTask } = await import('../services/cron-manager.js');
|
||||||
await removeCronTask(id);
|
await removeCronTask(id);
|
||||||
renderCronTaskList();
|
renderCronTaskList();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="logo">🦙</span>
|
<span class="logo">🦙</span>
|
||||||
<span class="app-title">Metona Ollama</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="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { ChatSession } from './types.js';
|
|||||||
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
||||||
|
|
||||||
async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
||||||
const bridge = (window as any).metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.isDesktop || !bridge?.db) {
|
if (!bridge?.isDesktop || !bridge?.db) {
|
||||||
logInit('非桌面环境,跳过迁移');
|
logInit('非桌面环境,跳过迁移');
|
||||||
return;
|
return;
|
||||||
@@ -65,7 +65,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
req.onerror = () => reject(req.error);
|
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 tx = idb.transaction('memories', 'readonly');
|
||||||
const store = tx.objectStore('memories');
|
const store = tx.objectStore('memories');
|
||||||
const req = store.getAll();
|
const req = store.getAll();
|
||||||
@@ -73,7 +73,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
req.onerror = () => reject(req.error);
|
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 tx = idb.transaction('settings', 'readonly');
|
||||||
const store = tx.objectStore('settings');
|
const store = tx.objectStore('settings');
|
||||||
const req = store.getAll();
|
const req = store.getAll();
|
||||||
@@ -102,7 +102,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
updated_at: s.updatedAt
|
updated_at: s.updatedAt
|
||||||
})),
|
})),
|
||||||
messages: sessions.flatMap(s =>
|
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}`,
|
id: `${s.id}_msg_${idx}_${m.timestamp}`,
|
||||||
session_id: s.id,
|
session_id: s.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -116,7 +116,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
created_at: m.timestamp || Date.now()
|
created_at: m.timestamp || Date.now()
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
memories: memories.map((m: any) => ({
|
memories: memories.map((m: Record<string, unknown>) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
type: m.type,
|
type: m.type,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
@@ -130,7 +130,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
updated_at: m.updatedAt,
|
updated_at: m.updatedAt,
|
||||||
last_used_at: m.lastUsedAt
|
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()
|
exportedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ function setupDesktopIntegration(): void {
|
|||||||
|
|
||||||
// 主进程日志转发到日志面板
|
// 主进程日志转发到日志面板
|
||||||
bridge.onMainLog((data: { level: string; message: string; detail?: string }) => {
|
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 api = state.get<OllamaAPI | null>(KEYS.API);
|
||||||
const model = state.get<string>('_defaultModel', '');
|
const model = state.get<string>('_defaultModel', '');
|
||||||
if (api && model) {
|
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', '');
|
const model = currentSession?.model || state.get<string>('_defaultModel', '');
|
||||||
if (api && model) {
|
if (api && model) {
|
||||||
try {
|
try {
|
||||||
await api.chat({ model, messages: [], keep_alive: 0 } as any);
|
await api.chat({ model, messages: [], keep_alive: 0 });
|
||||||
logDebug('释放旧模型显存');
|
logDebug('释放旧模型显存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarn('释放显存失败', (e as Error).message);
|
logWarn('释放显存失败', (e as Error).message);
|
||||||
|
|||||||
@@ -264,9 +264,14 @@ export async function addMemory(data: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查重复(内容相似度 > 80% 则跳过)
|
// 检查重复(内容相似度 > 80% 或前缀匹配则跳过)
|
||||||
const existing = memoryCache.find(e => {
|
const existing = memoryCache.find(e => {
|
||||||
if (e.type !== data.type) return false;
|
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);
|
const similarity = simpleSimilarity(e.content, data.content);
|
||||||
return similarity > 0.8;
|
return similarity > 0.8;
|
||||||
});
|
});
|
||||||
@@ -302,7 +307,9 @@ export async function addMemory(data: {
|
|||||||
await addMemoryVector(entry, embedding, colId);
|
await addMemoryVector(entry, embedding, colId);
|
||||||
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
|
if (db) await db.saveMemory(entry); // 保存包含 embedding 的版本
|
||||||
} catch (err) {
|
} 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 db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const SEVEN_DAYS = 7 * 24 * 3600 * 1000;
|
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
|
const scored = memoryCache
|
||||||
.map((entry, idx) => {
|
.map((entry) => {
|
||||||
const recencyBonus = (now - entry.lastUsedAt) < SEVEN_DAYS ? 5 : 0;
|
const recencyBonus = (now - entry.lastUsedAt) < SEVEN_DAYS ? 5 : 0;
|
||||||
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus;
|
const agePenalty = (now - entry.createdAt) > NINETY_DAYS ? 3 : 0;
|
||||||
return { entry, idx, score };
|
const score = entry.importance * 2 + entry.useCount * 3 + recencyBonus - agePenalty;
|
||||||
|
return { entry, score };
|
||||||
})
|
})
|
||||||
// rule 类型受保护
|
// rule 类型受保护
|
||||||
.filter(s => s.entry.type !== 'rule')
|
.filter(s => s.entry.type !== 'rule')
|
||||||
|
|||||||
Reference in New Issue
Block a user