diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go index d8e4bd20..dea41625 100644 --- a/internal/handler/chat_uploads.go +++ b/internal/handler/chat_uploads.go @@ -1,13 +1,17 @@ package handler import ( + "archive/zip" "crypto/rand" + "encoding/json" "fmt" "io" + "mime" "net/http" "os" "path/filepath" "sort" + "strconv" "strings" "time" "unicode/utf8" @@ -21,8 +25,15 @@ import ( ) const ( - chatUploadsRootDirName = "chat_uploads" - maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限 + chatUploadsRootDirName = "chat_uploads" + reductionRootDirName = "tmp/reduction" + artifactsRootDirName = "data/conversation_artifacts" + reductionVirtualPrefix = "__reduction__/" + artifactVirtualPrefix = "__conversation_artifact__/" + chatUploadSourceUpload = "upload" + chatUploadSourceReduction = "reduction" + chatUploadSourceConversation = "conversation_artifact" + maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限 ) // ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API @@ -66,6 +77,64 @@ func (h *ChatUploadsHandler) pathAllowed(c *gin.Context, relativePath string) bo return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1]) } +func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id string) bool { + session, ok := security.CurrentSession(c) + if !ok || h.db == nil { + return false + } + if session.Scope == database.RBACScopeAll { + return true + } + id = strings.TrimSpace(id) + switch scope { + case "conversations": + if id == "" || id == "default" { + return false + } + return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) + case "projects": + if id == "" { + return false + } + return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", id) + default: + return false + } +} + +func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix) + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) < 2 { + return false + } + return h.reductionPathAllowed(c, parts[0], parts[1]) +} + +func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool { + session, ok := security.CurrentSession(c) + if !ok || h.db == nil { + return false + } + if session.Scope == database.RBACScopeAll { + return true + } + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" || conversationID == "default" { + return false + } + return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) +} + +func (h *ChatUploadsHandler) conversationArtifactVirtualPathAllowed(c *gin.Context, relativePath string) bool { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix) + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) < 1 { + return false + } + return h.conversationArtifactPathAllowed(c, parts[0]) +} + func (h *ChatUploadsHandler) absRoot() (string, error) { cwd, err := os.Getwd() if err != nil { @@ -74,6 +143,46 @@ func (h *ChatUploadsHandler) absRoot() (string, error) { return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName)) } +func (h *ChatUploadsHandler) absReductionRoot() (string, error) { + if h.db != nil { + if base := strings.TrimSpace(h.db.EinoReductionBaseDir()); base != "" { + if filepath.IsAbs(base) { + return filepath.Abs(base) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, base)) + } + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, reductionRootDirName)) +} + +func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) { + if h.db != nil { + if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" { + if filepath.IsAbs(base) { + return filepath.Abs(base) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, base)) + } + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, artifactsRootDirName)) +} + // resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下 func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) { root, err := h.absRoot() @@ -105,30 +214,44 @@ type ChatUploadFileItem struct { RelativePath string `json:"relativePath"` AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致) Name string `json:"name"` + Source string `json:"source,omitempty"` Size int64 `json:"size"` ModifiedUnix int64 `json:"modifiedUnix"` Date string `json:"date"` ConversationID string `json:"conversationId"` + ProjectID string `json:"projectId,omitempty"` // SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。 SubPath string `json:"subPath"` } -// List GET /api/chat-uploads -func (h *ChatUploadsHandler) List(c *gin.Context) { - conversationFilter := strings.TrimSpace(c.Query("conversation")) +func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache map[string]string) string { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil { + return "" + } + if v, ok := cache[conversationID]; ok { + return v + } + projectID, err := h.db.GetConversationProjectID(conversationID) + if err != nil { + projectID = "" + } + cache[conversationID] = projectID + return projectID +} + +func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) { root, err := h.absRoot() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + return nil, nil, err } // 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏 if err := os.MkdirAll(root, 0755); err != nil { - h.logger.Warn("创建 chat_uploads 根目录失败", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + return nil, nil, err } var files []ChatUploadFileItem var folders []string + projectCache := make(map[string]string) err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -157,6 +280,7 @@ func (h *ChatUploadsHandler) List(c *gin.Context) { if len(parts) >= 3 { convID = parts[1] } + projectID := h.conversationProjectID(convID, projectCache) var subPath string if len(parts) >= 4 { subPath = strings.Join(parts[2:len(parts)-1], "/") @@ -164,29 +288,32 @@ func (h *ChatUploadsHandler) List(c *gin.Context) { if conversationFilter != "" && convID != conversationFilter { return nil } + if projectFilter != "" && projectID != projectFilter { + return nil + } absPath, _ := filepath.Abs(path) files = append(files, ChatUploadFileItem{ RelativePath: relSlash, AbsolutePath: absPath, Name: d.Name(), + Source: chatUploadSourceUpload, Size: info.Size(), ModifiedUnix: info.ModTime().Unix(), Date: dateStr, ConversationID: convID, + ProjectID: projectID, SubPath: subPath, }) return nil }) if err != nil { - h.logger.Warn("列举对话附件失败", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + return nil, nil, err } - if conversationFilter != "" { + if conversationFilter != "" || projectFilter != "" { filteredFolders := make([]string, 0, len(folders)) for _, rel := range folders { parts := strings.Split(rel, "/") - if len(parts) >= 2 && parts[1] == conversationFilter { + if len(parts) >= 2 && (conversationFilter == "" || parts[1] == conversationFilter) && (projectFilter == "" || h.conversationProjectID(parts[1], projectCache) == projectFilter) { filteredFolders = append(filteredFolders, rel) continue } @@ -221,12 +348,480 @@ func (h *ChatUploadsHandler) List(c *gin.Context) { sort.Slice(files, func(i, j int) bool { return files[i].ModifiedUnix > files[j].ModifiedUnix }) - c.JSON(http.StatusOK, gin.H{"files": files, "folders": folders}) + reductionFiles, err := h.collectReductionFiles(c, conversationFilter, projectFilter) + if err != nil { + h.logger.Warn("列举 reduction 产物失败", zap.Error(err)) + } else if len(reductionFiles) > 0 { + files = append(files, reductionFiles...) + } + artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter) + if err != nil { + h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err)) + } else if len(artifactFiles) > 0 { + files = append(files, artifactFiles...) + } + sort.Slice(files, func(i, j int) bool { + return files[i].ModifiedUnix > files[j].ModifiedUnix + }) + return files, folders, nil +} + +func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) { + root, err := h.absReductionRoot() + if err != nil { + return nil, err + } + if st, err := os.Stat(root); err != nil || !st.IsDir() { + return nil, nil + } + projectCache := make(map[string]string) + files := make([]ChatUploadFileItem, 0) + for _, scope := range []string{"conversations", "projects"} { + scopeRoot := filepath.Join(root, scope) + _ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil || d == nil || d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + relSlash := filepath.ToSlash(rel) + parts := strings.Split(relSlash, "/") + if len(parts) < 3 { + return nil + } + ownerID := parts[1] + if !h.reductionPathAllowed(c, scope, ownerID) { + return nil + } + var convID, projectID string + if scope == "conversations" { + convID = ownerID + projectID = h.conversationProjectID(convID, projectCache) + } else { + projectID = ownerID + } + if conversationFilter != "" && convID != conversationFilter { + return nil + } + if projectFilter != "" && projectID != projectFilter { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + name := d.Name() + if filepath.Ext(name) == "" { + name += ".txt" + } + abs, _ := filepath.Abs(path) + files = append(files, ChatUploadFileItem{ + RelativePath: reductionVirtualPrefix + relSlash, + AbsolutePath: abs, + Name: name, + Source: chatUploadSourceReduction, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: info.ModTime().Format("2006-01-02"), + ConversationID: convID, + ProjectID: projectID, + SubPath: strings.Join(parts[2:len(parts)-1], "/"), + }) + return nil + }) + } + return files, nil +} + +func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) { + root, err := h.absConversationArtifactsRoot() + if err != nil { + return nil, err + } + if st, err := os.Stat(root); err != nil || !st.IsDir() { + return nil, nil + } + projectCache := make(map[string]string) + files := make([]ChatUploadFileItem, 0) + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil || d == nil || d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + relSlash := filepath.ToSlash(rel) + parts := strings.Split(relSlash, "/") + if len(parts) < 2 { + return nil + } + convID := parts[0] + if !h.conversationArtifactPathAllowed(c, convID) { + return nil + } + projectID := h.conversationProjectID(convID, projectCache) + if conversationFilter != "" && convID != conversationFilter { + return nil + } + if projectFilter != "" && projectID != projectFilter { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + name := d.Name() + if filepath.Ext(name) == "" { + name += ".txt" + } + abs, _ := filepath.Abs(path) + files = append(files, ChatUploadFileItem{ + RelativePath: artifactVirtualPrefix + relSlash, + AbsolutePath: abs, + Name: name, + Source: chatUploadSourceConversation, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: info.ModTime().Format("2006-01-02"), + ConversationID: convID, + ProjectID: projectID, + SubPath: strings.Join(parts[1:len(parts)-1], "/"), + }) + return nil + }) + return files, nil +} + +func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (string, error) { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix) + rel = filepath.Clean(filepath.FromSlash(rel)) + if rel == "." || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("invalid path") + } + root, err := h.absReductionRoot() + if err != nil { + return "", err + } + full, err := filepath.Abs(filepath.Join(root, rel)) + if err != nil { + return "", err + } + rootAbs, _ := filepath.Abs(root) + if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes reduction root") + } + return full, nil +} + +func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix) + rel = filepath.Clean(filepath.FromSlash(rel)) + if rel == "." || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("invalid path") + } + root, err := h.absConversationArtifactsRoot() + if err != nil { + return "", err + } + full, err := filepath.Abs(filepath.Join(root, rel)) + if err != nil { + return "", err + } + rootAbs, _ := filepath.Abs(root) + if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes conversation artifacts root") + } + return full, nil +} + +func chatUploadItemIsInternal(item ChatUploadFileItem) bool { + return item.Source == chatUploadSourceReduction || + item.Source == chatUploadSourceConversation || + strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) || + strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) +} + +func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (string, error) { + switch { + case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix): + return h.resolveReductionVirtualPath(item.RelativePath) + case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix): + return h.resolveConversationArtifactVirtualPath(item.RelativePath) + default: + return h.resolveUnderChatUploads(item.RelativePath) + } +} + +func chatUploadSourceMatches(item ChatUploadFileItem, sourceFilter string) bool { + sourceFilter = strings.TrimSpace(sourceFilter) + if sourceFilter == "" || sourceFilter == "all" { + return true + } + source := strings.TrimSpace(item.Source) + if source == "" { + source = chatUploadSourceUpload + } + return source == sourceFilter +} + +func chatUploadSearchMatches(item ChatUploadFileItem, search string) bool { + search = strings.ToLower(strings.TrimSpace(search)) + if search == "" { + return true + } + values := []string{ + item.RelativePath, + item.Name, + item.Source, + item.Date, + item.ConversationID, + item.ProjectID, + item.SubPath, + } + for _, value := range values { + if strings.Contains(strings.ToLower(value), search) { + return true + } + } + return false +} + +func filterChatUploadItems(files []ChatUploadFileItem, sourceFilter, search string) []ChatUploadFileItem { + if strings.TrimSpace(sourceFilter) == "" && strings.TrimSpace(search) == "" { + return files + } + out := make([]ChatUploadFileItem, 0, len(files)) + for _, item := range files { + if chatUploadSourceMatches(item, sourceFilter) && chatUploadSearchMatches(item, search) { + out = append(out, item) + } + } + return out +} + +func parsePositiveIntQuery(c *gin.Context, key string, def, max int) int { + raw := strings.TrimSpace(c.Query(key)) + if raw == "" { + return def + } + if strings.EqualFold(raw, "all") { + return 0 + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return def + } + if max > 0 && n > max { + return max + } + return n +} + +func paginateChatUploadItems(files []ChatUploadFileItem, page, pageSize int) []ChatUploadFileItem { + if pageSize <= 0 { + return files + } + if page <= 0 { + page = 1 + } + start := (page - 1) * pageSize + if start >= len(files) { + return []ChatUploadFileItem{} + } + end := start + pageSize + if end > len(files) { + end = len(files) + } + return files[start:end] +} + +// List GET /api/chat-uploads +func (h *ChatUploadsHandler) List(c *gin.Context) { + conversationFilter := strings.TrimSpace(c.Query("conversation")) + projectFilter := strings.TrimSpace(c.Query("project")) + sourceFilter := strings.TrimSpace(c.Query("source")) + search := strings.TrimSpace(c.Query("search")) + page := parsePositiveIntQuery(c, "page", 1, 0) + pageSize := parsePositiveIntQuery(c, "pageSize", 20, 200) + files, folders, err := h.collectFiles(c, conversationFilter, projectFilter) + if err != nil { + h.logger.Warn("列举对话附件失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + files = filterChatUploadItems(files, sourceFilter, search) + total := len(files) + paged := paginateChatUploadItems(files, page, pageSize) + c.JSON(http.StatusOK, gin.H{ + "files": paged, + "folders": folders, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// Export GET /api/chat-uploads/export?conversation=...&project=... +func (h *ChatUploadsHandler) Export(c *gin.Context) { + conversationFilter := strings.TrimSpace(c.Query("conversation")) + projectFilter := strings.TrimSpace(c.Query("project")) + sourceFilter := strings.TrimSpace(c.Query("source")) + search := strings.TrimSpace(c.Query("search")) + files, _, err := h.collectFiles(c, conversationFilter, projectFilter) + if err != nil { + h.logger.Warn("导出对话附件失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + files = filterChatUploadItems(files, sourceFilter, search) + if len(files) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no files to export"}) + return + } + nameParts := []string{"chat-files"} + if projectFilter != "" { + nameParts = append(nameParts, "project-"+projectFilter) + } + if conversationFilter != "" { + nameParts = append(nameParts, "conversation-"+conversationFilter) + } + nameParts = append(nameParts, time.Now().Format("20060102-150405")) + filename := strings.Join(nameParts, "-") + ".zip" + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + manifest := gin.H{ + "exportedAt": time.Now().UTC().Format(time.RFC3339), + "conversationId": conversationFilter, + "projectId": projectFilter, + "source": sourceFilter, + "search": search, + "fileCount": len(files), + "files": files, + "layout": "chat uploads are stored under conversations//; internal outputs are stored under internal//", + "sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName}, + } + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + mw, err := zw.Create("manifest.json") + if err != nil { + h.logger.Warn("写入附件导出清单失败", zap.Error(err)) + return + } + _, _ = mw.Write(manifestBytes) + + used := make(map[string]int) + for _, item := range files { + abs, err := h.resolveListedFilePath(item) + if err != nil { + continue + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + continue + } + var zipName string + if item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) { + rel := strings.TrimPrefix(item.RelativePath, reductionVirtualPrefix) + zipName = filepath.ToSlash(filepath.Join("internal", "reduction", rel)) + if filepath.Ext(zipName) == "" { + zipName += ".txt" + } + } else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) { + rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix) + zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel)) + if filepath.Ext(zipName) == "" { + zipName += ".txt" + } + } else { + conv := strings.TrimSpace(item.ConversationID) + if conv == "" || conv == "_manual" || conv == "_new" { + conv = "manual" + } + zipName = filepath.ToSlash(filepath.Join("conversations", conv, strings.TrimSpace(item.SubPath), item.Name)) + } + if used[zipName] > 0 { + ext := filepath.Ext(zipName) + zipName = strings.TrimSuffix(zipName, ext) + fmt.Sprintf("-%d", used[zipName]+1) + ext + } + used[zipName]++ + fw, err := zw.Create(zipName) + if err != nil { + h.logger.Warn("创建附件导出项失败", zap.String("path", item.RelativePath), zap.Error(err)) + continue + } + src, err := os.Open(abs) + if err != nil { + continue + } + _, copyErr := io.Copy(fw, src) + _ = src.Close() + if copyErr != nil { + h.logger.Warn("复制附件导出项失败", zap.String("path", item.RelativePath), zap.Error(copyErr)) + return + } + } + if h.audit != nil { + h.audit.RecordOK(c, "file", "export", "导出对话附件", "chat_upload", filename, map[string]interface{}{ + "conversation_id": conversationFilter, + "project_id": projectFilter, + "file_count": len(files), + }) + } } // Download GET /api/chat-uploads/download?path=... func (h *ChatUploadsHandler) Download(c *gin.Context) { p := c.Query("path") + if strings.HasPrefix(strings.TrimSpace(p), reductionVirtualPrefix) { + if !h.reductionVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err := h.resolveReductionVirtualPath(p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + name := filepath.Base(abs) + if filepath.Ext(name) == "" { + name += ".txt" + } + c.FileAttachment(abs, name) + return + } + if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) { + if !h.conversationArtifactVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err := h.resolveConversationArtifactVirtualPath(p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + name := filepath.Base(abs) + if filepath.Ext(name) == "" { + name += ".txt" + } + c.FileAttachment(abs, name) + return + } if !h.pathAllowed(c, p) { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 1e45b6aa..a1933000 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -5798,10 +5798,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "get": map[string]interface{}{ "tags": []string{"对话附件"}, "summary": "列出附件", - "description": "获取对话附件文件列表,可按对话ID过滤。", + "description": "获取对话文件列表,包含手动上传附件、工具输出和会话产物,可按会话、项目、来源、文件名搜索和分页过滤。", "operationId": "listChatUploads", "parameters": []map[string]interface{}{ {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, + {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, + {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, + {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, + {"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}}, + {"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}}, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ @@ -5823,11 +5828,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "modifiedUnix": map[string]interface{}{"type": "integer"}, "date": map[string]interface{}{"type": "string"}, "conversationId": map[string]interface{}{"type": "string"}, + "projectId": map[string]interface{}{"type": "string"}, "subPath": map[string]interface{}{"type": "string"}, + "source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"}, }, }, }, "folders": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "total": map[string]interface{}{"type": "integer"}, + "page": map[string]interface{}{"type": "integer"}, + "pageSize": map[string]interface{}{ + "type": "integer", + }, }, }, }, @@ -5902,6 +5914,31 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/chat-uploads/export": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []string{"对话附件"}, + "summary": "导出附件", + "description": "按当前过滤条件导出对话文件 ZIP,包含 manifest.json。", + "operationId": "exportChatUploads", + "parameters": []map[string]interface{}{ + {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, + {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, + {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, + {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "ZIP文件下载", + "content": map[string]interface{}{ + "application/zip": map[string]interface{}{ + "schema": map[string]interface{}{"type": "string", "format": "binary"}, + }, + }, + }, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, "/api/chat-uploads/download": map[string]interface{}{ "get": map[string]interface{}{ "tags": []string{"对话附件"}, diff --git a/internal/handler/openapi_i18n.go b/internal/handler/openapi_i18n.go index 21286e49..d0480c0f 100644 --- a/internal/handler/openapi_i18n.go +++ b/internal/handler/openapi_i18n.go @@ -61,7 +61,7 @@ var apiDocI18nSummaryToKey = map[string]string{ "获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState", "获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations", "执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp", - "列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload", + "列出附件": "listChatUploads", "导出附件": "exportChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload", "下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent", "写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload", "企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",