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
@@ -225,11 +225,10 @@ class TextToSqlNode(BaseNode):
error='请输入要查询的问题', error='请输入要查询的问题',
) )
if not model_id: from ai_platform.services.llm_service import LLMService
return NodeResult(
success=False, llm_service = LLMService(context.db_session)
error='请选择 LLM 模型', model_id = await llm_service.resolve_chat_model_id(model_id)
)
# Step 1: 获取数据库 Schema # Step 1: 获取数据库 Schema
@@ -248,10 +247,7 @@ class TextToSqlNode(BaseNode):
# Step 2: 调用 LLM 生成 SQL # Step 2: 调用 LLM 生成 SQL
from datetime import datetime from datetime import datetime
from ai_platform.services.llm_service import LLMService
db_type = await self._get_db_type(db_connection) db_type = await self._get_db_type(db_connection)
llm_service = LLMService(context.db_session)
llm_result = None llm_result = None
use_function_calling = self.config.get('use_function_calling', True) use_function_calling = self.config.get('use_function_calling', True)
@@ -354,6 +350,7 @@ class TextToSqlNode(BaseNode):
tokens_used=response.total_tokens, tokens_used=response.total_tokens,
elapsed_time=elapsed_time, elapsed_time=elapsed_time,
metadata={ metadata={
'model_id': str(model_id),
'model': response.model, 'model': response.model,
'thought': thought, 'thought': thought,
'suggested_next_node': 'db_sql', 'suggested_next_node': 'db_sql',
@@ -404,13 +401,22 @@ class TextToSqlNode(BaseNode):
) )
return NodeResult(success=False, error='请输入要查询的问题') return NodeResult(success=False, error='请输入要查询的问题')
if not model_id: import asyncio
from ai_platform.services.llm_service import LLMService
llm_service = LLMService(context.db_session)
try:
model_id = asyncio.get_event_loop().run_until_complete(
llm_service.resolve_chat_model_id(model_id)
)
except Exception as e:
error_message = str(e)
yield TextToSqlStreamEvent( yield TextToSqlStreamEvent(
event_type='error', event_type='error',
content='请选择 LLM 模型', content=error_message,
is_finished=True, is_finished=True,
) )
return NodeResult(success=False, error='请选择 LLM 模型') return NodeResult(success=False, error=error_message)
# Step 1: 获取数据库 Schema # Step 1: 获取数据库 Schema
yield TextToSqlStreamEvent( yield TextToSqlStreamEvent(
@@ -420,7 +426,6 @@ class TextToSqlNode(BaseNode):
table_relations = self.config.get('table_relations', []) # 手动指定的表关系 table_relations = self.config.get('table_relations', []) # 手动指定的表关系
import asyncio
schema_context = asyncio.get_event_loop().run_until_complete( schema_context = asyncio.get_event_loop().run_until_complete(
self._get_schema_context( self._get_schema_context(
db_connection, db_connection,
@@ -449,13 +454,10 @@ class TextToSqlNode(BaseNode):
) )
from datetime import datetime from datetime import datetime
from ai_platform.services.llm_service import LLMService
db_type = asyncio.get_event_loop().run_until_complete( db_type = asyncio.get_event_loop().run_until_complete(
self._get_db_type(db_connection) self._get_db_type(db_connection)
) )
llm_service = LLMService()
accumulated_content = '' accumulated_content = ''
total_tokens = 0 total_tokens = 0
llm_result = None llm_result = None
@@ -566,6 +568,7 @@ class TextToSqlNode(BaseNode):
tokens_used=total_tokens, tokens_used=total_tokens,
elapsed_time=elapsed_time, elapsed_time=elapsed_time,
metadata={ metadata={
'model_id': str(model_id),
'thought': thought, 'thought': thought,
'suggested_next_node': 'db_sql', 'suggested_next_node': 'db_sql',
}, },
+40 -1
View File
@@ -71,6 +71,43 @@ function createLightAiMenuRoute(
return route as RouteRecordStringComponent; 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 = [ const LIGHT_AI_PLATFORM_MENU_ROUTES = [
createLightAiMenuRoute({ createLightAiMenuRoute({
component: '/_core/agent-chat/index', component: '/_core/agent-chat/index',
@@ -220,7 +257,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
}; };
const routePageMap = normalizePageMap(pageMap); const routePageMap = normalizePageMap(pageMap);
return await generateAccessible(preferences.app.accessMode, { const accessible = await generateAccessible(preferences.app.accessMode, {
...options, ...options,
fetchMenuListAsync: async () => { fetchMenuListAsync: async () => {
const appContextStore = useAppContextStore(); const appContextStore = useAppContextStore();
@@ -235,6 +272,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
layoutMap, layoutMap,
pageMap, pageMap,
}); });
prefetchLightPages(pageMap);
return accessible;
} }
export { generateAccess }; export { generateAccess };
@@ -25,6 +25,8 @@ const emit = defineEmits<{
select: [nodeId: string]; select: [nodeId: string];
}>(); }>();
type TagType = 'danger' | 'info' | 'primary' | 'success' | 'warning';
function formatDuration(ms?: number) { function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-'; if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
@@ -50,15 +52,19 @@ function getCommunication(log: ExecutionLogEntry) {
return log.metadata?.communication || log.event?.communication || {}; 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) { function getMetaItems(log: ExecutionLogEntry) {
const metadata = log.metadata || {}; const metadata = log.metadata || {};
const items: Array<{ const items: Array<{ label: string; type?: TagType }> = [];
label: string;
type?: 'danger' | 'info' | 'primary' | 'success' | 'warning';
}> = [];
const communication = getCommunication(log); const communication = getCommunication(log);
const collaboration = const collaboration = getCollaboration(log);
metadata.collaboration || log.event?.collaboration || {};
const branchLabel = const branchLabel =
log.branch_label || log.branch_label ||
log.branch || log.branch ||
@@ -88,24 +94,14 @@ function getMetaItems(log: ExecutionLogEntry) {
if (log.type || metadata.stream_event) { if (log.type || metadata.stream_event) {
items.push({ items.push({
label: getEventTypeLabel(log.type || log.event?.type), label: getEventTypeLabel(log.type || log.event?.type),
type: log.status === 'failed' ? 'danger' : 'info', type: getLogTone(log),
}); });
} }
if (branchLabel) { if (branchLabel) items.push({ label: `分支: ${branchLabel}`, type: 'primary' });
items.push({ label: `分支: ${branchLabel}`, type: 'primary' }); if (subflowName) items.push({ label: `子流程: ${subflowName}`, type: 'success' });
} if (providerName) items.push({ label: `提供商: ${providerName}`, type: 'info' });
if (subflowName) { if (modelName) items.push({ label: `模型: ${modelName}`, type: 'info' });
items.push({ label: `子流程: ${subflowName}`, type: 'success' }); if (log.tokens_used) items.push({ label: `Token ${log.tokens_used}`, type: 'info' });
}
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) { if (communication.channel) {
items.push({ label: getChannelLabel(communication.channel), type: 'info' }); items.push({ label: getChannelLabel(communication.channel), type: 'info' });
} }
@@ -118,6 +114,7 @@ function getMetaItems(log: ExecutionLogEntry) {
function getChannelLabel(channel: string) { function getChannelLabel(channel: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
agent_chat: '智能体对话',
human_input: '人机协作', human_input: '人机协作',
parallel_branch: '并行分支', parallel_branch: '并行分支',
subflow: '子流程', subflow: '子流程',
@@ -132,12 +129,18 @@ function getEventTypeLabel(type?: string) {
complete: '完成', complete: '完成',
error: '错误', error: '错误',
llm_chunk: 'LLM 输出', llm_chunk: 'LLM 输出',
loop_complete: '循环完成',
loop_iteration_complete: '循环迭代完成',
loop_iteration_error: '循环迭代失败',
loop_iteration_start: '循环迭代开始',
loop_start: '循环开始',
message: '消息', message: '消息',
node_complete: '节点完成', node_complete: '节点完成',
node_event: '节点事件', node_event: '节点事件',
node_start: '节点开始', node_start: '节点开始',
parallel_complete: '并行完成', parallel_complete: '并行完成',
parallel_start: '并行开始', parallel_start: '并行开始',
resume: '恢复执行',
start: '开始', start: '开始',
waiting_input: '等待输入', waiting_input: '等待输入',
}; };
@@ -150,22 +153,24 @@ function countItems(value: any) {
return 0; 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) { function getEventSummary(event: WorkflowRunEvent) {
const eventType = event.type || event.event?.type; 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_message) return event.error_message;
if (event.error) return event.error; if (event.error) return event.error;
if (eventType === 'waiting_input') { if (eventType === 'waiting_input') return getWaitingSummary(event);
return stringifyBrief(
waitingConfig?.title ||
waitingConfig?.question ||
waitingConfig?.content ||
waitingConfig ||
'等待用户输入',
160,
);
}
if (eventType === 'parallel_start') { if (eventType === 'parallel_start') {
return `启动 ${countItems(event.branches || event.branch_labels)} 个并行分支`; return `启动 ${countItems(event.branches || event.branch_labels)} 个并行分支`;
} }
@@ -196,13 +201,56 @@ function getEventSummary(event: WorkflowRunEvent) {
return stringifyBrief(event, 140); 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[] { function getLogEvents(log: ExecutionLogEntry): WorkflowRunEvent[] {
const primaryEvent = log.event ? [log.event] : []; const events = [
const directEvents = Array.isArray(log.events) ? log.events : []; ...(log.event ? [log.event] : []),
const metadataEvents = Array.isArray(log.metadata?.events) ...(Array.isArray(log.events) ? log.events : []),
? log.metadata.events ...(Array.isArray(log.metadata?.events) ? log.metadata.events : []),
: []; ].filter(Boolean);
return [...primaryEvent, ...directEvents, ...metadataEvents].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) { function getVisibleEvents(log: ExecutionLogEntry) {
@@ -229,6 +277,33 @@ function getLogDetail(log: ExecutionLogEntry) {
communication.message, 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> </script>
<template> <template>
@@ -240,8 +315,10 @@ function getLogDetail(log: ExecutionLogEntry) {
<ElTimeline v-else class="px-2 py-3"> <ElTimeline v-else class="px-2 py-3">
<ElTimelineItem <ElTimelineItem
v-for="(log, index) in logs" v-for="(log, index) in logs"
:key="`${log.node_id}-${index}`" :key="`${log.node_id}-${log.type || 'node'}-${index}`"
:timestamp="formatDuration(log.elapsed_time)" :timestamp="formatDuration(log.elapsed_time)"
:type="getLogTone(log)"
:hollow="log.status === 'running' || log.status === 'waiting'"
placement="top" placement="top"
> >
<button <button
@@ -255,15 +332,15 @@ function getLogDetail(log: ExecutionLogEntry) {
@click="emit('select', log.node_id)" @click="emit('select', log.node_id)"
> >
<div class="mb-1 flex items-center justify-between gap-2"> <div class="mb-1 flex items-center justify-between gap-2">
<span class="text-foreground text-sm font-medium"> <span class="text-foreground min-w-0 truncate text-sm font-medium">
{{ log.node_label || log.node_id }} {{ getLogTitle(log) }}
</span> </span>
<RunStatusTag :status="log.status" /> <RunStatusTag :status="log.status" />
</div> </div>
<div <div
class="text-muted-foreground flex flex-wrap items-center gap-1 text-xs" 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 <ElTag
v-for="item in getMetaItems(log)" v-for="item in getMetaItems(log)"
:key="item.label" :key="item.label"
@@ -291,14 +368,37 @@ function getLogDetail(log: ExecutionLogEntry) {
<div <div
v-for="(event, eventIndex) in getVisibleEvents(log)" v-for="(event, eventIndex) in getVisibleEvents(log)"
:key="`${log.node_id}-event-${eventIndex}`" :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"> <div class="flex min-w-0 items-start gap-2">
{{ getEventTypeLabel(event.type || event.event?.type) }} <ElTag
</span> class="shrink-0"
<span class="line-clamp-2 min-w-0 whitespace-pre-wrap"> :type="getEventTone(event)"
{{ getEventSummary(event) }} size="small"
</span> 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>
<div <div
v-if="getHiddenEventCount(log)" v-if="getHiddenEventCount(log)"
@@ -1,16 +1,32 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import AddButtonEdge from '../editor/edges/AddButtonEdge.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 ChoiceNode from '../editor/nodes/ChoiceNode.vue';
import CodeNode from '../editor/nodes/CodeNode.vue'; import CodeNode from '../editor/nodes/CodeNode.vue';
import ConditionNode from '../editor/nodes/ConditionNode.vue'; import ConditionNode from '../editor/nodes/ConditionNode.vue';
import ConfirmNode from '../editor/nodes/ConfirmNode.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 DbDeleteNode from '../editor/nodes/DbDeleteNode.vue';
import DbInsertNode from '../editor/nodes/DbInsertNode.vue'; import DbInsertNode from '../editor/nodes/DbInsertNode.vue';
import DbQueryNode from '../editor/nodes/DbQueryNode.vue'; import DbQueryNode from '../editor/nodes/DbQueryNode.vue';
import DbSqlNode from '../editor/nodes/DbSqlNode.vue'; import DbSqlNode from '../editor/nodes/DbSqlNode.vue';
import DbUpdateNode from '../editor/nodes/DbUpdateNode.vue'; import DbUpdateNode from '../editor/nodes/DbUpdateNode.vue';
import EndNode from '../editor/nodes/EndNode.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 HttpNode from '../editor/nodes/HttpNode.vue';
import IntentNode from '../editor/nodes/IntentNode.vue'; import IntentNode from '../editor/nodes/IntentNode.vue';
import KnowledgeRetrievalNode from '../editor/nodes/KnowledgeRetrievalNode.vue'; import KnowledgeRetrievalNode from '../editor/nodes/KnowledgeRetrievalNode.vue';
@@ -51,8 +67,29 @@ export const workflowNodeTypes = {
snowflake_cortex_llm: markRaw(SnowflakeCortexLLMNode), snowflake_cortex_llm: markRaw(SnowflakeCortexLLMNode),
snowflake_cortex_analyst: markRaw(SnowflakeCortexAnalystNode), snowflake_cortex_analyst: markRaw(SnowflakeCortexAnalystNode),
loop: markRaw(LoopNode), 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), system_summary: markRaw(SystemSummaryNode),
subflow: markRaw(SubflowNode), 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), text_to_sql: markRaw(TextToSqlNode),
knowledge_retrieval: markRaw(KnowledgeRetrievalNode), knowledge_retrieval: markRaw(KnowledgeRetrievalNode),
}; };