Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ui/src/router/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const router = createRouter({
history: createWebHistory(ADMIN_BASE_PATH),
routes: [
{ path: '/', redirect: { name: 'workspace-home', params: { workspaceId: 'default' } } },
// TODO(demo): 执行详情样式预览,验收后删除
{ path: '/details-demo', name: 'details-demo', component: () => import('@/views/details-demo/index.vue'), meta: { title: '执行详情 Demo' } },
...loginRoutes,
...workflowRoutes,
workspaceRoutes,
Expand All @@ -24,7 +26,7 @@ const router = createRouter({

router.beforeEach(async (to, from) => {
if (to.path !== from.path) NProgress.start()
const notAuthRouteNameList = ['login', 'forgot-password', 'not-found']
const notAuthRouteNameList = ['login', 'forgot-password', 'not-found', 'details-demo']
const { auth, user } = useStore()

if (!notAuthRouteNameList.includes(to.name ? to.name.toString() : '')) {
Expand Down
82 changes: 82 additions & 0 deletions ui/src/views/details-demo/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
import ExecutionDetailContent from '@/workflow-canvas/details/index.vue'
import { WorkflowMode, WorkflowNodeType } from '@/workflow-canvas/types'
import type { ExecutionNodeDetail } from '@/workflow-canvas/details/types'

defineOptions({ name: 'DetailsDemo' })

// 一个 AI 对话节点的示例执行详情。
function makeAiChat(overrides: Partial<ExecutionNodeDetail> = {}): ExecutionNodeDetail {
return {
type: WorkflowNodeType.AiChat,
name: 'AI 对话',
status: 200,
index: 0,
run_time: 1.234,
message_tokens: 128,
answer_tokens: 96,
system: '你是一个乐于助人的中文助手,请简洁作答。',
history_message: [
{ role: 'user', content: '你好' },
{ role: 'ai', content: '你好,有什么可以帮你的吗?' },
],
question: '用一句话介绍一下 MaxKB。',
reasoning_content: '用户想要一句话简介,需覆盖:开源、企业级、知识库、Agent 三个要点,控制在一句。',
answer:
'**MaxKB** 是一款开源的企业级 AI 应用平台,支持基于知识库的 RAG 检索、Agent 工作流编排与 MCP 工具调用。\n\n- 开源、可私有化部署\n- RAG + 工作流 + 工具\n- 多模型接入',
...overrides,
}
}

const detail = ref<ExecutionNodeDetail[]>([
makeAiChat({ index: 0 }),
{
type: WorkflowNodeType.LoopNode,
name: '循环',
status: 200,
index: 1,
run_time: 4.567,
loop_type: 'ARRAY',
loop_node_data: {
'0': {
aiNode: makeAiChat({
name: 'AI 对话(第 1 轮)',
index: 0,
question: '第 1 项:介绍知识库',
answer: '知识库把文档切片、向量化后存入 pgvector,用于语义检索。',
run_time: 0.98,
}),
},
'1': {
aiNode: makeAiChat({
name: 'AI 对话(第 2 轮)',
index: 0,
question: '第 2 项:介绍工作流',
answer: '工作流用 LogicFlow 编排节点,支持循环、分支与工具调用。',
run_time: 1.12,
status: 500,
err_message: '模型调用超时(示例错误,用于查看失败态样式)',
}),
},
},
} as ExecutionNodeDetail,
])

const mode = ref<WorkflowMode>(WorkflowMode.Application)
</script>

<template>
<div class="min-h-screen bg-N100 p-6">
<div class="mx-auto max-w-[480px]">
<div class="mb-4 flex items-center gap-3">
<h3>执行详情样式 Demo</h3>
<el-radio-group v-model="mode" size="small">
<el-radio-button :value="WorkflowMode.Application">application</el-radio-button>
<el-radio-button :value="WorkflowMode.Knowledge">knowledge</el-radio-button>
</el-radio-group>
</div>
<ExecutionDetailContent :detail="detail" :workflow-mode="mode" />
</div>
</div>
</template>
39 changes: 38 additions & 1 deletion ui/src/workflow-canvas/WORKFLOW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
src/workflow-canvas/
├── component/ # 画布内可复用的控制和搜索组件
├── config/ # 节点数据、映射、常量及预留的本地化配置
├── details/ # 执行详情:顶层分发入口与公共卡壳,节点内容由 nodes/*/details/ 提供
├── core/ # 稳定的画布内核与所有节点共用的基础能力
│ ├── edge/ # 普通边、循环边及边删除按钮
│ └── node-container/ # 节点容器、锚点按钮及私有的条件、操作下拉组件
Expand Down Expand Up @@ -225,6 +226,40 @@ MkFormList 的排序、增删均以 `cloneDeep` 回写;MkTable 保留普通行
带 ID 的新行使用 `default-item` 工厂函数,每次添加生成独立 ID。条件分支等保留锚点 ID、
固定末尾分支的特殊规则不能直接替换成普通列表移动。

### 执行详情(`details/`)

节点执行详情按三层组织,与画布节点注册同源,通过节点类型分发渲染:

- `details/index.vue`(`ExecutionDetailContent`)是顶层分发层:接收 `detail` 数组和 `workflowMode`,
按 `index` 升序,逐项按节点类型渲染对应节点的详情组件。类型到详情组件的映射来自
`import.meta.glob('../nodes/*/index.ts')` 收集的默认导出中的 `details` 字段,与画布节点注册同一份
清单,不按目录名推断,也不再单独维护映射表。
- `details/DetailContainer.vue` 是纯布局卡壳(架子),不认识任何具体节点类型:提供 `#header` 具名
插槽(透出折叠 `show`)、折叠体默认插槽承载节点内容,以及 `showContentOnError` 决定失败时是否仍
展示内容(默认失败只显示错误日志块)。
- `details/BaseHeader.vue` 是公共头部:折叠箭头、节点图标、名称、耗时和状态图标;是否显示 tokens 由
节点通过 `show-tokens` 布尔控制,tokens 数值由 `BaseHeader` 从 `data` 自行计算,容器不参与该判断。
- 详情载荷类型 `ExecutionNodeDetail` 在 `details/types.ts`,以开放索引签名承载各节点动态字段,仅显式
声明外层卡片通用字段;分发和模式统一使用 `WorkflowMode` 枚举,不自造字符串联合类型。

每个节点的详情入口统一为 `nodes/<node-type>/details/index.vue`,并在该节点 `index.ts` 的默认导出中通过
`details` 字段注册(LogicFlow 注册忽略该额外字段):

- 内容不随工作流模式变化的节点(如循环),`details/index.vue` 直接就是详情内容。
- 内容随模式不同的节点(如 AI 对话),`details/index.vue` 按 `workflowMode` 用映射分发到同目录下的
`application.vue`、`knowledge.vue` 等模式文件,其它模式兜底到应用视图;各模式文件各自完整、自包含,
共用部分不强行抽取。

详情内的灰底标题块直接使用内联 Tailwind(`overflow-hidden rounded-md bg-N100` + `h5.px-3.py-2` +
`border-t border-dashed px-3 py-2 text-N900`),不引入独立的区块组件。只读 Markdown 回答复用全局
`MdPreview`,并在节点内通过 `:deep()` 覆盖其固定高度与背景以融入灰底。

循环节点的详情是唯一的递归点:它维护循环设置与轮次选择,选中某轮后把该轮的子节点数组交回顶层
`ExecutionDetailContent` 渲染(排序与类型分发由顶层负责),并对外层卡壳传 `show-content-on-error`
以便整体失败时仍能展开已执行的子节点。递归由数据驱动、逐层向下收敛(一个循环的轮次数据不含它
自身),执行详情是有限树因而必然终止;`details/index.vue` 与节点详情之间的循环 import 仅在渲染期
使用、不在模块求值期调用,属正常的组件递归引用。

## View 接入约定

- 所有画布路由页面均放在 `src/views/workflow/`,并以 `XxxWorkflowView.vue` 命名,例如
Expand Down Expand Up @@ -260,7 +295,9 @@ MkFormList 的排序、增删均以 `cloneDeep` 回写;MkTable 保留普通行
3. 在 `config/node-mapping.ts` 中维护节点映射;需要作为初始节点时再加入默认节点集合。
4. 在 `node-menu/menu.ts` 中加入适用画布模式的菜单分组。
5. 在 `nodes/<node-type>/` 中实现注册文件和节点视图,并按需增加 `icons/` 图标。
6. 确认节点能被自动注册、从菜单添加、正确连线、校验并导出图数据。
6. 如需执行详情,在 `nodes/<node-type>/details/index.vue` 中实现,并在 `index.ts` 默认导出中通过
`details` 字段注册;内容随模式变化时在该目录下按 `workflowMode` 分发到模式文件。
7. 确认节点能被自动注册、从菜单添加、正确连线、校验并导出图数据。

节点协议值统一使用 `WorkflowNodeType` 等枚举或画布常量,不在判断和映射中重复书写
`ai-chat-node`、`tool-custom-node` 等字符串字面量。
Expand Down
45 changes: 45 additions & 0 deletions ui/src/workflow-canvas/details/BaseHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { CaretRight, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
import { iconComponent } from '@/workflow-canvas/icons/utils'
import type { ExecutionNodeDetail } from './types'

defineOptions({ name: 'BaseHeader' })

const props = defineProps<{
data: ExecutionNodeDetail
show?: boolean
// 是否显示 tokens 由各节点自行决定;tokens 数值统一由 data 计算。
showTokens?: boolean
}>()

const nodeIconComponent = computed<Component | null>(() => iconComponent(`${props.data?.type}-icon`))
const isSuccess = computed(() => props.data?.status === 200)
const isRunning = computed(() => props.data?.status === 202)
const tokenCount = computed(() => (props.data?.message_tokens || 0) + (props.data?.answer_tokens || 0))
</script>

<template>
<div class="flex-between">
<div class="flex items-center gap-2">
<el-icon class="text-N600 transition-transform" :class="{ 'rotate-90': show }">
<CaretRight />
</el-icon>
<component :is="nodeIconComponent" v-if="nodeIconComponent" :size="24" />
<h4>{{ data.name }}</h4>
</div>
<div class="flex items-center gap-3 text-N600">
<span v-if="showTokens">{{ tokenCount }} tokens</span>
<span v-if="!isRunning">{{ data?.run_time?.toFixed(2) || '0.00' }} s</span>
<el-icon v-if="isSuccess" class="text-success" :size="16">
<CircleCheck />
</el-icon>
<el-icon v-else-if="isRunning" class="is-loading text-primary" :size="16">
<Loading />
</el-icon>
<el-icon v-else class="text-danger" :size="16">
<CircleClose />
</el-icon>
</div>
</div>
</template>
38 changes: 38 additions & 0 deletions ui/src/workflow-canvas/details/DetailContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ExecutionNodeDetail } from './types'

defineOptions({ name: 'DetailContainer' })

const props = withDefaults(
defineProps<{
data: ExecutionNodeDetail
// 循环等节点即使整体失败也需展示已执行的子节点详情,其余节点失败时只展示错误日志。
showContentOnError?: boolean
}>(),
{
showContentOnError: false,
},
)

const show = ref(false)
const isSuccess = computed(() => props.data?.status === 200)
const showContent = computed(() => isSuccess.value || props.showContentOnError)
</script>

<template>
<el-card class="execution-detail-card mb-2" shadow="never" style="--el-card-padding: 8px 12px">
<div class="cursor-pointer" @click="show = !show">
<slot name="header" :show="show" />
</div>
<el-collapse-transition>
<div v-if="show" class="mt-3 flex flex-col gap-2">
<slot v-if="showContent" />
<div v-else class="overflow-hidden rounded-md bg-N100">
<h5 class="px-3 py-2">错误日志</h5>
<div class="border-t border-dashed px-3 py-2 text-N900">{{ data.err_message || '-' }}</div>
</div>
</div>
</el-collapse-transition>
</el-card>
</template>
55 changes: 55 additions & 0 deletions ui/src/workflow-canvas/details/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { WorkflowMode, WorkflowNodeType } from '@/workflow-canvas/types'
import type { ExecutionNodeDetail } from './types'

defineOptions({ name: 'ExecutionDetailContent' })

const props = withDefaults(
defineProps<{
detail?: ExecutionNodeDetail[]
workflowMode?: WorkflowMode
}>(),
{
detail: () => [],
workflowMode: WorkflowMode.Application,
},
)

// 复用节点注册表:每个节点的 index.ts 默认导出携带注册的 WorkflowNodeType 及其执行详情视图
// details(各节点的整卡),与画布注册节点同源。据此建立 type -> 详情组件 映射,按节点类型渲染。
const nodeRegistryModules = import.meta.glob<{ default: { type: WorkflowNodeType; details?: Component } }>(
'../nodes/*/index.ts',
{ eager: true },
)

const nodeDetailComponentMap: Map<WorkflowNodeType, Component> = (() => {
const map = new Map<WorkflowNodeType, Component>()
for (const { default: registration } of Object.values(nodeRegistryModules)) {
if (registration?.details) map.set(registration.type, registration.details)
}
return map
})()

function nodeDetailComponent(type?: WorkflowNodeType): Component | null {
return type ? (nodeDetailComponentMap.get(type) ?? null) : null
}

// 按 index 升序展示各节点执行详情。
const sortedDetail = computed(() =>
[...props.detail].sort((a, b) => ((a?.index as number) || 0) - ((b?.index as number) || 0)),
)
</script>

<template>
<div class="flex flex-col gap-2">
<template v-for="(item, index) in sortedDetail" :key="index">
<component
:is="nodeDetailComponent(item.type)"
v-if="nodeDetailComponent(item.type)"
:data="item"
:workflow-mode="workflowMode"
/>
</template>
</div>
</template>
18 changes: 18 additions & 0 deletions ui/src/workflow-canvas/details/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { WorkflowNodeType } from '@/workflow-canvas/types'

/**
* 节点执行详情的运行时载荷。字段随节点类型(含循环、工具工作流的嵌套子节点)差异极大,
* 由后端按节点动态返回,因此以开放索引签名承载动态字段,仅显式声明外层卡片通用的字段。
* 各节点的 details.vue 直接读取本类型所需字段,待接口契约稳定后再逐节点收敛为精确类型。
*/
export interface ExecutionNodeDetail {
[key: string]: any
answer_tokens?: number
err_message?: string
message_tokens?: number
name?: string
run_time?: number
status?: number
type?: WorkflowNodeType
}
Loading
Loading