Skip to content

Commit 60813ad

Browse files
author
Smoke Tester
committed
sse客户端第二版
1 parent 0209939 commit 60813ad

21 files changed

Lines changed: 4257 additions & 439 deletions

.snow/notebook/snow-cli.json

Lines changed: 113 additions & 14 deletions
Large diffs are not rendered by default.

.snow/notebook/snow-cli_snapshots.json

Lines changed: 99 additions & 0 deletions
Large diffs are not rendered by default.

requirements/全平台网页版Snow SSE客户端需求.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,4 +870,13 @@
870870
点击左侧的会话列表中的会话 tab 就是继续此会话,会话的历史记录参考一下那个测试客户端和 TUI
871871

872872
聊天记录区,自动置底,如果用户手动滚动到非底部,则不再自动置底,等当用手动滚到了最底部或用户发送了新消息后,再自动置底到最新一条.
873-
输入项目绝对路径的地方改成可输入的下拉框,下拉框里有哪些路径,新加一个配置文件吧,然后在启动和停止全部的后面新加一个按钮保存路径删除的话就不在网页里提供按钮了了,就让用户手动去配置里删除好了
873+
输入项目绝对路径的地方改成可输入的下拉框,下拉框里有哪些路径,新加一个配置文件吧,然后在启动和停止全部的后面新加一个按钮保存路径删除的话就不在网页里提供按钮了了,就让用户手动去配置里删除好了
874+
有中断会话按钮 和回退到记录点按钮,回退到的那一条消息,把回退点的消息的原文放到输入框中
875+
用户在主代理工作时也可发送消息 并放入 消息队列,且可撤回编辑参考 tui
876+
子代理工作完后 子代理自己这个弹窗可手动关闭
877+
878+
879+
现在 sse 客户端整体功能差不多了,但整体界面布局还是有些不好看,操作等不够人性化.特别是聊天记录区的显示和 todo 区的显示.优化下显示效果.用动画显示下当前会话正在工作还是已停止.聊天记录用类似聊天软件那种用户消息在右边助手和工具消息在左边的布局.子代理弹窗也优化下.todo 区也用颜色和图标等展示,并支持树状 todo.底部转换栏也优化下显示.整体界面人性化,风格简洁优美,仍保持深色主题.总之现在功能基本齐了,就是界面和交互体验还可以更上一层楼.
880+
git 简易查看和管理区也要美化
881+
当子代理工作时 应显示子代理弹窗,如果是多个子代理被并行调用了,则显示像卡片形式的,可左右滑动查看.多个子代理工作弹窗
882+
你自行开浏览器检查显示效果

source/api/sse-server.ts

Lines changed: 113 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export type SSEEventType =
2020
| 'agent_list'
2121
| 'agent_switched'
2222
| 'todo_update'
23-
| 'todos';
23+
| 'todos'
24+
| 'sub_agent_message';
2425

