diff --git a/.claude/settings.json b/.claude/settings.json index a6f7df9d..a9f021ed 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,9 @@ "Write(*.md)", "Write(*.ts)", "Edit(*.css)", - "Bash(ls*)" + "Bash(ls*)", + "Bash(ssh*)", + "Bash(curl*)" ], "deny": [] }, diff --git a/.claude/skills/analyze-logs/SKILL.md b/.claude/skills/analyze-logs/SKILL.md new file mode 100644 index 00000000..7968cdd4 --- /dev/null +++ b/.claude/skills/analyze-logs/SKILL.md @@ -0,0 +1,75 @@ +--- +description: Analyze runtime logs, evolve logs, and agent logs to identify error patterns, repeated failures, and evolution opportunities. Use when asked to review logs, diagnose issues, or find improvement areas. +user-invocable: true +argument-hint: "[hours=24] [level=error|warn|all]" +--- + +# Analyze Logs Skill + +Scan all available log sources, identify patterns, and produce an actionable report for self-improvement. + +## Log Sources + +1. **Runtime Log** (`~/.claude/runtime.log`) — Captured console.error/warn/log from the running process (JSONL format) +2. **Evolve Log** (`~/.claude/evolve-log.jsonl`) — Self-evolution history: what was changed, tsc pass/fail, restart count +3. **Agent Logs** (`~/.claude/tasks/conversations/*.log`) — Background agent execution results +4. **Session History** (`~/.claude/history.jsonl`) — User input history across sessions + +## Analysis Steps + +### Step 1: Read Runtime Logs +Read `~/.claude/runtime.log` (the main log file). Parse JSONL entries. Focus on: +- **error** level entries: categorize by module, extract patterns +- **warn** level entries: identify recurring warnings +- Count entries by module to find "noisy" components +- Identify time clusters (many errors at once = incident) + +### Step 2: Read Evolve Log +Read `~/.claude/evolve-log.jsonl`. Analyze: +- Which modules were modified most frequently (indicates instability) +- tsc failures (what caused them?) +- Patterns in "reason" field — recurring themes + +### Step 3: Scan Agent Logs +Grep across `~/.claude/tasks/conversations/*.log` for: +- Errors other than "Task cancelled by user" and "Test error" +- Stack traces +- Timeout patterns +- Empty log files (indicates logging bugs) + +### Step 4: Cross-Reference +- Do runtime errors correlate with evolve changes? (regression detection) +- Do agent failures cluster around certain time periods? +- Are there modules that appear in both runtime errors AND evolve log? (unstable modules) + +## Output Format + +Produce a structured report: + +``` +## Log Analysis Report (last N hours) + +### Runtime Errors Summary +- Total entries: X (errors: Y, warns: Z) +- Top error modules: [Module]: count +- Error patterns: [description] + +### Evolution Health +- Total evolves: X (success: Y, tsc fail: Z) +- Most modified modules: [module]: count +- Unstable modules (evolved 3+ times): [list] + +### Agent Health +- Total agent tasks: X (completed: Y, failed: Z, cancelled: W) +- Real failures (excluding cancels): [list with details] + +### Recommendations +1. [Specific actionable recommendation] +2. [Another recommendation] +``` + +## Important Notes +- Only do READ operations. Never modify any log files. +- If runtime.log doesn't exist yet, note that the Logger was just installed and skip that section. +- Use Grep with `head_limit` when scanning large directories to avoid timeout. +- Write key findings to the project notebook so they persist across sessions. diff --git a/docs/marketing/discord.md b/docs/marketing/discord.md new file mode 100644 index 00000000..0da271ed --- /dev/null +++ b/docs/marketing/discord.md @@ -0,0 +1,133 @@ +# Discord Promotion Posts + +## Target Communities + +1. Anthropic / Claude Community Discord +2. AI Developer Communities (Cline, Roo Code, Cursor, Windsurf, etc.) +3. Open Source / Coding Communities +4. Your own Discord: https://discord.gg/bNyJKk6PVZ + +--- + +## Post 1: General AI/Dev Communities (English) + +### Title / First Line +**I built an open-source Claude Code with a full Web IDE and multi-agent system** + +### Body + +Hey everyone! I've been working on an open-source AI coding platform called **Claude Code Open** - it started as a reverse-engineering study of Anthropic's Claude Code CLI but evolved into something much bigger. + +**What makes it different:** + +**Web IDE** - Not just a terminal. It's a full browser-based IDE with Monaco editor, file tree, AI-enhanced code editing, terminal panel, and Git integration. Open `localhost:3456` and start coding. + +**Blueprint Multi-Agent System** - Break complex tasks into subtasks and dispatch them across multiple AI agents working in parallel. Smart Planner analyzes requirements, Lead Agent coordinates, Workers execute. Real-time Swarm Console shows everything happening. + +**37+ Built-in Tools** - File ops, ripgrep search, shell, Playwright browser automation, database client (PostgreSQL/MySQL/SQLite/Redis/MongoDB), DAP debugger, LSP, scheduled task daemon, and more. + +**Self-Evolution** - The AI can modify its own source code, run TypeScript compilation checks, and hot-reload. Full audit log for safety. + +**One-Click Install** - Scripts for Windows/macOS/Linux. Docker support too. + +Everything runs locally. MIT licensed. No telemetry. Supports Anthropic API / AWS Bedrock / Google Vertex AI. + +- GitHub: https://github.com/kill136/claude-code-open +- Live Demo: http://voicegpt.site:3456/ +- Website: https://www.chatbi.site +- Discord: https://discord.gg/bNyJKk6PVZ + +Would love your feedback! + +--- + +## Post 2: Claude/Anthropic Specific Communities (English) + +### Title / First Line +**Open-source Claude Code alternative with Web UI - 37+ tools, multi-agent, runs locally** + +### Body + +Built an open-source platform that extends what Claude Code can do: + +- **Browser-based IDE** instead of terminal-only - Monaco editor, file tree, inline AI code review +- **Multi-agent orchestration** - Blueprint system breaks complex projects across parallel AI agents +- **Database client** - Connect to PostgreSQL, MySQL, SQLite, Redis, MongoDB directly from conversation +- **Scheduled automation** - "Every morning at 9am, review yesterday's commits and notify me" +- **Self-evolution** - AI modifies its own source code with safety checks +- **MCP protocol** support for external tool integration + +One-click install for Windows/macOS/Linux. Docker deployment. MIT licensed. + +Try the live demo: http://voicegpt.site:3456/ +GitHub: https://github.com/kill136/claude-code-open + +Star it if you find it useful! + +--- + +## Post 3: Chinese Communities (中文) + +### Title / First Line +**开源了一个 Claude Code 替代品:Web IDE + 多智能体 + 37+ 工具,一键安装** + +### Body + +大家好!分享一个我做的开源项目 **Claude Code Open**,一个完整的 AI 编程平台。 + +**和官方 Claude Code 的区别:** + +1. **有 Web IDE** — 浏览器打开就能用,Monaco 编辑器 + 文件树 + AI 辅助编辑,不需要终端 +2. **多智能体协作** — Blueprint 系统把复杂任务拆分给多个 AI Agent 并行处理 +3. **37+ 内置工具** — 文件操作、数据库客户端、浏览器自动化、调试器、定时任务等 +4. **自我进化** — AI 可以修改自己的源码并热重载 +5. **一键安装** — Windows/macOS/Linux 一条命令搞定 + +MIT 协议,完全开源,数据不出你的机器。 + +在线体验:http://voicegpt.site:3456/ +GitHub:https://github.com/kill136/claude-code-open +Discord:https://discord.gg/bNyJKk6PVZ + +欢迎 Star 和反馈! + +--- + +## Post 4: Short Version (for channels with character limits) + +**Claude Code Open** - Open-source AI coding platform with Web IDE, multi-agent system, 37+ tools. + +Features: Browser IDE, Blueprint multi-agent, database client, scheduled tasks, self-evolution, MCP support. + +MIT licensed. One-click install. Runs locally. + +GitHub: https://github.com/kill136/claude-code-open +Demo: http://voicegpt.site:3456/ + +--- + +## Discord Servers to Post In + +### Already posted (from project notebook): +- Cline +- Roo Code +- WeMakeDevs +- Cursor +- Windsurf + +### New targets: +1. **Anthropic Community** - discord.gg/anthropic (if exists) +2. **Claude Users** - search for Claude-focused servers +3. **AI Tools / AI Coding** - general AI developer communities +4. **Open Source** - open source project showcase channels +5. **TypeScript / Node.js** - language-specific communities +6. **Vibe Coding** - communities for AI-assisted development +7. **IndieHackers** - indie developer communities +8. **AI Agent Builders** - communities focused on AI agents + +### Posting Tips: +- Post in #showcase, #projects, #share-your-work, or #self-promotion channels +- Don't spam - one post per server +- Engage with comments/questions after posting +- Include the live demo link - it's the best proof +- Post during peak hours (US morning / evening) diff --git a/src/auth/index.ts b/src/auth/index.ts index 6bafdb4b..34e0eeb4 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -97,6 +97,8 @@ export interface UserProfileResponse { const AUTH_DIR = path.join(os.homedir(), '.claude'); const AUTH_FILE = path.join(AUTH_DIR, 'auth.json'); const CREDENTIALS_FILE = path.join(AUTH_DIR, 'credentials.json'); +// 用户配置文件(Web UI 保存的 apiKey 等配置) +const SETTINGS_FILE = path.join(AUTH_DIR, 'settings.json'); // 官方 Claude Code 的配置文件(存储 primaryApiKey) const CONFIG_FILE = path.join(AUTH_DIR, 'config.json'); // 官方 Claude Code 的 OAuth 凭据文件(存储 claudeAiOauth) @@ -269,6 +271,24 @@ export function initAuth(): AuthConfig | null { return currentAuth; } + // 1c. 检查 settings.json 的 apiKey(Web UI 配置) + // 用户在 Web UI 设置页面保存的 API Key,优先级高于 OAuth token 和内置代理 + if (fs.existsSync(SETTINGS_FILE)) { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')); + if (settings.apiKey) { + currentAuth = { + type: 'api_key', + accountType: 'api', + apiKey: settings.apiKey, + }; + return currentAuth; + } + } catch (err) { + // 忽略解析错误 + } + } + // 2. 检查官方 Claude Code 的 .credentials.json(OAuth token) // // 重要发现(通过抓包和测试发现): diff --git a/src/browser/manager.ts b/src/browser/manager.ts index f7c320a3..df1858d9 100644 --- a/src/browser/manager.ts +++ b/src/browser/manager.ts @@ -247,7 +247,7 @@ function killProc(proc: ChildProcessWithoutNullStreams): void { // Windows: use taskkill for reliable termination try { if (proc.pid) { - execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: 'ignore' }); + execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: 'ignore', windowsHide: true }); } } catch { proc.kill(); diff --git a/src/cli.ts b/src/cli.ts index c7207c99..a61b0cec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,10 @@ import { FAST_MODE_DISPLAY_NAME, forcePrefetchPenguinMode, } from './fast-mode/index.js'; +import { logger } from './utils/logger.js'; + +// 初始化运行时日志系统(CLI 模式) +logger.init({ interceptConsole: true, minLevel: 'info' }); // 工作目录列表 const additionalDirectories: string[] = []; diff --git a/src/context/session-memory.ts b/src/context/session-memory.ts index 8b098d93..8f4793a3 100644 --- a/src/context/session-memory.ts +++ b/src/context/session-memory.ts @@ -461,7 +461,12 @@ export const DEFAULT_UPDATE_CONFIG: SessionMemoryUpdateConfig = { // ============================================================================ /** - * Session Memory 状态 + * Session Memory 状态(按 sessionId 隔离) + * + * 官方 CLI 是单会话进程,所以用全局单例没问题。 + * 我们的 Web 模式允许多会话并发,必须按 sessionId 隔离状态, + * 否则会话 A 的 isWriting 会阻塞会话 B 的 waitForWrite, + * token 计数会跨会话累加导致错误触发更新。 */ interface SessionMemoryState { /** 上次压缩的 UUID */ @@ -478,52 +483,73 @@ interface SessionMemoryState { isInitialized: boolean; } -const state: SessionMemoryState = { - lastCompactedUuid: undefined, - isWriting: false, - totalInputTokens: 0, - totalMessageTokens: 0, - lastUpdateTokens: 0, - isInitialized: false, -}; +/** 默认 key(CLI 单会话模式) */ +const DEFAULT_SESSION_KEY = '__default__'; + +/** 按 sessionId 隔离的状态存储 */ +const stateMap = new Map(); + +/** 创建初始状态 */ +function createInitialState(): SessionMemoryState { + return { + lastCompactedUuid: undefined, + isWriting: false, + totalInputTokens: 0, + totalMessageTokens: 0, + lastUpdateTokens: 0, + isInitialized: false, + }; +} + +/** 获取指定会话的状态,不存在则创建 */ +function getState(sessionId?: string): SessionMemoryState { + const key = sessionId || DEFAULT_SESSION_KEY; + let state = stateMap.get(key); + if (!state) { + state = createInitialState(); + stateMap.set(key, state); + } + return state; +} /** * 获取上次压缩的 UUID * 官方: Xs2() */ -export function getLastCompactedUuid(): string | undefined { - return state.lastCompactedUuid; +export function getLastCompactedUuid(sessionId?: string): string | undefined { + return getState(sessionId).lastCompactedUuid; } /** * 设置上次压缩的 UUID * 官方: oEA() */ -export function setLastCompactedUuid(uuid: string | undefined): void { - state.lastCompactedUuid = uuid; +export function setLastCompactedUuid(uuid: string | undefined, sessionId?: string): void { + getState(sessionId).lastCompactedUuid = uuid; } /** * 标记开始写入 * 官方: Is2() */ -export function markWriteStart(): void { - state.isWriting = true; +export function markWriteStart(sessionId?: string): void { + getState(sessionId).isWriting = true; } /** * 标记写入结束 * 官方: Ds2() */ -export function markWriteEnd(): void { - state.isWriting = false; +export function markWriteEnd(sessionId?: string): void { + getState(sessionId).isWriting = false; } /** * 等待写入完成 * 官方: Ws2() */ -export async function waitForWrite(timeout: number = 15000): Promise { +export async function waitForWrite(timeout: number = 15000, sessionId?: string): Promise { + const state = getState(sessionId); const startTime = Date.now(); const maxWait = 60000; @@ -538,23 +564,24 @@ export async function waitForWrite(timeout: number = 15000): Promise { * 增加输入 token 数 * 官方: Hs2() */ -export function addInputTokens(tokens: number): void { - state.totalInputTokens += tokens; +export function addInputTokens(tokens: number, sessionId?: string): void { + getState(sessionId).totalInputTokens += tokens; } /** * 增加消息 token 数 * 官方: Es2() */ -export function addMessageTokens(tokens: number): void { - state.totalMessageTokens += tokens; +export function addMessageTokens(tokens: number, sessionId?: string): void { + getState(sessionId).totalMessageTokens += tokens; } /** * 记录上次更新的 token 数 * 官方: zs2() */ -export function recordUpdateTokens(): void { +export function recordUpdateTokens(sessionId?: string): void { + const state = getState(sessionId); state.lastUpdateTokens = state.totalInputTokens; } @@ -562,44 +589,48 @@ export function recordUpdateTokens(): void { * 检查是否已初始化 * 官方: $s2() */ -export function isInitialized(): boolean { - return state.isInitialized; +export function isInitialized(sessionId?: string): boolean { + return getState(sessionId).isInitialized; } /** * 标记已初始化 * 官方: Cs2() */ -export function markInitialized(): void { - state.isInitialized = true; +export function markInitialized(sessionId?: string): void { + getState(sessionId).isInitialized = true; } /** * 检查是否应该初始化 * 官方: Us2() */ -export function shouldInit(config: SessionMemoryUpdateConfig = DEFAULT_UPDATE_CONFIG): boolean { - return state.totalMessageTokens >= config.minimumMessageTokensToInit; +export function shouldInit(config: SessionMemoryUpdateConfig = DEFAULT_UPDATE_CONFIG, sessionId?: string): boolean { + return getState(sessionId).totalMessageTokens >= config.minimumMessageTokensToInit; } /** * 检查是否应该更新 * 官方: qs2() */ -export function shouldUpdate(config: SessionMemoryUpdateConfig = DEFAULT_UPDATE_CONFIG): boolean { +export function shouldUpdate(config: SessionMemoryUpdateConfig = DEFAULT_UPDATE_CONFIG, sessionId?: string): boolean { + const state = getState(sessionId); return state.totalInputTokens - state.lastUpdateTokens >= config.minimumTokensBetweenUpdate; } /** - * 重置状态 + * 重置指定会话的状态 + */ +export function resetState(sessionId?: string): void { + const key = sessionId || DEFAULT_SESSION_KEY; + stateMap.delete(key); +} + +/** + * 清除所有会话状态(用于测试) */ -export function resetState(): void { - state.lastCompactedUuid = undefined; - state.isWriting = false; - state.totalInputTokens = 0; - state.totalMessageTokens = 0; - state.lastUpdateTokens = 0; - state.isInitialized = false; +export function resetAllStates(): void { + stateMap.clear(); } // ============================================================================ diff --git a/src/core/client.ts b/src/core/client.ts index 6cd5ba6f..3edfd083 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -352,8 +352,44 @@ function formatSystemPrompt( * * 注意:thinking 和 redacted_thinking 类型的 block 不添加缓存控制 */ + +/** + * 清理字符串中的孤立 UTF-16 代理字符(lone surrogates) + * + * JSON 规范要求 surrogate pairs 必须成对出现(高代理 \uD800-\uDBFF 后跟低代理 \uDC00-\uDFFF)。 + * 如果字符串中包含孤立的代理字符,JSON.stringify 会保留它们,但 API 端解析时会报错: + * "not valid JSON: no low surrogate in string" + * + * 常见来源:Bash 输出包含二进制数据、损坏的文件内容、截断的 UTF-16 字符串。 + */ +function sanitizeSurrogates(str: string): string { + // 匹配孤立的高代理(后面没有低代理)或孤立的低代理(前面没有高代理) + // eslint-disable-next-line no-control-regex + return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?, enableThinking?: boolean): Array<{ role: string; content: any }> { - return messages.map((m, msgIndex) => { + const formatted = messages.map((m, msgIndex) => { const isLastMessage = msgIndex === messages.length - 1; // 如果 content 是字符串,转换为数组格式并添加缓存控制 @@ -401,6 +437,7 @@ function formatMessages(messages: Array<{ role: string; content: any }>, enableT return { role: m.role, content: m.content }; }); + return sanitizeContent(formatted); } // 会话相关的全局状态 diff --git a/src/core/loop.ts b/src/core/loop.ts index 5e4c4c50..523fbae4 100644 --- a/src/core/loop.ts +++ b/src/core/loop.ts @@ -9,6 +9,7 @@ import { toolRegistry } from '../tools/index.js'; import { runWithCwd, runGeneratorWithCwd } from './cwd-context.js'; import { isToolSearchEnabled } from '../tools/mcp.js'; import { isDeferredTool, getDiscoveredToolsFromMessages } from '../mcp/tools.js'; +import { t } from '../i18n/index.js'; import type { Message, ContentBlock, ToolDefinition, PermissionMode, AnyContentBlock, ToolResult } from '../types/index.js'; // ============================================================================ @@ -2956,7 +2957,7 @@ Guidelines: if (handlerResult.cancelled) { result = { success: false, - error: 'User cancelled the question dialog', + error: t('loop.userCancelled'), }; } else { // 使用官方格式返回结果 @@ -2971,7 +2972,7 @@ Guidelines: } catch (err) { result = { success: false, - error: `AskUserQuestion handler error: ${err instanceof Error ? err.message : String(err)}`, + error: t('loop.handlerError', { error: err instanceof Error ? err.message : String(err) }), }; } } else { diff --git a/src/daemon/alarm.ts b/src/daemon/alarm.ts index d3dc1b98..b816b9df 100644 --- a/src/daemon/alarm.ts +++ b/src/daemon/alarm.ts @@ -14,7 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; + // ============================================================================ // 路径常量 @@ -169,19 +169,9 @@ function ensureDir(dir: string): void { */ function isProcessAlive(pid: number): boolean { try { - if (process.platform === 'win32') { - const output = execSync(`tasklist /FI "PID eq ${pid}" /NH`, { - encoding: 'utf-8', - timeout: 3000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - // tasklist 输出包含 PID 数字则进程存在 - return output.includes(String(pid)); - } else { - // Unix: kill -0 不发送信号,只检查进程是否存在 - process.kill(pid, 0); - return true; - } + // kill(pid, 0) 不发送信号,仅检查进程是否存在,跨平台通用 + process.kill(pid, 0); + return true; } catch { return false; } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7ca398e0..f44f70f1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -862,6 +862,68 @@ const en = { 'structuredOutput.schemaError': 'Output does not match required schema: {{errors}}', 'structuredOutput.success': 'Structured output provided successfully', + // ============ File Tool ============ + 'file.mustReadBeforeEdit': 'You must read the file with the Read tool before editing it. File: {{path}}', + 'file.modifiedSinceRead': 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', + 'file.confirmationRequired': 'Confirmation required before applying changes', + 'file.stringNotFound': 'String to replace not found in file.\nString: {{str}}', + 'file.multipleMatches': 'Found {{count}} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: {{str}}', + 'file.blockedByHook': 'Blocked by hook', + + // ============ Agent Tool ============ + 'agent.executionFailed': 'Agent execution failed: {{error}}', + 'agent.executionInterrupted': 'Agent execution was interrupted', + 'agent.taskNotFound': 'Task {{id}} not found', + + // ============ Ask Tool ============ + 'ask.noQuestions': 'No questions provided', + 'ask.maxQuestions': 'Maximum 4 questions allowed', + 'ask.headerTooLong': 'Question header "{{header}}" exceeds maximum length of {{max}} characters', + 'ask.invalidOptionCount': 'Question "{{header}}" must have 2-4 options (has {{count}})', + 'ask.optionMissingLabel': 'Option {{index}} in question "{{header}}" must have a label', + 'ask.optionMissingDescription': 'Option {{index}} in question "{{header}}" must have a description', + + // ============ LSP Tool ============ + 'lsp.notInitialized': 'LSP server manager not initialized. This may indicate a startup issue.', + 'lsp.noServerAvailable': 'No LSP server available for file type: {{ext}}', + 'lsp.noCallHierarchy': 'LSP server did not return call hierarchy results', + + // ============ MCP Tool ============ + 'mcp.toolCallFailed': 'MCP tool call failed: {{tool}}. Server did not respond or returned an error.', + 'mcp.resourceNotFound': 'Resource not found or empty: {{uri}}', + 'mcp.serverAndUriRequired': 'Both "server" and "uri" are required for action="read"', + 'mcp.unknownAction': 'Unknown action: {{action}}. Use "list" or "read".', + + // ============ PlanMode Tool ============ + 'planmode.alreadyInPlanMode': 'Already in plan mode. Use ExitPlanMode to exit first.', + 'planmode.notInPlanMode': 'Not in plan mode. Use EnterPlanMode first.', + + // ============ Sandbox Tool ============ + 'sandbox.noSandboxAvailable': 'No sandbox available and fallback is disabled', + 'sandbox.executionFailed': 'Sandbox execution failed: {{error}}', + + // ============ Notebook Tool ============ + 'notebook.cellNotFoundById': 'Cell not found with ID: {{id}}. Available cells: {{count}}', + 'notebook.cellNotFound': 'Cell with ID "{{id}}" not found in notebook.', + 'notebook.cellOutOfRange': 'Cell index out of range: {{index}} (total cells: {{count}})', + 'notebook.cellIdRequired': 'cell_id is required for delete mode', + + // ============ Skill Tool ============ + 'skill.notFound': 'Skill "{{name}}" not found. Available skills: {{available}}', + 'skill.modelInvocationDisabled': 'Skill "{{name}}" has model invocation disabled', + + // ============ Bash Tool ============ + 'bash.commandBlocked': 'Command blocked for security reasons: {{reason}}', + 'bash.blockedByHook': 'Blocked by hook: {{message}}', + 'bash.shellNotFound': 'No shell found with ID: {{id}}', + + // ============ Loop Core ============ + 'loop.userCancelled': 'User cancelled the question dialog', + 'loop.handlerError': 'AskUserQuestion handler error: {{error}}', + + // ============ Web Redirect ============ + 'web.redirectDetected': 'REDIRECT DETECTED: The URL redirects to a different host.', + // ============ Auth ============ 'auth.envVar': 'Authenticated via environment variable', 'auth.apiKey': 'Authenticated with API key', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 5229e125..8746450a 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -864,6 +864,68 @@ const zh: Record = { 'structuredOutput.schemaError': '输出不匹配所需的模式:{{errors}}', 'structuredOutput.success': '结构化输出提供成功', + // ============ File Tool ============ + 'file.mustReadBeforeEdit': '编辑文件前必须先用 Read 工具读取。文件: {{path}}', + 'file.modifiedSinceRead': '文件自上次读取后已被修改(可能是用户或 linter 修改)。请重新读取后再尝试写入。', + 'file.confirmationRequired': '应用更改前需要确认', + 'file.stringNotFound': '文件中未找到要替换的字符串。\n字符串: {{str}}', + 'file.multipleMatches': '找到 {{count}} 个匹配项,但 replace_all 为 false。要替换所有匹配项请设置 replace_all 为 true。要只替换一个,请提供更多上下文以唯一标识。\n字符串: {{str}}', + 'file.blockedByHook': '被 Hook 阻止', + + // ============ Agent Tool ============ + 'agent.executionFailed': '代理执行失败: {{error}}', + 'agent.executionInterrupted': '代理执行被中断', + 'agent.taskNotFound': '任务 {{id}} 未找到', + + // ============ Ask Tool ============ + 'ask.noQuestions': '未提供问题', + 'ask.maxQuestions': '最多允许 4 个问题', + 'ask.headerTooLong': '问题标题 "{{header}}" 超过最大长度 {{max}} 个字符', + 'ask.invalidOptionCount': '问题 "{{header}}" 必须有 2-4 个选项(当前有 {{count}} 个)', + 'ask.optionMissingLabel': '问题 "{{header}}" 中第 {{index}} 个选项必须有标签', + 'ask.optionMissingDescription': '问题 "{{header}}" 中第 {{index}} 个选项必须有描述', + + // ============ LSP Tool ============ + 'lsp.notInitialized': 'LSP 服务器管理器未初始化。这可能表示启动时出了问题。', + 'lsp.noServerAvailable': '没有可用于文件类型 {{ext}} 的 LSP 服务器', + 'lsp.noCallHierarchy': 'LSP 服务器未返回调用层次结果', + + // ============ MCP Tool ============ + 'mcp.toolCallFailed': 'MCP 工具调用失败: {{tool}}。服务器未响应或返回了错误。', + 'mcp.resourceNotFound': '资源未找到或为空: {{uri}}', + 'mcp.serverAndUriRequired': 'action="read" 时 "server" 和 "uri" 都是必需的', + 'mcp.unknownAction': '未知操作: {{action}}。请使用 "list" 或 "read"。', + + // ============ PlanMode Tool ============ + 'planmode.alreadyInPlanMode': '已在计划模式中。请先使用 ExitPlanMode 退出。', + 'planmode.notInPlanMode': '不在计划模式中。请先使用 EnterPlanMode。', + + // ============ Sandbox Tool ============ + 'sandbox.noSandboxAvailable': '没有可用的沙箱且备用方案已禁用', + 'sandbox.executionFailed': '沙箱执行失败: {{error}}', + + // ============ Notebook Tool ============ + 'notebook.cellNotFoundById': '未找到 ID 为 {{id}} 的单元格。可用单元格数: {{count}}', + 'notebook.cellNotFound': '笔记本中未找到 ID 为 "{{id}}" 的单元格。', + 'notebook.cellOutOfRange': '单元格索引越界: {{index}}(共 {{count}} 个单元格)', + 'notebook.cellIdRequired': '删除模式需要 cell_id', + + // ============ Skill Tool ============ + 'skill.notFound': 'Skill "{{name}}" 未找到。可用 Skills: {{available}}', + 'skill.modelInvocationDisabled': 'Skill "{{name}}" 已禁用模型调用', + + // ============ Bash Tool ============ + 'bash.commandBlocked': '命令因安全原因被阻止: {{reason}}', + 'bash.blockedByHook': '被 Hook 阻止: {{message}}', + 'bash.shellNotFound': '未找到 ID 为 {{id}} 的 shell', + + // ============ Loop Core ============ + 'loop.userCancelled': '用户取消了问题对话', + 'loop.handlerError': 'AskUserQuestion 处理器错误: {{error}}', + + // ============ Web Redirect ============ + 'web.redirectDetected': '检测到重定向:URL 重定向到了不同的主机。', + // ============ Auth ============ 'auth.envVar': '已通过环境变量认证', 'auth.apiKey': '已通过 API Key 认证', diff --git a/src/mcp/config.ts b/src/mcp/config.ts index ca39a7cd..0174be08 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -425,7 +425,7 @@ export class McpConfigManager { private checkCommandExists(command: string): boolean { try { if (process.platform === 'win32') { - execSync(`where "${command}"`, { stdio: 'ignore' }); + execSync(`where "${command}"`, { stdio: 'ignore', windowsHide: true }); } else { execSync(`command -v "${command}"`, { stdio: 'ignore' }); } diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index e72c4acf..bf4f9737 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -31,6 +31,7 @@ import * as path from 'path'; import { fileURLToPath } from 'url'; import { getNotebookManager } from '../memory/notebook.js'; import { estimateTokens } from '../utils/token-estimate.js'; +import { logger } from '../utils/logger.js'; /** * 默认选项 @@ -279,7 +280,12 @@ ${process.env.CLAUDE_EVOLVE_ENABLED === '1' ? `- Status: ENABLED (running with - - Flow: Edit .ts files → SelfEvolve({ reason: "..." }) → tsc check → auto-restart → session restored - Evolve log: ${claudeConfigDir}/evolve-log.jsonl - IMPORTANT: Always use dryRun first to verify compilation before actual restart` : `- Status: DISABLED (not running with --evolve flag) -- To enable: start the server with claude-web --evolve instead of claude-web`}`); +- To enable: start the server with claude-web --evolve instead of claude-web`} + +### Runtime Logs (运行日志) +- Log file: ${claudeConfigDir}/runtime.log (JSONL, auto-rotated, Read this file to inspect runtime errors) +- Use /analyze-logs skill for comprehensive log analysis +${getLogStatsSummary()}`); // 13. 语言设置 if (context.language) { @@ -448,6 +454,38 @@ Always respond in ${context.language}. Use ${context.language} for all explanati } +/** + * 获取运行日志统计 + 最近错误摘要(用于系统提示词) + * 统计行 + 最近 5 分钟内的 error 详情(最多 5 条) + * 这样 AI 每轮对话都能感知到新出现的错误 + */ +function getLogStatsSummary(): string { + try { + const stats = logger.getStats(1); // 最近 1 小时 + const lines: string[] = []; + + if (stats.errors > 0 || stats.warns > 0) { + lines.push(`- Last 1h: ${stats.errors} errors, ${stats.warns} warns`); + } else { + lines.push('- Last 1h: no errors'); + } + + // 注入最近 5 分钟内的 error 摘要,让 AI 实时感知 + const recentErrors = logger.getRecentErrors(5 * 60 * 1000, 5); + if (recentErrors.length > 0) { + lines.push('- **Recent errors (last 5min):**'); + for (const err of recentErrors) { + const ago = Math.round((Date.now() - new Date(err.ts).getTime()) / 1000); + lines.push(` ${ago}s ago [${err.module}] ${err.msg.slice(0, 120)}`); + } + } + + return lines.join('\n'); + } catch { + return '- Stats: unavailable'; + } +} + /** * 全局构建器实例 */ diff --git a/src/prompt/templates.ts b/src/prompt/templates.ts index 9e4fc50c..0b918d41 100644 --- a/src/prompt/templates.ts +++ b/src/prompt/templates.ts @@ -626,7 +626,7 @@ export function getEnvironmentInfo(context: { // Windows: 列出所有可用磁盘驱动器,让 Agent 知道完整的文件系统布局 if (context.platform === 'win32') { try { - const wmicOutput = execSync('wmic logicaldisk get name', { encoding: 'utf-8', timeout: 5000 }); + const wmicOutput = execSync('wmic logicaldisk get name', { encoding: 'utf-8', timeout: 5000, windowsHide: true }); const drives = wmicOutput.split('\n') .map((l: string) => l.trim()) .filter((l: string) => /^[A-Z]:$/.test(l)); diff --git a/src/session/index.ts b/src/session/index.ts index 3de14eed..2e33dbd3 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -322,6 +322,11 @@ export interface MergeOptions { conflictResolution?: 'source' | 'target'; // 冲突解决策略 } +/** + * 上次清理过期会话的时间戳(用于节流) + */ +let lastCleanupTime = 0; + /** * 确保会话目录存在 */ @@ -531,7 +536,7 @@ export function saveSession(session: SessionData, options?: { useResumedPath?: b session.metadata.messageCount = session.messages.length; // 原子写入:先写临时文件再 rename,防止进程中途被杀导致文件半写损坏 - const content = JSON.stringify(session, null, 2); + const content = JSON.stringify(session); const tmpFile = `${sessionPath}.tmp.${process.pid}.${Date.now()}`; try { fs.writeFileSync(tmpFile, content, { mode: 0o600, flush: true }); @@ -545,8 +550,12 @@ export function saveSession(session: SessionData, options?: { useResumedPath?: b // 使该会话的缓存失效(因为文件已更新) invalidateSessionCache(sessionId); - // 清理过期会话 - cleanupOldSessions(); + // 清理过期会话(节流:只在距上次清理超过 10 分钟时才执行) + const now = Date.now(); + if (now - lastCleanupTime > 10 * 60 * 1000) { + lastCleanupTime = now; + cleanupOldSessions(); + } } /** @@ -921,44 +930,76 @@ export function updateSessionSummary(session: SessionData, summary: string): voi function cleanupOldSessions(): void { ensureSessionDir(); - const files = fs.readdirSync(getSessionDir()).filter((f) => f.endsWith('.json')); - const sessions: { file: string; updatedAt: number }[] = []; + const sessionDir = getSessionDir(); + const allFiles = fs.readdirSync(sessionDir); + const jsonFiles = allFiles.filter((f) => f.endsWith('.json')); + const sessions: { file: string; mtime: number }[] = []; const expiryTime = Date.now() - getSessionExpiryDays() * 24 * 60 * 60 * 1000; - for (const file of files) { + for (const file of jsonFiles) { try { - const content = fs.readFileSync(path.join(getSessionDir(), file), 'utf-8'); - const session = JSON.parse(content) as SessionData; - - // 删除过期会话 - if (session.metadata.updatedAt < expiryTime) { - fs.unlinkSync(path.join(getSessionDir(), file)); + const filePath = path.join(sessionDir, file); + // 优化:使用 fs.statSync 获取文件修改时间,而不是读取完整 JSON + const stats = fs.statSync(filePath); + const mtime = stats.mtimeMs; + + // 删除过期会话(基于文件修改时间) + if (mtime < expiryTime) { + deleteSessionFiles(sessionDir, file); continue; } - sessions.push({ file, updatedAt: session.metadata.updatedAt }); + sessions.push({ file, mtime }); } catch { - // 删除无法解析的文件 - try { - fs.unlinkSync(path.join(getSessionDir(), file)); - } catch {} + // 删除无法访问的文件 + deleteSessionFiles(sessionDir, file); } } // 如果超过最大数量,删除最旧的 if (sessions.length > getMaxSessions()) { - sessions.sort((a, b) => a.updatedAt - b.updatedAt); + sessions.sort((a, b) => a.mtime - b.mtime); const toDelete = sessions.slice(0, sessions.length - getMaxSessions()); for (const { file } of toDelete) { + deleteSessionFiles(sessionDir, file); + } + } + + // 清理孤立 WAL 文件(有 .wal.jsonl 但没有对应的 .json) + const jsonSet = new Set(jsonFiles.map(f => f.replace('.json', ''))); + const walFiles = allFiles.filter(f => f.endsWith('.wal.jsonl')); + for (const walFile of walFiles) { + const sessionId = walFile.replace('.wal.jsonl', ''); + if (!jsonSet.has(sessionId)) { try { - fs.unlinkSync(path.join(getSessionDir(), file)); - } catch {} + fs.unlinkSync(path.join(sessionDir, walFile)); + } catch { /* ignore */ } } } } +/** + * 删除会话的所有关联文件(.json + .wal.jsonl) + */ +function deleteSessionFiles(sessionDir: string, jsonFile: string): void { + const sessionId = jsonFile.replace('.json', ''); + // 删除主 JSON 文件 + try { + fs.unlinkSync(path.join(sessionDir, jsonFile)); + } catch { /* ignore */ } + // 删除对应的 WAL 文件 + try { + const walPath = path.join(sessionDir, `${sessionId}.wal.jsonl`); + if (fs.existsSync(walPath)) { + fs.unlinkSync(walPath); + } + } catch { /* ignore */ } + // 清除内存缓存 + sessionMetadataCache.delete(sessionId); +} + /** * 导出会话为 Markdown */ diff --git a/src/tools/agent.ts b/src/tools/agent.ts index e9c41eaf..b6d7f9aa 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -1232,11 +1232,13 @@ ${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, Sen this.sendAgentCompletionNotification(agent); }) .catch((error) => { - // 执行失败 + // 执行失败 — 记录完整堆栈到日志 agent.status = 'failed'; - agent.error = error instanceof Error ? error.message : String(error); + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + agent.error = errorStack || errorMsg; agent.endTime = new Date(); - addAgentHistory(agent, 'failed', `Agent failed: ${agent.error}`); + addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg}${errorStack ? '\n' + errorStack : ''}`); saveAgentState(agent); // v2.1.7: 发送代理失败通知 @@ -1272,15 +1274,17 @@ ${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, Sen return agent.result; } catch (error) { agent.status = 'failed'; - agent.error = error instanceof Error ? error.message : String(error); + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + agent.error = errorStack || errorMsg; agent.endTime = new Date(); - addAgentHistory(agent, 'failed', `Agent failed: ${agent.error}`); + addAgentHistory(agent, 'failed', `Agent failed: ${errorMsg}${errorStack ? '\n' + errorStack : ''}`); saveAgentState(agent); return { success: false, - error: `Agent execution failed: ${agent.error}`, + error: t('agent.executionFailed', { error: errorMsg + (errorStack ? '\nStack: ' + errorStack : '') }), }; } } @@ -1415,7 +1419,7 @@ ${!isAgentTeamsEnabled() ? `\nNote: The "Agent Teams" feature (TeammateTool, Sen break; } else if (event.type === 'interrupted') { // 如果被中断,记录状态 - throw new Error('Agent execution was interrupted'); + throw new Error(t('agent.executionInterrupted')); } } @@ -1544,13 +1548,13 @@ Usage notes: return this.handleBashTaskFromDisk(input.task_id, meta); } - return { success: false, error: `Task ${input.task_id} not found` }; + return { success: false, error: t('agent.taskNotFound', { id: input.task_id }) }; } // 处理 Agent 任务 const agent = getBackgroundAgent(input.task_id); if (!agent) { - return { success: false, error: `Task ${input.task_id} not found` }; + return { success: false, error: t('agent.taskNotFound', { id: input.task_id }) }; } if (input.block && agent.status === 'running') { diff --git a/src/tools/ask.ts b/src/tools/ask.ts index bda9405e..d89a7865 100644 --- a/src/tools/ask.ts +++ b/src/tools/ask.ts @@ -165,11 +165,11 @@ Usage notes: } if (!questions || questions.length === 0) { - return { success: false, error: 'No questions provided' }; + return { success: false, error: t('ask.noQuestions') }; } if (questions.length > 4) { - return { success: false, error: 'Maximum 4 questions allowed' }; + return { success: false, error: t('ask.maxQuestions') }; } // 验证所有问题 @@ -178,7 +178,7 @@ Usage notes: if (q.header && q.header.length > MAX_HEADER_LENGTH) { return { success: false, - error: `Question header "${q.header}" exceeds maximum length of ${MAX_HEADER_LENGTH} characters` + error: t('ask.headerTooLong', { header: q.header, max: String(MAX_HEADER_LENGTH) }) }; } @@ -186,7 +186,7 @@ Usage notes: if (!q.options || q.options.length < 2 || q.options.length > 4) { return { success: false, - error: `Question "${q.header}" must have 2-4 options (has ${q.options?.length ?? 0})` + error: t('ask.invalidOptionCount', { header: q.header, count: String(q.options?.length ?? 0) }) }; } @@ -196,13 +196,13 @@ Usage notes: if (!opt.label || typeof opt.label !== 'string') { return { success: false, - error: `Option ${i + 1} in question "${q.header}" must have a label` + error: t('ask.optionMissingLabel', { index: String(i + 1), header: q.header }) }; } if (!opt.description || typeof opt.description !== 'string') { return { success: false, - error: `Option ${i + 1} in question "${q.header}" must have a description` + error: t('ask.optionMissingDescription', { index: String(i + 1), header: q.header }) }; } } diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 779aa6c4..f57ce997 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -967,7 +967,7 @@ Important: return { success: false, - error: `Command blocked for security reasons: ${safetyCheck.reason}`, + error: t('bash.commandBlocked', { reason: safetyCheck.reason }), }; } @@ -1042,7 +1042,7 @@ Important: if (!hookResult.allowed) { return { success: false, - error: `Blocked by hook: ${hookResult.message || 'Operation not allowed'}`, + error: t('bash.blockedByHook', { message: hookResult.message || 'Operation not allowed' }), }; } @@ -1757,7 +1757,7 @@ export class KillShellTool extends BaseTool<{ shell_id: string }, BashResult> { async execute(input: { shell_id: string }): Promise { const task = backgroundTasks.get(input.shell_id); if (!task) { - return { success: false, error: `No shell found with ID: ${input.shell_id}` }; + return { success: false, error: t('bash.shellNotFound', { id: input.shell_id }) }; } try { diff --git a/src/tools/file.ts b/src/tools/file.ts index 75af1b53..5d74a5b9 100644 --- a/src/tools/file.ts +++ b/src/tools/file.ts @@ -1047,7 +1047,7 @@ Usage: try { const hookResult = await runPreToolUseHooks('Write', input); if (!hookResult.allowed) { - return { success: false, error: hookResult.message || 'Blocked by hook' }; + return { success: false, error: hookResult.message || t('file.blockedByHook') }; } // 注意:蓝图边界检查已移除 @@ -1350,14 +1350,14 @@ Usage: const hookResult = await runPreToolUseHooks('Edit', input); if (!hookResult.allowed) { - return { success: false, error: hookResult.message || 'Blocked by hook' }; + return { success: false, error: hookResult.message || t('file.blockedByHook') }; } // 2. 验证文件是否已被读取(如果启用了此检查) if (this.requireFileRead && !fileReadTracker.hasBeenRead(file_path)) { return { success: false, - error: `You must read the file with the Read tool before editing it. File: ${file_path}`, + error: t('file.mustReadBeforeEdit', { path: file_path }), errorCode: EditErrorCode.NOT_READ, }; } @@ -1398,7 +1398,7 @@ Usage: // 部分读取的文件不能进行完整内容比对,直接报错 return { success: false, - error: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', + error: t('file.modifiedSinceRead'), errorCode: EditErrorCode.EXTERNALLY_MODIFIED, }; } @@ -1408,7 +1408,7 @@ Usage: if (originalContent !== readRecord.content) { return { success: false, - error: 'File has been modified since it was read, either by the user or by a linter. Read it again before attempting to write it.', + error: t('file.modifiedSinceRead'), errorCode: EditErrorCode.EXTERNALLY_MODIFIED, }; } @@ -1445,7 +1445,7 @@ Usage: // 字符串未找到 return { success: false, - error: `String to replace not found in file.\nString: ${edit.old_string}`, + error: t('file.stringNotFound', { str: edit.old_string }), errorCode: EditErrorCode.STRING_NOT_FOUND, }; } @@ -1457,7 +1457,7 @@ Usage: if (matchCount > 1 && !edit.replace_all) { return { success: false, - error: `Found ${matchCount} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${edit.old_string}`, + error: t('file.multipleMatches', { count: String(matchCount), str: edit.old_string }), errorCode: EditErrorCode.MULTIPLE_MATCHES, }; } @@ -1502,7 +1502,7 @@ Usage: if (require_confirmation) { return { success: false, - error: 'Confirmation required before applying changes', + error: t('file.confirmationRequired'), output: diffPreview ? this.formatDiffOutput(diffPreview) : undefined, }; } diff --git a/src/tools/lsp.ts b/src/tools/lsp.ts index 71178a60..66f687bc 100644 --- a/src/tools/lsp.ts +++ b/src/tools/lsp.ts @@ -909,7 +909,7 @@ Note: LSP servers must be configured for the file type. If no server is availabl if (!manager) { return { success: false, - error: 'LSP server manager not initialized. This may indicate a startup issue.', + error: t('lsp.notInitialized'), operation: input.operation, filePath: input.filePath, }; @@ -934,7 +934,7 @@ Note: LSP servers must be configured for the file type. If no server is availabl if (result === undefined) { return { success: false, - error: `No LSP server available for file type: ${path.extname(filePath)}`, + error: t('lsp.noServerAvailable', { ext: path.extname(filePath) }), operation: input.operation, filePath: input.filePath, }; @@ -967,7 +967,7 @@ Note: LSP servers must be configured for the file type. If no server is availabl if (result === undefined) { return { success: false, - error: 'LSP server did not return call hierarchy results', + error: t('lsp.noCallHierarchy'), operation: input.operation, filePath: input.filePath, }; diff --git a/src/tools/mcp.ts b/src/tools/mcp.ts index 7c413dc0..1d7390f6 100644 --- a/src/tools/mcp.ts +++ b/src/tools/mcp.ts @@ -743,7 +743,7 @@ export async function callMcpTool( if (!result) { return { success: false, - error: `MCP tool call failed: ${toolName}. Server did not respond or returned an error.` + error: t('mcp.toolCallFailed', { tool: toolName }) }; } @@ -1642,7 +1642,7 @@ Parameters: if (!resourceResult.contents || resourceResult.contents.length === 0) { return { success: false, - error: `Resource not found or empty: ${uri}`, + error: t('mcp.resourceNotFound', { uri }), }; } @@ -1736,7 +1736,7 @@ Parameters: if (!input.server || !input.uri) { return { success: false, - error: 'Both "server" and "uri" are required for action="read"', + error: t('mcp.serverAndUriRequired'), }; } return this.readTool.execute({ @@ -1747,7 +1747,7 @@ Parameters: return { success: false, - error: `Unknown action: ${input.action}. Use "list" or "read".`, + error: t('mcp.unknownAction', { action: input.action }), }; } } diff --git a/src/tools/notebook.ts b/src/tools/notebook.ts index 93786e5c..734545b2 100644 --- a/src/tools/notebook.ts +++ b/src/tools/notebook.ts @@ -146,13 +146,13 @@ export class NotebookEditTool extends BaseTool { if (edit_mode !== 'insert') { return { success: false, - error: `Cell not found with ID: ${cell_id}. Available cells: ${notebook.cells.length}`, + error: t('notebook.cellNotFoundById', { id: cell_id, count: String(notebook.cells.length) }), }; } // insert 模式下找不到 cell_id,也应该报错(与官方行为一致) return { success: false, - error: `Cell with ID "${cell_id}" not found in notebook.`, + error: t('notebook.cellNotFound', { id: cell_id }), }; } @@ -166,7 +166,7 @@ export class NotebookEditTool extends BaseTool { if (edit_mode === 'delete' && !cell_id) { return { success: false, - error: 'cell_id is required for delete mode', + error: t('notebook.cellIdRequired'), }; } @@ -189,7 +189,7 @@ export class NotebookEditTool extends BaseTool { if (cellIndex < 0 || cellIndex >= notebook.cells.length) { return { success: false, - error: `Cell index out of range: ${cellIndex} (total cells: ${notebook.cells.length})`, + error: t('notebook.cellOutOfRange', { index: String(cellIndex), count: String(notebook.cells.length) }), }; } @@ -250,7 +250,7 @@ export class NotebookEditTool extends BaseTool { if (cellIndex < 0 || cellIndex >= notebook.cells.length) { return { success: false, - error: `Cell index out of range: ${cellIndex} (total cells: ${notebook.cells.length})`, + error: t('notebook.cellOutOfRange', { index: String(cellIndex), count: String(notebook.cells.length) }), }; } diff --git a/src/tools/planmode.ts b/src/tools/planmode.ts index 331a84d0..a9b9b638 100644 --- a/src/tools/planmode.ts +++ b/src/tools/planmode.ts @@ -153,7 +153,7 @@ This tool REQUIRES user approval to enter plan mode.`; if (appState.toolPermissionContext.mode === 'plan') { return { success: false, - error: 'Already in plan mode. Use ExitPlanMode to exit first.', + error: t('planmode.alreadyInPlanMode'), }; } @@ -329,7 +329,7 @@ Before using this tool, ensure your plan is clear and unambiguous. If there are if (appState.toolPermissionContext.mode !== 'plan') { return { success: false, - error: 'Not in plan mode. Use EnterPlanMode first.', + error: t('planmode.notInPlanMode'), }; } diff --git a/src/tools/sandbox.ts b/src/tools/sandbox.ts index 9d916217..e91f45ba 100644 --- a/src/tools/sandbox.ts +++ b/src/tools/sandbox.ts @@ -940,7 +940,7 @@ export async function executeInSandbox( stderr: '', exitCode: null, killed: false, - error: 'No sandbox available and fallback is disabled', + error: t('sandbox.noSandboxAvailable'), sandboxed: false, sandboxType: 'none', }; diff --git a/src/tools/self-evolve.ts b/src/tools/self-evolve.ts index 15d8336b..f3772894 100644 --- a/src/tools/self-evolve.ts +++ b/src/tools/self-evolve.ts @@ -26,7 +26,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { requestEvolveRestart, isEvolveEnabled, triggerGracefulShutdown } from '../web/server/evolve-state.js'; +import { requestEvolveRestart, isEvolveEnabled } from '../web/server/evolve-state.js'; export interface SelfEvolveInput { /** 重启原因(记录到日志) */ @@ -154,17 +154,11 @@ export class SelfEvolveTool extends BaseTool { this.appendLog(logEntry); // 6. 请求进化重启(设置退出码 42 标志) + // 注意:不再在这里 setTimeout 触发 gracefulShutdown, + // 而是让对话循环检测到此标志后,完成持久化再触发关闭。 + // 这样可以确保 SelfEvolve 工具的返回结果和最后一条 assistant 回复不丢失。 requestEvolveRestart(); - // 7. 触发 gracefulShutdown - // 直接调用 gracefulShutdown 闭包,而非 SIGTERM - // 原因:Windows 上 process.kill(pid, 'SIGTERM') 会直接终止进程, - // 不触发 process.on('SIGTERM') 监听器,导致退出码不是 42 - setTimeout(() => { - console.log('[SelfEvolve] Triggering graceful shutdown...'); - triggerGracefulShutdown(); - }, 100); - return this.success( `Self-evolve restart initiated.\n` + `Reason: ${reason}\n` + diff --git a/src/tools/skill.ts b/src/tools/skill.ts index 0c77fd7b..d1fc365b 100644 --- a/src/tools/skill.ts +++ b/src/tools/skill.ts @@ -14,6 +14,7 @@ import { BaseTool } from './base.js'; import type { ToolResult, ToolDefinition } from '../types/index.js'; import { getCurrentCwd } from '../core/cwd-context.js'; import { registerHook, type HookEvent, type HookConfig } from '../hooks/index.js'; +import { t } from '../i18n/index.js'; /** * v2.1.32: 额外目录列表(由 --add-dir 设置) @@ -1238,7 +1239,7 @@ Important: const available = Array.from(skillRegistry.keys()).join(', '); return { success: false, - error: `Skill "${skillInput}" not found. Available skills: ${available || 'none'}`, + error: t('skill.notFound', { name: skillInput, available: available || 'none' }), }; } @@ -1246,7 +1247,7 @@ Important: if (skill.disableModelInvocation) { return { success: false, - error: `Skill "${skill.skillName}" has model invocation disabled`, + error: t('skill.modelInvocationDisabled', { name: skill.skillName }), }; } diff --git a/src/utils/error-watcher.ts b/src/utils/error-watcher.ts new file mode 100644 index 00000000..436d5ac1 --- /dev/null +++ b/src/utils/error-watcher.ts @@ -0,0 +1,443 @@ +/** + * ErrorWatcher: 错误自感知与自修复系统 + * + * 作为 Logger 的 hook,实时接收 error 级别日志,执行: + * 1. 指纹提取 — 将错误消息模板化 + 源码位置 → 唯一指纹 + * 2. 分类 — 源码错误 vs 外部错误(网络/API/第三方) + * 3. 滑动窗口聚合 — 5 分钟内同类错误合并计数 + * 4. 阈值检测 — 源码错误 ≥3 次 → 触发自修复 Pipeline + * + * 所有模式默认启用,零门槛感知错误。 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import type { LogEntry } from './logger.js'; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export interface ErrorPattern { + fingerprint: string; + description: string; + firstSeen: number; + lastSeen: number; + count: number; + sample: LogEntry; + category: 'source' | 'external' | 'unknown'; + sourceLocation?: string; + repairTriggered: boolean; +} + +interface RepairRecord { + timestamp: number; + fingerprint: string; + action: string; + reason: string; + success: boolean; + sessionId?: string; +} + +/** + * 修复会话创建器回调类型 + * 由 web/server/index.ts 注入,避免 utils → web/server 的循环依赖 + * + * @param pattern - 触发修复的错误模式 + * @param sourceContext - 源码上下文 + * @returns sessionId 或 null(创建失败) + */ +export type RepairSessionCreator = ( + pattern: ErrorPattern, + sourceContext: string, +) => Promise; + +// ============================================================================ +// 常量 +// ============================================================================ + +const EXTERNAL_ERROR_PATTERNS = [ + /terminated/i, /ECONNRESET/i, /ECONNREFUSED/i, /ETIMEDOUT/i, + /ENOTFOUND/i, /socket hang up/i, /aborted/i, /429/, /529/, + /rate_limit/i, /overloaded/i, /network/i, /EPIPE/i, + /EHOSTUNREACH/i, /fetch failed/i, /getaddrinfo/i, + /certificate/i, /SSL/i, /TLS/i, /CERT_/i, /readyState/i, +]; + +const VARIABLE_PATTERNS: Array<[RegExp, string]> = [ + [/(?:\/[\w.-]+)+(?:\.\w+)?/g, ''], + [/(?:[A-Z]:\\[\w\\.-]+)+/g, ''], + [/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ''], + [/\b\d{2,}\b/g, ''], + [/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, ''], + [/\b[0-9a-f]{8,}\b/gi, ''], +]; + +const SOURCE_LOCATION_PATTERN = /[\\/]src[\\/](.+?):(\d+)/; + +// ============================================================================ +// ErrorWatcher +// ============================================================================ + +class ErrorWatcher { + private readonly WINDOW_MS = 5 * 60 * 1000; + private readonly REPAIR_THRESHOLD = 3; + private readonly COOLDOWN_MS = 10 * 60 * 1000; + private readonly MAX_REPAIRS_PER_HOUR = 3; + private readonly CLEANUP_INTERVAL_MS = 60 * 1000; + + private patterns = new Map(); + private lastRepairTime = 0; + private repairHistory: RepairRecord[] = []; + private enabled = false; + private cleanupTimer: ReturnType | null = null; + private repairSessionCreator: RepairSessionCreator | null = null; + + enable(): void { + if (this.enabled) return; + this.enabled = true; + this.cleanupTimer = setInterval(() => this.cleanupExpired(), this.CLEANUP_INTERVAL_MS); + if (this.cleanupTimer.unref) this.cleanupTimer.unref(); + this.log('info', 'ErrorWatcher enabled'); + } + + /** + * 注入修复会话创建器 + * 由 web/server/index.ts 在初始化时调用,将"创建修复会话"的能力注入 ErrorWatcher + */ + setRepairSessionCreator(creator: RepairSessionCreator): void { + this.repairSessionCreator = creator; + this.log('info', 'Repair session creator injected — auto-repair enabled'); + } + + disable(): void { + this.enabled = false; + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.patterns.clear(); + } + + onError(entry: LogEntry): void { + if (!this.enabled) return; + + const now = Date.now(); + const fingerprint = this.generateFingerprint(entry); + const category = this.classifyError(entry); + + const existing = this.patterns.get(fingerprint); + if (existing) { + existing.lastSeen = now; + existing.count++; + existing.sample = entry; + } else { + const sourceLocation = this.extractSourceLocation(entry); + this.patterns.set(fingerprint, { + fingerprint, + description: this.buildDescription(entry, sourceLocation), + firstSeen: now, + lastSeen: now, + count: 1, + sample: entry, + category, + sourceLocation: sourceLocation || undefined, + repairTriggered: false, + }); + } + + const pattern = this.patterns.get(fingerprint)!; + + if (this.shouldTriggerRepair(pattern)) { + this.log('warn', `Source error repeated ${pattern.count}x, triggering repair: ${pattern.description}`); + pattern.repairTriggered = true; + this.triggerRepair(pattern).catch(err => { + this.log('error', `Repair pipeline failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + } + + getPatterns(): ErrorPattern[] { + return Array.from(this.patterns.values()); + } + + getSourceErrors(): ErrorPattern[] { + return this.getPatterns().filter(p => p.category === 'source'); + } + + getStats(): { + enabled: boolean; + totalPatterns: number; + sourceErrors: number; + externalErrors: number; + repairsTriggered: number; + lastRepairTime: number; + } { + const patterns = this.getPatterns(); + return { + enabled: this.enabled, + totalPatterns: patterns.length, + sourceErrors: patterns.filter(p => p.category === 'source').length, + externalErrors: patterns.filter(p => p.category === 'external').length, + repairsTriggered: this.repairHistory.length, + lastRepairTime: this.lastRepairTime, + }; + } + + private generateFingerprint(entry: LogEntry): string { + const normalizedMsg = this.normalizeMessage(entry.msg); + const sourceLocation = this.extractSourceLocation(entry) || 'unknown'; + const raw = `${entry.module}::${normalizedMsg}::${sourceLocation}`; + return crypto.createHash('md5').update(raw).digest('hex').slice(0, 12); + } + + private normalizeMessage(msg: string): string { + let normalized = msg; + for (const [pattern, replacement] of VARIABLE_PATTERNS) { + normalized = normalized.replace(pattern, replacement); + } + return normalized.slice(0, 200); + } + + private extractSourceLocation(entry: LogEntry): string | null { + const stack = entry.stack || entry.msg; + const match = stack.match(SOURCE_LOCATION_PATTERN); + if (match) { + return match[1].replace(/\\/g, '/') + ':' + match[2]; + } + return null; + } + + private buildDescription(entry: LogEntry, sourceLocation: string | null): string { + const msgPreview = entry.msg.slice(0, 80) + (entry.msg.length > 80 ? '...' : ''); + if (sourceLocation) { + return `[${entry.module}] ${msgPreview} @ ${sourceLocation}`; + } + return `[${entry.module}] ${msgPreview}`; + } + + private classifyError(entry: LogEntry): 'source' | 'external' | 'unknown' { + const msg = entry.msg; + const stack = entry.stack || ''; + + for (const pattern of EXTERNAL_ERROR_PATTERNS) { + if (pattern.test(msg)) return 'external'; + } + + if (SOURCE_LOCATION_PATTERN.test(stack)) return 'source'; + + if (stack && !stack.includes('/src/') && !stack.includes('\\src\\')) { + if (stack.includes('node_modules') || stack.includes('node:internal')) { + return 'external'; + } + } + + return 'unknown'; + } + + private shouldTriggerRepair(pattern: ErrorPattern): boolean { + if (pattern.category !== 'source') return false; + if (pattern.repairTriggered) return false; + if (pattern.count < this.REPAIR_THRESHOLD) return false; + if (Date.now() - this.lastRepairTime < this.COOLDOWN_MS) return false; + + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const recentRepairs = this.repairHistory.filter(r => r.timestamp > oneHourAgo).length; + if (recentRepairs >= this.MAX_REPAIRS_PER_HOUR) return false; + + return true; + } + + private async triggerRepair(pattern: ErrorPattern): Promise { + const now = Date.now(); + this.lastRepairTime = now; + + this.log('info', '=== Repair Pipeline START ==='); + this.log('info', `Fingerprint: ${pattern.fingerprint}`); + this.log('info', `Description: ${pattern.description}`); + this.log('info', `Count: ${pattern.count}`); + this.log('info', `Location: ${pattern.sourceLocation || 'unknown'}`); + + const sourceContext = await this.readSourceContext(pattern); + + // Phase 2: 自动创建修复会话 + let sessionId: string | null = null; + let action = 'notify'; + + if (this.repairSessionCreator) { + try { + this.log('info', 'Creating auto-repair session...'); + sessionId = await this.repairSessionCreator(pattern, sourceContext); + if (sessionId) { + action = 'repair_session'; + this.log('info', `Repair session created: ${sessionId}`); + } else { + this.log('warn', 'Repair session creator returned null — falling back to notify'); + } + } catch (err) { + this.log('error', `Failed to create repair session: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + this.log('info', 'No repair session creator injected — notify only'); + } + + const record: RepairRecord = { + timestamp: now, + fingerprint: pattern.fingerprint, + action, + reason: `Source error repeated ${pattern.count}x: ${pattern.description}`, + success: !!sessionId, + sessionId: sessionId || undefined, + }; + this.repairHistory.push(record); + + this.appendRepairLog(record, pattern, sourceContext); + await this.writeToNotebook(pattern, sourceContext); + + this.log('info', `=== Repair Pipeline END (${action}) ===`); + } + + private async readSourceContext(pattern: ErrorPattern): Promise { + if (!pattern.sourceLocation) return '(no source location)'; + + try { + const lastColon = pattern.sourceLocation.lastIndexOf(':'); + if (lastColon < 0) return '(invalid source location)'; + const relPath = pattern.sourceLocation.slice(0, lastColon); + const line = parseInt(pattern.sourceLocation.slice(lastColon + 1), 10); + if (isNaN(line)) return '(cannot parse line number)'; + + const projectRoot = this.findProjectRoot(); + if (!projectRoot) return '(cannot locate project root)'; + + const fullPath = path.join(projectRoot, 'src', relPath); + if (!fs.existsSync(fullPath)) return `(file not found: src/${relPath})`; + + const content = fs.readFileSync(fullPath, 'utf-8'); + const lines = content.split('\n'); + const startLine = Math.max(0, line - 11); + const endLine = Math.min(lines.length, line + 10); + + const contextLines = lines.slice(startLine, endLine).map((l, i) => { + const lineNum = startLine + i + 1; + const marker = lineNum === line ? ' >>>' : ' '; + return `${marker} ${lineNum}: ${l}`; + }); + + return `File: src/${relPath}\n\n${contextLines.join('\n')}`; + } catch { + return '(failed to read source)'; + } + } + + private appendRepairLog(record: RepairRecord, pattern: ErrorPattern, sourceContext: string): void { + try { + const logDir = path.join(os.homedir(), '.claude'); + if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }); + + const logPath = path.join(logDir, 'error-watcher.jsonl'); + const entry = { + ...record, + pattern: { + fingerprint: pattern.fingerprint, + description: pattern.description, + count: pattern.count, + category: pattern.category, + sourceLocation: pattern.sourceLocation, + sampleMsg: pattern.sample.msg, + sampleStack: pattern.sample.stack?.slice(0, 500), + }, + sourceContext: sourceContext.slice(0, 1000), + }; + fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf-8'); + } catch { + // must not affect main flow + } + } + + private async writeToNotebook(pattern: ErrorPattern, _sourceContext: string): Promise { + try { + const projectRoot = this.findProjectRoot(); + if (!projectRoot) return; + + const claudeDir = path.join(os.homedir(), '.claude'); + const sanitized = projectRoot.replace(/[<>:"|?*]/g, '-').replace(/[\\/]+/g, '-').toLowerCase(); + const projectDir = path.join(claudeDir, 'memory', 'projects', sanitized); + + if (!fs.existsSync(projectDir)) return; + + const notebookPath = path.join(projectDir, 'project.md'); + if (!fs.existsSync(notebookPath)) return; + + const content = fs.readFileSync(notebookPath, 'utf-8'); + + const sectionHeader = '## ErrorWatcher 自动检测'; + if (content.includes(pattern.fingerprint)) return; + + const date = new Date().toISOString().slice(0, 10); + const newEntry = [ + '', + `### [${date}] ${pattern.description}`, + `- 指纹: \`${pattern.fingerprint}\``, + `- 分类: ${pattern.category}`, + `- 重复次数: ${pattern.count}`, + `- 位置: \`${pattern.sourceLocation || 'unknown'}\``, + `- 错误: \`${pattern.sample.msg.slice(0, 100)}\``, + '', + ].join('\n'); + + if (content.includes(sectionHeader)) { + const newContent = content.replace(sectionHeader, sectionHeader + '\n' + newEntry); + fs.writeFileSync(notebookPath, newContent, 'utf-8'); + } else { + fs.appendFileSync(notebookPath, '\n' + sectionHeader + '\n' + newEntry, 'utf-8'); + } + + this.log('info', `Written to project notebook: ${pattern.fingerprint}`); + } catch { + // must not affect main flow + } + } + + private cleanupExpired(): void { + const cutoff = Date.now() - this.WINDOW_MS; + for (const [key, pattern] of this.patterns) { + if (pattern.lastSeen < cutoff) { + this.patterns.delete(key); + } + } + + const oneHourAgo = Date.now() - 60 * 60 * 1000; + this.repairHistory = this.repairHistory.filter(r => r.timestamp > oneHourAgo); + } + + private findProjectRoot(): string | null { + let dir = path.dirname(new URL(import.meta.url).pathname); + if (process.platform === 'win32' && dir.startsWith('/')) { + dir = dir.slice(1); + } + + for (let i = 0; i < 5; i++) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg.name === 'claude-code-open') return dir; + } catch { /* continue */ } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; + } + + private log(level: 'info' | 'warn' | 'error', msg: string): void { + const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + fn(`[ErrorWatcher] ${msg}`); + } +} + +export const errorWatcher = new ErrorWatcher(); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..7a96b460 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,544 @@ +/** + * 统一运行时日志系统 + * + * 拦截三层输出并持久化到文件: + * 1. console.error / console.warn / console.log + * 2. process.stderr.write(过滤掉终端控制码和纯 UI 输出) + * 3. 程序化 API:logger.error/warn/info/debug + * + * 日志文件:~/.claude/runtime.log(JSONL,自动轮转) + * 轮转策略:单文件 2MB,保留 5 个历史文件,总上限 ~12MB + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export type LogLevel = 'error' | 'warn' | 'info' | 'debug'; + +export interface LogEntry { + ts: string; + level: LogLevel; + module: string; + msg: string; + stack?: string; + data?: unknown; +} + +export interface LoggerConfig { + /** 日志文件路径,默认 ~/.claude/runtime.log */ + logFile?: string; + /** 单文件最大字节数,默认 2MB */ + maxFileSize?: number; + /** 保留的轮转文件数量,默认 5 */ + maxFiles?: number; + /** 是否拦截 console 输出,默认 true */ + interceptConsole?: boolean; + /** 最低日志级别,默认 info */ + minLevel?: LogLevel; +} + +// ============================================================================ +// Logger 核心 +// ============================================================================ + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const CLAUDE_DIR = path.join(os.homedir(), '.claude'); +const DEFAULT_LOG_FILE = path.join(CLAUDE_DIR, 'runtime.log'); +const DEFAULT_MAX_SIZE = 2 * 1024 * 1024; // 2MB +const DEFAULT_MAX_FILES = 5; + +// 模块名提取正则:匹配 [ModuleName] 前缀 +const MODULE_PATTERN = /^\[([^\]]+)\]\s*/; + +// stderr 过滤:跳过终端控制码、纯 ANSI 颜色序列、空行、进度指示符 +const STDERR_SKIP_PATTERNS = [ + /^\x1b\[/, // ANSI escape 开头(光标移动、颜色等) + /^\x07$/, // Bell(通知) + /^\.+$/, // 纯进度点 "..." + /^\s*$/, // 空白行 + /^> $/, // 交互提示符 + /^Resume this session with/, // 退出提示(不是错误) +]; + +class RuntimeLogger { + private logFile: string; + private maxFileSize: number; + private maxFiles: number; + private minLevel: number; + private stream: fs.WriteStream | null = null; + private currentSize: number = 0; + private initialized: boolean = false; + private intercepted: boolean = false; + private statsCache: { ts: number; hours: number; result: ReturnType } | null = null; + private static STATS_CACHE_TTL = 30_000; // 30 秒缓存 + + // ErrorWatcher hook(仅在 evolve 模式下设置) + private errorWatcherCallback: ((entry: LogEntry) => void) | null = null; + + // 保存原始方法 + private originalConsoleError: typeof console.error = console.error; + private originalConsoleWarn: typeof console.warn = console.warn; + private originalConsoleLog: typeof console.log = console.log; + private originalStderrWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); + + constructor() { + this.logFile = DEFAULT_LOG_FILE; + this.maxFileSize = DEFAULT_MAX_SIZE; + this.maxFiles = DEFAULT_MAX_FILES; + this.minLevel = LOG_LEVELS.info; + } + + /** + * 初始化日志系统 + */ + init(config: LoggerConfig = {}): void { + if (this.initialized) return; + + this.logFile = config.logFile || DEFAULT_LOG_FILE; + this.maxFileSize = config.maxFileSize || DEFAULT_MAX_SIZE; + this.maxFiles = config.maxFiles || DEFAULT_MAX_FILES; + this.minLevel = LOG_LEVELS[config.minLevel || 'info']; + + // 确保目录存在 + const dir = path.dirname(this.logFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // 获取现有文件大小 + try { + const stat = fs.statSync(this.logFile); + this.currentSize = stat.size; + } catch { + this.currentSize = 0; + } + + // 打开写入流 + this.stream = fs.createWriteStream(this.logFile, { flags: 'a' }); + + this.initialized = true; + + // 写入启动标记 + this.writeEntry({ + ts: new Date().toISOString(), + level: 'info', + module: 'Logger', + msg: `=== Process started (PID: ${process.pid}) ===`, + }); + + // 拦截 console + stderr + if (config.interceptConsole !== false) { + this.interceptAll(); + } + + // 进程退出时 flush + process.on('exit', () => this.flush()); + } + + /** + * 拦截 console.error/warn/log + process.stderr.write + */ + private interceptAll(): void { + if (this.intercepted) return; + this.intercepted = true; + + // 标志位:console.error/warn 内部会调 stderr.write,用此标志跳过避免双重记录 + let insideConsole = false; + + // 拦截 console.log:只记录带 [ModuleName] 前缀的结构化日志, + // 跳过纯 UI 文本输出(Ink 渲染、进度条等) + const origConsoleLog = this.originalConsoleLog; + console.log = (...args: any[]) => { + origConsoleLog.apply(console, args); + // 只在第一个参数是字符串且带 [Module] 前缀时才记录 + if (args.length > 0 && typeof args[0] === 'string' && MODULE_PATTERN.test(args[0])) { + this.captureConsole('info', args); + } + }; + + const origConsoleError = this.originalConsoleError; + console.error = (...args: any[]) => { + insideConsole = true; + origConsoleError.apply(console, args); + insideConsole = false; + this.captureConsole('error', args); + }; + + const origConsoleWarn = this.originalConsoleWarn; + console.warn = (...args: any[]) => { + insideConsole = true; + origConsoleWarn.apply(console, args); + insideConsole = false; + this.captureConsole('warn', args); + }; + + const self = this; + const origStderrWrite = this.originalStderrWrite; + + process.stderr.write = function ( + chunk: any, + encodingOrCallback?: BufferEncoding | ((err?: Error) => void), + callback?: (err?: Error) => void + ): boolean { + // 先正常写出到 stderr + const result = origStderrWrite.call( + process.stderr, + chunk, + encodingOrCallback as BufferEncoding, + callback + ); + + // 如果是 console.error/warn 触发的,已经在上面捕获了,跳过 + if (insideConsole) return result; + + // 解析内容 + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + // 剥离 ANSI 颜色码后判断是否有价值 + const stripped = text.replace(/\x1b\[[0-9;]*m/g, '').trim(); + + if (!stripped) return result; + + // 过滤掉无诊断价值的输出 + const shouldSkip = STDERR_SKIP_PATTERNS.some((p) => p.test(stripped)); + if (shouldSkip) return result; + + // 有价值的 stderr 输出记录为 warn(不确定是否是 error,保守处理) + self.captureStderr(stripped); + + return result; + } as typeof process.stderr.write; + } + + /** + * 捕获 stderr 直写内容 + */ + private captureStderr(text: string): void { + let module = 'Stderr'; + let msg = text; + + const moduleMatch = text.match(MODULE_PATTERN); + if (moduleMatch) { + module = moduleMatch[1]; + msg = text.slice(moduleMatch[0].length); + } + + this.writeEntry({ + ts: new Date().toISOString(), + level: 'warn', + module, + msg: msg.trim(), + }); + } + + /** + * 解析 console 输出并写入日志 + */ + private captureConsole(level: LogLevel, args: any[]): void { + if (LOG_LEVELS[level] < this.minLevel) return; + + const message = args + .map((a) => { + if (typeof a === 'string') return a; + if (a instanceof Error) return a.stack || a.message; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(' '); + + // 跳过空消息 + if (!message.trim()) return; + + // 提取模块名 + let module = 'System'; + let msg = message; + const moduleMatch = message.match(MODULE_PATTERN); + if (moduleMatch) { + module = moduleMatch[1]; + msg = message.slice(moduleMatch[0].length); + } + + // 提取 stack trace(如果参数中有 Error 对象) + let stack: string | undefined; + for (const arg of args) { + if (arg instanceof Error && arg.stack) { + stack = arg.stack; + break; + } + } + + this.writeEntry({ + ts: new Date().toISOString(), + level, + module, + msg: msg.trim(), + stack, + }); + } + + /** + * 写入一条日志 + */ + private writeEntry(entry: LogEntry): void { + if (!this.stream) return; + + try { + const line = JSON.stringify(entry) + '\n'; + const bytes = Buffer.byteLength(line); + + // 检查是否需要轮转 + if (this.currentSize + bytes > this.maxFileSize) { + this.rotate(); + } + + this.stream.write(line); + this.currentSize += bytes; + } catch { + // 日志系统自身的错误不能再用 console.error,避免无限递归 + } + + // 通知 ErrorWatcher(error 级别) + if (entry.level === 'error' && this.errorWatcherCallback) { + try { this.errorWatcherCallback(entry); } catch { /* ErrorWatcher 错误不能影响日志系统 */ } + } + } + + /** + * 日志文件轮转 + * runtime.log -> runtime.log.1 -> runtime.log.2 -> ... -> runtime.log.N (删除) + */ + private rotate(): void { + // 关闭当前流 + this.stream?.end(); + this.stream = null; + + // 轮转文件:从最老的开始腾位 + for (let i = this.maxFiles - 1; i >= 1; i--) { + const from = i === 1 ? this.logFile : `${this.logFile}.${i - 1}`; + const to = `${this.logFile}.${i}`; + try { + if (fs.existsSync(from)) { + if (fs.existsSync(to)) fs.unlinkSync(to); + fs.renameSync(from, to); + } + } catch { + // 忽略轮转错误 + } + } + + // 重新打开流 + this.currentSize = 0; + this.stream = fs.createWriteStream(this.logFile, { flags: 'a' }); + } + + /** + * 刷新并关闭 + */ + flush(): void { + if (this.stream) { + this.writeEntry({ + ts: new Date().toISOString(), + level: 'info', + module: 'Logger', + msg: `=== Process exiting (PID: ${process.pid}) ===`, + }); + this.stream.end(); + this.stream = null; + } + } + + // ============================================================================ + // 公共 API:供代码中直接调用(比 console.error 更结构化) + // ============================================================================ + + error(module: string, msg: string, data?: unknown): void { + const entry: LogEntry = { + ts: new Date().toISOString(), + level: 'error', + module, + msg, + }; + if (data instanceof Error) { + entry.stack = data.stack; + entry.data = { message: data.message, code: (data as any).code }; + } else if (data !== undefined) { + entry.data = data; + } + this.writeEntry(entry); + } + + warn(module: string, msg: string, data?: unknown): void { + this.writeEntry({ + ts: new Date().toISOString(), + level: 'warn', + module, + msg, + data: data !== undefined ? data : undefined, + }); + } + + info(module: string, msg: string, data?: unknown): void { + this.writeEntry({ + ts: new Date().toISOString(), + level: 'info', + module, + msg, + data: data !== undefined ? data : undefined, + }); + } + + debug(module: string, msg: string, data?: unknown): void { + this.writeEntry({ + ts: new Date().toISOString(), + level: 'debug', + module, + msg, + data: data !== undefined ? data : undefined, + }); + } + + /** + * 设置 ErrorWatcher 回调(由 ErrorWatcher 模块在 evolve 模式下注入) + */ + setErrorWatcher(callback: (entry: LogEntry) => void): void { + this.errorWatcherCallback = callback; + } + + /** + * 获取日志文件路径 + */ + getLogFile(): string { + return this.logFile; + } + + /** + * 读取最近的日志条目(从文件尾部高效读取) + */ + readRecent(count: number = 100): LogEntry[] { + return this.readRecentFromFile(this.logFile, count); + } + + /** + * 从指定日志文件尾部读取条目 + */ + private readRecentFromFile(file: string, count: number): LogEntry[] { + try { + if (!fs.existsSync(file)) return []; + + const stat = fs.statSync(file); + if (stat.size === 0) return []; + + // 从尾部读取足够的字节(平均每行 ~300 字节,多读 50% 余量) + const readSize = Math.min(stat.size, count * 450); + const fd = fs.openSync(file, 'r'); + const buffer = Buffer.alloc(readSize); + fs.readSync(fd, buffer, 0, readSize, stat.size - readSize); + fs.closeSync(fd); + + let content = buffer.toString('utf-8'); + + // 如果从文件中间开始读取,切割点可能落在 UTF-8 多字节字符中间 + // 找到第一个换行符,从下一完整行开始,确保 JSON 不会被截断 + if (readSize < stat.size) { + const firstNewline = content.indexOf('\n'); + if (firstNewline >= 0) { + content = content.slice(firstNewline + 1); + } + } + + const lines = content.split('\n').filter(Boolean); + const recent = lines.slice(-count); + + return recent.map((line) => { + try { + return JSON.parse(line) as LogEntry; + } catch { + return { ts: '', level: 'info' as LogLevel, module: 'Unknown', msg: line }; + } + }); + } catch { + return []; + } + } + + /** + * 按级别统计最近日志 + */ + getStats(hours: number = 24): { total: number; errors: number; warns: number; topModules: Record } { + // 30 秒缓存:避免高频构建系统提示词时反复读文件 + const now = Date.now(); + if (this.statsCache && this.statsCache.hours === hours && (now - this.statsCache.ts) < RuntimeLogger.STATS_CACHE_TTL) { + return this.statsCache.result; + } + + const cutoff = new Date(now - hours * 60 * 60 * 1000).toISOString(); + + // 读当前文件 + let entries = this.readRecentFromFile(this.logFile, 10000).filter((e) => e.ts >= cutoff); + + // 同时读最近一个轮转文件,避免轮转后统计短暂失真 + const rotatedFile = `${this.logFile}.1`; + try { + const rotatedEntries = this.readRecentFromFile(rotatedFile, 5000).filter((e) => e.ts >= cutoff); + if (rotatedEntries.length > 0) { + entries = [...rotatedEntries, ...entries]; + } + } catch { + // 轮转文件不存在或读取失败,忽略 + } + + const topModules: Record = {}; + let errors = 0; + let warns = 0; + + for (const entry of entries) { + if (entry.level === 'error') errors++; + if (entry.level === 'warn') warns++; + topModules[entry.module] = (topModules[entry.module] || 0) + 1; + } + + // 按频率排序,保留前 10 + const sorted = Object.entries(topModules) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + const result = { + total: entries.length, + errors, + warns, + topModules: Object.fromEntries(sorted), + }; + + this.statsCache = { ts: now, hours, result }; + return result; + } + + /** + * 获取最近的 error 级别日志(用于实时注入系统提示词) + * @param maxAgeMs 最大时间范围(毫秒),默认 5 分钟 + * @param limit 最多返回条数,默认 5 + */ + getRecentErrors(maxAgeMs: number = 5 * 60 * 1000, limit: number = 5): LogEntry[] { + const cutoff = new Date(Date.now() - maxAgeMs).toISOString(); + const entries = this.readRecent(500); + return entries + .filter(e => e.level === 'error' && e.ts >= cutoff) + .slice(-limit); + } +} + +// 单例导出 +export const logger = new RuntimeLogger(); diff --git a/src/utils/platform.ts b/src/utils/platform.ts index ba56e417..47b10614 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -280,7 +280,7 @@ export function getDefaultShell(): ShellInfo { // 检查 PowerShell 是否可用 try { - execSync('powershell -Command "echo test"', { stdio: 'ignore' }); + execSync('powershell -Command "echo test"', { stdio: 'ignore', windowsHide: true }); return { shell: powershell, args: ['-NoProfile', '-Command'], @@ -458,7 +458,7 @@ export function killProcessTree(pid: number): boolean { try { if (isWindows()) { // Windows: 使用 taskkill 终止整个进程树 - execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); + execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore', windowsHide: true }); } else { // Unix: 使用 SIGTERM 发送到进程组 process.kill(-pid, 'SIGTERM'); @@ -774,7 +774,7 @@ export function getSandboxCapabilities(): SandboxCapabilities { if (isWindows()) { // 检查 Windows Sandbox try { - execSync('powershell -Command "Get-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM"', { stdio: 'ignore' }); + execSync('powershell -Command "Get-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM"', { stdio: 'ignore', windowsHide: true }); caps.windowsSandbox = true; } catch { // Not available diff --git a/src/web/client/src/App.tsx b/src/web/client/src/App.tsx index c3d64e60..5f9e7e3d 100644 --- a/src/web/client/src/App.tsx +++ b/src/web/client/src/App.tsx @@ -21,6 +21,7 @@ import { ArtifactsPanel } from './components/ArtifactsPanel/ArtifactsPanel'; import { GitPanel } from './components/GitPanel'; import { useProject } from './contexts/ProjectContext'; import { TerminalPanel } from './components/Terminal/TerminalPanel'; +import { LogsView } from './components/Terminal/LogsView'; import CodeView from './components/CodeView'; import type { SessionActions } from './types'; @@ -60,6 +61,7 @@ function AppContent({ const currentProjectPath = projectState.currentProject?.path; const [showDebugPanel, setShowDebugPanel] = useState(false); const [showTerminal, setShowTerminal] = useState(false); + const [showLogsPanel, setShowLogsPanel] = useState(false); const [terminalHeight, setTerminalHeight] = useState(280); const [isInputVisible, setIsInputVisible] = useState(true); // Git 面板状态:优先使用 Root 传入的 prop,否则使用内部 state @@ -478,14 +480,22 @@ function AppContent({ onToggleTerminal={() => setShowTerminal(!showTerminal)} onOpenDebugPanel={() => setShowDebugPanel(true)} onOpenGitPanel={() => { - setShowGitPanel(prev => { - const newValue = !prev; - // 如果打开 Git 面板,则关闭 Artifacts 面板(互斥显示) - if (newValue) { - artifactsState.setIsPanelOpen(false); + const willOpen = !showGitPanel; + if (willOpen) { + artifactsState.setIsPanelOpen(false); + setShowLogsPanel(false); + } + setShowGitPanel(() => willOpen); + }} + onOpenLogsPanel={() => { + const willOpenLogs = !showLogsPanel; + if (willOpenLogs) { + artifactsState.setIsPanelOpen(false); + if (showGitPanel) { + setShowGitPanel(() => false); } - return newValue; - }); + } + setShowLogsPanel(willOpenLogs); }} isPinned={chatInput.isPinned} onTogglePin={chatInput.togglePin} @@ -535,6 +545,25 @@ function AppContent({ projectPath={currentProjectPath} /> )} + + {/* 右侧:日志面板 */} + {showLogsPanel && ( +
+
+ Logs + +
+ +
+ )} )} diff --git a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx index 7f1ce0d1..406ee1c3 100644 --- a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx +++ b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx @@ -433,107 +433,107 @@ export function ArtifactsPanel({ {/* 定时任务分区 */} - {scheduleArtifacts && scheduleArtifacts.length > 0 && ( -
- {hasBothSections && ( -
定时任务
+ {scheduleArtifacts && scheduleArtifacts.length > 0 && ( +
+ {hasBothSections && ( +
定时任务
+ )} +
+ {scheduleArtifacts.map(sa => ( + { + onSelectArtifact(null); + onSelectScheduleArtifact?.(sa.id); + }} + /> + ))} +
+
)} -
- {scheduleArtifacts.map(sa => ( - { - onSelectArtifact(null); - onSelectScheduleArtifact?.(sa.id); - }} - /> - ))} -
-
- )} - {/* 文件变更分区 */} - {groups.length === 0 && (!scheduleArtifacts || scheduleArtifacts.length === 0) ? ( -
-
📄
-
{t('artifacts.empty')}
-
- ) : groups.length > 0 ? ( -
- {hasBothSections && ( -
文件变更
- )} -
- {groups.map(group => { - const fileName = getFileName(group.filePath); - const dirPath = getDirPath(group.filePath); - const toolName = getLatestToolName(group); - const status = getLatestStatus(group); - const isExpanded = expandedFiles.has(group.filePath); - const iconClass = toolName === 'Write' ? 'artifacts-file-icon--write' : 'artifacts-file-icon--edit'; - const iconChar = toolName === 'Write' ? '\u2795' : '\u270E'; - - return ( -
-
handleFileClick(group)} - > -
- {iconChar} -
-
- {fileName} - {dirPath && {dirPath}} -
-
- {group.artifacts.length > 1 && ( - - {group.artifacts.length} - - )} - -
-
- - {/* 展开的变更子列表 */} - {isExpanded && group.artifacts.length > 1 && ( -
- {group.artifacts.map(artifact => { - const toolClass = artifact.toolName === 'Write' - ? 'artifacts-change-tool--write' - : 'artifacts-change-tool--edit'; - const isSelected = selectedId === artifact.id; - - return ( -
{ - e.stopPropagation(); - onSelectArtifact(artifact.id); - }} - > - - {artifact.toolName} - - - - {formatTime(artifact.timestamp)} + {/* 文件变更分区 */} + {groups.length === 0 && (!scheduleArtifacts || scheduleArtifacts.length === 0) ? ( +
+
📄
+
{t('artifacts.empty')}
+
+ ) : groups.length > 0 ? ( +
+ {hasBothSections && ( +
文件变更
+ )} +
+ {groups.map(group => { + const fileName = getFileName(group.filePath); + const dirPath = getDirPath(group.filePath); + const toolName = getLatestToolName(group); + const status = getLatestStatus(group); + const isExpanded = expandedFiles.has(group.filePath); + const iconClass = toolName === 'Write' ? 'artifacts-file-icon--write' : 'artifacts-file-icon--edit'; + const iconChar = toolName === 'Write' ? '\u2795' : '\u270E'; + + return ( +
+
handleFileClick(group)} + > +
+ {iconChar} +
+
+ {fileName} + {dirPath && {dirPath}} +
+
+ {group.artifacts.length > 1 && ( + + {group.artifacts.length} -
- ); - })} + )} + +
+
+ + {/* 展开的变更子列表 */} + {isExpanded && group.artifacts.length > 1 && ( +
+ {group.artifacts.map(artifact => { + const toolClass = artifact.toolName === 'Write' + ? 'artifacts-change-tool--write' + : 'artifacts-change-tool--edit'; + const isSelected = selectedId === artifact.id; + + return ( +
{ + e.stopPropagation(); + onSelectArtifact(artifact.id); + }} + > + + {artifact.toolName} + + + + {formatTime(artifact.timestamp)} + +
+ ); + })} +
+ )}
- )} + ); + })}
- ); - })} -
-
- ) : null} +
+ ) : null}
); } diff --git a/src/web/client/src/components/CodeView/CompactMessage.tsx b/src/web/client/src/components/CodeView/CompactMessage.tsx index 70e99240..4ddce3e7 100644 --- a/src/web/client/src/components/CodeView/CompactMessage.tsx +++ b/src/web/client/src/components/CodeView/CompactMessage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { MarkdownContent } from '../MarkdownContent'; import type { ChatMessage, ChatContent, ToolUse } from '../../types'; +import { useLanguage } from '../../i18n'; import styles from './CompactMessage.module.css'; interface CompactMessageProps { @@ -182,6 +183,7 @@ function TextContent({ text, onOpenFile }: { text: string; onOpenFile?: (path: s // 工具调用内容组件 function ToolUseContent({ toolUse }: { toolUse: ToolUse }) { const [expanded, setExpanded] = useState(false); + const { t } = useLanguage(); // 提取文件路径(如果存在) let pathInfo = ''; @@ -222,7 +224,7 @@ function ToolUseContent({ toolUse }: { toolUse: ToolUse }) {
Output:
-                {toolUse.result.output || toolUse.result.error || 'No output'}
+                {toolUse.result.output || toolUse.result.error || t('error.noOutput')}
               
)} diff --git a/src/web/client/src/components/GitPanel/index.tsx b/src/web/client/src/components/GitPanel/index.tsx index 939b847d..ea9955c3 100644 --- a/src/web/client/src/components/GitPanel/index.tsx +++ b/src/web/client/src/components/GitPanel/index.tsx @@ -101,7 +101,7 @@ export function GitPanel({ isOpen, onClose, send, addMessageHandler, projectPath setGitStatus(msg.payload.data); setError(null); } else { - setError(msg.payload?.error || 'Failed to get git status'); + setError(msg.payload?.error || t('error.gitStatusFailed')); } break; @@ -111,7 +111,7 @@ export function GitPanel({ isOpen, onClose, send, addMessageHandler, projectPath setCommits(msg.payload.data || []); setError(null); } else { - setError(msg.payload?.error || 'Failed to get git log'); + setError(msg.payload?.error || t('error.gitLogFailed')); } break; diff --git a/src/web/client/src/components/InputArea.tsx b/src/web/client/src/components/InputArea.tsx index 2ec0cfd8..85794d2f 100644 --- a/src/web/client/src/components/InputArea.tsx +++ b/src/web/client/src/components/InputArea.tsx @@ -62,6 +62,9 @@ interface InputAreaProps { // Git onOpenGitPanel?: () => void; + // Logs + onOpenLogsPanel?: () => void; + // 可见性回调 onVisibilityChange?: (isVisible: boolean) => void; @@ -102,6 +105,7 @@ export function InputArea({ onToggleTerminal, onOpenDebugPanel, onOpenGitPanel, + onOpenLogsPanel, isPinned, onTogglePin, onVisibilityChange, @@ -296,8 +300,9 @@ export function InputArea({ + @@ -307,7 +312,7 @@ export function InputArea({ onClick={onToggleVoice} title={voiceState === 'idle' ? '开启语音识别' : voiceState === 'listening' ? '正在监听(点击关闭)' : '正在录入(点击关闭)'} > - + @@ -320,21 +325,21 @@ export function InputArea({ onClick={onTogglePin} title={isPinned ? t('input.pinUnlock') : t('input.pinLock')} > - + + {onOpenGitPanel && ( )} + {onOpenLogsPanel && ( + + )} {hasCompactBoundary && ( + ))} +
+
+ {/* 自动滚动开关 */} + + {/* 清屏按钮 */} + +
+ + + {/* 日志列表 */} +
+ {filteredEntries.length === 0 ? ( +
No logs to display
+ ) : ( + filteredEntries.map((entry, index) => { + const isExpanded = expandedIds.has(index); + const hasDetails = entry.stack || entry.data; + + return ( +
+
hasDetails && toggleExpand(index)} + style={{ cursor: hasDetails ? 'pointer' : 'default' }} + > + {/* 时间 */} + {formatTime(entry.ts)} + + {/* 级别标签 */} + + {entry.level.toUpperCase()} + + + {/* 模块名 */} + {entry.module} + + {/* 消息 */} + {entry.msg} + + {/* 展开指示器 */} + {hasDetails && ( + + {isExpanded ? '▼' : '▶'} + + )} +
+ + {/* 展开的详细信息 */} + {isExpanded && hasDetails && ( +
+ {entry.stack && ( +
+
Stack Trace:
+
{entry.stack}
+
+ )} + {entry.data && ( +
+
Data:
+
+                          {JSON.stringify(entry.data, null, 2)}
+                        
+
+ )} +
+ )} +
+ ); + }) + )} +
+ + ); +} diff --git a/src/web/client/src/components/Terminal/TerminalPanel.css b/src/web/client/src/components/Terminal/TerminalPanel.css index fedcbc9e..d5c814d8 100644 --- a/src/web/client/src/components/Terminal/TerminalPanel.css +++ b/src/web/client/src/components/Terminal/TerminalPanel.css @@ -63,12 +63,60 @@ overflow-x: auto; overflow-y: hidden; scrollbar-width: none; + gap: 8px; } .terminal-header-left::-webkit-scrollbar { display: none; } +/* Panel mode tabs (Terminal / Logs) */ +.panel-mode-tabs { + display: flex; + align-items: center; + gap: 0; + flex-shrink: 0; + border-right: 1px solid rgba(255, 255, 255, 0.08); + padding-right: 8px; +} + +.panel-mode-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0 12px; + height: 34px; + font-size: 11.5px; + font-weight: 500; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + position: relative; + border-bottom: 2px solid transparent; +} + +.panel-mode-btn:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.03); +} + +.panel-mode-btn.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.panel-mode-btn.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent-primary); +} + /* Tab bar */ .terminal-tabs { display: flex; diff --git a/src/web/client/src/components/Terminal/TerminalPanel.tsx b/src/web/client/src/components/Terminal/TerminalPanel.tsx index d2d2f181..76dabe24 100644 --- a/src/web/client/src/components/Terminal/TerminalPanel.tsx +++ b/src/web/client/src/components/Terminal/TerminalPanel.tsx @@ -8,6 +8,7 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import './TerminalPanel.css'; +import { LogsView } from './LogsView'; // xterm 主题配置 const XTERM_THEME = { @@ -196,6 +197,7 @@ export function TerminalPanel({ }: TerminalPanelProps) { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); + const [panelMode, setPanelMode] = useState<'terminal' | 'logs'>('terminal'); const isDraggingRef = useRef(false); const startYRef = useRef(0); const startHeightRef = useRef(0); @@ -576,43 +578,64 @@ export function TerminalPanel({ {/* 标题栏 + Tab 栏 */}
- {/* Tab 列表 */} -
- {tabs.map(tab => ( -
setActiveTabId(tab.id)} - > - - {tab.isReady && } - {tab.name} - - -
- ))} - {/* 新建终端按钮 */} - +
+ + {/* Terminal 模式:显示 Tab 列表 */} + {panelMode === 'terminal' && ( +
+ {tabs.map(tab => ( +
setActiveTabId(tab.id)} + > + + {tab.isReady && } + {tab.name} + + +
+ ))} + {/* 新建终端按钮 */} + +
+ )}
- + {/* Terminal 模式:显示重启和关闭按钮 */} + {panelMode === 'terminal' && ( + + )}
); diff --git a/src/web/client/src/components/config/SystemConfigPanel.tsx b/src/web/client/src/components/config/SystemConfigPanel.tsx index 822fb78c..3780e70c 100644 --- a/src/web/client/src/components/config/SystemConfigPanel.tsx +++ b/src/web/client/src/components/config/SystemConfigPanel.tsx @@ -114,7 +114,7 @@ export function SystemConfigPanel({ onSave, onClose }: ConfigPanelProps) { }); const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to save logging config'); + throw new Error(data.error || t('error.saveLoggingFailed')); } }; @@ -126,7 +126,7 @@ export function SystemConfigPanel({ onSave, onClose }: ConfigPanelProps) { }); const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to save proxy config'); + throw new Error(data.error || t('error.saveProxyFailed')); } }; @@ -138,7 +138,7 @@ export function SystemConfigPanel({ onSave, onClose }: ConfigPanelProps) { }); const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to save cache config'); + throw new Error(data.error || t('error.saveCacheFailed')); } }; @@ -150,7 +150,7 @@ export function SystemConfigPanel({ onSave, onClose }: ConfigPanelProps) { }); const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to save security config'); + throw new Error(data.error || t('error.saveSecurityFailed')); } }; diff --git a/src/web/client/src/hooks/useMessageHandler.ts b/src/web/client/src/hooks/useMessageHandler.ts index 3238fef1..40ed6ac4 100644 --- a/src/web/client/src/hooks/useMessageHandler.ts +++ b/src/web/client/src/hooks/useMessageHandler.ts @@ -117,16 +117,20 @@ export function useMessageHandler({ const unsubscribe = addMessageHandler((msg: WSMessage) => { const payload = msg.payload as Record; - // 会话隔离 + // 会话隔离:通用检查 + // 只要消息携带了 sessionId 且与当前会话不匹配,一律丢弃 + // 不再使用白名单方式,避免新增消息类型时遗漏导致消息串线 const msgSessionId = payload.sessionId as string | undefined; const currentSessionId = sessionIdRef.current; - const isStreamingMessage = [ - 'message_start', 'text_delta', 'thinking_start', 'thinking_delta', - 'thinking_complete', 'tool_use_start', 'tool_use_delta', 'tool_result', - 'message_complete', 'permission_request', 'user_question', 'context_update', 'context_compact', + // 不需要隔离的消息类型(全局消息 + 会话切换/创建消息) + // 会话切换/创建消息的 sessionId 是目标会话 ID,不能与当前会话比较过滤 + const isGlobalMessage = [ + 'connected', 'pong', 'skills_list', 'session_list', + 'session_created', 'session_deleted', 'permission_config_update', + 'session_switched', 'session_new_ready', ].includes(msg.type); - if (isStreamingMessage && msgSessionId && currentSessionId && msgSessionId !== currentSessionId) { + if (!isGlobalMessage && msgSessionId && currentSessionId && msgSessionId !== currentSessionId) { // 跨会话的弹框消息:不直接显示,但弹出通知提醒用户切回去 if (msg.type === 'permission_request') { setCrossSessionNotification({ @@ -146,10 +150,6 @@ export function useMessageHandler({ return; } - if (msg.type === 'status' && msgSessionId && currentSessionId && msgSessionId !== currentSessionId) { - return; - } - // 兜底:孤立流式事件自动创建消息上下文 const streamingEventTypes = [ 'text_delta', 'thinking_start', 'thinking_delta', @@ -429,6 +429,8 @@ export function useMessageHandler({ setMessages([]); setPermissionRequest(null); setUserQuestion(null); + setCompactState({ phase: 'idle' }); + setContextUsage(null); refreshSessionsRef.current(); break; @@ -460,6 +462,8 @@ export function useMessageHandler({ // 不在此处调用 refreshSessions(): // 让 useSessionManager 的乐观插入先展示,避免刷新时数据不一致。 // 列表刷新由 message_complete 事件负责。 + setCompactState({ phase: 'idle' }); + setContextUsage(null); } break; @@ -475,6 +479,8 @@ export function useMessageHandler({ setStatus('idle'); setPermissionRequest(null); setUserQuestion(null); + setCompactState({ phase: 'idle' }); + setContextUsage(null); break; case 'task_status': { diff --git a/src/web/client/src/i18n/locales.ts b/src/web/client/src/i18n/locales.ts index 6ee25dfa..c731372a 100644 --- a/src/web/client/src/i18n/locales.ts +++ b/src/web/client/src/i18n/locales.ts @@ -705,6 +705,20 @@ const en: Translations = { 'slashCommand.category.auth': 'Auth', 'slashCommand.category.development': 'Development', 'slashCommand.category.skill': 'Skills', + + // Error messages + 'error.gitStatusFailed': 'Failed to get git status', + 'error.gitLogFailed': 'Failed to get git log', + 'error.loadConfigFailed': 'Failed to load configs', + 'error.saveConfigFailed': 'Failed to save configs', + 'error.saveLoggingFailed': 'Failed to save logging config', + 'error.saveProxyFailed': 'Failed to save proxy config', + 'error.saveCacheFailed': 'Failed to save cache config', + 'error.saveSecurityFailed': 'Failed to save security config', + 'error.checkAuthFailed': 'Failed to check auth status', + 'error.logoutFailed': 'Failed to logout', + 'error.noOutput': 'No output', + 'error.mermaidLoadFailed': 'Failed to load mermaid', }; const zh: Translations = { @@ -1299,6 +1313,20 @@ const zh: Translations = { 'slashCommand.category.auth': '认证', 'slashCommand.category.development': '开发', 'slashCommand.category.skill': 'Skills', + + // Error messages + 'error.gitStatusFailed': '获取 git 状态失败', + 'error.gitLogFailed': '获取 git 日志失败', + 'error.loadConfigFailed': '加载配置失败', + 'error.saveConfigFailed': '保存配置失败', + 'error.saveLoggingFailed': '保存日志配置失败', + 'error.saveProxyFailed': '保存代理配置失败', + 'error.saveCacheFailed': '保存缓存配置失败', + 'error.saveSecurityFailed': '保存安全配置失败', + 'error.checkAuthFailed': '检查认证状态失败', + 'error.logoutFailed': '退出登录失败', + 'error.noOutput': '无输出', + 'error.mermaidLoadFailed': '加载 mermaid 失败', }; export const locales: Record = { en, zh }; diff --git a/src/web/client/src/styles/index.css b/src/web/client/src/styles/index.css index ed1e1688..7d61c475 100644 --- a/src/web/client/src/styles/index.css +++ b/src/web/client/src/styles/index.css @@ -524,11 +524,11 @@ body { } .model-selector-compact { - padding: 4px 8px; - height: 32px; + padding: 2px 6px; + height: 26px; background: var(--bg-secondary); border: 1px solid transparent; - border-radius: var(--radius-md); + border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer; font-size: 12px; @@ -556,11 +556,11 @@ body { Permission Mode Selector ========================================= */ .permission-mode-selector { - padding: 4px 8px; - height: 32px; + padding: 2px 6px; + height: 26px; background: var(--bg-secondary); border: 1px solid transparent; - border-radius: var(--radius-md); + border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer; font-size: 12px; @@ -610,13 +610,16 @@ body { border: 1px solid var(--border-color); color: var(--text-muted); cursor: pointer; - padding: 4px 6px; + width: 26px; + height: 26px; + padding: 0; border-radius: 4px; - font-size: 14px; + font-size: 13px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; + flex-shrink: 0; } .terminal-toggle-btn:hover { @@ -764,14 +767,15 @@ body { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 26px; + height: 26px; border: 1px solid var(--border-color, #333); - border-radius: 6px; + border-radius: 4px; background: transparent; color: var(--text-muted, #64748b); cursor: pointer; transition: all 0.15s ease; + flex-shrink: 0; } .transcript-toggle-btn:hover { @@ -1167,12 +1171,21 @@ body { .input-toolbar-left { display: flex; align-items: center; - gap: 4px; + gap: 2px; min-width: 0; flex: 1; overflow: hidden; } +/* 工具栏分隔线 */ +.toolbar-divider { + width: 1px; + height: 16px; + background: var(--border-color, rgba(255, 255, 255, 0.08)); + margin: 0 3px; + flex-shrink: 0; +} + .input-toolbar-right { display: flex; align-items: center; @@ -1181,9 +1194,9 @@ body { } .attach-btn { - width: 32px; - height: 32px; - border-radius: var(--radius-md); + width: 26px; + height: 26px; + border-radius: var(--radius-sm); background: transparent; border: 1px solid transparent; color: var(--text-secondary); @@ -1192,7 +1205,8 @@ body { justify-content: center; cursor: pointer; transition: 0.2s; - font-size: 14px; + font-size: 13px; + flex-shrink: 0; } .attach-btn:hover { @@ -1202,9 +1216,9 @@ body { /* Voice Button */ .voice-btn { - width: 32px; - height: 32px; - border-radius: var(--radius-md); + width: 26px; + height: 26px; + border-radius: var(--radius-sm); background: transparent; border: 1px solid transparent; color: var(--text-secondary); @@ -1213,7 +1227,7 @@ body { justify-content: center; cursor: pointer; transition: color 0.2s, background 0.2s, box-shadow 0.2s; - font-size: 14px; + font-size: 13px; position: relative; flex-shrink: 0; } @@ -1288,9 +1302,9 @@ body { /* Pin Toggle Button */ .pin-toggle-btn { - width: 32px; - height: 32px; - border-radius: var(--radius-md); + width: 26px; + height: 26px; + border-radius: var(--radius-sm); background: transparent; border: 1px solid transparent; color: var(--text-secondary); @@ -1299,7 +1313,8 @@ body { justify-content: center; cursor: pointer; transition: all 0.2s; - font-size: 14px; + font-size: 13px; + flex-shrink: 0; } .pin-toggle-btn:hover { @@ -1314,8 +1329,8 @@ body { } .send-btn { - width: 34px; - height: 34px; + width: 30px; + height: 30px; padding: 0; background: var(--accent-primary); color: white; @@ -1345,8 +1360,8 @@ body { } .stop-btn { - height: 32px; - padding: 0 16px; + height: 28px; + padding: 0 12px; background: #e53935; color: white; border: none; @@ -2776,4 +2791,92 @@ body { transform: scale(1); opacity: 1; } +} + +/* ========================================= + Logs Side Panel + 独立日志面板(右侧固定) + ========================================= */ + +.logs-side-panel { + position: fixed; + top: 0; + right: 0; + width: 500px; + height: 100vh; + background: var(--bg-primary); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 1000; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); +} + +.logs-side-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + background: var(--bg-secondary); +} + +.logs-side-panel-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.logs-side-panel-close { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.logs-side-panel-close:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.logs-side-panel .logs-view { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* Debug / Git / Logs trigger buttons in InputArea toolbar */ +.debug-trigger-btn, +.git-trigger-btn, +.logs-trigger-btn { + background: transparent; + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); + color: var(--text-muted, #64748b); + cursor: pointer; + width: 26px; + height: 26px; + padding: 0; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.debug-trigger-btn:hover, +.git-trigger-btn:hover, +.logs-trigger-btn:hover { + background: var(--bg-tertiary, #334155); + color: var(--accent-light, #818cf8); + border-color: var(--accent-primary, #6366f1); } \ No newline at end of file diff --git a/src/web/server/conversation.ts b/src/web/server/conversation.ts index dea15491..772ae0e9 100644 --- a/src/web/server/conversation.ts +++ b/src/web/server/conversation.ts @@ -49,6 +49,7 @@ import { isDaemonRunning } from '../../daemon/index.js'; import { parseTimeExpression } from '../../daemon/time-parser.js'; import { appendRunLog } from '../../daemon/run-log.js'; import { promptSnippetsManager } from './prompt-snippets.js'; +import { isEvolveRestartRequested, triggerGracefulShutdown } from './evolve-state.js'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -288,6 +289,8 @@ interface SessionState { lastCompactedUuid?: string; /** 标记:处理中 WebSocket 被刷新替换,完成后需要重发 history */ needsHistoryResend?: boolean; + /** 上次持久化时的消息数量(用于判断是否需要持久化,避免磁盘读取) */ + lastPersistedMessageCount: number; /** 用于取消正在执行的工具(如 Bash 命令)的 AbortController */ currentAbortController?: AbortController; /** 正在流式生成的助手消息内容(用于浏览器刷新后恢复中间状态) */ @@ -760,6 +763,7 @@ export class ConversationManager { }, isProcessing: false, lastActualInputTokens: 0, + lastPersistedMessageCount: 0, }; this.sessions.set(sessionId, state); @@ -832,8 +836,47 @@ export class ConversationManager { return state.chatHistory; } - // 会话处理中:从 messages 实时构建,确保包含所有中间工具调用 turn - return this.convertMessagesToChatHistory(state.messages); + // 会话处理中:需要包含 chatHistory 中尚未同步的实时消息(工具调用中间 turn) + // 关键修复:如果 chatHistory 中有 compact_boundary 标记,说明经历过 AutoCompact 压缩, + // 此时 state.messages 只包含压缩后的摘要+最近消息,不能完全重建, + // 必须以 chatHistory 为基础,仅追加增量。 + const hasCompactBoundary = state.chatHistory.some(m => m.isCompactBoundary); + if (!hasCompactBoundary) { + // 未压缩:messages 是完整的,可以安全重建 + return this.convertMessagesToChatHistory(state.messages); + } + + // 已压缩:以 chatHistory 为基础,找出 messages 中还没同步的增量部分 + // chatHistory 中最后一条记录的 _messagesLen 表示已经同步到 messages 的哪个位置 + const lastSyncedIndex = this.getLastSyncedMessageIndex(state.chatHistory); + if (lastSyncedIndex >= state.messages.length) { + // 没有新增消息,直接返回 + return state.chatHistory; + } + + // 从未同步的位置开始,转换增量消息 + const incrementalMessages = state.messages.slice(lastSyncedIndex); + if (incrementalMessages.length === 0) { + return state.chatHistory; + } + + const incrementalHistory = this.convertMessagesToChatHistory(incrementalMessages); + return [...state.chatHistory, ...incrementalHistory]; + } + + /** + * 获取 chatHistory 中最后一条已同步的 messages 索引 + */ + private getLastSyncedMessageIndex(chatHistory: ChatMessage[]): number { + // 从后往前找有 _messagesLen 的条目 + for (let i = chatHistory.length - 1; i >= 0; i--) { + if (chatHistory[i]._messagesLen !== undefined) { + return chatHistory[i]._messagesLen!; + } + } + // 没有 _messagesLen 标记,无法确定同步位置 + // 返回 Infinity 表示不追加增量(保守策略:宁可少显示正在处理的消息,也不丢历史) + return Infinity; } /** @@ -1126,6 +1169,8 @@ export class ConversationManager { let networkRetryCount = 0; /** 是否刚执行过因 "prompt too long" 而触发的强制压缩(防止无限循环) */ let justForceCompacted = false; + /** 是否刚执行过自动压缩(防止连续压缩:压缩后 API 实际 inputTokens 仍含系统提示词+工具定义,可能仍超阈值) */ + let justAutoCompacted = false; // 创建 AbortController,用于在取消时中断正在执行的工具 state.currentAbortController = new AbortController(); @@ -1159,8 +1204,13 @@ export class ConversationManager { // 双重检查:1) 估算值检查 2) 上一次 API 实际 inputTokens 检查 const resolvedModel = modelConfig.resolveAlias(state.model); const threshold = calculateAutoCompactThreshold(resolvedModel); - const needsCompact = shouldAutoCompact(cleanedMessages, resolvedModel) || - (state.lastActualInputTokens > 0 && state.lastActualInputTokens >= threshold); + // 防止连续压缩:刚压缩完的下一轮跳过(系统提示词+工具定义的 token 开销不可压缩, + // lastActualInputTokens 包含了这些不可压缩的部分,可能仍超阈值导致死循环) + const needsCompact = !justAutoCompacted && ( + shouldAutoCompact(cleanedMessages, resolvedModel) || + (state.lastActualInputTokens > 0 && state.lastActualInputTokens >= threshold) + ); + justAutoCompacted = false; // 重置标志(仅跳过紧接的一轮) if (needsCompact) { try { @@ -1252,6 +1302,7 @@ export class ConversationManager { cleanedMessages = [...compactResult.messages, ...messagesToKeep]; state.messages = [...compactResult.messages, ...messagesToKeep]; state.lastActualInputTokens = 0; // 压缩后重置 + justAutoCompacted = true; // 标记刚压缩完,防止下一轮再次触发 // 对齐官方:保存边界 UUID 用于增量压缩 if (compactResult.boundaryUuid) { state.lastCompactedUuid = compactResult.boundaryUuid; @@ -1562,8 +1613,15 @@ export class ConversationManager { } } - // 继续循环 - continueLoop = true; + // SelfEvolve 检查:如果进化重启已请求,不再发起下一轮 API 调用 + // 这样可以确保工具结果已保存,然后在循环结束后的持久化逻辑中触发关闭 + if (isEvolveRestartRequested()) { + console.log('[ConversationManager] Evolve restart requested, stopping conversation loop after tool persistence.'); + continueLoop = false; + } else { + // 继续循环 + continueLoop = true; + } } else { // 对话结束 continueLoop = false; @@ -1651,6 +1709,7 @@ export class ConversationManager { if (compactResult.wasCompacted) { state.messages = [...compactResult.messages, ...forceKeepMsgs]; state.lastActualInputTokens = 0; + justAutoCompacted = true; // 防止连续压缩 // 对齐官方:保存边界 UUID 用于增量压缩 if (compactResult.boundaryUuid) { state.lastCompactedUuid = compactResult.boundaryUuid; @@ -1776,6 +1835,15 @@ export class ConversationManager { } } + // SelfEvolve:会话已完整持久化,现在安全触发 gracefulShutdown + // 延迟 200ms 让 WebSocket 有机会推送最后的工具结果给前端 + if (isEvolveRestartRequested()) { + console.log('[ConversationManager] Session persisted, triggering graceful shutdown for evolve restart...'); + setTimeout(() => { + triggerGracefulShutdown(); + }, 200); + } + } /** @@ -3325,6 +3393,18 @@ Guidelines: return this.sessionManager; } + /** + * 从内存中获取会话的工作目录和项目路径 + * 用于避免重复从磁盘加载会话数据 + */ + getSessionProjectPath(sessionId: string): string | null { + const state = this.sessions.get(sessionId); + if (!state) { + return null; + } + return state.session.cwd; + } + /** * 持久化会话 */ @@ -3339,8 +3419,13 @@ Guidelines: return true; } + // 优化:通过 lastPersistedMessageCount 判断是否有变化,避免从磁盘加载会话 + if (state.messages.length === state.lastPersistedMessageCount) { + return true; // 没有新消息,跳过持久化 + } + try { - // 先检查会话是否存在于 sessionManager + // 从内存缓存获取会话数据(loadSessionById 有内存缓存,不会重复读磁盘) const sessionData = this.sessionManager.loadSessionById(sessionId); if (!sessionData) { @@ -3350,16 +3435,6 @@ Guidelines: return false; } - // 检查是否有实际变化(避免不必要的磁盘写入) - const hasChanges = - sessionData.messages.length !== state.messages.length || - sessionData.chatHistory?.length !== state.chatHistory.length || - sessionData.currentModel !== state.model; - - if (!hasChanges) { - return true; // 没有变化,直接返回 - } - // 更新会话数据 sessionData.messages = state.messages; sessionData.chatHistory = state.chatHistory; @@ -3374,6 +3449,8 @@ Guidelines: // 保存到磁盘 const success = this.sessionManager.saveSession(sessionId); if (success) { + // 更新 lastPersistedMessageCount,标记已持久化 + state.lastPersistedMessageCount = state.messages.length; console.log(`[ConversationManager] 会话已持久化: ${sessionId}`); } return success; @@ -3435,7 +3512,8 @@ Guidelines: // 从持久化数据恢复会话状态 const session = new Session(sessionData.metadata.workingDirectory || this.cwd); - await session.initializeGitInfo(); + // 在后台异步获取 Git 信息,不阻塞会话切换(Git 信息主要用于 system prompt,在用户发送消息时才需要) + session.initializeGitInfo().catch(() => {}); const clientConfig = this.buildClientConfig(sessionData.currentModel || this.defaultModel); const client = new ClaudeClient({ @@ -3468,9 +3546,19 @@ Guidelines: }, isProcessing: false, lastActualInputTokens: 0, + lastPersistedMessageCount: sessionData.messages.length, // 从磁盘加载时,初始化为当前消息数 }; this.sessions.set(sessionId, state); + + // 初始化 NotebookManager(SelfEvolve 重启后 resumeSession 不经过 getOrCreateSession,需要在这里初始化) + const workingDir = sessionData.metadata.workingDirectory || this.cwd; + try { + initNotebookManager(workingDir); + } catch (error) { + console.warn('[ConversationManager] resumeSession: 初始化 NotebookManager 失败:', error); + } + console.log(`[ConversationManager] 会话已恢复: ${sessionId}, 消息数: ${sessionData.messages.length}, chatHistory: ${chatHistory.length}, permissionMode: ${permissionMode || 'default'}`); return true; } catch (error) { diff --git a/src/web/server/handlers/types.ts b/src/web/server/handlers/types.ts index 37ec4469..94a8d9d8 100644 --- a/src/web/server/handlers/types.ts +++ b/src/web/server/handlers/types.ts @@ -85,12 +85,17 @@ export interface ContinuousDevOrchestrator { /** * 发送消息到客户端 + * 已关闭的 ws 只 warn 一次,避免高频流式事件产生日志洪水 */ +const closedWsLogged = new WeakSet(); export function sendMessage(ws: WebSocket, message: ServerMessage): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } else { - const sessionId = ('payload' in message ? (message.payload as any)?.sessionId : '') || ''; - console.warn(`[WebSocket] 消息被丢弃 (ws.readyState=${ws.readyState}): type=${message.type}, session=${sessionId}`); + if (!closedWsLogged.has(ws)) { + closedWsLogged.add(ws); + const sessionId = ('payload' in message ? (message.payload as any)?.sessionId : '') || ''; + console.warn(`[WebSocket] 连接已关闭 (readyState=${ws.readyState}), 后续消息将静默丢弃. session=${sessionId}, first_dropped=${message.type}`); + } } } diff --git a/src/web/server/index.ts b/src/web/server/index.ts index 5e11745a..c163970a 100644 --- a/src/web/server/index.ts +++ b/src/web/server/index.ts @@ -17,6 +17,8 @@ import { setupApiRoutes } from './routes/api.js'; import { setupConfigApiRoutes } from './routes/config-api.js'; import { initI18n } from '../../i18n/index.js'; import { configManager } from '../../config/index.js'; +import { logger } from '../../utils/logger.js'; +import { errorWatcher } from '../../utils/error-watcher.js'; import { requestEvolveRestart, isEvolveEnabled, @@ -45,6 +47,17 @@ export interface WebServerResult { } export async function startWebServer(options: WebServerOptions = {}): Promise { + // 初始化运行时日志系统 — 拦截所有 console 输出并持久化到 ~/.claude/runtime.log + logger.init({ + interceptConsole: true, + minLevel: 'info', + }); + + // 启用 ErrorWatcher — 实时感知 error 日志并聚合分析 + // 错误感知是基础能力,所有模式都启用;仅自动修复(Phase 2)需要 evolve 模式 + errorWatcher.enable(); + logger.setErrorWatcher((entry) => errorWatcher.onError(entry)); + // 设置 CLAUDE_CODE_ENTRYPOINT 环境变量(如果未设置) // 官方 Claude Code 使用此变量标识启动入口点 // WebUI 模式使用 'claude-vscode' 以匹配官方的 VSCode 扩展入口 @@ -215,6 +228,78 @@ export async function startWebServer(options: WebServerOptions = {}): Promise { + const { randomUUID } = await import('crypto'); + + // 1. 创建持久化会话 + const title = `🔧 自动修复: ${pattern.description.slice(0, 40)}`; + const newSession = sessionMgr.createSession({ + name: title, + model: model, + tags: ['webui', 'auto-repair'], + projectPath: cwd, + }); + const sessionId = newSession.metadata.id; + + // 2. 广播 session_created 给所有前端客户端,让侧边栏立即显示这个新会话 + broadcastMessage({ + type: 'session_created', + payload: { + sessionId, + name: title, + model: model, + createdAt: newSession.metadata.createdAt, + tags: ['auto-repair'], + }, + }); + + // 3. 构造修复指令 + const repairPrompt = buildRepairPrompt(pattern, sourceContext); + + // 4. 构建流式回调 — 将修复过程的所有消息广播到前端 + const messageId = randomUUID(); + const callbacks = buildRepairCallbacks(broadcastMessage, sessionId, messageId, conversationManager); + + // 5. 发送 message_start + status + broadcastMessage({ + type: 'message_start', + payload: { messageId, sessionId }, + }); + broadcastMessage({ + type: 'status', + payload: { status: 'thinking', sessionId }, + }); + + // 6. 启动修复对话(异步,不阻塞 ErrorWatcher) + // 使用 bypassPermissions 模式,让 AI 自动执行 Read/Edit 不需要用户确认 + conversationManager.chat( + sessionId, + repairPrompt, + undefined, // mediaAttachments + model, + callbacks, + cwd, // projectPath + undefined, // ws (无绑定的 ws,通过 broadcast 广播) + 'bypassPermissions', + ).catch(err => { + console.error(`[ErrorWatcher] Repair session ${sessionId} failed:`, err); + broadcastMessage({ + type: 'error', + payload: { error: `修复会话失败: ${err.message}`, sessionId }, + }); + }); + + return sessionId; + }); + } + // 延迟恢复未完成的蓝图执行(仅在 WebUI 服务器模式下) setTimeout(async () => { try { @@ -426,3 +511,162 @@ const isMainModule = process.argv[1]?.includes('server') || if (isMainModule) { startWebServer().catch(console.error); } + +// ============================================================================ +// ErrorWatcher 自动修复辅助函数 +// ============================================================================ + +import type { ErrorPattern } from '../../utils/error-watcher.js'; +import type { StreamCallbacks } from './conversation.js'; + +/** + * 构建修复提示词 + * 向 AI 描述错误上下文,指导它分析和修复 + */ +function buildRepairPrompt(pattern: ErrorPattern, sourceContext: string): string { + return [ + '# 自动错误修复任务', + '', + '系统检测到以下源码错误反复发生,请分析并修复。', + '', + '## 错误信息', + `- **模块**: ${pattern.sample.module}`, + `- **错误**: ${pattern.sample.msg}`, + `- **位置**: ${pattern.sourceLocation || '未知'}`, + `- **重复次数**: ${pattern.count} 次(5分钟窗口内)`, + `- **分类**: ${pattern.category}`, + pattern.sample.stack ? `- **堆栈**:\n\`\`\`\n${pattern.sample.stack.slice(0, 800)}\n\`\`\`` : '', + '', + '## 源码上下文', + '```typescript', + sourceContext, + '```', + '', + '## 要求', + '1. 先用 Read 工具仔细读取相关文件,理解上下文', + '2. 分析错误根因', + '3. 用 Edit 工具修复代码', + '4. 修复后简要说明修改内容和原因', + '', + '注意:', + '- 只修复这个错误,不做额外的"优化"或"改进"', + '- 如果错误是外部原因(网络、API),说明不需要修复即可', + '- 修复后不需要运行 SelfEvolve,由系统决定是否重启', + ].filter(Boolean).join('\n'); +} + +/** + * 构建修复会话的流式回调 + * 将 AI 的所有输出通过 broadcastMessage 广播到前端 + */ +function buildRepairCallbacks( + broadcast: (msg: any) => void, + sessionId: string, + messageId: string, + conversationManager: ConversationManager, +): StreamCallbacks { + return { + onThinkingStart: () => { + broadcast({ + type: 'thinking_start', + payload: { messageId, sessionId }, + }); + }, + onThinkingDelta: (text: string) => { + broadcast({ + type: 'thinking_delta', + payload: { messageId, text, sessionId }, + }); + }, + onThinkingComplete: () => { + broadcast({ + type: 'thinking_complete', + payload: { messageId, sessionId }, + }); + }, + onTextDelta: (text: string) => { + broadcast({ + type: 'text_delta', + payload: { messageId, text, sessionId }, + }); + }, + onToolUseStart: (toolUseId: string, toolName: string, input: unknown) => { + broadcast({ + type: 'tool_use_start', + payload: { messageId, toolUseId, toolName, input, sessionId }, + }); + broadcast({ + type: 'status', + payload: { status: 'tool_executing', message: `执行 ${toolName}...`, sessionId }, + }); + }, + onToolUseDelta: (toolUseId: string, partialJson: string) => { + broadcast({ + type: 'tool_use_delta', + payload: { toolUseId, partialJson, sessionId }, + }); + }, + onToolResult: (toolUseId: string, success: boolean, output?: string, error?: string, data?: unknown) => { + broadcast({ + type: 'tool_result', + payload: { + toolUseId, + success, + output, + error, + data: data as any, + defaultCollapsed: true, + sessionId, + }, + }); + }, + onPermissionRequest: (request: any) => { + // 修复会话使用 bypassPermissions,理论上不会触发权限请求 + // 但为了安全,仍然广播到前端 + broadcast({ + type: 'permission_request', + payload: { ...request, sessionId }, + }); + }, + onComplete: async (stopReason: string | null, usage?: { inputTokens: number; outputTokens: number }) => { + await conversationManager.persistSession(sessionId); + broadcast({ + type: 'message_complete', + payload: { + messageId, + stopReason: (stopReason || 'end_turn') as 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use', + usage, + sessionId, + }, + }); + broadcast({ + type: 'status', + payload: { status: 'idle', sessionId }, + }); + console.log(`[ErrorWatcher] Repair session ${sessionId} completed: ${stopReason}`); + }, + onError: (error: Error) => { + broadcast({ + type: 'error', + payload: { error: error.message, sessionId }, + }); + broadcast({ + type: 'status', + payload: { status: 'idle', sessionId }, + }); + console.error(`[ErrorWatcher] Repair session ${sessionId} error:`, error.message); + }, + onContextCompact: (phase: 'start' | 'end' | 'error', info?: Record) => { + broadcast({ + type: 'context_compact', + payload: { phase, info, sessionId }, + }); + }, + onContextUpdate: (usage: { usedTokens: number; maxTokens: number; percentage: number; model: string }) => { + broadcast({ + type: 'context_update', + payload: { ...usage, sessionId }, + }); + }, + }; +} diff --git a/src/web/server/task-manager.ts b/src/web/server/task-manager.ts index 9062b59e..ff90dab2 100644 --- a/src/web/server/task-manager.ts +++ b/src/web/server/task-manager.ts @@ -587,10 +587,12 @@ export class TaskManager { await runSubagentStopHooks(task.id, task.agentType); } catch (error) { - // 任务失败 + // 任务失败 — 保留完整堆栈用于诊断 task.status = 'failed'; task.endTime = new Date(); - task.error = error instanceof Error ? error.message : String(error); + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + task.error = errorMsg; const totalDuration = task.endTime.getTime() - task.startTime.getTime(); // v12.0: 构建结构化错误 @@ -609,8 +611,8 @@ export class TaskManager { : 'escalate', }; - // 日志:子 agent 失败 - console.log(`[SubAgent:${task.agentType}] ❌ 任务失败 (耗时: ${totalDuration}ms): ${task.error}`); + // 日志:子 agent 失败(含完整堆栈) + console.error(`[SubAgent:${task.agentType}] 任务失败 (耗时: ${totalDuration}ms): ${errorMsg}${errorStack ? '\n' + errorStack : ''}`); // 发送状态更新 this.sendTaskStatus(task); diff --git a/src/web/server/websocket.ts b/src/web/server/websocket.ts index caf67b31..3ab33ecf 100644 --- a/src/web/server/websocket.ts +++ b/src/web/server/websocket.ts @@ -27,6 +27,8 @@ import { getSwarmLogDB, type WorkerLog, type WorkerStream } from './database/swa import { registerE2EAgent, unregisterE2EAgent, getE2EAgent } from '../../blueprint/e2e-agent-registry.js'; // 终端管理器 import { TerminalManager } from './terminal-manager.js'; +// 日志系统 +import { logger } from '../../utils/logger.js'; // Git 管理器 import { GitManager } from './git-manager.js'; // Git WebSocket 处理函数 @@ -208,6 +210,9 @@ const terminalManager = new TerminalManager(); // 客户端终端映射:clientId -> Set of terminalIds const clientTerminals = new Map>(); +// 日志订阅管理:clientId -> { interval, lastFileSize } +const logSubscriptions = new Map(); + // 全局 WebSocket 客户端连接池(用于跨模块广播消息) const wsClients = new Map(); @@ -256,6 +261,12 @@ export function setupWebSocket( swarmSubscriptions.delete(blueprintId); } }); + // 清理日志订阅 + const logSub = logSubscriptions.get(clientId); + if (logSub) { + clearInterval(logSub.interval); + logSubscriptions.delete(clientId); + } }; // v5.0: thinking/text 流式内容聚合缓冲区(避免碎片化存储到 SQLite) @@ -1781,14 +1792,22 @@ export function setupWebSocket( /** * 发送消息到客户端 + * + * 当 WebSocket 已关闭时(readyState !== OPEN),消息被静默丢弃。 + * 仅在首次检测到某个 ws 连接关闭时记录一条 warn 日志, + * 避免高频流式事件(thinking_delta 等)产生日志洪水。 */ +const closedWsLogged = new WeakSet(); function sendMessage(ws: WebSocket, message: ServerMessage): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } else { - // 记录丢弃的消息类型,便于排查消息丢失问题 - const sessionId = ('payload' in message ? (message.payload as any)?.sessionId : '') || ''; - console.warn(`[WebSocket] 消息被丢弃 (ws.readyState=${ws.readyState}): type=${message.type}, session=${sessionId}`); + // 每个已关闭的 ws 只 warn 一次 + if (!closedWsLogged.has(ws)) { + closedWsLogged.add(ws); + const sessionId = ('payload' in message ? (message.payload as any)?.sessionId : '') || ''; + console.warn(`[WebSocket] 连接已关闭 (readyState=${ws.readyState}), 后续消息将静默丢弃. session=${sessionId}, first_dropped=${message.type}`); + } } } @@ -1830,7 +1849,7 @@ async function handleClientMessage( const history = conversationManager.getHistory(client.sessionId); sendMessage(ws, { type: 'history', - payload: { messages: history }, + payload: { messages: history, sessionId: client.sessionId }, }); break; @@ -1838,7 +1857,7 @@ async function handleClientMessage( conversationManager.clearHistory(client.sessionId); sendMessage(ws, { type: 'history', - payload: { messages: [] }, + payload: { messages: [], sessionId: client.sessionId }, }); break; @@ -2405,6 +2424,87 @@ async function handleClientMessage( break; } + // ========== 日志消息 ========== + case 'logs:read': { + const logsPayload = (message as any).payload || {}; + const count = logsPayload.count || 200; + const levelFilter = logsPayload.level as string | undefined; + + let entries = logger.readRecent(count); + + // 按级别过滤 + if (levelFilter && levelFilter !== 'ALL') { + entries = entries.filter(e => e.level === levelFilter.toLowerCase()); + } + + sendMessage(client.ws, { + type: 'logs:data', + payload: { entries }, + }); + break; + } + + case 'logs:subscribe': { + // 如果已经订阅了,先取消 + const existing = logSubscriptions.get(client.id); + if (existing) { + clearInterval(existing.interval); + } + + // 获取当前日志文件大小 + const fs = await import('fs'); + const logFile = logger.getLogFile(); + let lastFileSize = 0; + try { + const stat = fs.statSync(logFile); + lastFileSize = stat.size; + } catch { + // 文件不存在或无法读取 + } + + // 每 2 秒检查新日志 + const interval = setInterval(() => { + try { + const stat = fs.statSync(logFile); + const currentSize = stat.size; + + // 如果文件变大了,说明有新日志 + if (currentSize > lastFileSize) { + // 读取最近 50 条日志(包含新的) + const entries = logger.readRecent(50); + + if (entries.length > 0) { + sendMessage(client.ws, { + type: 'logs:tail', + payload: { entries }, + }); + } + + lastFileSize = currentSize; + // 更新存储的文件大小 + const sub = logSubscriptions.get(client.id); + if (sub) { + sub.lastFileSize = currentSize; + } + } + } catch { + // 读取失败,忽略 + } + }, 2000); + + logSubscriptions.set(client.id, { interval, lastFileSize }); + break; + } + + case 'logs:unsubscribe': { + const sub = logSubscriptions.get(client.id); + if (sub) { + clearInterval(sub.interval); + logSubscriptions.delete(client.id); + } + break; + } + default: console.warn('[WebSocket] 未知消息类型:', (message as any).type); } @@ -2661,7 +2761,7 @@ async function handleChatMessage( const updatedHistory = conversationManager.getHistory(chatSessionId); sendMessage(getActiveWs(), { type: 'history', - payload: { messages: updatedHistory }, + payload: { messages: updatedHistory, sessionId: chatSessionId }, }); } else { sendMessage(getActiveWs(), { @@ -2686,7 +2786,7 @@ async function handleChatMessage( const updatedHistory = conversationManager.getHistory(chatSessionId); sendMessage(getActiveWs(), { type: 'history', - payload: { messages: updatedHistory }, + payload: { messages: updatedHistory, sessionId: chatSessionId }, }); } sendMessage(getActiveWs(), { @@ -2815,7 +2915,7 @@ async function handleSlashCommand( if (result.action === 'clear') { sendMessage(ws, { type: 'history', - payload: { messages: [] }, + payload: { messages: [], sessionId: client.sessionId }, }); } } catch (error) { @@ -3022,11 +3122,10 @@ async function handleSessionSwitch( // 恢复后再次更新 ws(resumeSession 可能从磁盘新建了 SessionState) conversationManager.setWebSocket(sessionId, ws); - // 更新客户端项目路径(从会话元数据中获取) - const sessionManager = conversationManager.getSessionManager(); - const sessionData = sessionManager.loadSessionById(sessionId); - if (sessionData?.metadata?.projectPath) { - client.projectPath = sessionData.metadata.projectPath; + // 更新客户端项目路径(优化:从内存中的 SessionState 获取,避免重复读磁盘) + const projectPath = conversationManager.getSessionProjectPath(sessionId); + if (projectPath) { + client.projectPath = projectPath; } // 获取会话历史(使用 getLiveHistory:处理中时从 messages 实时构建,确保工具调用中间 turn 不丢失) @@ -3040,7 +3139,7 @@ async function handleSessionSwitch( sendMessage(ws, { type: 'history', - payload: { messages: history }, + payload: { messages: history, sessionId }, }); // 同步权限配置到客户端(刷新后客户端 permissionMode 会重置为 'default',需要从服务端恢复) @@ -3415,7 +3514,7 @@ async function handleSessionResume( sendMessage(ws, { type: 'history', - payload: { messages: history }, + payload: { messages: history, sessionId }, }); } else { sendMessage(ws, { diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index 9f9879bb..a9797269 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -3,6 +3,25 @@ * 前后端通用的类型 */ +// ============ 日志相关类型 ============ + +/** + * 日志级别 + */ +export type LogLevel = 'error' | 'warn' | 'info' | 'debug'; + +/** + * 日志条目 + */ +export interface LogEntry { + ts: string; + level: LogLevel; + module: string; + msg: string; + stack?: string; + data?: unknown; +} + // ============ WebSocket 消息类型 ============ /** @@ -172,6 +191,10 @@ export type ClientMessage = | { type: 'terminal:input'; payload: { terminalId: string; data: string } } | { type: 'terminal:resize'; payload: { terminalId: string; cols: number; rows: number } } | { type: 'terminal:destroy'; payload: { terminalId: string } } + // 日志消息 + | { type: 'logs:read'; payload?: { count?: number; level?: string } } + | { type: 'logs:subscribe' } + | { type: 'logs:unsubscribe' } // Rewind 消息 | { type: 'rewind_preview'; payload: { messageId: string; option: 'code' | 'conversation' | 'both' } } | { type: 'rewind_execute'; payload: { messageId: string; option: 'code' | 'conversation' | 'both' } } @@ -203,7 +226,7 @@ export type ClientMessage = export type ServerMessage = | { type: 'connected'; payload: { sessionId: string; model: string } } | { type: 'pong' } - | { type: 'history'; payload: { messages: ChatMessage[] } } + | { type: 'history'; payload: { messages: ChatMessage[]; sessionId?: string } } | { type: 'message_start'; payload: { messageId: string; sessionId?: string } } | { type: 'text_delta'; payload: { messageId: string; text: string; sessionId?: string } } | { type: 'tool_use_start'; payload: ToolUseStartPayload } @@ -347,6 +370,9 @@ export type ServerMessage = | { type: 'terminal:created'; payload: { terminalId: string } } | { type: 'terminal:output'; payload: { terminalId: string; data: string } } | { type: 'terminal:exit'; payload: { terminalId: string; exitCode: number } } + // 日志消息 + | { type: 'logs:data'; payload: { entries: LogEntry[] } } + | { type: 'logs:tail'; payload: { entries: LogEntry[] } } // Rewind 消息 | { type: 'rewind_preview'; payload: { success: boolean; preview?: any } } | { type: 'rewind_success'; payload: { success: boolean; result?: any; messages?: any[] } };