Преглед изворни кода

feat:集成统一 mod‑chat 面板,对接自定义聊天状态管理与 API 流程
- 将原有 ChatPanel 替换为 IntegratedChatPanel,基于 mod‑chat 实现聊天能力。
- 实现全新聊天状态存储,用于管理聊天消息、会话以及 API 交互逻辑。
- 支持在聊天界面内完成文档下载功能。
- 更新 App 组件,适配左侧面板布局改动。
- 增强 AI 聊天服务,支持 mod‑chat API 调用,同时处理文档相关工作流。
- 新增聊天配置项与 mod‑chat 属性的类型定义,提升类型安全。
- 配置 Vite 的模块联邦,将 mod‑chat 作为远程模块加载。
- 重构工作流服务,保证文档命名唯一性,优化响应处理逻辑。

Zhang Yice пре 3 недеља
родитељ
комит
e7de41a9d7

+ 5 - 1
.env

@@ -1,7 +1,7 @@
1 1
 # API Base URL
2 2
 # This is the base URL for the backend API server
3 3
 # Default: http://localhost:8000
4
-VITE_API_BASE_URL=http://192.168.0.195:8000
4
+VITE_API_BASE_URL=http://127.0.0.1:8000
5 5
 
6 6
 # Application Title
7 7
 # This will be displayed in the browser tab
@@ -15,6 +15,10 @@ VITE_DEBUG=false
15 15
 # AI and workflow calls must go through the backend proxy.
16 16
 VITE_AI_PROXY_URL=/api/v1/ai/chat
17 17
 VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow
18
+# mod-chat Composer backend used by the unified hostBridge chat.
19
+VITE_MOD_CHAT_API_BASE_URL=http://192.168.0.44:8010
20
+VITE_MOD_CHAT_REGISTRY_API_BASE_URL=http://192.168.0.44:8040
21
+VITE_MOD_CHAT_REMOTE_URL=http://localhost:5174/assets/remoteEntry.js
18 22
 
19 23
 # WebMCP Bridge 连接配置
20 24
 VITE_WEBMCP_CLIENT_ID=ax-editor-local

+ 6 - 0
.env.example

@@ -14,9 +14,15 @@ VITE_APP_TITLE=AX Document Editor
14 14
 # Default: false
15 15
 VITE_DEBUG=false
16 16
 
17
+# Module Federation remote entry for the integrated chat panel.
18
+VITE_MOD_CHAT_REMOTE_URL=http://localhost:5174/assets/remoteEntry.js
19
+
17 20
 # The browser must use backend proxy endpoints. Keep upstream API keys server-side.
18 21
 VITE_AI_PROXY_URL=/api/v1/ai/chat
19 22
 VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow
23
+# mod-chat Composer backend used by the unified hostBridge chat.
24
+VITE_MOD_CHAT_API_BASE_URL=http://192.168.0.44:8010
25
+VITE_MOD_CHAT_REGISTRY_API_BASE_URL=http://192.168.0.44:8040
20 26
 
21 27
 # Local development only; production uses a session-based WebMCP ticket.
22 28
 VITE_WEBMCP_BRIDGE_TOKEN=

+ 29 - 0
package-lock.json

@@ -23,6 +23,7 @@
23 23
       },
24 24
       "devDependencies": {
25 25
         "@eslint/js": "^10.0.1",
26
+        "@originjs/vite-plugin-federation": "^1.4.1",
26 27
         "@testing-library/jest-dom": "^6.9.1",
27 28
         "@testing-library/react": "^16.3.2",
28 29
         "@types/hash-sum": "^1.0.2",
@@ -954,6 +955,34 @@
954 955
         "@emnapi/runtime": "^1.7.1"
955 956
       }
956 957
     },
958
+    "node_modules/@originjs/vite-plugin-federation": {
959
+      "version": "1.4.1",
960
+      "resolved": "https://registry.npmmirror.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz",
961
+      "integrity": "sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ==",
962
+      "dev": true,
963
+      "license": "MulanPSL-2.0",
964
+      "dependencies": {
965
+        "estree-walker": "^3.0.2",
966
+        "magic-string": "^0.27.0"
967
+      },
968
+      "engines": {
969
+        "node": ">=14.0.0",
970
+        "pnpm": ">=7.0.1"
971
+      }
972
+    },
973
+    "node_modules/@originjs/vite-plugin-federation/node_modules/magic-string": {
974
+      "version": "0.27.0",
975
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.27.0.tgz",
976
+      "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==",
977
+      "dev": true,
978
+      "license": "MIT",
979
+      "dependencies": {
980
+        "@jridgewell/sourcemap-codec": "^1.4.13"
981
+      },
982
+      "engines": {
983
+        "node": ">=12"
984
+      }
985
+    },
957 986
     "node_modules/@oxc-project/types": {
958 987
       "version": "0.133.0",
959 988
       "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.133.0.tgz",

+ 1 - 0
package.json

@@ -37,6 +37,7 @@
37 37
   },
