feat: improve ai workflow runtime observability
This commit is contained in:
@@ -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 };
|
||||
|
||||
+150
-50
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user