feat(agent): add task-oriented run logs and execution details

This commit is contained in:
RockChinQ
2026-09-18 00:59:17 +08:00
parent b264d46d77
commit a6ac25cf84
20 changed files with 1080 additions and 11 deletions
@@ -27,6 +27,7 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import PluginProcessorDetailContent from './PluginProcessorDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
import AgentMonitoringTab from './components/AgentMonitoringTab';
import AgentFormComponent, {
AgentFormHandle,
RunnerStatus,
@@ -281,6 +282,21 @@ export default function AgentDetailContent({
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
monitoring={
currentWorkspace?.permissions.includes('resource.view')
? {
label: t('pipelines.monitoring.title'),
workbenchLabel: t('pipelines.monitoring.workbench'),
content: (
<AgentMonitoringTab
key={id}
agentId={id}
platformTools={platformTools}
/>
),
}
: undefined
}
/>
)}
<EntityBasicInfoDialog
@@ -0,0 +1,549 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Brain, ChevronRight, RefreshCw, Wrench } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type {
AgentPlatformTool,
ProcessorRun,
ProcessorRunEvent,
} from '@/app/infra/entities/api';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible';
import { executionSteps } from './debug-execution';
import { ProcessorPayload } from './PluginProcessorTrace';
import {
formatRunDuration,
processorRunDuration,
} from './processor-run-timing';
const activeStatuses = new Set([
'created',
'pending',
'queued',
'claimed',
'running',
'waiting',
]);
const failedStatuses = new Set(['failed', 'timeout']);
const json = (value: unknown) =>
typeof value === 'string' ? value : JSON.stringify(value, null, 2);
const textClass =
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm';
export default function AgentMonitoringTab({
agentId,
platformTools,
}: {
agentId: string;
platformTools: AgentPlatformTool[];
}) {
const { t } = useTranslation();
const [runs, setRuns] = useState<ProcessorRun[]>([]);
const [total, setTotal] = useState<number>();
const [cursor, setCursor] = useState<number | null>(null);
const [selectedId, setSelectedId] = useState<string>();
const [selected, setSelected] = useState<ProcessorRun>();
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [loadingTrace, setLoadingTrace] = useState(false);
const [listError, setListError] = useState(false);
const [traceError, setTraceError] = useState(false);
const [paging, setPaging] = useState(false);
const [traceRevision, setTraceRevision] = useState(0);
const generation = useRef(0);
const listBusy = useRef(false);
const listInitialized = useRef(false);
const traceBusy = useRef(false);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
generation.current += 1;
};
}, []);
const refresh = useCallback(async () => {
if (listBusy.current) return;
listBusy.current = true;
try {
const page = await httpClient.getProcessorRuns(agentId);
if (!alive.current) return;
setRuns((current) => {
const merged = new Map(page.items.map((run) => [run.run_id, run]));
current.forEach((run) => {
if (!merged.has(run.run_id)) merged.set(run.run_id, run);
});
return [...merged.values()];
});
if (!listInitialized.current) {
setCursor(page.has_more ? page.next_cursor : null);
listInitialized.current = true;
}
setTotal(page.total);
setSelectedId((current) => current ?? page.items[0]?.run_id);
setListError(false);
} catch {
if (alive.current) setListError(true);
} finally {
listBusy.current = false;
if (alive.current) setLoading(false);
}
}, [agentId]);
useEffect(() => {
void refresh();
const timer = window.setInterval(() => {
if (!document.hidden) void refresh();
}, 5000);
return () => window.clearInterval(timer);
}, [refresh]);
useEffect(() => {
if (!selectedId) return;
const version = ++generation.current;
setEvents([]);
setSelected(undefined);
setEventCursor(null);
setLoadingTrace(true);
setPaging(false);
setTraceError(false);
traceBusy.current = false;
void httpClient
.getProcessorRunEvents(agentId, selectedId)
.then((page) => {
if (generation.current !== version) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
})
.catch(() => {
if (generation.current === version) setTraceError(true);
})
.finally(() => {
if (generation.current === version) setLoadingTrace(false);
});
return () => {
generation.current += 1;
};
}, [agentId, selectedId, traceRevision]);
const nextEvents = useCallback(async () => {
if (!selectedId || traceBusy.current || loadingTrace) return;
const version = generation.current;
traceBusy.current = true;
setPaging(true);
try {
const page = await httpClient.getProcessorRunEvents(
agentId,
selectedId,
eventCursor ?? events.at(-1)?.sequence,
);
if (generation.current !== version) return;
setSelected(page.run);
setEvents((current) => [
...new Map(
[...current, ...page.items].map((event) => [event.sequence, event]),
).values(),
]);
setEventCursor(page.has_more ? page.next_cursor : null);
setTraceError(false);
} catch {
if (generation.current === version) setTraceError(true);
} finally {
if (generation.current === version) {
traceBusy.current = false;
setPaging(false);
}
}
}, [agentId, selectedId, eventCursor, events, loadingTrace]);
useEffect(() => {
if (
!selected ||
!activeStatuses.has(selected.status) ||
eventCursor !== null
)
return;
const timer = window.setInterval(() => {
if (!document.hidden) void nextEvents();
}, 3000);
return () => window.clearInterval(timer);
}, [selected, eventCursor, nextEvents]);
async function moreRuns() {
if (cursor === null || listBusy.current) return;
listBusy.current = true;
setLoading(true);
try {
const page = await httpClient.getProcessorRuns(agentId, cursor);
if (!alive.current) return;
setRuns((current) => [
...new Map(
[...current, ...page.items].map((run) => [run.run_id, run]),
).values(),
]);
setCursor(page.has_more ? page.next_cursor : null);
setListError(false);
} catch {
if (alive.current) setListError(true);
} finally {
listBusy.current = false;
if (alive.current) setLoading(false);
}
}
const labels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
);
const steps = executionSteps(events);
const duration = selected ? processorRunDuration(selected) : null;
const failureReason =
selected?.status_reason ||
events.findLast((event) => event.type === 'run.failed')?.data.error;
const models = [
...new Set(
events.flatMap((event) => {
const message = event.data.message ?? event.data.chunk;
return message &&
typeof message === 'object' &&
'model' in message &&
typeof message.model === 'string' &&
message.model
? [message.model]
: [];
}),
),
];
const stateLabel = (run: ProcessorRun) =>
t(
`agents.eventProcessor.status_${({ created: 'pending', claimed: 'queued' } as Record<string, string>)[run.status] ?? run.status}`,
{
defaultValue: run.status,
},
);
const debugRun = (run: ProcessorRun) =>
run.binding_id?.startsWith('debug:') || run.metadata.source === 'webui';
return (
<div
className="flex min-h-0 flex-1 flex-col gap-4"
data-testid="agent-monitoring"
>
<div className="flex shrink-0 items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{t('agents.monitoring.description')}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void refresh();
setTraceRevision((value) => value + 1);
}}
>
<RefreshCw className="size-4" />
{t('monitoring.refreshData')}
</Button>
</div>
{listError && (
<Alert variant="destructive">
<AlertDescription>{t('monitoring.loadError')}</AlertDescription>
</Alert>
)}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(16rem,0.8fr)_minmax(0,2fr)]">
<section
className="flex min-h-0 min-w-0 flex-col gap-2"
aria-label={t('agents.eventProcessor.runs')}
>
<p className="text-sm font-medium">
{t('agents.eventProcessor.runs')}
{total !== undefined && (
<span className="ml-2 text-muted-foreground">{total}</span>
)}
</p>
<div className="max-h-72 min-h-0 overflow-y-auto rounded-md border lg:max-h-none lg:flex-1">
{runs.map((run) => (
<Button
key={run.run_id}
variant="ghost"
aria-pressed={selectedId === run.run_id}
onClick={() => setSelectedId(run.run_id)}
className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b p-3 text-left font-normal last:border-b-0 aria-pressed:bg-accent"
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-medium">
{eventPatternLabel(run.metadata.event_type ?? '', t)}
</span>
<Badge
variant={
failedStatuses.has(run.status) ? 'destructive' : 'outline'
}
>
{stateLabel(run)}
</Badge>
</span>
{run.metadata.input?.text && (
<span className="truncate text-sm">
{run.metadata.input.text}
</span>
)}
<span className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<time>
{new Date(run.created_at * 1000).toLocaleString()}
</time>
{debugRun(run) && (
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
)}
{processorRunDuration(run) !== null && (
<span>{formatRunDuration(processorRunDuration(run)!)}</span>
)}
</span>
</Button>
))}
{!runs.length && (
<p className="p-4 text-sm text-muted-foreground">
{loading ? t('common.loading') : t('agents.monitoring.empty')}
</p>
)}
{cursor !== null && (
<Button
variant="ghost"
className="w-full"
disabled={loading}
onClick={() => void moreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</div>
</section>
<section
aria-label={t('agents.monitoring.execution')}
className="min-h-0 min-w-0 space-y-4 overflow-y-auto lg:pr-2"
>
{loadingTrace ? (
<p role="status">{t('common.loading')}</p>
) : (
selected && (
<>
<div className="space-y-2 border-b pb-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</h2>
<Badge
variant={
failedStatuses.has(selected.status)
? 'destructive'
: 'outline'
}
>
{stateLabel(selected)}
</Badge>
{debugRun(selected) && (
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<time>
{new Date(selected.created_at * 1000).toLocaleString()}
</time>
{duration !== null && (
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{formatRunDuration(duration)}
</span>
)}
{models.length > 0 && <span>{models.join(', ')}</span>}
{selected.usage?.total_tokens != null && (
<span>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{selected.usage.total_tokens.toLocaleString()}
</span>
)}
</div>
<p className="break-all text-xs text-muted-foreground">
{selected.runner_id}
</p>
</div>
{failedStatuses.has(selected.status) && failureReason && (
<Alert variant="destructive">
<AlertDescription className={textClass}>
{json(failureReason)}
</AlertDescription>
</Alert>
)}
<Card className="gap-2 py-3">
<CardHeader className="px-3">
<CardTitle className="text-sm">
{t('agents.monitoring.input')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-3">
{selected.metadata.input?.text && (
<p className={textClass}>
{selected.metadata.input.text}
</p>
)}
{(selected.metadata.input ??
selected.metadata.input_event) != null ? (
<ProcessorPayload
title={t('agents.monitoring.eventData')}
value={
selected.metadata.input_event
? {
...selected.metadata.input,
event: selected.metadata.input_event,
}
: selected.metadata.input
}
/>
) : (
<p className="text-sm text-muted-foreground">
{t('agents.monitoring.inputUnavailable')}
</p>
)}
</CardContent>
</Card>
<h3 className="text-sm font-medium">
{t('agents.monitoring.execution')}
</h3>
{steps.map((step, index) => (
<Card key={index} className="gap-2 py-3">
<CardHeader className="px-3">
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
{step.kind === 'tool' ? (
<>
<Wrench className="size-4 shrink-0" />
<span className="break-all">
{labels[step.name] || step.name}
</span>
<Badge
variant={
step.status === 'failed'
? 'destructive'
: 'outline'
}
>
{t(
`agents.debugTool${step.status === 'running' ? (activeStatuses.has(selected.status) || eventCursor !== null ? 'Running' : 'Interrupted') : step.result && typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true ? (step.status === 'failed' ? 'MockFailed' : 'Simulated') : step.status === 'failed' ? 'Failed' : 'Completed'}`,
)}
</Badge>
</>
) : (
<>
<Brain className="size-4" />
{t('agents.debugTextOutput')}
</>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-3">
{step.kind === 'message' ? (
<>
{step.text && (
<p className={textClass}>{step.text}</p>
)}
{step.reasoning && (
<ProcessorPayload
title={t('agents.debugReasoning')}
value={step.reasoning}
/>
)}
</>
) : (
<>
{step.error && (
<p className={`${textClass} text-destructive`}>
{step.error}
</p>
)}
<ProcessorPayload
title={t('agents.debugToolArguments')}
value={step.parameters}
/>
{step.result !== undefined && (
<ProcessorPayload
title={t('agents.debugToolResult')}
value={step.result}
/>
)}
</>
)}
</CardContent>
</Card>
))}
{events
.filter((event) => event.type === 'processor.log')
.map((event) => (
<Alert
key={event.sequence}
variant={
event.data.level === 'error' ? 'destructive' : 'default'
}
>
<AlertDescription className={textClass}>
{String(event.data.text ?? '')}
</AlertDescription>
</Alert>
))}
{eventCursor !== null && (
<Button
variant="outline"
disabled={paging}
onClick={() => void nextEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="group">
<ChevronRight className="size-4 group-data-[state=open]:rotate-90" />
{t('agents.monitoring.rawEvents')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre
className={`${textClass} rounded-md bg-muted p-3 font-mono text-xs`}
>
{json({
run_id: selected.run_id,
usage: selected.usage,
events,
})}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)
)}
{traceError && (
<Alert variant="destructive">
<AlertDescription>
{t('monitoring.loadError')}
<Button
variant="link"
onClick={() => setTraceRevision((value) => value + 1)}
>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
)}
</section>
</div>
</div>
);
}
+15 -1
View File
@@ -196,6 +196,13 @@ export interface RunnerDescriptor {
export interface ProcessorRun {
run_id: string;
binding_id?: string;
runner_id?: string;
usage?: {
prompt_tokens?: number | null;
completion_tokens?: number | null;
total_tokens?: number | null;
} | null;
status: string;
status_reason?: string;
created_at: number;
@@ -204,7 +211,13 @@ export interface ProcessorRun {
created_at_ms?: number | null;
started_at_ms?: number | null;
finished_at_ms?: number | null;
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
metadata: {
event_type?: string;
source?: string;
input?: { text?: string; contents?: unknown[]; attachments?: unknown[] };
input_event?: unknown;
delivery?: unknown;
};
}
export interface ProcessorRunEvent {
@@ -215,6 +228,7 @@ export interface ProcessorRunEvent {
export interface ProcessorRunPage {
items: ProcessorRun[];
total?: number;
next_cursor: number | null;
has_more: boolean;
}
+12
View File
@@ -787,6 +787,17 @@ const enUS = {
},
},
agents: {
monitoring: {
description:
'Follow each task from its triggering event through model output and tool execution.',
empty:
'No runs yet. Trigger a platform event or run a debug test to see it here.',
input: 'Triggering input',
eventData: 'Event data',
execution: 'Execution steps',
rawEvents: 'Raw run events',
inputUnavailable: 'Input was not recorded for this run.',
},
eventProcessor: {
configurations: 'Plugin processor configurations',
configTab: 'Configuration',
@@ -825,6 +836,7 @@ const enUS = {
loadMore: 'Load more',
activation:
'Install a plugin, create a processor configuration, then bind a bot.',
status_timeout: 'Timed out',
status_pending: 'Pending',
status_running: 'Running',
status_completed: 'Completed',
+12
View File
@@ -611,6 +611,17 @@ const esES = {
},
},
agents: {
monitoring: {
description:
'Consulta el evento, la salida del modelo y las herramientas de cada tarea.',
empty:
'Sin ejecuciones. Activa un evento o ejecuta una prueba de depuración.',
input: 'Entrada inicial',
eventData: 'Datos del evento',
execution: 'Pasos de ejecución',
rawEvents: 'Eventos sin procesar',
inputUnavailable: 'No se registró la entrada de esta ejecución.',
},
eventProcessor: {
configurations: 'Configuraciones de procesadores de plugins',
configTab: 'Configuración',
@@ -649,6 +660,7 @@ const esES = {
loadMore: 'Cargar más',
activation:
'Instala un plugin, crea una configuración de procesador y vincula un bot.',
status_timeout: 'Tiempo agotado',
status_pending: 'Pendiente',
status_running: 'En ejecución',
status_completed: 'Completado',
+12
View File
@@ -800,6 +800,17 @@ const jaJP = {
},
},
agents: {
monitoring: {
description:
'各タスクのトリガーイベント、モデル出力、ツール実行を確認します。',
empty:
'実行記録はありません。プラットフォームイベントまたはデバッグテストを実行してください。',
input: 'トリガー入力',
eventData: 'イベントデータ',
execution: '実行過程',
rawEvents: '生の実行イベント',
inputUnavailable: 'この実行の入力は記録されていません。',
},
eventProcessor: {
configurations: 'プラグインプロセッサー設定',
configTab: '設定',
@@ -839,6 +850,7 @@ const jaJP = {
loadMore: 'さらに読み込む',
activation:
'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。',
status_timeout: 'タイムアウト',
status_pending: '待機中',
status_running: '実行中',
status_completed: '完了',
+12
View File
@@ -606,6 +606,17 @@ const ruRU = {
},
},
agents: {
monitoring: {
description:
'Просмотр события, ответа модели и вызовов инструментов для каждой задачи.',
empty:
'Запусков пока нет. Вызовите событие платформы или запустите отладку.',
input: 'Входные данные',
eventData: 'Данные события',
execution: 'Ход выполнения',
rawEvents: 'Исходные события',
inputUnavailable: 'Входные данные этого запуска не записаны.',
},
eventProcessor: {
configurations: 'Конфигурации обработчиков плагинов',
configTab: 'Настройки',
@@ -643,6 +654,7 @@ const ruRU = {
loadMore: 'Загрузить ещё',
activation:
'Установите плагин, создайте конфигурацию обработчика и привяжите бота.',
status_timeout: 'Время истекло',
status_pending: 'Ожидание',
status_running: 'Выполняется',
status_completed: 'Завершено',
+11
View File
@@ -591,6 +591,16 @@ const thTH = {
},
},
agents: {
monitoring: {
description:
'ดูเหตุการณ์เริ่มต้น ผลลัพธ์โมเดล และการเรียกเครื่องมือของแต่ละงาน',
empty: 'ยังไม่มีการทำงาน เริ่มเหตุการณ์หรือทดสอบการดีบักเพื่อดูบันทึก',
input: 'ข้อมูลเริ่มต้น',
eventData: 'ข้อมูลเหตุการณ์',
execution: 'ขั้นตอนการทำงาน',
rawEvents: 'เหตุการณ์ดิบ',
inputUnavailable: 'ไม่มีการบันทึกข้อมูลเริ่มต้นของการทำงานนี้',
},
eventProcessor: {
configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน',
configTab: 'การตั้งค่า',
@@ -627,6 +637,7 @@ const thTH = {
destination: 'ปลายทางการส่ง',
loadMore: 'โหลดเพิ่มเติม',
activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท',
status_timeout: 'หมดเวลา',
status_pending: 'รอดำเนินการ',
status_running: 'กำลังทำงาน',
status_completed: 'เสร็จสิ้น',
+11
View File
@@ -601,6 +601,16 @@ const viVN = {
},
},
agents: {
monitoring: {
description:
'Xem sự kiện kích hoạt, đầu ra mô hình và quá trình gọi công cụ của mỗi tác vụ.',
empty: 'Chưa có lượt chạy. Kích hoạt sự kiện hoặc chạy thử gỡ lỗi.',
input: 'Đầu vào kích hoạt',
eventData: 'Dữ liệu sự kiện',
execution: 'Quá trình thực thi',
rawEvents: 'Sự kiện gốc',
inputUnavailable: 'Đầu vào của lượt chạy này chưa được ghi lại.',
},
eventProcessor: {
configurations: 'Cấu hình bộ xử lý plugin',
configTab: 'Cấu hình',
@@ -637,6 +647,7 @@ const viVN = {
destination: 'Đích gửi',
loadMore: 'Tải thêm',
activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.',
status_timeout: 'Hết thời gian',
status_pending: 'Đang chờ',
status_running: 'Đang chạy',
status_completed: 'Hoàn tất',
+10
View File
@@ -748,6 +748,15 @@ const zhHans = {
},
},
agents: {
monitoring: {
description: '查看每次任务的触发事件、模型输出和工具执行过程。',
empty: '暂无运行记录。触发平台事件或运行调试后,可在这里查看。',
input: '触发输入',
eventData: '事件数据',
execution: '执行过程',
rawEvents: '原始运行事件',
inputUnavailable: '这次运行未记录输入内容。',
},
eventProcessor: {
configurations: '插件处理器配置',
configTab: '配置',
@@ -783,6 +792,7 @@ const zhHans = {
destination: '投递目标',
loadMore: '加载更多',
activation: '安装插件,创建处理器配置,再绑定机器人。',
status_timeout: '已超时',
status_pending: '待执行',
status_running: '运行中',
status_completed: '已完成',
+10
View File
@@ -573,6 +573,15 @@ const zhHant = {
},
},
agents: {
monitoring: {
description: '查看每次任務的觸發事件、模型輸出和工具執行過程。',
empty: '尚無執行紀錄。觸發平台事件或執行除錯後,可在此查看。',
input: '觸發輸入',
eventData: '事件資料',
execution: '執行過程',
rawEvents: '原始執行事件',
inputUnavailable: '此次執行未記錄輸入內容。',
},
eventProcessor: {
configurations: '外掛處理器設定',
configTab: '設定',
@@ -608,6 +617,7 @@ const zhHant = {
destination: '傳送目標',
loadMore: '載入更多',
activation: '安裝外掛、建立處理器設定,再綁定機器人。',
status_timeout: '已逾時',
status_pending: '待執行',
status_running: '執行中',
status_completed: '已完成',