38 38
   "devDependencies": {
39 39
     "@eslint/js": "^10.0.1",
40
+    "@originjs/vite-plugin-federation": "^1.4.1",
40 41
     "@testing-library/jest-dom": "^6.9.1",
41 42
     "@testing-library/react": "^16.3.2",
42 43
     "@types/hash-sum": "^1.0.2",

+ 349 - 0
public/mockServiceWorker.js

@@ -0,0 +1,349 @@
1
+/* eslint-disable */
2
+/* tslint:disable */
3
+
4
+/**
5
+ * Mock Service Worker.
6
+ * @see https://github.com/mswjs/msw
7
+ * - Please do NOT modify this file.
8
+ */
9
+
10
+const PACKAGE_VERSION = '2.13.4'
11
+const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
12
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13
+const activeClientIds = new Set()
14
+
15
+addEventListener('install', function () {
16
+  self.skipWaiting()
17
+})
18
+
19
+addEventListener('activate', function (event) {
20
+  event.waitUntil(self.clients.claim())
21
+})
22
+
23
+addEventListener('message', async function (event) {
24
+  const clientId = Reflect.get(event.source || {}, 'id')
25
+
26
+  if (!clientId || !self.clients) {
27
+    return
28
+  }
29
+
30
+  const client = await self.clients.get(clientId)
31
+
32
+  if (!client) {
33
+    return
34
+  }
35
+
36
+  const allClients = await self.clients.matchAll({
37
+    type: 'window',
38
+  })
39
+
40
+  switch (event.data) {
41
+    case 'KEEPALIVE_REQUEST': {
42
+      sendToClient(client, {
43
+        type: 'KEEPALIVE_RESPONSE',
44
+      })
45
+      break
46
+    }
47
+
48
+    case 'INTEGRITY_CHECK_REQUEST': {
49
+      sendToClient(client, {
50
+        type: 'INTEGRITY_CHECK_RESPONSE',
51
+        payload: {
52
+          packageVersion: PACKAGE_VERSION,
53
+          checksum: INTEGRITY_CHECKSUM,
54
+        },
55
+      })
56
+      break
57
+    }
58
+
59
+    case 'MOCK_ACTIVATE': {
60
+      activeClientIds.add(clientId)
61
+
62
+      sendToClient(client, {
63
+        type: 'MOCKING_ENABLED',
64
+        payload: {
65
+          client: {
66
+            id: client.id,
67
+            frameType: client.frameType,
68
+          },
69
+        },
70
+      })
71
+      break
72
+    }
73
+
74
+    case 'CLIENT_CLOSED': {
75
+      activeClientIds.delete(clientId)
76
+
77
+      const remainingClients = allClients.filter((client) => {
78
+        return client.id !== clientId
79
+      })
80
+
81
+      // Unregister itself when there are no more clients
82
+      if (remainingClients.length === 0) {
83
+        self.registration.unregister()
84
+      }
85
+
86
+      break
87
+    }
88
+  }
89
+})
90
+
91
+addEventListener('fetch', function (event) {
92
+  const requestInterceptedAt = Date.now()
93
+
94
+  // Bypass navigation requests.
95
+  if (event.request.mode === 'navigate') {
96
+    return
97
+  }
98
+
99
+  // Opening the DevTools triggers the "only-if-cached" request
100
+  // that cannot be handled by the worker. Bypass such requests.
101
+  if (
102
+    event.request.cache === 'only-if-cached' &&
103
+    event.request.mode !== 'same-origin'
104
+  ) {
105
+    return
106
+  }
107
+
108
+  // Bypass all requests when there are no active clients.
109
+  // Prevents the self-unregistered worked from handling requests
110
+  // after it's been terminated (still remains active until the next reload).
111
+  if (activeClientIds.size === 0) {
112
+    return
113
+  }
114
+
115
+  const requestId = crypto.randomUUID()
116
+  event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
117
+})
118
+
119
+/**
120
+ * @param {FetchEvent} event
121
+ * @param {string} requestId
122
+ * @param {number} requestInterceptedAt
123
+ */
124
+async function handleRequest(event, requestId, requestInterceptedAt) {
125
+  const client = await resolveMainClient(event)
126
+  const requestCloneForEvents = event.request.clone()
127
+  const response = await getResponse(
128
+    event,
129
+    client,
130
+    requestId,
131
+    requestInterceptedAt,
132
+  )
133
+
134
+  // Send back the response clone for the "response:*" life-cycle events.
135
+  // Ensure MSW is active and ready to handle the message, otherwise
136
+  // this message will pend indefinitely.
137
+  if (client && activeClientIds.has(client.id)) {
138
+    const serializedRequest = await serializeRequest(requestCloneForEvents)
139
+
140
+    // Clone the response so both the client and the library could consume it.
141
+    const responseClone = response.clone()
142
+
143
+    sendToClient(
144
+      client,
145
+      {
146
+        type: 'RESPONSE',
147
+        payload: {
148
+          isMockedResponse: IS_MOCKED_RESPONSE in response,
149
+          request: {
150
+            id: requestId,
151
+            ...serializedRequest,
152
+          },
153
+          response: {
154
+            type: responseClone.type,
155
+            status: responseClone.status,
156
+            statusText: responseClone.statusText,
157
+            headers: Object.fromEntries(responseClone.headers.entries()),
158
+            body: responseClone.body,
159
+          },
160
+        },
161
+      },
162
+      responseClone.body ? [serializedRequest.body, responseClone.body] : [],
163
+    )
164
+  }
165
+
166
+  return response
167
+}
168
+
169
+/**
170
+ * Resolve the main client for the given event.
171
+ * Client that issues a request doesn't necessarily equal the client
172
+ * that registered the worker. It's with the latter the worker should
173
+ * communicate with during the response resolving phase.
174
+ * @param {FetchEvent} event
175
+ * @returns {Promise<Client | undefined>}
176
+ */
177
+async function resolveMainClient(event) {
178
+  const client = await self.clients.get(event.clientId)
179
+
180
+  if (activeClientIds.has(event.clientId)) {
181
+    return client
182
+  }
183
+
184
+  if (client?.frameType === 'top-level') {
185
+    return client
186
+  }
187
+
188
+  const allClients = await self.clients.matchAll({
189
+    type: 'window',
190
+  })
191
+
192
+  return allClients
193
+    .filter((client) => {
194
+      // Get only those clients that are currently visible.
195
+      return client.visibilityState === 'visible'
196
+    })
197
+    .find((client) => {
198
+      // Find the client ID that's recorded in the
199
+      // set of clients that have registered the worker.
200
+      return activeClientIds.has(client.id)
201
+    })
202
+}
203
+
204
+/**
205
+ * @param {FetchEvent} event
206
+ * @param {Client | undefined} client
207
+ * @param {string} requestId
208
+ * @param {number} requestInterceptedAt
209
+ * @returns {Promise<Response>}
210
+ */
211
+async function getResponse(event, client, requestId, requestInterceptedAt) {
212
+  // Clone the request because it might've been already used
213
+  // (i.e. its body has been read and sent to the client).
214
+  const requestClone = event.request.clone()
215
+
216
+  function passthrough() {
217
+    // Cast the request headers to a new Headers instance
218
+    // so the headers can be manipulated with.
219
+    const headers = new Headers(requestClone.headers)
220
+
221
+    // Remove the "accept" header value that marked this request as passthrough.
222
+    // This prevents request alteration and also keeps it compliant with the
223
+    // user-defined CORS policies.
224
+    const acceptHeader = headers.get('accept')
225
+    if (acceptHeader) {
226
+      const values = acceptHeader.split(',').map((value) => value.trim())
227
+      const filteredValues = values.filter(
228
+        (value) => value !== 'msw/passthrough',
229
+      )
230
+
231
+      if (filteredValues.length > 0) {
232
+        headers.set('accept', filteredValues.join(', '))
233
+      } else {
234
+        headers.delete('accept')
235
+      }
236
+    }
237
+
238
+    return fetch(requestClone, { headers })
239
+  }
240
+
241
+  // Bypass mocking when the client is not active.
242
+  if (!client) {
243
+    return passthrough()
244
+  }
245
+
246
+  // Bypass initial page load requests (i.e. static assets).
247
+  // The absence of the immediate/parent client in the map of the active clients
248
+  // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
249
+  // and is not ready to handle requests.
250
+  if (!activeClientIds.has(client.id)) {
251
+    return passthrough()
252
+  }
253
+
254
+  // Notify the client that a request has been intercepted.
255
+  const serializedRequest = await serializeRequest(event.request)
256
+  const clientMessage = await sendToClient(
257
+    client,
258
+    {
259
+      type: 'REQUEST',
260
+      payload: {
261
+        id: requestId,
262
+        interceptedAt: requestInterceptedAt,
263
+        ...serializedRequest,
264
+      },
265
+    },
266
+    [serializedRequest.body],
267
+  )
268
+
269
+  switch (clientMessage.type) {
270
+    case 'MOCK_RESPONSE': {
271
+      return respondWithMock(clientMessage.data)
272
+    }
273
+
274
+    case 'PASSTHROUGH': {
275
+      return passthrough()
276
+    }
277
+  }
278
+
279
+  return passthrough()
280
+}
281
+
282
+/**
283
+ * @param {Client} client
284
+ * @param {any} message
285
+ * @param {Array<Transferable>} transferrables
286
+ * @returns {Promise<any>}
287
+ */
288
+function sendToClient(client, message, transferrables = []) {
289
+  return new Promise((resolve, reject) => {
290
+    const channel = new MessageChannel()
291
+
292
+    channel.port1.onmessage = (event) => {
293
+      if (event.data && event.data.error) {
294
+        return reject(event.data.error)
295
+      }
296
+
297
+      resolve(event.data)
298
+    }
299
+
300
+    client.postMessage(message, [
301
+      channel.port2,
302
+      ...transferrables.filter(Boolean),
303
+    ])
304
+  })
305
+}
306
+
307
+/**
308
+ * @param {Response} response
309
+ * @returns {Response}
310
+ */
311
+function respondWithMock(response) {
312
+  // Setting response status code to 0 is a no-op.
313
+  // However, when responding with a "Response.error()", the produced Response
314
+  // instance will have status code set to 0. Since it's not possible to create
315
+  // a Response instance with status code 0, handle that use-case separately.
316
+  if (response.status === 0) {
317
+    return Response.error()
318
+  }
319
+
320
+  const mockedResponse = new Response(response.body, response)
321
+
322
+  Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
323
+    value: true,
324
+    enumerable: true,
325
+  })
326
+
327
+  return mockedResponse
328
+}
329
+
330
+/**
331
+ * @param {Request} request
332
+ */
333
+async function serializeRequest(request) {
334
+  return {
335
+    url: request.url,
336
+    mode: request.mode,
337
+    method: request.method,
338
+    headers: Object.fromEntries(request.headers.entries()),
339
+    cache: request.cache,
340
+    credentials: request.credentials,
341
+    destination: request.destination,
342
+    integrity: request.integrity,
343
+    redirect: request.redirect,
344
+    referrer: request.referrer,
345
+    referrerPolicy: request.referrerPolicy,
346
+    body: await request.arrayBuffer(),
347
+    keepalive: request.keepalive,
348
+  }
349
+}

+ 82 - 55
src/App.tsx

@@ -5,7 +5,7 @@
5 5
  * - Wraps everything in ErrorBoundary to catch unhandled render errors
6 6
  * - Uses useOnlineStatus for network detection (Req 11.3)
7 7
  * - Dual-panel desktop layout via ResizableLayout with 40/60 split (Req 12.1)
8
- * - Left panel: ChatPanel (fixed)
8
+ * - Left panel: Unified mod-chat panel running its own chat store and API flow
9 9
  * - Right panel: Dynamically switches between ExportRecordList (default) and EditorPanel (when document is opened)
10 10
  * - Uses React.lazy for code splitting of non-critical panels (Req 13.2, 13.8)
11 11
  *
@@ -24,9 +24,8 @@
24 24
  * @module App
