Files
Cyrene_v2/internal/services/gateway/chat_stream_dispatch.go
T
AskaEth a4789ec13e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): chat.documents 解析 + 修两处静默吞掉(design/24 S3-B)
- REST(同步 + stream:true)与 WS 两条入口同形解析 documents:只收服务端存储名(形态 + 真实存在 + 嗅探 MIME + 本地 URL + 大小),客户端谎报 mime/url/size 一律被服务端覆盖 ⇒ 不重开 SSRF 面(有「真实可命中 URL,命中次数 0」的用例钉住)。

- 拒绝口径对齐 S1:超过 5 条 → 400 too_many_documents(且先于任何 FS 访问);形态非法/穿越/不存在 → 404 document_not_found。

- 挂载:一回合一条 RoleUser 消息,文档部件追加其后(纯附件回合不塞空文本块,空文本会被 provider 拒)。

- 修 S2 移交的两处静默吞掉:session_transcript 用 history.DecodeDocument 把 h.Document 还原为部件(坏列带来源 WARN 不静默、也不卡会话);session_tail 的 messagesEmpty 计入 ContentTypeDocument(纯附件不再被判空回合)。

- 拆文件/拆函数(AGENTS:文件≤500 行、函数≤80 行):new chat_documents.go(rest.go 原 499/500 行塞不下)、handleChat 尾段 54 行原样搬成 submitChatTurn(handleChat 90→40 行),ws_hub 增 documents 依赖(与既有 envResolver 同接法)。

- 主会话独立验收(隔离 worktree = 当前 HEAD + 仅 B 的文件 ⇒ 正是 A+B 合流态):build/vet 绿、全树 59 包 0 FAIL、13 条新用例 PASS、linux/darwin 交叉编译绿;变异独立复现(摘掉条数上限判定 ⇒ 红)。

- 主会话补的测试钉子:TestMaxChatDocumentsMatchesDesign —— 原条数用例是用常量自身算输入条数的,改常量它照样绿(实测 5→1000 全包仍绿);补钉后改 10 时新钉子红、旧用例仍绿。
2026-10-04 17:25:35 +08:00

