Эх сурвалжийг харах

feat:MCP server接入MinIO

Jerry Liang 2 сар өмнө
parent
commit
2bd27d997b

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

@@ -12,6 +12,7 @@ import (
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/utils/filestore"
 
 	"fwh-toolbox-server/cmd/mcp/tools"
 )
@@ -42,6 +43,10 @@ func main() {
 		server.WithRecovery(),
 	)
 
+	// 初始化 MCP FileStore
+	mcpFS := filestore.NewFileStore("mcp")
+	tools.InitFileStore(mcpFS)
+
 	// 注册所有 Tools
 	tools.RegisterSiteInfoTools(s)
 	tools.RegisterSimCardTools(s)
@@ -49,6 +54,7 @@ func main() {
 	tools.RegisterAlarmTools(s)
 	tools.RegisterShutdownTools(s)
 	tools.RegisterPlaneTools(s)
+	tools.RegisterFileTools(s)
 
 	// 启动 stdio 通信
 	fmt.Fprintln(os.Stderr, "[fwh-mcp-server] started (stdio)")

+ 80 - 0
service/cmd/mcp/tools/fileUpload.go

@@ -0,0 +1,80 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+
+	"fwh-toolbox-server/utils/filestore"
+)
+
+// mcpFileStore MCP 专用的文件存储实例
+var mcpFileStore *filestore.FileStore
+
+// InitFileStore 初始化 MCP FileStore
+func InitFileStore(fs *filestore.FileStore) {
+	mcpFileStore = fs
+}
+
+// GetFileStore 获取 MCP FileStore 实例(供外部使用)
+func GetFileStore() *filestore.FileStore {
+	return mcpFileStore
+}
+
+func RegisterFileTools(s *server.MCPServer) {
+	// Tool: 读取用户上传的文件
+	s.AddTool(
+		mcp.NewTool("read_uploaded_file",
+			mcp.WithDescription("读取用户上传的文件内容。用户通过前端上传的文件会存储在 /uploads/ 路径下,使用此工具读取文件内容进行分析。"),
+			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}")),
+		),
+		handleReadUploadedFile,
+	)
+
+	// Tool: 列出用户上传的文件
+	s.AddTool(
+		mcp.NewTool("list_uploaded_files",
+			mcp.WithDescription("列出当前会话中用户上传的所有文件,返回文件名、路径、大小等信息。"),
+		),
+		handleListUploadedFiles,
+	)
+}
+
+func handleReadUploadedFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	path, err := req.RequireString("path")
+	if err != nil {
+		return mcp.NewToolResultError("参数 path 必填"), nil
+	}
+
+	if mcpFileStore == nil {
+		return mcp.NewToolResultError("文件存储未初始化"), nil
+	}
+
+	if !mcpFileStore.HasFile(path) {
+		return mcp.NewToolResultError(fmt.Sprintf("文件不存在: %s", path)), nil
+	}
+
+	content, err := mcpFileStore.ReadFile(path)
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("读取文件失败: %v", err)), nil
+	}
+
+	return mcp.NewToolResultText(content), nil
+}
+
+func handleListUploadedFiles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	if mcpFileStore == nil {
+		return mcp.NewToolResultError("文件存储未初始化"), nil
+	}
+
+	files := mcpFileStore.GetFiles()
+	if len(files) == 0 {
+		return mcp.NewToolResultText("当前没有上传的文件"), nil
+	}
+
+	jsonBytes, _ := json.MarshalIndent(files, "", "  ")
+	return mcp.NewToolResultText(string(jsonBytes)), nil
+}

+ 49 - 0
service/controllers/aiChatControllers.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"encoding/json"
 	"fmt"
+	"io"
 	"fwh-toolbox-server/middleware"
 	"fwh-toolbox-server/services"
 
@@ -237,3 +238,51 @@ func DownloadAiFile(c *fiber.Ctx) error {
 	c.Set("Content-Type", "application/octet-stream")
 	return c.SendString(content)
 }
+
+// UploadAiFile 上传文件给 AI 分析
+// POST /ai/files/upload
+func UploadAiFile(c *fiber.Ctx) error {
+	userId := middleware.AuthGetUserName(c)
+
+	sess, err := acpManager.GetOrCreateSession(userId)
+	if err != nil {
+		return Error(c, fmt.Sprintf("AI 会话创建失败: %v", err))
+	}
+
+	// 读取上传文件
+	file, err := c.FormFile("file")
+	if err != nil {
+		return Fail(c, "请选择要上传的文件")
+	}
+
+	// 限制文件大小(最大 2MB)
+	if file.Size > 2*1024*1024 {
+		return Fail(c, "文件大小不能超过 2MB")
+	}
+
+	// 读取文件内容
+	f, err := file.Open()
+	if err != nil {
+		return Error(c, "打开文件失败")
+	}
+	defer f.Close()
+
+	data, err := io.ReadAll(f)
+	if err != nil {
+		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))
+	if err != nil {
+		return Error(c, fmt.Sprintf("保存文件失败: %v", err))
+	}
+
+	return Success(c, fiber.Map{
+		"key":      record.Key,
+		"fileName": record.FileName,
+		"path":     record.Path,
+		"size":     record.Size,
+	})
+}

+ 3 - 0
service/routes/aiChatRoutes.go

@@ -36,4 +36,7 @@ func AiChatSetup(api fiber.Router) {
 
 	// 下载 AI 生成的文件
 	api.Get("/ai/files/download", controllers.DownloadAiFile)
+
+	// 上传文件给 AI 分析
+	api.Post("/ai/files/upload", controllers.UploadAiFile)
 }

+ 5 - 6
service/utils/acp/acp.go