25 25
  */
26 26
 
27
-import React, { useCallback, useEffect, useState, memo, lazy, Suspense, useMemo } from 'react';
28
-import { ConfigProvider, Alert, Button, Drawer, Spin, message } from 'antd';
29
-import { MessageOutlined } from '@ant-design/icons';
27
+import React, { useCallback, useEffect, memo, lazy, Suspense, useMemo } from 'react';
28
+import { ConfigProvider, Alert, Spin, message } from 'antd';
30 29
 import zhCN from 'antd/locale/zh_CN';
31 30
 
32 31
 import { ErrorBoundary } from './components/common';
@@ -34,18 +33,18 @@ import ResizableLayout from './components/Layout/ResizableLayout';
34 33
 import { useUIStore } from './stores/uiStore';
35 34
 import { getDocument } from './services/documentService';
36 35
 import { isApiError } from './services/api';
36
+import { useChatStore } from './stores/chatStore';
37
+import apiClient from './services/api';
38
+import { downloadBlob, getFileNameFromContentDisposition } from './utils/download';
37 39
 
38 40
 // ── Lazy-loaded panel components (Req 13.2, 13.8) ────────────────────────────
39 41
 // These non-first-screen panels are split into separate chunks by Vite,
40 42
 // reducing the initial JS bundle size.
41
-const ChatPanel = lazy(() => import('./components/ChatPanel/ChatPanel'));
43
+const ModChatPanel = lazy(() => import('mod_chat/App'));
42 44
 const EditorPanel = lazy(() => import('./components/EditorPanel/EditorPanel'));
43 45
 const ExportRecordList = lazy(() =>
44 46
   import('./components/ExportRecordList').then((m) => ({ default: m.ExportRecordList }))
45 47
 );
46
-const SessionList = lazy(() =>
47
-  import('./components/SessionList').then((m) => ({ default: m.SessionList }))
48
-);
49 48
 
50 49
 import { useOnlineStatus } from './hooks/useOnlineStatus';
51 50
 
@@ -75,12 +74,6 @@ const PanelFallback: React.FC = memo(() => (
75 74
 ));
76 75
 PanelFallback.displayName = 'PanelFallback';
77 76
 
78
-const leftPanel = (
79
-  <Suspense fallback={<PanelFallback />}>
80
-    <ChatPanel />
81
-  </Suspense>
82
-);
83
-
84 77
 const CURRENT_USER_ID = 'default-user';
85 78
 
86 79
 // ── Styles ────────────────────────────────────────────────────────────────────
@@ -123,6 +116,79 @@ const OfflineBanner: React.FC = memo(() => (
123 116
 ));
124 117
 OfflineBanner.displayName = 'OfflineBanner';
125 118
 
119
+const IntegratedChatPanel: React.FC = () => {
120
+  const messages = useChatStore((state) => state.messages);
121
+  const isLoading = useChatStore((state) => state.isLoading);
122
+  const sendMessage = useChatStore((state) => state.sendMessage);
123
+  const sessions = useChatStore((state) => state.sessions);
124
+  const activeSessionId = useChatStore((state) => state.currentSessionId);
125
+  const createSession = useChatStore((state) => state.createSession);
126
+  const loadSession = useChatStore((state) => state.loadSession);
127
+  const deleteSession = useChatStore((state) => state.deleteSession);
128
+  const updateSessionTitle = useChatStore((state) => state.updateSessionTitle);
129
+  const updateSessionSettings = useChatStore((state) => state.updateSessionSettings);
130
+  const openDocumentPreview = useUIStore((state) => state.openDocumentPreview);
131
+
132
+  const handleDownloadDocument = async (downloadUrl: string, fileName?: string) => {
133
+    const urlMatch = downloadUrl.match(/\/export\/records\/([^/]+)\/download/);
134
+    if (!urlMatch) {
135
+      window.open(downloadUrl, '_blank', 'noopener,noreferrer');
136
+      return;
137
+    }
138
+
139
+    const recordId = urlMatch[1];
140
+    const userId = new URL(downloadUrl, window.location.origin).searchParams.get('userId') || 'default-user';
141
+    const response = await apiClient.get(`/api/v1/export/records/${recordId}/download`, {
142
+      params: { userId },
143
+      responseType: 'blob',
144
+    });
145
+    const resolvedFileName = fileName || getFileNameFromContentDisposition(
146
+      response.headers['content-disposition'],
147
+      'document.docx'
148
+    );
149
+    downloadBlob(new Blob([response.data], { type: 'application/msword' }), resolvedFileName);
150
+  };
151
+
152
+  return (
153
+    <Suspense fallback={<PanelFallback />}>
154
+      <ModChatPanel
155
+        layoutMode="full-page"
156
+        hostBridge={{
157
+          messages: messages.map((chatMessage) => ({
158
+            id: chatMessage.id,
159
+            role: chatMessage.role,
160
+            content: chatMessage.content,
161
+            createdAt: chatMessage.timestamp,
162
+            exportRecord: chatMessage.exportRecord
163
+              ? {
164
+                  documentId: chatMessage.exportRecord.documentId,
165
+                  fileName: chatMessage.exportRecord.fileName,
166
+                  downloadUrl: chatMessage.exportRecord.downloadUrl,
167
+                }
168
+              : undefined,
169
+          })),
170
+          isLoading,
171
+          onSendMessage: (content, options) => sendMessage(content, options),
172
+          sessions: sessions.map((session) => ({
173
+            id: session.id,
174
+            title: session.title,
175
+            updatedAt: session.updatedAt,
176
+            settings: session.settings,
177
+          })),
178
+          activeSessionId,
179
+          onCreateSession: createSession,
180
+          onSelectSession: loadSession,
181
+          onDeleteSession: deleteSession,
182
+          onRenameSession: updateSessionTitle,
183
+          onUpdateSessionSettings: updateSessionSettings,
184
+          onPreviewDocument: openDocumentPreview,
185
+          onDownloadDocument: handleDownloadDocument,
186
+        }}
187
+      />
188
+    </Suspense>
189
+  );
190
+};
191
+
126 192
 // ── App Component ─────────────────────────────────────────────────────────────
127 193
 
128 194
 /**
@@ -131,7 +197,7 @@ OfflineBanner.displayName = 'OfflineBanner';
131 197
  * The top-level React component. Handles layout orchestration.
132 198
  *
133 199
  * Uses a fixed dual-panel desktop layout with ResizableLayout (40% left / 60% right).
134
- * The left panel shows ChatPanel (fixed), and the right panel dynamically switches:
200
+ * The left panel shows the unified mod-chat interface, and the right panel dynamically switches:
135 201
  * - Default: ExportRecordList (shows export history)
136 202
  * - When document opened: EditorPanel (shows document for preview and editing)
137 203
  */
@@ -172,13 +238,7 @@ const App: React.FC = () => {
172 238
     };
173 239
   }, [openDocumentPreview, closeDocumentPreview]);
174 240
 
175
-  // ── Session history drawer ────────────────────────────────────────────────
176
-  const [sessionListOpen, setSessionListOpen] = useState(false);
177
-
178
-  /** Stable callback to close the session drawer */
179
-  const handleCloseSessionList = useCallback(() => setSessionListOpen(false), []);
180
-  /** Stable callback to open the session drawer */
181
-  const handleOpenSessionList = useCallback(() => setSessionListOpen(true), []);
241
+  const leftPanel = <IntegratedChatPanel />;
182 242
 
183 243
   /**
184 244
    * Handle close editor button click
@@ -211,35 +271,6 @@ const App: React.FC = () => {
211 271
     );
212 272
   }, [previewDocumentId, previewDocumentName, handleCloseEditor]);
213 273
 
214
-  // ── Session history drawer ────────────────────────────────────────────────
215
-  const sessionListDrawer = sessionListOpen ? (
216
-    <Drawer
217
-      title="会话历史"
218
-      placement="left"
219
-      width={360}
220
-      open
221
-      onClose={handleCloseSessionList}
222
-      styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
223
-      data-testid="session-list-drawer"
224
-    >
225
-      <Suspense fallback={<PanelFallback />}>
226
-        <SessionList onSessionLoad={handleCloseSessionList} />
227
-      </Suspense>
228
-    </Drawer>
229
-  ) : null;
230
-
231
-  // ── Session history toggle button ─────────────────────────────────────────
232
-  const sessionListButton = (
233
-    <Button
234
-      type="text"
235
-      icon={<MessageOutlined />}
236
-      onClick={handleOpenSessionList}
237
-      title="会话历史"
238
-      aria-label="打开会话历史"
239
-      data-testid="open-session-list-button"
240
-    />
241
-  );
242
-
243 274
   // ── Desktop dual-panel layout (Req 12.1) ──────────────────────────────────
244 275
   return (
245 276
     <ConfigProvider locale={zhCN}>
@@ -248,9 +279,6 @@ const App: React.FC = () => {
248 279
           {/* Offline banner (Req 11.3) */}
