feat(model): Top5#1 结构化流事件 + WS 事件体系(对齐 OpenClaw AssistantMessageEvent)
- 新增 model/stream.go:StreamKind(text/thinking/tool_call 各 start/delta/end + tool_execution)
+ StreamEvent{Kind,Delta,Index,ID}。
- ChatStream 签名 onChunk func(delta string)→onEvent func(StreamEvent);loop OnStream 同改;
streamStep 转发结构化事件。
- openai:text_delta + text_start/text_end(正文三段式);anthropic:文本块 text_*/tool_call_*
按块 Index(content_block_stop 分支)真分段;fallback:有事件即交付。
- ws_hub:wsEvent 壳补 turnId/seq;新增 broadcastStream;streamChat 每帧打 turnId+seq;
stream.go 把事件 publish 到 bus ws.stream。
- ws_bridge:streamEventData 映射 text_*→message.start/delta/end、thinking_*→thinking.*、
tool_call_*→tool_call.*、tool_execution_update→tool_execution.update,未知回退 stream_chunk。
- 前端 ws.ts:解析 message.start/delta/end、thinking.start/delta/end(pendingThinking)、
tool_call.start/end→addTool、tool_execution.update;保留旧 stream_chunk/thinking/tool_used 兼容;
types.ts 补 turnId/seq。Docs/api-client.md 增 §3.1 v0.4。
已知限制(注释/文档标注):turnId 在 OnStream→bus→relay 路径暂为空(StreamEvent 无 turnId 字段,
bus ws.stream 载荷不带 turn);OpenAI adapter 未解析 tool_call 段;thinking 段两 adapter 暂不发;
tool_execution.update 是保留项(随工具循环钩子后置)。go build/vet/test + 前端 tsc 全绿。
This commit is contained in:
1 parent
54f0b75646
commit
24d5924cee
23 files changed
+425
-90
No files matched your search
@@ -143,13 +143,18 @@ export interface ChatSyncResult {
|
||||
|
||||
/**
|
||||
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
|
||||
* every consumer narrows it. The backend emits `{type, data}` and types include
|
||||
* stream_chunk / stream_end / thinking / mood / tool_used / subagent_event /
|
||||
* graph_update / reminder / device_update / auth / error / task_output.
|
||||
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?}`
|
||||
* and types include the structured stream events message.start/delta/end,
|
||||
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
|
||||
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
|
||||
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
|
||||
* error / task_output.
|
||||
*/
|
||||
export interface WsDownEvent {
|
||||
type: string;
|
||||
data: unknown;
|
||||
turnId?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/** Upstream WS frame (client → server). */
|
||||
|
||||
@@ -15,6 +15,11 @@ let shouldRun = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
/** Accumulator for a streamed thinking segment (thinking.* events). Reset on
|
||||
* thinking.start so a mid-turn reconnect can rebuild the collapsed block
|
||||
* without relying on prior state. */
|
||||
let pendingThinking = "";
|
||||
|
||||
const MAX_BACKOFF = 30_000;
|
||||
const BASE_BACKOFF = 1_000;
|
||||
|
||||
@@ -45,6 +50,24 @@ function parseTool(v: unknown): ToolUsed | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
|
||||
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
|
||||
* so we label the card by id when no name is present. */
|
||||
function parseToolCall(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const name = typeof r.name === "string" ? r.name : "";
|
||||
const id = typeof r.id === "string" ? r.id : "";
|
||||
if (!name && !id) return null;
|
||||
return {
|
||||
name: name || `tool_${id.slice(0, 4)}`,
|
||||
summary: typeof r.summary === "string" ? r.summary : undefined,
|
||||
detail: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: typeof r.ok === "boolean" ? r.ok : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAgent(payload: unknown): AgentInfo | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r) return null;
|
||||
@@ -112,6 +135,53 @@ function dispatch(ev: { type: string; data: unknown }): void {
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Structured stream events (design/04 §1.1, aligned with OpenClaw). These
|
||||
// arrive in place of the legacy stream_chunk when the loop streams events.
|
||||
case "message.start": {
|
||||
// The assistant bubble is already opened by useChat; nothing to do here.
|
||||
break;
|
||||
}
|
||||
case "message.delta": {
|
||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
|
||||
break;
|
||||
}
|
||||
case "message.end": {
|
||||
// Body-text segment closed; the turn may continue with tool calls, so we
|
||||
// do NOT end the stream here (stream_end owns that).
|
||||
break;
|
||||
}
|
||||
case "thinking.start": {
|
||||
pendingThinking = "";
|
||||
break;
|
||||
}
|
||||
case "thinking.delta": {
|
||||
if (r && typeof r.delta === "string") {
|
||||
pendingThinking += r.delta;
|
||||
s.setThinking(pendingThinking);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "thinking.end": {
|
||||
if (pendingThinking) s.setThinking(pendingThinking);
|
||||
pendingThinking = "";
|
||||
break;
|
||||
}
|
||||
case "tool_call.start":
|
||||
case "tool_call.end": {
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool);
|
||||
break;
|
||||
}
|
||||
case "tool_call.delta": {
|
||||
// Argument fragments stream between start/end; the card is added at the
|
||||
// boundary so mid-stream reconnects do not drop it.
|
||||
break;
|
||||
}
|
||||
case "tool_execution.update": {
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool);
|
||||
break;
|
||||
}
|
||||
case "thinking": {
|
||||
if (typeof ev.data === "string") {
|
||||
s.setThinking(ev.data);
|
||||
|
||||
Reference in new issue
Block a user