Ver Fonte

feat:完成AI文件生成功能,并测试通过✅

Jerry Liang há 2 meses atrás
pai
commit
0911b53fee

+ 53 - 43
docs/ACP_Protocol_Specification.md

@@ -18,12 +18,12 @@ ACP (Agent Client Protocol) 是一种用于 **AI 编程代理(Agent)与客
 
 ### 1.2 传输层
 
-| 属性 | 描述 |
-|------|------|
-| 传输方式 | 子进程的 stdin(写入)/ stdout(读取) |
-| 序列化格式 | **ND-JSON**(Newline Delimited JSON) |
-| 流适配 | Node.js Stream → Web Streams API (`ReadableStream` / `WritableStream`) |
-| SDK 入口 | `ndJsonStream(writable, readable)` 创建双向流 |
+| 属性       | 描述                                                                        |
+| ---------- | --------------------------------------------------------------------------- |
+| 传输方式   | 子进程的 stdin(写入)/ stdout(读取)                                      |
+| 序列化格式 | **ND-JSON**(Newline Delimited JSON)                                 |
+| 流适配     | Node.js Stream → Web Streams API (`ReadableStream` / `WritableStream`) |
+| SDK 入口   | `ndJsonStream(writable, readable)` 创建双向流                             |
 
 ---
 