249 280
           {!isOnline && <OfflineBanner />}
250 281
 
251
-          {/* Session history drawer */}
252
-          {sessionListDrawer}
253
-
254 282
           {/* Main two-panel layout */}
255 283
           <div style={mainContentStyle}>
256 284
             {/* Toolbar row with session history toggle */}
@@ -267,7 +295,6 @@ const App: React.FC = () => {
267 295
               }}
268 296
               data-testid="app-toolbar"
269 297
             >
270
-              {sessionListButton}
271 298
             </div>
272 299
 
273 300
             {/* Resizable dual-panel layout (Req 4.1–4.5, 12.1) */}

+ 39 - 10
src/main.tsx

@@ -1,21 +1,50 @@
1
-import { StrictMode } from 'react';
2
-import { createRoot } from 'react-dom/client';
1
+import * as React from 'react';
2
+import * as ReactDOM from 'react-dom';
3
+import * as ReactDOMClient from 'react-dom/client';
3 4
 import './index.css';
4 5
 import AppEntry from './AppEntry';
5 6
 import { registerWebMcpTools } from './services/webMcpService';
6 7
 import { connectWebMcpBridge } from './share/webmcp';
7 8
 
8
-connectWebMcpBridge();
9
+const removeStaleMockWorkers = async (): Promise<boolean> => {
10
+  if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return false;
11
+  const registrations = await navigator.serviceWorker.getRegistrations();
12
+  const staleRegistrations = registrations.filter((registration) =>
13
+    registration.active?.scriptURL.endsWith('/mockServiceWorker.js')
14
+  );
15
+  if (!staleRegistrations.length) return false;
16
+  await Promise.all(staleRegistrations.map((registration) => registration.unregister()));
17
+  return true;
18
+};
9 19
 
10
-void registerWebMcpTools().then((registration) => {
20
+const startHostApp = async (): Promise<void> => {
21
+  // Remove old mock workers before any host API or WebMCP request can start.
22
+  if (await removeStaleMockWorkers()) {
23
+    window.location.reload();
24
+    return;
25
+  }
26
+
27
+  connectWebMcpBridge();
11 28
 
29
+  const runtime = globalThis as typeof globalThis & {
30
+    __AXONIX_REACT__?: typeof React;
31
+    __AXONIX_REACT_DOM__?: typeof ReactDOM;
32
+    __AXONIX_REACT_DOM_CLIENT__?: typeof ReactDOMClient;
33
+  };
34
+  runtime.__AXONIX_REACT__ = React;
35
+  runtime.__AXONIX_REACT_DOM__ = ReactDOM;
36
+  runtime.__AXONIX_REACT_DOM_CLIENT__ = ReactDOMClient;
37
+
38
+  const registration = await registerWebMcpTools();
12 39
   if (!registration.supported || registration.error) {
13 40
     console.info('[WebMCP] 当前页面未注册工具', registration.error || '浏览器未启用 WebMCP');
14 41
   }
15
-});
16 42
 
17
-createRoot(document.getElementById('root')!).render(
18
-  <StrictMode>
19
-    <AppEntry />
20
-  </StrictMode>
21
-);
43
+  ReactDOMClient.createRoot(document.getElementById('root')!).render(
44
+    <React.StrictMode>
45
+      <AppEntry />
46
+    </React.StrictMode>
47
+  );
48
+};
49
+
50
+void startHostApp();

+ 7 - 0
src/react-jsx-dev-runtime.ts

@@ -0,0 +1,7 @@
1
+import { Fragment, jsx, jsxs } from './react-jsx-runtime';
2
+
3
+export { Fragment, jsx, jsxs };
4
+
5
+export const jsxDEV = jsx;
6
+
7
+export default { Fragment, jsx, jsxs, jsxDEV };

+ 16 - 0
src/react-jsx-runtime.ts

@@ -0,0 +1,16 @@
1
+import * as React from 'react';
2
+
3
+export const Fragment = React.Fragment;
4
+
5
+export const jsx = (type: React.ElementType, props: Record<string, unknown>, key?: React.Key) => {
6
+  const children = props?.children;
7
+  if (Array.isArray(children)) {
8
+    const { children: _children, ...rest } = props;
9
+    return React.createElement(type, { ...rest, key }, ...children);
10
+  }
11
+  return React.createElement(type, { ...props, key });
12
+};
13
+
14
+export const jsxs = jsx;
15
+
16
+export default { Fragment, jsx, jsxs };

+ 82 - 0
src/services/aiChatService.ts

@@ -48,6 +48,10 @@ export interface ChatCompletionRequest {
48 48
   detail?: boolean;
49 49
   /** Chat messages */
50 50
   messages: ChatMessage[];
51
+  agentId?: string | null;
52
+  model?: string;
53
+  tools?: string[];
54
+  fileUrls?: Array<{ uid: string; name: string; url: string }>;
51 55
 }
52 56
 
53 57
 /**
@@ -71,6 +75,7 @@ export interface ChatCompletionResponse {
71 75
  */
72 76
 interface AIChatConfig {
73 77
   apiUrl: string;
78
+  modChatApiUrl: string;
74 79
 }
75 80
 
