ソースを参照

feat: 更新前端密钥处理逻辑,优化文档预览功能

- 修改前端实现,AI、工作流接口仅读取代理地址,敏感密钥统一由后端管控
- 在应用根组件(App)实现基于URL参数自动唤起文档预览
- 优化消息条目组件(MessageItem),支持渲染文档链接并绑定预览点击事件
- 在区块编辑器(BlockEditor)新增事件监听,同步处理远端区块更新、删除操作,保持文档数据一致
- 重构导出记录列表组件(ExportRecordList),在导出任务创建时自动刷新列表
- 更新AI与工作流服务配置,消除前端明文暴露API密钥的风险
- 在区块服务(blockService)新增区块更新、区块删除事件定义
- 优化WebMCP服务,增强重连机制与凭证(ticket)拉取稳定性
- 升级翻译服务(translator service),完善异常捕获与超时管控逻辑
- 更新编辑器状态库(editor store),确保远端区块变更、删除操作生效
- 配置Vite接口代理,优化本地开发环境调试体验
Zhang Yice 1 ヶ月 前
コミット
f8667fd05d

+ 5 - 6
.env

@@ -12,12 +12,11 @@ VITE_APP_TITLE=AX Document Editor
12 12
 # Default: false
13 13
 VITE_DEBUG=false
14 14
 
15
-# External AI services are called directly by the browser.
16
-VITE_AI_API_URL=http://114.242.25.27:3000/api/v1/chat/completions
17
-VITE_AI_API_KEY=XAgent-eVVoqEO7WJYwzCc5wQ8meEtIuyIQgHhxYvpd6fSwa7BwJW8CaBom4
18
-VITE_WORKFLOW_API_URL=http://114.242.25.27:3000/api/v2/chat/completions
19
-VITE_WORKFLOW_API_KEY=XAgent-mWHBqQw06psUYRqx6PrHWiKdfY05ebt7I9drDBHzaG9QQesIkVEICj
15
+# AI and workflow calls must go through the backend proxy.
16
+VITE_AI_PROXY_URL=/api/v1/ai/chat
17
+VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow
20 18
 
21 19
 # WebMCP Bridge 连接配置
22
-VITE_WEBMCP_BRIDGE_TOKEN=AXWebMCP-04fb7ebd989246b28ca63a49e0efd2c0a27cc8c89e2241fda22b8b0c42c744a1
23 20
 VITE_WEBMCP_CLIENT_ID=ax-editor-local
21
+# Local development only. Never configure this in a production build.
22
+VITE_WEBMCP_BRIDGE_TOKEN=AXWebMCP-04fb7ebd989246b28ca63a49e0efd2c0a27cc8c89e2241fda22b8b0c42c744a1

+ 4 - 7
.env.example

@@ -14,12 +14,9 @@ VITE_APP_TITLE=AX Document Editor
14 14
 # Default: false
15 15
 VITE_DEBUG=false
16 16
 
17
-# Development-only direct AI endpoints. Never ship these keys in a production build.
18
-VITE_AI_API_URL=
19
-VITE_AI_API_KEY=
20
-VITE_WORKFLOW_API_URL=
21
-VITE_WORKFLOW_API_KEY=
22
-
23
-# Production same-origin proxy endpoints. The proxy stores the upstream API keys server-side.
17
+# The browser must use backend proxy endpoints. Keep upstream API keys server-side.
24 18
 VITE_AI_PROXY_URL=/api/v1/ai/chat
25 19
 VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow
20
+
21
+# Local development only; production uses a session-based WebMCP ticket.
22
+VITE_WEBMCP_BRIDGE_TOKEN=

+ 5 - 6
README.md

@@ -126,8 +126,7 @@ ax-frontend-app/
126 126
 - 支持断网检测和重连机制
127 127
 
128 128
 **API 集成**:
129
-- AI 对话 API: `VITE_AI_API_URL`
130
-- 工作流 API: `VITE_WORKFLOW_API_URL`
129
+- AI 对话和工作流通过后端代理访问,使用 `VITE_AI_PROXY_URL` 与 `VITE_WORKFLOW_PROXY_URL`
131 130
 
132 131
 ---
133 132
 
@@ -407,10 +406,10 @@ Content-Type: 'application/json'
407 406
 | `VITE_API_BASE_URL` | 后端 API 地址 | `http://192.168.0.195:8000` |
408 407
 | `VITE_APP_TITLE` | 应用标题 | `AX Document Editor` |
409 408
 | `VITE_DEBUG` | 调试模式 | `false` |
410
-| `VITE_AI_API_URL` | AI 对话 API | `http://114.242.25.27:3000/api/v1/chat/completions` |
411
-| `VITE_AI_API_KEY` | AI API 密钥 | - |
412
-| `VITE_WORKFLOW_API_URL` | 工作流 API | `http://114.242.25.27:3000/api/v2/chat/completions` |
413
-| `VITE_WORKFLOW_API_KEY` | 工作流 API 密钥 | - |
409
+| `VITE_AI_PROXY_URL` | 后端 AI 代理地址 | `/api/v1/ai/chat` |
410
+| `VITE_WORKFLOW_PROXY_URL` | 后端工作流代理地址 | `/api/v1/ai/workflow` |
411
+
412
+AI 和工作流上游密钥只能配置在后端,禁止放入任何 `VITE_*` 变量。
414 413
 
415 414
 ### 配置步骤
416 415
 

+ 3 - 5
docs/README.md

@@ -217,7 +217,7 @@ PDF、截图和 Word 解析库不应改为入口静态 import,否则会增加
217 217
 
218 218
 必须注意:
219 219
 
220
-`VITE_*` 环境变量会进入浏览器构建产物,因此 `VITE_AI_API_KEY` 和 `VITE_WORKFLOW_API_KEY` 不能作为生产秘密。生产环境应使用后端代理
220
+`VITE_*` 环境变量会进入浏览器构建产物,因此 AI 和工作流密钥不能作为前端配置。浏览器只能调用后端代理,生产密钥必须保存在后端
221 221
 
