From 0fc589e73b5a16a5eb6c7d6a7037b02718258d5b Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 14 Jul 2026 21:15:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.3.2=20?= =?UTF-8?q?=E2=80=94=20=E5=B7=A5=E5=85=B7=E4=BD=93=E7=B3=BB=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=87=B3=2026=20=E4=B8=AA=20+=20Context=20Window=20?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要变更 ### 1. Context Window 可配置化(v0.3.1 延续) - DeepSeek/Agnes contextWindow 不再写死 1M,可在设置中配置(min 4096) - 修复 Engine 128K vs Adapter 1M 不一致 bug,Engine 从 adapter.getContextWindow() 读取 - 热重载 configSig 加入 contextWindow,配置变化即时生效 ### 2. 新增 11 个工具(15 → 26 个) - Git 工具集(4):git_status, git_diff, git_log, git_commit - 开发工具集(3):lint_code, run_tests, project_info - HTTP 请求(1):http_request(Node 18+ fetch + AbortController) - TODO 管理(1):todo_write(会话级内存 + LRU 淘汰) - 结构化思考(1):think(无副作用思考空间) - 图片查看(1):view_image(base64 data URL,多模态 LLM 支持) ### 3. 审计修复(2 FAIL + 14 WARN) - FAIL-1: registry.ts truncateResult 添加 dataUrl 白名单(图片不被截断) - FAIL-2: todo.ts 添加 LRU 策略 + clearSession 静态方法 - WARN-1: run_tests filter 字符白名单校验,防 cmd 元字符注入 - WARN-2: dataUrl 检测前置到 stringify 之前,避免大图片无意义序列化 - WARN-3: todo.ts 实现真正 LRU(访问刷新位置,非 FIFO) - WARN-4: git_diff maxBuffer 提升至 5MB,支持超大变更集 - WARN-5: log.info 移至 DelegateTaskTool 注册后,输出正确的 26 - WARN-6: riskColors 添加 critical: 'error' 键 - WARN-7: 所有 execFileAsync 显式设置 encoding: 'utf-8' - WARN-8: git_log --author 拆分为独立参数 - WARN-9: todo_write 权限从 WRITE 改为 READ - WARN-10: description 区分与 task_manager 的不同用途 ### 4. PolicyEngine 策略 - 新增 11 条策略,26 个工具 + mcp_* 全覆盖 - git_commit: WRITE + requireConfirmation - todo_write: READ(内存操作) --- electron/harness/adapters/agnes-ai.adapter.ts | 9 +- electron/harness/adapters/deepseek.adapter.ts | 9 +- electron/harness/sandbox/permissions.ts | 24 + electron/harness/tools/built-in/dev-tools.ts | 528 ++++++++++++++++++ electron/harness/tools/built-in/git.ts | 421 ++++++++++++++ .../harness/tools/built-in/http-request.ts | 114 ++++ electron/harness/tools/built-in/index.ts | 22 + electron/harness/tools/built-in/think.ts | 51 ++ electron/harness/tools/built-in/todo.ts | 244 ++++++++ electron/harness/tools/built-in/view-image.ts | 105 ++++ electron/harness/tools/registry.ts | 17 +- electron/harness/types/metona-adapter.ts | 9 + electron/ipc/handlers.ts | 11 +- electron/main.ts | 78 ++- electron/services/database.service.ts | 5 + package-lock.json | 4 +- package.json | 2 +- src/components/settings/SettingsModal.tsx | 43 +- src/stores/agent-store.ts | 14 +- 19 files changed, 1680 insertions(+), 30 deletions(-) create mode 100644 electron/harness/tools/built-in/dev-tools.ts create mode 100644 electron/harness/tools/built-in/git.ts create mode 100644 electron/harness/tools/built-in/http-request.ts create mode 100644 electron/harness/tools/built-in/think.ts create mode 100644 electron/harness/tools/built-in/todo.ts create mode 100644 electron/harness/tools/built-in/view-image.ts diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index 33797f4..1290e68 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -116,10 +116,17 @@ export class AgnesAdapter extends BaseAdapter { /** * H-2 修复: 获取上下文窗口大小(规范要求) * - * Agnes 模型统一 1M 上下文窗口。 + * v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。 + * Agnes OpenAI 兼容 API 不支持 context_window 参数,此值仅用于 + * Engine 压缩判断和前端 UI 显示。 * 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。 */ override getContextWindow(): number { + // v0.3.1: 优先使用配置注入的 contextWindow + if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { + return this.config.contextWindow; + } + // 回退到 MODEL_INFO const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel]; return modelInfo?.contextWindow ?? 1_000_000; } diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index a0521e0..0458b91 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -149,9 +149,16 @@ export class DeepSeekAdapter extends BaseAdapter { /** * H-2 修复: 获取上下文窗口大小(规范要求) * - * DeepSeek 模型统一 1M 上下文窗口。 + * v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。 + * DeepSeek OpenAI 兼容 API 不支持 context_window 参数,此值仅用于 + * Engine 压缩判断和前端 UI 显示。 */ override getContextWindow(): number { + // v0.3.1: 优先使用配置注入的 contextWindow + if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { + return this.config.contextWindow; + } + // 回退到 MODEL_INFO const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]; return modelInfo?.contextWindow ?? 1_000_000; } diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index e779132..f452b71 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -59,6 +59,30 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ // 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具 // @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES { toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 }, + + // v0.3.1: Git 工具集(4 个) + { toolName: 'git_status', requiredLevel: PermissionLevel.READ }, + { toolName: 'git_diff', requiredLevel: PermissionLevel.READ }, + { toolName: 'git_log', requiredLevel: PermissionLevel.READ }, + { toolName: 'git_commit', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10 }, + + // v0.3.1: 开发工具集(3 个) + { toolName: 'lint_code', requiredLevel: PermissionLevel.READ }, + { toolName: 'run_tests', requiredLevel: PermissionLevel.READ }, + { toolName: 'project_info', requiredLevel: PermissionLevel.READ }, + + // v0.3.1: HTTP 请求工具(1 个) + { toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 }, + + // v0.3.1: TODO 管理工具(1 个)— 内存存储,无持久化副作用 + // v0.3.1 修复 WARN-9: 改为 READ(内存操作,非数据库) + { toolName: 'todo_write', requiredLevel: PermissionLevel.READ }, + + // v0.3.1: 结构化思考工具(1 个)— 无副作用 + { toolName: 'think', requiredLevel: PermissionLevel.READ }, + + // v0.3.1: 图片查看工具(1 个)— 只读 + { toolName: 'view_image', requiredLevel: PermissionLevel.READ }, ]; export class PolicyEngine { diff --git a/electron/harness/tools/built-in/dev-tools.ts b/electron/harness/tools/built-in/dev-tools.ts new file mode 100644 index 0000000..d8f2f69 --- /dev/null +++ b/electron/harness/tools/built-in/dev-tools.ts @@ -0,0 +1,528 @@ +/** + * 开发工具集(3 个) + * + * lint_code, run_tests, project_info + * + * 用于代码检查、测试运行和项目结构分析。 + * 使用 execFile 执行命令,防止命令注入。 + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { readFile, readdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; + +const execFileAsync = promisify(execFile); + +/** 输出最大长度(50KB) */ +const MAX_OUTPUT_LENGTH = 50 * 1024; + +/** Windows 平台检测 */ +const isWindows = process.platform === 'win32'; + +/** ESLint 配置文件列表 */ +const ESLINT_CONFIG_FILES = [ + '.eslintrc', + '.eslintrc.js', + '.eslintrc.json', + '.eslintrc.yml', + '.eslintrc.yaml', + '.eslintrc.cjs', + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.cjs', +]; + +/** 检测工作空间是否存在 ESLint 配置 */ +function hasEslintConfig(workspacePath: string): boolean { + return ESLINT_CONFIG_FILES.some((f) => existsSync(join(workspacePath, f))); +} + +/** 读取并解析 package.json */ +async function readPackageJson(workspacePath: string): Promise | null> { + try { + const content = await readFile(join(workspacePath, 'package.json'), 'utf-8'); + return JSON.parse(content) as Record; + } catch { + return null; + } +} + +/** 截断输出到 50KB */ +function truncateOutput(output: string): { output: string; truncated: boolean } { + if (output.length > MAX_OUTPUT_LENGTH) { + return { output: output.slice(0, MAX_OUTPUT_LENGTH), truncated: true }; + } + return { output, truncated: false }; +} + +/** 从 execFile 错误对象中提取 stdout/stderr 字符串 */ +function extractOutput(err: { stdout?: string | Buffer; stderr?: string | Buffer }): { stdout: string; stderr: string } { + return { + stdout: typeof err.stdout === 'string' ? err.stdout : '', + stderr: typeof err.stderr === 'string' ? err.stderr : '', + }; +} + +// ===== 1. lint_code ===== + +export class LintCodeTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'lint_code', + description: + 'Run code linting: TypeScript type checking (tsc) or ESLint code style checking. Auto-detects project configuration when type is "auto".', + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: + 'Check type: "tsc" (TypeScript type check), "eslint" (ESLint), or "auto" (auto-detect, prioritizes tsc then eslint). Defaults to "tsc".', + enum: ['tsc', 'eslint', 'auto'], + }, + }, + }, + category: MetonaToolCategory.CODE_EXECUTION, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 60_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const type = (args.type as string) ?? 'tsc'; + + try { + let actualType: 'tsc' | 'eslint'; + + if (type === 'auto') { + // 自动检测:优先 tsc,然后 eslint + if (existsSync(join(context.workspacePath, 'tsconfig.json'))) { + actualType = 'tsc'; + } else if (hasEslintConfig(context.workspacePath)) { + actualType = 'eslint'; + } else { + return { + type: 'none', + success: true, + errorCount: 0, + warningCount: 0, + output: 'No TypeScript or ESLint configuration found in workspace.', + truncated: false, + }; + } + } else if (type === 'tsc') { + if (!existsSync(join(context.workspacePath, 'tsconfig.json'))) { + return { + type: 'tsc', + success: true, + errorCount: 0, + warningCount: 0, + output: 'No tsconfig.json found, skipping TypeScript type check.', + truncated: false, + }; + } + actualType = 'tsc'; + } else { + // eslint + if (!hasEslintConfig(context.workspacePath)) { + return { + type: 'eslint', + success: true, + errorCount: 0, + warningCount: 0, + output: 'No ESLint configuration found, skipping ESLint check.', + truncated: false, + }; + } + actualType = 'eslint'; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + try { + if (actualType === 'tsc') { + const result = await execFileAsync('npx', ['tsc', '--noEmit'], { + cwd: context.workspacePath, + maxBuffer: 5 * 1024 * 1024, + timeout: 60_000, + shell: isWindows, + encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 + }); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execFileAsync('npx', ['eslint', '--ext', '.ts,.tsx', '.'], { + cwd: context.workspacePath, + maxBuffer: 5 * 1024 * 1024, + timeout: 60_000, + shell: isWindows, + encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 + }); + stdout = result.stdout; + stderr = result.stderr; + } + } catch (error) { + // lint 命令退出码非 0 是正常情况(代码有错误时),需要捕获输出用于解析 + const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number }; + const extracted = extractOutput(err); + stdout = extracted.stdout; + stderr = extracted.stderr; + exitCode = err.code ?? 1; + } + + const output = (stdout + (stderr ? '\n' + stderr : '')).trim(); + const { errorCount, warningCount } = this.parseCounts(output, actualType); + const { output: finalOutput, truncated } = truncateOutput(output); + + return { + type: actualType, + success: exitCode === 0, + errorCount, + warningCount, + output: finalOutput, + truncated, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } + + /** 解析 lint 输出中的错误和警告数量 */ + private parseCounts(output: string, type: 'tsc' | 'eslint'): { errorCount: number; warningCount: number } { + if (type === 'tsc') { + // tsc 输出格式: "file.ts(line,col): error TS1234: message" + const errorMatches = output.match(/error TS\d+:/g); + return { errorCount: errorMatches?.length ?? 0, warningCount: 0 }; + } + // eslint 输出末尾汇总: "✖ X problems (Y errors, Z warnings)" + const match = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/); + if (match) { + return { + errorCount: parseInt(match[2], 10), + warningCount: parseInt(match[3], 10), + }; + } + return { errorCount: 0, warningCount: 0 }; + } +} + +// ===== 2. run_tests ===== + +export class RunTestsTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'run_tests', + description: + 'Run the project test suite. Auto-detects the test command from package.json scripts, or uses a custom command. Supports test file filtering.', + parameters: { + type: 'object', + properties: { + filter: { + type: 'string', + description: 'Test file filter pattern (e.g., "auth" or "utils/*"). Passed to the test runner.', + }, + watch: { + type: 'boolean', + description: 'Watch mode (not applicable in this context, ignored).', + }, + }, + }, + category: MetonaToolCategory.CODE_EXECUTION, + riskLevel: MetonaRiskLevel.LOW, + requiresPermission: false, + timeoutMs: 120_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const filter = args.filter as string | undefined; + // v0.3.1 修复 WARN-2: 移除 customCommand — LLM 输出不可信,shell:true 有注入风险 + // watch 参数不适用,忽略 + + // v0.3.1 修复 WARN-1: filter 字符白名单校验,防止 Windows shell:true 下的 cmd 元字符注入 + // 允许字母/数字/连字符/下划线/斜杠/星号/点(常见测试文件路径模式) + const FILTER_ALLOWED_RE = /^[A-Za-z0-9_\-/. *]+$/; + if (filter && !FILTER_ALLOWED_RE.test(filter)) { + return { + command: 'npm test', + success: false, + passed: 0, + failed: 0, + duration: '0s', + output: `Invalid filter: contains disallowed characters. Only letters, digits, '-', '_', '/', '.', '*', ' ' are allowed.`, + truncated: false, + }; + } + + try { + let actualCommand: string; + let execCmd: string; + let execArgs: string[]; + + // 检测 package.json 的 scripts.test + const pkg = await readPackageJson(context.workspacePath); + const scripts = (pkg?.scripts as Record | undefined) ?? {}; + const testScript = scripts.test; + + if (!testScript || testScript === 'no test specified') { + return { + command: 'npm test', + success: false, + passed: 0, + failed: 0, + duration: '0s', + output: 'No test script found in package.json.', + truncated: false, + }; + } + + actualCommand = filter ? `npm test -- ${filter}` : 'npm test'; + execCmd = 'npm'; + execArgs = filter ? ['test', '--', filter] : ['test']; + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + try { + const result = await execFileAsync(execCmd, execArgs, { + cwd: context.workspacePath, + maxBuffer: 5 * 1024 * 1024, + timeout: 120_000, + shell: isWindows, + encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + // 测试失败(exitCode !== 0)是正常情况,需要捕获输出解析结果 + const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number }; + const extracted = extractOutput(err); + stdout = extracted.stdout; + stderr = extracted.stderr; + exitCode = err.code ?? 1; + } + + const output = (stdout + (stderr ? '\n' + stderr : '')).trim(); + const { passed, failed, duration } = this.parseTestResults(output); + const { output: finalOutput, truncated } = truncateOutput(output); + + return { + command: actualCommand, + success: exitCode === 0, + passed, + failed, + duration, + output: finalOutput, + truncated, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } + + /** 从测试输出中解析通过/失败数量和耗时(支持 jest/vitest/mocha 格式) */ + private parseTestResults(output: string): { passed: number; failed: number; duration: string } { + let passed = 0; + let failed = 0; + let duration = '0s'; + + // jest/vitest 格式: "Tests: 5 passed, 2 failed, 7 total" + const jestMatch = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?/); + if (jestMatch) { + passed = parseInt(jestMatch[1], 10); + failed = jestMatch[2] ? parseInt(jestMatch[2], 10) : 0; + } + + // mocha 格式: "X passing (Ys)" / "X failing" + const mochaPass = output.match(/(\d+)\s+passing/); + const mochaFail = output.match(/(\d+)\s+failing/); + if (mochaPass) passed = parseInt(mochaPass[1], 10); + if (mochaFail) failed = parseInt(mochaFail[1], 10); + + // 耗时解析: "Time: 3.5 s" / "Duration: 120ms" / "(3.5s)" + const timeMatch = output.match(/(?:Time|Duration|耗时)[:\s]+([\d.]+\s*(?:ms|s|m|h|seconds?|minutes?|hours?)?)/i); + if (timeMatch) { + duration = timeMatch[1]; + } else { + const parenTime = output.match(/\(([\d.]+\s*(?:ms|s|m|h))\)/); + if (parenTime) duration = parenTime[1]; + } + + return { passed, failed, duration }; + } +} + +// ===== 3. project_info ===== + +export class ProjectInfoTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'project_info', + description: + 'Analyze project structure and dependencies. Returns package.json overview, full dependency list, npm scripts, or directory structure (2 levels deep).', + parameters: { + type: 'object', + properties: { + detail: { + type: 'string', + description: + 'Detail level: "overview" (project summary), "dependencies" (full dependency list), "scripts" (npm scripts), or "structure" (directory tree, 2 levels). Defaults to "overview".', + enum: ['overview', 'dependencies', 'scripts', 'structure'], + }, + }, + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 10_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const detail = (args.detail as string) ?? 'overview'; + + try { + switch (detail) { + case 'dependencies': + return await this.getDependencies(context.workspacePath); + case 'scripts': + return await this.getScripts(context.workspacePath); + case 'structure': + return await this.getStructure(context.workspacePath); + default: + return await this.getOverview(context.workspacePath); + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } + + private async getOverview(workspacePath: string): Promise { + const pkg = await readPackageJson(workspacePath); + if (!pkg) { + return { error: 'package.json not found', success: false }; + } + + const deps = (pkg.dependencies as Record | undefined) ?? {}; + const devDeps = (pkg.devDependencies as Record | undefined) ?? {}; + const scripts = (pkg.scripts as Record | undefined) ?? {}; + + return { + name: (pkg.name as string) ?? 'unknown', + version: (pkg.version as string) ?? '0.0.0', + description: (pkg.description as string) ?? '', + type: pkg.type === 'module' ? 'module' : 'commonjs', + mainEntry: (pkg.module as string) ?? (pkg.main as string) ?? 'index.js', + dependencyCount: Object.keys(deps).length, + devDependencyCount: Object.keys(devDeps).length, + scriptCount: Object.keys(scripts).length, + hasTypeScript: existsSync(join(workspacePath, 'tsconfig.json')), + hasEslint: hasEslintConfig(workspacePath), + }; + } + + private async getDependencies(workspacePath: string): Promise { + const pkg = await readPackageJson(workspacePath); + if (!pkg) { + return { error: 'package.json not found', success: false }; + } + + const deps = (pkg.dependencies as Record | undefined) ?? {}; + const devDeps = (pkg.devDependencies as Record | undefined) ?? {}; + + const dependencies = Object.entries(deps).map(([name, version]) => ({ name, version })); + const devDependencies = Object.entries(devDeps).map(([name, version]) => ({ name, version })); + + return { + dependencies, + devDependencies, + total: dependencies.length + devDependencies.length, + }; + } + + private async getScripts(workspacePath: string): Promise { + const pkg = await readPackageJson(workspacePath); + if (!pkg) { + return { error: 'package.json not found', success: false }; + } + + const scripts = (pkg.scripts as Record | undefined) ?? {}; + const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command })); + + return { + scripts: scriptList, + count: scriptList.length, + }; + } + + private async getStructure(workspacePath: string): Promise { + const directories = await this.listDirectory(workspacePath, 0, 2); + return { + root: workspacePath, + directories, + }; + } + + /** 递归列出目录(最多 maxDepth 层),跳过 node_modules/.git/dist */ + private async listDirectory( + dirPath: string, + currentDepth: number, + maxDepth: number, + ): Promise }>> { + const results: Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }> = []; + + try { + const entries = await readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (this.shouldSkip(entry.name)) continue; + + if (entry.isDirectory()) { + const item: { name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> } = { + name: entry.name, + type: 'dir', + }; + if (currentDepth < maxDepth - 1) { + item.children = await this.listChildren(join(dirPath, entry.name)); + } + results.push(item); + } else { + results.push({ name: entry.name, type: 'file' }); + } + } + } catch { + // 目录读取失败,跳过 + } + + return results; + } + + /** 列出子目录内容(仅 1 层,不再递归) */ + private async listChildren(dirPath: string): Promise> { + const results: Array<{ name: string; type: string }> = []; + + try { + const entries = await readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (this.shouldSkip(entry.name)) continue; + results.push({ name: entry.name, type: entry.isDirectory() ? 'dir' : 'file' }); + } + } catch { + // 目录读取失败,跳过 + } + + return results; + } + + /** 判断目录/文件是否应跳过(node_modules/.git/dist 不递归) */ + private shouldSkip(name: string): boolean { + return ['node_modules', '.git', 'dist'].includes(name); + } +} diff --git a/electron/harness/tools/built-in/git.ts b/electron/harness/tools/built-in/git.ts new file mode 100644 index 0000000..ddc8e19 --- /dev/null +++ b/electron/harness/tools/built-in/git.ts @@ -0,0 +1,421 @@ +/** + * Git 工具集(4 个) + * + * git_status, git_diff, git_log, git_commit + * + * 使用 execFile 执行 git 命令,防止命令注入。 + * 所有命令在 context.workspacePath 下执行。 + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { resolve } from 'path'; +import { isPathWithinWorkspace } from './file-guard'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; + +const execFileAsync = promisify(execFile); + +/** diff 输出截断阈值(50KB) */ +const MAX_DIFF_SIZE = 50 * 1024; + +/** + * 执行 git 命令的统一入口 + * + * 使用 execFile(非 exec)+ 参数数组,从底层杜绝命令注入。 + * 显式设置 encoding: 'utf-8',git 默认输出 UTF-8。 + * + * v0.3.1 修复 WARN-4: 允许调用方覆盖 maxBuffer,默认 1MB; + * git_diff 传 5MB 以支持超大变更集(避免 maxBuffer exceeded 报错而非截断返回)。 + */ +async function runGit( + args: string[], + cwd: string, + timeout = 10_000, + maxBuffer = 1024 * 1024, // 默认 1MB +): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + maxBuffer, + timeout, + encoding: 'utf-8', + }); + return stdout; +} + +/** 判断错误是否为 "不是 git 仓库" 或 git 不可用 */ +function isNotGitRepoError(error: unknown): boolean { + if (error instanceof Error) { + const stderr = (error as Error & { stderr?: string }).stderr ?? ''; + const msg = `${error.message} ${stderr}`; + return /not a git repository/i.test(msg) || /git: not found|command not found/i.test(msg); + } + return false; +} + +/** 提取错误信息字符串 */ +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + const stderr = (error as Error & { stderr?: string }).stderr ?? ''; + return stderr ? `${error.message}\n${stderr}` : error.message; + } + return String(error); +} + +/** 获取当前分支名(失败时返回 'unknown',不抛错) */ +async function getCurrentBranch(cwd: string): Promise { + try { + const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim(); + return branch; + } catch { + return 'unknown'; + } +} + +/** 处理 porcelain v1 重命名格式 "old -> new" → 返回 new */ +function extractRenamedFile(file: string): string { + const idx = file.indexOf(' -> '); + return idx >= 0 ? file.slice(idx + 4) : file; +} + +// ===== 1. git_status ===== + +export class GitStatusTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'git_status', + description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.', + parameters: { + type: 'object', + properties: { + pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' }, + }, + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 15_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const pathspec = args.pathspec as string | undefined; + const gitArgs = ['status', '--porcelain=v1', '-b']; + if (pathspec) { + gitArgs.push('--', pathspec); + } + + try { + const output = await runGit(gitArgs, context.workspacePath); + return this.parseStatus(output); + } catch (error) { + if (isNotGitRepoError(error)) { + return { error: 'Not a git repository or git not available', success: false }; + } + return { error: extractErrorMessage(error), success: false }; + } + } + + /** + * 解析 `git status --porcelain=v1 -b` 输出 + * + * 分支行格式: `## ... [ahead N, behind M]` + * 文件行格式: `XY ` (X=暂存区状态, Y=工作区状态) + */ + private parseStatus(output: string): unknown { + const lines = output.split('\n').filter((l) => l.length > 0); + const staged: Array<{ status: string; file: string }> = []; + const unstaged: Array<{ status: string; file: string }> = []; + const untracked: string[] = []; + + let branch = ''; + let ahead = 0; + let behind = 0; + + for (const line of lines) { + // 分支行: ## main...origin/main [ahead 2, behind 1] + if (line.startsWith('## ')) { + const header = line.slice(3); + const bracketIdx = header.indexOf('['); + const branchPart = bracketIdx >= 0 ? header.slice(0, bracketIdx).trim() : header.trim(); + // branchPart 形如 "main...origin/main" / "main" / "HEAD (no branch)" + const dotIdx = branchPart.indexOf('...'); + branch = dotIdx >= 0 ? branchPart.slice(0, dotIdx) : branchPart.split(' ')[0]; + + if (bracketIdx >= 0) { + const meta = header.slice(bracketIdx + 1, header.lastIndexOf(']')); + const aheadMatch = meta.match(/ahead (\d+)/); + const behindMatch = meta.match(/behind (\d+)/); + if (aheadMatch) ahead = parseInt(aheadMatch[1], 10); + if (behindMatch) behind = parseInt(behindMatch[1], 10); + } + continue; + } + + // porcelain v1 文件行: XY + const x = line[0]; + const y = line[1]; + const file = line.slice(3); + + // 未跟踪: ?? + if (x === '?' && y === '?') { + untracked.push(file); + continue; + } + + // 暂存区变更 (X 非 ' ' 且非 '?') + if (x !== ' ' && x !== '?') { + staged.push({ status: x, file: extractRenamedFile(file) }); + } + // 工作区变更 (Y 非 ' ' 且非 '?') + if (y !== ' ' && y !== '?') { + unstaged.push({ status: y, file: extractRenamedFile(file) }); + } + } + + const clean = staged.length === 0 && unstaged.length === 0 && untracked.length === 0; + + return { + branch, + ahead, + behind, + staged, + unstaged, + untracked, + clean, + }; + } +} + +// ===== 2. git_diff ===== + +export class GitDiffTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'git_diff', + description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.', + parameters: { + type: 'object', + properties: { + cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' }, + pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' }, + contextLines: { type: 'number', description: 'Number of context lines (default 3)' }, + }, + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 15_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const cached = (args.cached as boolean) ?? false; + const pathspec = args.pathspec as string | undefined; + const contextLines = Math.max(0, (args.contextLines as number) ?? 3); + + const gitArgs = ['diff']; + if (cached) gitArgs.push('--cached'); + gitArgs.push(`--unified=${contextLines}`); + if (pathspec) gitArgs.push('--', pathspec); + + try { + // v0.3.1 修复 WARN-4: 使用 5MB maxBuffer 支持超大 diff,避免 maxBuffer exceeded 报错 + // 工具内部仍会截断到 50KB 返回,避免 LLM 上下文溢出 + const diff = await runGit(gitArgs, context.workspacePath, 10_000, 5 * 1024 * 1024); + const truncated = diff.length > MAX_DIFF_SIZE; + const truncatedDiff = truncated ? diff.slice(0, MAX_DIFF_SIZE) : diff; + const filesChanged = this.countFilesChanged(diff); + + return { + diff: truncatedDiff, + filesChanged, + truncated, + }; + } catch (error) { + if (isNotGitRepoError(error)) { + return { error: 'Not a git repository or git not available', success: false }; + } + return { error: extractErrorMessage(error), success: false }; + } + } + + /** 统计 diff 中变更的文件数(每变更文件一个 `diff --git` 头) */ + private countFilesChanged(diff: string): number { + let count = 0; + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git ')) count++; + } + return count; + } +} + +// ===== 3. git_log ===== + +export class GitLogTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'git_log', + description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.', + parameters: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Number of entries to return (default 20, max 100)' }, + oneline: { type: 'boolean', description: 'Use compact one-line format (default true)' }, + pathspec: { type: 'string', description: 'Limit to a path scope' }, + author: { type: 'string', description: 'Filter by author name/email' }, + }, + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 15_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 20)); + const oneline = (args.oneline as boolean) ?? true; + const pathspec = args.pathspec as string | undefined; + const author = args.author as string | undefined; + + const gitArgs = ['log', `-n${limit}`]; + if (oneline) { + gitArgs.push('--oneline'); + } else { + // 用 NULL 字节分隔字段,避免 author/message 含 '|' 导致解析错误 + gitArgs.push('--format=%H%x00%an%x00%ad%x00%s'); + } + // v0.3.1 修复 WARN-8: 拆分为独立参数,避免特殊字符干扰 git 解析 + if (author) gitArgs.push('--author', author); + if (pathspec) gitArgs.push('--', pathspec); + + try { + // 并行获取日志和当前分支名 + const [output, branch] = await Promise.all([ + runGit(gitArgs, context.workspacePath), + getCurrentBranch(context.workspacePath), + ]); + + const commits = this.parseLog(output, oneline); + return { commits, count: commits.length, branch }; + } catch (error) { + if (isNotGitRepoError(error)) { + return { error: 'Not a git repository or git not available', success: false }; + } + return { error: extractErrorMessage(error), success: false }; + } + } + + private parseLog( + output: string, + oneline: boolean, + ): Array<{ hash: string; author?: string; date?: string; message: string }> { + const lines = output.split('\n').filter((l) => l.length > 0); + const commits: Array<{ hash: string; author?: string; date?: string; message: string }> = []; + + for (const line of lines) { + if (oneline) { + // + const spaceIdx = line.indexOf(' '); + if (spaceIdx < 0) continue; + commits.push({ + hash: line.slice(0, spaceIdx), + message: line.slice(spaceIdx + 1), + }); + } else { + // \0\0\0 + const parts = line.split('\0'); + if (parts.length < 4) continue; + commits.push({ + hash: parts[0], + author: parts[1], + date: parts[2], + message: parts[3], + }); + } + } + + return commits; + } +} + +// ===== 4. git_commit ===== + +export class GitCommitTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'git_commit', + description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.', + parameters: { + type: 'object', + properties: { + message: { type: 'string', description: 'Commit message' }, + files: { + type: 'array', + items: { type: 'string', description: 'File path to stage' }, + description: 'Files to stage (default: all changes via git add -A)', + }, + amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' }, + }, + required: ['message'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.MEDIUM, + requiresPermission: true, + timeoutMs: 30_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const message = args.message as string; + const files = (args.files as string[] | undefined) ?? []; + const amend = (args.amend as boolean) ?? false; + + if (!message || message.trim().length === 0) { + return { success: false, error: 'Commit message is required' }; + } + + try { + // 1. 暂存文件(指定文件 → git add -- ;否则 git add -A) + // v0.3.1 修复 WARN-1: 校验每个 file 路径在工作空间内 + if (files.length > 0) { + for (const f of files) { + const resolved = resolve(context.workspacePath, f); + if (!isPathWithinWorkspace(resolved, context.workspacePath)) { + return { + success: false, + error: `File path outside workspace: ${f}`, + commit: '', branch: '', message: '', filesChanged: 0, + }; + } + } + await runGit(['add', '--', ...files], context.workspacePath); + } else { + await runGit(['add', '-A'], context.workspacePath); + } + + // 2. 统计暂存文件数(commit 前取样) + const stagedOutput = await runGit(['diff', '--cached', '--name-only'], context.workspacePath); + const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length; + + // 3. 提交(commit 可能触发 hooks,给予更长超时) + const commitArgs = ['commit']; + if (amend) commitArgs.push('--amend'); + commitArgs.push('-m', message); + await runGit(commitArgs, context.workspacePath, 20_000); + + // 4. 获取提交 hash 和当前分支 + const [commit, branch] = await Promise.all([ + runGit(['rev-parse', 'HEAD'], context.workspacePath).then((s) => s.trim()), + getCurrentBranch(context.workspacePath), + ]); + + return { + success: true, + commit, + branch, + message, + filesChanged, + }; + } catch (error) { + if (isNotGitRepoError(error)) { + return { error: 'Not a git repository or git not available', success: false }; + } + return { success: false, error: extractErrorMessage(error) }; + } + } +} diff --git a/electron/harness/tools/built-in/http-request.ts b/electron/harness/tools/built-in/http-request.ts new file mode 100644 index 0000000..008d1d0 --- /dev/null +++ b/electron/harness/tools/built-in/http-request.ts @@ -0,0 +1,114 @@ +/** + * HTTP 请求工具(1 个) + * + * http_request — 发送 HTTP/REST API 请求 + * + * 使用 Node.js 18+ 内置 fetch API。 + * 响应体截断到 50KB 防止结果过大。 + */ + +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; + +const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; +const MAX_BODY_BYTES = 50 * 1024; // 50KB + +export class HttpRequestTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'http_request', + description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.', + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'Request URL (must start with http:// or https://)' }, + method: { + type: 'string', + description: 'HTTP method (default GET)', + enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'], + }, + headers: { type: 'object', description: 'Request headers as key-value pairs' }, + body: { type: 'string', description: 'Request body (string)' }, + timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' }, + }, + required: ['url'], + }, + category: MetonaToolCategory.NETWORK, + riskLevel: MetonaRiskLevel.LOW, + requiresPermission: false, + timeoutMs: 30_000, + }; + + async execute(args: Record, _context: ToolExecutionContext): Promise { + try { + const url = args.url as string; + const method = ((args.method as string) ?? 'GET').toUpperCase(); + const headers = (args.headers as Record | undefined) ?? undefined; + const body = args.body as string | undefined; + const timeout = Math.min(60_000, Math.max(1, (args.timeout as number) ?? 30_000)); + + // 校验 URL + if (!url || !/^https?:\/\//i.test(url)) { + return { error: 'Invalid URL', success: false }; + } + + // 校验 method + if (!(ALLOWED_METHODS as readonly string[]).includes(method)) { + return { + error: `Invalid method: ${method}. Must be one of: ${ALLOWED_METHODS.join(', ')}`, + success: false, + }; + } + + // 超时控制 + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + try { + const fetchOptions: RequestInit = { + method, + headers, + signal: controller.signal, + redirect: 'follow', + }; + // GET/HEAD 不应携带 body + if (body !== undefined && method !== 'GET' && method !== 'HEAD') { + fetchOptions.body = body; + } + + const response = await fetch(url, fetchOptions); + const text = await response.text(); + + // 截断到 50KB + const truncated = text.length > MAX_BODY_BYTES; + const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text; + + // 只返回 content-type 和 content-length + const filteredHeaders: Record = {}; + const contentType = response.headers.get('content-type'); + if (contentType) filteredHeaders['content-type'] = contentType; + const contentLength = response.headers.get('content-length'); + if (contentLength) filteredHeaders['content-length'] = contentLength; + + return { + status: response.status, + statusText: response.statusText, + headers: filteredHeaders, + body: safeBody, + truncated, + ok: response.ok, + success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段 + }; + } finally { + clearTimeout(timer); + } + } catch (error) { + // 区分超时(AbortError)与其他网络错误 + if (error instanceof Error && error.name === 'AbortError') { + return { error: 'Request timeout', success: false }; + } + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } +} diff --git a/electron/harness/tools/built-in/index.ts b/electron/harness/tools/built-in/index.ts index c4d2575..a8a8512 100644 --- a/electron/harness/tools/built-in/index.ts +++ b/electron/harness/tools/built-in/index.ts @@ -1,6 +1,10 @@ /** * 内置工具导出 * + * v0.3.1: 从 15 个工具扩展到 26 个工具 + * 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests, + * project_info, http_request, todo_write, think, view_image + * * v0.2.0: 从 11 个工具扩展到 15 个工具 * 新增:file_editor, code_search, task_manager, diff_viewer * @@ -34,3 +38,21 @@ export { DelegateTaskTool } from './delegate-task'; // v0.2.0: 任务管理工具(1 个) export { TaskManagerTool } from './task-manager'; +// v0.3.1: Git 工具集(4 个) +export { GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool } from './git'; + +// v0.3.1: 开发工具集(3 个) +export { LintCodeTool, RunTestsTool, ProjectInfoTool } from './dev-tools'; + +// v0.3.1: HTTP 请求工具(1 个) +export { HttpRequestTool } from './http-request'; + +// v0.3.1: TODO 管理工具(1 个) +export { TodoWriteTool } from './todo'; + +// v0.3.1: 结构化思考工具(1 个) +export { ThinkTool } from './think'; + +// v0.3.1: 图片查看工具(1 个) +export { ViewImageTool } from './view-image'; + diff --git a/electron/harness/tools/built-in/think.ts b/electron/harness/tools/built-in/think.ts new file mode 100644 index 0000000..8ddf88d --- /dev/null +++ b/electron/harness/tools/built-in/think.ts @@ -0,0 +1,51 @@ +/** + * 思考工具(1 个) + * + * think — 结构化思考空间(无副作用) + * + * 参考 Claude Code 的 think 工具设计。 + * 帮助 LLM 在工具调用上下文中组织思路。 + */ + +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; + +export class ThinkTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'think', + description: + 'Use this tool for structured thinking. It helps you organize your thoughts before complex actions. This tool has no side effects — it simply returns your input for your own reference. Use it when: 1) Breaking down complex tasks into steps, 2) Evaluating multiple approaches, 3) Planning before writing code, 4) Reviewing your work before completing.', + parameters: { + type: 'object', + properties: { + thought: { type: 'string', description: 'The thought content' }, + nextAction: { type: 'string', description: 'Next action plan' }, + reasoning: { type: 'string', description: 'Reasoning process' }, + }, + required: ['thought'], + }, + category: MetonaToolCategory.CUSTOM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 1_000, + }; + + async execute(args: Record, _context: ToolExecutionContext): Promise { + try { + const thought = args.thought as string; + const nextAction = (args.nextAction as string) ?? null; + const reasoning = (args.reasoning as string) ?? null; + + return { + thought, + nextAction, + reasoning, + timestamp: Date.now(), + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } +} diff --git a/electron/harness/tools/built-in/todo.ts b/electron/harness/tools/built-in/todo.ts new file mode 100644 index 0000000..a8238f4 --- /dev/null +++ b/electron/harness/tools/built-in/todo.ts @@ -0,0 +1,244 @@ +/** + * TODO 管理工具(1 个) + * + * todo_write — 会话级 TODO 列表管理 + * + * 使用内存 Map 存储,按 sessionId 隔离。 + * 与 task_manager 区别:todo_write 用于临时思考拆解(内存,会话级), + * task_manager 用于持久化任务跟踪(数据库,跨会话)。 + * + * v0.3.1 修复 FAIL-2: 添加 LRU 策略,限制最大会话数 50, + * 超出时淘汰最久未访问的会话。提供 clearSession 静态方法供外部清理。 + * + * v0.3.1 修复 WARN-3: 真正的 LRU 实现 — 访问命中时通过 delete + re-insert + * 刷新会话位置,确保活跃会话不被淘汰,仅淘汰最久未访问的会话。 + */ + +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; +import log from 'electron-log'; + +type TodoStatus = 'pending' | 'in_progress' | 'completed'; +type TodoPriority = 'high' | 'medium' | 'low'; + +interface TodoItem { + id: string; + content: string; + status: TodoStatus; + priority: TodoPriority; + createdAt: number; + updatedAt: number; +} + +const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed']; +const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low']; + +// v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏 +const MAX_SESSIONS = 50; + +export class TodoWriteTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'todo_write', + description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Operation type', + enum: ['create', 'update', 'list', 'complete', 'clear'], + }, + id: { type: 'string', description: 'TODO item ID (required for update/complete)' }, + content: { type: 'string', description: 'TODO content (required for create/update)' }, + status: { + type: 'string', + description: 'Status (for update)', + enum: ['pending', 'in_progress', 'completed'], + }, + priority: { + type: 'string', + description: 'Priority (for create, default medium)', + enum: ['high', 'medium', 'low'], + }, + }, + required: ['action'], + }, + // v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库) + category: MetonaToolCategory.CUSTOM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 5_000, + }; + + // v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰 + private static todosBySession = new Map>(); + private static counter = 0; + + /** + * v0.3.1 修复 FAIL-2: 清理指定会话的 TODO(供外部调用,如会话结束时) + */ + static clearSession(sessionId: string): void { + const removed = TodoWriteTool.todosBySession.delete(sessionId); + if (removed) { + log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`); + } + } + + /** + * v0.3.1 修复 FAIL-2: LRU 淘汰 — 超过 MAX_SESSIONS 时删除最久未访问的会话 + * Map 的迭代顺序按插入顺序,第一个即最久未访问 + * + * v0.3.1 修复 WARN-3: 配合 getSessionMap 的访问刷新位置,实现真正的 LRU。 + * 活跃会话因访问而刷新到 Map 末尾,不会被淘汰。 + */ + private static evictIfFull(): void { + while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) { + const oldest = TodoWriteTool.todosBySession.keys().next().value; + if (oldest === undefined) break; + TodoWriteTool.todosBySession.delete(oldest); + log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`); + } + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const action = args.action as string; + + switch (action) { + case 'create': + return this.create(args, context); + case 'update': + return this.update(args, context); + case 'list': + return this.list(context); + case 'complete': + return this.complete(args, context); + case 'clear': + return this.clear(context); + default: + return { success: false, error: `Unknown action: ${action}` }; + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } + + /** + * 获取指定会话的 TODO Map + * + * v0.3.1 修复 WARN-3: 真正的 LRU — 命中已有会话时通过 delete + re-insert + * 将会话移到 Map 末尾(最近使用位置),防止活跃会话被淘汰。 + * 新会话插入前触发 evictIfFull。 + */ + private getSessionMap(context: ToolExecutionContext): Map { + const existing = TodoWriteTool.todosBySession.get(context.sessionId); + if (existing) { + // LRU 刷新位置:delete + re-insert → 移到 Map 末尾 + TodoWriteTool.todosBySession.delete(context.sessionId); + TodoWriteTool.todosBySession.set(context.sessionId, existing); + return existing; + } + // 新会话插入前检查 LRU 淘汰 + TodoWriteTool.evictIfFull(); + const map = new Map(); + TodoWriteTool.todosBySession.set(context.sessionId, map); + return map; + } + + private create(args: Record, context: ToolExecutionContext): unknown { + const content = args.content as string; + if (!content) { + return { success: false, id: '', todo: null, error: 'content is required for create action' }; + } + + const priority = (args.priority as TodoPriority) ?? 'medium'; + if (!VALID_PRIORITIES.includes(priority)) { + return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; + } + + const now = Date.now(); + const id = `todo_${now}_${TodoWriteTool.counter++}`; + const todo: TodoItem = { + id, + content, + status: 'pending', + priority, + createdAt: now, + updatedAt: now, + }; + this.getSessionMap(context).set(id, todo); + + return { success: true, id, todo }; + } + + private update(args: Record, context: ToolExecutionContext): unknown { + const id = args.id as string; + if (!id) { + return { success: false, id: '', todo: null, error: 'id is required for update action' }; + } + + const map = this.getSessionMap(context); + const todo = map.get(id); + if (!todo) { + return { success: false, id, todo: null, error: `TODO not found: ${id}` }; + } + + if (args.content !== undefined) { + todo.content = args.content as string; + } + if (args.status !== undefined) { + const status = args.status as TodoStatus; + if (!VALID_STATUSES.includes(status)) { + return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` }; + } + todo.status = status; + } + if (args.priority !== undefined) { + const priority = args.priority as TodoPriority; + if (!VALID_PRIORITIES.includes(priority)) { + return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; + } + todo.priority = priority; + } + todo.updatedAt = Date.now(); + + return { success: true, id, todo }; + } + + private list(context: ToolExecutionContext): unknown { + const map = this.getSessionMap(context); + const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt); + return { + todos, + count: todos.length, + pending: todos.filter((t) => t.status === 'pending').length, + completed: todos.filter((t) => t.status === 'completed').length, + }; + } + + private complete(args: Record, context: ToolExecutionContext): unknown { + const id = args.id as string; + if (!id) { + return { success: false, id: '', todo: null, error: 'id is required for complete action' }; + } + + const map = this.getSessionMap(context); + const todo = map.get(id); + if (!todo) { + return { success: false, id, todo: null, error: `TODO not found: ${id}` }; + } + + todo.status = 'completed'; + todo.updatedAt = Date.now(); + return { success: true, id, todo }; + } + + private clear(context: ToolExecutionContext): unknown { + const map = this.getSessionMap(context); + const cleared = map.size; + map.clear(); + return { success: true, cleared }; + } +} diff --git a/electron/harness/tools/built-in/view-image.ts b/electron/harness/tools/built-in/view-image.ts new file mode 100644 index 0000000..baa5200 --- /dev/null +++ b/electron/harness/tools/built-in/view-image.ts @@ -0,0 +1,105 @@ +/** + * 图片查看工具(1 个) + * + * view_image — 读取图片文件并返回 base64 data URL + * + * 支持格式:png, jpg, jpeg, gif, webp, bmp, svg + * 文件大小限制:5MB + */ + +import { readFile, stat } from 'fs/promises'; +import { resolve, extname } from 'path'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; +import { isPathWithinWorkspace } from './file-guard'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB + +/** 扩展名 → MIME 映射 */ +const SUPPORTED_FORMATS: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.svg': 'image/svg+xml', +}; + +const SUPPORTED_EXTENSIONS = Object.keys(SUPPORTED_FORMATS); + +export class ViewImageTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'view_image', + description: + 'Read an image file and return its content as a base64 data URL. Supports png, jpg, jpeg, gif, webp, bmp, svg. File size limit: 5MB. Path must be within the workspace.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'Image file path (relative to workspace or absolute)' }, + }, + required: ['path'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 10_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const path = args.path as string; + if (!path) { + return { error: 'path is required', success: false }; + } + + // 安全校验:路径必须在 workspace 内(防范路径遍历) + if (!isPathWithinWorkspace(path, context.workspacePath)) { + return { error: 'Path outside workspace', path, success: false }; + } + + // 扩展名校验 + const ext = extname(path).toLowerCase(); + const mimeType = SUPPORTED_FORMATS[ext]; + if (!mimeType) { + return { + error: 'Unsupported image format', + path, + supportedFormats: SUPPORTED_EXTENSIONS, + success: false, + }; + } + + const resolvedPath = resolve(context.workspacePath, path); + + // 文件存在性 + 大小检查(先 stat 再读取,避免读取超大文件) + let stats; + try { + stats = await stat(resolvedPath); + } catch { + return { error: 'File not found', path, success: false }; + } + + if (stats.size > MAX_IMAGE_BYTES) { + return { error: 'Image too large (max 5MB)', size: stats.size, success: false }; + } + + // 读取文件并编码为 base64 data URL + const buffer = await readFile(resolvedPath); + const dataUrl = `data:${mimeType};base64,${buffer.toString('base64')}`; + + // v0.3.1 修复 WARN-3: 成功路径添加 success: true + return { + path, + size: stats.size, + mimeType, + dataUrl, + success: true, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + return { error: errMsg, success: false }; + } + } +} diff --git a/electron/harness/tools/registry.ts b/electron/harness/tools/registry.ts index 26e790c..6419549 100644 --- a/electron/harness/tools/registry.ts +++ b/electron/harness/tools/registry.ts @@ -139,8 +139,23 @@ export class ToolRegistry { } } - /** 截断过大的工具返回值,防止 LLM 上下文溢出 */ + /** + * 截断过大的工具返回值,防止 LLM 上下文溢出 + * + * v0.3.1 修复 FAIL-1: view_image 返回的 dataUrl 需完整传输给多模态 LLM, + * 截断会导致 base64 损坏、图片无法显示。对含 dataUrl 字段的结果跳过截断。 + * view_image 已在工具内部限制文件大小 5MB,base64 后约 6.7MB, + * 多模态 LLM 能处理此量级数据。 + * + * v0.3.1 修复 WARN-2: dataUrl 检测前置到 stringify 之前, + * 避免对 5MB+ 的图片对象做无意义的 JSON.stringify(约 6.7MB 字符串)。 + */ private truncateResult(result: unknown): unknown { + // 先检测 dataUrl 白名单:图片类结果跳过截断(避免 base64 损坏 + 避免无意义的序列化) + if (typeof result === 'object' && result !== null && 'dataUrl' in result) { + return result; + } + const str = typeof result === 'string' ? result : JSON.stringify(result); if (str.length <= MAX_RESULT_CHARS) return result; diff --git a/electron/harness/types/metona-adapter.ts b/electron/harness/types/metona-adapter.ts index 6dcd119..91604e6 100644 --- a/electron/harness/types/metona-adapter.ts +++ b/electron/harness/types/metona-adapter.ts @@ -50,6 +50,15 @@ export interface AdapterConfig { timeoutMs?: number; /** 自定义请求头 */ headers?: Record; + /** + * 上下文窗口大小(用于 Engine 压缩判断和 UI 显示) + * + * v0.3.1 新增:DeepSeek/Agnes 不再写死 1M,可通过配置注入。 + * - Ollama: 不使用此字段(使用 contextLength/num_ctx 传给 API) + * - DeepSeek/Agnes: OpenAI 兼容 API 不支持 context_window 参数, + * 此值仅用于 Engine 压缩判断和前端显示,不传给 API + */ + contextWindow?: number; } /** diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 57e597c..ebb1c2a 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -667,7 +667,9 @@ export function registerAllIPCHandlers( }); // LLM 相关配置变更时热重载 Adapter - if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) { + // v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow,使上下文窗口配置变化也触发热重载 + if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx', + 'deepseek.contextWindow', 'agnes.contextWindow'].includes(key)) { // C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端 const reloadSuccess = reloadAdapter(); if (!reloadSuccess) { @@ -698,6 +700,13 @@ export function registerAllIPCHandlers( agentLoop.updateConfig({ contextLength: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); log.info(`[CONFIG] Agent contextLength updated to ${value}`); + } else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow') { + // v0.3.1: DeepSeek/Agnes contextWindow 变更,同步到 Engine 和 Orchestrator + // reloadAdapter 已重建 adapter,此处确保 Engine contextWindow 同步(兜底) + const ctxWindow = (value as number) || undefined; + agentLoop.updateConfig({ contextWindow: ctxWindow }); + orchestrator.updateDefaultConfig({ contextWindow: ctxWindow }); + log.info(`[CONFIG] ${key} updated to ${value}`); } else if (key === 'agent.confirmationTimeoutMs') { // 工具确认超时变更,即时应用到 ConfirmationHook confirmationHook.setConfirmationTimeout(value as number); diff --git a/electron/main.ts b/electron/main.ts index 34c95af..97461ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -49,6 +49,13 @@ import { CodeSearchTool, TaskManagerTool, DiffViewerTool, + // v0.3.1 新增工具(11 个) + GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool, + LintCodeTool, RunTestsTool, ProjectInfoTool, + HttpRequestTool, + TodoWriteTool, + ThinkTool, + ViewImageTool, } from './harness/tools/built-in'; import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks'; import { ConfirmationHook } from './harness/hooks/confirmation-hook'; @@ -144,7 +151,12 @@ async function initialize(): Promise { throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`); } - const adapterConfig = { provider, baseURL, apiKey, defaultModel: model }; + // v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段) + const contextWindow = provider !== 'ollama' + ? configService.get(`${provider}.contextWindow`) ?? undefined + : undefined; + + const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; switch (provider) { case 'agnes': return new AgnesAdapter(adapterConfig); case 'ollama': return new OllamaAdapter(adapterConfig); @@ -200,7 +212,26 @@ async function initialize(): Promise { // 注册 Web Browser 统一浏览器工具 toolRegistry.registerBuiltin(new WebBrowserTool()); - log.info(`Registered ${toolRegistry.size} built-in tools`); + + // v0.3.1: Git 工具集(4 个) + toolRegistry.registerBuiltin(new GitStatusTool()); + toolRegistry.registerBuiltin(new GitDiffTool()); + toolRegistry.registerBuiltin(new GitLogTool()); + toolRegistry.registerBuiltin(new GitCommitTool()); + + // v0.3.1: 开发工具集(3 个) + toolRegistry.registerBuiltin(new LintCodeTool()); + toolRegistry.registerBuiltin(new RunTestsTool()); + toolRegistry.registerBuiltin(new ProjectInfoTool()); + + // v0.3.1: 独立工具(4 个) + toolRegistry.registerBuiltin(new HttpRequestTool()); + toolRegistry.registerBuiltin(new TodoWriteTool()); + toolRegistry.registerBuiltin(new ThinkTool()); + toolRegistry.registerBuiltin(new ViewImageTool()); + + // v0.3.1 修复 WARN-5: DelegateTaskTool 注册后再输出总数(此时才是完整的 26 个) + // log.info 移至 DelegateTaskTool 注册后 // ===== MCP Manager ===== const mcpManager = new MCPManager(() => db, toolRegistry); @@ -236,6 +267,8 @@ async function initialize(): Promise { maxIterations: agentMaxIter ?? 20, totalTimeoutMs: agentTimeout ?? 600_000, contextLength: ollamaNumCtx ?? undefined, + // v0.3.1: 从 adapter.getContextWindow() 读取,修复 Engine 128K vs Adapter 1M 不一致 bug + contextWindow: adapter.getContextWindow(), thinkingEnabled: agentThinkingEnabled ?? true, thinkingEffort: agentThinkingEffort ?? 'high', toolExecutionTimeoutMs: toolExecTimeout ?? 120_000, @@ -255,6 +288,8 @@ async function initialize(): Promise { thinkingEnabled: agentThinkingEnabled ?? true, thinkingEffort: agentThinkingEffort ?? 'high', contextLength: ollamaNumCtx ?? undefined, + // v0.3.1: 同步 contextWindow 到 SubAgent 配置 + contextWindow: adapter.getContextWindow(), }, ); toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator)); @@ -262,16 +297,27 @@ async function initialize(): Promise { agentLoop.setTools(toolRegistry.listTools()); // v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存 confirmationHook.setToolDefs(toolRegistry.listAllTools()); + // v0.3.1: 所有工具(含 DelegateTaskTool)注册完成后输出总数,确保日志显示 26 而非 25 + log.info(`Registered ${toolRegistry.size} built-in tools`); // ===== 热重载 Adapter 回调(设置变更时触发)===== // 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化 let lastProvider = configService.get('llm.provider') ?? ''; - let lastConfigSig = JSON.stringify({ - provider: configService.get('llm.provider') ?? '', - model: configService.get('llm.model') ?? '', - apiKey: configService.get('llm.apiKey') ?? '', - baseURL: configService.get('llm.baseURL') ?? '', - }); + // v0.3.1: configSig 加入 contextWindow 配置,使 contextWindow 变化也能触发热重载 + const buildConfigSig = () => { + const provider = configService.get('llm.provider') ?? ''; + return JSON.stringify({ + provider, + model: configService.get('llm.model') ?? '', + apiKey: configService.get('llm.apiKey') ?? '', + baseURL: configService.get('llm.baseURL') ?? '', + // v0.3.1: 加入 contextWindow 配置(Ollama 用 numCtx,其他 Provider 用 ${provider}.contextWindow) + contextWindow: provider === 'ollama' + ? configService.get('ollama.numCtx') + : configService.get(`${provider}.contextWindow`), + }); + }; + let lastConfigSig = buildConfigSig(); const reloadAdapter = (): boolean => { try { // 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast) @@ -281,7 +327,7 @@ async function initialize(): Promise { apiKey: configService.get('llm.apiKey') ?? '', baseURL: configService.get('llm.baseURL') ?? '', }; - const currentSig = JSON.stringify(currentConfig); + const currentSig = buildConfigSig(); // 配置未变化 — 幂等返回成功,不重建 adapter,不发通知 if (currentSig === lastConfigSig) { @@ -292,13 +338,21 @@ async function initialize(): Promise { const newAdapter = createAdapter(); agentLoop.setAdapter(newAdapter); memoryConsolidator.setAdapter(newAdapter); - // Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口 + // Provider 切换时同步 contextLength 和 contextWindow const provider = currentConfig.provider; if (provider === 'ollama') { const numCtx = configService.get('ollama.numCtx'); - agentLoop.updateConfig({ contextLength: numCtx ?? undefined }); + agentLoop.updateConfig({ + contextLength: numCtx ?? undefined, + // v0.3.1: 同步 contextWindow(Ollama 的 getContextWindow 返回固定 4096) + contextWindow: newAdapter.getContextWindow(), + }); } else { - agentLoop.updateConfig({ contextLength: undefined }); + // v0.3.1: DeepSeek/Agnes 无 contextLength,但同步 contextWindow + agentLoop.updateConfig({ + contextLength: undefined, + contextWindow: newAdapter.getContextWindow(), + }); } log.info(`[CONFIG] Adapter reloaded: provider=${provider}`); // 仅在 Provider 真正变化时通知渲染进程 diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 9eea959..2812053 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -374,6 +374,11 @@ export class DatabaseService { // Ollama 配置 { key: 'ollama.numCtx', value: 'null', category: 'ollama' }, + // Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示) + // v0.3.1: DeepSeek/Agnes 改为可配置,不再写死 1M + { key: 'deepseek.contextWindow', value: '1000000', category: 'deepseek' }, + { key: 'agnes.contextWindow', value: '1000000', category: 'agnes' }, + // Onboarding { key: 'onboarding.completed', value: 'false', category: 'general' }, ]; diff --git a/package-lock.json b/package-lock.json index cb83857..8f89898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index c6251ea..b62bd7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.1", + "version": "0.3.2", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 1488030..410418c 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -290,6 +290,9 @@ function LLMSettings() { const [apiKey, setApiKey] = useConfig('llm.apiKey', ''); const [baseURL, setBaseURL] = useConfig('llm.baseURL', ''); const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null); + // v0.3.1: DeepSeek/Agnes contextWindow 可配置(不再写死 1M) + const [dsCtxWindow, setDsCtxWindow] = useConfig('deepseek.contextWindow', 1000000); + const [agnesCtxWindow, setAgnesCtxWindow] = useConfig('agnes.contextWindow', 1000000); const [showKey, setShowKey] = useState(false); // 同步 Provider/Model 到 Agent Store(含 contextWindow) @@ -297,12 +300,16 @@ function LLMSettings() { useAgentStore.getState().setProvider(provider, model); }, [provider, model]); - // Ollama numCtx 变化时同步 contextWindow + // v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider) useEffect(() => { - if (provider === 'ollama' && numCtx != null && numCtx > 0) { - useAgentStore.setState({ contextWindow: numCtx }); + if (provider === 'ollama') { + if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx }); + } else if (provider === 'deepseek') { + if (dsCtxWindow != null && dsCtxWindow > 0) useAgentStore.setState({ contextWindow: dsCtxWindow }); + } else if (provider === 'agnes') { + if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow }); } - }, [provider, numCtx]); + }, [provider, numCtx, dsCtxWindow, agnesCtxWindow]); // 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用) const handleProviderChange = (newProvider: string) => { @@ -359,6 +366,31 @@ function LLMSettings() { slotProps={{ htmlInput: { min: 512, step: 512 } }} /> )} + {/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */} + {provider === 'deepseek' && ( + setDsCtxWindow(Number(e.target.value) || 1000000)} + placeholder="如 64000、128000、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + helperText="用于上下文压缩判断,不传给 API" + /> + )} + {provider === 'agnes' && ( + setAgnesCtxWindow(Number(e.target.value) || 1000000)} + placeholder="如 64000、128000、1000000" + slotProps={{ htmlInput: { min: 4096, step: 4096 } }} + helperText="用于上下文压缩判断,不传给 API" + /> + )} ); } @@ -466,7 +498,8 @@ function ToolsSettings() { } }; - const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error' }; + // v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color + const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error', critical: 'error' }; // 需要确认的工具(high/critical 或 requiresPermission) const needsConfirmTools = tools.filter((t) => diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index a784bfd..a6deea9 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -375,12 +375,14 @@ export const useAgentStore = create((set, get) => ({ setProvider: (provider, model) => { // L-16 修复: 使用命名常量替代魔法数字 - // DeepSeek/Agnes 固定 1M,Ollama 从配置读取 - const ollamaCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; - set({ provider, model, contextWindow: ollamaCtx }); - // 异步读取 Ollama 实际配置 - if (provider === 'ollama' && window.metona?.config?.get) { - window.metona.config.get('ollama.numCtx').then((v) => { + // v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 + const initialCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; + set({ provider, model, contextWindow: initialCtx }); + // v0.3.1: 异步读取实际配置(所有 Provider) + if (window.metona?.config?.get) { + // Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow + const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; + window.metona.config.get(configKey).then((v) => { if (v != null && typeof v === 'number' && v > 0) { set({ contextWindow: v }); }