diff --git a/.gitignore b/.gitignore
index aa1909a2..7328085f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,11 @@ dist/
__tests__/*
# Video generation intermediate files
demo-screenshots/video-frames/
+demo-screenshots/*.mp4
+demo-screenshots/*.gif
+
+# Skill template assets (large binary files)
+.claude/skills/promo-video/templates/
# Windows: git-bash 中 > nul 会创建字面文件 nul(cmd.exe 的空设备名)
nul
\ No newline at end of file
diff --git a/README.md b/README.md
index 80045a9f..31df1c5d 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
[](LICENSE)
[](https://nodejs.org)
-[Visit Website](https://www.chatbi.site) | [Live Demo](http://voicegpt.site:3456/) | [中文文档](README.zh-CN.md) | [Discord](https://discord.gg/bNyJKk6PVZ)
+[Visit Website](https://www.chatbi.site) | [Live Demo](http://voicegpt.site:3456/) | [中文文档](README.zh-CN.md) | [操作手册](https://www.chatbi.site/zh/user-guide.html) | [Discord](https://discord.gg/bNyJKk6PVZ)
(null);
const { t } = useLanguage();
const output = result?.output || result?.error || t('cli.noOutput');
const allLines = output.split('\n');
@@ -142,6 +149,23 @@ function BashToolContent({ input, result }: { input: any; result?: any }) {
const displayOutput = expanded ? output : allLines.slice(0, maxLines).join('\n');
+ const isRunning = status === 'running';
+ const hasStreamingOutput = isRunning && streamingOutput;
+
+ // 自动滚动到实时输出底部
+ useEffect(() => {
+ if (streamingRef.current) {
+ streamingRef.current.scrollTop = streamingRef.current.scrollHeight;
+ }
+ }, [streamingOutput]);
+
+ // 限制实时输出显示的行数(只显示最后 N 行,避免 DOM 过大)
+ const streamingLines = streamingOutput?.split('\n') || [];
+ const maxStreamingLines = 20;
+ const displayStreaming = streamingLines.length > maxStreamingLines
+ ? streamingLines.slice(-maxStreamingLines).join('\n')
+ : streamingOutput || '';
+
return (
{input?.command && (
@@ -150,6 +174,19 @@ function BashToolContent({ input, result }: { input: any; result?: any }) {
{input.command}
)}
+ {/* 实时输出(前台执行中) */}
+ {hasStreamingOutput && (
+
+
LIVE
+
+ {displayStreaming}
+
+
+ )}
+ {/* 最终结果(执行完成后) */}
{result && (
{t('cli.outputLabel')}
@@ -890,7 +927,7 @@ function CliSubagentTool({ toolCall, index }: { toolCall: SubagentToolCall; inde
export function CliToolCall({ toolUse }: CliToolCallProps) {
const [collapsed, setCollapsed] = useState(false);
const { t } = useLanguage();
- const { name, input, status, result, subagentToolCalls, toolUseCount, lastToolInfo } = toolUse;
+ const { name, input, status, result, subagentToolCalls, toolUseCount, lastToolInfo, streamingOutput } = toolUse;
const toolName = CLI_TOOL_NAMES[name] || name;
const description = getToolDescription(name, input);
@@ -913,7 +950,7 @@ export function CliToolCall({ toolUse }: CliToolCallProps) {
const renderToolContent = () => {
switch (name) {
case 'Bash':
- return ;
+ return ;
case 'Edit':
return ;
case 'Write':
diff --git a/src/web/client/src/hooks/useMessageHandler.ts b/src/web/client/src/hooks/useMessageHandler.ts
index a037f351..88d75069 100644
--- a/src/web/client/src/hooks/useMessageHandler.ts
+++ b/src/web/client/src/hooks/useMessageHandler.ts
@@ -460,7 +460,7 @@ export function useMessageHandler({
case 'session_deleted':
if (payload.success) {
const deletedId = payload.sessionId as string;
- if (deletedId === sessionId) {
+ if (deletedId === sessionIdRef.current) {
setMessages([]);
}
}
@@ -555,6 +555,39 @@ export function useMessageHandler({
break;
}
+ // Bash 前台执行实时输出 — 追加到对应 tool_use 的 streamingOutput
+ case 'bash:foreground-output': {
+ const toolUseId = payload.toolUseId as string;
+ const data = payload.data as string;
+ if (!toolUseId || !data) break;
+
+ // 优先更新 currentMessageRef(当前正在构建的消息)
+ if (currentMessageRef.current) {
+ const currentMsg = currentMessageRef.current;
+ const idx = currentMsg.content.findIndex(
+ c => c.type === 'tool_use' && c.id === toolUseId && c.status === 'running'
+ );
+ if (idx !== -1) {
+ const item = currentMsg.content[idx];
+ if (item.type === 'tool_use') {
+ const newContent = [...currentMsg.content];
+ newContent[idx] = {
+ ...item,
+ streamingOutput: (item.streamingOutput || '') + data,
+ };
+ const updatedMsg = { ...currentMsg, content: newContent };
+ currentMessageRef.current = updatedMsg;
+ setMessages(prev => {
+ const filtered = prev.filter(m => m.id !== currentMsg.id);
+ return [...filtered, updatedMsg];
+ });
+ }
+ break;
+ }
+ }
+ break;
+ }
+
case 'subagent_tool_start': {
if (!payload.taskId || !payload.toolCall) break;
@@ -688,35 +721,41 @@ export function useMessageHandler({
const scheduleTriggerAt = payload.triggerAt as number || 0;
// 查找对应的 ScheduleTask tool_use
- const findAndUpdateScheduleTool = (msg: ChatMessage) => {
- const tool = msg.content.find(
- c => c.type === 'tool_use' && c.name === 'ScheduleTask'
- );
- if (tool && tool.type === 'tool_use') {
- tool.scheduleCountdown = {
- triggerAt: scheduleTriggerAt,
- remainingMs,
- phase,
- taskName: scheduleTaskName,
- };
- return true;
- }
- return false;
+ const countdownData = {
+ triggerAt: scheduleTriggerAt,
+ remainingMs,
+ phase,
+ taskName: scheduleTaskName,
};
+ const cloneWithCountdown = (msg: ChatMessage): ChatMessage => {
+ const newContent = msg.content.map(c => {
+ if (c.type === 'tool_use' && c.name === 'ScheduleTask') {
+ return { ...c, scheduleCountdown: countdownData };
+ }
+ return c;
+ });
+ return { ...msg, content: newContent };
+ };
+
+ const hasScheduleTool = (msg: ChatMessage) =>
+ msg.content.some(c => c.type === 'tool_use' && c.name === 'ScheduleTask');
+
let targetMsg = currentMessageRef.current;
- if (targetMsg && findAndUpdateScheduleTool(targetMsg)) {
+ if (targetMsg && hasScheduleTool(targetMsg)) {
+ const updated = cloneWithCountdown(targetMsg);
+ currentMessageRef.current = updated;
setMessages(prev => {
const filtered = prev.filter(m => m.id !== targetMsg!.id);
- return [...filtered, { ...targetMsg! }];
+ return [...filtered, updated];
});
} else {
setMessages(prev => {
for (let i = prev.length - 1; i >= 0; i--) {
const msg = prev[i];
if (msg.role !== 'assistant') continue;
- if (findAndUpdateScheduleTool(msg)) {
- return [...prev.slice(0, i), { ...msg }, ...prev.slice(i + 1)];
+ if (hasScheduleTool(msg)) {
+ return [...prev.slice(0, i), cloneWithCountdown(msg), ...prev.slice(i + 1)];
}
}
return prev;
@@ -968,10 +1007,12 @@ export function useMessageHandler({
case 'execution:report':
console.log('[App] Execution report:', (payload as any).status, (payload as any).summary?.substring(0, 100));
- addMessageHandler?.({
+ setMessages(prev => [...prev, {
+ id: `exec-report-${Date.now()}`,
role: 'assistant',
- content: (payload as any).message || '执行完成',
- } as any);
+ timestamp: Date.now(),
+ content: [{ type: 'text' as const, text: (payload as any).message || '执行完成' }],
+ }]);
break;
}
});
diff --git a/src/web/client/src/hooks/useWebSocket.ts b/src/web/client/src/hooks/useWebSocket.ts
index 2da2c2ad..7fb96275 100644
--- a/src/web/client/src/hooks/useWebSocket.ts
+++ b/src/web/client/src/hooks/useWebSocket.ts
@@ -101,6 +101,7 @@ export function useWebSocket(url: string): UseWebSocketReturn {
setSessionId(payload.sessionId);
}
setModel(payload.model);
+ setSessionReady(true);
}
// 接收后端推送的 skills 列表,更新到斜杠命令补全中
@@ -254,5 +255,5 @@ export function useWebSocket(url: string): UseWebSocketReturn {
}
}, []);
- return { connected, sessionId, model, setModel: handleModelChange, send, addMessageHandler };
+ return { connected, sessionReady, sessionId, model, setModel: handleModelChange, send, addMessageHandler };
}
diff --git a/src/web/client/src/types.ts b/src/web/client/src/types.ts
index 8d54d49b..1ca1e1d4 100644
--- a/src/web/client/src/types.ts
+++ b/src/web/client/src/types.ts
@@ -224,6 +224,8 @@ export interface ToolUse {
toolUseCount?: number;
/** 最后执行的工具信息(Task / ScheduleTask 使用) */
lastToolInfo?: string;
+ /** Bash 前台执行时的实时流式输出 */
+ streamingOutput?: string;
/** 定时任务倒计时信息(仅 ScheduleTask 使用) */
scheduleCountdown?: {
triggerAt: number;
diff --git a/src/web/server/conversation.ts b/src/web/server/conversation.ts
index 39deef1a..de013358 100644
--- a/src/web/server/conversation.ts
+++ b/src/web/server/conversation.ts
@@ -445,6 +445,8 @@ interface SessionState {
thinkingText: string;
textContent: string;
};
+ /** 上次创建 client 时的凭据指纹(用于检测认证变更后自动重建客户端) */
+ credentialsFingerprint?: string;
}
/**
@@ -694,6 +696,29 @@ export class ConversationManager {
return [];
}
+ /**
+ * 计算凭据指纹,用于检测认证变更
+ */
+ private getCredentialsFingerprint(): string {
+ const creds = webAuth.getCredentials();
+ // 用凭据的关键字段拼接成指纹,任何变更都会导致指纹不同
+ return `${creds.apiKey || ''}\0${creds.authToken || ''}\0${creds.baseUrl || ''}`;
+ }
+
+ /**
+ * 检查凭据是否变更,如果变更则重建客户端
+ * 处理场景:用户在 WebUI 重新登录/切换 API Key 后,已有会话自动使用新凭据
+ */
+ private ensureClientCredentialsFresh(state: SessionState): void {
+ const currentFingerprint = this.getCredentialsFingerprint();
+ if (state.credentialsFingerprint && state.credentialsFingerprint !== currentFingerprint) {
+ console.log('[ConversationManager] 检测到认证凭据变更,重建客户端');
+ const newConfig = this.buildClientConfig(state.model);
+ state.client = new ClaudeClient({ ...newConfig });
+ state.credentialsFingerprint = currentFingerprint;
+ }
+ }
+
/**
* 构建 ClaudeClient 配置
* 认证全部委托给 webAuth(唯一认证入口)
@@ -747,6 +772,7 @@ export class ConversationManager {
console.log('[ConversationManager] OAuth API Key 已自动创建,重新构建客户端');
const newConfig = this.buildClientConfig(state.model);
state.client = new ClaudeClient({ ...newConfig });
+ state.credentialsFingerprint = this.getCredentialsFingerprint();
} else {
console.warn('[ConversationManager] createOAuthApiKey 返回 null,推理可能失败');
}
@@ -769,6 +795,7 @@ export class ConversationManager {
if (tokenBefore !== tokenAfter) {
const newConfig = this.buildClientConfig(state.model);
state.client = new ClaudeClient({ ...newConfig });
+ state.credentialsFingerprint = this.getCredentialsFingerprint();
console.log('[ConversationManager] 客户端已使用刷新后的 OAuth 凭证');
}
}
@@ -841,6 +868,7 @@ export class ConversationManager {
lastActualInputTokens: 0,
messagesLenAtLastApiCall: 0,
lastPersistedMessageCount: 0,
+ credentialsFingerprint: this.getCredentialsFingerprint(),
};
this.sessions.set(sessionId, state);
@@ -1268,6 +1296,9 @@ export class ConversationManager {
// 初始化流式中间内容追踪(用于浏览器刷新后恢复)
state.streamingContent = { thinkingText: '', textContent: '' };
+ // 凭据变更检测(处理用户在 WebUI 重新登录后已有会话自动使用新凭据)
+ this.ensureClientCredentialsFresh(state);
+
// OAuth Token 自动刷新检查(在调用 API 之前)
try {
await this.ensureValidOAuthToken(state);
@@ -1457,8 +1488,15 @@ export class ConversationManager {
const contextWindow = getContextWindowSize(resolvedModel);
const blockingLimit = contextWindow - 3000;
- // 混合估算(复用上面计算的 hybridTokens,若为 0 则 fallback 纯估算)
- const tokensToCheck = hybridTokens > 0 ? hybridTokens : this.estimateMessageTokens(cleanedMessages);
+ // 混合估算:如果刚执行过 autoCompact,hybridTokens 是过时的(基于压缩前的数据),
+ // 需要重新计算。通过检查 justAutoCompacted 标志来判断。
+ let tokensToCheck: number;
+ if (justAutoCompacted || hybridTokens <= 0) {
+ // autoCompact 后 hybridTokens 过时,使用纯估算
+ tokensToCheck = this.estimateMessageTokens(cleanedMessages);
+ } else {
+ tokensToCheck = hybridTokens;
+ }
if (tokensToCheck >= blockingLimit) {
console.error(`[ConversationManager] 消息 token (${tokensToCheck.toLocaleString()}) 已达到 blocking limit (${blockingLimit.toLocaleString()}),无法继续对话`);
@@ -1831,7 +1869,11 @@ export class ConversationManager {
break;
}
}
- const compactResult = await this.performAutoCompact(state.messages, resolvedModel, state);
+ // 只传入需要压缩的部分(排除 forceKeepMsgs),与正常 autoCompact 逻辑一致
+ const messagesForCompact = forceKeepMsgs.length > 0
+ ? state.messages.slice(0, state.messages.length - forceKeepMsgs.length)
+ : state.messages;
+ const compactResult = await this.performAutoCompact(messagesForCompact, resolvedModel, state);
if (compactResult.wasCompacted) {
state.messages = [...compactResult.messages, ...forceKeepMsgs];
state.lastActualInputTokens = 0;
@@ -2444,7 +2486,8 @@ export class ConversationManager {
}
}
- // 非 inline create:走正常工具执行,但事后注入 sessionId 并通知 WebScheduler
+ // 非 inline create:直接执行工具,事后注入 sessionId 并通知 WebScheduler
+ // 注意:不能调用 this.executeTool(),否则会无限递归回到这里
if (input.action === 'create') {
// 执行前记录已有任务 ID,执行后取差集找到新建的任务
let existingIds: Set | null = null;
@@ -2453,7 +2496,19 @@ export class ConversationManager {
existingIds = new Set(preStore.listTasks().map((t: any) => t.id));
} catch { /* ignore */ }
- const result = await this.executeTool(toolUse, state, callbacks);
+ const scheduleTool = toolRegistry.get('ScheduleTask');
+ if (!scheduleTool) {
+ const error = 'ScheduleTask tool not found in registry';
+ callbacks.onToolResult?.(toolUse.id, false, undefined, error);
+ return { success: false, error };
+ }
+ const rawResult = await scheduleTool.execute(toolUse.input);
+ const result = typeof rawResult === 'object' && rawResult !== null
+ ? rawResult as { success: boolean; output?: string; error?: string }
+ : { success: true, output: String(rawResult) };
+
+ // 通知前端工具结果
+ callbacks.onToolResult?.(toolUse.id, result.success, result.output, result.error);
// 用差集精确找到新创建的任务
if (result.success && existingIds) {
diff --git a/src/web/server/index.ts b/src/web/server/index.ts
index 7d86137a..06c2994d 100644
--- a/src/web/server/index.ts
+++ b/src/web/server/index.ts
@@ -65,9 +65,7 @@ export async function startWebServer(options: WebServerOptions = {}): Promise {
try {
- const tasks = store.listTasks();
+ const tasks = getStore().listTasks();
res.json({ success: true, data: tasks });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -75,11 +82,13 @@ router.post('/tasks', (req, res) => {
taskData.watchPaths = watchPaths;
}
- // 调用 store.addTask(它会自动保存到磁盘)
- // 注意:addTask 会自己生成 id 和 createdAt,但我们已经提供了,所以直接使用内部逻辑
- store.reload(); // 先重新加载最新数据
+ const store = getStore();
const created = store.addTask(taskData);
- store.signalReload(); // 通知 daemon 重新加载
+ store.signalReload();
+
+ // 通知 WebScheduler 热加载
+ const ws = getWebScheduler();
+ if (ws) ws.onTaskCreated();
// 广播任务创建事件
broadcastMessage({
@@ -97,7 +106,7 @@ router.post('/tasks', (req, res) => {
// GET /api/schedule/tasks/:id - 获取单个任务
router.get('/tasks/:id', (req, res) => {
try {
- const task = store.getTask(req.params.id);
+ const task = getStore().getTask(req.params.id);
if (!task) {
return res.status(404).json({ success: false, error: 'Task not found' });
}
@@ -111,12 +120,17 @@ router.get('/tasks/:id', (req, res) => {
// DELETE /api/schedule/tasks/:id - 删除任务
router.delete('/tasks/:id', (req, res) => {
try {
+ const store = getStore();
const removed = store.removeTask(req.params.id);
if (!removed) {
return res.status(404).json({ success: false, error: 'Task not found' });
}
store.signalReload();
+ // 通知 WebScheduler 热加载
+ const ws = getWebScheduler();
+ if (ws) ws.onTaskCreated();
+
// 广播任务删除事件
broadcastMessage({
type: 'schedule:task_deleted',
@@ -133,7 +147,7 @@ router.delete('/tasks/:id', (req, res) => {
// POST /api/schedule/tasks/:id/toggle - 启用/禁用切换
router.post('/tasks/:id/toggle', (req, res) => {
try {
- store.reload(); // 获取最新数据
+ const store = getStore();
const task = store.getTask(req.params.id);
if (!task) {
return res.status(404).json({ success: false, error: 'Task not found' });
@@ -159,6 +173,10 @@ router.post('/tasks/:id/toggle', (req, res) => {
}
store.signalReload();
+ // 通知 WebScheduler 热加载
+ const ws = getWebScheduler();
+ if (ws) ws.onTaskCreated();
+
// 广播任务更新事件
const updatedTask = store.getTask(req.params.id);
if (updatedTask) {
@@ -178,7 +196,7 @@ router.post('/tasks/:id/toggle', (req, res) => {
// POST /api/schedule/tasks/:id/run-now - 立即执行任务
router.post('/tasks/:id/run-now', (req, res) => {
try {
- store.reload(); // 获取最新数据
+ const store = getStore();
const task = store.getTask(req.params.id);
if (!task) {
return res.status(404).json({ success: false, error: 'Task not found' });
@@ -197,6 +215,10 @@ router.post('/tasks/:id/run-now', (req, res) => {
store.signalReload();
+ // 通知 WebScheduler 热加载
+ const ws = getWebScheduler();
+ if (ws) ws.onTaskCreated();
+
// 广播任务更新事件
const updatedTask = store.getTask(req.params.id);
if (updatedTask) {
@@ -216,7 +238,7 @@ router.post('/tasks/:id/run-now', (req, res) => {
// PATCH /api/schedule/tasks/:id - 更新任务
router.patch('/tasks/:id', (req, res) => {
try {
- store.reload(); // 获取最新数据
+ const store = getStore();
const task = store.getTask(req.params.id);
if (!task) {
return res.status(404).json({ success: false, error: 'Task not found' });
@@ -305,6 +327,10 @@ router.patch('/tasks/:id', (req, res) => {
store.signalReload();
+ // 通知 WebScheduler 热加载
+ const ws = getWebScheduler();
+ if (ws) ws.onTaskCreated();
+
// 广播任务更新事件
const updatedTask = store.getTask(req.params.id);
if (updatedTask) {
diff --git a/src/web/server/session-manager.ts b/src/web/server/session-manager.ts
index 4cb36ae3..a90883cc 100644
--- a/src/web/server/session-manager.ts
+++ b/src/web/server/session-manager.ts
@@ -47,9 +47,14 @@ export class WebSessionManager {
private sessions = new Map();
private cwd: string;
+ private cleanupTimer: ReturnType | null = null;
+
constructor(cwd: string) {
this.cwd = cwd;
this.ensureSessionDir();
+ // 定期清理内存中过期的会话缓存(每 5 分钟)
+ this.cleanupTimer = setInterval(() => this.cleanupMemoryCache(), 5 * 60 * 1000);
+ this.cleanupTimer.unref();
}
/**
diff --git a/src/web/server/task-manager.ts b/src/web/server/task-manager.ts
index 64ea06dd..6454b662 100644
--- a/src/web/server/task-manager.ts
+++ b/src/web/server/task-manager.ts
@@ -446,7 +446,7 @@ export class TaskManager {
// 构建 LoopOptions(对齐 CLI agent.ts 的 executeAgentLoop)
const loopOptions: LoopOptions = {
model: resolvedModel,
- maxTurns: agentDef.maxTurns || 30,
+ maxTurns: agentDef.maxTurns || 100,
verbose: process.env.CLAUDE_VERBOSE === 'true',
permissionMode: agentDef.permissionMode || 'default',
allowedTools: effectiveTools,
diff --git a/src/web/server/web-auth.ts b/src/web/server/web-auth.ts
index 9ef9c058..f63b7550 100644
--- a/src/web/server/web-auth.ts
+++ b/src/web/server/web-auth.ts
@@ -91,10 +91,13 @@ class WebAuthProvider {
// 检查是否有 OAuth 配置
const config = oauthManager.getOAuthConfig();
- if (!config?.accessToken) return true; // 没有 OAuth 配置,交给后续报错
+ if (!config) return true; // 完全没有 OAuth 配置,交给后续报错
- // token 未过期(5 分钟缓冲)
- if (!oauthManager.isTokenExpired()) return true;
+ // accessToken 为空但有 refreshToken(上次刷新失败后保留的)→ 需要尝试刷新
+ const needsRefreshFromEmpty = !config.accessToken && config.refreshToken;
+
+ // token 未过期(5 分钟缓冲)且 accessToken 存在
+ if (!needsRefreshFromEmpty && !oauthManager.isTokenExpired()) return true;
// 需要刷新 — 使用并发锁
if (this.refreshPromise) {
@@ -345,6 +348,9 @@ class WebAuthProvider {
const oauthConfig = oauthManager.getOAuthConfig();
if (!oauthConfig) return {};
+ // accessToken 为空(刷新失败后清除了)则视为无凭证
+ if (!oauthConfig.accessToken) return {};
+
const hasInferenceScope = oauthConfig.scopes?.includes('user:inference');
if (hasInferenceScope) {
return { authToken: oauthConfig.accessToken };
diff --git a/src/web/server/web-scheduler.ts b/src/web/server/web-scheduler.ts
index f4036013..9ca0f35d 100644
--- a/src/web/server/web-scheduler.ts
+++ b/src/web/server/web-scheduler.ts
@@ -39,6 +39,16 @@ function errorBackoffMs(consecutiveErrors: number): number {
return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)];
}
+// =========================================================================
+// 全局单例 getter(供 ScheduleTask 工具等共享 store,避免多实例竞态)
+// =========================================================================
+
+let _instance: WebScheduler | null = null;
+
+export function getWebScheduler(): WebScheduler | null {
+ return _instance;
+}
+
export class WebScheduler {
private timer: NodeJS.Timeout | null = null;
private reloadTimer: NodeJS.Timeout | null = null;
@@ -62,6 +72,14 @@ export class WebScheduler {
this.broadcastFn = options.broadcastMessage;
this.defaultModel = options.defaultModel;
this.cwd = options.cwd;
+ _instance = this;
+ }
+
+ /**
+ * 获取 TaskStore 实例(供 ScheduleTask 工具等共享,避免多实例竞态)
+ */
+ getStore(): TaskStore {
+ return this.store;
}
// =========================================================================
diff --git a/src/web/server/websocket-git-handlers.ts b/src/web/server/websocket-git-handlers.ts
index fb223151..3dc3eaae 100644
--- a/src/web/server/websocket-git-handlers.ts
+++ b/src/web/server/websocket-git-handlers.ts
@@ -409,6 +409,34 @@ async function aiRequest(conversationManager: ConversationManager, prompt: strin
return '';
}
+/**
+ * 清理 AI 返回的 commit message,去掉多余的分析、markdown 标记等
+ */
+function cleanCommitMessage(raw: string): string {
+ let msg = raw.trim();
+
+ // 去掉 markdown 代码围栏
+ msg = msg.replace(/^```[\s\S]*?\n([\s\S]*?)\n```$/gm, '$1').trim();
+
+ // 如果包含 "---" 分隔符,提取其中的内容(AI 常用格式)
+ const dashMatch = msg.match(/---\s*\n([\s\S]*?)\n\s*---/);
+ if (dashMatch) {
+ msg = dashMatch[1].trim();
+ }
+
+ // 去掉开头的 "Here's the commit message:" 类前缀
+ msg = msg.replace(/^(?:Here(?:'s| is) (?:the|my|a) commit message[:\s]*\n*)/i, '').trim();
+
+ // 去掉 markdown 格式符号(**bold**、*italic*)
+ msg = msg.replace(/\*\*(.*?)\*\*/g, '$1');
+ msg = msg.replace(/\*(.*?)\*/g, '$1');
+
+ // 确保不以空行开头
+ msg = msg.replace(/^\n+/, '');
+
+ return msg;
+}
+
/**
* 获取 diff 内容的辅助函数
*/
@@ -447,18 +475,20 @@ export async function handleGitSmartCommit(
return;
}
- const message = await aiRequest(conversationManager, `分析以下 git diff,生成一条高质量的 commit message。
+ const rawMessage = await aiRequest(conversationManager, `You are a commit message generator. Output ONLY the commit message, nothing else. No analysis, no explanation, no markdown, no code fences, no "---" separators.
-要求:
-- 第一行是简短的摘要(不超过 72 字符),使用英文
-- 格式:type(scope): description
-- type 可选:feat, fix, refactor, docs, style, test, chore, perf
-- 如果改动复杂,可以空一行后添加详细说明
-- 只输出纯文本 commit message,不要包含 markdown 格式符号(如反引号、星号等)
+Rules:
+- First line: type(scope): description (max 72 chars, English)
+- type: feat|fix|refactor|docs|style|test|chore|perf
+- If complex, add a blank line then a short body paragraph
+- Output the commit message directly. Do NOT include any preamble like "Here's the commit message:" or analysis
Diff:
${diff.substring(0, 8000)}`);
+ // 后处理:清理 AI 可能输出的多余内容
+ const message = cleanCommitMessage(rawMessage);
+
sendMessage(client.ws, {
type: 'git:smart_commit_response',
payload: { success: true, message, needsStaging },
diff --git a/src/web/server/websocket.ts b/src/web/server/websocket.ts
index ed53c34a..0c1087cc 100644
--- a/src/web/server/websocket.ts
+++ b/src/web/server/websocket.ts
@@ -293,6 +293,9 @@ export function setupWebSocket(
// 监听 RealtimeCoordinator 执行事件 (v2.0 新架构)
// ============================================================================
+ // 防止 setupWebSocket 多次调用时监听器累积
+ executionEventEmitter.removeAllListeners();
+
// Worker 状态更新
executionEventEmitter.on('worker:update', (data: { blueprintId: string; workerId: string; updates: any }) => {
console.log(`[Swarm v2.0] Worker update: ${data.workerId} for blueprint ${data.blueprintId}`);
@@ -1177,13 +1180,15 @@ export function setupWebSocket(
fixAttempts: result.fixAttempts || [],
};
- // v4.8: 更新 E2E 测试状态(测试完成后保留结果,不立即删除)
+ // v4.8: 更新 E2E 测试状态(测试完成后保留结果,延迟清理)
activeE2EState.set(data.blueprintId, {
status: finalStatus,
message: finalMessage,
e2eTaskId,
result: finalResult,
});
+ // 30 分钟后自动清理,避免内存泄漏
+ setTimeout(() => activeE2EState.delete(data.blueprintId), 30 * 60 * 1000).unref();
broadcastToSubscribers(data.blueprintId, {
type: 'swarm:verification_update',
@@ -1208,6 +1213,8 @@ export function setupWebSocket(
message: `E2E 测试执行失败: ${error.message}`,
e2eTaskId,
});
+ // 30 分钟后自动清理
+ setTimeout(() => activeE2EState.delete(data.blueprintId), 30 * 60 * 1000).unref();
broadcastToSubscribers(data.blueprintId, {
type: 'swarm:verification_update',
@@ -1337,9 +1344,17 @@ export function setupWebSocket(
clients.delete(clientId);
});
- // 处理错误
+ // 处理错误(执行与 close 相同的清理,防止 error 后不触发 close 的情况)
ws.on('error', (error) => {
console.error(`[WebSocket] 客户端错误 ${clientId}:`, error);
+ cleanupClientSubscriptions(clientId);
+ const terminals = clientTerminals.get(clientId);
+ if (terminals) {
+ for (const termId of terminals) {
+ terminalManager.destroy(termId);
+ }
+ clientTerminals.delete(clientId);
+ }
clients.delete(clientId);
});
});
@@ -1534,7 +1549,7 @@ async function handleClientMessage(
payload: { success: result.success, result, messages: updatedMessages },
});
} catch (err: any) {
- sendMessage(client.ws, { type: 'error', payload: { message: `Rewind 执行失败: ${err.message}` } });
+ sendMessage(client.ws, { type: 'error', payload: { message: `Rewind 执行失败: ${err.message}`, source: 'rewind' } });
}
break;
diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts
index c890d8fd..80ea36cc 100644
--- a/src/web/shared/types.ts
+++ b/src/web/shared/types.ts
@@ -265,7 +265,7 @@ export type ServerMessage =
| { type: 'message_complete'; payload: MessageCompletePayload }
| { type: 'context_update'; payload: ContextUpdatePayload }
| { type: 'context_compact'; payload: ContextCompactPayload }
- | { type: 'error'; payload: { message: string; code?: string; sessionId?: string } }
+ | { type: 'error'; payload: { message: string; code?: string; sessionId?: string; source?: string } }
| { type: 'thinking_start'; payload: { messageId: string; sessionId?: string } }
| { type: 'thinking_delta'; payload: { messageId: string; text: string; sessionId?: string } }
| { type: 'thinking_complete'; payload: { messageId: string; sessionId?: string } }