222 222
 ```text
223 223
 浏览器 -> 自有后端 -> AI / Workflow 服务
@@ -239,10 +239,8 @@ npm audit --omit=dev
239 239
 | 变量                    | 用途                            |
240 240
 | ----------------------- | ------------------------------- |
241 241
 | `VITE_API_BASE_URL`     | 后端 API 地址                   |
242
-| `VITE_AI_API_URL`       | AI 聊天服务地址                 |
243
-| `VITE_AI_API_KEY`       | AI 服务访问令牌,不应放生产秘密 |
244
-| `VITE_WORKFLOW_API_URL` | 文档工作流地址                  |
245
-| `VITE_WORKFLOW_API_KEY` | 工作流访问令牌,不应放生产秘密  |
242
+| `VITE_AI_PROXY_URL`       | 后端 AI 代理地址                 |
243
+| `VITE_WORKFLOW_PROXY_URL` | 后端工作流代理地址              |
246 244
 | `VITE_DEBUG`            | 是否输出 API 调试日志           |
247 245
 
248 246
 默认后端地址由服务层提供,仅适合本地开发。部署时应显式配置 `VITE_API_BASE_URL`。

+ 520 - 0
docs/外部MCP服务接入指南.md

@@ -0,0 +1,520 @@
1
+# 外部 MCP 服务接入指南
2
+
3
+本文档说明 AX 文档编辑器如何接入外部 MCP(Model Context Protocol)服务,以及当前项目已经具备的能力和需要补充的实现。
4
+
5
+## 1. 先明确当前能力边界
6
+
7
+当前项目已经实现的是 **WebMCP Provider 和 Bridge**:
8
+
9
+```text
10
+其他网站
11
+  -> POST /api/v1/webmcp/invoke
12
+  -> AX 后端 WebMCP Bridge
13
+  -> AX 前端页面 WebSocket Connector
14
+  -> AX 前端注册的 WebMCP 工具
15
+```
16
+
17
+当前页面注册的工具包括:
18
+
19
+- `open_document`
20
+- `list_documents`
21
+- `get_document`
22
+- `search_document`
23
+- `get_block`
24
+- `get_document_toc`
25
+- `get_document_stats`
26
+- `list_export_records`
27
+- `insert_block`
28
+- `update_block`
29
+- `delete_block`
30
+- `export_document`
31
+- `download_export_record`
32
+
33
+这些工具在前端 [webMcpService.ts](../src/services/webMcpService.ts) 中定义,由当前 AX 页面执行。
34
+
35
+当前项目**还不是通用 MCP Client**,因此不能仅通过现有 WebMCP Bridge 自动发现或调用其他 MCP Server 的工具。要接入外部 MCP Server,需要在后端新增 MCP Client 层。
36
+
37
+## 2. 推荐的目标架构
38
+
39
+外部 MCP 服务应该由 FastAPI 后端连接,浏览器只负责展示结果和触发经过授权的操作。
40
+
41
+```text
42
+用户聊天输入
43
+  -> AX 前端 Chat Agent
44
+  -> AX 后端统一工具目录
45
+       ├── AX 本地 WebMCP 工具
46
+       └── 外部 MCP 工具
47
+  -> 工具路由器
48
+       ├── 本地工具:通过 WebMCP Bridge 调用当前页面
49
+       └── 外部工具:通过 MCP Client 调用对应 MCP Server
50
+  -> 返回结构化结果
51
+  -> 前端展示结果
52
+```
53
+
54
+外部 MCP Server 可能使用以下连接方式:
55
+
56
+- `stdio`:后端启动本地 MCP Server 子进程;
57
+- Streamable HTTP:后端通过 HTTP 连接远端 MCP Server;
58
+- SSE:连接支持 SSE 的 MCP Server;
59
+- 需要 API Key、OAuth 或其他服务端认证方式的远程服务。
60
+
61
+推荐把外部连接放在后端,原因如下:
62
+
63
+- 浏览器不能安全地启动 `stdio` 子进程;
64
+- API Key、OAuth Refresh Token 等机密不能放入 `VITE_*` 环境变量;
65
+- 后端可以统一做工具白名单、用户权限、限流、审计和写操作确认;
66
+- 多个前端页面可以复用同一个 MCP Client 连接池。
67
+
68
+## 3. 当前项目中已经可用的 WebMCP Bridge
69
+
70
+如果目标只是让其他网站调用 AX 自己的工具,不需要接入外部 MCP Server,可以直接使用现有接口。
71
+
72
+### 3.1 启动服务
73
+
74
+启动后端:
75
+
76
+```powershell
77
+cd "D:\work space\ax-shell\ax-backend-v1"
78
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
79
+```
80
+
81
+启动前端:
82
+
83
+```powershell
84
+cd "D:\work space\ax-shell\ax-frontend-app"
85
+npm run dev
86
+```
87
+
88
+然后保持 AX 页面打开:
89
+
90
+```text
91
+http://localhost:5173/
92
+```
93
+
94
+页面启动时会连接 WebSocket Bridge:
95
+
96
+```text
97
+ws://localhost:8000/api/v1/webmcp/bridge/{clientId}
98
+```
99
+
100
+### 3.2 调用 AX 工具
101
+
102
+```javascript
103
+async function invokeAxTool(tool, argumentsValue = {}) {
104
+  const response = await fetch('http://localhost:8000/api/v1/webmcp/invoke', {
105
+    method: 'POST',
106
+    headers: {
107
+      'Content-Type': 'application/json',
108
+      'X-WebMCP-Token': '从服务端安全配置中读取',
109
+    },
110
+    body: JSON.stringify({
111
+      clientId: 'ax-editor-local',
112
+      tool,
113
+      arguments: argumentsValue,
114
+    }),
115
+  });
116
+
117
+  const payload = await response.json();
118
+  if (!response.ok || payload.code !== 0) {
119
+    throw new Error(payload.detail || payload.message || 'WebMCP 调用失败');
120
+  }
121
+
122
+  const result = payload.data.result;
123
+  if (!result.ok) {
124
+    throw new Error(result.error || 'AX WebMCP 工具执行失败');
125
+  }
126
+
127
+  return result.data;
128
+}
129
+
130
+const documents = await invokeAxTool('list_documents', {
131
+  page: 1,
132
+  pageSize: 20,
133
+});
134
+```
135
+
136
+当前 AX 页面必须保持打开,因为 `open_document`、`update_block` 等工具依赖浏览器中的编辑器状态。
137
+
138
+## 4. 接入外部 MCP Server 的实施步骤
139
+
140
+### 步骤 1:选择连接方式
141
+
142
+先确认外部服务提供的协议和认证方式:
143
+
144
+| 类型            | 适用场景                         | 机密存放位置                   |
145
+| --------------- | -------------------------------- | ------------------------------ |
146
+| `stdio`         | 本机工具、文件系统工具、内部脚本 | 后端主机环境变量或密钥管理服务 |
147
+| Streamable HTTP | 远程部署的 MCP Server            | 后端服务端配置                 |
148
+| SSE             | 旧版或仍提供 SSE 的 MCP Server   | 后端服务端配置                 |
149
+| OAuth           | 第三方 SaaS 服务                 | 后端安全 Token 存储            |
150
+
151
+不要把外部 MCP Server 的命令、Token 或 API Key 写入前端代码。
152
+
153
+### 步骤 2:在后端增加 MCP Client 依赖
154
+
155
+当前 [requirements.txt](../../ax-backend-v1/requirements.txt) 尚未包含 MCP Client SDK。接入时应选择与目标 Server 协议匹配的官方或可信 SDK,并固定版本。
156
+
157
+安装依赖前应确认:
158
+
159
+- SDK 支持当前 Python 版本;
160
+- SDK 支持目标 Server 的传输方式;
161
+- SDK 的许可证和维护状态符合项目要求;
162
+- 依赖不会把不受信任的命令执行能力暴露给公网请求。
163
+
164
+不要直接把任意用户输入拼接到 `stdio` 命令或参数中。
165
+
166
+### 步骤 3:增加外部 Server 配置
167
+
168
+建议在后端配置中增加 Server 配置,而不是把配置硬编码到业务代码:
169
+
170
+```env
171
+EXTERNAL_MCP_ENABLED=false
172
+EXTERNAL_MCP_SERVERS_JSON={}
173
+EXTERNAL_MCP_CONNECT_TIMEOUT=10
174
+EXTERNAL_MCP_CALL_TIMEOUT=60
175
+EXTERNAL_MCP_MAX_RESULT_BYTES=524288
176
+```
177
+
178
+生产环境更推荐使用单独的密钥管理系统。配置内容至少应包括:
179
+
180
+```json
181
+{
182
+  "filesystem": {
183
+    "enabled": true,
184
+    "transport": "stdio",
185
+    "command": "npx",
186
+    "args": ["-y", "@modelcontextprotocol/server-filesystem", "D:/documents"],
187
+    "allowedTools": ["read_file", "list_directory"]
188
+  }
189
+}
190
+```
191
+
192
+注意:上面的配置是接入设计示例,不代表当前项目已经支持该配置格式。
193
+
194
+### 步骤 4:增加 MCP Client 管理器
195
+
196
+建议新增以下后端结构:
197
+
198
+```text
199
+ax-backend-v1/app/mcp/
200
+  __init__.py
201
+  client.py       # 单个 MCP Server 的连接和调用
202
+  manager.py      # 多个 Server 的生命周期管理
203
+  registry.py     # 工具目录、命名空间和白名单
204
+  schemas.py      # 工具和调用结果的数据结构
205
+```
206
+
207
+单个 Client 至少应提供:
208
+
209
+```python
210
+class ExternalMcpClient:
211
+    async def connect(self) -> None:
212
+        """建立连接并完成 MCP initialize。"""
213
+
214
+    async def list_tools(self) -> list[dict]:
215
+        """调用 tools/list 获取工具定义。"""
216
+
217
+    async def call_tool(self, name: str, arguments: dict) -> dict:
218
+        """调用 tools/call 执行工具。"""
219
+
220
+    async def close(self) -> None:
221
+        """释放连接、子进程和后台任务。"""
222
+```
223
+
224
+管理器应负责:
225
+
226
+- 应用启动时连接已启用的 Server;
227
+- 应用关闭时释放连接;
228
+- 连接断开后的有限次重连;
229
+- `tools/list` 结果缓存和刷新;
230
+- 工具名称冲突处理;
231
+- 单 Server 和单用户的并发限制;
232
+- 调用超时和结果大小限制。
233
+
234
+### 步骤 5:为外部工具添加命名空间
235
+
236
+外部工具不能直接使用裸名称,否则容易与 AX 本地工具冲突。建议统一使用:
237
+
238
+```text
239
+filesystem.read_file
240
+github.search_repositories
241
+notion.search
242
+```
243
+
244
+内部映射可以是:
245
+
246
+```python
247
+TOOL_MAPPING = {
248
+    "filesystem.read_file": {
249
+        "server": "filesystem",
250
+        "remote_name": "read_file",
251
+    },
252
+}
253
+```
254
+
255
+对外暴露的统一工具结构建议如下:
256
+
257
+```python
258
+{
259
+    "name": "filesystem.read_file",
260
+    "title": "读取文件",
261
+    "description": "读取允许目录中的文件内容",
262
+    "inputSchema": {
263
+        "type": "object",
264
+        "properties": {
265
+            "path": {"type": "string"}
266
+        },
267
+        "required": ["path"]
268
+    },
269
+    "source": "external",
270
+    "server": "filesystem",
271
+    "requiresConfirmation": false
272
+}
273
+```
274
+
275
+### 步骤 6:增加后端代理接口
276
+
277
+推荐新增独立的外部 MCP API,而不是把外部工具直接塞进现有 AX Bridge 白名单:
278
+
279
+```http
280
+GET /api/v1/mcp/tools
281
+POST /api/v1/mcp/tools/call
282
+```
283
+
284
+获取工具:
285
+
286
+```http
287
+GET /api/v1/mcp/tools
288
+Authorization: Bearer <user-token>
289
+```
290
+
291
+调用工具:
292
+
293
+```http
294
+POST /api/v1/mcp/tools/call
295
+Authorization: Bearer <user-token>
296
+Content-Type: application/json
297
+
298
+{
299
+  "tool": "filesystem.read_file",
300
+  "arguments": {
301
+    "path": "D:/documents/example.txt"
302
+  }
303
+}
304
+```
305
+
306
+返回结果建议统一成:
307
+
308
+```json
309
+{
310
+  "ok": true,
311
+  "tool": "filesystem.read_file",
312
+  "server": "filesystem",
313
+  "data": {
314
+    "content": "文件内容"
315
+  }
316
+}
317
+```
318
+
319
+外部 MCP Server 的原始结果应在后端转换成受控结构,不能无条件把任意对象、二进制数据或超大文本直接返回给浏览器。
320
+
321
+### 步骤 7:接入聊天工具目录
322
+
323
+当前聊天转译上下文主要来自前端 [webMcpAgentService.ts](../src/services/webMcpAgentService.ts) 的本地工具列表。接入外部工具后,应将本地和外部工具合并:
324
+
325
+```text
326
+本地工具:getWebMcpTools()
327
+外部工具:GET /api/v1/mcp/tools
328
+统一工具目录:localTools + externalTools
329
+```
330
+
331
+工具调用时按来源路由:
332
+
333
+```text
334
+open_document
335
+  -> AX 前端 WebMCP Runtime
336
+
337
+filesystem.read_file
338
+  -> AX 后端 MCP Client
339
+```
340
+
341
+自然语言转译结果必须经过服务端二次校验,不能因为模型返回了某个工具名称就直接执行。
342
+
343
+## 5. 安全要求
344
+
345
+### 5.1 工具白名单
346
+
347
+只允许配置明确启用的 Server 和工具:
348
+
349
+```python
350
+ALLOWED_EXTERNAL_TOOLS = {
351
+    "filesystem.read_file",
352
+    "filesystem.list_directory",
353
+}
354
+```
355
+
356
+不能允许请求方任意传入 Server 地址、命令、工具名称或远程 URL。
357
+
358
+### 5.2 写操作确认
359
+
360
+以下类型的外部工具默认需要用户确认:
361
+
362
+- 写入、删除或移动文件;
363
+- 创建、修改或删除第三方平台数据;
364
+- 发送邮件、消息或通知;
365
+- 创建费用、订单或权限变更;
366
+- 执行脚本、命令或数据库写操作。
367
+
368
+确认凭证必须绑定:
369
+
370
+- 当前用户;
371
+- 当前会话;
372
+- 工具名称;
373
+- 完整参数哈希;
374
+- 短时间有效期;
375
+- 一次性使用标识。
376
+
377
+### 5.3 认证和权限
378
+
379
+生产环境不能使用长期共享的 `VITE_WEBMCP_BRIDGE_TOKEN`。当前项目的生产配置应使用:
380
+
381
+```env
382
+WEBMCP_AUTH_REQUIRED=true
383
+WEBMCP_JWT_SECRET=独立的高熵服务端密钥
384
+WEBMCP_REDIS_URL=redis://...
385
+WEBMCP_REQUIRE_SHARED_STORE=true
386
+```
387
+
388
+外部 MCP Client 还必须校验:
389
+
390
+- 当前用户是否有权访问目标 Server;
391
+- 当前用户是否有权访问目标资源;
392
+- 文档、文件或第三方对象是否属于当前租户;
393
+- 外部工具返回内容是否可展示给当前用户。
394
+
395
+### 5.4 SSRF 和命令执行防护
396
+
397
+如果支持 HTTP MCP Server:
398
+
399
+- 不允许用户直接提交任意 URL;
400
+- 只允许服务端配置的域名或地址;
401
+- 禁止访问云元数据地址、内网管理地址和本机敏感端口;
402
+- 对重定向进行重新校验;
403
+- 配置连接、读取和总调用超时。
404
+
405
+如果支持 `stdio` MCP Server:
406
+
407
+- 命令和参数只能来自服务端配置;
408
+- 不要使用 shell 拼接执行;
409
+- 使用参数数组启动进程;
410
+- 限制工作目录和环境变量;
411
+- 使用专用低权限系统用户;
412
+- 限制子进程数量、内存和运行时间。
413
+
414
+### 5.5 结果和日志
415
+
416
+必须限制:
417
+
418
+- 请求参数大小;
419
+- 工具结果大小;
420
+- 单用户并发数;
421
+- 单 Server 并发数;
422
+- 调用频率;
423
+- 连接重试次数。
424
+
425
+日志中不要记录:
426
+
427
+- API Key;
428
+- OAuth Token;
429
+- Cookie;
430
+- 完整文件内容;
431
+- 可能包含个人信息的工具参数。
432
+
433
+建议记录:
434
+
435
+- 用户 ID 和会话 ID;
436
+- Server 名称;
437
+- 命名空间工具名;
438
+- 调用是否需要确认;
439
+- 成功、失败或超时;
440
+- 调用耗时和结果大小。
441
+
442
+## 6. 推荐的分阶段落地顺序
443
+
444
+### 阶段一:只读工具
445
+
446
+先接入一个只读外部 MCP Server,例如:
447
+
448
+- 文件列表;
449
+- 文件读取;
450
+- 搜索;
451
+- 查询类工具。
452
+
453
+只实现:
454
+
455
+- Server 配置;
456
+- Client 连接;
457
+- `tools/list`;
458
+- `tools/call`;
459
+- 工具白名单;
460
+- 超时和结果限制;
461
+- 只读调用 API。
462
+
463
+### 阶段二:接入聊天
464
+
465
+将外部工具目录加入聊天 Agent:
466
+
467
+- 展示工具名称和描述;
468
+- 将工具 Schema 提供给转译器;
469
+- 服务端校验工具名称和参数;
470
+- 将调用结果转换成适合聊天展示的文本或结构化卡片。
471
+
472
+### 阶段三:写操作和确认
473
+
474
+增加:
475
+
476
+- 用户确认弹窗;
477
+- 一次性确认凭证;
478
+- 参数哈希绑定;
479
+- 审计日志;
480
+- 失败重试和幂等策略。
481
+
482
+### 阶段四:多租户和生产部署
483
+
484
+增加:
485
+
486
+- 每个租户独立的 MCP Server 配置;
487
+- 用户级 OAuth Token;
488
+- Redis 或其他共享状态存储;
489
+- 多实例连接管理;
490
+- 监控、告警和调用成本统计。
491
+
492
+## 7. 上线检查清单
493
+
494
+- [ ] 外部 MCP Client 只运行在后端。
495
+- [ ] 外部 MCP Server 地址来自服务端配置,不来自用户输入。
496
+- [ ] API Key 和 OAuth Token 没有进入前端构建产物。
497
+- [ ] 所有外部工具使用命名空间。
498
+- [ ] 工具和 Server 都有白名单。
499
+- [ ] 只读和写操作明确区分。
500
+- [ ] 写操作需要当前用户确认。
501
+- [ ] 确认凭证绑定用户、会话、工具和参数哈希。
502
+- [ ] 配置连接超时、调用超时和结果大小限制。
503
+- [ ] 配置单用户和单 Server 并发限制。
504
+- [ ] HTTP Server 已防护 SSRF。
505
+- [ ] `stdio` Server 使用低权限进程和固定参数。
506
+- [ ] 日志已脱敏。
507
+- [ ] 已测试断线、超时、错误结果和超大结果。
508
+- [ ] 已测试多用户和多租户权限隔离。
509
+- [ ] 生产环境未使用长期共享 Bridge Token。
510
+
511
+## 8. 相关代码和文档
512
+
513
+- [AX WebMCP 跨网站调用文档](./WebMCP调用接入文档.md)
514
+- [AX WebMCP 工具定义](../src/services/webMcpService.ts)
515
+- [AX WebMCP Bridge](../src/share/webmcp/bridge.ts)
516
+- [后端 WebMCP 路由](../../ax-backend-v1/app/api/v1/webmcp.py)
517
+- [后端配置](../../ax-backend-v1/app/config.py)
518
+- [后端依赖](../../ax-backend-v1/requirements.txt)
519
+
520
+> 本指南中的外部 MCP Client 目录、配置项和代理接口属于推荐设计。当前仓库已经实现的是 AX 自有 WebMCP 工具及 Bridge;在外部 MCP Client 代码落地前,不应把外部 Server 工具当作当前系统已支持的功能对外承诺。

+ 1 - 1
docs/新编辑器架构设计.md

@@ -90,7 +90,7 @@ MessageItem / ExportRecordList
90 90
 
91 91
 `richTextConverter` 在生成 HTML 前会转义文本,并限制颜色和字体值格式。富文本解析使用临时 DOM,不应直接将未过滤的用户 HTML 传给 `dangerouslySetInnerHTML`。
92 92
 
93
-当前前端仍会读取 `VITE_AI_API_KEY` 和 `VITE_WORKFLOW_API_KEY`。Vite 的 `VITE_*` 变量会被打包到浏览器,不能视为秘密。生产部署应由后端代理 AI 和工作流请求
93
+前端只读取 `VITE_AI_PROXY_URL` 和 `VITE_WORKFLOW_PROXY_URL`,AI 与工作流上游密钥必须由后端代理保存和使用。任何 `VITE_*_API_KEY` 都会被打包到浏览器,不能配置为秘密
94 94
 
95 95
 ## 8. 性能决策
96 96
 

+ 7 - 1
src/App.tsx

@@ -24,7 +24,7 @@
24 24
  * @module App
25 25
  */
26 26
 
27
-import React, { useCallback, useState, memo, lazy, Suspense, useMemo } from 'react';
27
+import React, { useCallback, useEffect, useState, memo, lazy, Suspense, useMemo } from 'react';
28 28
 import { ConfigProvider, Alert, Button, Drawer, Spin } from 'antd';
29 29
 import { MessageOutlined } from '@ant-design/icons';
30 30
 import zhCN from 'antd/locale/zh_CN';
@@ -132,8 +132,14 @@ const App: React.FC = () => {
132 132
   // ── Editor state ──────────────────────────────────────────────────────────
133 133
   const previewDocumentId = useUIStore((state) => state.previewDocumentId);
134 134
   const previewDocumentName = useUIStore((state) => state.previewDocumentName);
135
+  const openDocumentPreview = useUIStore((state) => state.openDocumentPreview);
135 136
   const closeDocumentPreview = useUIStore((state) => state.closeDocumentPreview);
136 137
 
138
+  useEffect(() => {
139
+    const documentId = new URLSearchParams(window.location.search).get('documentId');
140
+    if (documentId) openDocumentPreview(documentId);
141
+  }, [openDocumentPreview]);
142
+
137 143
   // ── Session history drawer ────────────────────────────────────────────────
138 144
   const [sessionListOpen, setSessionListOpen] = useState(false);
139 145
 

+ 79 - 1
src/components/ChatPanel/MessageItem.tsx

@@ -104,6 +104,84 @@ const exportCardActionStyle: React.CSSProperties = {
104 104
   transition: 'color 0.2s',
105 105
 };
106 106
 
107
+const URL_PATTERN = /(https?:\/\/[^\s]+)/g;
108
+
109
+const documentLinkCardStyle: React.CSSProperties = {
110
+  ...exportCardStyle,
111
+  marginTop: 0,
112
+  marginBottom: 8,
113
+};
114
+
115
+const getDocumentIdFromUrl = (value: string): string | undefined => {
116
+  try {
117
+    return new URL(value).searchParams.get('documentId') || undefined;
118
+  } catch {
119
+    return undefined;
120
+  }
121
+};
122
+
123
+const renderMessageContent = (
124
+  content: string,
125
+  onPreviewDocument?: (documentId: string, documentName?: string) => void
126
+): React.ReactNode => {
127
+  const lines = content.split('\n');
128
+  const nodes: React.ReactNode[] = [];
129
+
130
+  for (let index = 0; index < lines.length; index += 1) {
131
+    const documentLine = lines[index].match(/^\s*\d+\.\s+(.+?)(?:\s+\(更新于[^)]*\))?\s*$/);
132
+    const documentUrl = lines[index + 1]?.trim();
133
+    const documentId = documentUrl && /^https?:\/\//i.test(documentUrl)
134
+      ? getDocumentIdFromUrl(documentUrl)
135
+      : undefined;
136
+
137
+    if (documentLine && documentId) {
138
+      nodes.push(
139
+        <Card
140
+          key={`document-link-${index}`}
141
+          style={documentLinkCardStyle}
142
+          styles={{ body: exportCardBodyStyle }}
143
+          hoverable
144
+          onClick={() => onPreviewDocument?.(documentId, documentLine[1])}
145
+          data-testid="document-link-card"
146
+        >
147
+          <div style={exportCardHeaderStyle}>
148
+            <FileTextOutlined style={exportCardIconStyle} />
149
+            <div style={exportCardTitleStyle}>{documentLine[1]}</div>
150
+          </div>
151
+          <div style={exportCardActionsStyle}>
152
+            <span>点击打开并编辑</span>
153
+          </div>
154
+        </Card>
155
+      );
156
+      index += 1;
157
+      continue;
158
+    }
159
+
160
+    const parts = lines[index].split(URL_PATTERN);
161
+    nodes.push(
162
+      <React.Fragment key={`message-line-${index}`}>
163
+        {parts.map((part, partIndex) => {
164
+          if (!/^https?:\/\//i.test(part)) return part;
165
+          return (
166
+            <a
167
+              key={`${part}-${partIndex}`}
168
+              href={part}
169
+              target="_blank"
170
+              rel="noreferrer"
171
+              onClick={(event) => event.stopPropagation()}
172
+            >
173
+              {part}
174
+            </a>
175
+          );
176
+        })}
177
+        {index < lines.length - 1 ? '\n' : null}
178
+      </React.Fragment>
179
+    );
180
+  }
181
+
182
+  return nodes;
183
+};
184
+
107 185
 // ── Component ────────────────────────────────────────────────────────────────
108 186
 
109 187
 /**
@@ -267,7 +345,7 @@ const MessageItem: React.FC<MessageItemProps> = memo(
267 345
       >
268 346
         {/* Message bubble */}
269 347
         <div style={bubbleStyle(role)} data-testid="message-bubble">
270
-          {content}
348
+          {renderMessageContent(content, onPreviewDocument)}
271 349
         </div>
272 350
 
273 351
         {/* Export record card (if present) */}

+ 39 - 0
src/components/Editor/BlockEditor.tsx

@@ -11,8 +11,10 @@ import React, { useEffect, useCallback } from 'react';
11 11
 import { Spin, message } from 'antd';
12 12
 import { useShallow } from 'zustand/react/shallow';
13 13
 import { useEditorStore } from '../../stores/editorStore';
14
+import { BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from '../../services/blockService';
14 15
 import { BlockCanvas } from './BlockCanvas';
15 16
 import { MainToolbar } from './toolbar/MainToolbar';
17
+import type { BlockUpdate } from '../../types/editor';
16 18
 import './BlockEditor.css';
17 19
 
18 20
 // ══════════════════════════════════════════════════════════════════════════════
@@ -86,6 +88,43 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
86 88
     };
87 89
   }, [documentId, loadDocument]);
88 90
 
91
+  // 接收聊天/WebMCP 已保存的块更新,立即同步当前打开的文档。
92
+  useEffect(() => {
93
+    const handleBlockUpdated = (event: Event) => {
94
+      const detail = (event as CustomEvent<{
95
+        documentId?: unknown;
96
+        blockId?: unknown;
97
+        updates?: unknown;
98
+      }>).detail;
99
+      if (
100
+        detail?.documentId !== documentId ||
101
+        typeof detail.blockId !== 'string' ||
102
+        !detail.updates ||
103
+        typeof detail.updates !== 'object'
104
+      ) {
105
+        return;
106
+      }
107
+      useEditorStore.getState().applyRemoteBlockUpdate(
108
+        detail.blockId,
109
+        detail.updates as BlockUpdate
110
+      );
111
+    };
112
+
113
+    window.addEventListener(BLOCK_UPDATED_EVENT, handleBlockUpdated);
114
+    return () => window.removeEventListener(BLOCK_UPDATED_EVENT, handleBlockUpdated);
115
+  }, [documentId]);
116
+
117
+  useEffect(() => {
118
+    const handleBlockDeleted = (event: Event) => {
119
+      const detail = (event as CustomEvent<{ documentId?: unknown; blockId?: unknown }>).detail;
120
+      if (detail?.documentId !== documentId || typeof detail.blockId !== 'string') return;
121
+      useEditorStore.getState().applyRemoteBlockDelete(detail.blockId);
122
+    };
123
+
124
+    window.addEventListener(BLOCK_DELETED_EVENT, handleBlockDeleted);
125
+    return () => window.removeEventListener(BLOCK_DELETED_EVENT, handleBlockDeleted);
126
+  }, [documentId]);
127
+
89 128
   // 在编辑器范围内接管撤销/重做快捷键,避免影响聊天输入框。
90 129
   useEffect(() => {
91 130
     const handleKeyDown = (event: KeyboardEvent) => {

+ 9 - 18
src/components/ExportRecordList/ExportRecordList.tsx

@@ -17,6 +17,7 @@ import {
17 17
   downloadExportRecord,
18 18
   deleteExportRecord,
19 19
 } from '../../services/exportRecordService';
20
+import { EXPORT_RECORD_CREATED_EVENT } from '../../services/exportService';
20 21
 import type { ExportRecord } from '../../types/export';
21 22
 
22 23
 const { Text } = Typography;
@@ -105,28 +106,18 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
105 106
 
106 107
   // Fetch records on mount and when page changes
107 108
   useEffect(() => {
108
-    let canceled = false;
109
+    const refresh = () => {
110
+      setLoading(true);
111
+      void fetchRecords();
112
+    };
109 113
 
110
-    listExportRecords({ userId, page, pageSize, sortOrder: 'desc' })
111
-      .then((response) => {
112
-        if (canceled) return;
113
-        setRecords(response.records);
114
-        setTotal(response.pagination.total);
115
-      })
116
-      .catch(() => {
117
-        if (canceled) return;
118
-        message.error('加载导出记录失败');
119
-        setRecords([]);
120
-        setTotal(0);
121
-      })
122
-      .finally(() => {
123
-        if (!canceled) setLoading(false);
124
-      });
114
+    window.addEventListener(EXPORT_RECORD_CREATED_EVENT, refresh);
115
+    void fetchRecords();
125 116
 
126 117
     return () => {
127
-      canceled = true;
118
+      window.removeEventListener(EXPORT_RECORD_CREATED_EVENT, refresh);
128 119
     };
129
-  }, [userId, page]);
120
+  }, [fetchRecords]);
130 121
 
131 122
   /**
132 123
    * Handle download button click

+ 1 - 9
src/services/aiChatService.ts

@@ -71,23 +71,17 @@ export interface ChatCompletionResponse {
71 71
  */
72 72
 interface AIChatConfig {
73 73
   apiUrl: string;
74
-  apiKey: string;
75 74
 }
76 75
 
77 76
 /**
78 77
  * Get AI Chat configuration from environment variables
79 78
  */
80 79
 const getAIChatConfig = (): AIChatConfig => {
81
-  const isDevelopment = import.meta.env.DEV;
82 80
   return {
83
-    apiUrl: isDevelopment ? import.meta.env.VITE_AI_API_URL : import.meta.env.VITE_AI_PROXY_URL,
84
-    apiKey: isDevelopment ? import.meta.env.VITE_AI_API_KEY : '',
81
+    apiUrl: import.meta.env.VITE_AI_PROXY_URL,
85 82
   };
86 83
 };
87 84
 
88
-const getAuthorizationHeaders = (apiKey: string): Record<string, string> =>
89
-  apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
90
-
91 85
 /**
92 86
  * Send a chat completion request to AI platform
93 87
  *
@@ -112,7 +106,6 @@ export const sendChatCompletion = async (
112 106
       method: 'POST',
113 107
       headers: {
114 108
         'Content-Type': 'application/json',
115
-        ...getAuthorizationHeaders(config.apiKey),
116 109
       },
117 110
       body: JSON.stringify({
118 111
         chatId: request.chatId,
@@ -176,7 +169,6 @@ export const sendStreamingChatCompletion = async (
176 169
       method: 'POST',
177 170
       headers: {
178 171
         'Content-Type': 'application/json',
179
-        ...getAuthorizationHeaders(config.apiKey),
180 172
       },
181 173
       body: JSON.stringify({
182 174
         ...request,

+ 3 - 0
src/services/blockService.ts

@@ -29,6 +29,9 @@ import type {
29 29
 
30 30
 const BLOCKS_BASE_URL = '/api/v1/documents';
31 31
 
32
+export const BLOCK_UPDATED_EVENT = 'ax:block-updated';
33
+export const BLOCK_DELETED_EVENT = 'ax:block-deleted';
34
+
32 35
 function encodeResourceId(value: string, name: string): string {
33 36
   const hasUnsafeCharacter = Array.from(value).some((character) => {
34 37
     const codePoint = character.codePointAt(0) ?? 0;

+ 7 - 1
src/services/exportService.ts

@@ -19,6 +19,8 @@ import apiClient, { getErrorMessage, isApiError } from './api';
19 19
 import type { ApiResponse } from '../types/api';
20 20
 import type { ExportDocRequest, ExportDocResponse } from '../types/export';
21 21
 
22
+export const EXPORT_RECORD_CREATED_EVENT = 'ax:export-record-created';
23
+
22 24
 /**
23 25
  * Export document to Word format
24 26
  *
@@ -36,7 +38,10 @@ export const exportToWord = async (request: ExportDocRequest): Promise<ExportDoc
36 38
   try {
37 39
     const response = await apiClient.post<ApiResponse<ExportDocResponse>>(
38 40
       '/api/v1/export/doc',
39
-      request
41
+      request,
42
+      {
43
+        timeout: 120000,
44
+      }
40 45
     );
41 46
 
42 47
     const data = response.data.data;
@@ -61,6 +66,7 @@ export const exportToWord = async (request: ExportDocRequest): Promise<ExportDoc
61 66
       }
62 67
     }
63 68
 
69
+    window.dispatchEvent(new Event(EXPORT_RECORD_CREATED_EVENT));
64 70
     return data;
65 71
   } catch (error: unknown) {
66 72
     let friendlyMessage = '导出Word文档失败';

+ 80 - 6
src/services/webMcpAgentService.ts

@@ -301,6 +301,79 @@ const documentActionHelp = (content: string): string => {
301 301
 	return '请补充要操作的文档名称或文档块信息。';
302 302
 };
303 303
 
304
+const formatBlockContent = (content: unknown): string => {
305
+	if (typeof content === 'string') return content.trim();
306
+	if (Array.isArray(content)) {
307
+		return content
308
+			.map((part) => {
309
+				if (typeof part === 'string') return part;
310
+				if (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string') {
311
+					return (part as { text: string }).text;
312
+				}
313
+				return '';
314
+			})
315
+			.join('')
316
+			.trim();
317
+	}
318
+	if (content && typeof content === 'object') {
319
+		const title = (content as { title?: unknown }).title;
320
+		if (typeof title === 'string' && title.trim()) return title.trim();
321
+		try {
322
+			return JSON.stringify(content);
323
+		} catch {
324
+			return '';
325
+		}
326
+	}
327
+	return '';
328
+};
329
+
330
+const formatSearchResult = (documentLabel: string, data: unknown): string => {
331
+	if (!data || typeof data !== 'object') return `未找到“${documentLabel}”中的匹配内容。`;
332
+	const payload = data as { blocks?: unknown; total?: unknown; query?: unknown };
333
+	const blocks = Array.isArray(payload.blocks) ? payload.blocks : [];
334
+	const total = typeof payload.total === 'number' ? payload.total : blocks.length;
335
+	const query = typeof payload.query === 'string' ? payload.query : '';
336
+	if (!blocks.length) {
337
+		return `在“${documentLabel}”中未找到${query ? `包含“${query}”的` : ''}内容。`;
338
+	}
339
+
340
+	const lines = blocks.slice(0, 20).map((block, index) => {
341
+		if (!block || typeof block !== 'object') return `${index + 1}. (无法读取内容)`;
342
+		const item = block as { id?: unknown; type?: unknown; content?: unknown };
343
+		const id = typeof item.id === 'string' ? item.id : '未知块';
344
+		const type = typeof item.type === 'string' ? item.type : '内容块';
345
+		const content = formatBlockContent(item.content).replace(/\s+/g, ' ').trim();
346
+		const preview = content.length > 500 ? `${content.slice(0, 500)}...` : content;
347
+		return `${index + 1}. 【${type}】${preview || '(无可显示内容)'}\n   块 ID:${id}`;
348
+	});
349
+	const suffix = total > blocks.length ? `\n\n仅显示前 ${blocks.length} 条,共找到 ${total} 条。` : '';
350
+	return `在“${documentLabel}”中找到 ${total} 条${query ? `包含“${query}”的` : ''}内容:\n${lines.join('\n')}\n${suffix}`.trim();
351
+};
352
+
353
+const getDocumentUrl = (documentId: string): string => {
354
+	const url = new URL(window.location.href);
355
+	url.searchParams.set('documentId', documentId);
356
+	return url.toString();
357
+};
358
+
359
+const formatDocumentListResult = (data: unknown): string => {
360
+	if (!data || typeof data !== 'object') return '暂无可查看的文档。';
361
+	const payload = data as { documents?: unknown; pagination?: { total?: unknown } };
362
+	const documents = Array.isArray(payload.documents) ? payload.documents : [];
363
+	if (!documents.length) return '暂无可查看的文档。';
364
+
365
+	const lines = documents.map((document, index) => {
366
+		if (!document || typeof document !== 'object') return `${index + 1}. 文档信息无效`;
367
+		const item = document as { id?: unknown; updatedAt?: unknown };
368
+		const documentId = typeof item.id === 'string' ? item.id : '';
369
+		if (!documentId) return `${index + 1}. 文档 ID 缺失`;
370
+		const updatedAt = typeof item.updatedAt === 'string' ? `(更新于 ${item.updatedAt})` : '';
371
+		return `${index + 1}. ${documentId}${updatedAt}\n   ${getDocumentUrl(documentId)}`;
372
+	});
373
+	const total = typeof payload.pagination?.total === 'number' ? payload.pagination.total : documents.length;
374
+	return `共找到 ${total} 个文档:\n${lines.join('\n')}`;
375
+};
376
+
304 377
 const friendlyResult = (
305 378
 	toolName: string,
306 379
 	input: Record<string, unknown>,
@@ -310,9 +383,10 @@ const friendlyResult = (
310 383
 	const documentId = typeof input.documentId === 'string' ? input.documentId : '';
311 384
 	const blockId = typeof input.blockId === 'string' ? input.blockId : '';
312 385
 	const documentLabel = typeof input.documentTitle === 'string' ? input.documentTitle : documentId;
386
+	if (toolName === 'list_documents') return formatDocumentListResult(result.data);
313 387
 	if (toolName === 'open_document') return `已帮您打开“${documentLabel || '指定'}”文档。`;
314 388
 	if (toolName === 'get_document') return `已读取“${documentLabel || documentId}”文档内容。`;
315
-	if (toolName === 'search_document') return `已完成“${documentLabel || documentId}”文档搜索。`;
389
+	if (toolName === 'search_document') return formatSearchResult(documentLabel || documentId, result.data);
316 390
 	if (toolName === 'update_block') return `已修改“${documentLabel || documentId}”中的区块 ${blockId}。`;
317 391
 	if (toolName === 'delete_block') return `已删除“${documentLabel || documentId}”中的区块 ${blockId}。`;
318 392
 	if (toolName === 'insert_block') return `已向“${documentLabel || documentId}”插入新的内容块。`;
@@ -332,7 +406,7 @@ export const executeWebMcpChatCommand = async (content: string): Promise<WebMcpC
332 406
 	}
333 407
 
334 408
 	const matched = matchWebMcpChatTemplate(content);
335
-	const localCommand = await parseLocalDocumentCommand(content);
409
+	const localCommand = matched ? { handled: false as const } : await parseLocalDocumentCommand(content);
336 410
 	if (localCommand.handled && localCommand.command) {
337 411
 		const tool = getWebMcpTool(localCommand.command.toolName);
338 412
 		if (!tool) return { handled: true, response: `WebMCP 工具不存在:${localCommand.command.toolName}` };
@@ -418,7 +492,7 @@ export const confirmAndExecuteWebMcpChatCommand = async (
418 492
 ): Promise<WebMcpChatResult> => {
419 493
 	if (typeof contentOrCommand !== 'string') {
420 494
 		if (!contentOrCommand.toolName || !contentOrCommand.input) return { handled: false };
421
-		const result = await executeWebMcpTool(contentOrCommand.toolName, contentOrCommand.input);
495
+		const result = await executeWebMcpTool(contentOrCommand.toolName, contentOrCommand.input, { confirmed: true });
422 496
 		return {
423 497
 			handled: true,
424 498
 			toolName: contentOrCommand.toolName,
@@ -433,7 +507,8 @@ export const confirmAndExecuteWebMcpChatCommand = async (
433 507
 		};
434 508
 	}
435 509
 	const content = contentOrCommand;
436
-	const localCommand = await parseLocalDocumentCommand(content);
510
+	const matched = matchWebMcpChatTemplate(content);
511
+	const localCommand = matched ? { handled: false as const } : await parseLocalDocumentCommand(content);
437 512
 	if (localCommand.command) {
438 513
 		const { toolName, input, documentTitle } = localCommand.command;
439 514
 		const result = await executeWebMcpTool(toolName, input);
@@ -446,14 +521,13 @@ export const confirmAndExecuteWebMcpChatCommand = async (
446 521
 			response: friendlyResult(toolName, { ...input, documentTitle }, result),
447 522
 		};
448 523
 	}
449
-	const matched = matchWebMcpChatTemplate(content);
450 524
 	if (!matched?.template.toolName) {
451 525
 		try {
452 526
 			const translated = await translateInput(content);
453 527
 			const toolCall = translated.translation.toolCall;
454 528
 			if (!toolCall) return { handled: false };
455 529
 			const input = { ...toolCall.arguments };
456
-			const result = await executeWebMcpTool(toolCall.name, input);
530
+			const result = await executeWebMcpTool(toolCall.name, input, { confirmed: true });
457 531
 			return {
458 532
 				handled: true,
459 533
 				toolName: toolCall.name,

+ 38 - 5
src/services/webMcpService.ts

@@ -6,6 +6,7 @@ import type { BlockType, CreateBlockRequest, UpdateBlockRequest } from '../types
6 6
 import type { DocumentListFilters } from '../types/document';
7 7
 import { useUIStore } from '../stores/uiStore';
8 8
 import { finishWebMcpActivity, startWebMcpActivity } from './webMcpActivityService';
9
+import { BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from './blockService';
9 10
 
10 11
 export interface WebMcpToolResult {
11 12
 	ok: boolean;
@@ -48,11 +49,18 @@ export interface WebMcpRegistrationResult {
48 49
 	error?: string;
49 50
 }
50 51
 
52
+export interface WebMcpExecutionOptions {
53
+	confirmed?: boolean;
54
+}
55
+
51 56
 const DEFAULT_USER_ID = import.meta.env.VITE_WEBMCP_USER_ID || 'default-user';
52 57
 let registrationPromise: Promise<WebMcpRegistrationResult> | undefined;
53 58
 
54 59
 const text = (data: unknown): WebMcpToolResult => ({ ok: true, data });
55 60
 
61
+const isRecord = (value: unknown): value is Record<string, unknown> =>
62
+	typeof value === 'object' && value !== null && !Array.isArray(value);
63
+
56 64
 const failed = (error: unknown): WebMcpToolResult => ({
57 65
 	ok: false,
58 66
 	error: error instanceof Error ? error.message : 'WebMCP 工具执行失败',
@@ -188,7 +196,7 @@ const tools: WebMcpTool[] = [
188 196
 		readOnlyHint: true,
189 197
 		untrustedContentHint: true,
190 198
 		inputSchema: objectSchema(
191
-			{ documentId: documentIdProperty, query: { type: 'string', description: '搜索关键词' }, type: { type: 'string' } },
199
+			{ documentId: documentIdProperty, query: { type: 'string', description: '搜索关键词', maxLength: 2000 }, type: { type: 'string', maxLength: 64 } },
192 200
 			['documentId', 'query']
193 201
 		),
194 202
 		execute: (input) =>
@@ -243,7 +251,7 @@ const tools: WebMcpTool[] = [
243 251
 			{
244 252
 				documentId: documentIdProperty,
245 253
 				type: { type: 'string', enum: ['heading', 'paragraph', 'table', 'image', 'toc'] },
246
-				content: { type: 'string', description: '块内容' },
254
+				content: { type: 'string', description: '块内容', maxLength: 200000 },
247 255
 				level: { type: 'number', minimum: 1, maximum: 6 },
248 256
 				afterBlockId: { type: 'string', description: '插入到此块之后,可选' },
249 257
 			},
@@ -267,8 +275,15 @@ const tools: WebMcpTool[] = [
267 275
 		requiresConfirmation: true,
268 276
 		inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty, content: { type: 'string', description: '新的块内容' } }, ['documentId', 'blockId', 'content']),
269 277
 		execute: (input) => run(() => {
278
+			const documentId = requiredString(input, 'documentId');
279
+			const blockId = requiredString(input, 'blockId');
270 280
 			const updates: UpdateBlockRequest = { content: requiredString(input, 'content') };
271
-			return blockService.updateBlock(requiredString(input, 'documentId'), requiredString(input, 'blockId'), updates);
281
+			return blockService.updateBlock(documentId, blockId, updates).then((result) => {
282
+				window.dispatchEvent(new CustomEvent(BLOCK_UPDATED_EVENT, {
283
+					detail: { documentId, blockId, updates },
284
+				}));
285
+				return result;
286
+			});
272 287
 		}),
273 288
 	},
274 289
 	{
@@ -277,7 +292,16 @@ const tools: WebMcpTool[] = [
277 292
 		description: '删除指定文档块。需要用户确认。',
278 293
 		requiresConfirmation: true,
279 294
 		inputSchema: objectSchema({ documentId: documentIdProperty, blockId: blockIdProperty }, ['documentId', 'blockId']),
280
-		execute: (input) => run(() => blockService.deleteBlock(requiredString(input, 'documentId'), requiredString(input, 'blockId')).then(() => ({ message: 'Block deleted successfully' }))),
295
+		execute: (input) => run(() => {
296
+			const documentId = requiredString(input, 'documentId');
297
+			const blockId = requiredString(input, 'blockId');
298
+			return blockService.deleteBlock(documentId, blockId).then(() => {
299
+				window.dispatchEvent(new CustomEvent(BLOCK_DELETED_EVENT, {
300
+					detail: { documentId, blockId },
301
+				}));
302
+				return { message: 'Block deleted successfully' };
303
+			});
304
+		}),
281 305
 	},
282 306
 	{
283 307
 		name: 'export_document',
@@ -325,8 +349,12 @@ export const getWebMcpTool = (name: string): WebMcpTool | undefined => tools.fin
325 349
 
326 350
 export const executeWebMcpTool = async (
327 351
 	name: string,
328
-	input: Record<string, unknown>
352
+	input: unknown,
353
+	options: WebMcpExecutionOptions = {}
329 354
 ): Promise<WebMcpToolResult> => {
355
+	if (!isRecord(input)) {
356
+		return { ok: false, error: 'WebMCP 工具参数必须是对象' };
357
+	}
330 358
 	const activityId = startWebMcpActivity(name, input);
331 359
 	const tool = getWebMcpTool(name);
332 360
 	if (!tool) {
@@ -334,6 +362,11 @@ export const executeWebMcpTool = async (
334 362
 		finishWebMcpActivity(activityId, result);
335 363
 		return result;
336 364
 	}
365
+	if (tool.requiresConfirmation && !options.confirmed) {
366
+		const result = { ok: false, error: '该 WebMCP 工具需要当前页面用户确认' };
367
+		finishWebMcpActivity(activityId, result);
368
+		return result;
369
+	}
337 370
 	const validationError = validateToolInput(tool, input);
338 371
 	if (validationError) {
339 372
 		const result = { ok: false, error: validationError };

+ 2 - 11
src/services/workflowService.ts

@@ -74,25 +74,17 @@ interface WorkflowResponse {
74 74
  */
75 75
 interface WorkflowConfig {
76 76
   apiUrl: string;
77
-  apiKey: string;
78 77
 }
79 78
 
80 79
 /**
81 80
  * Get Workflow configuration from environment variables
82 81
  */
83 82
 const getWorkflowConfig = (): WorkflowConfig => {
84
-  const isDevelopment = import.meta.env.DEV;
85 83
   return {
86
-    apiUrl: isDevelopment
87
-      ? import.meta.env.VITE_WORKFLOW_API_URL
88
-      : import.meta.env.VITE_WORKFLOW_PROXY_URL,
89
-    apiKey: isDevelopment ? import.meta.env.VITE_WORKFLOW_API_KEY : '',
84
+    apiUrl: import.meta.env.VITE_WORKFLOW_PROXY_URL,
90 85
   };
91 86
 };
92 87
 
93
-const getAuthorizationHeaders = (apiKey: string): Record<string, string> =>
94
-  apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
95
-
96 88
 /**
97 89
  * Check if user input should trigger document generation workflow
98 90
  *
@@ -137,7 +129,7 @@ export const triggerDocumentWorkflow = async (
137 129
   try {
138 130
     const config = getWorkflowConfig();
139 131
 
140
-    if (!config.apiUrl || !config.apiKey) {
132
+    if (!config.apiUrl) {
141 133
       return { content: '工作流未配置,无法生成文档。请检查前端环境配置。' };
142 134
     }
143 135
 
@@ -156,7 +148,6 @@ export const triggerDocumentWorkflow = async (
156 148
       method: 'POST',
157 149
       headers: {
158 150
         'Content-Type': 'application/json',
159
-        ...getAuthorizationHeaders(config.apiKey),
160 151
       },
161 152
       body: JSON.stringify({
162 153
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存

+ 58 - 26
src/share/webmcp/bridge.ts

@@ -1,4 +1,4 @@
1
-import { executeWebMcpTool, getWebMcpTool, getWebMcpTools, type WebMcpToolResult } from '../../services/webMcpService';
1
+import { executeWebMcpTool, getWebMcpTools, type WebMcpToolResult } from '../../services/webMcpService';
2 2
 import { finishWebMcpActivity, startWebMcpActivity } from '../../services/webMcpActivityService';
3 3
 
4 4
 interface BridgeToolCall {
@@ -11,6 +11,8 @@ interface BridgeToolCall {
11 11
 const MAX_BRIDGE_MESSAGE_LENGTH = 256 * 1024;
12 12
 const MAX_BRIDGE_RESULT_LENGTH = 512 * 1024;
13 13
 const MAX_IN_FLIGHT_CALLS = 8;
14
+const MAX_RECONNECT_ATTEMPTS = 6;
15
+const RECONNECT_BASE_DELAY_MS = 1000;
14 16
 const pendingRequestIds = new Set<string>();
15 17
 
16 18
 const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -54,8 +56,14 @@ const getBridgeUrl = (): string => {
54 56
 };
55 57
 
56 58
 const clientId = import.meta.env.VITE_WEBMCP_CLIENT_ID || `ax-editor-${crypto.randomUUID()}`;
57
-const bridgeToken = import.meta.env.VITE_WEBMCP_BRIDGE_TOKEN || '';
59
+// The static token is a local-development fallback only; production uses a session ticket.
60
+const developmentBridgeToken = import.meta.env.DEV
61
+  ? import.meta.env.VITE_WEBMCP_BRIDGE_TOKEN || ''
62
+  : '';
58 63
 let socket: WebSocket | undefined;
64
+let connectionPending = false;
65
+let reconnectTimer: number | undefined;
66
+let reconnectAttempts = 0;
59 67
 
60 68
 const getApiUrl = (path: string): string => {
61 69
   const base = import.meta.env.VITE_API_BASE_URL || window.location.origin;
@@ -63,25 +71,56 @@ const getApiUrl = (path: string): string => {
63 71
 };
64 72
 
65 73
 const getBridgeTicket = async (): Promise<string | undefined> => {
66
-  if (bridgeToken) return bridgeToken;
67
-  const response = await fetch(getApiUrl('/api/v1/webmcp/bridge/ticket'), {
68
-    method: 'POST',
69
-    headers: { 'Content-Type': 'application/json' },
70
-    credentials: 'include',
71
-    body: JSON.stringify({ clientId }),
72
-  });
74
+  let response: Response;
75
+  try {
76
+    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
77
+    if (developmentBridgeToken) headers['X-WebMCP-Token'] = developmentBridgeToken;
78
+    response = await fetch(getApiUrl('/api/v1/webmcp/bridge/ticket'), {
79
+      method: 'POST',
80
+      headers,
81
+      credentials: 'include',
82
+      body: JSON.stringify({ clientId }),
83
+    });
84
+  } catch {
85
+    return undefined;
86
+  }
73 87
   if (!response.ok) return undefined;
74
-  const payload = (await response.json()) as { data?: { ticket?: string } };
75
-  return payload.data?.ticket;
88
+  try {
89
+    const payload = (await response.json()) as { data?: { ticket?: string } };
90
+    return typeof payload.data?.ticket === 'string' ? payload.data.ticket : undefined;
91
+  } catch {
92
+    return undefined;
93
+  }
94
+};
95
+
96
+const scheduleReconnect = (): void => {
97
+  if (reconnectTimer !== undefined || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
98
+  const delay = RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempts;
99
+  reconnectAttempts += 1;
100
+  reconnectTimer = window.setTimeout(() => {
101
+    reconnectTimer = undefined;
102
+    connectWebMcpBridge();
103
+  }, delay);
76 104
 };
77 105
 
78 106
 export const connectWebMcpBridge = (): WebSocket | undefined => {
79
-  if (socket) return socket;
107
+  if (socket || connectionPending || reconnectTimer !== undefined) return socket;
108
+  connectionPending = true;
80 109
   void getBridgeTicket().then((ticket) => {
81
-    if (!ticket || socket) return;
110
+    connectionPending = false;
111
+    if (!ticket || socket) {
112
+      if (!socket) scheduleReconnect();
113
+      return;
114
+    }
82 115
     const socketUrl = `${getBridgeUrl()}/${encodeURIComponent(clientId)}?token=${encodeURIComponent(ticket)}`;
83
-    socket = new WebSocket(socketUrl);
116
+    try {
117
+      socket = new WebSocket(socketUrl);
118
+    } catch {
119
+      scheduleReconnect();
120
+      return;
121
+    }
84 122
     socket.addEventListener('open', () => {
123
+      reconnectAttempts = 0;
85 124
       socket?.send(JSON.stringify({
86 125
         type: 'register',
87 126
         clientId,
@@ -101,18 +140,7 @@ export const connectWebMcpBridge = (): WebSocket | undefined => {
101 140
     pendingRequestIds.add(call.requestId);
102 141
     let result: WebMcpToolResult;
103 142
     try {
104
-      const tool = getWebMcpTool(call.tool);
105
-      if (!tool) {
106
-        const activityId = startWebMcpActivity(call.tool, call.arguments);
107
-        result = { ok: false, error: `未知的 WebMCP 工具: ${call.tool}` };
108
-        finishWebMcpActivity(activityId, result);
109
-      } else if (tool.requiresConfirmation) {
110
-        const activityId = startWebMcpActivity(call.tool, call.arguments);
111
-        result = { ok: false, error: '该 WebMCP 工具需要当前页面用户确认' };
112
-        finishWebMcpActivity(activityId, result);
113
-      } else {
114
-        result = await executeWebMcpTool(call.tool, call.arguments);
115
-      }
143
+      result = await executeWebMcpTool(call.tool, call.arguments);
116 144
     } catch (error) {
117 145
       const activityId = startWebMcpActivity(call.tool, call.arguments);
118 146
       result = { ok: false, error: error instanceof Error ? error.message : '工具执行失败' };
@@ -134,10 +162,14 @@ export const connectWebMcpBridge = (): WebSocket | undefined => {
134 162
     socket.addEventListener('close', () => {
135 163
       pendingRequestIds.clear();
136 164
       socket = undefined;
165
+      scheduleReconnect();
137 166
     });
138 167
     socket.addEventListener('error', () => {
139 168
       socket?.close();
140 169
     });
170
+  }).catch(() => {
171
+    connectionPending = false;
172
+    scheduleReconnect();
141 173
   });
142 174
   return socket;
143 175
 };

+ 29 - 6
src/share/webmcp/translator.ts

@@ -1,5 +1,7 @@
1 1
 import type { WebMcpTranslationContext, WebMcpTranslationResponse } from './types';
2 2
 
3
+const TRANSLATION_TIMEOUT_MS = 30000;
4
+
3 5
 const getTranslatorUrl = (): string => {
4 6
   const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
5 7
   return `${baseUrl.replace(/\/$/, '')}/api/v1/webmcp/translate`;
@@ -10,7 +12,7 @@ export const translateWebMcpInput = async (
10 12
   context: WebMcpTranslationContext = {}
11 13
 ): Promise<WebMcpTranslationResponse> => {
12 14
   const controller = new AbortController();
13
-  const timeoutId = window.setTimeout(() => controller.abort(), 15000);
15
+  const timeoutId = window.setTimeout(() => controller.abort(), TRANSLATION_TIMEOUT_MS);
14 16
   let response: Response;
15 17
   try {
16 18
     response = await fetch(getTranslatorUrl(), {
@@ -20,6 +22,11 @@ export const translateWebMcpInput = async (
20 22
       body: JSON.stringify({ input, context }),
21 23
       signal: controller.signal,
22 24
     });
25
+  } catch (error) {
26
+    if (error instanceof DOMException && error.name === 'AbortError') {
27
+      throw new Error('WebMCP 转译请求超时,请稍后重试', { cause: error });
28
+    }
29
+    throw error;
23 30
   } finally {
24 31
     window.clearTimeout(timeoutId);
25 32
   }
@@ -29,13 +36,29 @@ export const translateWebMcpInput = async (
29 36
     throw new Error(`WebMCP 转译失败 (${response.status}): ${detail}`);
30 37
   }
31 38
 
32
-  const payload = (await response.json()) as {
33
-    data?: WebMcpTranslationResponse;
34
-  };
35
-  if (!payload.data?.translation) {
39
+  let payload: { data?: WebMcpTranslationResponse };
40
+  try {
41
+    payload = (await response.json()) as { data?: WebMcpTranslationResponse };
42
+  } catch {
43
+    throw new Error('WebMCP 转译响应不是有效 JSON');
44
+  }
45
+  const translation = payload.data?.translation;
46
+  if (!translation || typeof translation !== 'object') {
36 47
     throw new Error('WebMCP 转译响应格式无效');
37 48
   }
38
-  return payload.data;
49
+  if (translation.toolCall) {
50
+    if (typeof translation.toolCall.name !== 'string' || !translation.toolCall.name.trim()) {
51
+      throw new Error('WebMCP 转译工具名称无效');
52
+    }
53
+    if (
54
+      typeof translation.toolCall.arguments !== 'object'
55
+      || translation.toolCall.arguments === null
56
+      || Array.isArray(translation.toolCall.arguments)
57
+    ) {
58
+      throw new Error('WebMCP 转译工具参数无效');
59
+    }
60
+  }
61
+  return { ...payload.data, translation };
39 62
 };
40 63
 
41 64
 export type { WebMcpTranslationContext, WebMcpTranslationResponse } from './types';

+ 60 - 0
src/stores/editorStore.ts

@@ -262,6 +262,12 @@ interface EditorStore {
262 262
    */
263 263
   updateBlock: (id: string, updates: BlockUpdate) => void;
264 264
 
265
+  /** 应用来自聊天/WebMCP 的已保存块更新 */
266
+  applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => void;
267
+
268
+  /** 应用来自聊天/WebMCP 的已保存块删除 */
269
+  applyRemoteBlockDelete: (id: string) => void;
270
+
265 271
   /**
266 272
    * 标记文档已修改
267 273
    */
@@ -1273,6 +1279,60 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1273 1279
       }
1274 1280
     },
1275 1281
 
1282
+    // ── applyRemoteBlockUpdate ─────────────────────────────────────────────
1283
+    applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => {
1284
+      const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
1285
+      const blockIndex = blocks.findIndex((block) => block.id === id);
1286
+      if (!documentId || blockIndex < 0) return;
1287
+
1288
+      const updatedBlocks = blocks.slice();
1289
+      updatedBlocks[blockIndex] = { ...updatedBlocks[blockIndex], ...updates } as DocumentBlock;
1290
+      const updatedHash = computeBlockHash(updatedBlocks[blockIndex]);
1291
+      const updatedHashes = new Map(blockHashes);
1292
+      updatedHashes.set(id, updatedHash);
1293
+      const updatedDirtyBlocks = new Set(dirtyBlocks);
1294
+      updatedDirtyBlocks.delete(id);
1295
+      const updatedPending = new Map(pendingBlockUpdates);
1296
+      updatedPending.delete(id);
1297
+      const snapshot = JSON.stringify(updatedBlocks);
1298
+
1299
+      set({
1300
+        blocks: updatedBlocks,
1301
+        blockHashes: updatedHashes,
1302
+        dirtyBlocks: updatedDirtyBlocks,
1303
+        pendingBlockUpdates: updatedPending,
1304
+        hasModified: updatedDirtyBlocks.size > 0,
1305
+        originalBlocksSnapshot: snapshot,
1306
+        lastSavedSnapshot: snapshot,
1307
+      });
1308
+    },
1309
+
1310
+    // ── applyRemoteBlockDelete ─────────────────────────────────────────────
1311
+    applyRemoteBlockDelete: (id: string) => {
1312
+      const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
1313
+      if (!documentId || !blocks.some((block) => block.id === id)) return;
1314
+
1315
+      const updatedBlocks = blocks.filter((block) => block.id !== id);
1316
+      const updatedHashes = new Map(blockHashes);
1317
+      updatedHashes.delete(id);
1318
+      const updatedDirtyBlocks = new Set(dirtyBlocks);
1319
+      updatedDirtyBlocks.delete(id);
1320
+      const updatedPending = new Map(pendingBlockUpdates);
1321
+      updatedPending.delete(id);
1322
+      const snapshot = JSON.stringify(updatedBlocks);
1323
+
1324
+      set({
1325
+        blocks: updatedBlocks,
1326
+        blockHashes: updatedHashes,
1327
+        dirtyBlocks: updatedDirtyBlocks,
1328
+        pendingBlockUpdates: updatedPending,
1329
+        selectedBlockId: get().selectedBlockId === id ? null : get().selectedBlockId,
1330
+        hasModified: updatedDirtyBlocks.size > 0,
1331
+        originalBlocksSnapshot: snapshot,
1332
+        lastSavedSnapshot: snapshot,
1333
+      });
1334
+    },
1335
+
1276 1336
     // ── deleteBlock ─────────────────────────────────────────────────────────
1277 1337
     deleteBlock: async (id: string) => {
1278 1338
       const {

+ 19 - 2
vite.config.ts

@@ -1,9 +1,13 @@
1
-import { defineConfig } from 'vite';
1
+import { defineConfig, loadEnv } from 'vite';
2 2
 import react from '@vitejs/plugin-react';
3 3
 import { visualizer } from 'rollup-plugin-visualizer';
4 4
 
5 5
 // https://vite.dev/config/
6
-export default defineConfig({
6
+export default defineConfig(({ mode }) => {
7
+  const env = loadEnv(mode, process.cwd(), '');
8
+  const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:8000';
9
+
10
+  return {
7 11
   plugins: [
8 12
     react(),
9 13
     
@@ -56,12 +60,24 @@ export default defineConfig({
56 60
     host: true,
57 61
     strictPort: true,
58 62
     open: false,
63
+    proxy: {
64
+      '/api': {
65
+        target: apiTarget,
66
+        changeOrigin: true,
67
+      },
68
+    },
59 69
   },
60 70
 
61 71
   preview: {
62 72
     port: 4173,
63 73
     host: true,
64 74
     strictPort: true,
75
+    proxy: {
76
+      '/api': {
77
+        target: apiTarget,
78
+        changeOrigin: true,
79
+      },
80
+    },
65 81
   },
66 82
 
67 83
   // 依赖预构建优化
@@ -79,4 +95,5 @@ export default defineConfig({
79 95
       'mammoth',
80 96
     ],
81 97
   },
98
+  };
82 99
 });