From 121fef20f54e51c9e13da5db093663d9da416204 Mon Sep 17 00:00:00 2001 From: Worker Date: Sun, 22 Feb 2026 09:58:13 +0800 Subject: [PATCH 1/7] =?UTF-8?q?[LeadAgent]=20=E6=96=B0=E5=A2=9E=20Terminal?= =?UTF-8?q?Panel=20Logs=20Tab=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 WebUI 的底部 TerminalPanel 中新增 Logs Tab,实时显示 runtime.log 日志内容,支持级别过滤和自动刷新。 ## 后端改动 - **types.ts**: 新增 LogLevel 和 LogEntry 类型定义,添加日志相关的 WebSocket 消息类型(logs:read、logs:subscribe、logs:unsubscribe、logs:data、logs:tail) - **websocket.ts**: - 导入 logger 模块 - 新增 logSubscriptions Map 管理客户端日志订阅 - 实现三个日志消息处理:logs:read(读取)、logs:subscribe(订阅)、logs:unsubscribe(取消订阅) - 在 cleanupClientSubscriptions 中添加断开连接时的订阅清理 ## 前端改动 - **LogsView.tsx**: 新建日志查看器组件(282行) - 纯 HTML 渲染(不使用 xterm) - 显示时间、级别(彩色标签)、模块、消息 - 支持 ALL/ERROR/WARN/INFO 级别过滤 - 自动滚动到底部,用户手动上滚时暂停 - 点击展开查看 stack 和 data 字段 - 清屏功能 - **LogsView.css**: 日志查看器样式(256行) - 背景色匹配终端面板(#0a0e1a) - 等宽字体,紧凑行高 - 级别标签用彩色圆角小色块 - 滚动条样式与终端一致 - **TerminalPanel.tsx**: 集成 Logs Tab - 导入 LogsView 组件 - 新增 panelMode state('terminal' | 'logs') - 在 header 左侧添加 Terminal/Logs 模式切换按钮 - Terminal 模式显示终端 Tab 列表和重启按钮 - Logs 模式显示 LogsView 组件 - 终端实例保持始终挂载(通过 display:none 控制) - **TerminalPanel.css**: 新增模式切换按钮样式 - panel-mode-tabs 和 panel-mode-btn 样式 - active 状态底部下划线高亮 ## 验收标准 ✅ TerminalPanel 底部面板有 Terminal / Logs 两个模式切换按钮 ✅ 点击 Logs 按钮显示日志查看器,展示 runtime.log 最近日志 ✅ 日志条目包含时间、级别(彩色)、模块、消息 ✅ 支持按级别过滤(ALL/ERROR/WARN/INFO) ✅ 新日志自动追加并滚动到底部 ✅ 切回 Terminal 模式时,终端实例状态完好 ✅ npx tsc --noEmit 通过 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/Terminal/LogsView.css | 255 ++++++++++++++++ .../src/components/Terminal/LogsView.tsx | 281 ++++++++++++++++++ .../src/components/Terminal/TerminalPanel.css | 48 +++ .../src/components/Terminal/TerminalPanel.tsx | 100 ++++--- src/web/server/websocket.ts | 92 ++++++ src/web/shared/types.ts | 26 ++ 6 files changed, 768 insertions(+), 34 deletions(-) create mode 100644 src/web/client/src/components/Terminal/LogsView.css create mode 100644 src/web/client/src/components/Terminal/LogsView.tsx diff --git a/src/web/client/src/components/Terminal/LogsView.css b/src/web/client/src/components/Terminal/LogsView.css new file mode 100644 index 00000000..cfdd79e6 --- /dev/null +++ b/src/web/client/src/components/Terminal/LogsView.css @@ -0,0 +1,255 @@ +/* LogsView - 日志查看器样式 */ + +.logs-view { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: #0a0e1a; + color: #e2e8f0; + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace; + font-size: 12px; + overflow: hidden; +} + +/* 工具栏 */ +.logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: rgba(15, 23, 42, 0.9); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; + gap: 8px; +} + +.logs-toolbar-left, +.logs-toolbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +/* 级别过滤按钮 */ +.logs-filter-btn { + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; + font-family: inherit; +} + +.logs-filter-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-secondary); +} + +.logs-filter-btn.active { + background: rgba(99, 102, 241, 0.15); + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +/* 自动滚动开关 */ +.logs-auto-scroll { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + font-size: 11px; + color: var(--text-muted); +} + +.logs-auto-scroll input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; +} + +.logs-auto-scroll:hover { + color: var(--text-secondary); +} + +/* 清屏按钮 */ +.logs-clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s; +} + +.logs-clear-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +/* 日志容器 */ +.logs-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 0; + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.3) transparent; +} + +.logs-container::-webkit-scrollbar { + width: 6px; +} + +.logs-container::-webkit-scrollbar-track { + background: transparent; +} + +.logs-container::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.3); + border-radius: 3px; +} + +.logs-container::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.5); +} + +/* 空状态 */ +.logs-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 13px; +} + +/* 日志条目 */ +.log-entry { + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.log-entry-main { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + min-height: 24px; + transition: background 0.1s; +} + +.log-entry-main:hover { + background: rgba(255, 255, 255, 0.03); +} + +/* 时间 */ +.log-time { + flex-shrink: 0; + width: 90px; + color: var(--text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +/* 级别标签 */ +.log-level { + flex-shrink: 0; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + border-radius: 3px; + text-align: center; + min-width: 50px; + letter-spacing: 0.3px; +} + +/* 模块名 */ +.log-module { + flex-shrink: 0; + min-width: 100px; + max-width: 150px; + color: var(--text-muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 消息 */ +.log-message { + flex: 1; + color: var(--text-secondary); + word-break: break-word; + line-height: 1.4; +} + +/* 展开图标 */ +.log-expand-icon { + flex-shrink: 0; + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} + +/* 展开的详细信息 */ +.log-entry-details { + padding: 8px 8px 8px 110px; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.log-detail-section { + margin-bottom: 8px; +} + +.log-detail-section:last-child { + margin-bottom: 0; +} + +.log-detail-label { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; + font-weight: 500; +} + +.log-detail-content { + margin: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + font-size: 11px; + color: var(--text-secondary); + overflow-x: auto; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.log-detail-content::-webkit-scrollbar { + height: 4px; +} + +.log-detail-content::-webkit-scrollbar-track { + background: transparent; +} + +.log-detail-content::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.3); + border-radius: 2px; +} + +.log-detail-content::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.5); +} diff --git a/src/web/client/src/components/Terminal/LogsView.tsx b/src/web/client/src/components/Terminal/LogsView.tsx new file mode 100644 index 00000000..cf6efec2 --- /dev/null +++ b/src/web/client/src/components/Terminal/LogsView.tsx @@ -0,0 +1,281 @@ +/** + * LogsView - 日志查看器组件 + * 实时显示 runtime.log 日志内容,支持级别过滤和自动刷新 + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; +import './LogsView.css'; + +// 日志级别类型 +type LogLevel = 'error' | 'warn' | 'info' | 'debug'; + +// 日志条目接口 +interface LogEntry { + ts: string; + level: LogLevel; + module: string; + msg: string; + stack?: string; + data?: unknown; +} + +interface LogsViewProps { + active: boolean; // 当前是否是活跃 Tab + panelVisible: boolean; // 面板是否可见 + send: (msg: any) => void; + addMessageHandler: (handler: (msg: any) => void) => () => void; +} + +// 级别过滤选项 +const LEVEL_FILTERS = ['ALL', 'ERROR', 'WARN', 'INFO'] as const; +type LevelFilter = typeof LEVEL_FILTERS[number]; + +// 级别颜色映射 +const LEVEL_COLORS: Record = { + error: '#ef4444', + warn: '#f59e0b', + info: '#3b82f6', + debug: '#64748b', +}; + +/** + * 格式化时间戳为 HH:MM:SS.mmm + */ +function formatTime(ts: string): string { + try { + const date = new Date(ts); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + const ms = date.getMilliseconds().toString().padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${ms}`; + } catch { + return ts; + } +} + +/** + * LogsView 组件 + */ +export function LogsView({ active, panelVisible, send, addMessageHandler }: LogsViewProps) { + const [entries, setEntries] = useState([]); + const [levelFilter, setLevelFilter] = useState('ALL'); + const [autoScroll, setAutoScroll] = useState(true); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const containerRef = useRef(null); + const lastScrollTop = useRef(0); + + // 过滤后的日志条目 + const filteredEntries = levelFilter === 'ALL' + ? entries + : entries.filter(e => e.level === levelFilter.toLowerCase()); + + // 初始化:请求初始数据 + 订阅实时更新 + useEffect(() => { + // 请求最近 200 条日志 + send({ + type: 'logs:read', + payload: { count: 200 }, + }); + + // 订阅实时日志 + send({ + type: 'logs:subscribe', + }); + + // 组件卸载时取消订阅 + return () => { + send({ + type: 'logs:unsubscribe', + }); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // 监听日志消息 + useEffect(() => { + const unsubscribe = addMessageHandler((msg: any) => { + if (msg.type === 'logs:data') { + // 初始数据 + setEntries(msg.payload.entries || []); + } else if (msg.type === 'logs:tail') { + // 实时更新(追加新日志) + const newEntries = msg.payload.entries || []; + if (newEntries.length > 0) { + setEntries(prev => { + // 合并新日志,去重(基于时间戳) + const combined = [...prev, ...newEntries]; + const unique = Array.from( + new Map(combined.map(e => [e.ts, e])).values() + ); + // 保留最近 1000 条 + return unique.slice(-1000); + }); + } + } + }); + + return unsubscribe; + }, [addMessageHandler]); + + // 自动滚动到底部 + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [filteredEntries, autoScroll]); + + // 监听用户手动滚动,暂停自动滚动 + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + + // 如果用户向上滚动,暂停自动滚动 + if (scrollTop < lastScrollTop.current) { + setAutoScroll(false); + } + + // 如果滚动到底部,恢复自动滚动 + if (scrollHeight - scrollTop - clientHeight < 10) { + setAutoScroll(true); + } + + lastScrollTop.current = scrollTop; + }, []); + + // 清屏 + const handleClear = useCallback(() => { + setEntries([]); + setExpandedIds(new Set()); + }, []); + + // 切换展开/折叠 + const toggleExpand = useCallback((index: number) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }, []); + + return ( +
+ {/* 工具栏 */} +
+
+ {/* 级别过滤按钮 */} + {LEVEL_FILTERS.map(level => ( + + ))} +
+
+ {/* 自动滚动开关 */} + + {/* 清屏按钮 */} + +
+
+ + {/* 日志列表 */} +
+ {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..5d1bda64 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/server/websocket.ts b/src/web/server/websocket.ts index caf67b31..ab114950 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) @@ -2405,6 +2416,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); } diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index 9f9879bb..b796d0bd 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' } } @@ -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[] } }; From e6d61dae217222959572a590a769316cdf937d52 Mon Sep 17 00:00:00 2001 From: Worker Date: Sun, 22 Feb 2026 10:36:02 +0800 Subject: [PATCH 2/7] feat(session-memory): implement session isolation for multi-session support Add sessionId parameter to session memory functions to enable concurrent session handling in web mode. Replace global state singleton with Map-based storage keyed by sessionId, maintaining backward compatibility with CLI single-session mode via DEFAULT_SESSION_KEY. Add logger initialization in CLI and UTF-16 surrogate character sanitization in message formatting to prevent JSON parsing errors. --- .claude/skills/analyze-logs/SKILL.md | 75 +++ docs/marketing/discord.md | 133 +++++ src/cli.ts | 4 + src/context/session-memory.ts | 107 ++-- src/core/client.ts | 39 +- src/prompt/builder.ts | 40 +- src/tools/agent.ts | 16 +- src/tools/self-evolve.ts | 14 +- src/utils/logger.ts | 529 ++++++++++++++++++ src/web/client/src/App.tsx | 3 + .../ArtifactsPanel/ArtifactsPanel.css | 31 + .../ArtifactsPanel/ArtifactsPanel.tsx | 238 ++++---- .../src/components/Terminal/LogsView.tsx | 42 +- .../src/components/Terminal/TerminalPanel.tsx | 1 + src/web/client/src/hooks/useMessageHandler.ts | 6 + src/web/server/conversation.ts | 34 +- src/web/server/index.ts | 7 + src/web/server/task-manager.ts | 10 +- 18 files changed, 1139 insertions(+), 190 deletions(-) create mode 100644 .claude/skills/analyze-logs/SKILL.md create mode 100644 docs/marketing/discord.md create mode 100644 src/utils/logger.ts 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/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/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/tools/agent.ts b/src/tools/agent.ts index e9c41eaf..757ae640 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: `Agent execution failed: ${errorMsg}${errorStack ? '\nStack: ' + errorStack : ''}`, }; } } 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/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..b11b0cca --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,529 @@ +/** + * 统一运行时日志系统 + * + * 拦截三层输出并持久化到文件: + * 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 秒缓存 + + // 保存原始方法 + 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,避免无限递归 + } + } + + /** + * 日志文件轮转 + * 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, + }); + } + + /** + * 获取日志文件路径 + */ + 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/web/client/src/App.tsx b/src/web/client/src/App.tsx index c3d64e60..7fde0ab0 100644 --- a/src/web/client/src/App.tsx +++ b/src/web/client/src/App.tsx @@ -522,6 +522,9 @@ function AppContent({ selectedScheduleId={scheduleState.selectedScheduleId} selectedScheduleArtifact={scheduleState.selectedScheduleArtifact} onSelectScheduleArtifact={scheduleState.setSelectedScheduleId} + connected={connected} + send={send} + addMessageHandler={addMessageHandler} /> )} diff --git a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css index 94cdb98e..a9957b90 100644 --- a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css +++ b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css @@ -54,6 +54,37 @@ opacity: 0.7; } +/* Tab 切换按钮组 */ +.artifacts-panel-tabs { + display: flex; + align-items: center; + gap: 2px; +} + +.artifacts-tab-btn { + background: none; + border: none; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + display: flex; + align-items: center; + gap: 6px; +} + +.artifacts-tab-btn:hover { + color: var(--text-secondary); +} + +.artifacts-tab-btn.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + .artifacts-panel-badge { font-size: 11px; font-weight: 500; diff --git a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx index 7f1ce0d1..37dc9545 100644 --- a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx +++ b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx @@ -4,6 +4,7 @@ import type { FileArtifact, ArtifactGroup } from '../../hooks/useArtifacts'; import type { ScheduleArtifact } from '../../hooks/useScheduleArtifacts'; import { computeSideBySideDiff } from '../../utils/diffUtils'; import { useLanguage } from '../../i18n'; +import { LogsView } from '../Terminal/LogsView'; import './ArtifactsPanel.css'; interface ArtifactsPanelProps { @@ -17,6 +18,10 @@ interface ArtifactsPanelProps { selectedScheduleId?: string | null; selectedScheduleArtifact?: ScheduleArtifact | null; onSelectScheduleArtifact?: (id: string | null) => void; + // 日志 Tab 相关 + connected?: boolean; + send?: (msg: any) => void; + addMessageHandler?: (handler: (msg: any) => void) => () => void; } function getFileName(filePath: string): string { @@ -372,11 +377,16 @@ export function ArtifactsPanel({ selectedScheduleId, selectedScheduleArtifact, onSelectScheduleArtifact, + connected, + send, + addMessageHandler, }: ArtifactsPanelProps) { const { t } = useLanguage(); const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [activeTab, setActiveTab] = useState<'artifacts' | 'logs'>('artifacts'); const totalCount = artifacts.length + (scheduleArtifacts?.length || 0); const hasBothSections = (scheduleArtifacts?.length || 0) > 0 && groups.length > 0; + const hasLogsSupport = !!(send && addMessageHandler); const toggleFileExpand = (filePath: string) => { setExpandedFiles(prev => { @@ -420,11 +430,23 @@ export function ArtifactsPanel({ )}
-
- - {t('artifacts.title')} - {totalCount > 0 && ( - {totalCount} +
+ + {hasLogsSupport && ( + )}
- {/* 定时任务分区 */} - {scheduleArtifacts && scheduleArtifacts.length > 0 && ( -
- {hasBothSections && ( -
定时任务
- )} -
- {scheduleArtifacts.map(sa => ( - { - onSelectArtifact(null); - onSelectScheduleArtifact?.(sa.id); - }} - /> - ))} -
-
+ {/* Logs Tab */} + {activeTab === 'logs' && hasLogsSupport && ( + )} - {/* 文件变更分区 */} - {groups.length === 0 && (!scheduleArtifacts || scheduleArtifacts.length === 0) ? ( -
-
📄
-
{t('artifacts.empty')}
-
- ) : groups.length > 0 ? ( -
- {hasBothSections && ( -
文件变更
+ {/* Artifacts Tab 内容 */} + {activeTab === 'artifacts' && ( + <> + {/* 定时任务分区 */} + {scheduleArtifacts && scheduleArtifacts.length > 0 && ( +
+ {hasBothSections && ( +
定时任务
+ )} +
+ {scheduleArtifacts.map(sa => ( + { + onSelectArtifact(null); + onSelectScheduleArtifact?.(sa.id); + }} + /> + ))} +
+
)} -
- {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/Terminal/LogsView.tsx b/src/web/client/src/components/Terminal/LogsView.tsx index cf6efec2..963f611e 100644 --- a/src/web/client/src/components/Terminal/LogsView.tsx +++ b/src/web/client/src/components/Terminal/LogsView.tsx @@ -22,6 +22,7 @@ interface LogEntry { interface LogsViewProps { active: boolean; // 当前是否是活跃 Tab panelVisible: boolean; // 面板是否可见 + connected: boolean; // WebSocket 是否已连接 send: (msg: any) => void; addMessageHandler: (handler: (msg: any) => void) => () => void; } @@ -57,7 +58,7 @@ function formatTime(ts: string): string { /** * LogsView 组件 */ -export function LogsView({ active, panelVisible, send, addMessageHandler }: LogsViewProps) { +export function LogsView({ active, panelVisible, connected, send, addMessageHandler }: LogsViewProps) { const [entries, setEntries] = useState([]); const [levelFilter, setLevelFilter] = useState('ALL'); const [autoScroll, setAutoScroll] = useState(true); @@ -70,29 +71,11 @@ export function LogsView({ active, panelVisible, send, addMessageHandler }: Logs ? entries : entries.filter(e => e.level === levelFilter.toLowerCase()); - // 初始化:请求初始数据 + 订阅实时更新 + // 先注册消息处理器,再发请求——避免竞态(响应早于 handler 注册) + // 依赖 connected:组件可能在 WebSocket 连接前就挂载,connected 变 true 时重新订阅 useEffect(() => { - // 请求最近 200 条日志 - send({ - type: 'logs:read', - payload: { count: 200 }, - }); - - // 订阅实时日志 - send({ - type: 'logs:subscribe', - }); - - // 组件卸载时取消订阅 - return () => { - send({ - type: 'logs:unsubscribe', - }); - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (!connected) return; - // 监听日志消息 - useEffect(() => { const unsubscribe = addMessageHandler((msg: any) => { if (msg.type === 'logs:data') { // 初始数据 @@ -102,10 +85,10 @@ export function LogsView({ active, panelVisible, send, addMessageHandler }: Logs const newEntries = msg.payload.entries || []; if (newEntries.length > 0) { setEntries(prev => { - // 合并新日志,去重(基于时间戳) + // 合并新日志,去重(基于时间戳+消息) const combined = [...prev, ...newEntries]; const unique = Array.from( - new Map(combined.map(e => [e.ts, e])).values() + new Map(combined.map(e => [`${e.ts}:${e.msg}`, e])).values() ); // 保留最近 1000 条 return unique.slice(-1000); @@ -114,8 +97,15 @@ export function LogsView({ active, panelVisible, send, addMessageHandler }: Logs } }); - return unsubscribe; - }, [addMessageHandler]); + // handler 注册后再发请求 + send({ type: 'logs:read', payload: { count: 200 } }); + send({ type: 'logs:subscribe' }); + + return () => { + unsubscribe(); + send({ type: 'logs:unsubscribe' }); + }; + }, [addMessageHandler, send, connected]); // 自动滚动到底部 useEffect(() => { diff --git a/src/web/client/src/components/Terminal/TerminalPanel.tsx b/src/web/client/src/components/Terminal/TerminalPanel.tsx index 5d1bda64..76dabe24 100644 --- a/src/web/client/src/components/Terminal/TerminalPanel.tsx +++ b/src/web/client/src/components/Terminal/TerminalPanel.tsx @@ -662,6 +662,7 @@ export function TerminalPanel({ diff --git a/src/web/client/src/hooks/useMessageHandler.ts b/src/web/client/src/hooks/useMessageHandler.ts index 3238fef1..e9c56cf3 100644 --- a/src/web/client/src/hooks/useMessageHandler.ts +++ b/src/web/client/src/hooks/useMessageHandler.ts @@ -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/server/conversation.ts b/src/web/server/conversation.ts index dea15491..f2a45dcb 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'; @@ -1126,6 +1127,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 +1162,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 +1260,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 +1571,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 +1667,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 +1793,15 @@ export class ConversationManager { } } + // SelfEvolve:会话已完整持久化,现在安全触发 gracefulShutdown + // 延迟 200ms 让 WebSocket 有机会推送最后的工具结果给前端 + if (isEvolveRestartRequested()) { + console.log('[ConversationManager] Session persisted, triggering graceful shutdown for evolve restart...'); + setTimeout(() => { + triggerGracefulShutdown(); + }, 200); + } + } /** diff --git a/src/web/server/index.ts b/src/web/server/index.ts index 5e11745a..f8b839a7 100644 --- a/src/web/server/index.ts +++ b/src/web/server/index.ts @@ -17,6 +17,7 @@ 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 { requestEvolveRestart, isEvolveEnabled, @@ -45,6 +46,12 @@ export interface WebServerResult { } export async function startWebServer(options: WebServerOptions = {}): Promise { + // 初始化运行时日志系统 — 拦截所有 console 输出并持久化到 ~/.claude/runtime.log + logger.init({ + interceptConsole: true, + minLevel: 'info', + }); + // 设置 CLAUDE_CODE_ENTRYPOINT 环境变量(如果未设置) // 官方 Claude Code 使用此变量标识启动入口点 // WebUI 模式使用 'claude-vscode' 以匹配官方的 VSCode 扩展入口 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); From 7e4ff3cabcbf9d05a1651093da6971dd7ddd414c Mon Sep 17 00:00:00 2001 From: Worker Date: Sun, 22 Feb 2026 11:16:40 +0800 Subject: [PATCH 3/7] feat(app): add dedicated logs panel and refactor UI layout Move logs view from artifacts panel tab to independent side panel for better UX separation. Add new showLogsPanel state with proper panel toggle logic and mutual exclusion with git and artifacts panels. Remove logs-related props from ArtifactsPanel and update styling accordingly. --- src/web/client/src/App.tsx | 46 +++++-- .../ArtifactsPanel/ArtifactsPanel.css | 31 ----- .../ArtifactsPanel/ArtifactsPanel.tsx | 50 +------- src/web/client/src/components/InputArea.tsx | 31 ++++- src/web/client/src/styles/index.css | 117 ++++++++++++++++-- 5 files changed, 171 insertions(+), 104 deletions(-) diff --git a/src/web/client/src/App.tsx b/src/web/client/src/App.tsx index 7fde0ab0..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} @@ -522,9 +532,6 @@ function AppContent({ selectedScheduleId={scheduleState.selectedScheduleId} selectedScheduleArtifact={scheduleState.selectedScheduleArtifact} onSelectScheduleArtifact={scheduleState.setSelectedScheduleId} - connected={connected} - send={send} - addMessageHandler={addMessageHandler} /> )} @@ -538,6 +545,25 @@ function AppContent({ projectPath={currentProjectPath} /> )} + + {/* 右侧:日志面板 */} + {showLogsPanel && ( +
+
+ Logs + +
+ +
+ )}
)} diff --git a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css index a9957b90..94cdb98e 100644 --- a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css +++ b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.css @@ -54,37 +54,6 @@ opacity: 0.7; } -/* Tab 切换按钮组 */ -.artifacts-panel-tabs { - display: flex; - align-items: center; - gap: 2px; -} - -.artifacts-tab-btn { - background: none; - border: none; - padding: 6px 12px; - font-size: 13px; - font-weight: 500; - color: var(--text-muted); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: color 0.15s, border-color 0.15s; - display: flex; - align-items: center; - gap: 6px; -} - -.artifacts-tab-btn:hover { - color: var(--text-secondary); -} - -.artifacts-tab-btn.active { - color: var(--accent-primary); - border-bottom-color: var(--accent-primary); -} - .artifacts-panel-badge { font-size: 11px; font-weight: 500; diff --git a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx index 37dc9545..406ee1c3 100644 --- a/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx +++ b/src/web/client/src/components/ArtifactsPanel/ArtifactsPanel.tsx @@ -4,7 +4,6 @@ import type { FileArtifact, ArtifactGroup } from '../../hooks/useArtifacts'; import type { ScheduleArtifact } from '../../hooks/useScheduleArtifacts'; import { computeSideBySideDiff } from '../../utils/diffUtils'; import { useLanguage } from '../../i18n'; -import { LogsView } from '../Terminal/LogsView'; import './ArtifactsPanel.css'; interface ArtifactsPanelProps { @@ -18,10 +17,6 @@ interface ArtifactsPanelProps { selectedScheduleId?: string | null; selectedScheduleArtifact?: ScheduleArtifact | null; onSelectScheduleArtifact?: (id: string | null) => void; - // 日志 Tab 相关 - connected?: boolean; - send?: (msg: any) => void; - addMessageHandler?: (handler: (msg: any) => void) => () => void; } function getFileName(filePath: string): string { @@ -377,16 +372,11 @@ export function ArtifactsPanel({ selectedScheduleId, selectedScheduleArtifact, onSelectScheduleArtifact, - connected, - send, - addMessageHandler, }: ArtifactsPanelProps) { const { t } = useLanguage(); const [expandedFiles, setExpandedFiles] = useState>(new Set()); - const [activeTab, setActiveTab] = useState<'artifacts' | 'logs'>('artifacts'); const totalCount = artifacts.length + (scheduleArtifacts?.length || 0); const hasBothSections = (scheduleArtifacts?.length || 0) > 0 && groups.length > 0; - const hasLogsSupport = !!(send && addMessageHandler); const toggleFileExpand = (filePath: string) => { setExpandedFiles(prev => { @@ -430,23 +420,11 @@ export function ArtifactsPanel({ )}
-
- - {hasLogsSupport && ( - +
+ + {t('artifacts.title')} + {totalCount > 0 && ( + {totalCount} )}
- {/* Logs Tab */} - {activeTab === 'logs' && hasLogsSupport && ( - - )} - - {/* Artifacts Tab 内容 */} - {activeTab === 'artifacts' && ( - <> - {/* 定时任务分区 */} + {/* 定时任务分区 */} {scheduleArtifacts && scheduleArtifacts.length > 0 && (
{hasBothSections && ( @@ -570,8 +534,6 @@ export function ArtifactsPanel({
) : null} - - )}
); } 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 && (