Просмотр исходного кода

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

Zhang Yice 1 месяц назад
Родитель
Сommit
33ca8672f5

+ 2 - 4
.env

@@ -13,10 +13,8 @@ VITE_APP_TITLE=AX Document Editor
13
 VITE_DEBUG=false
13
 VITE_DEBUG=false
14
 
14
 
15
 # External AI services are called directly by the browser.
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
 # WebMCP Bridge 连接配置
19
 # WebMCP Bridge 连接配置
22
 VITE_WEBMCP_BRIDGE_TOKEN=AXWebMCP-04fb7ebd989246b28ca63a49e0efd2c0a27cc8c89e2241fda22b8b0c42c744a1
20
 VITE_WEBMCP_BRIDGE_TOKEN=AXWebMCP-04fb7ebd989246b28ca63a49e0efd2c0a27cc8c89e2241fda22b8b0c42c744a1

+ 1 - 7
.env.example

@@ -14,12 +14,6 @@ VITE_APP_TITLE=AX Document Editor
14
 # Default: false
14
 # Default: false
15
 VITE_DEBUG=false
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
 VITE_AI_PROXY_URL=/api/v1/ai/chat
18
 VITE_AI_PROXY_URL=/api/v1/ai/chat
25
 VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow
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
 **API 集成**:
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
 | `VITE_API_BASE_URL` | 后端 API 地址 | `http://192.168.0.195:8000` |
408
 | `VITE_API_BASE_URL` | 后端 API 地址 | `http://192.168.0.195:8000` |
408
 | `VITE_APP_TITLE` | 应用标题 | `AX Document Editor` |
409
 | `VITE_APP_TITLE` | 应用标题 | `AX Document Editor` |
409
 | `VITE_DEBUG` | 调试模式 | `false` |
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
 ```text
222
 ```text
223
 浏览器 -> 自有后端 -> AI / Workflow 服务
223
 浏览器 -> 自有后端 -> AI / Workflow 服务
@@ -239,10 +239,8 @@ npm audit --omit=dev
239
 | 变量                    | 用途                            |
239
 | 变量                    | 用途                            |
240
 | ----------------------- | ------------------------------- |
240
 | ----------------------- | ------------------------------- |
241
 | `VITE_API_BASE_URL`     | 后端 API 地址                   |
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
 | `VITE_DEBUG`            | 是否输出 API 调试日志           |
244
 | `VITE_DEBUG`            | 是否输出 API 调试日志           |
247
 
245
 
248
 默认后端地址由服务层提供,仅适合本地开发。部署时应显式配置 `VITE_API_BASE_URL`。
246
 默认后端地址由服务层提供,仅适合本地开发。部署时应显式配置 `VITE_API_BASE_URL`。

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

@@ -90,7 +90,7 @@ MessageItem / ExportRecordList
90
 
90
 
91
 `richTextConverter` 在生成 HTML 前会转义文本,并限制颜色和字体值格式。富文本解析使用临时 DOM,不应直接将未过滤的用户 HTML 传给 `dangerouslySetInnerHTML`。
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
 ## 8. 性能决策
95
 ## 8. 性能决策
96
 
96
 

+ 1 - 16
src/services/aiChatService.ts

