diff --git a/ui/package.json b/ui/package.json index e0029a542c0..80b5a11638c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,19 +24,29 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.9", "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", "@he-tree/vue": "^2.10.5", "@logicflow/core": "^2.2.5", "@logicflow/extension": "^2.3.1", + "@vavt/cm-extension": "^1.11.2", "axios": "^1.19.0", "codemirror": "^6.0.2", + "cropperjs": "^1.6.3", "dingtalk-jsapi": "^3.2.9", + "dompurify": "^3.4.14", + "echarts": "^6.1.0", "element-plus": "^2.14.2", + "highlight.js": "^11.12.0", "jsencrypt": "^3.5.4", + "katex": "^0.16.47", "lodash": "^4.18.1", "md-editor-v3": "^6.5.6", + "mermaid": "^11.17.2", "nanoid": "^6.0.1", "nprogress": "^0.2.0", "pinia": "^3.0.4", + "prettier": "3.8.4", + "screenfull": "^6.0.2", "sortablejs": "^1.15.7", "vue": "^3.5.38", "vue-codemirror": "^6.1.1", @@ -60,7 +70,6 @@ "jiti": "^2.7.0", "npm-run-all2": "^9.0.2", "oxlint": "~1.69.0", - "prettier": "3.8.4", "sass": "^1.101.0", "tailwindcss": "^4.3.2", "typescript": "~6.0.0", diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index 31786877383..7e4fdc5612e 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -20,7 +20,7 @@ src/api/ │ │ ├── application/ # 智能体接口 │ │ ├── knowledge/ # 知识库接口 │ │ ├── model/ # 模型接口 -│ │ ├── tool/ # 工具及工具商店接口 +│ │ ├── tool/ # 工具、工具工作流及工具商店接口 │ │ └── .ts # 工作空间公共资源接口 │ └── provider.ts # Workspace 与 System 共用的模型供应商接口 ├── chat/ # Chat 独立请求体系,当前预留 @@ -52,6 +52,8 @@ src/api/ 所属一级业务域下。 - 一类资源的增删改查放在同一个最终资源文件中。调用方直接导入该文件,不为业务目录创建聚合 入口,也不创建汇总所有业务接口的 `api.ts`。 +- 工具基础信息和工具工作流使用后端不同资源接口:`workspace/tool/tool.ts` 维护工具增删改查, + `workspace/tool/workflow.ts` 维护工具工作流的加载、保存和发布。 - 每个业务接口函数必须添加简短的 JSDoc,说明接口的业务作用;注释应描述“获取什么”“保存什么” 或“对哪个资源执行什么操作”,不重复参数类型、请求方法等代码已经清楚表达的信息。 - 每个业务接口使用 `const` 声明的箭头函数,不单独具名导出;在文件末尾通过 @@ -134,7 +136,7 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文 - Admin Router 与请求客户端直接读取 `window.MaxKB` 运行时路径配置;`Window` 和 `MaxKBRuntimeConfig` 的全局类型统一声明在根目录 `env.d.ts`。 - Admin 普通 JSON 请求使用 Axios;`request.ts` 导出 Axios 实例以及 `promise`、`get`、 - `post`、`put`、`del` 和 Blob 文件 `downloadRequest` 请求封装。 + `post`、`put`、`del`、流式响应 `postStream` 和 Blob 文件 `downloadRequest` 请求封装。 - 正常 JSON 接口返回 `Promise`,请求层负责解包后端 `{ code, message, data }` 响应。 - GET 文件导出使用 `getExportFile`;需要通过 POST 同时传递查询参数和可选请求体的 Excel 导出 使用 `postExportExcel`;Skill 压缩包等指定请求方法的文件下载使用 `downloadRequest`。请求层统一 @@ -146,4 +148,6 @@ API 枚举与类型统一在 `src/api` 范围内管理,相关规则由本文 `stores/index.ts` 导出的 `useStore()` 按需访问 Store;401 响应统一清除 token 并跳转 Admin 登录页。 - loading 不作为 API 函数参数,由调用接口的页面或 Store 管理。 -- 上传、下载和流式请求在真实需求出现时独立设计,不提前塞入普通 JSON 请求客户端。 +- 流式 POST 请求使用 `postStream` 返回原始 `Response`,由业务组件按具体协议解析数据块; + 鉴权、语言请求头和错误状态仍由请求基础设施统一处理。 +- 上传、下载和其他特殊请求在真实需求出现时独立设计,不提前塞入普通 JSON 请求客户端。 diff --git a/ui/src/api/admin/core/request.ts b/ui/src/api/admin/core/request.ts index 9dfe0120dd6..683a0497233 100644 --- a/ui/src/api/admin/core/request.ts +++ b/ui/src/api/admin/core/request.ts @@ -14,6 +14,10 @@ interface ExportRequestConfig extends AxiosRequestConfig { skipGlobalErrorMessage?: boolean } +interface StreamRequestConfig { + signal?: AbortSignal +} + function setRequestHeaders(config: InternalAxiosRequestConfig) { const { auth, user } = useStore() @@ -93,6 +97,16 @@ async function getResponseErrorMessage(error: unknown) { return responseData?.message } +async function getFetchErrorMessage(response: Response) { + const responseText = await response.text() + try { + const responseData = JSON.parse(responseText) as Partial> + return responseData.message || responseText + } catch { + return responseText + } +} + async function downloadExportResponse(response: AxiosResponse, fileName: string, mimeType = 'application/octet-stream') { if (response.data.type.includes('application/json')) { const text = await response.data.text() @@ -182,10 +196,23 @@ export function get(url: string, params?: Dict, loading?: } /** 发送指定方法的 Blob 请求并触发浏览器下载。 */ -export async function downloadRequest(url: string, method: string, data?: unknown, params?: Dict, loading?: LoadingTarget): Promise { +export async function downloadRequest( + url: string, + method: string, + data?: unknown, + params?: Dict, + loading?: LoadingTarget, +): Promise { startLoading(loading) try { - const response = await request.request({ url, method, data, params, responseType: 'blob', skipGlobalErrorMessage: true } as ExportRequestConfig) + const response = await request.request({ + url, + method, + data, + params, + responseType: 'blob', + skipGlobalErrorMessage: true, + } as ExportRequestConfig) return downloadExportResponse(response, 'download') } finally { @@ -206,7 +233,13 @@ export async function getExportFile(fileName: string, url: string, params?: Dict } /** 发送 POST 请求并将 Blob 响应下载为 Excel 文件。 */ -export async function postExportExcel(fileName: string, url: string, params?: Dict, data?: TData, loading?: LoadingTarget): Promise { +export async function postExportExcel( + fileName: string, + url: string, + params?: Dict, + data?: TData, + loading?: LoadingTarget, +): Promise { startLoading(loading) try { const response = await request.post(url, data, { params, responseType: 'blob', skipGlobalErrorMessage: true } as ExportRequestConfig) @@ -222,6 +255,39 @@ export function post(url: string, data?: TData, pa return promise(request.post>(url, data, { params, timeout }), loading) } +/** 发送 POST 请求并返回可逐块读取的原始响应。 */ +export async function postStream(url: string, data?: TData, config: StreamRequestConfig = {}) { + const { auth, user } = useStore() + const headers = new Headers({ 'Content-Type': 'application/json' }) + if (auth.token) headers.set('Authorization', `Bearer ${auth.token}`) + if (user.language) headers.set('Accept-Language', user.language) + + const baseUrl = String(request.defaults.baseURL ?? '').replace(/\/+$/, '') + const response = await fetch(`${baseUrl}/${url.replace(/^\/+/, '')}`, { + body: JSON.stringify(data ?? {}), + headers, + method: 'POST', + signal: config.signal, + }) + + if (response.ok) return response + + const errorMessage = (await getFetchErrorMessage(response)) || response.statusText + if (response.status === 401) { + auth.clearToken() + void router.push({ name: 'login' }) + } else if (response.status === 403) { + MsgError(errorMessage || 'No permission to access') + } else if (response.status === 404) { + void router.replace({ name: 'not-found', params: { pathMatch: ['404'] } }) + } else { + MsgError(errorMessage) + } + const error = new Error(errorMessage) + error.name = 'StreamRequestError' + throw error +} + /** 发送 PUT 请求。 */ export function put(url: string, data?: TData, params?: Dict, loading?: LoadingTarget, timeout?: number) { return promise(request.put>(url, data, { params, timeout }), loading) diff --git a/ui/src/api/admin/workspace/application/application.ts b/ui/src/api/admin/workspace/application/application.ts index e28af47f92c..99e590a5e01 100644 --- a/ui/src/api/admin/workspace/application/application.ts +++ b/ui/src/api/admin/workspace/application/application.ts @@ -1,6 +1,6 @@ -import { del, get, getExportFile, post, put } from '../../core/request' +import { del, get, getExportFile, post, postStream, put } from '../../core/request' import type { ParamsPage, ResponsePage } from '../../core/types' -import type { ApplicationDetail, ApplicationFormPayload, Dict } from '@/api/types' +import type { ApplicationDetail, ApplicationFormPayload, Dict, PromptGeneratePayload } from '@/api/types' import { getWorkspaceId } from '@/utils/resource-context' const getPrefix = () => { @@ -72,6 +72,11 @@ const putApplicationPublish = (applicationId: string) => { return put, ApplicationDetail>(`${getPrefix()}/${applicationId}/publish`, {}) } +/** 使用指定模型流式生成或优化系统提示词。 */ +const postPromptGenerate = (applicationId: string, modelId: string, payload: PromptGeneratePayload, signal?: AbortSignal) => { + return postStream(`${getPrefix()}/${applicationId}/model/${modelId}/prompt_generate`, payload, { signal }) +} + export default { getApplicationPage, getApplicationDetail, @@ -85,4 +90,5 @@ export default { putBatchMoveApplications, putApplicationPublish, getAllApplication, + postPromptGenerate, } diff --git a/ui/src/api/admin/workspace/tool/workflow.ts b/ui/src/api/admin/workspace/tool/workflow.ts new file mode 100644 index 00000000000..c4293bcbf25 --- /dev/null +++ b/ui/src/api/admin/workspace/tool/workflow.ts @@ -0,0 +1,30 @@ +import type LogicFlow from '@logicflow/core' +import { get, put } from '../../core/request' +import type { ToolWorkflowDetail } from '@/api/types' +import { getWorkspaceId } from '@/utils/resource-context' + +interface ToolWorkflowPayload { + work_flow: LogicFlow.GraphConfigData +} + +const getPrefix = () => { + const workspaceId = getWorkspaceId() + return `/workspace/${workspaceId}/tool` +} + +/** 获取工具工作流详情。 */ +const getToolWorkflow = (toolId: string) => { + return get(`${getPrefix()}/${toolId}/workflow`) +} + +/** 保存工具工作流。 */ +const putToolWorkflow = (toolId: string, payload: ToolWorkflowPayload) => { + return put(`${getPrefix()}/${toolId}/workflow`, payload) +} + +/** 发布工具工作流。 */ +const putToolWorkflowPublish = (toolId: string) => { + return put(`${getPrefix()}/${toolId}/publish`) +} + +export default { getToolWorkflow, putToolWorkflow, putToolWorkflowPublish } diff --git a/ui/src/api/types/application.ts b/ui/src/api/types/application.ts index d38aa2ba06f..7b6f89a7249 100644 --- a/ui/src/api/types/application.ts +++ b/ui/src/api/types/application.ts @@ -69,3 +69,13 @@ export interface ApplicationFormPayload { long_term_trigger_setting?: Record long_term_trigger_type?: string } + +export interface PromptGenerateMessage { + content: string + role: 'ai' | 'user' +} + +export interface PromptGeneratePayload { + messages: PromptGenerateMessage[] + prompt: string +} diff --git a/ui/src/api/types/tool.ts b/ui/src/api/types/tool.ts index ed8e69f5c60..118292d04b9 100644 --- a/ui/src/api/types/tool.ts +++ b/ui/src/api/types/tool.ts @@ -1,5 +1,6 @@ /** Workspace 工具列表和工具维护共用的业务类型。 */ +import type LogicFlow from '@logicflow/core' import { TOOL_SCOPE, TOOL_TYPE } from '@/api/enums' import type { DynamicFormField } from './model' @@ -65,7 +66,18 @@ export interface ToolItem { update_time?: string user_id?: string | null version?: string | null - work_flow?: Record + work_flow?: LogicFlow.GraphConfigData + workspace_id: string +} + +export interface ToolWorkflowDetail { + create_time?: string + id: string + is_publish: boolean + publish_time?: string | null + tool: string + update_time?: string + work_flow: LogicFlow.GraphConfigData workspace_id: string } @@ -87,7 +99,7 @@ export interface ToolPayload { name?: string scope?: ToolScope tool_type?: ToolType - work_flow?: Record + work_flow?: LogicFlow.GraphConfigData work_flow_template?: ToolStoreItem } diff --git a/ui/src/chat.ts b/ui/src/chat.ts index 596e0e89b14..0c104dd9a76 100644 --- a/ui/src/chat.ts +++ b/ui/src/chat.ts @@ -3,12 +3,15 @@ import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' +import { configureMarkdownEditor } from '@/components/global/markdown-editor/config' import router from '@/router/chat' import { pinia } from '@/stores' import 'element-plus/dist/index.css' import '@/styles/tailwind.css' import '@/styles/index.scss' +configureMarkdownEditor() + const app = createApp(App) app.use(pinia) diff --git a/ui/src/components.d.ts b/ui/src/components.d.ts index 5890da4b476..48a1e707d1f 100644 --- a/ui/src/components.d.ts +++ b/ui/src/components.d.ts @@ -16,6 +16,9 @@ declare module 'vue' { KnowledgeIcon: typeof import('./components/global/mk-icon/KnowledgeIcon.vue')['default'] LayoutAside: typeof import('./components/global/mk-view-layout/layout-aside.vue')['default'] LayoutBatchFooter: typeof import('./components/global/mk-view-layout/layout-batch-footer.vue')['default'] + MdEditor: typeof import('./components/global/markdown-editor/MdEditor.vue')['default'] + MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default'] + MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default'] MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default'] MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default'] MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default'] @@ -47,6 +50,9 @@ declare global { const KnowledgeIcon: typeof import('./components/global/mk-icon/KnowledgeIcon.vue')['default'] const LayoutAside: typeof import('./components/global/mk-view-layout/layout-aside.vue')['default'] const LayoutBatchFooter: typeof import('./components/global/mk-view-layout/layout-batch-footer.vue')['default'] + const MdEditor: typeof import('./components/global/markdown-editor/MdEditor.vue')['default'] + const MdEditorMagnify: typeof import('./components/global/markdown-editor/MdEditorMagnify.vue')['default'] + const MdPreview: typeof import('./components/global/markdown-editor/MdPreview.vue')['default'] const MkCollapse: typeof import('./components/global/mk-collapse/index.vue')['default'] const MkComplexSearch: typeof import('./components/global/mk-complex-search/index.vue')['default'] const MkDialog: typeof import('./components/global/mk-dialog/index.vue')['default'] diff --git a/ui/src/components/COMPONENT_README.md b/ui/src/components/COMPONENT_README.md index 1a0750c01c3..ba9e2e9ce6b 100644 --- a/ui/src/components/COMPONENT_README.md +++ b/ui/src/components/COMPONENT_README.md @@ -47,6 +47,13 @@ src/components/ │ └── workspace-relation-tags/ │ └── index.vue # 标签及关联工作空间展示 ├── global/ # 高频、稳定的基础组件,自动注册 +│ ├── markdown-editor/ +│ │ ├── config.ts # 本地扩展、内容过滤和语言包的全局配置 +│ │ ├── MdEditor.vue # 支持多语言和自定义页脚的 Markdown 编辑器 +│ │ ├── MdEditorMagnify.vue # 支持弹窗放大编辑的 Markdown 编辑器 +│ │ ├── MdPreview.vue # 禁用代码折叠的 Markdown 预览器 +│ │ ├── sup-popover.scss # Markdown 上标说明悬浮层样式 +│ │ └── sup-popover.ts # Markdown 上标说明悬浮层交互 │ ├── mk-complex-search/ │ │ └── index.vue # 字段选择与输入或枚举条件组合搜索框 │ ├── mk-collapse/ @@ -146,8 +153,9 @@ import WorkspaceRelationTags from '@/components/business/workspace-relation-tags 由使用方手动导入。业务组件不使用 `Mk` 前缀,也不再按 Workspace 等上级领域增加额外目录。 - 不依赖固定业务的共享组合 UI 组件放在 `components/`,由使用方手动导入。 - 一个公共组件默认使用一个 kebab-case 目录,入口统一为 `index.vue`;`codemirror-editor` 按语言 - 提供 `python.vue` 和 `Json.vue` 两个专用入口,`mk-logo` 按完整 Logo 与图形 Logo 提供 - `LogoFull.vue` 和 `LogoIcon.vue` 两个专用入口。 + 提供 `python.vue` 和 `Json.vue` 两个专用入口,`markdown-editor` 提供 `MdEditor.vue`、 + `MdEditorMagnify.vue` 和 `MdPreview.vue` 三个自动注册入口,`mk-logo` 按完整 Logo 与图形 Logo + 提供 `LogoFull.vue` 和 `LogoIcon.vue` 两个专用入口。 - 组件名使用 PascalCase;目录名使用 kebab-case,例如 `MkIcon` 对应 `mk-icon/index.vue`。 - `index.vue` 必须通过 `defineOptions({ name: 'ComponentName' })` 声明多单词组件名,避免 @@ -179,6 +187,25 @@ Element Plus 使用 `ElOnlyChild` 处理浮层触发器。`el-tooltip`、`el-pop ## 自动注册组件 +### MdEditor、MdEditorMagnify、MdPreview + +基于 `md-editor-v3` 的全局自动注册组件。三个组件统一使用 User Store 中的当前语言,并为繁体中文 +加载 `zh-TW` 语言包;底层组件的 Props 和事件通过 `$attrs` 透传。`MdEditor` 默认关闭 Prettier, +并透传 `defFooters` 插槽;`MdEditorMagnify` 在无工具栏编辑器的页脚提供放大按钮,弹窗中点击确定 +才会写回内容并触发 `submitDialog`,取消或关闭弹窗会丢弃本次弹窗编辑;`MdPreview` 默认关闭代码 +折叠。Admin 和 Chat 入口统一调用 +`configureMarkdownEditor()`,将代码高亮、全屏、KaTeX、图片裁剪、Mermaid、ECharts 和 Prettier +配置为本地实例,避免运行时加载 CDN 资源;`md-editor-v3` v6 的图标已随依赖打包,不再维护或加载 +旧版 `markdown-iconfont.js`。 + +```vue + + + + + +``` + ### MkCollapse 带标题触发器和展开过渡动画的内容折叠组件。组件内部维护展开状态,`default-expanded` 设置初始 diff --git a/ui/src/components/global/markdown-editor/MdEditor.vue b/ui/src/components/global/markdown-editor/MdEditor.vue new file mode 100644 index 00000000000..57d88c7fb35 --- /dev/null +++ b/ui/src/components/global/markdown-editor/MdEditor.vue @@ -0,0 +1,26 @@ + + + diff --git a/ui/src/components/global/markdown-editor/MdEditorMagnify.vue b/ui/src/components/global/markdown-editor/MdEditorMagnify.vue new file mode 100644 index 00000000000..14d6f784fa7 --- /dev/null +++ b/ui/src/components/global/markdown-editor/MdEditorMagnify.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/ui/src/components/global/markdown-editor/MdPreview.vue b/ui/src/components/global/markdown-editor/MdPreview.vue new file mode 100644 index 00000000000..9dc997ca190 --- /dev/null +++ b/ui/src/components/global/markdown-editor/MdPreview.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/ui/src/components/global/markdown-editor/config.ts b/ui/src/components/global/markdown-editor/config.ts new file mode 100644 index 00000000000..5af098baadb --- /dev/null +++ b/ui/src/components/global/markdown-editor/config.ts @@ -0,0 +1,75 @@ +/** 配置 Markdown 编辑器的本地扩展、内容过滤和繁体中文语言包。 */ + +import ZH_TW from '@vavt/cm-extension/dist/locale/zh-TW' +import Cropper from 'cropperjs' +import * as echarts from 'echarts' +import highlight from 'highlight.js' +import katex from 'katex' +import mermaid from 'mermaid' +import { config, XSSPlugin } from 'md-editor-v3' +import * as prettierMarkdownPlugin from 'prettier/plugins/markdown' +import * as prettier from 'prettier/standalone' +import screenfull from 'screenfull' +import { supPopover } from './sup-popover' +import 'cropperjs/dist/cropper.css' +import 'highlight.js/styles/atom-one-dark.css' +import 'katex/dist/katex.min.css' +import './sup-popover.scss' + +let configured = false + +/** 在应用挂载前注册本地扩展,避免 Markdown 编辑器运行时加载 CDN 资源。 */ +export function configureMarkdownEditor() { + if (configured) return + + config({ + editorConfig: { + languageUserDefined: { + 'zh-Hant': ZH_TW, + 'zh-TW': ZH_TW, + }, + }, + editorExtensions: { + cropper: { instance: Cropper }, + echarts: { instance: echarts }, + highlight: { instance: highlight }, + katex: { instance: katex }, + mermaid: { instance: mermaid }, + prettier: { + parserMarkdownInstance: prettierMarkdownPlugin, + prettierInstance: prettier, + }, + screenfull: { instance: screenfull }, + }, + markdownItPlugins(plugins) { + return [ + ...plugins, + { + type: 'xss', + plugin: XSSPlugin, + options: { + extendedWhiteList: { + a: ['href', 'style'], + iframe: ['allow', 'allowfullscreen', 'border', 'class', 'frameborder', 'framespacing', 'height', 'src', 'title', 'width'], + input: ['checked', 'class', 'disabled', 'type'], + source: ['src', 'type'], + sup: ['data-title'], + video: ['controls', 'height', 'playsinline', 'preload', 'src', 'width'], + }, + xss: { + onTagAttr(tag: string, name: string, value: string) { + if (tag !== 'video') return undefined + if (name === 'autoplay') return '' + if (name === 'preload' && !['none', 'metadata'].includes(value)) return 'preload="metadata"' + return undefined + }, + }, + }, + }, + ] + }, + }) + + supPopover.init() + configured = true +} diff --git a/ui/src/components/global/markdown-editor/sup-popover.scss b/ui/src/components/global/markdown-editor/sup-popover.scss new file mode 100644 index 00000000000..a82134327c2 --- /dev/null +++ b/ui/src/components/global/markdown-editor/sup-popover.scss @@ -0,0 +1,23 @@ +.markdown-sup-popover { + background: var(--el-bg-color-overlay); + border-radius: 4px; + box-shadow: var(--el-box-shadow-light); + color: var(--mk-N900); + display: none; + font-size: 13px; + line-height: 1.5; + padding: 10px 12px; + position: absolute; + width: 240px; + word-break: break-word; + z-index: 20001; +} + +.markdown-sup-popover__arrow { + background: var(--el-bg-color-overlay); + box-shadow: 1px 1px 4px rgb(0 0 0 / 8%); + height: 8px; + position: absolute; + transform: rotate(45deg); + width: 8px; +} diff --git a/ui/src/components/global/markdown-editor/sup-popover.ts b/ui/src/components/global/markdown-editor/sup-popover.ts new file mode 100644 index 00000000000..e69f4a2239f --- /dev/null +++ b/ui/src/components/global/markdown-editor/sup-popover.ts @@ -0,0 +1,134 @@ +/** 管理 Markdown 上标说明的全局悬浮层。 */ + +import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' +import DOMPurify from 'dompurify' + +let arrowElement: HTMLDivElement | null = null +let contentElement: HTMLDivElement | null = null +let currentSupElement: HTMLElement | null = null +let mouseX = 0 +let mouseY = 0 +let popoverElement: HTMLDivElement | null = null +let stopAutoUpdate: (() => void) | null = null +let initialized = false + +function createPopover() { + const popover = document.createElement('div') + popover.className = 'markdown-sup-popover' + + const content = document.createElement('div') + content.className = 'markdown-sup-popover__content' + popover.appendChild(content) + + const arrowElement = document.createElement('div') + arrowElement.className = 'markdown-sup-popover__arrow' + popover.appendChild(arrowElement) + + document.body.appendChild(popover) + return { arrowElement, contentElement: content, popoverElement: popover } +} + +function updatePosition(referenceElement: HTMLElement) { + if (!popoverElement || !arrowElement) return + + computePosition(referenceElement, popoverElement, { + middleware: [offset(10), flip(), shift({ padding: 8 }), arrow({ element: arrowElement })], + placement: 'top', + }).then(({ x, y, placement, middlewareData }) => { + if (!popoverElement || !arrowElement) return + + Object.assign(popoverElement.style, { left: `${x}px`, top: `${y}px` }) + popoverElement.dataset.placement = placement + + const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {} + const side = placement.split('-')[0] as 'bottom' | 'left' | 'right' | 'top' + const staticSide = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' }[side] + + Object.assign(arrowElement.style, { + bottom: '', + left: arrowX == null ? '' : `${arrowX}px`, + right: '', + top: arrowY == null ? '' : `${arrowY}px`, + [staticSide]: '-5px', + }) + }) +} + +function showPopover(supElement: HTMLElement) { + if (!popoverElement || !contentElement) return + + contentElement.innerHTML = DOMPurify.sanitize(supElement.dataset.title ?? '') + popoverElement.style.display = 'block' + popoverElement.style.pointerEvents = 'auto' + + stopAutoUpdate?.() + stopAutoUpdate = autoUpdate(supElement, popoverElement, () => updatePosition(supElement)) +} + +function hidePopover() { + if (!popoverElement) return + + popoverElement.style.display = 'none' + stopAutoUpdate?.() + stopAutoUpdate = null + currentSupElement = null +} + +function isMouseInsideSafeZone() { + if (!popoverElement || !currentSupElement) return false + + const supRect = currentSupElement.getBoundingClientRect() + const popoverRect = popoverElement.getBoundingClientRect() + const tolerance = 2 + const minX = Math.min(supRect.left, popoverRect.left) - tolerance + const maxX = Math.max(supRect.right, popoverRect.right) + tolerance + const minY = Math.min(supRect.top, popoverRect.top) - tolerance + const maxY = Math.max(supRect.bottom, popoverRect.bottom) + tolerance + + return mouseX >= minX && mouseX <= maxX && mouseY >= minY && mouseY <= maxY +} + +function handleMouseOver(event: MouseEvent) { + const supElement = (event.target as HTMLElement).closest('sup[data-title]') + if (!supElement || supElement === currentSupElement) return + + currentSupElement = supElement + showPopover(supElement) +} + +function handleMouseMove(event: MouseEvent) { + mouseX = event.clientX + mouseY = event.clientY + if (!currentSupElement) return + if (popoverElement?.contains(event.target as Node)) return + if (!(event.target as HTMLElement).closest('sup[data-title]') && !isMouseInsideSafeZone()) hidePopover() +} + +export const supPopover = { + /** 初始化一次全局上标悬浮层与事件监听。 */ + init() { + if (initialized) return + + const elements = createPopover() + arrowElement = elements.arrowElement + contentElement = elements.contentElement + popoverElement = elements.popoverElement + document.addEventListener('mousemove', handleMouseMove, { passive: true }) + document.addEventListener('mouseover', handleMouseOver) + initialized = true + }, + + /** 移除上标悬浮层及其全局事件监听。 */ + destroy() { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseover', handleMouseOver) + stopAutoUpdate?.() + popoverElement?.remove() + arrowElement = null + contentElement = null + currentSupElement = null + popoverElement = null + stopAutoUpdate = null + initialized = false + }, +} diff --git a/ui/src/main.ts b/ui/src/main.ts index f048461c81d..d6918c5b6f1 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -3,12 +3,15 @@ import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' +import { configureMarkdownEditor } from '@/components/global/markdown-editor/config' import router from '@/router/admin' import { pinia } from '@/stores' import permission from '@/permission/install' import '@/styles/tailwind.css' import '@/styles/index.scss' +configureMarkdownEditor() + const app = createApp(App) app.use(pinia) diff --git a/ui/src/router/ROUTE_README.md b/ui/src/router/ROUTE_README.md index 3a96d45ee33..06bc90ae3b5 100644 --- a/ui/src/router/ROUTE_README.md +++ b/ui/src/router/ROUTE_README.md @@ -65,11 +65,12 @@ System 根地址为 `/admin/system`,默认进入首页 `/admin/system/home`。 Login、Workflow 路由没有挂载 `AppLayout`,因此不会出现在左侧导航中: - `login`:登录及账户相关独立页面。 -- `workflow`:集中维护各业务的全屏画布路由,当前包含智能体工作流,后续知识库等画布继续在 - `admin/workflow/index.ts` 中注册。 -- Workflow 统一使用 `/workflow//:resourceId` 结构;当前智能体工作流地址为 - `/workflow/application/:applicationId`。智能体页面使用新标签打开该地址;路由参数 - 只保留智能体标识,页面当前不请求接口。 +- `workflow`:集中维护各业务的全屏画布路由,当前包含智能体和工具工作流,后续知识库等画布 + 继续在 `admin/workflow/index.ts` 中注册。 +- Workflow 路由保留当前 Workspace 和资源标识;智能体工作流地址为 + `/workspace/:workspaceId/application/:applicationId/workflow`,工具工作流地址为 + `/workspace/:workspaceId/tool/:toolId/workflow`。两个页面均可从资源操作入口进入,按住 Ctrl 或 + Command 点击时在新标签页打开。 登录页面地址为 `/login`;忘记密码使用独立页面 `/forgot-password`,两者均复用登录布局且不挂载 `AppLayout`。 @@ -186,7 +187,10 @@ Workspace 页面: /admin/workspace/:workspaceId/tools /admin/workspace/:workspaceId/model +/admin/workspace/:workspaceId/tool/:toolId/workflow + /admin/workspace/:workspaceId/application +/admin/workspace/:workspaceId/application/:applicationId/workflow /admin/workspace/:workspaceId/application/:applicationId/:type/overview /admin/workspace/:workspaceId/application/:applicationId/:type/setting diff --git a/ui/src/router/admin/workflow/index.ts b/ui/src/router/admin/workflow/index.ts index f27310ea716..bb32098a67e 100644 --- a/ui/src/router/admin/workflow/index.ts +++ b/ui/src/router/admin/workflow/index.ts @@ -6,7 +6,12 @@ export const workflowRoutes: RouteRecordRaw[] = [ path: '/workspace/:workspaceId/application/:applicationId/workflow', name: 'workflow-application', component: () => import('@/views/workflow/ApplicationWorkflowView.vue'), - props: true, meta: { title: '智能体工作流' }, }, + { + path: '/workspace/:workspaceId/tool/:toolId/workflow', + name: 'workflow-tool', + component: () => import('@/views/workflow/ToolWorkflowView.vue'), + meta: { title: '工具工作流' }, + }, ] diff --git a/ui/src/styles/index.scss b/ui/src/styles/index.scss index 624f1a82297..c67064e4500 100644 --- a/ui/src/styles/index.scss +++ b/ui/src/styles/index.scss @@ -1,4 +1,5 @@ @use 'nprogress/nprogress.css' as nprogress-base; +@use 'md-editor-v3/lib/style.css'; @use 'element-plus/dist/index.css'; @use './element-plus.scss'; @use './variables.scss'; diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index 04cdde2ea3b..569983e7dc3 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -150,6 +150,7 @@ src/views/tool/ │ ├── ExportToolAction.vue │ ├── InitParamAction.vue │ ├── MoveToolAction.vue +│ ├── ToolWorkflowAction.vue │ └── mcp-config-action/ │ ├── McpConfigAction.vue │ └── McpConfigDialog.vue # MCP 工具配置查看弹窗 @@ -166,11 +167,12 @@ src/views/tool/ 不在类型目录中重复实现。`ToolCodeSetting` 的生成入口默认隐藏,只由普通自定义工具表单通过 `showGenerate` 显式开启。工具列表页面通过 `ToolCard` 的 `actions` 和 `action-dropdown` 插槽组合 操作;编辑 Action 负责按 `TOOL_TYPE` 打开对应类型表单,启动参数 Action 获取工具详情后打开 -`InitParamDialog`,MCP 配置 Action 获取 MCP 工具详情后打开 `McpConfigDialog`。各类型表单创建成功后通过 `refresh` 事件 -刷新列表,编辑成功后通过 `update` 事件返回接口响应的完整工具数据,由页面局部更新对应卡片;不要 +`InitParamDialog`,MCP 配置 Action 获取 MCP 工具详情后打开 `McpConfigDialog`。工作流 Action 进入 +独立的工具工作流画布;创建工作流工具后直接进入该画布,其他类型表单创建成功后通过 `refresh` +事件刷新列表。编辑成功后通过 `update` 事件返回接口响应的完整工具数据,由页面局部更新对应卡片;不要 把不同工具类型的字段重新合并到一个通用表单中。所有调用 `postTool` 创建工具的流程在接口成功后 -先调用 `auth.loadAuthBaseProfile()` 刷新当前用户基础资料,再执行成功提示和页面刷新;`putTool` 编辑 -流程不触发该刷新。跨资源范围复用且需要请求的 Action 和表单接收页面传入的完整 Tool API,不额外 +先调用 `auth.loadAuthBaseProfile()` 刷新当前用户基础资料,再执行成功提示以及列表刷新或工作流页面 +跳转;`putTool` 编辑流程不触发该刷新。跨资源范围复用且需要请求的 Action 和表单接收页面传入的完整 Tool API,不额外 维护逐方法接口类型;固定服务于 Workspace 工具商店的 `StoreToolFormDialog` 直接内聚对应请求。 工具启用状态属于固定的卡片内交互,由 `ToolStatusSwitch` 使用卡片传入的 Tool API 更新,并通过 `update` 事件通知页面替换列表数据。工具批量选择状态、 @@ -235,6 +237,7 @@ Dialog。新增或重命名文件时,应同步更新所有导入和页面功 | `application-detail/overview/OverviewView.vue` | 智能体概览内容 | | `application-detail/setting/SimpleSettingView.vue` | 简易智能体设置内容 | | `workflow/ApplicationWorkflowView.vue` | 智能体工作流页面头部与全屏画布 | +| `workflow/ToolWorkflowView.vue` | 工具工作流页面头部与全屏画布 | | `chat/ChatView.vue` | Chat 入口的对话页面 | | `error/NotFoundView.vue` | Admin 未匹配路由和全局 404 页面 | | `home/HomeView.vue` | Workspace 首页 | diff --git a/ui/src/views/tool/ToolView.vue b/ui/src/views/tool/ToolView.vue index 25ee02783c5..0d0126ee852 100644 --- a/ui/src/views/tool/ToolView.vue +++ b/ui/src/views/tool/ToolView.vue @@ -20,6 +20,7 @@ import { InitParamAction, McpConfigAction, MoveToolAction, + ToolWorkflowAction, } from './tool-card/action-dropdown' import CreateToolDropdown from './components/CreateToolDropdown.vue' import OpenToolStoreButton from './components/OpenToolStoreButton.vue' @@ -231,7 +232,7 @@ onMounted(() => { @update="handleToolUpdate" > diff --git a/ui/src/workflow-canvas/nodes/ai-chat-node/types.ts b/ui/src/workflow-canvas/nodes/ai-chat-node/types.ts new file mode 100644 index 00000000000..a9029ebc494 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/ai-chat-node/types.ts @@ -0,0 +1,56 @@ +import type { ApplicationDetail, ToolItem } from '@/api/types' + +export type AiModelSource = 'custom' | 'default' | 'reference' +export type DialogueSource = 'NODE' | 'WORKFLOW' +export type McpSource = 'custom' | 'referencing' + +export interface AiModelSetting { + model_id: string + model_id_reference: string[] + model_id_type: AiModelSource + model_params_setting: Record +} + +export interface PromptSetting { + prompt: string + system: string +} + +export interface HistorySetting { + dialogue_number: number + dialogue_type: DialogueSource +} + +export interface VisionSetting { + image_list: string[] + video_list: string[] + vision: boolean +} + +export interface ReasoningSetting { + reasoning_content_enable: boolean + reasoning_content_end: string + reasoning_content_start: string +} + +export interface McpSetting { + mcp_servers: string + mcp_source: McpSource + mcp_tool_ids: string[] +} + +export interface ResourceSetting extends McpSetting { + application_ids: string[] + mcp_output_enable: boolean + skill_tool_ids: string[] + tool_ids: string[] +} + +export interface AiChatNodeForm extends AiModelSetting, HistorySetting, PromptSetting, ResourceSetting, VisionSetting { + is_result: boolean + model_setting: ReasoningSetting +} + +export type ToolResourceOption = Pick +export type ApplicationResourceOption = Pick +export type ResourceOption = ToolResourceOption | ApplicationResourceOption diff --git a/ui/src/workflow-canvas/nodes/base-node/BaseNodeFieldTables.vue b/ui/src/workflow-canvas/nodes/base-node/BaseNodeFieldTables.vue deleted file mode 100644 index abe0ee39b37..00000000000 --- a/ui/src/workflow-canvas/nodes/base-node/BaseNodeFieldTables.vue +++ /dev/null @@ -1,352 +0,0 @@ - - - diff --git a/ui/src/workflow-canvas/nodes/base-node/PrologueEditor.vue b/ui/src/workflow-canvas/nodes/base-node/PrologueEditor.vue deleted file mode 100644 index 52fa9a504a8..00000000000 --- a/ui/src/workflow-canvas/nodes/base-node/PrologueEditor.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - diff --git a/ui/src/workflow-canvas/nodes/base-node/component/api-parameter/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/api-parameter/index.vue new file mode 100644 index 00000000000..11773d7cf25 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/api-parameter/index.vue @@ -0,0 +1,101 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/component/conversation-variable/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/conversation-variable/index.vue new file mode 100644 index 00000000000..78dd6fb9c47 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/conversation-variable/index.vue @@ -0,0 +1,88 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/FileUploadSettingDialog.vue b/ui/src/workflow-canvas/nodes/base-node/component/file-upload/FileUploadSettingDialog.vue similarity index 99% rename from ui/src/workflow-canvas/nodes/base-node/FileUploadSettingDialog.vue rename to ui/src/workflow-canvas/nodes/base-node/component/file-upload/FileUploadSettingDialog.vue index 81e821e9652..c98b27aaf81 100644 --- a/ui/src/workflow-canvas/nodes/base-node/FileUploadSettingDialog.vue +++ b/ui/src/workflow-canvas/nodes/base-node/component/file-upload/FileUploadSettingDialog.vue @@ -3,7 +3,7 @@ import { nextTick, ref, useTemplateRef } from 'vue' import { cloneDeep } from 'lodash' import type { InputInstance } from 'element-plus' import { MsgWarning } from '@/utils/message' -import { defaultFileUploadSetting, type FileUploadSetting } from './types' +import { defaultFileUploadSetting, type FileUploadSetting } from '../../types' defineOptions({ name: 'BaseNodeFileUploadSettingDialog' }) diff --git a/ui/src/workflow-canvas/nodes/base-node/component/file-upload/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/file-upload/index.vue new file mode 100644 index 00000000000..67f4f2dd1d4 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/file-upload/index.vue @@ -0,0 +1,43 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/LongTermSettingDialog.vue b/ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/LongTermSettingDialog.vue similarity index 98% rename from ui/src/workflow-canvas/nodes/base-node/LongTermSettingDialog.vue rename to ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/LongTermSettingDialog.vue index ca8f872cf4e..c157484b3fe 100644 --- a/ui/src/workflow-canvas/nodes/base-node/LongTermSettingDialog.vue +++ b/ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/LongTermSettingDialog.vue @@ -5,8 +5,8 @@ import type { FormInstance } from 'element-plus' import { Operation } from '@element-plus/icons-vue' import ModelSelect from '@/components/business/model-select/index.vue' import type { ModelItem, ModelProviderItem } from '@/api/types' -import ModelParamsDialog from './ModelParamsDialog.vue' -import type { LongTermSetting } from './types' +import ModelParamsDialog from '../../ModelParamsDialog.vue' +import type { LongTermSetting } from '../../types' defineOptions({ name: 'BaseNodeLongTermSettingDialog' }) diff --git a/ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/index.vue new file mode 100644 index 00000000000..9ca31ac783b --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/long-term-memory/index.vue @@ -0,0 +1,51 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/component/speech-input/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/speech-input/index.vue new file mode 100644 index 00000000000..590e13de733 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/speech-input/index.vue @@ -0,0 +1,60 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/component/speech-playback/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/speech-playback/index.vue new file mode 100644 index 00000000000..19d6b2d8605 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/speech-playback/index.vue @@ -0,0 +1,91 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/component/user-input/index.vue b/ui/src/workflow-canvas/nodes/base-node/component/user-input/index.vue new file mode 100644 index 00000000000..cfff0b9bc1f --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/component/user-input/index.vue @@ -0,0 +1,180 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/index.vue b/ui/src/workflow-canvas/nodes/base-node/index.vue index 88b24ea8ea6..06633af1c68 100644 --- a/ui/src/workflow-canvas/nodes/base-node/index.vue +++ b/ui/src/workflow-canvas/nodes/base-node/index.vue @@ -2,18 +2,29 @@ import { computed, inject, onMounted, ref, useTemplateRef } from 'vue' import { cloneDeep, set } from 'lodash' import type { FormInstance } from 'element-plus' -import { Operation, QuestionFilled } from '@element-plus/icons-vue' -import ModelSelect from '@/components/business/model-select/index.vue' import type { ModelItem, ModelProviderItem } from '@/api/types' import NodeContainer from '@/workflow-canvas/core/node-container/index.vue' import type { WorkflowNodeModel } from '@/workflow-canvas/core/workflow-node' import { useWorkflowStore } from '@/workflow-canvas/store' -import BaseNodeFieldTables from './BaseNodeFieldTables.vue' -import FileUploadSettingDialog from './FileUploadSettingDialog.vue' -import LongTermSettingDialog from './LongTermSettingDialog.vue' -import ModelParamsDialog from './ModelParamsDialog.vue' -import PrologueEditor from './PrologueEditor.vue' -import { defaultFileUploadSetting, type BaseNodeForm, type LongTermSetting, type UserInputField } from './types' +import ApiParameter from './component/api-parameter/index.vue' +import ConversationVariable from './component/conversation-variable/index.vue' +import FileUploadSetting from './component/file-upload/index.vue' +import LongTermMemorySetting from './component/long-term-memory/index.vue' +import SpeechInput from './component/speech-input/index.vue' +import SpeechPlayback from './component/speech-playback/index.vue' +import UserInput from './component/user-input/index.vue' +import { + defaultFileUploadSetting, + type ApiInputField, + type BaseNodeForm, + type ChatInputField, + type FileUploadSetting as FileUploadSettingValue, + type LongTermSetting, + type SpeechInputSetting, + type SpeechPlaybackSetting, + type UserInputField, + type UserInputSetting, +} from './types' defineOptions({ name: 'WorkflowBaseNode' }) @@ -23,9 +34,6 @@ const model = getModel() const store = useWorkflowStore(apiType) const formRef = useTemplateRef('formRef') -const fileUploadDialogRef = useTemplateRef>('fileUploadDialogRef') -const longTermDialogRef = useTemplateRef>('longTermDialogRef') -const ttsParamsDialogRef = useTemplateRef>('ttsParamsDialogRef') const sttModelOptions = ref([]) const ttsModelOptions = ref([]) @@ -77,13 +85,47 @@ const formData = computed({ set: (value) => set(model.properties, 'node_data', value), }) +function handleEditorWheel(event: WheelEvent) { + if (event.ctrlKey) event.preventDefault() + else event.stopPropagation() +} + +// 子模块数据映射 +const userInputFields = computed(() => (model.properties.user_input_field_list ?? []) as UserInputField[]) +const apiInputFields = computed(() => (model.properties.api_input_field_list ?? []) as ApiInputField[]) +const conversationVariables = computed(() => (model.properties.chat_input_field_list ?? []) as ChatInputField[]) +const userInputSetting = computed(() => + cloneDeep((model.properties.user_input_field_list_setting as UserInputSetting | undefined) ?? { exposed_fields: [], menu_title: '更多设置' }), +) +const longTermSetting = computed(() => ({ + long_term_model_id: formData.value.long_term_model_id, + long_term_model_id_type: formData.value.long_term_model_id_type, + long_term_model_params_setting: formData.value.long_term_model_params_setting, + long_term_trigger_setting: formData.value.long_term_trigger_setting, + long_term_trigger_type: formData.value.long_term_trigger_type, +})) +const speechInputSetting = computed(() => ({ + stt_autosend: formData.value.stt_autosend, + stt_model_enable: formData.value.stt_model_enable, + stt_model_id: formData.value.stt_model_id, + stt_model_id_type: formData.value.stt_model_id_type, +})) +const speechPlaybackSetting = computed(() => ({ + tts_autoplay: formData.value.tts_autoplay, + tts_model_enable: formData.value.tts_model_enable, + tts_model_id: formData.value.tts_model_id, + tts_model_params_setting: formData.value.tts_model_params_setting, + tts_type: formData.value.tts_type, +})) + function validateUserFieldReferences() { - const userFields = (model.properties.user_input_field_list ?? []) as UserInputField[] - for (const userField of userFields) { + for (const userField of userInputFields.value) { for (const condition of userField.visibility_rules?.conditions ?? []) { if (!condition.field?.[0] || !condition.field?.[1]) continue const isCurrentForm = condition.field[0] === model.id || (model.id === 'base-node' && condition.field[0] === 'global') - if (isCurrentForm && !userFields.some(({ field }) => field === condition.field[1])) return Promise.reject('用户输入参数引用了已删除的变量') + if (isCurrentForm && !userInputFields.value.some(({ field }) => field === condition.field[1])) { + return Promise.reject('用户输入参数引用了已删除的变量') + } } } return Promise.resolve() @@ -102,65 +144,56 @@ function validate() { return Promise.all([validateUserFieldReferences(), formRef.value?.validate()]).catch((error) => Promise.reject({ node: model, errMessage: error })) } -function switchLongTermMemory() { - if (formData.value.long_term_enable && !formData.value.long_term_model_id_type) formData.value.long_term_model_id_type = 'default' +// 子模块数据更新 +function updateLongTermEnabled(enabled: boolean) { + formData.value.long_term_enable = enabled + if (enabled && !formData.value.long_term_model_id_type) formData.value.long_term_model_id_type = 'default' model.graphModel.eventCenter.emit('refreshLongTermConfig', undefined) } -function openLongTermSettings() { - const setting: LongTermSetting = { - long_term_model_id: formData.value.long_term_model_id, - long_term_model_id_type: formData.value.long_term_model_id_type, - long_term_model_params_setting: formData.value.long_term_model_params_setting, - long_term_trigger_setting: formData.value.long_term_trigger_setting, - long_term_trigger_type: formData.value.long_term_trigger_type, - } - longTermDialogRef.value?.open(setting) -} - -function submitLongTermSettings(setting: LongTermSetting) { +function updateLongTermSetting(setting: LongTermSetting) { Object.assign(formData.value, setting) model.graphModel.eventCenter.emit('refreshLongTermConfig', undefined) } -function switchFileUpload() { - if (formData.value.file_upload_enable && !formData.value.file_upload_setting) { +function updateFileUploadEnabled(enabled: boolean) { + formData.value.file_upload_enable = enabled + if (enabled && !formData.value.file_upload_setting) { formData.value.file_upload_setting = cloneDeep(defaultFileUploadSetting) } model.graphModel.eventCenter.emit('refreshFileUploadConfig', undefined) } -function submitFileUploadSettings(setting: BaseNodeForm['file_upload_setting']) { +function updateFileUploadSetting(setting: FileUploadSettingValue) { formData.value.file_upload_setting = setting model.graphModel.eventCenter.emit('refreshFileUploadConfig', undefined) } -function switchSttModel() { - if (!formData.value.stt_model_enable) formData.value.stt_model_id = '' - if (!formData.value.stt_model_id_type) formData.value.stt_model_id_type = 'default' +function updateUserInputFields(fields: UserInputField[]) { + model.properties.user_input_field_list = fields + model.graphModel.eventCenter.emit('refreshFieldList', undefined) } -function switchTtsModel() { - if (!formData.value.tts_model_enable) { - formData.value.tts_model_id = '' - formData.value.tts_type = 'BROWSER' - } +function updateUserInputSetting(setting: UserInputSetting) { + model.properties.user_input_field_list_setting = setting } -function changeTtsModel(modelId: string) { - formData.value.tts_model_id = modelId - if (!modelId) { - formData.value.tts_model_params_setting = {} - return - } - ttsParamsDialogRef.value?.resetDefault(modelId).then((settings) => { - formData.value.tts_model_params_setting = settings - }) +function updateApiInputFields(fields: ApiInputField[]) { + model.properties.api_input_field_list = fields + model.graphModel.eventCenter.emit('refreshFieldList', undefined) +} + +function updateConversationVariables(fields: ChatInputField[]) { + model.properties.chat_input_field_list = fields + model.graphModel.eventCenter.emit('chatFieldList', undefined) +} + +function updateSpeechInput(setting: SpeechInputSetting) { + Object.assign(formData.value, setting) } -function openTtsParams() { - if (!formData.value.tts_model_id) return - ttsParamsDialogRef.value?.open(formData.value.tts_model_id, formData.value.tts_model_params_setting) +function updateSpeechPlayback(setting: SpeechPlaybackSetting) { + Object.assign(formData.value, setting) } onMounted(() => { @@ -195,125 +228,47 @@ onMounted(() => { - - + - - - 长期记忆使用默认模型或设置中选择的自定义模型。 - + - - - + - - - - - - + - - - - - + + + - - - + + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/types.ts b/ui/src/workflow-canvas/nodes/base-node/types.ts index d0c8cbdeda9..4e0c562a29d 100644 --- a/ui/src/workflow-canvas/nodes/base-node/types.ts +++ b/ui/src/workflow-canvas/nodes/base-node/types.ts @@ -35,17 +35,14 @@ export interface LongTermSetting { long_term_trigger_type: 'ROUND' | 'SCHEDULED' } -export interface BaseNodeForm extends LongTermSetting { - desc: string - file_upload_enable: boolean - file_upload_setting: FileUploadSetting - long_term_enable: boolean - name: string - prologue: string +export interface SpeechInputSetting { stt_autosend: boolean stt_model_enable: boolean stt_model_id: string stt_model_id_type: 'custom' | 'default' +} + +export interface SpeechPlaybackSetting { tts_autoplay: boolean tts_model_enable: boolean tts_model_id: string @@ -53,6 +50,20 @@ export interface BaseNodeForm extends LongTermSetting { tts_type: 'BROWSER' | 'CUSTOM' | 'DEFAULT' } +export interface UserInputSetting { + exposed_fields: string[] + menu_title: string +} + +export interface BaseNodeForm extends LongTermSetting, SpeechInputSetting, SpeechPlaybackSetting { + desc: string + file_upload_enable: boolean + file_upload_setting: FileUploadSetting + long_term_enable: boolean + name: string + prologue: string +} + export type UserInputField = FormField export const defaultFileUploadSetting: FileUploadSetting = { diff --git a/ui/src/workflow-canvas/nodes/start-node/index.vue b/ui/src/workflow-canvas/nodes/start-node/index.vue index f7e05964a7b..587f9ee73e2 100644 --- a/ui/src/workflow-canvas/nodes/start-node/index.vue +++ b/ui/src/workflow-canvas/nodes/start-node/index.vue @@ -15,6 +15,23 @@ interface SourceField { variable?: string } +type FileUploadField = 'audio' | 'document' | 'image' | 'other' | 'video' + +interface BaseNodeData { + file_upload_enable?: boolean + file_upload_setting?: Partial> + long_term_enable?: boolean +} + +const fileUploadFields: Array<{ label: string; value: FileUploadField }> = [ + { label: '文档', value: 'document' }, + { label: '图片', value: 'image' }, + { label: '音频', value: 'audio' }, + { label: '视频', value: 'video' }, + { label: '其他', value: 'other' }, +] +const fileUploadFieldValues = new Set(fileUploadFields.map(({ value }) => value)) + const getModel = inject('getModel') as () => BaseNodeModel const model = getModel() const nodeConfig = model.properties.config ?? (model.properties.config = {}) @@ -30,8 +47,19 @@ function formatFieldReference(fieldValue: string) { return `{${fieldValue}}` } -function refreshFields() { - const baseNode = model.graphModel.getNodeModelById(WorkflowNodeType.Base) +// 同步基本信息节点配置到开始节点的可用输出字段。 +function getBaseNode() { + return model.graphModel.getNodeModelById(WorkflowNodeType.Base) +} + +function refreshStartQuestionField() { + const questionFields = [{ label: '用户问题', value: 'question' }] + nodeConfig.fields = questionFields + model.properties.fields = questionFields +} + +function getRefreshFieldList() { + const baseNode = getBaseNode() const userFields = (cloneDeep(baseNode?.properties.user_input_field_list ?? []) as SourceField[]).map((field: SourceField) => ({ label: typeof field.label === 'object' ? field.label.label : (field.label ?? field.name), value: field.field ?? field.variable, @@ -40,12 +68,10 @@ function refreshFields() { label: field.name ?? field.variable, value: field.variable, })) - const chatInputFields = (cloneDeep(baseNode?.properties.chat_input_field_list ?? []) as SourceField[]).map((field: SourceField) => ({ - label: field.label, - value: field.field, - })) + return [...userFields, ...apiFields] +} - nodeConfig.fields = [{ label: '用户问题', value: 'question' }] +function refreshFieldList() { nodeConfig.globalFields = [ { label: '当前时间', value: 'time' }, { label: '历史聊天记录', value: 'history_context' }, @@ -54,21 +80,55 @@ function refreshFields() { { label: '对话用户类型', value: 'chat_user_type' }, { label: '对话用户组', value: 'chat_user_group' }, { label: '对话用户', value: 'chat_user' }, - ...userFields, - ...apiFields, + ...getRefreshFieldList(), ] - nodeConfig.chatFields = chatInputFields +} + +function refreshChatFieldList() { + const baseNode = getBaseNode() + nodeConfig.chatFields = (cloneDeep(baseNode?.properties.chat_input_field_list ?? []) as SourceField[]).map((field) => ({ + label: field.label, + value: field.field, + })) +} + +function refreshFileUploadConfig() { + const fields = (cloneDeep(nodeConfig.fields ?? []) as WorkflowNodeField[]).filter( + ({ value }) => !fileUploadFieldValues.has(value as FileUploadField), + ) + const baseNodeData = getBaseNode()?.properties.node_data as BaseNodeData | undefined + if (!baseNodeData?.file_upload_enable) { + nodeConfig.fields = fields + return + } + + const enabledFileFields = fileUploadFields.filter(({ value }) => baseNodeData.file_upload_setting?.[value]) + nodeConfig.fields = [...fields, ...enabledFileFields] +} + +function refreshLongTermConfig() { + const fields = (cloneDeep(nodeConfig.fields ?? []) as WorkflowNodeField[]).filter(({ value }) => value !== 'memory') + const baseNodeData = getBaseNode()?.properties.node_data as BaseNodeData | undefined + nodeConfig.fields = baseNodeData?.long_term_enable ? [...fields, { label: '长期记忆', value: 'memory' }] : fields } onMounted(() => { - refreshFields() - model.graphModel.eventCenter.on('refreshFieldList', refreshFields) - model.graphModel.eventCenter.on('chatFieldList', refreshFields) + refreshStartQuestionField() + refreshChatFieldList() + refreshFieldList() + refreshFileUploadConfig() + refreshLongTermConfig() + model.graphModel.eventCenter.on('refreshFieldList', refreshFieldList) + model.graphModel.eventCenter.on('chatFieldList', refreshChatFieldList) + model.graphModel.eventCenter.on('refreshFileUploadConfig', refreshFileUploadConfig) + model.graphModel.eventCenter.on('refreshLongTermConfig', refreshLongTermConfig) }) onBeforeUnmount(() => { - model.graphModel.eventCenter.off('refreshFieldList', refreshFields) - model.graphModel.eventCenter.off('chatFieldList', refreshFields) + model.graphModel.eventCenter.off('refreshFieldList', refreshFieldList) + model.graphModel.eventCenter.off('chatFieldList', refreshChatFieldList) + model.graphModel.eventCenter.off('refreshFileUploadConfig', refreshFileUploadConfig) + model.graphModel.eventCenter.off('refreshLongTermConfig', refreshLongTermConfig) })