feat(model): Top5#1 结构化流事件 + WS 事件体系(对齐 OpenClaw AssistantMessageEvent)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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:
Aska committed 2026-09-06 16:31:28 +08:00
1 parent 54f0b75646
commit 24d5924cee
23 files changed
+425 -90

No files matched your search

+8 -3
View File
@@ -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). */
+70
View File
@@ -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);