Преглед на файлове

feat: 更新环境变量配置,使用后端代理替代直接 API 调用;优化文档和代码注释

Zhang Yice преди 1 месец
родител
ревизия
33ca8672f5
променени са 9 файла, в които са добавени 81 реда и са изтрити 76 реда
  1. 2 4
      .env
  2. 1 7
      .env.example
  3. 5 6
      README.md
  4. 3 5
      docs/README.md
  5. 1 1
      docs/新编辑器架构设计.md
  6. 1 16
      src/services/aiChatService.ts
  7. 5 1
      src/services/exportService.ts
  8. 62 22
      src/services/webMcpAgentService.ts
  9. 1 14
      src/services/workflowService.ts

+ 2 - 4
.env

@@ -13,10 +13,8 @@ VITE_APP_TITLE=AX Document Editor
13 13
 VITE_DEBUG=false
14 14
 
15 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
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 20
 VITE_WEBMCP_BRIDGE_TOKEN=AXWebMCP-04fb7ebd989246b28ca63a49e0efd2c0a27cc8c89e2241fda22b8b0c42c744a1

+ 1 - 7
.env.example

@@ -14,12 +14,6 @@ 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
+# Frontend only calls these backend proxy endpoints. Upstream keys belong in ax-backend-v1/.env.
24 18
 VITE_AI_PROXY_URL=/api/v1/ai/chat
25 19
 VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow

+ 5 - 6
README.md

@@ -126,8 +126,9 @@ 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 对话通过后端代理 `/api/v1/ai/chat`
130
+- 文档工作流通过后端代理 `/api/v1/ai/workflow`,对应平台接口 `/api/v2/chat/completions`
131
+- 平台 `appId/projectId` 和 API 密钥只配置在 `ax-backend-v1/.env`,不会进入前端构建
131 132
 
132 133
 ---
133 134
 
@@ -407,10 +408,8 @@ Content-Type: 'application/json'
407 408
 | `VITE_API_BASE_URL` | 后端 API 地址 | `http://192.168.0.195:8000` |
408 409
 | `VITE_APP_TITLE` | 应用标题 | `AX Document Editor` |
409 410
 | `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 密钥 | - |
411
+| `VITE_AI_PROXY_URL` | 后端 AI 代理 | `/api/v1/ai/chat` |
412
+| `VITE_WORKFLOW_PROXY_URL` | 后端工作流代理 | `/api/v1/ai/workflow` |
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_*` 环境变量会进入浏览器构建产物,因此 `VITE_AI_API_KEY` 和 `VITE_WORKFLOW_API_KEY` 不能作为生产秘密。生产环境应使用后端代理;平台密钥和固定的 `appId/projectId` 只放在后端环境
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`。

+ 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_API_KEY` 或 `VITE_WORKFLOW_API_KEY`。Vite 的 `VITE_*` 变量会被打包到浏览器,不能视为秘密。AI 和工作流请求统一通过后端代理,目标工作流固定为 `/api/v2/chat/completions`,并由后端附带固定的 `appId/projectId`
94 94
 
95 95
 ## 8. 性能决策
96 96
 

+ 1 - 16
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 || '/api/v1/ai/chat',
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
  *
