소스 검색

fix:修复AI助手上传文件AI无法读取的问题

Jerry Liang 2 달 전
부모
커밋
73326f7431

+ 6 - 3
service/cmd/mcp/.kiro/steering/file-output-rules.md

@@ -30,7 +30,7 @@ $$FILE_DOWNLOAD:{"key":"<返回的key>","fileName":"<文件名>","size":<文件
 
 ```json
 {
-  "path": "/uploads/report.csv",
+  "fileName": "report.csv",
   "content": "id,name,value\n1,test,100",
   "userId": "jerry",
   "sessionId": "sess_abc123"
@@ -42,7 +42,6 @@ $$FILE_DOWNLOAD:{"key":"<返回的key>","fileName":"<文件名>","size":<文件
 ```json
 {
   "message": "文件写入成功",
-  "path": "/uploads/report.csv",
   "fileName": "report.csv",
   "size": 2048,
   "key": "download/jerry/sess_abc123/a1b2c3d4_report.csv"
@@ -59,16 +58,20 @@ $$FILE_DOWNLOAD:{"key":"download/jerry/sess_abc123/a1b2c3d4_report.csv","fileNam
 
 ## 读文件策略
 
+> 说明:读取工具 `storage_file_read` / `storage_file_read_to_local` 通过 `key` 参数定位文件;`key` 来自 `storage_file_write` / `storage_file_upload` 的返回值,或 `storage_file_list` 列出的文件项。`storage_file_list` 可传入 `userId` / `sessionId` 限定范围(取值方式同上,来自消息开头的 `[context]` 行)。
+
 根据文件类型选择不同的读取方式:
 
 ### storage_file_read(文本文件)
 
 - 适用于:CSV、JSON、TXT、Markdown、代码文件等文本类型
+- 入参:`key`(对象 key)
 - 返回纯文本内容,可直接用于分析
 
 ### storage_file_read_to_local(任意文件)
 
 - 适用于:图片、PDF、压缩包、Excel 等二进制文件
+- 入参:`key`(对象 key)
 - 将文件下载到本地临时路径(`/tmp/fwh-toolbox-agent/downloads/`),返回本地文件路径 `localPath`
 - **图片文件**(png/jpg/gif/webp)下载后可通过本地路径进行 AI 视觉分析
 - 其他二进制文件下载后主要用于中转(如重新上传、返回给前端下载)
@@ -78,7 +81,7 @@ $$FILE_DOWNLOAD:{"key":"download/jerry/sess_abc123/a1b2c3d4_report.csv","fileNam
 - 用户要求分析文本文件 → `storage_file_read`
 - 用户要求分析图片/二进制内容 → `storage_file_read_to_local`
 - 需要获取文件原始数据(中转、转发) → `storage_file_read_to_local`
-- 不确定文件类型时 → 先用 `storage_file_list` 查看文件信息,再选择合适的读取方式
+- 不确定文件类型或不知道 key 时 → 先用 `storage_file_list` 查看文件信息(含 key),再选择合适的读取方式
 
 ## 写文件策略
 

+ 0 - 6
service/cmd/mcp/main.go

@@ -13,12 +13,10 @@ import (
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
-	"fwh-toolbox-server/utils/filestore"
 	minioUtil "fwh-toolbox-server/utils/minio"
 
 	"fwh-toolbox-server/cmd/mcp/tools"
 	"fwh-toolbox-server/cmd/mcp/tools/middleware"
-	"fwh-toolbox-server/cmd/mcp/tools/storage"
 )
 
 const (
@@ -54,10 +52,6 @@ func main() {
 	// service_feedback_* 会空指针 panic。
 	repositories.InitPlane(&cfg.PlaneWebApi)
 
-	// 初始化 MCP FileStore
-	mcpFS := filestore.NewFileStore("download")
-	storage.InitFileStore(mcpFS)
-
 	// 创建 MCP Server
 	s := server.NewMCPServer(
 		serverName,

+ 43 - 59
service/cmd/mcp/tools/storage/file.go

@@ -13,18 +13,13 @@ import (
 	"fwh-toolbox-server/utils/filestore"
 )
 
-// mcpFileStore MCP 专用的文件存储实例
-var mcpFileStore *filestore.FileStore
-
-// InitFileStore 初始化 MCP FileStore(由 main.go 调用)
-func InitFileStore(fs *filestore.FileStore) {
-	mcpFileStore = fs
-}
-
-// GetFileStore 获取 MCP FileStore 实例
-func GetFileStore() *filestore.FileStore {
-	return mcpFileStore
-}
+// MinIO 文件前缀约定:
+//   - uploadPrefix:用户上传、供 Agent 读取的文件
+//   - downloadPrefix:Agent 生成、供用户下载的文件
+const (
+	uploadPrefix   = "upload"
+	downloadPrefix = "download"
+)
 
 // localTempDir 用于存放从 MinIO 下载的临时文件
 const localTempDir = "/tmp/fwh-toolbox-agent/downloads"
@@ -32,24 +27,24 @@ const localTempDir = "/tmp/fwh-toolbox-agent/downloads"
 func registerFileTools(s *server.MCPServer) {
 	s.AddTool(
 		mcp.NewTool("storage_file_read",
-			mcp.WithDescription("读取用户上传的文本文件内容(返回纯文本)。用户通过前端上传的文件存储在 /uploads/ 路径下。仅适用于文本类文件。"),
-			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}")),
+			mcp.WithDescription("读取文件文本内容(返回纯文本)。仅适用于文本类文件。key 为写入/上传/列表返回的对象 key。"),
+			mcp.WithString("key", mcp.Required(), mcp.Description("文件的对象 key(写入/上传成功或 storage_file_list 返回的 key)")),
 		),
 		handleFileRead,
 	)
 
 	s.AddTool(
 		mcp.NewTool("storage_file_read_to_local",
-			mcp.WithDescription("将用户上传的文件下载到本地临时路径。支持任意文件类型(图片、PDF、压缩包等)。返回本地文件路径,可直接用于后续处理或分析。"),
-			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}")),
+			mcp.WithDescription("将文件下载到本地临时路径。支持任意文件类型(图片、PDF、压缩包等)。返回本地文件路径,可直接用于后续处理或分析。key 为写入/上传/列表返回的对象 key。"),
+			mcp.WithString("key", mcp.Required(), mcp.Description("文件的对象 key(写入/上传成功或 storage_file_list 返回的 key)")),
 		),
 		handleFileReadToLocal,
 	)
 
 	s.AddTool(
 		mcp.NewTool("storage_file_write",
-			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。最大 20MB。文件路径会自动添加 /uploads/ 前缀。如需上传本地文件请使用 storage_file_upload。"),
-			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}(如 /uploads/report.csv)")),
+			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。最大 20MB。如需上传本地文件请使用 storage_file_upload。"),
+			mcp.WithString("fileName", mcp.Required(), mcp.Description("文件名(如 report.csv)")),
 			mcp.WithString("content", mcp.Required(), mcp.Description("要写入的文本内容(最大 20MB)")),
 			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
 			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
@@ -59,7 +54,9 @@ func registerFileTools(s *server.MCPServer) {
 
 	s.AddTool(
 		mcp.NewTool("storage_file_list",
-			mcp.WithDescription("列出当前会话中用户上传的所有文件,返回文件名、路径、大小等信息。"),
+			mcp.WithDescription("列出当前会话中用户上传的所有文件,返回文件名、key、大小等信息。"),
+			mcp.WithString("userId", mcp.Description("用户标识,用于定位文件(尽量填写,不传则使用默认值 mcp-agent)")),
+			mcp.WithString("sessionId", mcp.Description("会话标识,用于定位文件(尽量填写,不传则使用默认值 mcp-session)")),
 		),
 		handleFileList,
 	)
@@ -77,20 +74,16 @@ func registerFileTools(s *server.MCPServer) {
 }
 
 func handleFileRead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	path, err := req.RequireString("path")
+	key, err := req.RequireString("key")
 	if err != nil {
-		return helper.ParamError("path"), nil
+		return helper.ParamError("key"), nil
 	}
 
-	if mcpFileStore == nil {
-		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
+	if !filestore.HasFile(key) {
+		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", key)), nil
 	}
 
-	if !mcpFileStore.HasFile(path) {
-		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", path)), nil
-	}
-
-	content, err := mcpFileStore.ReadFile(path)
+	content, err := filestore.ReadFile(key)
 	if err != nil {
 		return helper.ServiceError("读取文件", err), nil
 	}
@@ -99,20 +92,16 @@ func handleFileRead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 }
 
 func handleFileReadToLocal(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	path, err := req.RequireString("path")
+	key, err := req.RequireString("key")
 	if err != nil {
-		return helper.ParamError("path"), nil
+		return helper.ParamError("key"), nil
 	}
 
-	if mcpFileStore == nil {
-		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
+	if !filestore.HasFile(key) {
+		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", key)), nil
 	}
 
-	if !mcpFileStore.HasFile(path) {
-		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", path)), nil
-	}
-
-	data, err := mcpFileStore.ReadFileBytes(path)
+	data, err := filestore.ReadFileBytes(key)
 	if err != nil {
 		return helper.ServiceError("读取文件", err), nil
 	}
@@ -122,8 +111,11 @@ func handleFileReadToLocal(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
 		return helper.ServiceError("创建临时目录", err), nil
 	}
 
-	// 写入本地临时文件
-	fileName := filepath.Base(path)
+	// 从 key 还原文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符)
+	fileName := filepath.Base(key)
+	if len(fileName) > 9 {
+		fileName = fileName[9:]
+	}
 	localPath := filepath.Join(localTempDir, fileName)
 	if err := os.WriteFile(localPath, data, 0644); err != nil {
 		return helper.ServiceError("写入本地文件", err), nil
@@ -138,11 +130,13 @@ func handleFileReadToLocal(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
 }
 
 func handleFileList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	if mcpFileStore == nil {
-		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
-	}
+	userId := req.GetString("userId", "mcp-agent")
+	sessionId := req.GetString("sessionId", "mcp-session")
 
-	files := mcpFileStore.GetFiles()
+	files, err := filestore.GetFiles(uploadPrefix, userId, sessionId)
+	if err != nil {
+		return helper.ServiceError("列举文件", err), nil
+	}
 	if len(files) == 0 {
 		return helper.EmptyResult("当前没有上传的文件"), nil
 	}
@@ -151,9 +145,9 @@ func handleFileList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 }
 
 func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	path, err := req.RequireString("path")
+	fileName, err := req.RequireString("fileName")
 	if err != nil {
-		return helper.ParamError("path"), nil
+		return helper.ParamError("fileName"), nil
 	}
 	content, err := req.RequireString("content")
 	if err != nil {
@@ -166,21 +160,17 @@ func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		return helper.ParamInvalidError("content", fmt.Sprintf("内容大小 %d bytes 超过 20MB 限制", len(content))), nil
 	}
 
-	if mcpFileStore == nil {
-		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
-	}
-
 	userId := req.GetString("userId", "mcp-agent")
 	sessionId := req.GetString("sessionId", "mcp-session")
 
-	record, err := mcpFileStore.WriteFile(userId, sessionId, path, content)
+	// 仅取文件名部分,上传到 "download" 前缀(供用户下载)
+	record, err := filestore.DownloadFile(userId, sessionId, filepath.Base(fileName), content)
 	if err != nil {
 		return helper.ServiceError("写入文件", err), nil
 	}
 
 	return helper.JSONResultOrFileAuto(map[string]any{
 		"message":  "文件写入成功",
-		"path":     record.Path,
 		"fileName": record.FileName,
 		"size":     record.Size,
 		"key":      record.Key,
@@ -193,10 +183,6 @@ func handleFileUpload(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
 		return helper.ParamError("localPath"), nil
 	}
 
-	if mcpFileStore == nil {
-		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
-	}
-
 	// 检查本地文件是否存在
 	info, err := os.Stat(localPath)
 	if err != nil {
@@ -230,16 +216,14 @@ func handleFileUpload(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
 	userId := req.GetString("userId", "mcp-agent")
 	sessionId := req.GetString("sessionId", "mcp-session")
 
-	virtualPath := fmt.Sprintf("/uploads/%s", filepath.Base(fileName))
-
-	record, err := mcpFileStore.WriteBytesWithSession(userId, sessionId, virtualPath, data)
+	// 上传到 "download" 前缀(供用户下载),仅取文件名部分
+	record, err := filestore.DownloadBytes(userId, sessionId, filepath.Base(fileName), data)
 	if err != nil {
 		return helper.ServiceError("上传文件", err), nil
 	}
 
 	return helper.JSONResultOrFileAuto(map[string]any{
 		"message":  "文件上传成功",
-		"path":     record.Path,
 		"fileName": record.FileName,
 		"size":     record.Size,
 		"key":      record.Key,

+ 2 - 4
service/controllers/aiChatControllers.go

@@ -379,9 +379,8 @@ func UploadAiFile(c *fiber.Ctx) error {
 		return Error(c, "读取文件失败")
 	}
 
-	// 写入 FileStore(存到 ACP 的 FileStore,这样 Agent readTextFile 可以读到)
-	virtualPath := fmt.Sprintf("/uploads/%s", file.Filename)
-	record, err := sess.Client.FileStore.WriteFile(userId, sess.SessionId, virtualPath, string(data))
+	// 上传到 MinIO 的 "upload" 前缀(这样 Agent readTextFile 可以读到)
+	record, err := filestore.UploadFile(userId, sess.SessionId, file.Filename, string(data))
 	if err != nil {
 		return Error(c, fmt.Sprintf("保存文件失败: %v", err))
 	}
@@ -389,7 +388,6 @@ func UploadAiFile(c *fiber.Ctx) error {
 	return Success(c, fiber.Map{
 		"key":      record.Key,
 		"fileName": record.FileName,
-		"path":     record.Path,
 		"size":     record.Size,
 	})
 }

+ 8 - 14
service/utils/acp/acp.go

@@ -232,9 +232,8 @@ type Client struct {
 	permissionCh chan string  // 接收前端选择的 optionId
 	eventCh      chan<- Event // 当前 prompt 的事件通道(用于推送 permission 事件)
 
-	// 文件存储
-	FileStore *filestore.FileStore
-	UserId    string
+	// 文件存储:会话身份(用于定位 MinIO 中的文件)
+	UserId string
 
 	// 文本累积(用于合并日志输出)
 	textBuffer string
@@ -420,7 +419,6 @@ func NewClient(cfg ClientConfig) (*Client, error) {
 		stdin:        stdin,
 		scanner:      scanner,
 		permissionCh: make(chan string, 1),
-		FileStore:    filestore.NewFileStore("upload"),
 	}, nil
 }
 
@@ -816,11 +814,6 @@ func (c *Client) Close() {
 	case c.permissionCh <- "deny":
 	default:
 	}
-
-	// 清理 FileStore
-	if c.FileStore != nil {
-		c.FileStore.Clear()
-	}
 }
 
 // IsAlive 检查 Agent 进程是否存活
@@ -1086,10 +1079,11 @@ func (c *Client) handleAgentRequest(rawId json.RawMessage, method string, rawPar
 			var content string
 			var readErr error
 
-			// 优先从 FileStore(MinIO)读取
-			if c.FileStore.HasFile(params.Path) {
-				content, readErr = c.FileStore.ReadFile(params.Path)
-				zap.L().Debug("[ACP] readTextFile 从 MinIO 读取", zap.String("path", params.Path))
+			// 优先从 FileStore(MinIO)读取:先按路径解析出 object key
+			key, keyErr := filestore.FindKeyByPath("upload", c.UserId, c.sessionId, params.Path)
+			if keyErr == nil && key != "" {
+				content, readErr = filestore.ReadFile(key)
+				zap.L().Debug("[ACP] readTextFile 从 MinIO 读取", zap.String("path", params.Path), zap.String("key", key))
 			} else {
 				// 回退到本地文件系统
 				data, err := os.ReadFile(params.Path)
@@ -1125,7 +1119,7 @@ func (c *Client) handleAgentRequest(rawId json.RawMessage, method string, rawPar
 			Content string `json:"content"`
 		}
 		if err := json.Unmarshal(rawParams, &params); err == nil && params.Path != "" {
-			record, err := c.FileStore.WriteFile(c.UserId, c.sessionId, params.Path, params.Content)
+			record, err := filestore.UploadFile(c.UserId, c.sessionId, filepath.Base(params.Path), params.Content)
 			if err == nil {
 				respData, _ = json.Marshal(map[string]any{
 					"jsonrpc": "2.0",

+ 113 - 133
service/utils/filestore/filestore.go

@@ -7,49 +7,37 @@ import (
 	"io"
 	"path/filepath"
 	"strings"
-	"sync"
 	"time"
 
+	minioUtil "fwh-toolbox-server/utils/minio"
+
 	"github.com/google/uuid"
 	"github.com/minio/minio-go/v7"
-	minioUtil "fwh-toolbox-server/utils/minio"
 	"go.uber.org/zap"
 )
 
+// AIFileBucket AI 相关文件统一存放的 MinIO bucket。
 const AIFileBucket = "fwh-toolbox-ai-file"
+
+// AIFileExpireDays 上传文件的过期天数,超过后由 MinIO 生命周期策略自动清理。
 const AIFileExpireDays = 7
 
-// FileRecord 文件记录
+// FileRecord 文件记录,描述一个已存入 MinIO 的对象。
+// Key 是唯一寻址标识(完整 object key),读取时直接使用它。
 type FileRecord struct {
-	Key       string `json:"key"`       // MinIO object key
+	Key       string `json:"key"`       // MinIO object key,读取文件时的唯一寻址标识
 	FileName  string `json:"fileName"`  // 原始文件名
-	Path      string `json:"path"`      // Agent 请求的路径
-	Size      int64  `json:"size"`      // 文件大小
-	CreatedAt int64  `json:"createdAt"` // 创建时间戳
-}
-
-// FileStore 会话级别的虚拟文件系统
-type FileStore struct {
-	prefix string // "upload" 或 "download",用于区分文件来源
-	mu     sync.RWMutex
-	files  map[string]*FileRecord // path → FileRecord(同路径覆盖更新)
-}
-
-// NewFileStore 创建文件存储
-// prefix: "upload" 或 "download",对应 MinIO 中的子目录前缀
-func NewFileStore(prefix string) *FileStore {
-	return &FileStore{
-		prefix: prefix,
-		files:  make(map[string]*FileRecord),
-	}
+	Size      int64  `json:"size"`      // 文件大小(字节)
+	CreatedAt int64  `json:"createdAt"` // 创建时间戳(Unix 秒)
 }
 
-// WriteFile 将内容上传到 MinIO,返回文件记录
+// writeFile 将文本内容上传到 MinIO,返回文件记录。
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
+// 其中 uuid 前缀用于避免同名文件相互覆盖。
 // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRecord, error) {
-	fileName := filepath.Base(path)
+func writeFile(prefix, userId, sessionId, fileName, content string) (*FileRecord, error) {
 	uniqueId := uuid.New().String()[:8]
-	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
+	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
 
 	// 上传到 MinIO(带7天过期)
 	reader := strings.NewReader(content)
@@ -66,19 +54,12 @@ func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRe
 	record := &FileRecord{
 		Key:       objectKey,
 		FileName:  fileName,
-		Path:      path,
 		Size:      int64(len(content)),
 		CreatedAt: time.Now().Unix(),
 	}
 
-	// 存入内存映射(同路径覆盖)
-	fs.mu.Lock()
-	fs.files[path] = record
-	fs.mu.Unlock()
-
 	zap.L().Info("[FileStore] 文件已保存到MinIO",
-		zap.String("prefix", fs.prefix),
-		zap.String("path", path),
+		zap.String("prefix", prefix),
 		zap.String("key", objectKey),
 		zap.String("fileName", fileName),
 	)
@@ -86,25 +67,17 @@ func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRe
 	return record, nil
 }
 
-// WriteBytes 将字节内容上传到 MinIO(支持二进制文件)
-// objectKey 格式: {prefix}/{userId}/{timestamp}_{uuid}_{fileName}
-func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentType string) (*FileRecord, error) {
+// writeBytes 将字节内容上传到 MinIO(支持任意文件类型)
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
+func writeBytes(prefix, userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
 	uniqueId := uuid.New().String()[:8]
-	timestamp := time.Now().Unix()
-	objectKey := fmt.Sprintf("%s/%s/%d_%s_%s", fs.prefix, userId, timestamp, uniqueId, fileName)
+	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
 
-	// 虚拟路径(供 Agent readTextFile 使用)
-	virtualPath := fmt.Sprintf("/uploads/%s", fileName)
-
-	reader := strings.NewReader(string(data))
+	reader := bytes.NewReader(data)
 	ctx := context.Background()
 
-	if contentType == "" {
-		contentType = DetectContentType(fileName)
-	}
-
 	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
-		ContentType: contentType,
+		ContentType: DetectContentType(fileName),
 		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
 	})
 	if err != nil {
@@ -114,19 +87,12 @@ func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentTyp
 	record := &FileRecord{
 		Key:       objectKey,
 		FileName:  fileName,
-		Path:      virtualPath,
 		Size:      int64(len(data)),
 		CreatedAt: time.Now().Unix(),
 	}
 
-	// 存入内存映射
-	fs.mu.Lock()
-	fs.files[virtualPath] = record
-	fs.mu.Unlock()
-
 	zap.L().Info("[FileStore] 文件已保存到MinIO",
-		zap.String("prefix", fs.prefix),
-		zap.String("path", virtualPath),
+		zap.String("prefix", prefix),
 		zap.String("key", objectKey),
 		zap.String("fileName", fileName),
 	)
@@ -134,67 +100,39 @@ func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentTyp
 	return record, nil
 }
 
-// WriteBytesWithSession 将字节内容上传到 MinIO(支持任意文件类型)
-// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-func (fs *FileStore) WriteBytesWithSession(userId, sessionId, path string, data []byte) (*FileRecord, error) {
-	fileName := filepath.Base(path)
-	uniqueId := uuid.New().String()[:8]
-	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
-
-	reader := bytes.NewReader(data)
-	ctx := context.Background()
-
-	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
-		ContentType: DetectContentType(fileName),
-		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
-	}
-
-	record := &FileRecord{
-		Key:       objectKey,
-		FileName:  fileName,
-		Path:      path,
-		Size:      int64(len(data)),
-		CreatedAt: time.Now().Unix(),
-	}
+// UploadFile 上传文本内容到 MinIO 的 "upload" 前缀下(用户上传/供 Agent 读取的文件)。
+func UploadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
+	return writeFile("upload", userId, sessionId, fileName, content)
+}
 
-	fs.mu.Lock()
-	fs.files[path] = record
-	fs.mu.Unlock()
+// DownloadFile 上传文本内容到 MinIO 的 "download" 前缀下(Agent 生成/供用户下载的文件)。
+func DownloadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
+	return writeFile("download", userId, sessionId, fileName, content)
+}
 
-	zap.L().Info("[FileStore] 文件已保存到MinIO",
-		zap.String("prefix", fs.prefix),
-		zap.String("path", path),
-		zap.String("key", objectKey),
-		zap.String("fileName", fileName),
-	)
+// UploadBytes 上传字节内容到 MinIO 的 "upload" 前缀下(支持任意文件类型)。
+func UploadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
+	return writeBytes("upload", userId, sessionId, fileName, data)
+}
 
-	return record, nil
+// DownloadBytes 上传字节内容到 MinIO 的 "download" 前缀下(支持任意文件类型)。
+func DownloadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
+	return writeBytes("download", userId, sessionId, fileName, data)
 }
 
-// ReadFile 从 MinIO 读取文件内容
-func (fs *FileStore) ReadFile(path string) (string, error) {
-	data, err := fs.ReadFileBytes(path)
+// ReadFile 按 key 从 MinIO 读取文件文本内容。
+func ReadFile(key string) (string, error) {
+	data, err := ReadFileBytes(key)
 	if err != nil {
 		return "", err
 	}
 	return string(data), nil
 }
 
-// ReadFileBytes 从 MinIO 读取文件原始字节内容
-func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
-	fs.mu.RLock()
-	record, ok := fs.files[path]
-	fs.mu.RUnlock()
-
-	if !ok {
-		return nil, fmt.Errorf("文件不存在: %s", path)
-	}
-
+// ReadFileBytes 按 key 从 MinIO 读取文件原始字节内容。
+func ReadFileBytes(key string) ([]byte, error) {
 	ctx := context.Background()
-	obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, record.Key, minio.GetObjectOptions{})
+	obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, key, minio.GetObjectOptions{})
 	if err != nil {
 		return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
 	}
@@ -208,44 +146,86 @@ func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
 	return data, nil
 }
 
-// HasFile 检查路径是否有文件记录
-func (fs *FileStore) HasFile(path string) bool {
-	fs.mu.RLock()
-	defer fs.mu.RUnlock()
-	_, ok := fs.files[path]
-	return ok
+// HasFile 检查指定 key 的文件是否存在于 MinIO。
+// 通过 StatObject 验证对象存在性,出错(含不存在)时返回 false。
+func HasFile(key string) bool {
+	if key == "" {
+		return false
+	}
+	_, err := minioUtil.StatObjectFromBucket(AIFileBucket, key)
+	return err == nil
 }
 
-// GetFiles 获取所有文件记录
-func (fs *FileStore) GetFiles() []FileRecord {
-	fs.mu.RLock()
-	defer fs.mu.RUnlock()
+// GetFiles 列出指定范围下的所有文件记录。
+// prefix、userId 必填;sessionId 可为空。
+//   - sessionId 非空:列举 {prefix}/{userId}/{sessionId}/ 下的对象
+//   - sessionId 为空:列举 {prefix}/{userId}/ 下的对象(跨会话)
+//
+// 直接以 MinIO 为数据源递归列举对象。
+func GetFiles(prefix, userId, sessionId string) ([]FileRecord, error) {
+	if prefix == "" {
+		return nil, fmt.Errorf("prefix 不能为空")
+	}
+	if userId == "" {
+		return nil, fmt.Errorf("userId 不能为空")
+	}
 
-	result := make([]FileRecord, 0, len(fs.files))
-	for _, record := range fs.files {
-		result = append(result, *record)
+	var listPrefix string
+	if sessionId == "" {
+		listPrefix = fmt.Sprintf("%s/%s/", prefix, userId)
+	} else {
+		listPrefix = fmt.Sprintf("%s/%s/%s/", prefix, userId, sessionId)
 	}
-	return result
+
+	objects, err := minioUtil.ListFilesObjectFromBucket(AIFileBucket, listPrefix, true)
+	if err != nil {
+		return nil, fmt.Errorf("列举MinIO文件失败: %w", err)
+	}
+
+	result := make([]FileRecord, 0, len(objects))
+	for _, obj := range objects {
+		// 从 object key 中还原原始文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符)
+		fileName := filepath.Base(obj.Key)
+		if len(fileName) > 9 {
+			fileName = fileName[9:]
+		}
+		result = append(result, FileRecord{
+			Key:       obj.Key,
+			FileName:  fileName,
+			Size:      obj.Size,
+			CreatedAt: obj.LastModified.Unix(),
+		})
+	}
+	return result, nil
 }
 
-// GetFileByKey 按 MinIO key 查找文件记录
-func (fs *FileStore) GetFileByKey(key string) *FileRecord {
-	fs.mu.RLock()
-	defer fs.mu.RUnlock()
+// FindKeyByFileName 在 {prefix}/{userId}/{sessionId}/ 下按原始文件名查找最新的 object key。
+//
+// 由于 object key 形如 {prefix}/{userId}/{sessionId}/{uuid}_{fileName},包含随机 uuid,
+// 无法由 fileName 直接推导。此处通过列举该会话前缀下的对象、匹配还原后的文件名,
+// 并在多个同名文件时返回创建时间最新的一个(CreatedAt 最大)。
+// 找不到时返回空字符串。
+func FindKeyByFileName(prefix, userId, sessionId, fileName string) (string, error) {
+	files, err := GetFiles(prefix, userId, sessionId)
+	if err != nil {
+		return "", err
+	}
 
-	for _, record := range fs.files {
-		if record.Key == key {
-			return record
+	var newestKey string
+	var newestAt int64 = -1
+	for _, f := range files {
+		if f.FileName == fileName && f.CreatedAt > newestAt {
+			newestKey = f.Key
+			newestAt = f.CreatedAt
 		}
 	}
-	return nil
+	return newestKey, nil
 }
 
-// Clear 清除所有文件记录(不删除 MinIO 文件)
-func (fs *FileStore) Clear() {
-	fs.mu.Lock()
-	fs.files = make(map[string]*FileRecord)
-	fs.mu.Unlock()
+// FindKeyByPath 与 FindKeyByFileName 类似,但接受 Agent 侧使用的路径(如 /uploads/report.csv),
+// 自动取 basename 作为文件名进行查找。
+func FindKeyByPath(prefix, userId, sessionId, path string) (string, error) {
+	return FindKeyByFileName(prefix, userId, sessionId, filepath.Base(path))
 }
 
 // DetectContentType 根据文件扩展名推断 Content-Type