@@ -71,23 +71,17 @@ export interface ChatCompletionResponse {
71
  */
71
  */
72
 interface AIChatConfig {
72
 interface AIChatConfig {
73
   apiUrl: string;
73
   apiUrl: string;
74
-  apiKey: string;
75
 }
74
 }
76
 
75
 
77
 /**
76
 /**
78
  * Get AI Chat configuration from environment variables
77
  * Get AI Chat configuration from environment variables
79
  */
78
  */
80
 const getAIChatConfig = (): AIChatConfig => {
79
 const getAIChatConfig = (): AIChatConfig => {
81
-  const isDevelopment = import.meta.env.DEV;
82
   return {
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
  * Send a chat completion request to AI platform
86
  * Send a chat completion request to AI platform
93
  *
87
  *
@@ -101,18 +95,10 @@ export const sendChatCompletion = async (
101
   try {
95
   try {
102
     const config = getAIChatConfig();
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
     const response = await fetchWithTimeout(config.apiUrl, {
98
     const response = await fetchWithTimeout(config.apiUrl, {
112
       method: 'POST',
99
       method: 'POST',
113
       headers: {
100
       headers: {
114
         'Content-Type': 'application/json',
101
         'Content-Type': 'application/json',
115
-        ...getAuthorizationHeaders(config.apiKey),
116
       },
102
       },
117
       body: JSON.stringify({
103
       body: JSON.stringify({
118
         chatId: request.chatId,
104
         chatId: request.chatId,
@@ -176,7 +162,6 @@ export const sendStreamingChatCompletion = async (
176
       method: 'POST',
162
       method: 'POST',
177
       headers: {
163
       headers: {
178
         'Content-Type': 'application/json',
164
         'Content-Type': 'application/json',
179
-        ...getAuthorizationHeaders(config.apiKey),
180
       },
165
       },
181
       body: JSON.stringify({
166
       body: JSON.stringify({
182
         ...request,
167
         ...request,

+ 5 - 1
src/services/exportService.ts

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

+ 62 - 22
src/services/webMcpAgentService.ts

@@ -8,6 +8,7 @@ import { getWebMcpTools } from './webMcpService';
8
 import { translateWebMcpInput } from '../share/webmcp';
8
 import { translateWebMcpInput } from '../share/webmcp';
9
 import { listDocuments } from './documentService';
9
 import { listDocuments } from './documentService';
10
 import { blockService } from './blockService';
10
 import { blockService } from './blockService';
11
+import { useEditorStore } from '../stores/editorStore';
11
 
12
 
12
 export interface WebMcpChatResult {
13
 export interface WebMcpChatResult {
13
 	handled: boolean;
14
 	handled: boolean;
@@ -52,7 +53,11 @@ const stringifyResult = (result: WebMcpToolResult): string => {
52
 };
53
 };
53
 
54
 
54
 const getDocumentCandidates = async (): Promise<ExportCandidate[]> => {
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
 		return documentCandidatesCache.value;
61
 		return documentCandidatesCache.value;
57
 	}
62
 	}
58
 	if (documentCandidatesRequest) return documentCandidatesRequest;
63
 	if (documentCandidatesRequest) return documentCandidatesRequest;
@@ -68,6 +73,34 @@ const getDocumentCandidates = async (): Promise<ExportCandidate[]> => {
68
 
73
 
69
 const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
74
 const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
70
 	const unique = new Map<string, ExportCandidate>();
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
 	try {
104
 	try {
72
 		const documentResult = await listDocuments({
105
 		const documentResult = await listDocuments({
73
 			userId: 'default-user',
106
 			userId: 'default-user',
@@ -82,23 +115,13 @@ const loadDocumentCandidates = async (): Promise<ExportCandidate[]> => {
82
 				? (detail.data as { blocks?: unknown }).blocks
115
 				? (detail.data as { blocks?: unknown }).blocks
83
 				: undefined;
116
 				: undefined;
84
 			if (!Array.isArray(blocks)) return;
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
 	} catch (error) {
126
 	} catch (error) {
104
 		console.warn('[WebMCP] 获取文档名称失败,将继续使用导出记录:', error);
127
 		console.warn('[WebMCP] 获取文档名称失败,将继续使用导出记录:', error);
@@ -142,10 +165,24 @@ const findDocumentCandidate = (
142
 	candidates: ExportCandidate[]
165
 	candidates: ExportCandidate[]
143
 ): { candidate?: ExportCandidate; ambiguous: boolean } => {
166
 ): { candidate?: ExportCandidate; ambiguous: boolean } => {
144
 	const normalizedTitle = normalizeDocumentText(title);
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
 	const matches = candidates.filter((candidate) => {
179
 	const matches = candidates.filter((candidate) => {
146
 		const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(normalizeDocumentText);
180
 		const candidateTitles = [candidate.title, ...(candidate.aliases || [])].map(normalizeDocumentText);
147
 		return candidate.documentId === title || candidate.recordId === title || candidateTitles.some((candidateTitle) =>
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
 	return { candidate: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
188
 	return { candidate: matches.length === 1 ? matches[0] : undefined, ambiguous: matches.length > 1 };
@@ -231,8 +268,8 @@ const parseLocalDocumentCommand = async (content: string): Promise<
231
 	if (isBlockAction) {
268
 	if (isBlockAction) {
232
 		const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i);
269
 		const blockMatch = remainder.match(/\b(block-[\w-]+)\b/i);
233
 		blockId = blockMatch?.[1];
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
 		const parsedReference = parseBlockReference(remainder);
273
 		const parsedReference = parseBlockReference(remainder);
237
 		blockReference = parsedReference.reference;
274
 		blockReference = parsedReference.reference;
238
 		remainder = parsedReference.remainder;
275
 		remainder = parsedReference.remainder;
@@ -267,7 +304,10 @@ const parseLocalDocumentCommand = async (content: string): Promise<
267
 	const input: Record<string, unknown> = toolName === 'download_export_record'
304
 	const input: Record<string, unknown> = toolName === 'download_export_record'
268
 		? { recordId: candidate.recordId || candidate.documentId }
305
 		? { recordId: candidate.recordId || candidate.documentId }
269
 		: { documentId: candidate.documentId };
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
 	if (toolName === 'update_block' && blockContent) input.content = blockContent;
311
 	if (toolName === 'update_block' && blockContent) input.content = blockContent;
272
 	if (toolName === 'insert_block' && blockContent) {
312
 	if (toolName === 'insert_block' && blockContent) {
273
 		input.type = 'paragraph';
313
 		input.type = 'paragraph';

+ 1 - 14
src/services/workflowService.ts

@@ -74,25 +74,17 @@ interface WorkflowResponse {
74
  */
74
  */
75
 interface WorkflowConfig {
75
 interface WorkflowConfig {
76
   apiUrl: string;
76
   apiUrl: string;
77
-  apiKey: string;
78
 }
77
 }
79
 
78
 
80
 /**
79
 /**
81
  * Get Workflow configuration from environment variables
80
  * Get Workflow configuration from environment variables
82
  */
81
  */
83
 const getWorkflowConfig = (): WorkflowConfig => {
82
 const getWorkflowConfig = (): WorkflowConfig => {
84
-  const isDevelopment = import.meta.env.DEV;
85
   return {
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
  * Check if user input should trigger document generation workflow
89
  * Check if user input should trigger document generation workflow
98
  *
90
  *
@@ -137,10 +129,6 @@ export const triggerDocumentWorkflow = async (
137
   try {
129
   try {
138
     const config = getWorkflowConfig();
130
     const config = getWorkflowConfig();
139
 
131
 
140
-    if (!config.apiUrl || !config.apiKey) {
141
-      return { content: '工作流未配置,无法生成文档。请检查前端环境配置。' };
142
-    }
143
-
144
     // ⭐ 重要: 添加时间戳避免工作流缓存
132
     // ⭐ 重要: 添加时间戳避免工作流缓存
145
     // 每次调用都会生成唯一的chatId,但sessionId保持不变
133
     // 每次调用都会生成唯一的chatId,但sessionId保持不变
146
     // 这样工作流会为每个请求生成新的文档,但它们都关联到同一个sessionId
134
     // 这样工作流会为每个请求生成新的文档,但它们都关联到同一个sessionId
@@ -156,7 +144,6 @@ export const triggerDocumentWorkflow = async (
156
       method: 'POST',
144
       method: 'POST',
157
       headers: {
145
       headers: {
158
         'Content-Type': 'application/json',
146
         'Content-Type': 'application/json',
159
-        ...getAuthorizationHeaders(config.apiKey),
160
       },
147
       },
161
       body: JSON.stringify({
148
       body: JSON.stringify({
162
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存
149
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存