feat: improve ai workflow runtime observability

This commit is contained in:
2026-06-22 10:11:05 +08:00
parent 9515a4d121
commit d954279c9c
4 changed files with 245 additions and 66 deletions
+40 -1
View File
@@ -71,6 +71,43 @@ function createLightAiMenuRoute(
return route as RouteRecordStringComponent;
}
const PREFETCH_LIGHT_PAGE_KEYS = [
'../views/_core/agent-chat/index.vue',
'../views/_core/menu/index.vue',
'../views/_core/page-render/index.vue',
'../views/_core/role/index.vue',
'../views/_core/user/index.vue',
'../views/ai-platform/agent/index.vue',
'../views/ai-platform/model/index.vue',
'../views/ai-platform/workflow-runs/index.vue',
'../views/ai-platform/workflow/index.vue',
];
let lightPagesPrefetched = false;
function scheduleIdleTask(callback: () => void) {
if (typeof window === 'undefined') return;
const requestIdle = (window as any).requestIdleCallback;
if (typeof requestIdle === 'function') {
requestIdle(callback, { timeout: 3000 });
return;
}
window.setTimeout(callback, 1200);
}
function prefetchLightPages(pageMap: ComponentRecordType) {
if (lightPagesPrefetched) return;
lightPagesPrefetched = true;
scheduleIdleTask(() => {
for (const key of PREFETCH_LIGHT_PAGE_KEYS) {
const loader = pageMap[key];
if (typeof loader === 'function') {
void loader().catch(() => undefined);
}
}
});
}
const LIGHT_AI_PLATFORM_MENU_ROUTES = [
createLightAiMenuRoute({
component: '/_core/agent-chat/index',
@@ -220,7 +257,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
};
const routePageMap = normalizePageMap(pageMap);
return await generateAccessible(preferences.app.accessMode, {
const accessible = await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
const appContextStore = useAppContextStore();
@@ -235,6 +272,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
layoutMap,
pageMap,
});
prefetchLightPages(pageMap);
return accessible;
}
export { generateAccess };
@@ -25,6 +25,8 @@ const emit = defineEmits<{
select: [nodeId: string];
}>();
type TagType = 'danger' | 'info' | 'primary' | 'success' | 'warning';
function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`;
@@ -50,15 +52,19 @@ function getCommunication(log: ExecutionLogEntry) {
return log.metadata?.communication || log.event?.communication || {};
}
function getCollaboration(log: ExecutionLogEntry) {
return log.metadata?.collaboration || log.event?.collaboration || {};
}
function getEventCollaboration(event: WorkflowRunEvent) {
return event.collaboration || event.event?.collaboration || {};
}
function getMetaItems(log: ExecutionLogEntry) {
const metadata = log.metadata || {};
const items: Array<{
label: string;
type?: 'danger' | 'info' | 'primary' | 'success' | 'warning';
}> = [];
const items: Array<{ label: string; type?: TagType }> = [];
const communication = getCommunication(log);
const collaboration =
metadata.collaboration || log.event?.collaboration || {};
const collaboration = getCollaboration(log);
const branchLabel =
log.branch_label ||
log.branch ||
@@ -88,24 +94,14 @@ function getMetaItems(log: ExecutionLogEntry) {
if (log.type || metadata.stream_event) {
items.push({
label: getEventTypeLabel(log.type || log.event?.type),
type: log.status === 'failed' ? 'danger' : 'info',
type: getLogTone(log),
});
}
if (branchLabel) {
items.push({ label: `分支: ${branchLabel}`, type: 'primary' });
}
if (subflowName) {
items.push({ label: `子流程: ${subflowName}`, type: 'success' });
}
if (providerName) {
items.push({ label: `提供商: ${providerName}`, type: 'info' });
}
if (modelName) {
items.push({ label: `模型: ${modelName}`, type: 'info' });
}
if (log.tokens_used) {
items.push({ label: `Token ${log.tokens_used}`, type: 'info' });
}
if (branchLabel) items.push({ label: `分支: ${branchLabel}`, type: 'primary' });
if (subflowName) items.push({ label: `子流程: ${subflowName}`, type: 'success' });
if (providerName) items.push({ label: `提供商: ${providerName}`, type: 'info' });
if (modelName) items.push({ label: `模型: ${modelName}`, type: 'info' });
if (log.tokens_used) items.push({ label: `Token ${log.tokens_used}`, type: 'info' });
if (communication.channel) {
items.push({ label: getChannelLabel(communication.channel), type: 'info' });
}
@@ -118,6 +114,7 @@ function getMetaItems(log: ExecutionLogEntry) {
function getChannelLabel(channel: string) {
const labels: Record<string, string> = {
agent_chat: '智能体对话',
human_input: '人机协作',
parallel_branch: '并行分支',
subflow: '子流程',
@@ -132,12 +129,18 @@ function getEventTypeLabel(type?: string) {
complete: '完成',
error: '错误',
llm_chunk: 'LLM 输出',
loop_complete: '循环完成',
loop_iteration_complete: '循环迭代完成',
loop_iteration_error: '循环迭代失败',
loop_iteration_start: '循环迭代开始',
loop_start: '循环开始',
message: '消息',
node_complete: '节点完成',
node_event: '节点事件',
node_start: '节点开始',
parallel_complete: '并行完成',
parallel_start: '并行开始',
resume: '恢复执行',
start: '开始',
waiting_input: '等待输入',
};
@@ -150,22 +153,24 @@ function countItems(value: any) {
return 0;
}
function getWaitingSummary(event: WorkflowRunEvent) {
const waitingConfig = event.waiting_config || event.config;
return stringifyBrief(
waitingConfig?.title ||
waitingConfig?.question ||
waitingConfig?.content ||
waitingConfig ||
'等待用户输入',
160,
);
}
function getEventSummary(event: WorkflowRunEvent) {
const eventType = event.type || event.event?.type;
const waitingConfig = event.waiting_config || event.config;
if (event.error_message) return event.error_message;
if (event.error) return event.error;
if (eventType === 'waiting_input') {
return stringifyBrief(
waitingConfig?.title ||
waitingConfig?.question ||
waitingConfig?.content ||
waitingConfig ||
'等待用户输入',
160,
);
}
if (eventType === 'waiting_input') return getWaitingSummary(event);
if (eventType === 'parallel_start') {
return `启动 ${countItems(event.branches || event.branch_labels)} 个并行分支`;
}
@@ -196,13 +201,56 @@ function getEventSummary(event: WorkflowRunEvent) {
return stringifyBrief(event, 140);
}
function getEventMetaItems(event: WorkflowRunEvent) {
const collaboration = getEventCollaboration(event);
const items: Array<{ label: string; type?: TagType }> = [];
const branchLabel =
event.branch_label ||
event.branch_id ||
collaboration.branch_label ||
collaboration.branch_id;
const subflowName = event.subflow_name || collaboration.subflow_name;
const providerName = event.provider_name || collaboration.provider_name;
const modelName =
event.model ||
event.model_name ||
event.model_id ||
collaboration.model ||
collaboration.model_name ||
collaboration.model_id;
if (branchLabel) items.push({ label: `分支 ${branchLabel}`, type: 'primary' });
if (subflowName) items.push({ label: `子流程 ${subflowName}`, type: 'success' });
if (providerName) items.push({ label: providerName, type: 'info' });
if (modelName) items.push({ label: `模型 ${modelName}`, type: 'info' });
if (event.tokens_used || event.total_tokens) {
items.push({
label: `Token ${event.tokens_used || event.total_tokens}`,
type: 'info',
});
}
return items;
}
function getLogEvents(log: ExecutionLogEntry): WorkflowRunEvent[] {
const primaryEvent = log.event ? [log.event] : [];
const directEvents = Array.isArray(log.events) ? log.events : [];
const metadataEvents = Array.isArray(log.metadata?.events)
? log.metadata.events
: [];
return [...primaryEvent, ...directEvents, ...metadataEvents].filter(Boolean);
const events = [
...(log.event ? [log.event] : []),
...(Array.isArray(log.events) ? log.events : []),
...(Array.isArray(log.metadata?.events) ? log.metadata.events : []),
].filter(Boolean);
const seen = new Set<string>();
return events.filter((event) => {
const key = [
event.type || event.event?.type || '',
event.node_id || event.event?.node_id || '',
event.timestamp || event.event?.timestamp || '',
stringifyBrief(event.content || event.message || event.error_message, 80),
].join('|');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function getVisibleEvents(log: ExecutionLogEntry) {
@@ -229,6 +277,33 @@ function getLogDetail(log: ExecutionLogEntry) {
communication.message,
);
}
function getLogTitle(log: ExecutionLogEntry) {
if (log.node_label) return log.node_label;
if (log.node_id && log.node_id !== 'workflow') return log.node_id;
return getEventTypeLabel(log.type || log.event?.type);
}
function getLogTone(log: ExecutionLogEntry): TagType {
if (log.status === 'failed' || log.type === 'error') return 'danger';
if (log.status === 'waiting' || log.type === 'waiting_input') return 'warning';
if (log.status === 'completed' || log.type === 'complete') return 'success';
if (log.status === 'running') return 'primary';
return 'info';
}
function getEventTone(event: WorkflowRunEvent): TagType {
const type = event.type || event.event?.type;
if (type === 'error' || event.error || event.error_message) return 'danger';
if (type === 'waiting_input') return 'warning';
if (type === 'complete' || type === 'node_complete' || type === 'parallel_complete') {
return 'success';
}
if (type === 'start' || type === 'node_start' || type === 'parallel_start') {
return 'primary';
}
return 'info';
}
</script>
<template>
@@ -240,8 +315,10 @@ function getLogDetail(log: ExecutionLogEntry) {
<ElTimeline v-else class="px-2 py-3">
<ElTimelineItem
v-for="(log, index) in logs"
:key="`${log.node_id}-${index}`"
:key="`${log.node_id}-${log.type || 'node'}-${index}`"
:timestamp="formatDuration(log.elapsed_time)"
:type="getLogTone(log)"
:hollow="log.status === 'running' || log.status === 'waiting'"
placement="top"
>
<button
@@ -255,15 +332,15 @@ function getLogDetail(log: ExecutionLogEntry) {
@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 class="text-foreground min-w-0 truncate text-sm font-medium">
{{ getLogTitle(log) }}
</span>
<RunStatusTag :status="log.status" />
</div>
<div
class="text-muted-foreground flex flex-wrap items-center gap-1 text-xs"
>
<span>{{ log.node_type }}</span>
<span v-if="log.node_type">{{ log.node_type }}</span>
<ElTag
v-for="item in getMetaItems(log)"
:key="item.label"
@@ -291,14 +368,37 @@ function getLogDetail(log: ExecutionLogEntry) {
<div
v-for="(event, eventIndex) in getVisibleEvents(log)"
:key="`${log.node_id}-event-${eventIndex}`"
class="text-muted-foreground flex gap-2 text-xs"
class="border-border/70 bg-background/60 mb-1 rounded border px-2 py-1 last:mb-0"
>
<span class="shrink-0 font-medium">
{{ getEventTypeLabel(event.type || event.event?.type) }}
</span>
<span class="line-clamp-2 min-w-0 whitespace-pre-wrap">
{{ getEventSummary(event) }}
</span>
<div class="flex min-w-0 items-start gap-2">
<ElTag
class="shrink-0"
:type="getEventTone(event)"
size="small"
effect="plain"
>
{{ getEventTypeLabel(event.type || event.event?.type) }}
</ElTag>
<span
class="text-muted-foreground line-clamp-2 min-w-0 whitespace-pre-wrap text-xs"
>
{{ getEventSummary(event) }}
</span>
</div>
<div
v-if="getEventMetaItems(event).length"
class="mt-1 flex flex-wrap gap-1 pl-[68px]"
>
<ElTag
v-for="item in getEventMetaItems(event)"
:key="item.label"
:type="item.type"
size="small"
effect="plain"
>
{{ item.label }}
</ElTag>
</div>
</div>
<div
v-if="getHiddenEventCount(log)"
@@ -1,16 +1,32 @@
import { markRaw } from 'vue';
import AddButtonEdge from '../editor/edges/AddButtonEdge.vue';
import AppCreateNode from '../editor/nodes/AppCreateNode.vue';
import AppDesignNode from '../editor/nodes/AppDesignNode.vue';
import AppSettingsNode from '../editor/nodes/AppSettingsNode.vue';
import AppUpdateNode from '../editor/nodes/AppUpdateNode.vue';
import ChoiceNode from '../editor/nodes/ChoiceNode.vue';
import CodeNode from '../editor/nodes/CodeNode.vue';
import ConditionNode from '../editor/nodes/ConditionNode.vue';
import ConfirmNode from '../editor/nodes/ConfirmNode.vue';
import DashboardBasicInfoNode from '../editor/nodes/DashboardBasicInfoNode.vue';
import DashboardCreateNode from '../editor/nodes/DashboardCreateNode.vue';
import DashboardDesignNode from '../editor/nodes/DashboardDesignNode.vue';
import DashboardPublishNode from '../editor/nodes/DashboardPublishNode.vue';
import DbDeleteNode from '../editor/nodes/DbDeleteNode.vue';
import DbInsertNode from '../editor/nodes/DbInsertNode.vue';
import DbQueryNode from '../editor/nodes/DbQueryNode.vue';
import DbSqlNode from '../editor/nodes/DbSqlNode.vue';
import DbUpdateNode from '../editor/nodes/DbUpdateNode.vue';
import EndNode from '../editor/nodes/EndNode.vue';
import FormBasicInfoNode from '../editor/nodes/FormBasicInfoNode.vue';
import FormCreateNode from '../editor/nodes/FormCreateNode.vue';
import FormDatabaseCreateNode from '../editor/nodes/FormDatabaseCreateNode.vue';
import FormDatabaseDesignNode from '../editor/nodes/FormDatabaseDesignNode.vue';
import FormDataNode from '../editor/nodes/FormDataNode.vue';
import FormListDesignNode from '../editor/nodes/FormListDesignNode.vue';
import FormPublishNode from '../editor/nodes/FormPublishNode.vue';
import FormUIDesignNode from '../editor/nodes/FormUIDesignNode.vue';
import HttpNode from '../editor/nodes/HttpNode.vue';
import IntentNode from '../editor/nodes/IntentNode.vue';
import KnowledgeRetrievalNode from '../editor/nodes/KnowledgeRetrievalNode.vue';
@@ -51,8 +67,29 @@ export const workflowNodeTypes = {
snowflake_cortex_llm: markRaw(SnowflakeCortexLLMNode),
snowflake_cortex_analyst: markRaw(SnowflakeCortexAnalystNode),
loop: markRaw(LoopNode),
form_basic_info: markRaw(FormBasicInfoNode),
form_database_design: markRaw(FormDatabaseDesignNode),
form_database_create: markRaw(FormDatabaseCreateNode),
form_ui_design: markRaw(FormUIDesignNode),
form_list_design: markRaw(FormListDesignNode),
form_create: markRaw(FormCreateNode),
form_publish: markRaw(FormPublishNode),
app_create: markRaw(AppCreateNode),
app_design: markRaw(AppDesignNode),
app_settings: markRaw(AppSettingsNode),
app_update: markRaw(AppUpdateNode),
dashboard_basic_info: markRaw(DashboardBasicInfoNode),
dashboard_design: markRaw(DashboardDesignNode),
dashboard_create: markRaw(DashboardCreateNode),
dashboard_publish: markRaw(DashboardPublishNode),
system_summary: markRaw(SystemSummaryNode),
subflow: markRaw(SubflowNode),
form_data_create: markRaw(FormDataNode),
form_data_read: markRaw(FormDataNode),
form_data_update: markRaw(FormDataNode),
form_data_delete: markRaw(FormDataNode),
form_data_list: markRaw(FormDataNode),
form_schema_to_llm: markRaw(FormDataNode),
text_to_sql: markRaw(TextToSqlNode),
knowledge_retrieval: markRaw(KnowledgeRetrievalNode),
};