@@ -40,6 +40,7 @@ Client                                          Agent Process
 ```
 
 Agent 配置结构:
+
 ```typescript
 interface AgentConfigEntry {
   command: string;       // 启动命令,如 "npx"
@@ -119,6 +120,7 @@ Client                                Agent
 ```
 
 认证请求:
+
 ```typescript
 { methodId: string }  // 用户选择的认证方式 ID
 ```
@@ -424,6 +426,7 @@ interface ReadTextFileResponse {
 ```
 
 **实现特性**:
+
 - 优先读取编辑器中未保存的内容(dirty buffer)
 - 支持行范围读取(line + limit)
 
@@ -441,6 +444,7 @@ interface WriteTextFileResponse {}  // 空响应表示成功
 ```
 
 **实现特性**:
+
 - 自动创建父目录
 - 写入后在编辑器中打开文件
 
@@ -528,6 +532,7 @@ interface ReleaseTerminalResponse {}  // 空响应
 ```
 
 **实现特性**:
+
 - 如果进程仍在运行,先发送 SIGTERM
 - VS Code 终端保持可见(不自动关闭),符合 ACP 规范
 
@@ -579,6 +584,7 @@ interface SessionMode {
 ```
 
 **设置模式**:Client → Agent
+
 ```typescript
 // setSessionMode 请求
 { sessionId: string; modeId: string; }
@@ -600,6 +606,7 @@ interface SessionModel {
 ```
 
 **设置模型**:Client → Agent(unstable API)
+
 ```typescript
 // unstable_setSessionModel 请求
 { sessionId: string; modelId: string; }
@@ -634,6 +641,7 @@ interface GroupedOptions {
 ```
 
 **设置配置选项**:Client → Agent
+
 ```typescript
 // setSessionConfigOption 请求
 {
@@ -652,11 +660,12 @@ interface GroupedOptions {
 
 ## 8. 协议错误码
 
-| 错误码 | 含义 | 触发场景 |
-|--------|------|----------|
+| 错误码     | 含义                     | 触发场景                                                |
+| ---------- | ------------------------ | ------------------------------------------------------- |
 | `-32000` | 需要认证 (Auth Required) | `newSession` / `listSessions` 调用时 Agent 需要认证 |
 
 错误通过 `RequestError` 类传递:
+
 ```typescript
 class RequestError extends Error {
   code: number;
@@ -730,6 +739,7 @@ ACP 提供了公共的 Agent 注册表服务:
 - **缓存策略**:客户端缓存 5 分钟
 
 注册表数据结构:
+
 ```typescript
 interface Registry {
   agents: RegistryAgent[];
@@ -748,19 +758,19 @@ interface RegistryAgent {
 
 ## 11. 已知兼容的 ACP Agent
 
-| Agent 名称 | 命令 | 参数 |
-|------------|------|------|
-| GitHub Copilot | `npx` | `@github/copilot-language-server@latest --acp` |
-| Claude Code | `npx` | `@agentclientprotocol/claude-agent-acp@latest` |
-| Gemini CLI | `npx` | `@google/gemini-cli@latest --experimental-acp` |
-| Qwen Code | `npx` | `@qwen-code/qwen-code@latest --acp --experimental-skills` |
-| Auggie CLI | `npx` | `@augmentcode/auggie@latest --acp` |
-| Qoder CLI | `npx` | `@qoder-ai/qodercli@latest --acp` |
-| Codex CLI | `npx` | `@zed-industries/codex-acp@latest` |
-| OpenCode | `npx` | `opencode-ai@latest acp` |
-| OpenClaw | `npx` | `openclaw acp` |
-| Kiro CLI | `kiro-cli` | `acp` |
-| Hermes Agent | `hermes` | `acp` |
+| Agent 名称     | 命令         | 参数                                                        |
+| -------------- | ------------ | ----------------------------------------------------------- |
+| GitHub Copilot | `npx`      | `@github/copilot-language-server@latest --acp`            |
+| Claude Code    | `npx`      | `@agentclientprotocol/claude-agent-acp@latest`            |
+| Gemini CLI     | `npx`      | `@google/gemini-cli@latest --experimental-acp`            |
+| Qwen Code      | `npx`      | `@qwen-code/qwen-code@latest --acp --experimental-skills` |
+| Auggie CLI     | `npx`      | `@augmentcode/auggie@latest --acp`                        |
+| Qoder CLI      | `npx`      | `@qoder-ai/qodercli@latest --acp`                         |
+| Codex CLI      | `npx`      | `@zed-industries/codex-acp@latest`                        |
+| OpenCode       | `npx`      | `opencode-ai@latest acp`                                  |
+| OpenClaw       | `npx`      | `openclaw acp`                                            |
+| Kiro CLI       | `kiro-cli` | `acp`                                                     |
+| Hermes Agent   | `hermes`   | `acp`                                                     |
 
 ---
 
@@ -799,25 +809,25 @@ Agent 据此判断可以向客户端发起哪些反向请求。
 
 ## 14. 附录:消息流方向汇总
 
-| 方法 | 方向 | 类型 | 说明 |
-|------|------|------|------|
-| `initialize` | Client → Agent | Request | 协议握手 |
-| `authenticate` | Client → Agent | Request | 认证 |
-| `newSession` | Client → Agent | Request | 创建会话 |
-| `listSessions` | Client → Agent | Request | 列举会话 |
-| `loadSession` | Client → Agent | Request | 加载会话(重放历史) |
-| `resumeSession` | Client → Agent | Request | 恢复会话(无重放) |
-| `prompt` | Client → Agent | Request | 发送提示 |
-| `cancel` | Client → Agent | Request | 取消当前轮次 |
-| `setSessionMode` | Client → Agent | Request | 设置模式 |
-| `setSessionConfigOption` | Client → Agent | Request | 设置配置选项 |
-| `unstable_setSessionModel` | Client → Agent | Request | 设置模型 |
-| `session/update` | Agent → Client | Notification | 会话状态推送 |
-| `readTextFile` | Agent → Client | Request | 读取文件 |
-| `writeTextFile` | Agent → Client | Request | 写入文件 |
-| `createTerminal` | Agent → Client | Request | 创建终端 |
-| `terminalOutput` | Agent → Client | Request | 获取终端输出 |
-| `waitForTerminalExit` | Agent → Client | Request | 等待终端退出 |
-| `killTerminal` | Agent → Client | Request | 终止终端 |
-| `releaseTerminal` | Agent → Client | Request | 释放终端 |
-| `requestPermission` | Agent → Client | Request | 请求用户授权 |
+| 方法                         | 方向            | 类型         | 说明                 |
+| ---------------------------- | --------------- | ------------ | -------------------- |
+| `initialize`               | Client → Agent | Request      | 协议握手             |
+| `authenticate`             | Client → Agent | Request      | 认证                 |
+| `newSession`               | Client → Agent | Request      | 创建会话             |
+| `listSessions`             | Client → Agent | Request      | 列举会话             |
+| `loadSession`              | Client → Agent | Request      | 加载会话(重放历史) |
+| `resumeSession`            | Client → Agent | Request      | 恢复会话(无重放)   |
+| `prompt`                   | Client → Agent | Request      | 发送提示             |
+| `cancel`                   | Client → Agent | Request      | 取消当前轮次         |
+| `setSessionMode`           | Client → Agent | Request      | 设置模式             |
+| `setSessionConfigOption`   | Client → Agent | Request      | 设置配置选项         |
+| `unstable_setSessionModel` | Client → Agent | Request      | 设置模型             |
+| `session/update`           | Agent → Client | Notification | 会话状态推送         |
+| `readTextFile`             | Agent → Client | Request      | 读取文件             |
+| `writeTextFile`            | Agent → Client | Request      | 写入文件             |
+| `createTerminal`           | Agent → Client | Request      | 创建终端             |
+| `terminalOutput`           | Agent → Client | Request      | 获取终端输出         |
+| `waitForTerminalExit`      | Agent → Client | Request      | 等待终端退出         |
+| `killTerminal`             | Agent → Client | Request      | 终止终端             |
+| `releaseTerminal`          | Agent → Client | Request      | 释放终端             |
+| `requestPermission`        | Agent → Client | Request      | 请求用户授权         |

+ 41 - 0
service/cmd/mcp/.kiro/agents/fwh-toolbox-agent.json

@@ -0,0 +1,41 @@
+{
+	"name": "fwh-toolbox-agent",
+	"description": "fwh-toolbox平台AI助手",
+	"prompt": "你是一个本地平台专业分析工具。使用本地知识库和MCP server读取数据,实现数据分析。不可创建/修改文件,只能通过MCP将文件生成到Minio服务器。",
+	"tools": [
+		"read", "write", "grep", "glob", "code", "web_fetch", "web_search", "knowledge", "subagent", "todo_list", "introspect",
+		"@fwh-tools"
+	],
+	"allowedTools": [
+		"read", "grep", "glob", "code", "web_fetch", "web_search", "knowledge", "subagent", "todo_list", "introspect",
+		"@fwh-tools"
+	],
+	"toolsSettings": {
+		"read": {
+			"allowedPaths": ["./**", "/temp/fwh-toolbox-agent/**", "/home/jerry-remote/web/FranklinWH-Wiki/client/docs/**"],
+			"deniedPaths": []
+		},
+		"write": {
+			"allowedPaths": ["/temp/fwh-toolbox-agent/**"],
+			"deniedPaths": []
+		}
+	},	
+	"resources": [
+		{
+			"type": "knowledgeBase",
+			"source": "file:///home/jerry-remote/web/FranklinWH-Wiki/client/docs",
+			"name": "FranklinWHProductDocs",
+			"description": "产品文档和指南,包括产品的软件、硬件、系统功能详细说明",
+			"indexType": "best",
+			"autoUpdate": true
+		}
+	],
+	"hooks": {},
+	"mcpServers": {
+		"fwh-tools": {
+			"command": "./fwh-mcp-server",
+			"args": []
+		}
+	},
+	"welcomeMessage": "欢迎使用fwh-toolbox平台AI助手"
+}

+ 19 - 0
service/cmd/mcp/.kiro/settings/cli.json

@@ -0,0 +1,19 @@
+{
+	"chat.enableKnowledge": true,
+	"chat.enableTodoList": true,
+	"chat.greeting.enabled": true,
+	"chat.allowAnimations": true,
+	"chat.allowAsciiArt": true,
+	"chat.allowIcons": true,
+	"knowledge.defaultIncludePatterns": ["**/*.ts", "**/*.vue", "**/*.md"],
+	"knowledge.defaultExcludePatterns": ["node_modules/**", "dist/**"],
+	"knowledge.maxFiles": 1000,
+	"knowledge.chunkSize": 512,
+	"knowledge.chunkOverlap": 50,
+	"knowledge.indexType": "best",
+	"api.timeout": 30000,
+	"mcp.initTimeout": 120000,
+	"mcp.noInteractiveTimeout": 30000,
+	"compaction.excludeContextWindowPercent": 10,
+	"compaction.excludeMessages": 5
+}

+ 72 - 0
service/cmd/mcp/.kiro/steering/file-output-rules.md

@@ -0,0 +1,72 @@
+# 文件输出规则
+
+## storage_file_write 调用规范
+
+调用 `storage_file_write` 时,**必须**填写 `userId` 和 `sessionId` 参数:
+- `userId`:从消息开头的 `[context] userId=xxx` 中获取
+- `sessionId`:从消息开头的 `[context] sessionId=xxx` 中获取
+- 每条用户消息开头都会包含 `[context]` 行,请从中提取这两个值
+
+## 文件下载事件
+
+当你通过 `storage_file_write` 工具成功写入文件后,**必须**在回复中输出以下标记,让前端生成下载按钮:
+
+```
+$$FILE_DOWNLOAD:{"key":"<返回的key>","fileName":"<文件名>","size":<文件大小>}$$
+```
+
+### 规则
+
+1. 标记必须独占一行
+2. 使用 `storage_file_write` 返回的 `key`、`fileName`、`size` 字段原样填入
+3. 不要将标记放在代码块(```)内
+4. 一次回复中可输出多个标记(多个文件)
+5. 标记前后可以正常写文字说明
+
+### 示例
+
+调用 `storage_file_write`:
+```json
+{
+  "path": "/uploads/report.csv",
+  "content": "id,name,value\n1,test,100",
+  "userId": "jerry",
+  "sessionId": "sess_abc123"
+}
+```
+
+返回结果:
+```json
+{
+  "message": "文件写入成功",
+  "path": "/uploads/report.csv",
+  "fileName": "report.csv",
+  "size": 2048,
+  "key": "download/jerry/sess_abc123/a1b2c3d4_report.csv"
+}
+```
+
+则你的回复中需要包含:
+
+已为您生成报告文件:
+
+$$FILE_DOWNLOAD:{"key":"download/jerry/sess_abc123/a1b2c3d4_report.csv","fileName":"report.csv","size":2048}$$
+
+## 写文件策略
+
+根据文件用途选择不同的输出方式:
+
+### 需要给用户下载的文件
+- 使用 `storage_file_write` 工具写入
+- 尽量传入 `userId` 和 `sessionId`
+- 写入成功后输出 `$$FILE_DOWNLOAD:...$$` 标记
+- 适用场景:导出数据、生成报告、分析结果文件
+
+### 临时文件(中间处理、缓存等)
+- 写入 `/temp/fwh-toolbox-agent/` 目录
+- 处理完成后无需输出下载标记
+
+### 判断标准
+- 用户说"下载"、"导出"、"生成报告"、"保存为文件" → 使用 storage_file_write + 输出标记
+- 中间计算结果、日志 → 写入 /temp
+- 不确定时 → 询问用户

+ 5 - 5
service/cmd/mcp/DESIGN.md

@@ -219,10 +219,10 @@ var minioAllowedBuckets = []string{
 
 ```
 MinIO Bucket: fwh-toolbox-ai-file
-├── acp/                          ← ACP (AI Chat) 产生的文件
+├── upload/                        ← 用户上传的文件
 │   └── {userId}/{sessionId}/{uuid}_{fileName}
 │
-└── mcp/                          ← MCP (用户上传) 的文件
+└── download/                      ← AI 生成的下载文件
     └── {userId}/{sessionId}/{uuid}_{fileName}
 ```
 
@@ -243,7 +243,7 @@ MinIO Bucket: fwh-toolbox-ai-file
 acpFS := filestore.NewFileStore("acp")
 
 // MCP 使用
-mcpFS := filestore.NewFileStore("mcp")
+mcpFS := filestore.NewFileStore("download")
 ```
 
 ---
@@ -284,8 +284,8 @@ func main() {
     cache.Init(cfg.App.RedisAddr)    // Redis
     repositories.PlaneWebHttpInit()  // Plane API
 
-    // 3. 初始化 MCP FileStore (prefix="mcp")
-    mcpFS := filestore.NewFileStore("mcp")
+    // 3. 初始化 MCP FileStore (prefix="download")
+    mcpFS := filestore.NewFileStore("download")
     storage.InitFileStore(mcpFS)
 
     // 4. 创建 MCP Server

+ 60 - 9
service/cmd/mcp/main.go

@@ -2,9 +2,10 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"os"
 
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 	"github.com/mark3labs/mcp-go/server"
 
 	"fwh-toolbox-server/cache"
@@ -13,6 +14,7 @@ import (
 	"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/storage"
@@ -21,28 +23,31 @@ import (
 const (
 	serverName    = "fwh-tools"
 	serverVersion = "2.0.0"
+	logDir        = "/var/log/fwh-mcp-server"
+	logFile       = "/var/log/fwh-mcp-server/mcp.log"
 )
 
 func main() {
+	// 初始化 MCP 专用 logger(写入文件,不污染 stdio)
+	initMCPLogger()
+	defer zap.L().Sync()
+
 	// 加载配置(复用现有 config)
 	cfg, err := config.LoadConfig()
 	if err != nil {
-		log.Fatalf("加载配置失败: %v", err)
+		zap.L().Fatal("加载配置失败", zap.Error(err))
 	}
 
-	// 初始化 logger
-	logger.Init(cfg.App.AppEnv)
-	defer logger.Log.Sync()
-
 	// 初始化基础设施(与 HTTP Server 相同)
 	if err := database.Init(&cfg.App); err != nil {
-		log.Fatalf("数据库初始化失败: %v", err)
+		zap.L().Fatal("数据库初始化失败", zap.Error(err))
 	}
 	cache.Init(cfg.App.RedisAddr)
+	minioUtil.InitClient(&cfg.App)
 	repositories.PlaneWebHttpInit(&cfg.PlaneWebApi)
 
 	// 初始化 MCP FileStore
-	mcpFS := filestore.NewFileStore("mcp")
+	mcpFS := filestore.NewFileStore("download")
 	storage.InitFileStore(mcpFS)
 
 	// 创建 MCP Server
@@ -58,10 +63,56 @@ func main() {
 	tools.RegisterAll(s)
 
 	// 启动 stdio 通信
+	zap.L().Info("[fwh-mcp-server] started", zap.String("version", serverVersion))
 	fmt.Fprintln(os.Stderr, "[fwh-mcp-server] started (stdio) v"+serverVersion)
 	if err := server.ServeStdio(s); err != nil {
-		log.Fatalf("[fwh-mcp-server] error: %v", err)
+		zap.L().Fatal("[fwh-mcp-server] error", zap.Error(err))
+	}
+}
+
+// initMCPLogger 初始化 MCP Server 专用 logger
+// 日志写入文件,不输出到 stdout/stderr(避免污染 stdio JSON-RPC 通道)
+func initMCPLogger() {
+	// 确保日志目录存在
+	if err := os.MkdirAll(logDir, 0755); err != nil {
+		fmt.Fprintf(os.Stderr, "[fwh-mcp-server] 创建日志目录失败: %v\n", err)
+	}
+
+	config := zap.Config{
+		Level:       zap.NewAtomicLevelAt(zap.InfoLevel),
+		Development: false,
+		Encoding:    "json",
+		EncoderConfig: zapcore.EncoderConfig{
+			TimeKey:        "ts",
+			LevelKey:       "level",
+			NameKey:        "logger",
+			CallerKey:      "caller",
+			MessageKey:     "msg",
+			StacktraceKey:  "stacktrace",
+			LineEnding:     zapcore.DefaultLineEnding,
+			EncodeLevel:    zapcore.LowercaseLevelEncoder,
+			EncodeTime:     zapcore.ISO8601TimeEncoder,
+			EncodeDuration: zapcore.SecondsDurationEncoder,
+			EncodeCaller:   zapcore.ShortCallerEncoder,
+		},
+		OutputPaths:      []string{logFile},
+		ErrorOutputPaths: []string{logFile},
+	}
+
+	zapLogger, err := config.Build(zap.AddStacktrace(zap.ErrorLevel))
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[fwh-mcp-server] logger 初始化失败: %v\n", err)
+		// fallback: 使用 nop logger
+		zapLogger = zap.NewNop()
 	}
+
+	// 设置全局 logger(zap.L() 和 zap.S())
+	zap.ReplaceGlobals(zapLogger)
+
+	// 同时设置 logger 包变量(database 等模块直接引用 logger.Sugar)
+	logger.Log = zapLogger
+	logger.Sugar = zapLogger.Sugar()
+	logger.Std = zap.NewStdLog(zapLogger)
 }
 
 // serverInstructions MCP Server 使用说明,帮助 AI 理解工具分类和使用优先级

+ 6 - 2
service/cmd/mcp/tools/storage/file.go

@@ -38,6 +38,8 @@ func registerFileTools(s *server.MCPServer) {
 			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。文件路径会自动添加 /uploads/ 前缀。"),
 			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}(如 /uploads/report.csv)")),
 			mcp.WithString("content", mcp.Required(), mcp.Description("要写入的文本内容")),
+			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
+			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
 		),
 		handleFileWrite,
 	)
@@ -99,8 +101,10 @@ func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
 	}
 
-	// 写入文件(userId 和 sessionId 使用固定值,MCP 模式下无用户上下文)
-	record, err := mcpFileStore.WriteFile("mcp-agent", "mcp-session", path, content)
+	userId := req.GetString("userId", "mcp-agent")
+	sessionId := req.GetString("sessionId", "mcp-session")
+
+	record, err := mcpFileStore.WriteFile(userId, sessionId, path, content)
 	if err != nil {
 		return helper.ServiceError("写入文件", err), nil
 	}

+ 1 - 1
service/cmd/mcp/tools/storage/minio.go

@@ -16,7 +16,7 @@ import (
 
 // minioAllowedBuckets 允许访问的 bucket 白名单
 var minioAllowedBuckets = []string{
-	"fwh-toolbox-batch-download",
+	"fwh-toolbox",
 	"fwh-toolbox-ai-file",
 }
 

+ 30 - 17
service/controllers/aiChatControllers.go

@@ -2,13 +2,19 @@ package controllers
 
 import (
 	"bufio"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
-	"fwh-toolbox-server/middleware"
-	"fwh-toolbox-server/services"
+	"path/filepath"
 
 	"github.com/gofiber/fiber/v2"
+	"github.com/minio/minio-go/v7"
+
+	"fwh-toolbox-server/middleware"
+	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/utils/filestore"
+	minioUtil "fwh-toolbox-server/utils/minio"
 )
 
 var acpManager *services.ACPManager
@@ -215,28 +221,35 @@ func DownloadAiFile(c *fiber.Ctx) error {
 		return Fail(c, "key 不能为空")
 	}
 
-	userId := middleware.AuthGetUserName(c)
-	sess := acpManager.GetSessionInfo(userId)
-	if sess == nil {
-		return Fail(c, "没有活跃的会话")
+	// 直接从 MinIO 读取文件(key 即 objectKey)
+	obj, err := minioUtil.Client.GetObject(context.Background(), filestore.AIFileBucket, key, minio.GetObjectOptions{})
+	if err != nil {
+		return Fail(c, "文件不存在")
 	}
+	defer obj.Close()
 
-	// 从 FileStore 查找文件记录
-	record := sess.Client.FileStore.GetFileByKey(key)
-	if record == nil {
-		return Fail(c, "文件不存在")
+	// 获取文件信息
+	stat, err := obj.Stat()
+	if err != nil {
+		return Fail(c, fmt.Sprintf("文件不存在: %v", err))
 	}
 
-	// 从 MinIO 读取文件内容
-	content, err := sess.Client.FileStore.ReadFile(record.Path)
+	// 读取全部内容
+	data, err := io.ReadAll(obj)
 	if err != nil {
-		return Error(c, err.Error())
+		return Error(c, fmt.Sprintf("读取文件失败: %v", err))
+	}
+
+	// 从 key 中提取文件名(key 格式: prefix/userId/sessionId/uuid_fileName)
+	fileName := filepath.Base(key)
+	// 去掉 uuid 前缀(前9个字符: 8位uuid + 下划线)
+	if len(fileName) > 9 {
+		fileName = fileName[9:]
 	}
 
-	// 设置下载头(文件名为原始名,不含 uuid 前缀)
-	c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, record.FileName))
-	c.Set("Content-Type", "application/octet-stream")
-	return c.SendString(content)
+	c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
+	c.Set("Content-Type", stat.ContentType)
+	return c.Send(data)
 }
 
 // UploadAiFile 上传文件给 AI 分析

+ 4 - 3
service/services/aiChatServices.go

@@ -326,9 +326,12 @@ func (m *ACPManager) Prompt(sess *ACPSession, message string, eventCh chan<- AIC
 		sess.Title = title
 	}
 
+	// 注入会话上下文(让 AI 在调用 MCP Tool 时能传入正确的 userId 和 sessionId)
+	contextMessage := fmt.Sprintf("[context] userId=%s, sessionId=%s\n\n%s", sess.UserId, sess.SessionId, message)
+
 	// 用 acp.Client.Prompt 流式读取
 	acpEvents := make(chan acp.Event, 50)
-	go sess.Client.Prompt(sess.SessionId, message, acpEvents)
+	go sess.Client.Prompt(sess.SessionId, contextMessage, acpEvents)
 
 	// 转换 acp.Event → AIChatEvent
 	for event := range acpEvents {
@@ -341,8 +344,6 @@ func (m *ACPManager) Prompt(sess *ACPSession, message string, eventCh chan<- AIC
 			eventCh <- AIChatEvent{Type: "tool_update", ToolName: event.ToolName, Status: event.ToolStatus}
 		case "permission":
 			eventCh <- AIChatEvent{Type: "permission", Content: toJSON(event.Permission)}
-		case "file_created":
-			eventCh <- AIChatEvent{Type: "file_created", Content: toJSON(event.File)}
 		case "done":
 			eventCh <- AIChatEvent{Type: "done", SessionId: event.SessionId, StopReason: event.StopReason}
 		case "error":

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

@@ -163,7 +163,7 @@ type ToolCallUpdate struct {
 
 // Event 输出给上层的事件
 type Event struct {
-	Type       string // "text", "tool_call", "tool_update", "done", "error", "permission", "file_created"
+	Type       string // "text", "tool_call", "tool_update", "done", "error", "permission"
 	Content    string
 	ToolName   string
 	ToolStatus string
@@ -171,8 +171,6 @@ type Event struct {
 	StopReason string
 	// 权限请求相关
 	Permission *PermissionEvent `json:"permission,omitempty"`
-	// 文件创建相关
-	File *filestore.FileRecord `json:"file,omitempty"`
 }
 
 // PermissionEvent 权限请求事件(推给前端)
@@ -268,7 +266,7 @@ func NewClient(cfg ClientConfig) (*Client, error) {
 		stdin:        stdin,
 		scanner:      scanner,
 		permissionCh: make(chan string, 1),
-		FileStore:    filestore.NewFileStore("acp"),
+		FileStore:    filestore.NewFileStore("upload"),
 	}, nil
 }
 
@@ -847,10 +845,7 @@ func (c *Client) handleAgentRequest(rawId json.RawMessage, method string, rawPar
 					"id":      rawId,
 					"result":  map[string]any{},
 				})
-				// 推送 file_created 事件给前端
-				if c.eventCh != nil {
-					c.eventCh <- Event{Type: "file_created", File: record}
-				}
+				_ = record // 文件下载通过前端标记检测实现,不再推送事件
 			} else {
 				zap.L().Error("[ACP] writeTextFile MinIO上传失败", zap.Error(err))
 			}

+ 2 - 2
service/utils/filestore/filestore.go

@@ -29,13 +29,13 @@ type FileRecord struct {
 
 // FileStore 会话级别的虚拟文件系统
 type FileStore struct {
-	prefix string // "acp" 或 "mcp",用于区分文件来源
+	prefix string // "upload" 或 "download",用于区分文件来源
 	mu     sync.RWMutex
 	files  map[string]*FileRecord // path → FileRecord(同路径覆盖更新)
 }
 
 // NewFileStore 创建文件存储
-// prefix: "acp" 或 "mcp",对应 MinIO 中的子目录前缀
+// prefix: "upload" 或 "download",对应 MinIO 中的子目录前缀
 func NewFileStore(prefix string) *FileStore {
 	return &FileStore{
 		prefix: prefix,

+ 4 - 6
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -3,6 +3,7 @@ import { NTag, NSpin, NButton, NCard } from 'naive-ui'
 import { computed } from 'vue'
 import { marked } from 'marked'
 import DOMPurify from 'dompurify'
+import { downloadAiFile } from '@/service/aiChat'
 import type { ChatMessage } from './interface'
 
 const props = defineProps<{
@@ -25,9 +26,8 @@ const formattedContent = computed(() => {
   return DOMPurify.sanitize(raw)
 })
 
-function getDownloadUrl(key: string): string {
-  const base = import.meta.env.BASE_URL || '/'
-  return `${base}api/ai/files/download?key=${encodeURIComponent(key)}`
+function downloadFile(key: string, fileName: string) {
+  downloadAiFile(key, fileName)
 }
 
 function formatSize(bytes: number): string {
@@ -96,9 +96,7 @@ function formatSize(bytes: number): string {
           <NButton
             size="tiny"
             type="primary"
-            tag="a"
-            :href="getDownloadUrl(file.key)"
-            target="_blank"
+            @click="downloadFile(file.key, file.fileName)"
           >
             <template #icon>
               <span class="i-ph:download-simple" />

+ 130 - 133
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -1,7 +1,16 @@
-import { ref, reactive } from 'vue'
+import { ref } from 'vue'
 import type { ChatMessage, ToolCallInfo, SessionListItem } from './interface'
 import { useUserStore } from '@/stores/user'
 import { chatDB } from './chatDB'
+import {
+  getAiSession,
+  getAiSessionList,
+  createAiSession,
+  loadAiSession,
+  deleteAiSession,
+  cancelAiChat,
+  resolveAiPermission,
+} from '@/service/aiChat'
 
 export function useAiChat() {
   const messages = ref<ChatMessage[]>([])
@@ -23,25 +32,21 @@ export function useAiChat() {
   // 获取会话列表
   async function fetchSessionList() {
     try {
-      const resp = await fetch(`${getBaseUrl()}/ai/sessions/list`, {
-        headers: { Authorization: `Bearer ${userStore.token}` },
-      })
-      if (resp.ok) {
-        const json = await resp.json()
-        if (json.data) {
-          sessionList.value = json.data
-
-          // 清理本地缓存中不存在于后端列表的会话
-          const serverIds = new Set(json.data.map((s: any) => s.sessionId))
-          const localIds = await chatDB.getAllSessionIds()
-          for (const localId of localIds) {
-            if (!serverIds.has(localId)) {
-              await chatDB.deleteSession(localId)
-            }
+      const data = await getAiSessionList()
+      if (data) {
+        sessionList.value = data
+
+        // 清理本地缓存中不存在于后端列表的会话
+        const serverIds = new Set(data.map((s: any) => s.sessionId))
+        const localIds = await chatDB.getAllSessionIds()
+        for (const localId of localIds) {
+          if (!serverIds.has(localId)) {
+            await chatDB.deleteSession(localId)
           }
         }
       }
-    } catch {
+    }
+    catch {
       // 忽略
     }
   }
@@ -50,19 +55,14 @@ export function useAiChat() {
   async function fetchSession() {
     isLoadingSession.value = true
     try {
-      const resp = await fetch(`${getBaseUrl()}/ai/sessions`, {
-        headers: { Authorization: `Bearer ${userStore.token}` },
-      })
-      if (resp.ok) {
-        const json = await resp.json()
-        if (json.data?.welcomeMessage) {
-          welcomeMessage.value = json.data.welcomeMessage
-        }
-        if (json.data?.sessionId) {
-          currentSessionId.value = json.data.sessionId
-          // 从 IndexedDB 恢复本地消息
-          await loadLocalMessages(json.data.sessionId)
-        }
+      const data = await getAiSession()
+      if (data?.welcomeMessage) {
+        welcomeMessage.value = data.welcomeMessage
+      }
+      if (data?.sessionId) {
+        currentSessionId.value = data.sessionId
+        // 从 IndexedDB 恢复本地消息
+        await loadLocalMessages(data.sessionId)
       }
     }
     catch {
@@ -79,27 +79,18 @@ export function useAiChat() {
   async function createNewSession() {
     isLoadingSession.value = true
     try {
-      const resp = await fetch(`${getBaseUrl()}/ai/sessions/new`, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${userStore.token}`,
-        },
-      })
-      if (resp.ok) {
-        const json = await resp.json()
-        if (json.data?.sessionId) {
-          // 保存当前会话消息到 IndexedDB
-          if (currentSessionId.value && messages.value.length > 0) {
-            await saveLocalMessages()
-          }
-          // 切换到新会话
-          currentSessionId.value = json.data.sessionId
-          messages.value = []
-          welcomeMessage.value = json.data.welcomeMessage || '欢迎使用AI助手'
-          // 刷新列表
-          await fetchSessionList()
+      const data = await createAiSession()
+      if (data?.sessionId) {
+        // 保存当前会话消息到 IndexedDB
+        if (currentSessionId.value && messages.value.length > 0) {
+          await saveLocalMessages()
         }
+        // 切换到新会话
+        currentSessionId.value = data.sessionId
+        messages.value = []
+        welcomeMessage.value = data.welcomeMessage || '欢迎使用AI助手'
+        // 刷新列表
+        await fetchSessionList()
       }
     }
     catch {
@@ -129,50 +120,39 @@ export function useAiChat() {
         welcomeMessage.value = ''
 
         // 后台静默调用 session/load 恢复 Kiro 上下文(不等待回放结果)
-        fetch(`${getBaseUrl()}/ai/sessions/${sessionId}/load`, {
-          method: 'POST',
-          headers: {
-            'Content-Type': 'application/json',
-            Authorization: `Bearer ${userStore.token}`,
-          },
-        })
+        loadAiSession(sessionId)
         await fetchSessionList()
-      } else {
+      }
+      else {
         // 无缓存:调用 session/load 获取回放消息
-        const resp = await fetch(`${getBaseUrl()}/ai/sessions/${sessionId}/load`, {
-          method: 'POST',
-          headers: {
-            'Content-Type': 'application/json',
-            Authorization: `Bearer ${userStore.token}`,
-          },
-        })
-        if (resp.ok) {
-          const json = await resp.json()
-          if (json.data?.sessionId) {
-            currentSessionId.value = json.data.sessionId
-            welcomeMessage.value = ''
-
-            // 将回放消息转为 ChatMessage 格式
-            if (json.data.replayMessages && json.data.replayMessages.length > 0) {
-              messages.value = json.data.replayMessages.map((m: any, i: number) => ({
-                id: m.messageId || `replay_${i}`,
-                role: m.role,
-                content: m.content,
-                timestamp: Date.now() - (json.data.replayMessages.length - i) * 1000,
-              }))
-              // 保存到缓存
-              await saveLocalMessages()
-            } else {
-              messages.value = []
-            }
-
-            await fetchSessionList()
+        const data = await loadAiSession(sessionId)
+        if (data?.sessionId) {
+          currentSessionId.value = data.sessionId
+          welcomeMessage.value = ''
+
+          // 将回放消息转为 ChatMessage 格式
+          if ((data as any).replayMessages && (data as any).replayMessages.length > 0) {
+            messages.value = (data as any).replayMessages.map((m: any, i: number) => ({
+              id: m.messageId || `replay_${i}`,
+              role: m.role,
+              content: m.content,
+              timestamp: Date.now() - ((data as any).replayMessages.length - i) * 1000,
+            }))
+            // 保存到缓存
+            await saveLocalMessages()
+          }
+          else {
+            messages.value = []
           }
+
+          await fetchSessionList()
         }
       }
-    } catch {
+    }
+    catch {
       // 忽略
-    } finally {
+    }
+    finally {
       isLoadingSession.value = false
     }
   }
@@ -180,25 +160,20 @@ export function useAiChat() {
   // 删除会话
   async function deleteSession(sessionId: string) {
     try {
-      const resp = await fetch(`${getBaseUrl()}/ai/sessions/${sessionId}`, {
-        method: 'DELETE',
-        headers: { Authorization: `Bearer ${userStore.token}` },
-      })
-      if (resp.ok) {
-        // 清除 IndexedDB 中的消息
-        await chatDB.deleteSession(sessionId)
-        // 如果删的是当前会话,清空消息
-        if (sessionId === currentSessionId.value) {
-          messages.value = []
-          currentSessionId.value = ''
-        }
-        // 刷新列表
-        await fetchSessionList()
+      await deleteAiSession(sessionId)
+      // 清除 IndexedDB 中的消息
+      await chatDB.deleteSession(sessionId)
+      // 如果删的是当前会话,清空消息
+      if (sessionId === currentSessionId.value) {
+        messages.value = []
+        currentSessionId.value = ''
+      }
+      // 刷新列表
+      await fetchSessionList()
 
-        // 如果没有任何会话了,自动创建新会话
-        if (sessionList.value.length === 0) {
-          await createNewSession()
-        }
+      // 如果没有任何会话了,自动创建新会话
+      if (sessionList.value.length === 0) {
+        await createNewSession()
       }
     }
     catch {
@@ -338,9 +313,24 @@ export function useAiChat() {
       const payload = JSON.parse(data)
 
       switch (eventType || payload.type) {
-        case 'text':
-          aiMessage.content += payload.content || ''
+        case 'text': {
+          const text = payload.content || ''
+          // 实时检测 $$FILE_DOWNLOAD:{...}$$ 标记
+          const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
+          let match
+          let cleanText = text
+          while ((match = fileRegex.exec(text)) !== null) {
+            try {
+              const fileData = JSON.parse(match[1])
+              if (!aiMessage.files) aiMessage.files = []
+              aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
+            }
+            catch { /* ignore malformed */ }
+            cleanText = cleanText.replace(match[0], '')
+          }
+          aiMessage.content += cleanText
           break
+        }
         case 'tool_call':
           if (!aiMessage.toolCalls) aiMessage.toolCalls = []
           aiMessage.toolCalls.push({
@@ -364,21 +354,12 @@ export function useAiChat() {
           }
           break
         }
-        case 'file_created': {
-          const fileData =
-            typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
-          if (!aiMessage.files) aiMessage.files = []
-          aiMessage.files.push({
-            key: fileData.key,
-            fileName: fileData.fileName,
-            size: fileData.size,
-          })
-          break
-        }
         case 'done':
           if (payload.sessionId) {
             currentSessionId.value = payload.sessionId
           }
+          // 最终扫描:处理跨 chunk 的标记
+          extractFileDownloads(aiMessage)
           break
         case 'welcome':
           welcomeMessage.value = payload.content || ''
@@ -392,32 +373,48 @@ export function useAiChat() {
     }
   }
 
+  // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记
+  function extractFileDownloads(aiMessage: ChatMessage) {
+    const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
+    let match
+    let hasMatch = false
+    while ((match = fileRegex.exec(aiMessage.content)) !== null) {
+      try {
+        const fileData = JSON.parse(match[1])
+        if (!aiMessage.files) aiMessage.files = []
+        // 避免重复添加(实时匹配可能已添加)
+        const exists = aiMessage.files.some(f => f.key === fileData.key)
+        if (!exists) {
+          aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
+        }
+        hasMatch = true
+      }
+      catch { /* ignore */ }
+    }
+    // 清除内容中的标记文本
+    if (hasMatch) {
+      aiMessage.content = aiMessage.content.replace(/\$\$FILE_DOWNLOAD:.*?\$\$/g, '').trim()
+    }
+  }
+
   async function resolvePermission(optionId: string) {
     try {
-      await fetch(`${getBaseUrl()}/ai/permission`, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${userStore.token}`,
-        },
-        body: JSON.stringify({ optionId }),
-      })
+      await resolveAiPermission(optionId)
       const lastMsg = messages.value[messages.value.length - 1]
       if (lastMsg?.permission) {
         lastMsg.permission.resolved = true
       }
-    } catch {
+    }
+    catch {
       // 忽略
     }
   }
 
   async function cancelChat() {
     try {
-      await fetch(`${getBaseUrl()}/ai/cancel`, {
-        method: 'POST',
-        headers: { Authorization: `Bearer ${userStore.token}` },
-      })
-    } catch {
+      await cancelAiChat()
+    }
+    catch {
       // 忽略
     }
   }

+ 79 - 0
src/service/aiChat/index.ts

@@ -0,0 +1,79 @@
+// API List
+// /ai/chat              - POST  发送消息(SSE 流式响应)
+// /ai/sessions          - GET   获取当前会话信息
+// /ai/sessions/list     - GET   获取会话历史列表
+// /ai/sessions/new      - POST  创建新会话
+// /ai/sessions/:id/load - POST  加载历史会话
+// /ai/sessions/:id      - PATCH 更新会话标题
+// /ai/sessions/:id      - DELETE 删除会话
+// /ai/cancel            - POST  取消当前操作
+// /ai/permission        - POST  回复权限请求
+// /ai/files/upload      - POST  上传文件
+// /ai/files/download    - GET   下载文件
+
+import http from '@/service/serverReq'
+import type { AIChatSession, SessionListItem } from '@/layout/header/Actions/AiChat/interface'
+
+const API = {
+  chat: '/ai/chat',
+  sessions: '/ai/sessions',
+  sessionList: '/ai/sessions/list',
+  sessionNew: '/ai/sessions/new',
+  sessionLoad: (id: string) => `/ai/sessions/${id}/load`,
+  sessionUpdate: (id: string) => `/ai/sessions/${id}`,
+  sessionDelete: (id: string) => `/ai/sessions/${id}`,
+  cancel: '/ai/cancel',
+  permission: '/ai/permission',
+  fileUpload: '/ai/files/upload',
+  fileDownload: '/ai/files/download',
+}
+
+// GET 获取当前会话信息
+export const getAiSession = async (): Promise<AIChatSession> => {
+  return await http.get<AIChatSession>(API.sessions)
+}
+
+// GET 获取会话历史列表
+export const getAiSessionList = async (): Promise<SessionListItem[]> => {
+  return await http.get<SessionListItem[]>(API.sessionList)
+}
+
+// POST 创建新会话
+export const createAiSession = async (): Promise<{ sessionId: string, welcomeMessage?: string }> => {
+  return await http.post(API.sessionNew)
+}
+
+// POST 加载历史会话
+export const loadAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string }> => {
+  return await http.post(API.sessionLoad(sessionId))
+}
+
+// PATCH 更新会话标题
+export const updateAiSessionTitle = async (sessionId: string, title: string) => {
+  return await http.request(API.sessionUpdate(sessionId), 'PATCH', { data: { title } })
+}
+
+// DELETE 删除会话
+export const deleteAiSession = async (sessionId: string) => {
+  return await http.delete(API.sessionDelete(sessionId))
+}
+
+// POST 取消当前操作
+export const cancelAiChat = async () => {
+  return await http.post(API.cancel)
+}
+
+// POST 回复权限请求
+export const resolveAiPermission = async (optionId: string) => {
+  return await http.post(API.permission, { optionId })
+}
+
+// GET 下载文件(流式下载)
+export const downloadAiFile = async (key: string, fileName: string) => {
+  await http.download(API.fileDownload, fileName, {
+    params: { key },
+  })
+}
+
+// 导出 chat SSE 请求路径(流式请求需在 useAiChat 中直接 fetch)
+export { API as AI_CHAT_API }