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 时新钉子红、旧用例仍绿。
This commit is contained in:
1 parent
a8851dc0a2
commit
a4789ec13e
8 files changed
+859
-87
No files matched your search
@@ -10,8 +10,6 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
)
|
||||
|
||||
@@ -124,7 +122,11 @@ type chatRequest struct {
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ClientMsgID string `json:"clientMsgId,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
// Documents 是本回合附带的文档引用(design/24 §6.1):最多 maxChatDocuments
|
||||
// 条,每条只给**服务端存储名**(绝不接受任意 URL)。解析与校验见
|
||||
// chat_documents.go。
|
||||
Documents []chatDocumentRef `json:"documents,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
// chatResponse 是同步聊天回复的载荷。
|
||||
@@ -272,8 +274,15 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
|
||||
// 文档引用只认服务端存储名且必须真实存在,非法 / 不存在一律当场拒(design/24 §4.4)。
|
||||
docs, err := s.resolveChatDocuments(req.Documents)
|
||||
if err != nil {
|
||||
s.documentsLog().Warn("chat documents rejected", "session", req.SessionID, "count", len(req.Documents))
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" && len(req.Documents) == 0 {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text、image 或 documents 至少需要一项"))
|
||||
return
|
||||
}
|
||||
if req.Stream {
|
||||
@@ -282,59 +291,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChatStream(w, r, req)
|
||||
return
|
||||
}
|
||||
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 := userChatMessages(req.Text, req.Image)
|
||||
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 or image is required"))
|
||||
}
|
||||
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})
|
||||
// 剩下的交给派发:把回合交给编排器(或未接线时的旧同步路径)并写响应。
|
||||
s.submitChatTurn(w, r, req, docs)
|
||||
}
|
||||
|
||||
// handleListSessions 在接线了 history 存储时返回持久化聊天历史会话
|
||||
@@ -460,16 +418,6 @@ func firstMode(modes []string) string {
|
||||
return modes[0]
|
||||
}
|
||||
|
||||
// userChatMessages 为 chat / stream 请求构建用户回合。image
|
||||
// 存在时该回合成为图片消息(可带文本);否则是
|
||||
// 纯文本消息。空文本加图片得到仅图片的回合。
|
||||
func userChatMessages(text, image string) []model.Message {
|
||||
if strings.TrimSpace(image) != "" {
|
||||
return []model.Message{model.ImageMessage(model.RoleUser, text, image)}
|
||||
}
|
||||
return []model.Message{model.TextMessage(model.RoleUser, text)}
|
||||
}
|
||||
|
||||
// summaryOfSession 把 session.Session 映射为其线上结构。
|
||||
func summaryOfSession(sess session.Session) SessionSummary {
|
||||
return SessionSummary{
|
||||
|
||||
Reference in new issue
Block a user