@@ -13,6 +13,7 @@ import (
 	"sync"
 	"sync/atomic"
 
+	"fwh-toolbox-server/utils/filestore"
 	"go.uber.org/zap"
 )
 
@@ -171,7 +172,7 @@ type Event struct {
 	// 权限请求相关
 	Permission *PermissionEvent `json:"permission,omitempty"`
 	// 文件创建相关
-	File *FileRecord `json:"file,omitempty"`
+	File *filestore.FileRecord `json:"file,omitempty"`
 }
 
 // PermissionEvent 权限请求事件(推给前端)
@@ -205,7 +206,7 @@ type Client struct {
 	eventCh      chan<- Event // 当前 prompt 的事件通道(用于推送 permission 事件)
 
 	// 文件存储
-	FileStore *FileStore
+	FileStore *filestore.FileStore
 	UserId    string
 
 	// 文本累积(用于合并日志输出)
@@ -267,7 +268,7 @@ func NewClient(cfg ClientConfig) (*Client, error) {
 		stdin:        stdin,
 		scanner:      scanner,
 		permissionCh: make(chan string, 1),
-		FileStore:    NewFileStore(),
+		FileStore:    filestore.NewFileStore("acp"),
 	}, nil
 }
 
@@ -569,9 +570,7 @@ func (c *Client) Close() {
 
 	// 清理 FileStore
 	if c.FileStore != nil {
-		c.FileStore.mu.Lock()
-		c.FileStore.files = nil
-		c.FileStore.mu.Unlock()
+		c.FileStore.Clear()
 	}
 }
 

+ 76 - 10
service/utils/acp/filestore.go → service/utils/filestore/filestore.go

@@ -1,4 +1,4 @@
-package acp
+package filestore
 
 import (
 	"context"
@@ -29,29 +29,33 @@ type FileRecord struct {
 
 // FileStore 会话级别的虚拟文件系统
 type FileStore struct {
-	mu    sync.RWMutex
-	files map[string]*FileRecord // path → FileRecord(同路径覆盖更新)
+	prefix string // "acp" 或 "mcp",用于区分文件来源
+	mu     sync.RWMutex
+	files  map[string]*FileRecord // path → FileRecord(同路径覆盖更新)
 }
 
 // NewFileStore 创建文件存储
-func NewFileStore() *FileStore {
+// prefix: "acp" 或 "mcp",对应 MinIO 中的子目录前缀
+func NewFileStore(prefix string) *FileStore {
 	return &FileStore{
-		files: make(map[string]*FileRecord),
+		prefix: prefix,
+		files:  make(map[string]*FileRecord),
 	}
 }
 
 // WriteFile 将内容上传到 MinIO,返回文件记录
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
 func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRecord, error) {
 	fileName := filepath.Base(path)
 	uniqueId := uuid.New().String()[:8]
-	objectKey := fmt.Sprintf("%s/%s/%s_%s", userId, sessionId, uniqueId, fileName)
+	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
 
 	// 上传到 MinIO(带7天过期)
 	reader := strings.NewReader(content)
 	ctx := context.Background()
 
 	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(content)), minio.PutObjectOptions{
-		ContentType: detectContentType(fileName),
+		ContentType: DetectContentType(fileName),
 		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
 	})
 	if err != nil {
@@ -71,7 +75,8 @@ func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRe
 	fs.files[path] = record
 	fs.mu.Unlock()
 
-	zap.L().Info("[ACP-FileStore] 文件已保存到MinIO",
+	zap.L().Info("[FileStore] 文件已保存到MinIO",
+		zap.String("prefix", fs.prefix),
 		zap.String("path", path),
 		zap.String("key", objectKey),
 		zap.String("fileName", fileName),
@@ -80,6 +85,54 @@ 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) {
+	uniqueId := uuid.New().String()[:8]
+	timestamp := time.Now().Unix()
+	objectKey := fmt.Sprintf("%s/%s/%d_%s_%s", fs.prefix, userId, timestamp, uniqueId, fileName)
+
+	// 虚拟路径(供 Agent readTextFile 使用)
+	virtualPath := fmt.Sprintf("/uploads/%s", fileName)
+
+	reader := strings.NewReader(string(data))
+	ctx := context.Background()
+
+	if contentType == "" {
+		contentType = DetectContentType(fileName)
+	}
+
+	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
+		ContentType: contentType,
+		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:      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("key", objectKey),
+		zap.String("fileName", fileName),
+	)
+
+	return record, nil
+}
+
 // ReadFile 从 MinIO 读取文件内容
 func (fs *FileStore) ReadFile(path string) (string, error) {
 	fs.mu.RLock()
@@ -138,8 +191,15 @@ func (fs *FileStore) GetFileByKey(key string) *FileRecord {
 	return nil
 }
 
-// detectContentType 根据文件扩展名推断 Content-Type
-func detectContentType(fileName string) string {
+// Clear 清除所有文件记录(不删除 MinIO 文件)
+func (fs *FileStore) Clear() {
+	fs.mu.Lock()
+	fs.files = make(map[string]*FileRecord)
+	fs.mu.Unlock()
+}
+
+// DetectContentType 根据文件扩展名推断 Content-Type
+func DetectContentType(fileName string) string {
 	ext := strings.ToLower(filepath.Ext(fileName))
 	switch ext {
 	case ".txt":
@@ -168,6 +228,12 @@ func detectContentType(fileName string) string {
 		return "text/yaml"
 	case ".sh":
 		return "text/x-shellscript"
+	case ".vue":
+		return "text/x-vue"
+	case ".sql":
+		return "text/x-sql"
+	case ".log":
+		return "text/plain; charset=utf-8"
 	default:
 		return "application/octet-stream"
 	}