Files
Agent-System/internal/server/reply_export_api.go
T
weichenandCursor ad9b67d599 feat(artifactvault): close same-tx staging, recover-apply, and GET zero-write gaps
Approve/commit now write FAV staging in the domain transaction, recover-apply materializes scratch collections, and unpublished or GET paths cannot mutate the registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:52:28 +00:00

297 lines
8.6 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 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.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
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.svc.ContextManager().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.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
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.svc.ContextManager().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.svc == nil || s.svc.ContextManager() == 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.svc.ContextManager().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.svc.ContextManager().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
}