feat(gateway): 地基③ 打通实时信号 producer→前端WS + loop 真流式 + 上行路由补全
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

打通前端 ws.ts 里那批死消费者事件(thinking/mood/tool_used/subagent_event/
task_output/device_update/reminder/backthink),并给 loop 接真 token 流式。

- wire.go:loop 建时传 OnStream(streamChunk 闭包把 delta 发 bus ws.stream_chunk);
  gateway.Deps 注入 Scheduler/Tasks/Streaming;iot 接共享总线 iot.WithBus(a.bus)。
- ws_bridge.go:hub 额外订阅 iot.>/task.>/automation.>/reminder.>/cyrene.agent.*.event,
  normalizeEvent 归一为 ws.*(device_update/task_output/task_done/automation_result/
  reminder/subagent_event);不订阅 cyrene.agent.> 以免 ask/reply 内部事件进 relay。
- ws_route.go:voice_input→ws.voice_input;subagent_ask→Scheduler.AuditContext(真实
  上下文审计,替换 pending stub);task_output_query→Manager.TaskOutput(真实输出,
  替换空 {})。
- server.go:新增 SubagentAuditor/TaskOutputStore 接口 + Deps.Scheduler/Tasks/Streaming。
- orchestration/auditcontext.go:Scheduler.AuditContext(full/tail/keyword)。
- task/manager.go:Manager.TaskOutput(id)。
- backthink:record() 补发 ws.backthink 事件。
- json tag 修正(device_update/reminder/task output 结构体加小写 tag + DeviceUpdate
  补 message),使前端分支能真正渲染。

实机验证:WS 握手 101、task manager ready、iot manager initialized(WithBus 生效)。
go build/vet/test 全绿;TestHubBridgeDeviceUpdate/TestHubBridgeSubagentProgress 覆盖
映射。dev 推至后续版本。
This commit is contained in:
Aska committed 2026-09-06 15:53:48 +08:00
1 parent 04bfa2c7a7
commit 9b207a31e8
14 files changed
+673 -109

No files matched your search

+54 -41
View File
@@ -23,6 +23,7 @@ const (
wsEventsPattern = "ws.>"
wsPrefix = "ws."
wsChatSubject = "ws.chat"
wsVoiceInput = "ws.voice_input"
wsReadLimit = 4096
wsWriteWait = 10 * time.Second
wsPongWait = 60 * time.Second
@@ -57,27 +58,33 @@ type wsConn struct {
// hub owns the connection set and relays bus events to every client.
type hub struct {
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
mu sync.RWMutex
conns map[*wsConn]struct{}
bus bus.Bus
runner Chatter
auditor SubagentAuditor
taskOutputs TaskOutputStore
streaming bool
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
}
// newHub builds a hub bound to the server's dependencies.
func newHub(s *Server) *hub {
origins, allowAll := s.allowedOrigins()
return &hub{
conns: make(map[*wsConn]struct{}),
bus: s.bus,
runner: s.runner,
log: s.log,
origins: origins,
allowAll: allowAll,
conns: make(map[*wsConn]struct{}),
bus: s.bus,
runner: s.runner,
auditor: s.auditor,
taskOutputs: s.taskOutputs,
streaming: s.streaming,
log: s.log,
origins: origins,
allowAll: allowAll,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
@@ -87,15 +94,20 @@ func newHub(s *Server) *hub {
}
// start subscribes the hub to the bus event stream. Publishing to ws.> arrives
// here and is fanned out to clients; nothing happens when no bus is wired.
// here and is fanned out to clients; the producer bridge patterns (iot/task/
// automation/reminder/subagent-progress) are also subscribed and normalized into
// ws.* event types. Nothing happens when no bus is wired.
func (h *hub) start() {
if h.bus == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
if _, err := h.bus.Subscribe(ctx, wsEventsPattern, h.relay); err != nil {
h.log.Warn("bus subscribe", "pattern", wsEventsPattern, "err", err)
patterns := append([]string{wsEventsPattern}, bridgePatterns...)
for _, p := range patterns {
if _, err := h.bus.Subscribe(ctx, p, h.relay); err != nil {
h.log.Warn("bus subscribe", "pattern", p, "err", err)
}
}
}
@@ -154,15 +166,23 @@ func (h *hub) broadcast(ev wsEvent) {
}
}
// relay is the bus handler for the ws.> pattern. The ws.chat subject is a
// streaming trigger (run the loop and emit chunks); every other subject becomes
// an outbound event of its design type.
// relay is the bus handler for the ws.> pattern and the producer bridge
// patterns. The ws.chat subject is a streaming trigger (run the loop and emit
// chunks); a ws.* subject becomes an outbound event of its design type; a
// bridged producer subject (device_update, task output/done, automation result,
// reminder, subagent progress) is normalized into the matching ws.* event type.
// Events with no WS-visible mapping are dropped so internal agent-to-agent
// traffic never leaks to clients.
func (h *hub) relay(ctx context.Context, e bus.Event) error {
if e.Subject == wsChatSubject {
go h.streamChat(ctx, e.Payload)
return nil
}
h.broadcast(wsEvent{Type: eventType(e.Subject), Data: e.Payload})
ev, ok := normalizeEvent(e)
if !ok {
return nil
}
h.broadcast(ev)
return nil
}
@@ -187,7 +207,13 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "error", "error": err.Error()}})
return
}
h.broadcast(wsEvent{Type: "stream_chunk", Data: map[string]any{"delta": res.Text}})
// When the loop is streaming (OnStream wired), the deltas already fan out as
// ws.stream_chunk through the OnStream→bus→relay path; a second whole-text
// chunk would duplicate the tail. The non-streaming path (or a stub runner)
// still emits one chunk so the stream contract holds in tests.
if !h.streaming {
h.broadcast(wsEvent{Type: "stream_chunk", Data: map[string]any{"delta": res.Text}})
}
h.broadcast(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
}
@@ -282,22 +308,6 @@ func (c *wsConn) writePump() {
}
}
// route dispatches one inbound frame by its type.
func (c *wsConn) route(in wsInbound) {
switch in.Type {
case "mode_switch":
c.publishPublic("ws.mode_switch", rawData(in.Data))
case "chat":
c.publishPublic(wsChatSubject, rawData(in.Data))
case "subagent_ask":
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: map[string]any{"state": "pending"}})
case "task_output_query":
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{}})
default:
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
}
}
// publishPublic publishes a bus event without any per-connection reply.
func (c *wsConn) publishPublic(subject string, payload any) {
if c.h.bus == nil {
@@ -306,11 +316,14 @@ func (c *wsConn) publishPublic(subject string, payload any) {
_ = c.h.bus.Publish(context.Background(), subject, payload)
}
// shutdown closes the connection exactly once.
// shutdown closes the connection exactly once. It tolerates a nil *websocket.Conn
// so tests that build a fake connection can rely on a safe teardown.
func (c *wsConn) shutdown() {
c.once.Do(func() {
close(c.done)
_ = c.conn.Close()
if c.conn != nil {
_ = c.conn.Close()
}
})
}