package server import ( "fmt" "net/http" "strconv" "strings" "chat-agent-system/internal/artifactvault" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/replyexport" ) // handleGetExportCapability 获取单条消息权威导出能力 // GET /api/v1/chat/messages/{message_id}/export-capability func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Request) { user, ok := s.requireAuthenticatedUser(w, r) if !ok { return } if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } msgID := extractMessageIDFromPath(r, "export-capability") if msgID <= 0 { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "INVALID_MESSAGE_ID", "message": "无效的消息ID", }) return } msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", "message": "指定消息不存在或已被删除", }) return } if !s.assertMessageOwnedByUser(w, r, msg, user.ID) { return } cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability) if !ok { // 动态降级重新评估 hasCard := false if msg.Metadata != nil { if _, exists := msg.Metadata["interactive_card"]; exists { hasCard = true } } _, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{ Role: msg.Role, Status: "completed", Content: msg.Content, HasInteractiveCard: hasCard, Revision: msg.ContentRevision, }) } resp := map[string]any{ "message_id": msg.ID, "policy": cap.Policy, "formats": cap.Formats, "body_selector": "content_only", "content_revision": cap.ContentRevision, "content_sha256": cap.ContentSha256, } if cap.ReasonCode != "" { resp["reason_code"] = cap.ReasonCode } writeJSONResponse(w, http.StatusOK, resp) } // handleGetExportSource 获取单条消息受控不可变正文快照 // GET /api/v1/chat/messages/{message_id}/export-source?content_revision= func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) { user, ok := s.requireAuthenticatedUser(w, r) if !ok { return } if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } msgID := extractMessageIDFromPath(r, "export-source") if msgID <= 0 { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "INVALID_MESSAGE_ID", "message": "无效的消息ID", }) return } msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", "message": "指定消息不存在或已被删除", }) return } if !s.assertMessageOwnedByUser(w, r, msg, user.ID) { return } cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability) if !ok { hasCard := false if msg.Metadata != nil { if _, exists := msg.Metadata["interactive_card"]; exists { hasCard = true } } _, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{ Role: msg.Role, Status: "completed", Content: msg.Content, HasInteractiveCard: hasCard, Revision: msg.ContentRevision, }) } if cap.Policy == replyexport.PolicyNone { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "MESSAGE_NOT_EXPORTABLE", "message": "该消息不支持导出为文档", }) return } // 校验 revision(若客户端传入了指定版本) if reqRevStr := r.URL.Query().Get("content_revision"); reqRevStr != "" { if reqRev, err := strconv.Atoi(reqRevStr); err == nil && reqRev > 0 { if reqRev != cap.ContentRevision { writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "MESSAGE_REVISION_CHANGED", "message": "消息内容版本已更新,请重新加载最新快照", }) return } } } markdown := msg.Content source := "ledger" if strings.TrimSpace(markdown) == "" && s.cfg != nil && s.cfg.FAVDegradedRead { env := s.cfg.Env if env == "" { env = "dev" } if payload, sha, verr := s.tryVaultDegraded(r.Context(), env, fmt.Sprintf("msg:%d", msg.ID), artifactvault.ProjectKeyFromID(messageProjectIDFromMeta(msg.Metadata))); verr == nil && len(payload) > 0 { markdown = string(payload) cap.ContentSha256 = sha source = "vault/degraded" } } title := replyexport.DeriveTitle(markdown, msg.CreatedAt) snapshotID := fmt.Sprintf("msg:%d:rev:%d", msg.ID, cap.ContentRevision) w.Header().Set("Cache-Control", "private, no-store") writeJSONResponse(w, http.StatusOK, map[string]any{ "snapshot_id": snapshotID, "message_id": msg.ID, "content_revision": cap.ContentRevision, "content_sha256": cap.ContentSha256, "title": title, "markdown": markdown, "capability": cap, "source": source, }) } // handlePinExport archives a completed copy_only assistant reply after an explicit pin. // POST /api/v1/chat/messages/{message_id}/pin-export func (s *Server) handlePinExport(w http.ResponseWriter, r *http.Request) { user, ok := s.requireAuthenticatedUser(w, r) if !ok { return } if s.ctxMgr() == nil || s.vaultIngest == nil { http.Error(w, "artifact vault not initialized", http.StatusServiceUnavailable) return } msgID := extractMessageIDFromPath(r, "pin-export") if msgID <= 0 { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "INVALID_MESSAGE_ID", "message": "无效的消息ID", }) return } msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", "message": "指定消息不存在或已被删除", }) return } if !s.assertMessageOwnedByUser(w, r, msg, user.ID) { return } if msg.Role != "assistant" { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "MESSAGE_NOT_PINNABLE", "message": "仅助手回复可显式 pin", }) return } turn, err := s.ctxMgr().GetChatTurnByAssistantMessageID(r.Context(), msg.SessionID, msg.ID) if err != nil || turn == nil || turn.Status != ctxmgr.TurnStatusCompleted || turn.CompletedAt == nil { writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "TURN_NOT_COMPLETED", "message": "未完成回合不得 pin 进保险库", }) return } cap, okCap := msg.ExportCapability.(replyexport.MessageExportCapability) if !okCap { _, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{ Role: msg.Role, Status: "completed", Content: msg.Content, Revision: msg.ContentRevision, }) } if cap.Policy != replyexport.PolicyCopyOnly { writeJSONResponse(w, http.StatusBadRequest, map[string]string{ "error_code": "PIN_POLICY_REJECTED", "message": "仅 copy_only 需要显式 pin;full 已自动归档,none 永不进入", }) return } eventID := fmt.Sprintf("pin:%d:%d", msg.ID, cap.ContentRevision) err = s.vaultIngest.AssistantReplyPin( r.Context(), turn.ProjectID, fmt.Sprintf("msg:%d", msg.ID), eventID, msg.ID, "", string(cap.Policy), turn.CompletedAt, []byte(msg.Content), ) if err != nil { writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "PIN_REJECTED", "message": "保险库拒绝该 pin", }) return } writeJSONResponse(w, http.StatusOK, map[string]any{ "message_id": msg.ID, "policy": cap.Policy, "status": "requested", }) } // extractMessageIDFromPath 从路由参数或 URL 路径中提取 message_id func messageProjectIDFromMeta(meta map[string]any) int64 { if meta == nil { return 0 } if pVal, ok := meta["project_id"].(int64); ok { return pVal } if pVal, ok := meta["project_id"].(float64); ok { return int64(pVal) } return 0 } func extractMessageIDFromPath(r *http.Request, actionName string) int64 { // 优先尝试 Go 1.22 PathValue val := r.PathValue("message_id") if val != "" { id, err := strconv.ParseInt(val, 10, 64) if err == nil && id > 0 { return id } } // 降级:从路径分割提取 /api/v1/chat/messages/{id}/{action} parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") for i, p := range parts { if p == "messages" && i+1 < len(parts) { id, err := strconv.ParseInt(parts[i+1], 10, 64) if err == nil && id > 0 { return id } } } return 0 }