2526
/**
2627
* SSE 事件数据结构
@@ -56,6 +57,7 @@ export interface ClientMessage {
5657
agentId?: string; // 目标主代理ID,用于 switch_agent 消息
5758
rollback?: {
5859
messageIndex: number;
60+
snapshotIndex?: number; // UI 消息索引, 用于快照操作(与 messageIndex 属不同索引体系)
5961
rollbackFiles: boolean;
6062
selectedFiles?: string[];
6163
crossSessionRollback?: boolean;
@@ -545,6 +547,9 @@ export class SSEServer {
545547
const {hashBasedSnapshotManager} = await import(
546548
'../utils/codebase/hashBasedSnapshot.js'
547549
);
550+
const {convertSessionMessagesToUI} = await import(
551+
'../utils/session/sessionConverter.js'
552+
);
548553

549554
const sessionIdRaw = query?.['sessionId'];
550555
const sessionId =
@@ -568,6 +573,30 @@ export class SSEServer {
568573
return;
569574
}
570575

576+
// 快照系统使用 UI 消息索引(convertSessionMessagesToUI 后的长度)
577+
// 而非原始 session.messages 索引, 两者不一致(UI 转换会合并/过滤 tool 等消息)
578+
const uiMessages = convertSessionMessagesToUI(session.messages);
579+
580+
// 构建 "原始 user 消息索引 → 对应 UI 消息索引" 映射
581+
// 快照在 tool 执行期间创建, messageIndex = 当时的 uiMessages.length
582+
// 即: user 消息保存后, uiMessages 中该 user 位于 uiIdx, 快照记录 uiIdx + 1
583+
const rawToUiIndexMap = new Map<number, number>();
584+
let uiIdx = 0;
585+
for (let i = 0; i < session.messages.length; i++) {
586+
const msg: any = session.messages[i];
587+
if (!msg || msg.role !== 'user') continue;
588+
while (
589+
uiIdx < uiMessages.length &&
590+
uiMessages[uiIdx]!.role !== 'user'
591+
) {
592+
uiIdx++;
593+
}
594+
if (uiIdx < uiMessages.length) {
595+
rawToUiIndexMap.set(i, uiIdx + 1);
596+
uiIdx++;
597+
}
598+
}
599+
571600
const snapshots = await hashBasedSnapshotManager.listSnapshots(
572601
sessionId,
573602
);
@@ -584,9 +613,11 @@ export class SSEServer {
584613

585614
const points: Array<{
586615
messageIndex: number;
616+
snapshotIndex?: number;
587617
role: 'user';
588618
timestamp: number;
589619
summary: string;
620+
content: string;
590621
hasSnapshot: boolean;
591622
snapshot?: {timestamp: number; fileCount: number};
592623
filesToRollbackCount: number;
@@ -603,40 +634,31 @@ export class SSEServer {
603634
? normalized.slice(0, maxSummaryLen) + '…'
604635
: normalized;
605636

606-
// Snapshot 的 messageIndex 和 session.messages 的索引并不总是一致。
607-
// 实测快照通常对应“下一条消息写入前”的索引(例如首条 user 消息后快照会落在 1)。
608-
const snapAtNext = snapshotByIndex.get(i + 1);
609-
const snapAtCurrent = snapshotByIndex.get(i);
610-
const snap = snapAtNext ?? snapAtCurrent;
611-
const rollbackIndex = snapAtNext ? i + 1 : i;
637+
// 使用 UI 消息索引查找快照(快照系统记录的是 UI 索引)
638+
// 映射缺失时(uiIndex === -1)表示该 user 消息无 UI 对应, 跳过快照查找
639+
const uiIndex = rawToUiIndexMap.get(i) ?? -1;
640+
const snap = uiIndex >= 0 ? snapshotByIndex.get(uiIndex) : undefined;
612641

613642
let filesToRollbackCount = 0;
614643
if (snap && snap.fileCount > 0) {
615644
const files = await hashBasedSnapshotManager.getFilesToRollback(
616645
sessionId,
617-
rollbackIndex,
646+
uiIndex,
618647
);
619648
filesToRollbackCount = Array.isArray(files) ? files.length : 0;
620649
}
621650

622651
points.push({
623652
messageIndex: i,
653+
...(uiIndex >= 0 ? {snapshotIndex: uiIndex} : {}),
624654
role: 'user',
625655
timestamp: typeof m.timestamp === 'number' ? m.timestamp : 0,
626656
summary,
657+
content,
627658
hasSnapshot: !!snap && snap.fileCount > 0,
628659
snapshot: snap,
629660
filesToRollbackCount,
630661
});
631-
632-
// 如果快照存在但落在 i+1(常见),让前端能直接用 messageIndex 作为回滚点索引。
633-
if (
634-
snapAtNext &&
635-
snapAtNext.fileCount > 0 &&
636-
i + 1 < session.messages.length
637-
) {
638-
// 这里不改变 messageIndex 的语义,仅用于确保 hasSnapshot 展示正确。
639-
}
640662
}
641663

642664
res.writeHead(200, {
@@ -782,6 +804,7 @@ export class SSEServer {
782804
content: string;
783805
[key: string]: any;
784806
}>;
807+
let currentSession: any = null;
785808

786809
// 支持两种方式:直接传入 messages 或通过 sessionId 获取
787810
if (body.messages && Array.isArray(body.messages)) {
@@ -799,6 +822,7 @@ export class SSEServer {
799822
return;
800823
}
801824
messages = session.messages || [];
825+
currentSession = session;
802826
} else {
803827
res.writeHead(400, {
804828
'Content-Type': 'application/json',
@@ -856,11 +880,82 @@ export class SSEServer {
856880
return;
857881
}
858882

883+
// Build new session messages with compression summary
884+
const newSessionMessages: Array<any> = [];
885+
let finalContent = `[Context Summary from Previous Conversation]\n\n${result.summary}`;
886+
887+
if (result.preservedMessages && result.preservedMessages.length > 0) {
888+
finalContent +=
889+
'\n\n---\n\n[Last Interaction - Preserved for Continuity]\n\n';
890+
for (const msg of result.preservedMessages) {
891+
if (msg.role === 'user') {
892+
finalContent += `**User:**\n${msg.content}\n\n`;
893+
} else if (msg.role === 'assistant') {
894+
finalContent += `**Assistant:**\n${msg.content}`;
895+
if (msg.tool_calls && msg.tool_calls.length > 0) {
896+
finalContent += '\n\n**[Tool Calls Initiated]:**\n```json\n';
897+
finalContent += JSON.stringify(msg.tool_calls, null, 2);
898+
finalContent += '\n```\n\n';
899+
} else {
900+
finalContent += '\n\n';
901+
}
902+
} else if (msg.role === 'tool') {
903+
finalContent += `**[Tool Result - ${msg.tool_call_id}]:**\n`;
904+
try {
905+
const parsed = JSON.parse(msg.content);
906+
finalContent +=
907+
'```json\n' + JSON.stringify(parsed, null, 2) + '\n```\n\n';
908+
} catch {
909+
finalContent += `${msg.content}\n\n`;
910+
}
911+
}
912+
}
913+
}
914+
915+
newSessionMessages.push({
916+
role: 'user',
917+
content: finalContent,
918+
timestamp: Date.now(),
919+
});
920+
921+
// Create new compressed session
922+
const compressedSession = await sessionManager.createNewSession(
923+
false,
924+
true,
925+
);
926+
927+
// Set session properties
928+
compressedSession.messages = newSessionMessages;
929+
compressedSession.messageCount = newSessionMessages.length;
930+
compressedSession.updatedAt = Date.now();
931+
compressedSession.title = currentSession?.title || 'Compressed Session';
932+
compressedSession.summary = currentSession?.summary || '';
933+
934+
// Record compression relationship
935+
const sourceSessionId = body.sessionId;
936+
compressedSession.compressedFrom = sourceSessionId;
937+
compressedSession.compressedAt = Date.now();
938+
compressedSession.originalMessageIndex =
939+
result.preservedMessageStartIndex;
940+
941+
// Save new session to disk
942+
await sessionManager.saveSession(compressedSession);
943+
944+
// Return full response with new session info
859945
res.writeHead(200, {
860946
'Content-Type': 'application/json',
861947
'Access-Control-Allow-Origin': '*',
862948
});
863-
res.end(JSON.stringify({success: true, result}));
949+
res.end(
950+
JSON.stringify({
951+
success: true,
952+
result: {
953+
...result,
954+
sessionId: compressedSession.id,
955+
compressedFrom: sourceSessionId,
956+
},
957+
}),
958+
);
864959
} catch (error) {
865960
res.writeHead(500, {
866961
'Content-Type': 'application/json',

source/hooks/conversation/useConversation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ToolCall,
1919
type MCPExecutionContext,
2020
} from '../../utils/execution/toolExecutor.js';
21+
import type {SubAgentMessage} from '../../utils/execution/subAgentExecutor.js';
2122
import {getOpenAiConfig} from '../../utils/config/apiConfig.js';
2223
import {sessionManager} from '../../utils/session/sessionManager.js';
2324
import {unifiedHooksExecutor} from '../../utils/execution/unifiedHooksExecutor.js';
@@ -122,6 +123,7 @@ export type ConversationHandlerOptions = {
122123
getCurrentContextPercentage?: () => number; // Get current context percentage from ChatInput
123124
setCurrentModel?: React.Dispatch<React.SetStateAction<string | null>>; // Set current model name for display
124125
setIsStopping?: React.Dispatch<React.SetStateAction<boolean>>; // Control stopping state
126+
onRawSubAgentMessage?: (message: SubAgentMessage) => void; // 可选: 原始子代理消息透传回调(SSE 服务端用于转发事件)
125127
};
126128

127129
/**
@@ -987,6 +989,8 @@ async function executeWithInternalRetry(
987989
setStreamTokenCount,
988990

989991
async subAgentMessage => {
992+
// 原始子代理消息透传(SSE 服务端用于转发 sub_agent_message 事件到客户端)
993+
options.onRawSubAgentMessage?.(subAgentMessage);
990994
// Handle sub-agent messages - display and save to session
991995
setMessages(prev => {
992996
// Handle sub-agent context usage update

source/utils/MainAgentManager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {AgentInfo} from '../api/sse-server.js';
1414
import {
1515
getAgentsPrompt,
1616
createSystemContext,
17-
getTaskCompletionPrompt,
17+
// getTaskCompletionPrompt,
1818
} from './agentsPromptUtils.js';
1919
import {getModelSpecificPrompt} from './config/apiConfig.js';
2020

@@ -363,10 +363,10 @@ export class MainAgentManager {
363363
}
364364

365365
// 添加任务完成标识提示词
366-
const taskCompletionPrompt = getTaskCompletionPrompt();
367-
if (taskCompletionPrompt) {
368-
prompt += '\n\n' + taskCompletionPrompt;
369-
}
366+
// const taskCompletionPrompt = getTaskCompletionPrompt();
367+
// if (taskCompletionPrompt) {
368+
// prompt += '\n\n' + taskCompletionPrompt;
369+
// }
370370

371371
return prompt.trim();
372372
}

source/utils/agentsPromptUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ export function getAgentsPrompt(): string {
215215
*
216216
*/
217217
export function getTaskCompletionPrompt(): string {
218-
return `若你完成了所有任务,请在你的最终回复的最开头或最结尾添加 \`[Mission_Accomplished!]\` 标记!!!并重新完整做出带标记的最终回复!!!
219-
你要不停的使用工具直到完成所有任务,才可进行一般回复,且MUST在任务完成后的最终回复的最开头或最结尾中添加 \`[Mission_Accomplished!]\` 标记,即使是简单的打招呼,测试等极简单任务完成后也MUST加上该标记,否则将不会认为此条回复是最终回复,并打回让你继续任务.
220-
若任务未完成,请继续使用工具直到完成所有任务.特别的不用每完成一个阶段就问一下用户,而是不停继续,去完成所有任务!
218+
return `若你完成了所有任务和所有需求,请在你的最终回复的最开头或最结尾添加 \`[Mission_Accomplished!]\` 标记!!!并重新完整做出带标记的最终回复!!!
219+
你要不停的使用工具直到完成所有任务所有需求,才可进行一般回复,且MUST在任务完成后的最终回复的最开头或最结尾中添加 \`[Mission_Accomplished!]\` 标记,即使是简单的打招呼,测试等极简单任务完成后也MUST加上该标记,否则将不会认为此条回复是最终回复,并打回让你继续任务.
220+
若任务未完成,请继续使用工具直到完成所有任务所有需求.特别的不用每完成一个阶段就问一下用户,而是不停继续,去完成所有任务!
221221
若想提问则必须使用专门的提问工具提问,若你无此工具则说明你不可提问,MUST自主决策继续任务.
222222
`;
223223
}