@@ -101,18 +95,10 @@ export const sendChatCompletion = async (
101 95
   try {
102 96
     const config = getAIChatConfig();
103 97
 
104
-    if (!config.apiUrl) {
105
-      if (import.meta.env.PROD) {
106
-        throw new Error('AI 服务未配置,请先配置服务端代理');
107
-      }
108
-      return mockChatCompletion(request);
109
-    }
110
-
111 98
     const response = await fetchWithTimeout(config.apiUrl, {
112 99
       method: 'POST',
113 100
       headers: {
114 101
         'Content-Type': 'application/json',
115
-        ...getAuthorizationHeaders(config.apiKey),
116 102
       },
117 103
       body: JSON.stringify({
118 104
         chatId: request.chatId,
@@ -176,7 +162,6 @@ export const sendStreamingChatCompletion = async (
176 162
       method: 'POST',
177 163
       headers: {
178 164
         'Content-Type': 'application/json',
179
-        ...getAuthorizationHeaders(config.apiKey),
180 165
       },
181 166
       body: JSON.stringify({
182 167
         ...request,

+ 5 - 1
src/services/exportService.ts

@@ -36,7 +36,11 @@ export const exportToWord = async (request: ExportDocRequest): Promise<ExportDoc
36 36
   try {
37 37
     const response = await apiClient.post<ApiResponse<ExportDocResponse>>(
38 38
       '/api/v1/export/doc',
39
-      request
39
+      request,
40
+      {
41
+        // 大文档的排版和文件生成可能超过普通 API 的 30 秒保护窗口。
42
+        timeout: 120000,
43
+      }
40 44
     );
41 45
 
42 46
     const data = response.data.data;

+ 62 - 22
src/services/webMcpAgentService.ts

@@ -8,6 +8,7 @@ import { getWebMcpTools } from './webMcpService';
8 8
 import { translateWebMcpInput } from '../share/webmcp';
9 9
 import { listDocuments } from './documentService';
10 10
 import { blockService } from './blockService';
11
+import { useEditorStore } from '../stores/editorStore';
11 12
 
12 13
 export interface WebMcpChatResult {
13 14
 	handled: boolean;
@@ -52,7 +53,11 @@ const stringifyResult = (result: WebMcpToolResult): string => {
52 53
 };
53 54
 
54 55
 const getDocumentCandidates = async (): Promise<ExportCandidate[]> => {
55
-	if (documentCandidatesCache && documentCandidatesCache.expiresAt > Date.now()) {
56
+	const currentDocumentId = useEditorStore.getState().documentId;
57
+	const cacheContainsCurrentDocument = !currentDocumentId || documentCandidatesCache?.value.some(
58
+		(candidate) => candidate.documentId === currentDocumentId
59
+	);
60
+	if (documentCandidatesCache && documentCandidatesCache.expiresAt > Date.now() && cacheContainsCurrentDocument) {
56 61
 		return documentCandidatesCache.value;
57 62
 	}
58 63
 	if (documentCandidatesRequest) return documentCandidatesRequest;
@@ -68,6 +73,34 @@ const getDocumentCandidates = async (): Promise<ExportCandidate[]> => {
68 73
 
69 74
 const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
70 75
 	const unique = new Map<string, ExportCandidate>();
76
+	const getBlockText = (block: unknown): string => {
77
+		if (!block || typeof block !== 'object') return '';
78
+		const content = (block as { content?: unknown }).content;
79
+		if (typeof content === 'string') return content.trim();
80
+		if (Array.isArray(content)) {
81
+			return content
82
+				.map((part) => part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
83
+					? (part as { text: string }).text
84
+					: '')
85
+				.join('')
86
+				.trim();
87
+		}
88
+		return '';
89
+	};
90
+	const getFirstBlockText = (blocks: unknown): string =>
91
+		Array.isArray(blocks) ? blocks.map(getBlockText).find(Boolean) || '' : '';
92
+	const currentEditor = useEditorStore.getState();
93
+	if (currentEditor.documentId) {
94
+		const currentBlockAliases = currentEditor.blocks
95
+			.map(getBlockText)
96
+			.filter(Boolean)
97
+			.slice(0, 20);
98
+		unique.set(currentEditor.documentId, {
99
+			documentId: currentEditor.documentId,
100
+			title: getFirstBlockText(currentEditor.blocks) || currentEditor.documentId,
101
+			aliases: currentBlockAliases,
102
+		});
103
+	}
71 104
 	try {
72 105
 		const documentResult = await listDocuments({
73 106
 			userId: 'default-user',
@@ -82,23 +115,13 @@ const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
82 115
 				? (detail.data as { blocks?: unknown }).blocks
83 116
 				: undefined;
84 117
 			if (!Array.isArray(blocks)) return;
85
-			const firstText = blocks
86
-				.map((block) => {
87
-					if (!block || typeof block !== 'object') return '';
88
-					const content = (block as { content?: unknown }).content;
89
-					if (typeof content === 'string') return content.trim();
90
-					if (Array.isArray(content)) {
91
-						return content
92
-							.map((part) => part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
93
-								? (part as { text: string }).text
94
-								: '')
95
-							.join('')
96
-							.trim();
97
-					}
98
-					return '';
99
-				})
100
-				.find(Boolean);
101
-			unique.set(document.id, { documentId: document.id, title: firstText || document.id });
118
+			const firstText = getFirstBlockText(blocks);
119
+			const existing = unique.get(document.id);
120
+		unique.set(document.id, {
121
+			...existing,
122
+			documentId: document.id,
123
+			title: firstText || existing?.title || document.id,
124
+		});
102 125
 		}));
103 126
 	} catch (error) {
104 127
 		console.warn('[WebMCP] 获取文档名称失败,将继续使用导出记录:', error);
@@ -142,10 +165,24 @@ const findDocumentCandidate = (
142 165
 	candidates: ExportCandidate[]
143 166
 ): { candidate?: ExportCandidate; ambiguous: boolean } => {
144 167
 	const normalizedTitle = normalizeDocumentText(title);
168
+	const isChineseShortName = (value: string): boolean =>
169
+		value.length >= 3 && /^[\u3400-\u9fff]+$/.test(value);
170
+	const isOrderedChineseMatch = (shortName: string, fullName: string): boolean => {
171
+		if (!isChineseShortName(shortName)) return false;
172
+		let shortIndex = 0;
173
+		for (const character of fullName) {
174
+			if (character === shortName[shortIndex]) shortIndex += 1;
175
+			if (shortIndex === shortName.length) return true;
176
+		}
177
+		return false;
178
+	};
145 179
 	const matches = candidates.filter((candidate) => {
146 180
 		const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(normalizeDocumentText);
147 181
 		return candidate.documentId === title || candidate.recordId === title || candidateTitles.some((candidateTitle) =>
148
-			candidateTitle === normalizedTitle || candidateTitle.includes(normalizedTitle) || normalizedTitle.includes(candidateTitle)
182
+			candidateTitle === normalizedTitle ||
183
+			candidateTitle.includes(normalizedTitle) ||
184
+			normalizedTitle.includes(candidateTitle) ||
185
+			isOrderedChineseMatch(normalizedTitle, candidateTitle)
149 186
 		);
150 187
 	});
151 188
 	return { candidate: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
@@ -231,8 +268,8 @@ const parseLocalDocumentCommand = async (content: string): Promise<
231 268
 	if (isBlockAction) {
232 269
 		const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i);
233 270
 		blockId = blockMatch?.[1];
234
-		blockContent = remainder.match(/(?:为|改为|内容为|插入)[::]?\s*(.+)$/)?.[1];
235
-		remainder = remainder.replace(blockId || '', '').replace(/(?:为|改为|内容为|插入)[::]?\s*.+$/, '').trim();
271
+		blockContent = remainder.match(/(?:为|改为|内容为|插入|:|:)\s*(.+)$/)?.[1];
272
+		remainder = remainder.replace(blockId || '', '').replace(/(?:为|改为|内容为|插入|:|:)\s*.+$/, '').trim();
236 273
 		const parsedReference = parseBlockReference(remainder);
237 274
 		blockReference = parsedReference.reference;
238 275
 		remainder = parsedReference.remainder;
@@ -267,7 +304,10 @@ const parseLocalDocumentCommand = async (content: string): Promise<
267 304
 	const input: Record<string, unknown> = toolName === 'download_export_record'
268 305
 		? { recordId: candidate.recordId || candidate.documentId }
269 306
 		: { documentId: candidate.documentId };
270
-	if (blockId) input.blockId = blockId;
307
+	if (blockId) {
308
+		if (toolName === 'insert_block') input.afterBlockId = blockId;
309
+		else input.blockId = blockId;
310
+	}
271 311
 	if (toolName === 'update_block' && blockContent) input.content = blockContent;
272 312
 	if (toolName === 'insert_block' && blockContent) {
273 313
 		input.type = 'paragraph';

+ 1 - 14
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 || '/api/v1/ai/workflow',
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,10 +129,6 @@ export const triggerDocumentWorkflow = async (
137 129
   try {
138 130
     const config = getWorkflowConfig();
139 131
 
140
-    if (!config.apiUrl || !config.apiKey) {
141
-      return { content: '工作流未配置,无法生成文档。请检查前端环境配置。' };
142
-    }
143
-
144 132
     // ⭐ 重要: 添加时间戳避免工作流缓存
145 133
     // 每次调用都会生成唯一的chatId,但sessionId保持不变
146 134
     // 这样工作流会为每个请求生成新的文档,但它们都关联到同一个sessionId
@@ -156,7 +144,6 @@ export const triggerDocumentWorkflow = async (
156 144
       method: 'POST',
157 145
       headers: {
158 146
         'Content-Type': 'application/json',
159
-        ...getAuthorizationHeaders(config.apiKey),
160 147
       },
161 148
       body: JSON.stringify({
162 149
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存