diff --git a/ui/package.json b/ui/package.json index a3d3bf3eff9..e8df7319bbd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,11 +17,17 @@ }, "dependencies": { "@antv/layout": "^2.0.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lint": "^6.9.7", + "@codemirror/state": "^6.7.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.9", "@element-plus/icons-vue": "^2.3.2", "@he-tree/vue": "^2.10.5", "@logicflow/core": "^2.2.5", "@logicflow/extension": "^2.3.1", "axios": "^1.19.0", + "codemirror": "^6.0.2", "dingtalk-jsapi": "^3.2.9", "element-plus": "^2.14.2", "jsencrypt": "^3.5.4", @@ -29,6 +35,7 @@ "nprogress": "^0.2.0", "pinia": "^3.0.4", "vue": "^3.5.38", + "vue-codemirror": "^6.1.1", "vue-demi": "^0.14.10", "vue-router": "^5.1.0" }, diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index 1b02d03d604..0c3696616db 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -94,6 +94,7 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文 `putRole` 和 `deleteKnowledge`。 - 前缀与实际请求方法保持一致:查询使用 `get`,创建和业务动作使用 `post`,完整更新使用 `put`,删除使用 `delete`。局部更新接口真实采用 PATCH 时使用 `patch`。 +- 文件导出是直接触发浏览器下载的业务动作,使用 `exportXxx` 命名,例如 `exportTool`。 - HTTP 方法前缀后必须带有明确的业务名称,不导出 `get`、`post`、`list`、`detail`、`login` 或 `logout` 等缺少请求方式或业务含义的名称。 - 函数名不追加 `Api` 后缀,所属业务域由目录和文件名表达。 @@ -104,9 +105,10 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文 - Admin Router 与请求客户端直接读取 `window.MaxKB` 运行时路径配置;`Window` 和 `MaxKBRuntimeConfig` 的全局类型统一声明在根目录 `env.d.ts`。 - Admin 普通 JSON 请求使用 Axios;`request.ts` 导出 Axios 实例以及 `promise`、`get`、 - `post`、`put`、`del` 请求封装。 + `post`、`put` 和 `del` 请求封装。 - 正常 JSON 接口返回 `Promise`,请求层负责解包后端 `{ code, message, data }` 响应。 -- 文件下载接口使用 `postBlob` 获取原始 `Blob`,由调用页面负责命名并触发浏览器下载。 +- GET 文件导出使用 `getExportFile`,由请求层统一获取 Blob、解析 `Content-Disposition` 文件名 + 并触发浏览器下载;业务 API 只需传入默认文件名、接口地址和可选请求参数。 - 业务代码通过 `api.method().then(...)` 处理接口成功后的状态变化;通用接口错误由请求层统一 提示,不在调用处重复使用 `try/catch` 或 `.catch()` 提示相同错误。只有业务降级、状态恢复等 非提示类失败处理可以按需保留失败分支。 diff --git a/ui/src/api/admin/core/request.ts b/ui/src/api/admin/core/request.ts index 9f54fdfbd7a..cdeb350a282 100644 --- a/ui/src/api/admin/core/request.ts +++ b/ui/src/api/admin/core/request.ts @@ -1,6 +1,11 @@ /** 提供 Admin API 的 Axios 实例与常用 HTTP 请求封装。 */ -import axios, { AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import axios, { + AxiosHeaders, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios' import router from '@/router/admin' import { useStore } from '@/stores' import type { ApiResponse, LoadingTarget } from './types' @@ -10,6 +15,10 @@ import { MsgError } from '@/utils/message' const DEFAULT_TIMEOUT = 30 * 60 * 1_000 // 30 minutes const ADMIN_BASE_PATH = window.MaxKB?.prefix || import.meta.env.VITE_BASE_PATH || '/admin/' +interface ExportRequestConfig extends AxiosRequestConfig { + skipGlobalErrorMessage?: boolean +} + function setRequestHeaders(config: InternalAxiosRequestConfig) { const { auth, user } = useStore() @@ -48,6 +57,47 @@ function finishLoading(loading?: LoadingTarget) { loading.value = false } +function extractFilename(contentDisposition?: string) { + if (!contentDisposition) { + return undefined + } + + const encodedName = contentDisposition.match(/filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i)?.[1] + const plainName = contentDisposition.match(/filename\s*=\s*(?:"([^"]+)"|([^;]+))/i) + const responseName = encodedName || plainName?.[1] || plainName?.[2] + if (!responseName) { + return undefined + } + + const normalizedName = responseName.trim().replace(/^['"]|['"]$/g, '') + try { + return decodeURIComponent(normalizedName) + } catch { + return normalizedName + } +} + +async function getResponseErrorMessage(error: unknown) { + if (!axios.isAxiosError | Blob | string>(error)) { + return undefined + } + + const responseData = error.response?.data + if (responseData instanceof Blob) { + const text = await responseData.text() + try { + const data = JSON.parse(text) as Partial> + return data.message || text + } catch { + return text + } + } + if (typeof responseData === 'string') { + return responseData + } + return responseData?.message +} + export const request = axios.create({ baseURL: `${ADMIN_BASE_PATH.replace(/\/+$/, '')}/api`, timeout: DEFAULT_TIMEOUT, @@ -68,13 +118,16 @@ request.interceptors.response.use( } return response }, - (error: unknown) => { + async (error: unknown) => { if (!axios.isAxiosError>(error)) { return Promise.reject(error) } const requestUrl = error.config?.url ?? '' const status = error.response?.status + const responseMessage = await getResponseErrorMessage(error) + const skipGlobalErrorMessage = (error.config as ExportRequestConfig | undefined) + ?.skipGlobalErrorMessage if (error.code === 'ECONNABORTED') { MsgError(error.message) @@ -89,10 +142,14 @@ request.interceptors.response.use( router.push({ name: 'login' }) } if (status === 403) { - MsgError(error.response?.data.message || 'No permission to access') + MsgError(responseMessage || 'No permission to access') } - if (error.code !== 'ECONNABORTED' && ![401, 403, 404].includes(status ?? 0)) { - MsgError(error.response?.data.message || error.message) + if ( + error.code !== 'ECONNABORTED' && + ![401, 403, 404].includes(status ?? 0) && + !skipGlobalErrorMessage + ) { + MsgError(responseMessage || error.message) } return Promise.reject(error) @@ -125,6 +182,44 @@ export function get( return promise(request.get>(url, { params, timeout }), loading) } +/** 发送 GET 请求并将 Blob 响应下载为文件。 */ +export async function getExportFile( + fileName: string, + url: string, + params?: RequestParams, + loading?: LoadingTarget, +): Promise { + startLoading(loading) + try { + const response = await request.get(url, { + params, + responseType: 'blob', + skipGlobalErrorMessage: true, + } as ExportRequestConfig) + + if (response.data.type.includes('application/json')) { + const text = await response.data.text() + try { + const data = JSON.parse(text) as Partial> + MsgError(data.message || text) + } catch { + MsgError(text) + } + throw new Error('Response is not a valid file') + } + + const blob = new Blob([response.data], { type: 'application/octet-stream' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = extractFilename(response.headers['content-disposition']) || fileName + link.click() + URL.revokeObjectURL(link.href) + return true + } finally { + finishLoading(loading) + } +} + /** 发送 POST 请求。 */ export function post( url: string, @@ -136,20 +231,6 @@ export function post( return promise(request.post>(url, data, { params, timeout }), loading) } -/** 发送返回文件 Blob 的 POST 请求。 */ -export async function postBlob( - url: string, - data?: TData, - loading?: LoadingTarget, -) { - startLoading(loading) - try { - const response = await request.post(url, data, { responseType: 'blob' }) - return response.data - } finally { - finishLoading(loading) - } -} /** 发送 PUT 请求。 */ export function put( diff --git a/ui/src/api/admin/workspace/folder.ts b/ui/src/api/admin/workspace/folder.ts index c8c9cad1cb7..36d4f6cc9c1 100644 --- a/ui/src/api/admin/workspace/folder.ts +++ b/ui/src/api/admin/workspace/folder.ts @@ -1,5 +1,5 @@ import { del, get, post, put } from '../core/request' -import type { FolderSource, FolderItem, RequestParams, FolderRequest } from '@/api/types' +import type { FolderSource, FolderItem, RequestParams, FolderPayload } from '@/api/types' import { getWorkspaceId } from '@/utils/workspace-context' const getPrefix = (source: FolderSource) => { @@ -13,17 +13,17 @@ const getFolderTree = (source: FolderSource, query?: RequestParams) => { } /** 更新 Workspace 的文件夹。 */ -const putFolder = (folderId: string, source: FolderSource, payload?: FolderRequest) => { - return put(`${getPrefix(source)}/${folderId}`, payload) +const putFolder = (folderId: string, source: FolderSource, payload?: FolderPayload) => { + return put(`${getPrefix(source)}/${folderId}`, payload) } /** 在指定资源模块中创建 Workspace 文件夹。 */ -const postFolder = (source: FolderSource, payload: FolderRequest) => { - return post(getPrefix(source), payload) +const postFolder = (source: FolderSource, payload: FolderPayload) => { + return post(getPrefix(source), payload) } /** 删除指定 Workspace 文件夹及其中的资源。 */ -const deleteFolder = (source: FolderSource, folderId: string) => { +const deleteFolder = (folderId: string, source: FolderSource) => { return del(`${getPrefix(source)}/${folderId}`) } diff --git a/ui/src/api/admin/workspace/model/model-shared.ts b/ui/src/api/admin/workspace/model/model-shared.ts deleted file mode 100644 index 1515985617f..00000000000 --- a/ui/src/api/admin/workspace/model/model-shared.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { get } from '../../core/request' -import type { RequestParams, ModelItem } from '@/api/types' -import { getWorkspaceId } from '@/utils/workspace-context' - -const getPrefix = () => { - const workspaceId = getWorkspaceId() - return `/system/shared/workspace/${workspaceId}` -} - -/** 获取工作空间共享的模型列表。 */ -const getModelList = (query?: RequestParams) => { - return get(`${getPrefix()}/model`, query) -} - -export default { - getModelList, -} diff --git a/ui/src/api/admin/workspace/shared.ts b/ui/src/api/admin/workspace/shared.ts new file mode 100644 index 00000000000..27dbc58a86a --- /dev/null +++ b/ui/src/api/admin/workspace/shared.ts @@ -0,0 +1,24 @@ +import { get } from '../core/request' +import type { ParamsPage, ResponsePage } from '../core/types' +import type { RequestParams, ModelItem, ToolItem } from '@/api/types' +import { getWorkspaceId } from '@/utils/workspace-context' + +const getPrefix = () => { + const workspaceId = getWorkspaceId() + return `/system/shared/workspace/${workspaceId}` +} + +/** 获取工作空间共享的模型列表。 */ +const getModelList = (query?: RequestParams) => { + return get(`${getPrefix()}/model`, query) +} + +/** 获取工作空间共享的工具列表。 */ +const getToolPage = (page: ParamsPage, query?: RequestParams) => { + return get>(`${getPrefix()}/tool/${page.currentPage}/${page.pageSize}`, query) +} + +export default { + getModelList, + getToolPage, +} diff --git a/ui/src/api/admin/workspace/tool/tool.ts b/ui/src/api/admin/workspace/tool/tool.ts index d2bda12e5e1..b4d34cf8352 100644 --- a/ui/src/api/admin/workspace/tool/tool.ts +++ b/ui/src/api/admin/workspace/tool/tool.ts @@ -1,6 +1,12 @@ -import { del, get, post, put } from '../../core/request' +import { del, getExportFile, get, post, put } from '../../core/request' import type { ParamsPage, ResponsePage } from '../../core/types' -import type { RequestParams, ToolPayload, ToolItem } from '@/api/types' +import type { + RequestParams, + ToolDebugPayload, + ToolItem, + ToolPayload, + ToolPylintIssue, +} from '@/api/types' import { getWorkspaceId } from '@/utils/workspace-context' const getPrefix = () => { @@ -10,15 +16,12 @@ const getPrefix = () => { /** 获取工具分页列表。 */ const getToolPage = (page: ParamsPage, query?: RequestParams) => { - return get>( - `${getPrefix()}/${page.currentPage}/${page.pageSize}`, - query, - ) + return get>(`${getPrefix()}/${page.currentPage}/${page.pageSize}`, query) } -/** 更新工作空间工具。 */ -const putTool = (toolId: string, payload: ToolPayload) => { - return put(`${getPrefix()}/${toolId}`, payload) +/** 删除工作空间工具。 */ +const deleteTool = (toolId: string) => { + return del(`${getPrefix()}/${toolId}`) } /** 创建工作空间工具。 */ @@ -26,14 +29,42 @@ const postTool = (payload: ToolPayload) => { return post(getPrefix(), payload) } +/** 更新工作空间工具。 */ +const putTool = (toolId: string, payload: ToolPayload) => { + return put(`${getPrefix()}/${toolId}`, payload) +} + /** 获取工具详情。 */ const getToolDetail = (toolId: string) => { return get(`${getPrefix()}/${toolId}`) } -/** 删除工作空间工具。 */ -const deleteTool = (toolId: string) => { - return del(`${getPrefix()}/${toolId}`) +/** 导入工具文件并创建工作空间工具。 */ +const postToolImport = (file: File, folderId: string) => { + const payload = new FormData() + payload.append('file', file) + payload.append('folder_id', folderId) + return post(`${getPrefix()}/import`, payload) +} + +/** 导出工作空间工具文件。 */ +const exportTool = (toolId: string, toolName: string) => { + return getExportFile(`${toolName}.tool`, `${getPrefix()}/${toolId}/export`) +} + +/** 检查工作空间工具的 Python 代码。 */ +const postToolPylint = (code: string) => { + return post<{ code: string }, ToolPylintIssue[]>(`${getPrefix()}/pylint`, { code }) +} + +// const generateCode = (data: any) => { +// const p = (window.MaxKB?.prefix ? window.MaxKB?.prefix : '/admin') + '/api' +// return postStream(`${p}${getPrefix()}/generate_code`, data) +// } + +/** 调试自定义工具代码并返回运行结果。 */ +const postToolDebug = (payload: ToolDebugPayload) => { + return post(`${getPrefix()}/debug`, payload) } /** 测试工具配置是否可连接。 */ @@ -58,10 +89,14 @@ const putBatchMoveTools = (toolIds: string[], folderId: string) => { export default { getToolPage, + exportTool, deleteTool, getToolDetail, postTool, + postToolDebug, + postToolImport, + postToolPylint, postToolTestConnection, putBatchDeleteTools, putBatchMoveTools, diff --git a/ui/src/api/types/common.ts b/ui/src/api/types/common.ts index 25111c4da59..ee58c403725 100644 --- a/ui/src/api/types/common.ts +++ b/ui/src/api/types/common.ts @@ -15,3 +15,11 @@ export interface OptionItem model_name: string + model_params_form?: DynamicFormField[] model_type: string name: string provider: string } export interface DynamicFormField { + attrs?: Record default_value?: unknown field: string - label: string + input_type?: string + label: string | DynamicFormLabel + option_list?: Record[] + relation_show_field_dict?: Record required?: boolean + text_field?: string + value_field?: string [key: string]: unknown } +export interface DynamicFormLabel { + attrs?: { + tooltip?: string + [key: string]: unknown + } + label: string + type?: string +} + export interface BaseModelOption { desc?: string model_type: string diff --git a/ui/src/api/types/tool.ts b/ui/src/api/types/tool.ts index 3e47a62e5a2..cb80f952bc5 100644 --- a/ui/src/api/types/tool.ts +++ b/ui/src/api/types/tool.ts @@ -5,6 +5,54 @@ import { TOOL_SCOPE, TOOL_TYPE } from '@/api/enums' export type ToolScope = (typeof TOOL_SCOPE)[keyof typeof TOOL_SCOPE] export type ToolType = (typeof TOOL_TYPE)[keyof typeof TOOL_TYPE] +export type ToolInputFieldType = 'array' | 'dict' | 'float' | 'int' | 'string' +export type ToolInputFieldSource = 'custom' | 'reference' + +export interface ToolInputField { + desc?: string + is_required: boolean + name: string + source: ToolInputFieldSource + type: ToolInputFieldType +} + +export interface ToolInitField { + attrs?: Record + default_value?: unknown + field: string + input_type: string + label: string + option_list?: Record[] + props_info?: Record + required: boolean + show_default_value?: boolean +} + +export interface ToolDebugField extends ToolInputField { + value: string +} + +export interface ToolDebugPayload { + code: string + debug_field_list: ToolDebugField[] + init_field_list: ToolInitField[] + init_params: Record + input_field_list: ToolInputField[] +} + +export interface ToolPylintIssue { + column: number + endColumn: number + endLine: number + line: number + message: string + module: string + obj: string + path: string + symbol: string + type: 'error' | 'warning' +} + export interface ToolItem { code?: string create_time?: string @@ -12,10 +60,11 @@ export interface ToolItem { folder_id?: string icon?: string id: string - init_field_list?: Record[] + init_field_list?: ToolInitField[] init_params?: Record | string | null - input_field_list?: Record[] + input_field_list?: ToolInputField[] is_active: boolean + is_publish?: boolean label?: string | null name: string nick_name?: string | null @@ -34,9 +83,9 @@ export interface ToolPayload { desc?: string | null folder_id?: string | null icon?: string - init_field_list?: Record[] + init_field_list?: ToolInitField[] init_params?: Record | null - input_field_list?: Record[] + input_field_list?: ToolInputField[] is_active?: boolean name?: string scope?: ToolScope diff --git a/ui/src/assets/iconfont.js b/ui/src/assets/iconfont.js index d7458848af2..03897e75ce1 100644 --- a/ui/src/assets/iconfont.js +++ b/ui/src/assets/iconfont.js @@ -1 +1 @@ -window._iconfont_svg_string_5200172='',(l=>{var a=(h=(h=document.getElementsByTagName("script"))[h.length-1]).getAttribute("data-injectcss"),h=h.getAttribute("data-disable-injectsvg");if(!h){var v,o,t,i,c,e=function(a,h){h.parentNode.insertBefore(a,h)};if(a&&!l.__iconfont__svg__cssinject__){l.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}v=function(){var a,h=document.createElement("div");h.innerHTML=l._iconfont_svg_string_5200172,(h=h.getElementsByTagName("svg")[0])&&(h.setAttribute("aria-hidden","true"),h.style.position="absolute",h.style.width=0,h.style.height=0,h.style.overflow="hidden",h=h,(a=document.body).firstChild?e(h,a.firstChild):a.appendChild(h))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(v,0):(o=function(){document.removeEventListener("DOMContentLoaded",o,!1),v()},document.addEventListener("DOMContentLoaded",o,!1)):document.attachEvent&&(t=v,i=l.document,c=!1,m(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,d())})}function d(){c||(c=!0,t())}function m(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(m,50)}d()}})(window); \ No newline at end of file +window._iconfont_svg_string_5200172='',(l=>{var a=(h=(h=document.getElementsByTagName("script"))[h.length-1]).getAttribute("data-injectcss"),h=h.getAttribute("data-disable-injectsvg");if(!h){var o,v,t,i,c,d=function(a,h){h.parentNode.insertBefore(a,h)};if(a&&!l.__iconfont__svg__cssinject__){l.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}o=function(){var a,h=document.createElement("div");h.innerHTML=l._iconfont_svg_string_5200172,(h=h.getElementsByTagName("svg")[0])&&(h.setAttribute("aria-hidden","true"),h.style.position="absolute",h.style.width=0,h.style.height=0,h.style.overflow="hidden",h=h,(a=document.body).firstChild?d(h,a.firstChild):a.appendChild(h))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),o()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(t=o,i=l.document,c=!1,m(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,e())})}function e(){c||(c=!0,t())}function m(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(m,50)}e()}})(window); \ No newline at end of file diff --git a/ui/src/assets/mk_icon_import.svg b/ui/src/assets/mk_icon_import.svg new file mode 100644 index 00000000000..e377fe46e40 --- /dev/null +++ b/ui/src/assets/mk_icon_import.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/src/assets/mk-icon-user-gradient.svg b/ui/src/assets/mk_icon_user_gradient.svg similarity index 100% rename from ui/src/assets/mk-icon-user-gradient.svg rename to ui/src/assets/mk_icon_user_gradient.svg diff --git a/ui/src/assets/mk-login-background.png b/ui/src/assets/mk_login_background.png similarity index 100% rename from ui/src/assets/mk-login-background.png rename to ui/src/assets/mk_login_background.png diff --git a/ui/src/components/COMPONENT_README.md b/ui/src/components/COMPONENT_README.md index e17c2172eec..da49bba5990 100644 --- a/ui/src/components/COMPONENT_README.md +++ b/ui/src/components/COMPONENT_README.md @@ -83,6 +83,9 @@ src/components/ │ └── mk-list-item.vue # 列表与业务分组列表复用的行结构 ├── mk-form-list/ │ └── index.vue # 可动态增删的表单行列表,手动导入 +├── codemirror-editor/ +│ ├── index.vue # 支持异步诊断和全屏编辑的代码编辑器 +│ └── types.ts # 编辑器行列诊断类型 └── mk-source-card/ │ ├── index.vue # 来源资源的统一卡片结构,手动导入 │ ├── mk-source-card-action.vue # 卡片悬浮操作容器 @@ -96,6 +99,7 @@ Vue 模板中使用,不需要手动导入。其他共享组件必须从具体 import MkSearchList from '@/components/mk-search-list/index.vue' import MkListItem from '@/components/mk-search-list/mk-list-item.vue' import MkFormList from '@/components/mk-form-list/index.vue' +import CodemirrorEditor from '@/components/codemirror-editor/index.vue' import MkSourceCard from '@/components/mk-source-card/index.vue' import FolderTree from '@/components/business/folder-tree/index.vue' import ModelSelect from '@/components/business/model-select/index.vue' @@ -470,6 +474,27 @@ const paginationConfig = ref({ ## 手动导入组件 +### CodemirrorEditor + +基于 CodeMirror 6 的代码编辑器,当前内置 Python 语法和 One Dark 主题。通过 `v-model` 管理代码, +通过 `lint` 传入异步检查函数;检查结果使用 `CodeLintIssue` 的行、列、结束位置、严重程度和消息 +生成编辑器诊断,组件最多展示 50 条诊断,并在代码停止输入 500ms 后检查。编辑器提供内置全屏 +入口;全屏确认时更新 `v-model` 并触发 `submit-dialog`,`header-extra` 插槽用于添加全屏标题栏操作。 + +```vue + + + +``` + ### MkSourceCard 用于模型、工具等带来源信息的等高资源卡片。`title` 提供默认标题,`nick_name` 和 `create_time` diff --git a/ui/src/components/business/folder-tree/FolderFormDialog.vue b/ui/src/components/business/folder-tree/FolderFormDialog.vue index f60e5fa9611..094af40f13f 100644 --- a/ui/src/components/business/folder-tree/FolderFormDialog.vue +++ b/ui/src/components/business/folder-tree/FolderFormDialog.vue @@ -1,93 +1,101 @@