- 新增 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 全绿。
126 lines
4.8 KiB
Go
126 lines
4.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
|
|
)
|
|
|
|
// eventBridge maps a producer bus subject to its outbound WS event type. The hub
|
|
// subscribes to these (and re-normalizes into ws.* types) so the frontend's
|
|
// existing consumers light up without the producers changing their subjects.
|
|
var eventBridge = map[string]string{
|
|
"iot.device_update": "device_update",
|
|
"task.output": "task_output",
|
|
"task.done": "task_done",
|
|
"automation.result": "automation_result",
|
|
"reminder.trigger": "reminder",
|
|
}
|
|
|
|
// bridgePatterns are the additional bus patterns the hub subscribes to on top of
|
|
// ws.> to capture producer events and normalize them for WS delivery.
|
|
var bridgePatterns = []string{
|
|
"iot.>",
|
|
"task.>",
|
|
"automation.>",
|
|
"reminder.>",
|
|
"cyrene.agent.*.event",
|
|
}
|
|
|
|
// normalizeEvent maps a bus event to an outbound wsEvent. A ws.* subject keeps
|
|
// its design type (the ws. prefix is stripped); a known producer subject is
|
|
// bridged to its ws.* type; a sub-agent progress event is shaped into
|
|
// ws.subagent_event. It returns ok=false for events that have no WS-visible
|
|
// representation (e.g. internal cyrene.agent ask/reply traffic).
|
|
func normalizeEvent(e bus.Event) (wsEvent, bool) {
|
|
if typ, ok := eventBridge[e.Subject]; ok {
|
|
return wsEvent{Type: typ, Data: e.Payload}, true
|
|
}
|
|
if strings.HasPrefix(e.Subject, wsPrefix) {
|
|
return wsEvent{Type: eventType(e.Subject), Data: e.Payload}, true
|
|
}
|
|
if ev, ok := subagentProgressEvent(e); ok {
|
|
return ev, true
|
|
}
|
|
return wsEvent{}, false
|
|
}
|
|
|
|
// subagentProgressEvent maps an orchestration progress event — broadcast on
|
|
// cyrene.agent.<from>.event via comm.PublishEvent — into a ws.subagent_event
|
|
// frame. Only the five sub-agent progress names are surfaced; any other event
|
|
// broadcast on the same subject (ask/reply/addressed events, or foreign event
|
|
// names) is ignored so internal agent-to-agent traffic stays internal.
|
|
func subagentProgressEvent(e bus.Event) (wsEvent, bool) {
|
|
if !isBroadcastAgentEvent(e.Subject) {
|
|
return wsEvent{}, false
|
|
}
|
|
ev, ok := e.Payload.(comm.Event)
|
|
if !ok {
|
|
return wsEvent{}, false
|
|
}
|
|
switch ev.Name {
|
|
case orchestration.EventDispatched, orchestration.EventStarted,
|
|
orchestration.EventDone, orchestration.EventFailed, orchestration.EventAborted:
|
|
default:
|
|
return wsEvent{}, false
|
|
}
|
|
p, ok := ev.Data.(orchestration.Progress)
|
|
if !ok {
|
|
return wsEvent{}, false
|
|
}
|
|
return wsEvent{Type: "subagent_event", Data: map[string]any{
|
|
"id": p.AgentID,
|
|
"name": p.AgentID,
|
|
"status": string(p.Status),
|
|
"role": p.RoleID,
|
|
"mode": p.Mode,
|
|
}}, true
|
|
}
|
|
|
|
// streamEventData maps a streamed model.StreamKind to its downstream WS event
|
|
// type and builds a typed data payload. It is what turns the loop's structured
|
|
// events into the message.*/thinking.*/tool_call.*/tool_execution.* frames the
|
|
// frontend renders. Unknown kinds fall back to the legacy stream_chunk shape so
|
|
// an unrecognized event still reaches the client instead of being dropped.
|
|
func streamEventData(ev model.StreamEvent) (string, map[string]any) {
|
|
switch ev.Kind {
|
|
case model.StreamKindTextStart:
|
|
return "message.start", map[string]any{"index": ev.Index}
|
|
case model.StreamKindTextDelta:
|
|
return "message.delta", map[string]any{"delta": ev.Delta, "index": ev.Index}
|
|
case model.StreamKindTextEnd:
|
|
return "message.end", map[string]any{"index": ev.Index}
|
|
case model.StreamKindThinkingStart:
|
|
return "thinking.start", map[string]any{"index": ev.Index}
|
|
case model.StreamKindThinkingDelta:
|
|
return "thinking.delta", map[string]any{"delta": ev.Delta, "index": ev.Index}
|
|
case model.StreamKindThinkingEnd:
|
|
return "thinking.end", map[string]any{"index": ev.Index}
|
|
case model.StreamKindToolCallStart:
|
|
return "tool_call.start", map[string]any{"id": ev.ID, "index": ev.Index}
|
|
case model.StreamKindToolCallDelta:
|
|
return "tool_call.delta", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}
|
|
case model.StreamKindToolCallEnd:
|
|
return "tool_call.end", map[string]any{"id": ev.ID, "index": ev.Index}
|
|
case model.StreamKindToolExecution:
|
|
return "tool_execution.update", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}
|
|
default:
|
|
return "stream_chunk", map[string]any{"delta": ev.Delta}
|
|
}
|
|
}
|
|
|
|
// isBroadcastAgentEvent reports whether subject has the exact 4-token broadcast
|
|
// shape cyrene.agent.<from>.event, which is how the scheduler publishes progress.
|
|
// It deliberately does not match addressed events (cyrene.agent.<from>.<to>.event)
|
|
// or the ask/reply subjects.
|
|
func isBroadcastAgentEvent(subject string) bool {
|
|
parts := strings.Split(subject, ".")
|
|
if len(parts) != 4 {
|
|
return false
|
|
}
|
|
return parts[0] == "cyrene" && parts[1] == "agent" && parts[3] == "event"
|
|
}
|