Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { ExecutionLogEntry } from '#/api/ai-platform/ai-platform';
import { $t } from '@vben/locales';
import { ElEmpty, ElScrollbar, ElTimeline, ElTimelineItem } from 'element-plus';
import RunStatusTag from './RunStatusTag.vue';
defineProps<{
activeNodeId?: string;
logs: ExecutionLogEntry[];
}>();
const emit = defineEmits<{
select: [nodeId: string];
}>();
function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
</script>
<template>
<ElScrollbar class="h-full">
<ElEmpty
v-if="logs.length === 0"
:description="$t('ai-platform.workflowRuns.detail.noLogs')"
/>
<ElTimeline v-else class="px-2 py-3">
<ElTimelineItem
v-for="(log, index) in logs"
:key="`${log.node_id}-${index}`"
:timestamp="formatDuration(log.elapsed_time)"
placement="top"
>
<button
type="button"
class="hover:bg-muted w-full rounded border px-3 py-2 text-left transition-colors"
:class="activeNodeId === log.node_id ? 'border-primary bg-primary/5' : 'border-border'"
@click="emit('select', log.node_id)"
>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="text-foreground text-sm font-medium">
{{ log.node_label || log.node_id }}
</span>
<RunStatusTag :status="log.status" />
</div>
<div class="text-muted-foreground text-xs">
{{ log.node_type }}
</div>
<div
v-if="log.error"
class="text-destructive mt-1 line-clamp-2 text-xs"
>
{{ log.error }}
</div>
</button>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben/locales';
import { ElTag } from 'element-plus';
const props = defineProps<{ status: string }>();
const tagType = computed(() => {
switch (props.status) {
case 'completed':
return 'success';
case 'failed':
return 'danger';
case 'running':
return 'primary';
case 'waiting':
return 'warning';
case 'stopped':
return 'info';
default:
return 'info';
}
});
const label = computed(() => {
const key = `ai-platform.workflowRuns.status.${props.status}`;
const translated = $t(key);
return translated === key ? props.status : translated;
});
</script>
<template>
<ElTag :type="tagType" size="small">{{ label }}</ElTag>
</template>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { Edge, Node } from '@vue-flow/core';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useVueFlow, VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { workflowEdgeTypes, workflowNodeTypes } from '../../workflow/shared/workflowNodeTypes';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
const props = defineProps<{
edges: Edge[];
fitNodeId?: string;
nodes: Node[];
}>();
const containerRef = ref<HTMLElement>();
const { fitView } = useVueFlow();
async function refreshView(nodeId?: string) {
await nextTick();
if (!containerRef.value?.clientHeight) return;
if (nodeId) {
fitView({ nodes: [nodeId], padding: 0.4, duration: 200 });
return;
}
fitView({ padding: 0.2, duration: 200 });
}
function onNodesInitialized() {
refreshView(props.fitNodeId);
}
watch(
() => props.fitNodeId,
(nodeId) => {
if (nodeId) refreshView(nodeId);
},
);
watch(
() => [props.nodes, props.edges],
() => refreshView(props.fitNodeId),
{ deep: true },
);
let resizeObserver: ResizeObserver | undefined;
onMounted(() => {
if (!containerRef.value) return;
resizeObserver = new ResizeObserver(() => {
if (containerRef.value && containerRef.value.clientHeight > 0) {
refreshView(props.fitNodeId);
}
});
resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div
ref="containerRef"
class="workflow-readonly-canvas bg-muted/30 h-full w-full min-h-[320px]"
>
<VueFlow
:nodes="props.nodes"
:edges="props.edges"
:node-types="workflowNodeTypes"
:edge-types="workflowEdgeTypes"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
:pan-on-drag="[1, 2]"
:zoom-on-scroll="true"
@nodes-initialized="onNodesInitialized"
>
<MiniMap pannable zoomable />
</VueFlow>
</div>
</template>
<style scoped>
.workflow-readonly-canvas :deep(.vue-flow) {
width: 100%;
height: 100%;
}
.workflow-readonly-canvas :deep(.vue-flow__container) {
width: 100%;
height: 100%;
}
.workflow-readonly-canvas :deep(.vue-flow__node) {
cursor: default;
overflow: visible;
}
.workflow-readonly-canvas :deep(.nopan) {
pointer-events: all;
}
.workflow-readonly-canvas :deep(.vue-flow__panel.top) {
display: none;
}
.workflow-readonly-canvas :deep(.vue-flow__edge button) {
display: none !important;
pointer-events: none;
}
</style>
@@ -0,0 +1,160 @@
import type { Column } from 'element-plus';
import type { VbenFormSchema } from '#/adapter/form';
import { $t } from '@vben/locales';
import { getWorkflowListApi } from '#/api/ai-platform/ai-platform';
type TagType = 'danger' | 'info' | 'primary' | 'success' | 'warning';
export function getStatusOptions() {
return [
{ label: $t('ai-platform.workflowRuns.status.pending'), value: 'pending', type: 'info' as TagType },
{ label: $t('ai-platform.workflowRuns.status.running'), value: 'running', type: 'primary' as TagType },
{ label: $t('ai-platform.workflowRuns.status.waiting'), value: 'waiting', type: 'warning' as TagType },
{ label: $t('ai-platform.workflowRuns.status.completed'), value: 'completed', type: 'success' as TagType },
{ label: $t('ai-platform.workflowRuns.status.failed'), value: 'failed', type: 'danger' as TagType },
{ label: $t('ai-platform.workflowRuns.status.stopped'), value: 'stopped', type: 'info' as TagType },
];
}
export function getTriggerOptions() {
return [
{ label: $t('ai-platform.workflowRuns.trigger.editor_draft'), value: 'editor_draft', type: 'warning' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.editor_published'), value: 'editor_published', type: 'primary' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.agent'), value: 'agent', type: 'success' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.api'), value: 'api', type: 'info' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.form_button'), value: 'form_button', type: 'info' as TagType },
];
}
export function getTagType(value: string, options: Array<{ type?: TagType; value: string }>): TagType {
const option = options.find((item) => item.value === value);
return option?.type || 'info';
}
export function getTagLabel(value: string, options: Array<{ label: string; value: string }>): string {
const option = options.find((item) => item.value === value);
return option?.label || value;
}
export function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function formatTime(value?: string) {
if (!value) return '-';
return new Date(value).toLocaleString();
}
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'ApiSelect',
fieldName: 'workflowId',
label: $t('ai-platform.workflowRuns.filters.workflow'),
componentProps: {
api: async () => {
const res = await getWorkflowListApi({ page: 1, pageSize: 200 });
return (res.items || []).map((item) => ({
label: item.name,
value: item.id,
}));
},
placeholder: $t('ai-platform.workflowRuns.filters.allWorkflows'),
clearable: true,
filterable: true,
},
},
{
component: 'Select',
fieldName: 'status',
label: $t('ai-platform.workflowRuns.filters.status'),
componentProps: {
placeholder: $t('ai-platform.workflowRuns.filters.allStatus'),
options: getStatusOptions(),
clearable: true,
},
},
{
component: 'Select',
fieldName: 'triggerType',
label: $t('ai-platform.workflowRuns.filters.trigger'),
componentProps: {
placeholder: $t('ai-platform.workflowRuns.filters.allTriggers'),
options: getTriggerOptions(),
clearable: true,
},
},
];
}
export function useZqTableColumns(): Column[] {
return [
{
key: 'workflow_name',
dataKey: 'workflow_name',
title: $t('ai-platform.workflowRuns.columns.workflow'),
minWidth: 160,
},
{
key: 'status',
title: $t('ai-platform.workflowRuns.columns.status'),
width: 110,
align: 'center' as const,
slots: { default: 'cell-status' },
},
{
key: 'trigger_type',
title: $t('ai-platform.workflowRuns.columns.trigger'),
width: 140,
align: 'center' as const,
slots: { default: 'cell-trigger_type' },
},
{
key: 'total_steps',
dataKey: 'total_steps',
title: $t('ai-platform.workflowRuns.columns.steps'),
width: 80,
align: 'center' as const,
},
{
key: 'total_tokens',
dataKey: 'total_tokens',
title: $t('ai-platform.workflowRuns.columns.tokens'),
width: 90,
align: 'center' as const,
},
{
key: 'elapsed_time',
title: $t('ai-platform.workflowRuns.columns.duration'),
width: 100,
align: 'center' as const,
slots: { default: 'cell-elapsed_time' },
},
{
key: 'started_at',
title: $t('ai-platform.workflowRuns.columns.startedAt'),
width: 180,
slots: { default: 'cell-started_at' },
},
{
key: 'error_message',
dataKey: 'error_message',
title: $t('ai-platform.workflowRuns.columns.error'),
minWidth: 180,
showOverflowTooltip: true,
},
{
key: 'actions',
title: $t('ai-platform.workflowRuns.columns.actions'),
width: 120,
fixed: true,
align: 'center' as const,
slots: { default: 'cell-actions' },
},
];
}
@@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { WorkflowRunListItem } from '#/api/ai-platform/ai-platform';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Eye } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElTag } from 'element-plus';
import { getAllWorkflowRunsApi } from '#/api/ai-platform/ai-platform';
import { useZqTable } from '#/components/zq-table';
import RunStatusTag from './components/RunStatusTag.vue';
import {
formatDuration,
formatTime,
getTagLabel,
getTagType,
getTriggerOptions,
useSearchFormSchema,
useZqTableColumns,
} from './data';
import DetailDialog from './modules/detail-dialog.vue';
defineOptions({ name: 'WorkflowRunHistory' });
const detailRef = ref<InstanceType<typeof DetailDialog>>();
const triggerOptions = getTriggerOptions();
const fetchRunList = async (params: any) => {
const res = await getAllWorkflowRunsApi({
page: params.page.currentPage,
pageSize: params.page.pageSize,
workflowId: params.form?.workflowId || undefined,
status: params.form?.status || undefined,
triggerType: params.form?.triggerType || undefined,
});
return {
items: res.items,
total: res.total,
};
};
const [Grid] = useZqTable({
gridOptions: {
columns: useZqTableColumns(),
border: true,
stripe: true,
showIndex: true,
proxyConfig: {
autoLoad: true,
ajax: {
query: fetchRunList,
},
},
pagerConfig: {
enabled: true,
pageSize: 20,
},
toolbarConfig: {
search: true,
refresh: true,
zoom: true,
custom: true,
},
},
formOptions: {
schema: useSearchFormSchema(),
showCollapseButton: true,
submitOnChange: true,
},
});
function openDetail(row: WorkflowRunListItem) {
detailRef.value?.open(row.id);
}
</script>
<template>
<Page auto-content-height>
<DetailDialog ref="detailRef" />
<Grid>
<template #cell-status="{ row }">
<RunStatusTag :status="row.status" />
</template>
<template #cell-trigger_type="{ row }">
<ElTag
:type="getTagType(row.trigger_type, triggerOptions)"
size="small"
>
{{ getTagLabel(row.trigger_type, triggerOptions) }}
</ElTag>
</template>
<template #cell-elapsed_time="{ row }">
{{ formatDuration(row.elapsed_time) }}
</template>
<template #cell-started_at="{ row }">
{{ formatTime(row.started_at) }}
</template>
<template #cell-actions="{ row }">
<ElButton link type="primary" :icon="Eye" @click.stop="openDetail(row)">
{{ $t('common.view') }}
</ElButton>
</template>
</Grid>
</Page>
</template>
@@ -0,0 +1,236 @@
<script lang="ts" setup>
import type { Edge, Node } from '@vue-flow/core';
import type { WorkflowRun } from '#/api/ai-platform/ai-platform';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ExternalLink } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElAlert, ElButton, ElTag } from 'element-plus';
import { getWorkflowDetailApi, getWorkflowRunDetailApi } from '#/api/ai-platform/ai-platform';
import { ZqDesc, ZqDescItem } from '#/components/zq-desc';
import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
import { definitionToFlowElements } from '../../workflow/shared/loadDefinition';
import { applyRunReplay } from '../../workflow/shared/useRunReplay';
import RunStatusTag from '../components/RunStatusTag.vue';
import WorkflowReadonlyCanvas from '../components/WorkflowReadonlyCanvas.vue';
import {
formatDuration,
formatTime,
getTagLabel,
getTagType,
getTriggerOptions,
} from '../data';
const router = useRouter();
const appContextStore = useAppContextStore();
const triggerOptions = getTriggerOptions();
const visible = ref(false);
const loading = ref(false);
const runId = ref('');
const run = ref<WorkflowRun | null>(null);
const nodes = ref<Node[]>([]);
const edges = ref<Edge[]>([]);
const activeNodeId = ref('');
const definitionFallback = ref(false);
const dialogTitle = computed(() => run.value?.workflow_name || $t('ai-platform.workflowRuns.detail.runId'));
async function loadDetail() {
if (!runId.value) return;
loading.value = true;
definitionFallback.value = false;
activeNodeId.value = '';
run.value = null;
nodes.value = [];
edges.value = [];
try {
const detail = await getWorkflowRunDetailApi(runId.value);
run.value = detail;
let definition = detail.definition_snapshot;
if (!definition?.nodes?.length) {
definitionFallback.value = true;
try {
const workflow = await getWorkflowDetailApi(detail.workflow_id);
definition = detail.use_draft
? workflow.definition
: workflow.published_definition || workflow.definition;
} catch (error) {
console.error(error);
definition = { nodes: [], edges: [] };
}
}
const base = definitionToFlowElements(definition || { nodes: [], edges: [] });
const replayed = applyRunReplay(base.nodes, base.edges, detail);
nodes.value = replayed.nodes;
edges.value = replayed.edges;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
function open(id: string) {
runId.value = id;
visible.value = true;
}
function handleOpen() {
loadDetail();
}
function openEditor() {
if (!run.value?.workflow_id) return;
visible.value = false;
router.push(
appContextStore.getContextPath(
`/ai-platform/workflow/editor/${run.value.workflow_id}`,
),
);
}
function triggerLabel(value?: string) {
if (!value) return '-';
return getTagLabel(value, triggerOptions);
}
defineExpose({ open });
</script>
<template>
<ZqDialog
v-model="visible"
class="workflow-run-detail-dialog"
:title="dialogTitle"
:loading="loading"
default-fullscreen
:draggable="false"
:show-footer="false"
@open="handleOpen"
>
<template #title>
<span class="flex items-center gap-2">
{{ dialogTitle }}
<RunStatusTag v-if="run" :status="run.status" />
</span>
</template>
<template #header-extra>
<ElButton
v-if="run?.workflow_id"
:icon="ExternalLink"
@click="openEditor"
>
{{ $t('ai-platform.workflowRuns.detail.openEditor') }}
</ElButton>
</template>
<div v-if="run" class="run-detail-layout flex h-full min-h-0 flex-col gap-4">
<ElAlert
v-if="definitionFallback"
type="warning"
:closable="false"
show-icon
:title="$t('ai-platform.workflowRuns.detail.snapshotMissing')"
/>
<ZqDesc :column="4">
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.runId')">
{{ runId }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.trigger')">
<ElTag
:type="getTagType(run.trigger_type, triggerOptions)"
size="small"
>
{{ triggerLabel(run.trigger_type) }}
</ElTag>
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.steps')">
{{ run.total_steps }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.tokens')">
{{ run.total_tokens }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.duration')">
{{ formatDuration(run.elapsed_time) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.startedAt')">
{{ formatTime(run.started_at) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.completedAt')">
{{ formatTime(run.completed_at) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.version')">
{{
run.use_draft
? $t('ai-platform.workflowRuns.detail.draft')
: run.workflow_version || '-'
}}
</ZqDescItem>
<ZqDescItem
v-if="run.error_message"
:label="$t('ai-platform.workflowRuns.columns.error')"
:span="4"
>
<span class="text-destructive">{{ run.error_message }}</span>
</ZqDescItem>
</ZqDesc>
<div class="run-detail-canvas border-border overflow-hidden rounded-lg border">
<WorkflowReadonlyCanvas
:nodes="nodes"
:edges="edges"
:fit-node-id="activeNodeId"
/>
</div>
</div>
</ZqDialog>
</template>
<style>
.workflow-run-detail-dialog.is-fullscreen .el-dialog__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.workflow-run-detail-dialog .zq-dialog-body {
flex: 1;
min-height: 0;
}
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__wrap,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__view,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__view > div {
height: 100%;
}
.workflow-run-detail-dialog .run-detail-layout {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 16px;
}
.workflow-run-detail-dialog .run-detail-canvas {
flex: 1;
min-height: 320px;
height: 0;
}
</style>