76 81
 /**
@@ -79,10 +84,74 @@ interface AIChatConfig {
79 84
 const getAIChatConfig = (): AIChatConfig => {
80 85
   return {
81 86
     apiUrl: import.meta.env.VITE_AI_PROXY_URL,
87
+    modChatApiUrl: import.meta.env.VITE_MOD_CHAT_API_BASE_URL,
82 88
   };
83 89
 };
84 90
 
85 91
 /**
92
+ * Send a normal chat message through the same Composer backend used by mod-chat.
93
+ * The host still owns session/workflow/WebMCP orchestration; this adapter only
94
+ * replaces the normal text completion request.
95
+ */
96
+const sendModChatCompletion = async (
97
+  request: ChatCompletionRequest,
98
+  baseUrl: string,
99
+): Promise<ChatCompletionResponse> => {
100
+  const normalizedBaseUrl = baseUrl.replace(/\/$/, '');
101
+  const payload = {
102
+    dialog_id: request.chatId,
103
+    axf_asset_id: request.agentId ?? '',
104
+    chat_input: request.messages.at(-1)?.content ?? '',
105
+    model: request.model ?? 'axonix-chat',
106
+    tools: request.tools ?? [],
107
+    file_urls: (request.fileUrls ?? []).map((file) => file.url),
108
+    streaming: true,
109
+  };
110
+
111
+  const runResponse = await fetchWithTimeout(`${normalizedBaseUrl}/composer/runs`, {
112
+    method: 'POST',
113
+    headers: { 'Content-Type': 'application/json' },
114
+    body: JSON.stringify(payload),
115
+  });
116
+  if (!runResponse.ok) {
117
+    throw new Error(`Composer 创建运行失败 (${runResponse.status})`);
118
+  }
119
+
120
+  const runData = (await runResponse.json()) as { run_id?: string };
121
+  if (!runData.run_id) throw new Error('Composer 未返回 run_id');
122
+
123
+  const streamResponse = await fetchWithTimeout(`${normalizedBaseUrl}/composer/runs/start`, {
124
+    method: 'POST',
125
+    headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
126
+    body: JSON.stringify({ ...payload, run_id: runData.run_id }),
127
+  });
128
+  if (!streamResponse.ok) {
129
+    throw new Error(`Composer 启动运行失败 (${streamResponse.status})`);
130
+  }
131
+
132
+  const responseText = await streamResponse.text();
133
+  const chunks = responseText
134
+    .split(/\r?\n/)
135
+    .filter((line) => line.startsWith('data:'))
136
+    .map((line) => line.slice(5).trim())
137
+    .filter(Boolean)
138
+    .map((line) => {
139
+      try {
140
+        const event = JSON.parse(line) as {
141
+          content?: { items?: Array<{ kind?: string; data?: { content?: unknown } }> };
142
+        };
143
+        const item = event.content?.items?.find((entry) => entry.kind === 'llm_result');
144
+        return typeof item?.data?.content === 'string' ? item.data.content : '';
145
+      } catch {
146
+        return '';
147
+      }
148
+    })
149
+    .filter(Boolean);
150
+
151
+  return { content: chunks.join('') || responseText.trim() };
152
+};
153
+
154
+/**
86 155
  * Send a chat completion request to AI platform
87 156
  *
88 157
  * @param request - Chat completion request
@@ -95,6 +164,15 @@ export const sendChatCompletion = async (
95 164
   try {
96 165
     const config = getAIChatConfig();
97 166
 
167
+    if (config.modChatApiUrl) {
168
+      try {
169
+        return await sendModChatCompletion(request, config.modChatApiUrl);
170
+      } catch (error) {
171
+        if (!config.apiUrl) throw error;
172
+        console.warn('mod-chat Composer 不可用,回退到宿主 AI 代理', error);
173
+      }
174
+    }
175
+
98 176
     if (!config.apiUrl) {
99 177
       if (import.meta.env.PROD) {
100 178
         throw new Error('AI 服务未配置,请先配置服务端代理');
@@ -112,6 +190,10 @@ export const sendChatCompletion = async (
112 190
         stream: request.stream ?? false,
113 191
         detail: request.detail ?? false,
114 192
         messages: request.messages,
193
+        agentId: request.agentId,
194
+        model: request.model,
195
+        tools: request.tools,
196
+        fileUrls: request.fileUrls,
115 197
       }),
116 198
     });
117 199
 

+ 63 - 27
src/services/webMcpAgentService.ts

@@ -156,20 +156,18 @@ const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
156 156
         if (!record || typeof record !== 'object') continue;
157 157
         const item = record as Record<string, unknown>;
158 158
         if (typeof item.documentId !== 'string' || typeof item.fileName !== 'string') continue;
159
+        const exportTitle = item.fileName.replace(/\.(docx?|DOCX?)$/i, '').trim();
160
+        const baseExportTitle = exportTitle.replace(/[_-]\d{8,}(?:[_-][a-f0-9]{8})?$/i, '').trim();
159 161
         const candidate = unique.get(item.documentId) || {
160 162
           documentId: item.documentId,
161
-          title: item.fileName
162
-            .replace(/\.(docx?|DOCX?)$/i, '')
163
-            .replace(/[_-]\d{8,}$/, '')
164
-            .trim(),
163
+          title: exportTitle,
165 164
         };
166
-        const exportTitle = item.fileName
167
-          .replace(/\.(docx?|DOCX?)$/i, '')
168
-          .replace(/[_-]\d{8,}$/, '')
169
-          .trim();
170 165
         if (exportTitle && exportTitle !== candidate.title) {
171 166
           candidate.aliases = [...new Set([...(candidate.aliases || []), exportTitle])];
172 167
         }
168
+        if (baseExportTitle && baseExportTitle !== candidate.title) {
169
+          candidate.aliases = [...new Set([...(candidate.aliases || []), baseExportTitle])];
170
+        }
173 171
         if (!candidate.recordId && typeof item.recordId === 'string')
174 172
           candidate.recordId = item.recordId;
175 173
         unique.set(item.documentId, candidate);
@@ -191,29 +189,55 @@ const normalizeDocumentText = (value: string): string =>
191 189
 const findDocumentCandidate = (
192 190
   title: string,
193 191
   candidates: ExportCandidate[]
194
-): { candidate?: ExportCandidate; ambiguous: boolean } => {
192
+): { candidate?: ExportCandidate; ambiguous: boolean; matches?: ExportCandidate[] } => {
195 193
   const normalizedTitle = normalizeDocumentText(title);
196 194
   const matches = candidates.filter((candidate) => {
197 195
     const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(
198 196
       normalizeDocumentText
199 197
     );
200
-    return (
201
-      candidate.documentId === title ||
202
-      candidate.recordId === title ||
203
-      candidateTitles.some(
204
-        (candidateTitle) =>
205
-          candidateTitle === normalizedTitle ||
206
-          candidateTitle.includes(normalizedTitle) ||
207
-          normalizedTitle.includes(candidateTitle)
208
-      )
198
+    return candidate.documentId === title || candidate.recordId === title || candidateTitles.includes(normalizedTitle);
199
+  });
200
+  if (matches.length === 1) return { candidate: matches[0], ambiguous: false, matches };
201
+  if (matches.length > 1) return { ambiguous: true, matches };
202
+
203
+  const fuzzyMatches = candidates.filter((candidate) => {
204
+    const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(
205
+      normalizeDocumentText
206
+    );
207
+    return candidateTitles.some(
208
+      (candidateTitle) =>
209
+        candidateTitle.includes(normalizedTitle) || normalizedTitle.includes(candidateTitle)
209 210
     );
210 211
   });
211 212
   return {
212
-    candidate: matches.length === 1 ? matches[0] : undefined,
213
-    ambiguous: matches.length > 1,
213
+    candidate: fuzzyMatches.length === 1 ? fuzzyMatches[0] : undefined,
214
+    ambiguous: fuzzyMatches.length > 1,
215
+    matches: fuzzyMatches,
214 216
   };
215 217
 };
216 218
 
219
+const formatAmbiguousDocuments = (title: string, candidates: ExportCandidate[]): string => {
220
+  const lines = candidates.slice(0, 10).map((candidate, index) => {
221
+    const uniqueName = candidate.aliases?.find((alias) => /_\d{8,}(?:_[a-f0-9]{8})?$/i.test(alias));
222
+    return `${index + 1}. ${uniqueName || candidate.title}(文档 ID:${candidate.documentId})`;
223
+  });
224
+  return `找到多份与“${title}”相近的文档,请使用完整文件名或文档 ID:\n${lines.join('\n')}`;
225
+};
226
+
227
+const resolveOpenDocument = async (
228
+  title: string
229
+): Promise<{ candidate?: ExportCandidate; response?: string }> => {
230
+  const candidates = await getDocumentCandidates();
231
+  const match = findDocumentCandidate(title, candidates);
232
+  if (match.ambiguous) {
233
+    return { response: formatAmbiguousDocuments(title, match.matches || []) };
234
+  }
235
+  if (!match.candidate) {
236
+    return { response: `没有找到名为“${title}”的文档,请先列出文档或使用文档 ID。` };
237
+  }
238
+  return { candidate: match.candidate };
239
+};
240
+
217 241
 const getActionHelp = (action: string): string => {
218 242
   if (action === 'download_export_record')
219 243
     return '请告诉我要下载的文档名称,例如:下载产品说明文档。';
@@ -346,11 +370,12 @@ const parseLocalDocumentCommand = async (
346 370
   if (!remainder || (isBlockAction && !blockId && !blockReference))
347 371
     return { handled: true, response: getActionHelp(toolName) };
348 372
   const candidates = await getDocumentCandidates();
349
-  const { candidate, ambiguous } = findDocumentCandidate(remainder, candidates);
373
+  const documentMatch = findDocumentCandidate(remainder, candidates);
374
+  const { candidate, ambiguous } = documentMatch;
350 375
   if (ambiguous)
351 376
     return {
352 377
       handled: true,
353
-      response: `找到多份与“${remainder}”相近的文档,请提供更完整的文档名称。`,
378
+      response: formatAmbiguousDocuments(remainder, documentMatch.matches || []),
354 379
     };
355 380
   if (!candidate)
356 381
     return { handled: true, response: `没有找到名为“${remainder}”的文档,请确认文档名称后重试。` };
@@ -609,26 +634,37 @@ export const executeWebMcpChatCommand = async (content: string): Promise<WebMcpC
609 634
   const tool = getWebMcpTool(toolName);
610 635
   if (!tool) return { handled: true, response: `WebMCP 工具不存在:${toolName}`, toolName };
611 636
 
637
+  let matchedInput = matched.input;
638
+  let documentTitle: string | undefined;
639
+  if (toolName === 'open_document' && typeof matched.input.documentId === 'string') {
640
+    const resolved = await resolveOpenDocument(matched.input.documentId);
641
+    if (resolved.response) return { handled: true, response: resolved.response, toolName };
642
+    if (resolved.candidate) {
643
+      matchedInput = { documentId: resolved.candidate.documentId };
644
+      documentTitle = resolved.candidate.title;
645
+    }
646
+  }
647
+
612 648
   if (!READ_ONLY_TOOLS.has(toolName)) {
613 649
     return {
614 650
       handled: true,
615 651
       toolName,
616 652
       template: matched.template,
617
-      input: matched.input,
653
+      input: matchedInput,
618 654
       requiresConfirmation: true,
619
-      response: `该操作需要确认:${matched.template.label}\n${JSON.stringify(matched.input, null, 2)}`,
655
+      response: `该操作需要确认:${matched.template.label}\n${JSON.stringify(matchedInput, null, 2)}`,
620 656
     };
621 657
   }
622 658
 
623
-  const result = await executeWebMcpTool(toolName, matched.input);
659
+  const result = await executeWebMcpTool(toolName, matchedInput);
624 660
   return {
625 661
     handled: true,
626 662
     toolName,
627 663
     template: matched.template,
628
-    input: matched.input,
664
+    input: matchedInput,
629 665
     requiresConfirmation: false,
630 666
     result,
631
-    response: friendlyResult(toolName, matched.input, result),
667
+    response: friendlyResult(toolName, { ...matchedInput, documentTitle }, result),
632 668
   };
633 669
 };
634 670
 

+ 1 - 1
src/services/webMcpChatTemplates.ts

@@ -26,7 +26,7 @@ const template = (
26 26
 
27 27
 export const webMcpChatTemplates: ReadonlyArray<WebMcpChatTemplate> = [
28 28
     template('list_documents', '列出我的文档', /^列出(?:我的|当前用户的)?文档$/, ['列出我的文档'], () => ({})),
29
-    template('open_document', '打开文档', /^(?:打开|查看)\s+(\S+)\s+文档$/, ['查看 doc-abc123 文档'], (match) => ({ documentId: match[1] })),
29
+    template('open_document', '打开文档', /^(?:打开|查看)\s+(.+?)\s+文档$/, ['查看 doc-abc123 文档'], (match) => ({ documentId: match[1] })),
30 30
     template('get_document', '读取文档', /^读取文档\s+(\S+)$/, ['读取文档 doc-abc123'], (match) => ({ documentId: match[1] })),
31 31
     template('search_document', '搜索文档', /^搜索文档\s+(\S+)\s+(.+)$/, ['搜索文档 doc-abc123 WebMCP'], (match) => ({ documentId: match[1], query: match[2] })),
32 32
     template('get_block', '读取文档块', /^读取文档块\s+(\S+)\s+(\S+)$/, ['读取文档块 doc-abc123 block-p-50'], (match) => ({ documentId: match[1], blockId: match[2] })),

+ 9 - 4
src/services/webMcpService.ts

@@ -43,6 +43,10 @@ interface WebMcpNavigator extends Navigator {
43 43
 	modelContext?: ModelContext;
44 44
 }
45 45
 
46
+interface WebMcpDocument extends Document {
47
+	modelContext?: ModelContext;
48
+}
49
+
46 50
 export interface WebMcpRegistrationResult {
47 51
 	supported: boolean;
48 52
 	registeredTools: string[];
@@ -178,7 +182,7 @@ const tools: WebMcpTool[] = [
178 182
 	{
179 183
 		name: 'open_document',
180 184
 		title: '打开文档',
181
-		description: '在当前网站编辑器中打开指定文档。只读操作。',
185
+		description: '在当前网站编辑器中打开指定文档。请使用文档 ID 或导出文件的完整唯一文件名;同名文档需先列出候选。只读操作。',
182 186
 		readOnlyHint: true,
183 187
 		inputSchema: objectSchema({ documentId: documentIdProperty }, ['documentId']),
184 188
 		execute: (input) => run(async () => {
@@ -433,12 +437,13 @@ export const executeWebMcpTool = async (
433 437
 export const registerWebMcpTools = (): Promise<WebMcpRegistrationResult> => {
434 438
 	if (registrationPromise) return registrationPromise;
435 439
 	registrationPromise = (async () => {
436
-		const modelContext = (navigator as WebMcpNavigator).modelContext;
440
+		const modelContext =
441
+			(document as WebMcpDocument).modelContext ?? (navigator as WebMcpNavigator).modelContext;
437 442
 		if (!modelContext) return { supported: false, registeredTools: [], error: '当前浏览器未提供 navigator.modelContext' };
438 443
 		try {
439 444
 			const definitions = browserTools();
440
-			if (modelContext.provideContext) await modelContext.provideContext({ tools: definitions });
441
-			else if (modelContext.registerTool) for (const tool of definitions) await modelContext.registerTool(tool);
445
+			if (modelContext.registerTool) for (const tool of definitions) await modelContext.registerTool(tool);
446
+			else if (modelContext.provideContext) await modelContext.provideContext({ tools: definitions });
442 447
 			else return { supported: true, registeredTools: [], error: 'WebMCP API 不支持工具注册' };
443 448
 			return { supported: true, registeredTools: tools.map((tool) => tool.name) };
444 449
 		} catch (error) {

+ 98 - 45
src/services/workflowService.ts

@@ -28,6 +28,9 @@ interface WorkflowRequest {
28 28
   chatId: string;
29 29
   sessionId?: string; // 新增:显式传递sessionId
30 30
   timestamp?: number; // 新增:时间戳避免缓存
31
+  requestId?: string;
32
+  input?: string;
33
+  query?: string;
31 34
   stream?: boolean;
32 35
   detail?: boolean;
33 36
   messages: WorkflowMessage[];
@@ -69,6 +72,17 @@ interface WorkflowResponse {
69 72
   }>;
70 73
 }
71 74
 
75
+type WorkflowRecord = NonNullable<WorkflowResponse['records']>[number];
76
+
77
+const isWorkflowRecordFresh = (record: WorkflowRecord, requestTimestamp: number): boolean =>
78
+  typeof record.createdAt !== 'number' || record.createdAt >= requestTimestamp - 5_000;
79
+
80
+const selectWorkflowRecord = (records: WorkflowRecord[], requestTimestamp: number): WorkflowRecord | undefined => {
81
+  const freshRecord = records.find((record) => isWorkflowRecordFresh(record, requestTimestamp));
82
+  return freshRecord ??
83
+    [...records].sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))[0];
84
+};
85
+
72 86
 /**
73 87
  * Configuration for Workflow API
74 88
  */
