package gateway import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // relay 是 ws.> 模式与各生产者桥接模式的 bus 处理器。ws.chat 主题是 // 流式触发器(跑 loop 并发 chunk);ws.* 主题成为其设计类型对应的 // 下行事件;被桥接的生产者主题(device_update、任务 output/done、 // automation result、reminder、subagent progress)被规范化为匹配的 // ws.* 事件类型。没有 WS 可见映射的事件会被丢弃,使内部 agent 间 // 流量永不泄露给客户端。 func (h *hub) relay(ctx context.Context, e bus.Event) error { if e.Subject == wsChatSubject { go h.streamChat(ctx, e.Payload) return nil } if e.Subject == wsStreamSubject { // OnStream→bus→relay 路径:loop 已经发布了结构化的 // model.StreamEvent;把它映射为带类型的下行事件。 h.broadcastStream(e.Payload) return nil } ev, ok := normalizeEvent(e) if !ok { return nil } h.broadcast(ev) return nil } // streamChat 是 ws.chat 触发器的入口。它解码请求,并在接了 // orchestrator 时把该轮路由经会话 orchestrator(design/15 的非阻塞 // busy/pending/resume 路径);否则回退到旧的直接运行方式,使未接 // orchestrator 的 hub 保持完全相同的 stream_chunk/stream_end 契约。 // 它跑在各自的 goroutine 上,因此慢的模型调用不会阻塞 bus 订阅 // worker。 // // 它把编排器对本次提交的判定**交回调用方**(协同 G2):REST 的 stream:true // 路径与 WS 帧共用这一段,前者据此回明确的 2xx/非 2xx/202/409,而不是在问 // 编排器之前就先回 200 —— 那样客户端可能拿到 200 却什么都没发生。异步调用点 // (`go h.streamChat(...)`,WS 帧与 bus 中继)照常丢弃返回值。 func (h *hub) streamChat(ctx context.Context, payload any) SubmitResult { var req chatStreamRequest if b, err := json.Marshal(payload); err == nil { _ = json.Unmarshal(b, &req) } if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") { h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), SessionID: req.SessionID, Data: map[string]any{"stopReason": "idle"}}) return SubmitResult{} } if req.SessionID == "" { req.SessionID = "web" } // Web 主会话是 owner 的私有对话(JWT = owner),因此它绑定全权限 // env:记忆读写,行为不变。角色来自会话的绑定(design/22 §4.5): // 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续 // 解析为全局默认角色。 msgs := userChatMessages(req.Text, req.Image) // 通过 ctx 把客户端的 deviceID、行为模式与可选幂等键串进 // orchestrator,使持久化的用户轮次与助手回复都带上标记,并让重复到达的 // clientMsgId 被判重(design/16 §4.5、§5.1;协同 G4)。空 clientMsgId 是 // 空操作,因此老客户端的行为逐字不变。 ctx = withClientMsgHint(withMode(withDeviceID(ctx, req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID}) env := h.envResolver.owner(ctx, req.SessionID) if h.orchestrator != nil { return h.orchestrator.Submit(ctx, req.SessionID, msgs, env) } _, _ = h.runTurn(ctx, req.SessionID, msgs, env) return SubmitResult{Accepted: true, Immediate: true} } // runTurn 执行一个 agent 轮次并广播其输出流。它是 orchestrator(经其 // 注入的 TurnRunner)与旧回退路径共用的单元,因此无论由谁驱动,一轮 // 总是发出相同的 stream_chunk/stream_end 事件。当 loop 处于流式 // (接了 OnStream)时,结构化增量已经通过 OnStream→bus→relay 路径以 // message.*/thinking.*/tool_call.* 事件扇出,因此只有非流式路径才发出 // 一整段文本 chunk 以维持流契约。 func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) { turnID := newTurnID() // 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式 // 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到 // 下行帧上。每一轮都取全新的 id —— 一个会话最多一个在途轮次 // —— 因此客户端能把一串增量关联到同一个助手轮次 // (design/16 §4.2)。 ctx = loop.WithTurnID(ctx, turnID) if h.runner == nil || len(msgs) == 0 { h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}}) return &loop.Result{StopReason: "idle"}, nil } res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs) if err != nil { h.log.Error("chat run", "err", err, "session", sessionID) h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "error", "error": err.Error()}}) return nil, err } if !h.streaming { h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"delta": res.Text}}) } h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}}) return res, nil } // wsBadRequestCode 是上行帧载荷非法时定向回发送者的机器可读错误码。它与 // ws_route.go 的 wsForbiddenCode 同形({type:"error", data:{code}}), // 因此客户端只看 data.code 就能把「你没有这个权限」与「这个帧写错了」 // 区分开。 const wsBadRequestCode = "bad_request" // interruptRequest 是解码后的 "interrupt" 上行载荷(design/15 §3)。 type interruptRequest struct { SessionID string `json:"sessionId"` // Scope 选择这次打断清到哪一层(协同项 A2,取值见 InterruptScope): // 省略 / 空串 / "all" = 现状语义(取消在途回合并清空待处理队列), // "current" = 只取消在途回合、保留待处理队列让它们照常续跑。 // 其余取值一律显式拒绝 —— 客户端以为队列保住了、实际被清空, // 就是静默丢消息,正是 A2 要修掉的那类缺陷。 Scope string `json:"scope,omitempty"` } // parseInterruptScope 把上行 scope 解析为取值域内的口径。省略、空串与 // "all" 都落到现状语义(省略 = 什么都不改,因此老客户端只发 // {sessionId} 的行为逐字不变);"current" 是新增的保留队列口径;其余 // 取值返回错误,由调用方定向回 bad_request,绝不静默当默认执行。 // // 取值大小写敏感(与契约里的枚举 id 同口径),只容忍首尾空白。 func parseInterruptScope(raw string) (InterruptScope, error) { switch strings.TrimSpace(raw) { case "", string(InterruptScopeAll): return InterruptScopeAll, nil case string(InterruptScopeCurrent): return InterruptScopeCurrent, nil default: return "", fmt.Errorf("interrupt: 未知 scope %q(合法值:%q / %q)", raw, InterruptScopeAll, InterruptScopeCurrent) } } // handleInterrupt 处理客户端的 "interrupt" 帧(design/15 §3 + 协同项 A2): // 按 scope 让 orchestrator 取消该会话的在途回合,必要时保留待处理队列。 // 未接 orchestrator 时是空操作。 // // 它是**连接级**的,因为非法 scope 必须定向回发送者一条 // {type:"error", data:{code:"bad_request"}}:只到发送者、不广播、也不打断 // 任何会话(显式报错,绝不静默按默认执行)。该帧只由 owner/admin 连接 // 分发到此(design/11 §7.2 B)。 // // 缺 sessionId 时是空操作(既有行为,本次不改)。 func (c *wsConn) handleInterrupt(payload json.RawMessage) { if c.h == nil { return } var req interruptRequest _ = json.Unmarshal(payload, &req) scope, err := parseInterruptScope(req.Scope) if err != nil { c.h.log.Debug("ws interrupt rejected", "err", err, "scope", req.Scope) c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsBadRequestCode}}) return } if c.h.orchestrator == nil || req.SessionID == "" { return } c.h.orchestrator.InterruptScope(req.SessionID, scope) } // broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型, // 并经 hub 的帧门扇出(design/11 §7.1):每一个结构化流帧都携带模型 // 输出,因此只到达 owner + admin。它跑在 bus 订阅 worker 上;seq 原子 // 分配,因此即便 loop 从多个 goroutine 发出事件,一串帧仍保持有序。 // SessionID/TurnID 随事件本身携带(loop 在轮次边界从运行上下文盖上 // 它们),因此帧带有客户端把它路由到活跃会话以及关联到某个助手 // 轮次所需的范围。 func (h *hub) broadcastStream(payload any) { ev, ok := payload.(model.StreamEvent) if !ok { return } typ, data := streamEventData(ev) h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data}) } // chatStreamRequest 是解码后的 ws.chat 载荷。 type chatStreamRequest struct { SessionID string `json:"sessionId"` Text string `json:"text"` Image string `json:"image,omitempty"` // DeviceID 是客户端生成的发送浏览器/设备标识(design/16 §4.5), // 例如 "web:chrome:a1b2c3";它可选,并随消息一同持久化,使 agent // 知道主人是从哪一端发消息。 DeviceID string `json:"deviceId,omitempty"` // Mode 是客户端发送该轮时所用的行为模式(design/01 §3、 // design/16 §5.1);它可选,按消息持久化,缺省时回退为 "chat"。 Mode string `json:"mode,omitempty"` // ClientMsgID 是客户端自带的幂等键(协同 G4):同一会话内同一个非空 // clientMsgId 只承认第一条,重复到达不再落库、不再起回合,而是回一条 // `chat_duplicate`(data 带既有 messageId)。它可选,省略时行为与加入该 // 字段之前逐字相同。 ClientMsgID string `json:"clientMsgId,omitempty"` } // newTurnID 返回新的随机十六进制标识,用于把一串流式 WS 事件关联到 // 同一个助手轮次。它消耗 crypto/rand 源,源不可用时回退到纳秒 // 时间戳,因此总能产出 id(空的 turnId 会静默破坏客户端关联)。 func newTurnID() string { var b [8]byte if _, err := rand.Read(b[:]); err == nil { return "turn-" + hex.EncodeToString(b[:]) } return "turn-" + time.Now().UTC().Format("20060102150405.000000000") }