From 8c3641a905ce9c2131b3c23e5a0f5945d637e816 Mon Sep 17 00:00:00 2001 From: Ethan-Zhang Date: Fri, 15 Aug 2025 09:38:52 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=99=A8&=E5=8F=98=E9=87=8F=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AE=9A=E4=B9=89=E6=96=87=E4=BB=B6=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=92=8C=E6=96=87=E4=BB=B6=E6=95=B0=E7=BB=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?&=E5=AF=B9=E8=AF=9D=E5=8F=98=E9=87=8F=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=B8=8E=E9=99=84=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.micro | 2 + .env.tmp | 5 + env.d.ts | 14 + src/apis/index.ts | 2 + src/apis/paths/README-file-upload.md | 173 +++ src/apis/paths/conversation.ts | 88 ++ src/apis/paths/document.ts | 33 + src/apis/paths/index.ts | 1 + src/apis/server.ts | 169 ++- src/assets/svgs/audio.svg | 3 + src/assets/svgs/document.svg | 3 + src/assets/svgs/image.svg | 3 + src/assets/svgs/other-file.svg | 3 + src/assets/svgs/video.svg | 3 + src/components/VariableChooser.vue | 68 +- src/components/VariableRichTextEditor.vue | 123 +- src/components/VariableSelector.vue | 84 +- src/components/dialoguePanel/DialogueFlow.vue | 8 +- .../dialoguePanel/DialoguePanel.vue | 122 +- .../dialoguePanel/FileAttachment.vue | 198 +++ src/components/dialoguePanel/FlowCode.vue | 262 +++- src/components/icons/AttachmentIcon.vue | 9 + src/components/icons/MultipleFilesIcon.vue | 11 + src/components/icons/SingleFileIcon.vue | 31 + src/store/conversation.ts | 130 +- src/utils/tools.ts | 28 + src/views/chat/index.vue | 96 +- src/views/createapp/components/DebugApp.vue | 219 +++- src/views/createapp/components/types.ts | 11 + src/views/createapp/components/workFlow.vue | 129 +- .../workFlowConfig/DebugVariablePanel.vue | 1036 ++++++++++------ .../workFlowConfig/DirectReplyDrawer.vue | 273 +++- .../workFlowConfig/FileExtractorDrawer.vue | 471 +++++++ .../workFlowConfig/NodeListPanel.vue | 4 + .../VariableBasedStartNodeDrawer.vue | 1105 +++++++++++++++-- .../components/workFlowConfig/useDnD.js | 17 +- .../createapp/components/workFlowDebug.vue | 50 +- .../dialogue/components/DialogueSession.vue | 132 +- .../components/DialogueVariablePanel.vue | 1005 ++++++++++++--- vite.config.ts | 8 + 40 files changed, 5230 insertions(+), 902 deletions(-) create mode 100644 .env.tmp create mode 100644 src/apis/paths/README-file-upload.md create mode 100644 src/apis/paths/document.ts create mode 100644 src/assets/svgs/audio.svg create mode 100644 src/assets/svgs/document.svg create mode 100644 src/assets/svgs/image.svg create mode 100644 src/assets/svgs/other-file.svg create mode 100644 src/assets/svgs/video.svg create mode 100644 src/components/dialoguePanel/FileAttachment.vue create mode 100644 src/components/icons/AttachmentIcon.vue create mode 100644 src/components/icons/MultipleFilesIcon.vue create mode 100644 src/components/icons/SingleFileIcon.vue create mode 100644 src/views/createapp/components/workFlowConfig/FileExtractorDrawer.vue diff --git a/.env.micro b/.env.micro index 79b12e9..d9d530c 100755 --- a/.env.micro +++ b/.env.micro @@ -1,3 +1,5 @@ VITE_BASE_URL=/copilot/ VITE_MICRO_ROUTER_URL=/eulercopilot/ VITE_BASE_PROXY_URL=http://10.211.55.10:8002 + +VITE_WITCHAIND_PROXY_URL=http://10.211.55.10:9988 \ No newline at end of file diff --git a/.env.tmp b/.env.tmp new file mode 100644 index 0000000..5f45056 --- /dev/null +++ b/.env.tmp @@ -0,0 +1,5 @@ +VITE_BASE_URL=/copilot/ +VITE_MICRO_ROUTER_URL=/eulercopilot/ +VITE_BASE_PROXY_URL=http://10.211.55.10:8002 + +VITE_WITCHAIND_PROXY_URL=http://10.211.55.10:9988 diff --git a/env.d.ts b/env.d.ts index 43df7b9..22da69e 100644 --- a/env.d.ts +++ b/env.d.ts @@ -8,6 +8,18 @@ // PURPOSE. // See the Mulan PSL v2 for more details. /// + +declare module '*.svg' { + const content: string + export default content +} + +declare module '*.svg?component' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + declare interface Window { onHtmlEventDispatch: any; eulercopilot: any; @@ -38,4 +50,6 @@ declare interface ImportMetaEnv { readonly VITE_MAIL_URL: string; readonly VITE_BULLETIN_URL: string; readonly VITE_HISS_URL: string; + + readonly VITE_WITCHAIND_PROXY_URL: string; } diff --git a/src/apis/index.ts b/src/apis/index.ts index 9791c92..f6e27f0 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -18,6 +18,7 @@ import { modelApi, mcpApi, llmApi, + documentApi, } from './paths'; import { workFlowApi } from './workFlow'; import { appCenterApi, promptApi, kbApi } from './appCenter'; @@ -35,6 +36,7 @@ export const api = { ...modelApi, ...mcpApi, ...llmApi, + ...documentApi, ...promptApi, ...kbApi, }; diff --git a/src/apis/paths/README-file-upload.md b/src/apis/paths/README-file-upload.md new file mode 100644 index 0000000..9dbdf5c --- /dev/null +++ b/src/apis/paths/README-file-upload.md @@ -0,0 +1,173 @@ +# 文件上传API使用指南 + +## 概述 + +该项目提供了两种方式来处理文件类型变量的上传: + +1. **查询参数方式**(推荐)- 符合当前后端接口设计 +2. **Form字段方式**(备用方案)- 适用于后端修改为使用Form()注解的情况 + +## API接口说明 + +### 1. 查询参数方式(当前使用) + +```typescript +export const uploadFilesForVariable = ( + formData: FormData, + sessionId: string, + varName: string, + varType: string, + scope: string = 'conversation' +) +``` + +**特点:** +- `scope`、`var_name`、`var_type` 作为URL查询参数传递 +- 文件通过FormData传递 +- 符合当前后端接口设计:`scope: str = "system"` + +**请求示例:** +``` +POST /api/document/{conversation_id}?scope=conversation&var_name=myFile&var_type=file +Content-Type: multipart/form-data + +[FormData with files] +``` + +### 2. Form字段方式(备用方案) + +```typescript +export const uploadFilesForVariableWithFormFields = ( + files: File | File[], + sessionId: string, + varName: string, + varType: string, + scope: string = 'conversation' +) +``` + +**特点:** +- 所有参数都作为FormData字段传递 +- 适用于后端使用`Form()`注解的情况 + +**请求示例:** +``` +POST /api/document/{conversation_id} +Content-Type: multipart/form-data + +------WebKitFormBoundary +Content-Disposition: form-data; name="documents"; filename="file.pdf" +Content-Type: application/pdf + +[文件内容] +------WebKitFormBoundary +Content-Disposition: form-data; name="scope" + +conversation +------WebKitFormBoundary +Content-Disposition: form-data; name="var_name" + +myFile +------WebKitFormBoundary +Content-Disposition: form-data; name="var_type" + +file +------WebKitFormBoundary-- +``` + +## 使用配置 + +在组件中通过 `USE_FORM_FIELDS` 常量来控制使用哪种方式: + +```typescript +// 配置:选择使用查询参数还是Form字段方式 +const USE_FORM_FIELDS = false // true: 使用Form字段, false: 使用查询参数(推荐) +``` + +## 后端兼容性 + +### 当前后端设计 + +```python +async def document_upload( + conversation_id: str, + documents: Annotated[list[UploadFile], File(...)], + scope: str = "system", # 默认参数 = 查询参数 + var_name: Optional[str] = None, # 默认参数 = 查询参数 + var_type: Optional[str] = None, # 默认参数 = 查询参数 +) +``` + +### 如果后端改为Form字段 + +```python +async def document_upload( + conversation_id: str, + documents: Annotated[list[UploadFile], File(...)], + scope: Annotated[str, Form()] = "system", # Form字段 + var_name: Annotated[Optional[str], Form()] = None, # Form字段 + var_type: Annotated[Optional[str], Form()] = None, # Form字段 +) +``` + +## FastAPI Form处理最佳实践 + +### 1. 混合文件和表单数据 + +当需要同时处理文件和表单数据时: + +```python +from fastapi import FastAPI, Form, File, UploadFile + +@app.post("/upload/") +async def upload_file( + file: UploadFile = File(...), + scope: str = Form(...), # 必须使用Form() + var_name: str = Form(...), # 必须使用Form() + var_type: str = Form(...) # 必须使用Form() +): + return {"filename": file.filename, "scope": scope} +``` + +### 2. 前端FormData构造 + +```javascript +const formData = new FormData(); + +// 添加文件 +formData.append('documents', file); + +// 添加表单字段 +formData.append('scope', 'conversation'); +formData.append('var_name', 'myFile'); +formData.append('var_type', 'file'); + +// 发送请求 +fetch('/api/document/123', { + method: 'POST', + body: formData, + // 不要手动设置Content-Type,让浏览器自动设置 +}); +``` + +### 3. 注意事项 + +1. **Content-Type**: 不要手动设置`Content-Type: multipart/form-data`,让浏览器自动设置边界 +2. **参数类型**: Form字段只能是简单类型(string, number, boolean) +3. **文件数组**: 多个文件使用相同的字段名`documents` +4. **后端兼容**: 确保前端发送方式与后端接收方式匹配 + +## 切换指南 + +如果需要从查询参数方式切换到Form字段方式: + +1. 修改后端接口,添加`Form()`注解 +2. 修改前端配置:`USE_FORM_FIELDS = true` +3. 测试文件上传功能 + +## 调试建议 + +1. 使用浏览器开发者工具查看Network请求 +2. 检查Content-Type和请求体格式 +3. 对比后端接口期望的参数格式 +4. 查看后端日志确认参数接收情况 \ No newline at end of file diff --git a/src/apis/paths/conversation.ts b/src/apis/paths/conversation.ts index 8c3057b..1527ebb 100644 --- a/src/apis/paths/conversation.ts +++ b/src/apis/paths/conversation.ts @@ -257,6 +257,92 @@ export const uploadFiles = ( ); }; +// 新增:专门用于变量文件上传的API +export const uploadFilesForVariable = ( + formData, + sessionId, + varName, + varType, + scope = 'conversation', + flowId?: string +): Promise< + [ + any, + ( + | FcResponse<{ + documents: Array; + }> + | undefined + ), + ] +> => { + // 方式1:URL查询参数(推荐,符合后端接口定义) + const params = new URLSearchParams({ + scope, + var_name: varName, + var_type: varType + }); + + // 如果提供了flowId,添加到查询参数中 + if (flowId) { + params.append('flow_id', flowId); + } + + return post( + `/api/document/${sessionId}?${params.toString()}`, + formData, + {}, + { + 'Content-Type': 'multipart/form-data', + }, + ); +}; + +// 新增:支持Form字段方式的文件上传API(备用方案) +export const uploadFilesForVariableWithFormFields = ( + files, + sessionId, + varName, + varType, + scope = 'conversation' +): Promise< + [ + any, + ( + | FcResponse<{ + documents: Array; + }> + | undefined + ), + ] +> => { + // 方式2:Form字段方式 + const formData = new FormData(); + + // 添加文件 + if (Array.isArray(files)) { + files.forEach(file => { + formData.append('documents', file); + }); + } else { + formData.append('documents', files); + } + + // 添加变量相关参数作为Form字段 + formData.append('scope', scope); + formData.append('var_name', varName); + formData.append('var_type', varType); + + return post( + `/api/document/${sessionId}`, + formData as any, + {}, + { + 'Content-Type': 'multipart/form-data', + }, + ); +}; + export const deleteUploadedFile = ( documentId: any, ): Promise<[any, FcResponse | undefined]> => { @@ -275,5 +361,7 @@ export const sessionApi = { stopGeneration: stopGeneration, getUploadFiles, uploadFiles, + uploadFilesForVariable, + uploadFilesForVariableWithFormFields, deleteUploadedFile, }; diff --git a/src/apis/paths/document.ts b/src/apis/paths/document.ts new file mode 100644 index 0000000..368f28f --- /dev/null +++ b/src/apis/paths/document.ts @@ -0,0 +1,33 @@ +// Copyright (c) Huawei Technologies Co., Ltd. 2023-2025. All rights reserved. +// licensed under the Mulan PSL v2. +// You can use this software according to the terms and conditions of the Mulan PSL v2. +// You may obtain a copy of Mulan PSL v2 at: +// http://license.coscl.org.cn/MulanPSL2 +// THIS SOFTWARE IS PROVIDED ON AN 'AS IS' BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +// PURPOSE. +// See the Mulan PSL v2 for more details. +import { witchainDGet } from 'src/apis/server'; +import type { FcResponse } from 'src/apis/server'; + +/** + * 获取文档解析方法列表 + * @returns Promise<[any, FcResponse | undefined]> + */ +export const getParseMethodList = (): Promise<[any, FcResponse | undefined]> => { + return witchainDGet('/witchainD/other/parse_method'); +}; + +/** + * 文档解析方法接口类型定义 + */ +export interface ParseMethod { + id: string; + name: string; + description?: string; + supported_formats?: string[]; +} + +export const documentApi = { + getParseMethodList, +}; \ No newline at end of file diff --git a/src/apis/paths/index.ts b/src/apis/paths/index.ts index ddf41fa..2b70594 100644 --- a/src/apis/paths/index.ts +++ b/src/apis/paths/index.ts @@ -17,3 +17,4 @@ export * from './api'; export * from './model'; export * from './mcp'; export * from './llm'; +export * from './document'; diff --git a/src/apis/server.ts b/src/apis/server.ts index 6b059ea..cb78356 100644 --- a/src/apis/server.ts +++ b/src/apis/server.ts @@ -18,7 +18,7 @@ import type { } from 'axios'; import { ElMessage } from 'element-plus'; import { successMsg } from 'src/components/Message'; -import { getBaseProxyUrl } from 'src/utils/tools'; +import { getBaseProxyUrl, getWitchainDProxyUrl } from 'src/utils/tools'; import i18n from 'src/i18n'; export interface FcResponse { @@ -44,69 +44,85 @@ if (import.meta.env.MODE === 'electron-production') { }); } -let server: ReturnType; // axios 实例 +let server: ReturnType; // 主API服务 axios 实例 +let witchainDServer: ReturnType; // WitchainD服务 axios 实例 export const initServer = async () => { const baseURL = await getBaseProxyUrl(); + const witchainDURL = await getWitchainDProxyUrl(); + + // 初始化主API服务实例 server = axios.create({ baseURL, - // API 请求的默认前缀 timeout: 60 * 1000, // 请求超时时间 }); + + // 初始化WitchainD服务实例 + witchainDServer = axios.create({ + baseURL: witchainDURL, + timeout: 60 * 1000, // 请求超时时间 + }); + + // 设置拦截器的通用函数 + const setupInterceptors = (axiosInstance: ReturnType) => { + // request interceptor + axiosInstance.interceptors.request.use( + ( + config: InternalAxiosRequestConfig, + ): + | InternalAxiosRequestConfig + | Promise> => { + return handleChangeRequestHeader(config); + }, + (error) => { + return Promise.reject(error); + }, + ); - // request interceptor - server.interceptors.request.use( - ( - config: InternalAxiosRequestConfig, - ): - | InternalAxiosRequestConfig - | Promise> => { - return handleChangeRequestHeader(config); - }, - (error) => { - return Promise.reject(error); - }, - ); - - // response interceptor - server.interceptors.response.use( - (response: AxiosResponse): AxiosResponse | Promise => { - if (response.status !== 200) { - ElMessage({ - showClose: true, - message: response.statusText, - icon: IconError, - customClass: 'o-message--error', - duration: 3000, - }); - return Promise.reject(new Error(response.statusText)); - } - return Promise.resolve(response); - }, - async (error: AxiosError) => { - if ( - error.status !== 401 && - error.status !== 403 && - error.status !== 409 - ) { - ElMessage({ - showClose: true, - message: - ((error as any)?.response?.data?.message as string) || - error.message, - icon: IconError, - customClass: 'o-message--error', - duration: 3000, - }); - } - if (error.status === 409) { - // 处理错误码为409的情况 - successMsg(i18n.global.t('history.latestConversation')); - return Promise.reject(error as any); - } - return await handleStatusError(error); - }, - ); + // response interceptor + axiosInstance.interceptors.response.use( + (response: AxiosResponse): AxiosResponse | Promise => { + if (response.status !== 200) { + ElMessage({ + showClose: true, + message: response.statusText, + icon: IconError, + customClass: 'o-message--error', + duration: 3000, + }); + return Promise.reject(new Error(response.statusText)); + } + return Promise.resolve(response); + }, + async (error: AxiosError) => { + if ( + error.status !== 401 && + error.status !== 403 && + error.status !== 409 + ) { + ElMessage({ + showClose: true, + message: + ((error as any)?.response?.data?.message as string) || + error.message, + icon: IconError, + customClass: 'o-message--error', + duration: 3000, + }); + } + if (error.status === 409) { + // 处理错误码为409的情况 + successMsg(i18n.global.t('history.latestConversation')); + return Promise.reject(error as any); + } + return await handleStatusError(error); + }, + ); + }; + + // 为两个服务实例设置拦截器 + setupInterceptors(server); + setupInterceptors(witchainDServer); }; /** * request with get @@ -188,3 +204,44 @@ export const del = async ( return [error as IError, undefined]; } }; + +// WitchainD 服务专用方法 +/** + * WitchainD service - request with get + */ +export const witchainDGet = async ( + url: string, + params: IAnyObj = {}, +): Promise<[IError, FcResponse | undefined]> => { + try { + const result = await witchainDServer.get(url, { params: params }); + return [null, result.data as FcResponse]; + } catch (error) { + return [error as IError, undefined]; + } +}; + +/** + * WitchainD service - request with post + */ +export const witchainDPost = async ( + url: string, + data: IAnyObj = {}, + params: IAnyObj = {}, + headers: IAnyObj = {}, +): Promise<[IError, FcResponse | undefined]> => { + try { + const result = await witchainDServer.post(url, data, { + params: params, + headers: headers as AxiosHeaders, + }); + return [null, result.data as FcResponse]; + } catch (error) { + return [error as IError, undefined]; + } +}; + +/** + * 获取 WitchainD axios 实例(用于更复杂的请求) + */ +export const getWitchainDServer = () => witchainDServer; diff --git a/src/assets/svgs/audio.svg b/src/assets/svgs/audio.svg new file mode 100644 index 0000000..f5222ac --- /dev/null +++ b/src/assets/svgs/audio.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/document.svg b/src/assets/svgs/document.svg new file mode 100644 index 0000000..a85fc4e --- /dev/null +++ b/src/assets/svgs/document.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/image.svg b/src/assets/svgs/image.svg new file mode 100644 index 0000000..7e43f54 --- /dev/null +++ b/src/assets/svgs/image.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/other-file.svg b/src/assets/svgs/other-file.svg new file mode 100644 index 0000000..6043296 --- /dev/null +++ b/src/assets/svgs/other-file.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/video.svg b/src/assets/svgs/video.svg new file mode 100644 index 0000000..8144b18 --- /dev/null +++ b/src/assets/svgs/video.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/VariableChooser.vue b/src/components/VariableChooser.vue index 3f0f3bc..2cc4c94 100644 --- a/src/components/VariableChooser.vue +++ b/src/components/VariableChooser.vue @@ -30,6 +30,8 @@ {{ modelValue || getVariableDisplayName(selectedVariable) }} + + {{ getVariableTypeDisplay(selectedVariable.var_type) }}