@@ -101,6 +115,18 @@ const normalizeExportDownloadUrl = (downloadUrl: string): string => {
101 115
   return downloadUrl;
102 116
 };
103 117
 
118
+/** Give every workflow result a unique client-visible name, even when the
119
+ * workflow returns the same base filename for repeated requests. */
120
+const makeUniqueExportFileName = (fileName: string, requestTimestamp: number): string => {
121
+  const extensionMatch = fileName.match(/(\.[^./\\]+)$/);
122
+  const extension = extensionMatch?.[1] || '.docx';
123
+  const baseName = extensionMatch ? fileName.slice(0, -extension.length) : fileName;
124
+  const randomSuffix = typeof crypto !== 'undefined' && 'randomUUID' in crypto
125
+    ? crypto.randomUUID().slice(0, 8)
126
+    : Math.random().toString(16).slice(2, 10);
127
+  return `${baseName || '导出文档'}_${requestTimestamp}_${randomSuffix}${extension}`;
128
+};
129
+
104 130
 /**
105 131
  * Check if user input should trigger document generation workflow
106 132
  *
@@ -152,6 +178,7 @@ export const triggerDocumentWorkflow = async (
152 178
     // 这样工作流会为每个请求生成新的文档,但它们都关联到同一个sessionId
153 179
     const timestamp = Date.now();
154 180
     const uniqueChatId = `${sessionId}__${timestamp}`;
181
+    const requestId = `${uniqueChatId}__document`;
155 182
 
156 183
     // Call the workflow API
157 184
     // 关键点:
@@ -167,6 +194,9 @@ export const triggerDocumentWorkflow = async (
167 194
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存
168 195
         sessionId: sessionId, // 原始sessionId,供工作流调用后端API时使用
169 196
         timestamp: timestamp, // 显式传递时间戳
197
+        requestId,
198
+        input: userInput,
199
+        query: userInput,
170 200
         stream: false,
171 201
         detail: false,
172 202
         messages: [
@@ -197,6 +227,8 @@ export const triggerDocumentWorkflow = async (
197 227
       try {
198 228
         const parsedContent = JSON.parse(content);
199 229
 
230
+        const noFreshRecordMessage = '⚠️ 工作流未返回本次请求生成的新文档,请检查工作流输入配置';
231
+
200 232
         // Handle nested response format: { code: 0, data: { records: [...] } }
201 233
         if (
202 234
           parsedContent.code === 0 &&
@@ -204,8 +236,8 @@ export const triggerDocumentWorkflow = async (
204 236
           Array.isArray(parsedContent.data.records)
205 237
         ) {
206 238
           const records = parsedContent.data.records;
207
-          if (records.length > 0) {
208
-            const record = records[0];
239
+          const record = selectWorkflowRecord(records, timestamp);
240
+          if (record) {
209 241
             exportRecord = {
210 242
               recordId: record.recordId,
211 243
               fileName: record.fileName || '导出文档.docx',
@@ -215,6 +247,8 @@ export const triggerDocumentWorkflow = async (
215 247
 
216 248
             // Update content to be more user-friendly
217 249
             content = '✅ 文档已生成,点击下方卡片预览或下载';
250
+          } else {
251
+            content = noFreshRecordMessage;
218 252
           }
219 253
         }
220 254
         // Handle direct records array format
@@ -223,41 +257,53 @@ export const triggerDocumentWorkflow = async (
223 257
           Array.isArray(parsedContent.records) &&
224 258
           parsedContent.records.length > 0
225 259
         ) {
226
-          const record = parsedContent.records[0];
227
-          exportRecord = {
228
-            recordId: record.recordId,
229
-            fileName: record.fileName || '导出文档.docx',
230
-            downloadUrl: record.downloadUrl,
231
-            documentId: record.documentId || record.recordId,
232
-          };
233
-
234
-          // Update content to be more user-friendly
235
-          content = '✅ 文档已生成,点击下方卡片预览或下载';
260
+          const record = selectWorkflowRecord(parsedContent.records, timestamp);
261
+          if (!record) {
262
+            content = noFreshRecordMessage;
263
+          } else {
264
+            exportRecord = {
265
+              recordId: record.recordId,
266
+              fileName: record.fileName || '导出文档.docx',
267
+              downloadUrl: record.downloadUrl,
268
+              documentId: record.documentId || record.recordId,
269
+            };
270
+
271
+            // Update content to be more user-friendly
272
+            content = '✅ 文档已生成,点击下方卡片预览或下载';
273
+          }
236 274
         }
237 275
         // Handle single record object format (not wrapped in array)
238 276
         else if (parsedContent.recordId && parsedContent.downloadUrl) {
239
-          exportRecord = {
240
-            recordId: parsedContent.recordId,
241
-            fileName: parsedContent.fileName || '导出文档.docx',
242
-            downloadUrl: parsedContent.downloadUrl,
243
-            documentId: parsedContent.documentId || parsedContent.recordId,
244
-          };
245
-
246
-          // Update content to be more user-friendly
247
-          content = '✅ 文档已生成,点击下方卡片预览或下载';
277
+          if (isWorkflowRecordFresh(parsedContent, timestamp)) {
278
+            exportRecord = {
279
+              recordId: parsedContent.recordId,
280
+              fileName: parsedContent.fileName || '导出文档.docx',
281
+              downloadUrl: parsedContent.downloadUrl,
282
+              documentId: parsedContent.documentId || parsedContent.recordId,
283
+            };
284
+
285
+            // Update content to be more user-friendly
286
+            content = '✅ 文档已生成,点击下方卡片预览或下载';
287
+          } else {
288
+            content = noFreshRecordMessage;
289
+          }
248 290
         }
249 291
         // Handle nested data format with single record
250 292
         else if (parsedContent.data && parsedContent.data.recordId) {
251 293
           const record = parsedContent.data;
252
-          exportRecord = {
253
-            recordId: record.recordId,
254
-            fileName: record.fileName || '导出文档.docx',
255
-            downloadUrl: record.downloadUrl,
256
-            documentId: record.documentId || record.recordId,
257
-          };
258
-
259
-          // Update content to be more user-friendly
260
-          content = '✅ 文档已生成,点击下方卡片预览或下载';
294
+          if (isWorkflowRecordFresh(record, timestamp)) {
295
+            exportRecord = {
296
+              recordId: record.recordId,
297
+              fileName: record.fileName || '导出文档.docx',
298
+              downloadUrl: record.downloadUrl,
299
+              documentId: record.documentId || record.recordId,
300
+            };
301
+
302
+            // Update content to be more user-friendly
303
+            content = '✅ 文档已生成,点击下方卡片预览或下载';
304
+          } else {
305
+            content = noFreshRecordMessage;
306
+          }
261 307
         }
262 308
       } catch {
263 309
         // Content looks like JSON but failed to parse
@@ -267,28 +313,35 @@ export const triggerDocumentWorkflow = async (
267 313
     // Check for export record in top-level response
268 314
     if (!exportRecord && data.exportRecord) {
269 315
       // Single export record format
270
-      exportRecord = {
271
-        recordId: data.exportRecord.recordId,
272
-        fileName: data.exportRecord.fileName,
273
-        downloadUrl: data.exportRecord.downloadUrl,
274
-        documentId: data.exportRecord.documentId,
275
-      };
276
-      content = '✅ 文档已生成,点击下方卡片预览或下载';
316
+      if (typeof data.exportRecord.createdAt !== 'number' || data.exportRecord.createdAt >= timestamp - 5_000) {
317
+        exportRecord = {
318
+          recordId: data.exportRecord.recordId,
319
+          fileName: data.exportRecord.fileName,
320
+          downloadUrl: data.exportRecord.downloadUrl,
321
+          documentId: data.exportRecord.documentId,
322
+        };
323
+        content = '✅ 文档已生成,点击下方卡片预览或下载';
324
+      }
277 325
     } else if (!exportRecord && data.records && data.records.length > 0) {
278 326
       // Records array format (take the first one)
279
-      const record = data.records[0];
280
-      exportRecord = {
281
-        recordId: record.recordId,
282
-        fileName: record.fileName,
283
-        downloadUrl: record.downloadUrl,
284
-        documentId: record.documentId,
285
-      };
286
-      content = '✅ 文档已生成,点击下方卡片预览或下载';
327
+      const record = selectWorkflowRecord(data.records, timestamp);
328
+      if (record) {
329
+        exportRecord = {
330
+          recordId: record.recordId,
331
+          fileName: record.fileName,
332
+          downloadUrl: record.downloadUrl,
333
+          documentId: record.documentId,
334
+        };
335
+        content = '✅ 文档已生成,点击下方卡片预览或下载';
336
+      }
287 337
     }
288 338
 
289 339
     if (exportRecord?.downloadUrl) {
290 340
       exportRecord.downloadUrl = normalizeExportDownloadUrl(exportRecord.downloadUrl);
291 341
     }
342
+    if (exportRecord?.fileName) {
343
+      exportRecord.fileName = makeUniqueExportFileName(exportRecord.fileName, timestamp);
344
+    }
292 345
 
293 346
     return {
294 347
       content,

+ 18 - 4
src/stores/chatStore.ts

@@ -22,7 +22,7 @@
22 22
 
23 23
 import { create } from 'zustand';
24 24
 import type { ChatStoreState } from '../types/store';
25
-import type { ChatMessage, ChatSession, ExportRecordInfo } from '../types/chat';
25
+import type { ChatMessage, ChatSession, ChatSendOptions, ExportRecordInfo } from '../types/chat';
26 26
 import { v4 as uuidv4 } from 'uuid';
27 27
 import { message, Modal } from 'antd';
28 28
 import { sendChatCompletion, createTextMessage } from '../services/aiChatService';
@@ -189,7 +189,8 @@ const generateSessionTitle = (firstMessage: string): string => {
189 189
 const getAIResponse = async (
190 190
   userMessage: string,
191 191
   sessionId: string,
192
-  messageHistory: ChatMessage[]
192
+  messageHistory: ChatMessage[],
193
+  options?: ChatSendOptions,
193 194
 ): Promise<{ response: string; shouldExport: boolean; title: string }> => {
194 195
   // Build message history for API
195 196
   const apiMessages = messageHistory
@@ -205,6 +206,10 @@ const getAIResponse = async (
205 206
     stream: false,
206 207
     detail: false,
207 208
     messages: apiMessages,
209
+    agentId: options?.agentId,
210
+    model: options?.model,
211
+    tools: options?.tools,
212
+    fileUrls: options?.fileUrls,
208 213
   });
209 214
 
210 215
   // Check if the response indicates document generation
@@ -308,6 +313,15 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
308 313
 
309 314
     return sessionId;
310 315
   },
316
+  updateSessionSettings: (sessionId, settings) => {
317
+    const updatedSessions = get().sessions.map((session) =>
318
+      session.id === sessionId
319
+        ? { ...session, settings: { ...session.settings, ...settings }, updatedAt: Date.now() }
320
+        : session
321
+    );
322
+    set({ sessions: updatedSessions });
323
+    scheduleSessionsStorageSave(updatedSessions);
324
+  },
311 325
 
312 326
   /**
313 327
    * Load an existing session by ID
@@ -400,7 +414,7 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
400 414
    * @param content - User message content
401 415
    * @throws {Error} When AI service call fails
402 416
    */