source/utils/config/toolDisplayConfig.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
* 这些通常是耗时较长的工具,用户需要看到执行进度
99
*/
1010
const TWO_STEP_TOOLS = new Set([
11+
// 文件读取工具 - 用户需要看到读取操作
12+
'filesystem-read',
13+
'ace-file_outline',
14+
'ace-text_search',
15+
'context_engine-codebase-retrieval', // 上下文引擎工具
16+
1117
// 文件编辑工具 - 耗时较长,需要显示进度
1218
'filesystem-edit',
1319
'filesystem-edit_search',

source/utils/execution/mcpToolsManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1677,7 +1677,7 @@ export async function executeMCPTool(
16771677
args.question,
16781678
args.options,
16791679
'', //toolCallId will be set by executeToolCall
1680-
false, // multiSelect 已移除,默认支持单选和多选
1680+
true, // 默认多选模式, 用户可选一个或多个
16811681
);
16821682
default:
16831683
throw new Error(`Unknown askuser tool: ${actualToolName}`);

source/utils/execution/subAgentExecutor.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {getSubAgent, getSubAgents} from '../config/subAgentConfig.js';
66
import {
77
getAgentsPrompt,
88
createSystemContext,
9-
getTaskCompletionPrompt,
9+
// getTaskCompletionPrompt,
1010
} from '../agentsPromptUtils.js';
1111
import {
1212
collectAllMCPTools,
@@ -597,12 +597,12 @@ You have access to these collaboration tools:
597597
}
598598

599599
// 5. 添加任务完成标识提示词
600-
const taskCompletionPrompt = getTaskCompletionPrompt();
601-
if (taskCompletionPrompt) {
602-
finalPrompt = finalPrompt
603-
? `${finalPrompt}\n\n${taskCompletionPrompt}`
604-
: taskCompletionPrompt;
605-
}
600+
// const taskCompletionPrompt = getTaskCompletionPrompt();
601+
// if (taskCompletionPrompt) {
602+
// finalPrompt = finalPrompt
603+
// ? `${finalPrompt}\n\n${taskCompletionPrompt}`
604+
// : taskCompletionPrompt;
605+
// }
606606

607607
// 5.5 注入并行协作上下文
608608
if (collaborationContext) {

0 commit comments

Comments
 (0)