feat(gateway): chat.documents 解析 + 修两处静默吞掉(design/24 S3-B)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 时新钉子红、旧用例仍绿。
This commit is contained in:
AskaEth committed 2026-10-04 17:25:35 +08:00
1 parent a8851dc0a2
commit a4789ec13e
8 files changed
+859 -87

No files matched your search

@@ -7,9 +7,12 @@ import (
"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)。
// 本文件归属 REST `POST /api/v1/chat` 的派发:`stream:true`(Android 协同 §G2)
// 与默认的同步回合(submitChatTurn)两条路径。
//
// 修复前的链路是 publishChatStream → bus.Publish → hub 订阅 → streamChat,三处
// 叠加起来让「客户端拿到 200、却什么都没发生」成为可能:
@@ -49,7 +52,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request, req ch
if sid == "" {
sid = defaultSessionID
}
sr, err := s.submitChatStream(detachedTurnContext(r.Context()), sid, req.Text, req.Image, req.DeviceID, req.Mode, req.ClientMsgID)
sr, err := s.submitChatStream(detachedTurnContext(r.Context()), sid, req)
if err != nil {
// 派发没成功:明确报 503,而不是让客户端以为这一轮在跑(协同 G2)。
s.log.Error("chat stream dispatch", "err", err, "session", sid)
@@ -62,14 +65,18 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request, req ch
// submitChatStream 把一次 stream:true 的对话交给编排器,并把它对本次提交的判定
// 交回 HTTP 层(协同 G2)。error 非 nil 表示这一轮**没有**被接收,此时调用方
// 绝不能回 2xx。
func (s *Server) submitChatStream(ctx context.Context, sessionID, text, image, deviceID, mode, clientMsgID string) (SubmitResult, error) {
//
// 它接收整个 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": text,
"image": image,
"deviceId": deviceID,
"mode": mode,
"clientMsgId": clientMsgID,
"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 保持完全相同的
@@ -96,6 +103,69 @@ func (s *Server) publishChatStream(ctx context.Context, payload map[string]any)
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 只是「提前释放计时器」的优化,