403
-  sendMessage: async (content: string) => {
417
+  sendMessage: async (content: string, options?: ChatSendOptions) => {
404 418
     try {
405 419
       // Create session if not exists
406 420
       let sessionId = get().currentSessionId;
@@ -504,7 +518,7 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
504 518
         }
505 519
       } else {
506 520
         // Use regular AI service for normal chat
507
-        const aiResult = await getAIResponse(content, sessionId, get().messages);
521
+        const aiResult = await getAIResponse(content, sessionId, get().messages, options);
508 522
         aiResponse = aiResult.response;
509 523
         exportRecord = undefined;
510 524
       }

+ 8 - 0
src/types/chat.ts

@@ -32,6 +32,13 @@ export interface ChatMessage {
32 32
   exportRecord?: ExportRecordInfo;
33 33
 }
34 34
 
35
+export interface ChatSendOptions {
36
+  agentId?: string | null;
37
+  model?: string;
38
+  tools?: string[];
39
+  fileUrls?: Array<{ uid: string; name: string; url: string }>;
40
+}
41
+
35 42
 /**
36 43
  * Chat session interface
37 44
  */
@@ -48,4 +55,5 @@ export interface ChatSession {
48 55
   updatedAt: number;
49 56
   /** List of export records associated with this session */
50 57
   exportRecords: ExportRecordInfo[];
58
+  settings?: Record<string, unknown>;
51 59
 }

+ 37 - 0
src/types/mod-chat.d.ts

@@ -0,0 +1,37 @@
1
+declare module 'mod_chat/App' {
2
+  import type { ComponentType } from 'react';
3
+
4
+  interface ChatHostSendOptions {
5
+    agentId: string | null;
6
+    model: string;
7
+    tools: string[];
8
+    fileUrls: Array<{ uid: string; name: string; url: string }>;
9
+  }
10
+
11
+  interface ModChatProps {
12
+    layoutMode?: 'full-page' | 'embedded-widget';
13
+    hostBridge?: {
14
+      messages: Array<{
15
+        id: string;
16
+        role: 'user' | 'assistant';
17
+        content: string;
18
+        createdAt: number;
19
+        exportRecord?: { documentId?: string; fileName?: string; downloadUrl?: string };
20
+      }>;
21
+      isLoading: boolean;
22
+      onSendMessage: (content: string, options?: ChatHostSendOptions) => Promise<void>;
23
+      sessions: Array<{ id: string; title: string; updatedAt: number; settings?: Record<string, unknown> }>;
24
+      activeSessionId: string | null;
25
+      onCreateSession: () => void;
26
+      onSelectSession: (sessionId: string) => void;
27
+      onDeleteSession: (sessionId: string) => Promise<void>;
28
+      onRenameSession: (sessionId: string, title: string) => void;
29
+      onUpdateSessionSettings: (sessionId: string, settings: Record<string, unknown>) => void;
30
+      onPreviewDocument?: (documentId: string, documentName?: string) => void;
31
+      onDownloadDocument?: (downloadUrl: string, fileName?: string) => Promise<void>;
32
+    };
33
+  }
34
+
35
+  const ModChatApp: ComponentType<ModChatProps>;
36
+  export default ModChatApp;
37
+}

+ 4 - 2
src/types/store.ts

@@ -3,7 +3,7 @@
3 3
  */
4 4
 
5 5
 import type { Document, DocumentListItem, CreateDocumentRequest } from './document';
6
-import type { ChatMessage, ChatSession } from './chat';
6
+import type { ChatMessage, ChatSession, ChatSendOptions } from './chat';
7 7
 import type { SaveStatus } from './ui';
8 8
 import type { ListFilters, PaginationInfo } from './api';
9 9
 
@@ -60,13 +60,15 @@ export interface ChatStoreState {
60 60
   /** Delete a session by ID (calls backend API to delete documents) */
61 61
   deleteSession: (sessionId: string) => Promise<void>;
62 62
   /** Send a message to AI and get response */
63
-  sendMessage: (content: string) => Promise<void>;
63
+  sendMessage: (content: string, options?: ChatSendOptions) => Promise<void>;
64 64
   /** Add a message to the current session */
65 65
   addMessage: (message: ChatMessage) => void;
66 66
   /** Clear all messages in current session */
67 67
   clearMessages: () => void;
68 68
   /** Update session title */
69 69
   updateSessionTitle: (sessionId: string, title: string) => void;
70
+  /** Update host-owned session settings */
71
+  updateSessionSettings: (sessionId: string, settings: Record<string, unknown>) => void;
70 72
 }
71 73
 
72 74
 /**

+ 68 - 0
vite.config.ts

@@ -1,15 +1,35 @@
1 1
 import { defineConfig, loadEnv } from 'vite';
2 2
 import react from '@vitejs/plugin-react';
3 3
 import { visualizer } from 'rollup-plugin-visualizer';
4
+import federation from '@originjs/vite-plugin-federation';
4 5
 
5 6
 // https://vite.dev/config/
6 7
 export default defineConfig(({ mode }) => {
7 8
   const env = loadEnv(mode, process.cwd(), '');
8 9
   const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:8000';
10
+  const modChatApiTarget = env.VITE_MOD_CHAT_API_BASE_URL || apiTarget;
9 11
 
10 12
   return {
11 13
   plugins: [
12 14
     react(),
15
+    federation({
16
+      name: 'ax_frontend_app',
17
+      remotes: {
18
+        mod_chat: env.VITE_MOD_CHAT_REMOTE_URL || 'http://localhost:5172/assets/remoteEntry.js',
19
+      },
20
+      shared: {
21
+        react: { requiredVersion: false },
22
+        'react-dom': { requiredVersion: false },
23
+        'react/jsx-runtime': {
24
+          packagePath: './src/react-jsx-runtime.ts',
25
+          requiredVersion: false,
26
+        },
27
+        'react/jsx-dev-runtime': {
28
+          packagePath: './src/react-jsx-dev-runtime.ts',
29
+          requiredVersion: false,
30
+        },
31
+      },
32
+    }),
13 33
     
14 34
     // Bundle 分析工具(通过 ANALYZE=true 环境变量启用)
15 35
     process.env.ANALYZE === 'true' && visualizer({
@@ -65,6 +85,30 @@ export default defineConfig(({ mode }) => {
65 85
         target: apiTarget,
66 86
         changeOrigin: true,
67 87
       },
88
+      '/composer': {
89
+        target: modChatApiTarget,
90
+        changeOrigin: true,
91
+      },
92
+      '/registry': {
93
+        target: env.VITE_MOD_CHAT_REGISTRY_API_BASE_URL || modChatApiTarget,
94
+        changeOrigin: true,
95
+      },
96
+      '/agents': {
97
+        target: modChatApiTarget,
98
+        changeOrigin: true,
99
+      },
100
+      '/models': {
101
+        target: modChatApiTarget,
102
+        changeOrigin: true,
103
+      },
104
+      '/tools': {
105
+        target: modChatApiTarget,
106
+        changeOrigin: true,
107
+      },
108
+      '/sessions': {
109
+        target: modChatApiTarget,
110
+        changeOrigin: true,
111
+      },
68 112
     },
69 113
   },
70 114
 
@@ -77,6 +121,30 @@ export default defineConfig(({ mode }) => {
77 121
         target: apiTarget,
78 122
         changeOrigin: true,
79 123
       },
124
+      '/composer': {
125
+        target: modChatApiTarget,
126
+        changeOrigin: true,
127
+      },
128
+      '/registry': {
129
+        target: env.VITE_MOD_CHAT_REGISTRY_API_BASE_URL || modChatApiTarget,
130
+        changeOrigin: true,
131
+      },
132
+      '/agents': {
133
+        target: modChatApiTarget,
134
+        changeOrigin: true,
135
+      },
136
+      '/models': {
137
+        target: modChatApiTarget,
138
+        changeOrigin: true,
139
+      },
140
+      '/tools': {
141
+        target: modChatApiTarget,
142
+        changeOrigin: true,
143
+      },
144
+      '/sessions': {
145
+        target: modChatApiTarget,
146
+        changeOrigin: true,
147
+      },
80 148
     },
81 149
   },
82 150