Files
Agent-System/internal/server/reply_export_api.go
T
WeiCHEN 1fc7d0d01f feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API
- Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制
- Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动
- 专项修复:
  1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表
  2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply)
  3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
2026-09-13 02:50:21 +00:00

174 lines
4.9 KiB
Go

package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"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) {
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
}
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) {
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
}
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
}
}
}
title := replyexport.DeriveTitle(msg.Content, 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": msg.Content,
"capability": cap,
})
}
// extractMessageIDFromPath 从路由参数或 URL 路径中提取 message_id
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
}