200 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"fmt"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件归属 REST `POST /api/v1/chat` 的派发:`stream:true`(Android 协同 §G2)
// 与默认的同步回合(submitChatTurn)两条路径。
//
// 修复前的链路是 publishChatStream → bus.Publish → hub 订阅 → streamChat,三处
// 叠加起来让「客户端拿到 200、却什么都没发生」成为可能:
// ① bus 的订阅缓冲满时会**丢事件**(bus 包文档明示的代价),而会话消息被丢就是
// 用户消息消失;
// ② handler 在问编排器之前就回了 200 {"stream":true};
// ③ Publish 的错误被丢弃,且传的是**请求作用域**的 ctx(请求返回后即被取消,
// Publish 直接返回)。
//
// 修复后它与 WS 的 chat 帧走**同一条直达路径**(ws_route.go 的既有先例,
// design/15 §2.1「会话消息不得被 drop」):接了编排器就直接提交,完全不经总线。
// handler 因此在回 200 之前就知道这一轮真的被接收并落库。只有在没接编排器时才
// 回退到总线发布,而发布失败不再被吞掉 —— 回 503 + 明确错误码,绝不假装成功。
// chatStreamTurnBudget 是 stream:true 派生回合上下文的上限。该上下文必须与请求
// 作用域**脱钩**:handler 返回后 r.Context() 即被取消,若回合挂在它下面,流式
// 回合会在客户端刚收到 200 时被连带取消(回合是提交之后在后台跑的)。因此基础
// 用 context.WithoutCancel(保留 trace/审计等值、丢掉取消信号),再套一个上限。
//
// 上限取一个远大于任何正常回合的值:工具循环上限 8 轮(loop.DefaultMaxRounds),
// 正常一轮是秒级到分钟级,30 分钟只在回合真的挂死时兜底 —— 它是安全网,不是
// 功能限制(与 WS 路径的 context.Background() 相比,只是多了一个上界)。
const chatStreamTurnBudget = 30 * time.Minute
// handleChatStream 是 POST /api/v1/chat 的 stream:true 分支。它先把这一轮交给
// submitChatStream(与 WS chat 帧同一条直达路径),拿到判定后才回响应,因此
// **200 意味着消息已被接住并落库**;派发用的 ctx 与请求作用域脱钩、带上限
// (见 detachedTurnContext)。
func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request, req chatRequest) {
if s.runner == nil {
// 与同步路径同一口径:没接 loop 就不能假装受理了一轮对话(修复前这里
// 照样回 200,客户端永远等不到任何帧)。
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
return
}
sid := req.SessionID
if sid == "" {
sid = defaultSessionID
}
sr, err := s.submitChatStream(detachedTurnContext(r.Context()), sid, req)
if err != nil {
// 派发没成功:明确报 503,而不是让客户端以为这一轮在跑(协同 G2)。
s.log.Error("chat stream dispatch", "err", err, "session", sid)
writeError(w, apperr.Wrap(apperr.ErrTimeout, "chat_dispatch_failed", "流式通道不可用,本轮未提交;请重试"))
return
}
s.writeChatStreamAck(w, sr, sid)
}
// submitChatStream 把一次 stream:true 的对话交给编排器,并把它对本次提交的判定
// 交回 HTTP 层(协同 G2)。error 非 nil 表示这一轮**没有**被接收,此时调用方
// 绝不能回 2xx。
//
// 它接收整个 chatRequest(而不是逐个字段),使新增的可选字段(documents,
// design/24 §6.1)自动随载荷下传 —— 少抄一个字段就是一次静默丢附件。
func (s *Server) submitChatStream(ctx context.Context, sessionID string, req chatRequest) (SubmitResult, error) {
payload := map[string]any{
"sessionId": sessionID,
"text": req.Text,
"image": req.Image,
"deviceId": req.DeviceID,
"mode": req.Mode,
"clientMsgId": req.ClientMsgID,
"documents": req.Documents,
}
if s.hub == nil || s.hub.orchestrator == nil {
// 未接编排器:回退到既有的总线触发路径(未接线的 hub 保持完全相同的
// ws.chat 契约),但发布失败与「没有总线」都不再是静默成功。
return s.publishChatStream(ctx, payload)
}
// 直达编排器:streamChat 同步返回提交判定(真正的回合跑在它自己派生的
// goroutine 上),因此这里不会阻塞在模型调用上。
return s.hub.streamChat(ctx, payload), nil
}
// publishChatStream 是 stream:true 的兜底派发:把载荷发布到 ws.chat 主题,由 hub
// 的中继消费。它只在未接编排器时被用到,并且**把错误交回调用方** —— 修复前它吞掉
// 了 Publish 的错误,于是总线关闭/ctx 已取消时客户端照样拿到 200。
func (s *Server) publishChatStream(ctx context.Context, payload map[string]any) (SubmitResult, error) {
if s.bus == nil {
return SubmitResult{}, fmt.Errorf("gateway: chat stream has neither an orchestrator nor a bus: %w", apperr.ErrInternal)
}
if err := s.bus.Publish(ctx, wsChatSubject, payload); err != nil {
return SubmitResult{}, fmt.Errorf("gateway: publish %s: %w", wsChatSubject, err)
}
// 回退路径只能确认「已交给总线」,不能确认编排器收下了:如实按既有契约
// 当已接受处理(2xx),并且已由调用方记日志。
return SubmitResult{Accepted: true, Immediate: true}, nil
}
// submitChatTurn 是 `POST /api/v1/chat` 默认(非 stream)路径的派发:把一个已
// 校验装配好的回合交给编排器跑完,并把它对本次提交的判定写成响应。它是
// submitChatStream 的同步孪生 —— 两者只差「等回合跑完再回」与「立刻回提交判定」,
// 因此都住在同一个派发文件里,且共用同一条「接了编排器就直达、否则回退」的口径。
//
// 它由 handleChat 的尾段原样搬来(零逻辑改动,只把装配好的文档一起接过来),
// 使 handleChat 回到「解码 → 校验 → 装配 → 派发」的薄壳形态(函数长度上限)。
func (s *Server) submitChatTurn(w http.ResponseWriter, r *http.Request, req chatRequest, docs []model.Document) {
if s.runner == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_unavailable", "chat service is not configured"))
return
}
if req.SessionID == "" {
req.SessionID = "web"
}
// Web 主会话是主人的私密对话(JWT = owner),因此它绑定
// 全访问 env:记忆读+写,行为不变。角色
// 来自会话的绑定(design/22 §4.5)。
msgs := chatTurnMessages(req.Text, req.Image, docs)
env := s.envResolver.owner(r.Context(), req.SessionID)
// 客户端的 deviceID、行为模式与可选幂等键(协同 G4)都经 ctx 串进编排器。
ctx := withClientMsgHint(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), clientMsgHint{ClientMsgID: req.ClientMsgID})
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(ctx, req.SessionID, msgs, env)
// 重复到达(同一个 clientMsgId):明确告知既有消息 id,而不是再跑一遍
// 回合、再落一条用户消息(协同 G4)。
if sr.Duplicate {
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": req.SessionID})
return
}
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
} else {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text、image 或 documents 至少需要一项"))
}
return
}
// 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是
// 让请求阻塞在一个可能跑一阵子的回合上。
if sr.Pending {
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
return
}
if sr.Err != nil {
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
return
}
// 未接线编排器时的旧同步路径(未接线的 hub
// 保持完全与 design/15 之前相同的回复契约)。
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
return
}
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
}
// detachedTurnContext 返回一个与请求作用域脱钩、带上限的回合上下文(见
// chatStreamTurnBudget)。它刻意**不**把 cancel 交出去:回合活过这次 HTTP 请求,
// 在这里取消父 ctx 会连坐取消在途回合 —— cancel 只是「提前释放计时器」的优化,
// 上限到点自会释放,因此显式丢弃并写明原因(vet 的 lostcancel 检查也要求把
// cancel 用在某个地方)。
func detachedTurnContext(parent context.Context) context.Context {
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), chatStreamTurnBudget)
_ = cancel
return ctx
}
// writeChatStreamAck 把 stream:true 的提交判定写成响应(协同 G2 顺带项 + G4)。
// 此前它无条件回 200 {"stream":true},于是「排队满」与「重复到达」都只能靠客户端
// 猜;现在四种判定各有明确的状态码与载荷:
// - 重复到达:200 {duplicate:true, messageId}(没有新回合、没有新帧,客户端用
// messageId 把本地乐观气泡对齐到既有消息)
// - 待处理队列满:409 {error:{code:"chat_busy"}}(与同步路径同一口径)
// - 会话正忙、已续接排队:202 {stream:true, queued:true, queueDepth}
// - 已开始:200 {stream:true}
func (s *Server) writeChatStreamAck(w http.ResponseWriter, sr SubmitResult, sessionID string) {
switch {
case sr.Duplicate:
writeJSON(w, http.StatusOK, map[string]any{"duplicate": true, "messageId": sr.MessageID, "sessionId": sessionID})
case sr.PendingFull:
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
case sr.Pending:
writeJSON(w, http.StatusAccepted, map[string]any{"stream": true, "queued": true, "queueDepth": sr.QueueDepth})
default:
writeJSON(w, http.StatusOK, map[string]any{"stream": true})
}
}