fix(ci): align SDK dependency and validate acceptance migrations

This commit is contained in:
Hyu
2026-10-11 00:59:51 +08:00
parent e3e5f88809
commit 353b452453
81 changed files with 3527 additions and 1818 deletions
+32 -21
View File
@@ -63,31 +63,42 @@ export default function AgentDetailContent({
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const runnerStatusKey = JSON.stringify([
userInfo?.account_uuid, currentWorkspace?.workspace.uuid, id, i18n.language,
userInfo?.account_uuid,
currentWorkspace?.workspace.uuid,
id,
i18n.language,
]);
const [runnerStatusState, setRunnerStatusState] = useState<{
key: string; status: RunnerStatus;
key: string;
status: RunnerStatus;
} | null>(null);
const runnerStatus = runnerStatusState?.key === runnerStatusKey
? runnerStatusState.status : runnerStatusCache.get(runnerStatusKey) ?? null;
const updateRunnerStatus = useCallback((status: RunnerStatus) => {
setRunnerStatusState((previous) => {
if (status.tone === 'neutral') {
const cached = previous?.key === runnerStatusKey
? previous.status : runnerStatusCache.get(runnerStatusKey);
if (cached) return { key: runnerStatusKey, status: cached };
const runnerStatus =
runnerStatusState?.key === runnerStatusKey
? runnerStatusState.status
: (runnerStatusCache.get(runnerStatusKey) ?? null);
const updateRunnerStatus = useCallback(
(status: RunnerStatus) => {
setRunnerStatusState((previous) => {
if (status.tone === 'neutral') {
const cached =
previous?.key === runnerStatusKey
? previous.status
: runnerStatusCache.get(runnerStatusKey);
if (cached) return { key: runnerStatusKey, status: cached };
}
return { key: runnerStatusKey, status };
});
if (status.tone !== 'neutral' && !formDirty) {
runnerStatusCache.delete(runnerStatusKey);
runnerStatusCache.set(runnerStatusKey, status);
if (runnerStatusCache.size > 100) {
const oldest = runnerStatusCache.keys().next().value;
if (oldest !== undefined) runnerStatusCache.delete(oldest);
}
}
return { key: runnerStatusKey, status };
});
if (status.tone !== 'neutral' && !formDirty) {
runnerStatusCache.delete(runnerStatusKey);
runnerStatusCache.set(runnerStatusKey, status);
if (runnerStatusCache.size > 100) {
const oldest = runnerStatusCache.keys().next().value;
if (oldest !== undefined) runnerStatusCache.delete(oldest);
}
}
}, [runnerStatusKey, formDirty]);
},
[runnerStatusKey, formDirty],
);
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
'message.received',
]);
@@ -30,7 +30,10 @@ import PluginProcessorTrace, {
ProcessorPayload,
} from './components/PluginProcessorTrace';
import ProcessorRunList from './components/ProcessorRunList';
import { processorRunDuration, formatRunDuration } from './components/processor-run-timing';
import {
processorRunDuration,
formatRunDuration,
} from './components/processor-run-timing';
import PluginProcessorSettings from './components/PluginProcessorSettings';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
@@ -55,13 +58,19 @@ export default function PluginProcessorDetailContent({
}) {
const { t } = useTranslation();
const [, setSearchParams] = useSearchParams();
const setActiveTab = useCallback((tab: string) => {
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set('tab', tab);
return next;
}, { preventScrollReset: true });
}, [setSearchParams]);
const setActiveTab = useCallback(
(tab: string) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set('tab', tab);
return next;
},
{ preventScrollReset: true },
);
},
[setSearchParams],
);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
@@ -83,7 +92,8 @@ export default function PluginProcessorDetailContent({
const [needsInitialSave, setNeedsInitialSave] = useState(
Boolean(agent.component_ref) && !agent.supported_event_patterns?.length,
);
const dirty = needsInitialSave ||
const dirty =
needsInitialSave ||
componentRef !== savedConfig.componentRef ||
!isEqual(parameters, savedConfig.parameters);
const [runs, setRuns] = useState<ProcessorRun[]>([]);
@@ -161,22 +171,25 @@ export default function PluginProcessorDetailContent({
[t, available, hasParameters, canOperate, setActiveTab],
);
const applyInstalledProcessor = useCallback((component: RunnerDescriptor) => {
setComponents((current) => [
...current.filter((item) => item.id !== component.id),
component,
]);
setComponentRef(component.id);
setParameters(
Object.fromEntries(
(component.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
setActiveTab('config');
validate.current = null;
}, [setActiveTab]);
const applyInstalledProcessor = useCallback(
(component: RunnerDescriptor) => {
setComponents((current) => [
...current.filter((item) => item.id !== component.id),
component,
]);
setComponentRef(component.id);
setParameters(
Object.fromEntries(
(component.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
setActiveTab('config');
validate.current = null;
},
[setActiveTab],
);
const load = useCallback(async () => {
setLoading(true);
@@ -364,7 +377,11 @@ export default function PluginProcessorDetailContent({
const logsContent = (
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
{failed && (
<LoadErrorState compact title={t('agents.eventProcessor.loadError')} onRetry={refreshLatestRun} />
<LoadErrorState
compact
title={t('agents.eventProcessor.loadError')}
onRetry={refreshLatestRun}
/>
)}
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
@@ -382,104 +399,112 @@ export default function PluginProcessorDetailContent({
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
{runs.length > 0 && (
<ProcessorRunList
className="max-h-64 overflow-y-auto rounded-lg border lg:max-h-none lg:h-full"
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
) : undefined
}
/>
)}
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 pr-2">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
<div className="space-y-1.5">
<p className="font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-3">
{processorRunDuration(selected) !== null && (
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
<Clock3 className="size-3.5" />
{formatRunDuration(processorRunDuration(selected)!)}
</span>
)}
<Badge status={selected.status}>
{t(`agents.eventProcessor.status_${selected.status}`, { defaultValue: selected.status })}
</Badge>
</div>
</div>
<div className="space-y-2 rounded-lg border p-3">
<h3 className="text-sm font-medium">{t('agents.eventProcessor.input')}</h3>
<ProcessorPayload
title={t('agents.monitoring.eventData')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
/>
)}
</div>
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-sm font-medium">
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
{t('agents.eventProcessor.type')} · {t('agents.eventProcessor.logsTab')}
</h2>
<PluginProcessorTrace events={events} toolLabels={toolLabels} />
</section>
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
{runs.length > 0 && (
<ProcessorRunList
className="max-h-64 overflow-y-auto rounded-lg border lg:max-h-none lg:h-full"
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
</ScrollArea>
) : undefined
}
/>
)}
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 pr-2">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
<div className="space-y-1.5">
<p className="font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-3">
{processorRunDuration(selected) !== null && (
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
<Clock3 className="size-3.5" />
{formatRunDuration(processorRunDuration(selected)!)}
</span>
)}
<Badge status={selected.status}>
{t(`agents.eventProcessor.status_${selected.status}`, {
defaultValue: selected.status,
})}
</Badge>
</div>
</div>
<div className="space-y-2 rounded-lg border p-3">
<h3 className="text-sm font-medium">
{t('agents.eventProcessor.input')}
</h3>
<ProcessorPayload
title={t('agents.monitoring.eventData')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
/>
)}
</div>
<section className="space-y-3">
<h2 className="flex items-center gap-2 text-sm font-medium">
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
{t('agents.eventProcessor.type')} ·{' '}
{t('agents.eventProcessor.logsTab')}
</h2>
<PluginProcessorTrace
events={events}
toolLabels={toolLabels}
/>
</section>
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
</ScrollArea>
</div>
</div>
);
@@ -489,10 +514,13 @@ export default function PluginProcessorDetailContent({
return (
<>
<form id="event-processor-form" onSubmit={(event) => {
event.preventDefault();
void save();
}} />
<form
id="event-processor-form"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
/>
<GuidedTour
enabled={canManage}
storageKey="langbot_plugin_processor_setup_guide_v1"
@@ -502,7 +530,10 @@ export default function PluginProcessorDetailContent({
<ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`}
titleBadge={
<Badge variant="outline" className="gap-1.5 border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300">
<Badge
variant="outline"
className="gap-1.5 border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300"
>
<Puzzle className="size-3.5" />
{t('agents.eventProcessor.type')}
</Badge>
@@ -551,38 +582,40 @@ export default function PluginProcessorDetailContent({
) : undefined
}
configTitle={t('agents.eventProcessor.type')}
configIcon={<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />}
configIcon={
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
}
monitoring={{
label: t('pipelines.monitoring.title'),
workbenchLabel: t('pipelines.monitoring.workbench'),
content: logsContent,
}}
configContent={
<div className="h-full overflow-y-auto">
{!component ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectComponent')}
</p>
) : component.config_schema.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noSettings')}
</p>
) : (
<fieldset disabled={!canManage || saving}>
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
</fieldset>
)}
</div>
<div className="h-full overflow-y-auto">
{!component ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectComponent')}
</p>
) : component.config_schema.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noSettings')}
</p>
) : (
<fieldset disabled={!canManage || saving}>
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
</fieldset>
)}
</div>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
@@ -217,7 +217,8 @@ export default function AgentApiToolPicker({
if (checked) selection.add(tool.name);
else selection.delete(tool.name);
}
if (tools.some((tool) => tool.kind === 'platform')) onPlatformChange([...platform]);
if (tools.some((tool) => tool.kind === 'platform'))
onPlatformChange([...platform]);
if (tools.some((tool) => tool.kind === 'host')) onHostChange([...host]);
};
@@ -294,8 +295,16 @@ export default function AgentApiToolPicker({
<span>{groupLabel(group)}</span>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={groupTools.every(isSelected) ? true : groupTools.some(isSelected) ? 'indeterminate' : false}
onCheckedChange={(checked) => setGroup(groupTools, checked === true)}
checked={
groupTools.every(isSelected)
? true
: groupTools.some(isSelected)
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
setGroup(groupTools, checked === true)
}
aria-label={`${groupLabel(group)} · ${t('agents.apiToolsSelectAll')}`}
/>
{t('agents.apiToolsSelectAll')}
@@ -49,15 +49,31 @@ export default function AgentCreateContent({
const createdPipeline = useRef<string | null>(null);
const loadMetadata = useCallback(async () => {
setLoading(true);
try { setMetadata(await httpClient.getAgentMetadata()); }
catch { toast.error(t('agents.createError')); }
finally { setLoading(false); }
try {
setMetadata(await httpClient.getAgentMetadata());
} catch {
toast.error(t('agents.createError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => { void loadMetadata(); }, [loadMetadata]);
const runnerOptions = kind === 'event_processor'
? (metadata?.event_processors ?? []).map((item) => ({ name: item.id, label: { en_US: item.id, zh_Hans: item.id, ...item.label } }))
: metadata?.runner_config?.stages.find((stage) => stage.name === 'runner')?.config.find((item) => item.name === 'id')?.options ?? [];
const selectorLabel = t(kind === 'event_processor' ? 'agents.selectProcessorPlugin' : 'agents.selectAgentRunner');
useEffect(() => {
void loadMetadata();
}, [loadMetadata]);
const runnerOptions =
kind === 'event_processor'
? (metadata?.event_processors ?? []).map((item) => ({
name: item.id,
label: { en_US: item.id, zh_Hans: item.id, ...item.label },
}))
: (metadata?.runner_config?.stages
.find((stage) => stage.name === 'runner')
?.config.find((item) => item.name === 'id')?.options ?? []);
const selectorLabel = t(
kind === 'event_processor'
? 'agents.selectProcessorPlugin'
: 'agents.selectAgentRunner',
);
const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }),
emoji: z.string().optional(),
@@ -75,9 +91,13 @@ export default function AgentCreateContent({
setRunner(value);
if (!form.getValues('name').trim()) {
const option = runnerOptions.find((item) => item.name === value);
const displayName = name || (option ? extractI18nObject(option.label) : '');
const displayName =
name || (option ? extractI18nObject(option.label) : '');
if (displayName) {
form.setValue('name', displayName, { shouldDirty: true, shouldValidate: true });
form.setValue('name', displayName, {
shouldDirty: true,
shouldValidate: true,
});
}
}
}
@@ -102,43 +122,91 @@ export default function AgentCreateContent({
}
async function handleSubmit(values: FormValues) {
if (!runner) { toast.error(selectorLabel); return; }
const runnerStage = metadata?.runner_config?.stages.find((stage) => stage.name === runner);
if (!runner) {
toast.error(selectorLabel);
return;
}
const runnerStage = metadata?.runner_config?.stages.find(
(stage) => stage.name === runner,
);
const parameters = runnerStage ? getDefaultValues(runnerStage.config) : {};
const config = { runner: { id: runner }, runner_config: { [runner]: parameters } };
const config = {
runner: { id: runner },
runner_config: { [runner]: parameters },
};
try {
if (kind === 'pipeline' && runnerStage?.config.some((field) => ['llm-model-selector', 'select-llm-model', 'model-fallback-selector'].includes(field.type))) {
if (
kind === 'pipeline' &&
runnerStage?.config.some((field) =>
[
'llm-model-selector',
'select-llm-model',
'model-fallback-selector',
].includes(field.type),
)
) {
const { uuid } = await httpClient.getDefaultModel();
if (uuid) {
for (const field of runnerStage.config) {
if (['llm-model-selector', 'select-llm-model'].includes(field.type) && !parameters[field.name]) parameters[field.name] = uuid;
if (field.type === 'model-fallback-selector' && !parameters[field.name]?.primary) {
parameters[field.name] = { ...parameters[field.name], primary: uuid, fallbacks: parameters[field.name]?.fallbacks ?? [] };
if (
['llm-model-selector', 'select-llm-model'].includes(field.type) &&
!parameters[field.name]
)
parameters[field.name] = uuid;
if (
field.type === 'model-fallback-selector' &&
!parameters[field.name]?.primary
) {
parameters[field.name] = {
...parameters[field.name],
primary: uuid,
fallbacks: parameters[field.name]?.fallbacks ?? [],
};
}
}
}
}
if (kind === 'pipeline') {
// Keep the created ID if configuration fails, so retry does not create duplicates.
const uuid = createdPipeline.current ?? (await httpClient.createAgent({ kind, name: values.name, description: '', emoji: values.emoji })).uuid;
const uuid =
createdPipeline.current ??
(
await httpClient.createAgent({
kind,
name: values.name,
description: '',
emoji: values.emoji,
})
).uuid;
createdPipeline.current = uuid;
const { pipeline } = await httpClient.getPipeline(uuid);
await httpClient.updatePipeline(uuid, {
name: values.name, emoji: values.emoji,
config: { ...pipeline.config, ai: { ...pipeline.config.ai, ...config } },
name: values.name,
emoji: values.emoji,
config: {
...pipeline.config,
ai: { ...pipeline.config.ai, ...config },
},
});
toast.success(t('agents.createSuccess'));
onCreated(uuid);
} else {
const response = await httpClient.createAgent({
kind, name: values.name, description: '', emoji: values.emoji,
component_ref: runner, ...(kind === 'event_processor' ? {} : { config }),
kind,
name: values.name,
description: '',
emoji: values.emoji,
component_ref: runner,
...(kind === 'event_processor' ? {} : { config }),
});
toast.success(t('agents.createSuccess'));
onCreated(response.uuid);
}
} catch (error) {
toast.error(t('agents.createError') + String((error as { msg?: string }).msg ?? error));
toast.error(
t('agents.createError') +
String((error as { msg?: string }).msg ?? error),
);
}
}
const typeOptions = [
@@ -163,11 +231,17 @@ export default function AgentCreateContent({
];
return (
<div className="flex h-full flex-col">
<div className={embedded ? 'order-last flex justify-end border-t pt-4 shrink-0' : 'flex items-center justify-between pb-4 shrink-0'}>
<div
className={
embedded
? 'order-last flex justify-end border-t pt-4 shrink-0'
: 'flex items-center justify-between pb-4 shrink-0'
}
>
{!embedded && (
<h1 className="text-xl font-semibold">
{t('agents.eventProcessor.createPageTitle')}
</h1>
<h1 className="text-xl font-semibold">
{t('agents.eventProcessor.createPageTitle')}
</h1>
)}
<Button
type="submit"
@@ -193,9 +267,11 @@ export default function AgentCreateContent({
>
{t('agents.chooseType')}
</h2>
{!embedded && <p className="mt-1 text-sm text-muted-foreground">
{t('agents.chooseTypeDescription')}
</p>}
{!embedded && (
<p className="mt-1 text-sm text-muted-foreground">
{t('agents.chooseTypeDescription')}
</p>
)}
</div>
<ToggleGroup
@@ -208,31 +284,36 @@ export default function AgentCreateContent({
spacing={3}
className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-1"
>
{typeOptions.filter((option) => !allowedKinds || allowedKinds.includes(option.kind)).map((option) => {
const Icon = option.icon;
return (
<div key={option.kind} className="relative w-full">
<ToggleGroupItem
value={option.kind}
data-processor-kind={option.kind}
aria-label={`${option.title} ${option.description}`}
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
<Icon className="size-4" />
</span>
<span className="min-w-0 space-y-1.5">
<span className="block text-sm font-medium text-foreground">
{option.title}
</span>
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</ToggleGroupItem>
</div>
);
})}
{typeOptions
.filter(
(option) =>
!allowedKinds || allowedKinds.includes(option.kind),
)
.map((option) => {
const Icon = option.icon;
return (
<div key={option.kind} className="relative w-full">
<ToggleGroupItem
value={option.kind}
data-processor-kind={option.kind}
aria-label={`${option.title} ${option.description}`}
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
<Icon className="size-4" />
</span>
<span className="min-w-0 space-y-1.5">
<span className="block text-sm font-medium text-foreground">
{option.title}
</span>
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</ToggleGroupItem>
</div>
);
})}
</ToggleGroup>
</section>
@@ -262,12 +343,43 @@ export default function AgentCreateContent({
disabled={loading || form.formState.isSubmitting}
installScope={`processor-create-${kind === 'event_processor' ? 'event' : 'agent'}`}
onInstalled={(installed) => {
setMetadata((previous) => previous ? { ...previous, runner_config: installed.configTab } : previous);
selectRunner(installed.runner.name, extractI18nObject(installed.runner.label));
setMetadata((previous) =>
previous
? {
...previous,
runner_config: installed.configTab,
}
: previous,
);
selectRunner(
installed.runner.name,
extractI18nObject(installed.runner.label),
);
}}
onEventProcessorInstalled={(component) => {
setMetadata((previous) => previous ? { ...previous, event_processors: [...(previous.event_processors ?? []).filter((item) => item.id !== component.id), component] } : previous);
selectRunner(component.id, extractI18nObject({ en_US: component.id, zh_Hans: component.id, ...component.label }));
setMetadata((previous) =>
previous
? {
...previous,
event_processors: [
...(
previous.event_processors ?? []
).filter(
(item) => item.id !== component.id,
),
component,
],
}
: previous,
);
selectRunner(
component.id,
extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
}),
);
}}
/>
</div>
@@ -305,15 +417,19 @@ export default function AgentCreateContent({
)}
/>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
<Card className={embedded ? 'min-h-[360px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[480px]' : 'min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]'}>
<Card
className={
embedded
? 'min-h-[360px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[480px]'
: 'min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]'
}
>
<CardContent className="flex h-full items-center p-0">
<ProcessorTypeDiagram kind={kind} />
</CardContent>
@@ -212,8 +212,13 @@ export default function AgentMonitoringTab({
(step) => step.kind === 'tool' || step.text.trim() || step.reasoning.trim(),
);
const toolSteps = steps.filter((step) => step.kind === 'tool');
const sentCount = toolSteps.filter((step) => isReplyStep(step) && !isMockStep(step) && step.status === 'completed').length;
const sendFailed = toolSteps.some((step) => isReplyStep(step) && step.status === 'failed');
const sentCount = toolSteps.filter(
(step) =>
isReplyStep(step) && !isMockStep(step) && step.status === 'completed',
).length;
const sendFailed = toolSteps.some(
(step) => isReplyStep(step) && step.status === 'failed',
);
const duration = selected ? processorRunDuration(selected) : null;
const failureReason =
selected?.status_reason ||
@@ -264,7 +269,11 @@ export default function AgentMonitoringTab({
</Button>
</div>
{listError && (
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={refresh} />
<LoadErrorState
compact
title={t('monitoring.loadError')}
onRetry={refresh}
/>
)}
<div className="grid min-h-0 flex-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<section
@@ -383,17 +392,32 @@ export default function AgentMonitoringTab({
</div>
<div className="grid grid-cols-1 divide-y rounded-lg border bg-muted/20 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
<div className="space-y-1 p-4">
<p className="text-xs text-muted-foreground">{t('agents.monitoring.runStatus')}</p>
<p className="text-sm font-semibold">{stateLabel(selected)}</p>
<p className="text-xs text-muted-foreground">
{t('agents.monitoring.runStatus')}
</p>
<p className="text-sm font-semibold">
{stateLabel(selected)}
</p>
</div>
<div className="space-y-1 p-4">
<p className="text-xs text-muted-foreground">{t('agents.monitoring.toolCalls')}</p>
<p className="text-sm font-semibold tabular-nums">{toolSteps.length}</p>
<p className="text-xs text-muted-foreground">
{t('agents.monitoring.toolCalls')}
</p>
<p className="text-sm font-semibold tabular-nums">
{toolSteps.length}
</p>
</div>
<div className="space-y-1 p-4">
<p className="text-xs text-muted-foreground">{t('agents.monitoring.delivery')}</p>
<p className={`text-sm font-semibold ${sendFailed ? 'text-destructive' : ''}`}>
{t(`agents.monitoring.${sendFailed ? 'sendFailed' : sentCount ? 'sendCount' : 'noSend'}`, { count: sentCount })}
<p className="text-xs text-muted-foreground">
{t('agents.monitoring.delivery')}
</p>
<p
className={`text-sm font-semibold ${sendFailed ? 'text-destructive' : ''}`}
>
{t(
`agents.monitoring.${sendFailed ? 'sendFailed' : sentCount ? 'sendCount' : 'noSend'}`,
{ count: sentCount },
)}
</p>
</div>
</div>
@@ -438,13 +462,21 @@ export default function AgentMonitoringTab({
</Card>
<h3 className="text-sm font-medium">
{t('agents.monitoring.execution')}
<span className="ml-2 text-muted-foreground">{steps.length}</span>
<span className="ml-2 text-muted-foreground">
{steps.length}
</span>
</h3>
{!steps.length && <p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">{t('agents.monitoring.noSteps')}</p>}
{!steps.length && (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
{t('agents.monitoring.noSteps')}
</p>
)}
<AgentRunTimeline
steps={steps}
labels={labels}
active={activeStatuses.has(selected.status) || eventCursor !== null}
active={
activeStatuses.has(selected.status) || eventCursor !== null
}
/>
{events
.filter((event) => event.type === 'processor.log')
@@ -493,7 +525,10 @@ export default function AgentMonitoringTab({
)
)}
{traceError && (
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => setTraceRevision((value) => value + 1)} />
<LoadErrorState
title={t('monitoring.loadError')}
onRetry={() => setTraceRevision((value) => value + 1)}
/>
)}
</section>
</div>
@@ -6,16 +6,24 @@ import type { ExecutionStep } from './debug-execution';
import { toolDisplay, toolSummary } from './tool-summary';
export function isReplyStep(step: ExecutionStep) {
return step.kind === 'tool' &&
toolDisplay(step.name)?.kind === 'reply';
return step.kind === 'tool' && toolDisplay(step.name)?.kind === 'reply';
}
export function isMockStep(step: ExecutionStep) {
return step.kind === 'tool' && !!step.result &&
typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true;
return (
step.kind === 'tool' &&
!!step.result &&
typeof step.result === 'object' &&
'mock' in step.result &&
step.result.mock === true
);
}
export default function AgentRunTimeline({ steps, labels, active }: {
export default function AgentRunTimeline({
steps,
labels,
active,
}: {
steps: ExecutionStep[];
labels: Record<string, string>;
active: boolean;
@@ -26,43 +34,136 @@ export default function AgentRunTimeline({ steps, labels, active }: {
{steps.map((step, index) => {
const reply = isReplyStep(step);
const mock = isMockStep(step);
const params = step.kind === 'tool' && step.parameters && typeof step.parameters === 'object'
? step.parameters as Record<string, unknown> : {};
const summary = toolSummary(step.kind === 'tool' ? step.name : '', params);
const params =
step.kind === 'tool' &&
step.parameters &&
typeof step.parameters === 'object'
? (step.parameters as Record<string, unknown>)
: {};
const summary = toolSummary(
step.kind === 'tool' ? step.name : '',
params,
);
const replyText = reply ? summary.text : '';
const actionText = reply ? '' : summary.text;
const Icon = step.kind === 'message' ? MessageSquare : reply ? Send : Wrench;
const Icon =
step.kind === 'message' ? MessageSquare : reply ? Send : Wrench;
const status = step.kind === 'tool' ? step.status : null;
const statusKey = status === 'running' ? (active ? 'Running' : 'Interrupted')
: mock ? (status === 'failed' ? 'MockFailed' : 'Simulated')
: status === 'failed' ? 'Failed' : 'Completed';
const statusKey =
status === 'running'
? active
? 'Running'
: 'Interrupted'
: mock
? status === 'failed'
? 'MockFailed'
: 'Simulated'
: status === 'failed'
? 'Failed'
: 'Completed';
return (
<li key={step.kind === 'tool' ? step.id : `message-${index}`} className="relative flex min-w-0 gap-3 pb-5 last:pb-0">
{index < steps.length - 1 && <span className="absolute bottom-0 left-4 top-8 w-px bg-border" aria-hidden="true" />}
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground">{index + 1}</span>
<li
key={step.kind === 'tool' ? step.id : `message-${index}`}
className="relative flex min-w-0 gap-3 pb-5 last:pb-0"
>
{index < steps.length - 1 && (
<span
className="absolute bottom-0 left-4 top-8 w-px bg-border"
aria-hidden="true"
/>
)}
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 flex-1 rounded-lg border bg-background">
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/30 px-4 py-3">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<h4 className="min-w-0 flex-1 break-words text-sm font-medium">
{step.kind === 'message' ? t('agents.debugTextOutput') : labels[step.name] || step.name}
{step.kind === 'message'
? t('agents.debugTextOutput')
: labels[step.name] || step.name}
</h4>
{status && <Badge status={status === 'failed' ? 'failed' : mock ? 'simulated' : status === 'running' && !active ? 'cancelled' : status}>{t(`agents.debugTool${statusKey}`)}</Badge>}
{status && (
<Badge
status={
status === 'failed'
? 'failed'
: mock
? 'simulated'
: status === 'running' && !active
? 'cancelled'
: status
}
>
{t(`agents.debugTool${statusKey}`)}
</Badge>
)}
</div>
<div className="space-y-3 p-4 text-sm">
{step.kind === 'message' ? <>
{step.text && <p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">{step.text}</p>}
{step.reasoning && <ProcessorPayload title={t('agents.debugReasoning')} value={step.reasoning} />}
</> : <>
{replyText && <p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">{replyText}</p>}
{!reply && actionText && <p className="whitespace-pre-wrap break-words rounded-md bg-muted/50 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">{actionText}</p>}
{summary.recipient && <p className="break-all text-xs text-muted-foreground">{t('agents.monitoring.recipient')}: {summary.recipient}</p>}
{step.error && <p role="alert" className="whitespace-pre-wrap break-words text-destructive">{step.error}</p>}
{status === 'completed' && (mock || !reply) && <p className="text-xs text-muted-foreground">{t(`agents.monitoring.${mock ? 'simulatedAction' : 'actionCompleted'}`)}</p>}
{(step.parameters != null || step.result != null) && <div className="rounded-md border border-dashed text-muted-foreground">
{step.parameters != null && <ProcessorPayload title={t('agents.debugToolArguments')} value={step.parameters} />}
{step.result != null && <ProcessorPayload title={t('agents.debugToolResult')} value={step.result} />}
</div>}
</>}
{step.kind === 'message' ? (
<>
{step.text && (
<p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">
{step.text}
</p>
)}
{step.reasoning && (
<ProcessorPayload
title={t('agents.debugReasoning')}
value={step.reasoning}
/>
)}
</>
) : (
<>
{replyText && (
<p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">
{replyText}
</p>
)}
{!reply && actionText && (
<p className="whitespace-pre-wrap break-words rounded-md bg-muted/50 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
{actionText}
</p>
)}
{summary.recipient && (
<p className="break-all text-xs text-muted-foreground">
{t('agents.monitoring.recipient')}: {summary.recipient}
</p>
)}
{step.error && (
<p
role="alert"
className="whitespace-pre-wrap break-words text-destructive"
>
{step.error}
</p>
)}
{status === 'completed' && (mock || !reply) && (
<p className="text-xs text-muted-foreground">
{t(
`agents.monitoring.${mock ? 'simulatedAction' : 'actionCompleted'}`,
)}
</p>
)}
{(step.parameters != null || step.result != null) && (
<div className="rounded-md border border-dashed text-muted-foreground">
{step.parameters != null && (
<ProcessorPayload
title={t('agents.debugToolArguments')}
value={step.parameters}
/>
)}
{step.result != null && (
<ProcessorPayload
title={t('agents.debugToolResult')}
value={step.result}
/>
)}
</div>
)}
</>
)}
</div>
</div>
</li>
@@ -53,33 +53,42 @@ export default function PluginProcessorTrace({
key={event.sequence ?? index}
className="min-w-0 overflow-hidden rounded-lg border bg-background"
>
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-3">
<ScrollText className="size-4 text-muted-foreground" />
<span className="flex-1 text-sm font-medium">{t('agents.eventProcessor.logsTab')}</span>
<Badge status={String(event.data.level)}>{String(event.data.level)}</Badge>
</div>
<p className={`min-w-0 whitespace-pre-wrap break-words p-4 text-sm leading-7 [overflow-wrap:anywhere] ${event.data.level === 'error' ? 'text-destructive' : ''}`}>
{String(event.data.text)}
</p>
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-3">
<ScrollText className="size-4 text-muted-foreground" />
<span className="flex-1 text-sm font-medium">
{t('agents.eventProcessor.logsTab')}
</span>
<Badge status={String(event.data.level)}>
{String(event.data.level)}
</Badge>
</div>
<p
className={`min-w-0 whitespace-pre-wrap break-words p-4 text-sm leading-7 [overflow-wrap:anywhere] ${event.data.level === 'error' ? 'text-destructive' : ''}`}
>
{String(event.data.text)}
</p>
</div>
) : (
<div key={event.sequence ?? index} className="rounded-lg border bg-background p-3">
<ProcessorPayload
title={
<>
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] || event.data.tool_name}
</span>
)}
</>
}
value={event.data}
/>
<div
key={event.sequence ?? index}
className="rounded-lg border bg-background p-3"
>
<ProcessorPayload
title={
<>
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] || event.data.tool_name}
</span>
)}
</>
}
value={event.data}
/>
</div>
),
)}
@@ -28,7 +28,9 @@ export default function ProcessorRunList({
<div
role="group"
aria-label={t('agents.eventProcessor.runs')}
className={className ?? 'max-h-56 shrink-0 overflow-y-auto rounded-md border'}
className={
className ?? 'max-h-56 shrink-0 overflow-y-auto rounded-md border'
}
>
{runs.map((run) => {
const duration = processorRunDuration(run);
@@ -521,18 +521,22 @@ export default function RunnerSelect({
</div>
)}
</SelectGroup>
<SelectSeparator />
<a
href="https://langbot.app/docs/zh/plugin/dev/components/runner"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-sm px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{t(usage === 'event' ? 'bots.routingHelp.docs' : 'bots.routingHelp.runnerDocs')}
<ExternalLink className="size-3" />
</a>
<SelectSeparator />
<a
href="https://langbot.app/docs/zh/plugin/dev/components/runner"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-sm px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{t(
usage === 'event'
? 'bots.routingHelp.docs'
: 'bots.routingHelp.runnerDocs',
)}
<ExternalLink className="size-3" />
</a>
</SelectContent>
</Select>
@@ -92,35 +92,87 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
'message.deleted': deletion,
'dingtalk.card_action': {
fields: [user],
defaults: { space_id: 'dtv1.card//IM_GROUP.debug-group', card_instance_id: 'debug-card', action: 'confirm', data: {} },
defaults: {
space_id: 'dtv1.card//IM_GROUP.debug-group',
card_instance_id: 'debug-card',
action: 'confirm',
data: {},
},
},
'wecombot.enter_chat': {
fields: [user],
defaults: { chat_id: 'debug-user', chat_type: 'private', message_id: 'debug-entry', data: {} },
defaults: {
chat_id: 'debug-user',
chat_type: 'private',
message_id: 'debug-entry',
data: {},
},
},
'wecombot.template_card_event': {
fields: [user],
defaults: { chat_id: 'debug-user', chat_type: 'private', message_id: 'debug-card-click', task_id: 'debug-card', event_key: 'confirm', card_type: 'button_interaction', selected_items: [], data: {} },
defaults: {
chat_id: 'debug-user',
chat_type: 'private',
message_id: 'debug-card-click',
task_id: 'debug-card',
event_key: 'confirm',
card_type: 'button_interaction',
selected_items: [],
data: {},
},
},
'wecomcs.enter_session': {
fields: [user],
defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', scene: 'debug', scene_param: '', welcome_code: '', wechat_channels: {} },
defaults: {
open_kfid: 'debug-kf',
external_userid: 'debug-user',
chat_id: 'debug-user|debug-kf',
scene: 'debug',
scene_param: '',
welcome_code: '',
wechat_channels: {},
},
},
'wecomcs.msg_send_fail': {
fields: [user],
defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', fail_msgid: 'debug-message', fail_type: 4 },
defaults: {
open_kfid: 'debug-kf',
external_userid: 'debug-user',
chat_id: 'debug-user|debug-kf',
fail_msgid: 'debug-message',
fail_type: 4,
},
},
'wecomcs.servicer_status_change': {
fields: [],
defaults: { open_kfid: 'debug-kf', servicer_userid: 'debug-servicer', status: 2, stop_type: 0 },
defaults: {
open_kfid: 'debug-kf',
servicer_userid: 'debug-servicer',
status: 2,
stop_type: 0,
},
},
'wecomcs.session_status_change': {
fields: [user],
defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', change_type: 2, old_servicer_userid: 'debug-servicer-1', new_servicer_userid: 'debug-servicer-2', msg_code: '' },
defaults: {
open_kfid: 'debug-kf',
external_userid: 'debug-user',
chat_id: 'debug-user|debug-kf',
change_type: 2,
old_servicer_userid: 'debug-servicer-1',
new_servicer_userid: 'debug-servicer-2',
msg_code: '',
},
},
'wecomcs.reject_customer_msg_switch_change': {
fields: [user],
defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', servicer_userid: 'debug-servicer', reject_switch: 1 },
defaults: {
open_kfid: 'debug-kf',
external_userid: 'debug-user',
chat_id: 'debug-user|debug-kf',
servicer_userid: 'debug-servicer',
reject_switch: 1,
},
},
'message.recalled': deletion,
'message.reaction': {
@@ -14,7 +14,11 @@ const TOOL_DISPLAYS: Readonly<Record<string, ToolDisplay>> = {
glob: { fields: ['pattern', 'path'] },
grep: { fields: ['pattern', 'path'] },
event_reply: { fields: ['text'], kind: 'reply' },
platform_send_message: { fields: ['text'], kind: 'reply', recipient: 'target_id' },
platform_send_message: {
fields: ['text'],
kind: 'reply',
recipient: 'target_id',
},
event_delete_message: { fields: [] },
event_get_actor: { fields: [] },
event_get_group: { fields: [] },
@@ -41,21 +45,29 @@ const TOOL_DISPLAYS: Readonly<Record<string, ToolDisplay>> = {
export function toolDisplay(name: string): ToolDisplay | undefined {
return Object.prototype.hasOwnProperty.call(TOOL_DISPLAYS, name)
? TOOL_DISPLAYS[name] : undefined;
? TOOL_DISPLAYS[name]
: undefined;
}
export function toolSummary(name: string, parameters: Record<string, unknown>) {
const display = toolDisplay(name);
const fields = (display?.fields ?? []).flatMap((field) => {
const value = parameters[field];
if (!['string', 'number', 'boolean'].includes(typeof value) || value === '') return [];
if (!['string', 'number', 'boolean'].includes(typeof value) || value === '')
return [];
return [{ field, text: String(value) }];
});
return {
text: fields.map(({ field, text }) =>
display?.kind === 'reply' || display?.fields.length === 1 ? text : `${field}: ${text}`,
).join('\n'),
recipient: display?.recipient && typeof parameters[display.recipient] === 'string'
? String(parameters[display.recipient]) : '',
text: fields
.map(({ field, text }) =>
display?.kind === 'reply' || display?.fields.length === 1
? text
: `${field}: ${text}`,
)
.join('\n'),
recipient:
display?.recipient && typeof parameters[display.recipient] === 'string'
? String(parameters[display.recipient])
: '',
};
}
@@ -316,7 +316,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
`${adapter.label} ${adapter.value} ${adapterDescriptionList[adapter.value] ?? ''}`
.toLocaleLowerCase()
.includes(adapterQuery);
const galleryGroups = groupByCategory(activeAdapters.filter(matchesAdapterSearch));
const galleryGroups = groupByCategory(
activeAdapters.filter(matchesAdapterSearch),
);
const galleryLegacyAdapters = legacyAdapters.filter(matchesAdapterSearch);
// Whether the collapsed legacy adapter group is expanded in the Select.
@@ -717,10 +719,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
}
const hasProcessing =
(form.watch('event_bindings') ?? []).some((binding) =>
binding.enabled !== false && !!binding.event_pattern &&
(binding.target_type === 'discard' ||
agentNameList.some((agent) => agent.uuid === binding.target_uuid)),
(form.watch('event_bindings') ?? []).some(
(binding) =>
binding.enabled !== false &&
!!binding.event_pattern &&
(binding.target_type === 'discard' ||
agentNameList.some((agent) => agent.uuid === binding.target_uuid)),
) ||
(form.watch('plugin_processors') ?? []).some(
(processor) => processor.enabled !== false && !!processor.processor_uuid,
@@ -807,7 +811,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
<Input
type="search"
value={adapterSearch}
onChange={(event) => setAdapterSearch(event.target.value)}
onChange={(event) =>
setAdapterSearch(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
@@ -823,11 +829,15 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
</div>
<div className="space-y-8">
{galleryGroups.length === 0 && galleryLegacyAdapters.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground" role="status">
{t('bots.noMatchingAdapters')}
</p>
)}
{galleryGroups.length === 0 &&
galleryLegacyAdapters.length === 0 && (
<p
className="py-8 text-center text-sm text-muted-foreground"
role="status"
>
{t('bots.noMatchingAdapters')}
</p>
)}
{galleryGroups.map((group) => (
<section
key={group.categoryId ?? 'uncategorized'}
@@ -889,23 +899,25 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
data-adapter-category="legacy"
>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setShowLegacyAdapters((value) => !value)}
className="flex items-center gap-2 py-2 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
aria-expanded={showLegacyAdapters || !!adapterQuery}
>
{showLegacyAdapters || adapterQuery ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
{galleryLegacyAdapters.length}
</span>
</button>
<LegacyAdapterHelp />
<button
type="button"
onClick={() =>
setShowLegacyAdapters((value) => !value)
}
className="flex items-center gap-2 py-2 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
aria-expanded={showLegacyAdapters || !!adapterQuery}
>
{showLegacyAdapters || adapterQuery ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
{galleryLegacyAdapters.length}
</span>
</button>
<LegacyAdapterHelp />
</div>
{(showLegacyAdapters || !!adapterQuery) && (
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -1251,12 +1263,18 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
role="status"
className="flex shrink-0 items-start gap-2.5 rounded-t-xl border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm leading-relaxed text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/40 dark:text-amber-200"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<TriangleAlert
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>{t('botSetup.warning')}</p>
</div>
)}
<CardHeader>
<CardTitle className="flex items-center gap-1.5">{t('bots.eventRouting')}<EventRoutingHelp /></CardTitle>
<CardTitle className="flex items-center gap-1.5">
{t('bots.eventRouting')}
<EventRoutingHelp />
</CardTitle>
<CardDescription>
{t('bots.eventRoutingDescription')}
</CardDescription>
@@ -1269,7 +1287,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
>
<EventBindingsEditor
form={form}
onAgentCreated={(agent) => setAgentNameList((items) => [...items.filter((item) => item.uuid !== agent.uuid), agent])}
onAgentCreated={(agent) =>
setAgentNameList((items) => [
...items.filter((item) => item.uuid !== agent.uuid),
agent,
])
}
botId={initBotId}
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
agentOptions={agentNameList.filter(
@@ -586,7 +586,10 @@ function TargetCombobox({
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder={t('bots.searchTarget')} className="h-8" />
<CommandInput
placeholder={t('bots.searchTarget')}
className="h-8"
/>
<CommandList>
<CommandEmpty>{t('bots.noTargetFound')}</CommandEmpty>
{agents.length > 0 && (
@@ -595,7 +598,9 @@ function TargetCombobox({
<CommandItem
key={a.uuid}
value={`agent:${a.uuid}:${a.name}`}
onSelect={() => select(encodeTarget('agent', a.uuid || ''))}
onSelect={() =>
select(encodeTarget('agent', a.uuid || ''))
}
>
<Bot className="mr-2 size-3.5 shrink-0" />
<span className="truncate">{targetLabel(a)}</span>
@@ -650,7 +655,9 @@ function TargetCombobox({
>
<DialogHeader>
<DialogTitle>{t('botSetup.create')}</DialogTitle>
<DialogDescription>{t('botSetup.createDescription')}</DialogDescription>
<DialogDescription>
{t('botSetup.createDescription')}
</DialogDescription>
</DialogHeader>
<AgentCreateContent
embedded
@@ -660,9 +667,10 @@ function TargetCombobox({
try {
const { agent } = await backendClient.getAgent(id);
onAgentCreated(agent);
const compatible = agent.kind === 'pipeline'
? pipelineAllowed
: agentSupportsEventPattern(agent, binding.event_pattern);
const compatible =
agent.kind === 'pipeline'
? pipelineAllowed
: agentSupportsEventPattern(agent, binding.event_pattern);
if (compatible) {
onUpdate({ target_type: agent.kind, target_uuid: id });
} else {
@@ -1570,9 +1578,15 @@ export default function EventBindingsEditor({
setRouteStatusError(null);
try {
const response = await backendClient.getBotEventRouteStatuses(botId);
setRouteStatuses([...response.routes, ...response.unmatched_events, ...response.stale_routes]
.filter((record) => record.last_status)
.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)));
setRouteStatuses(
[
...response.routes,
...response.unmatched_events,
...response.stale_routes,
]
.filter((record) => record.last_status)
.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)),
);
} catch (error) {
console.error('Failed to refresh Bot event route status', error);
setRouteStatusError(t('bots.routeStatusRefreshFailed'));
@@ -1805,7 +1819,9 @@ export default function EventBindingsEditor({
type="button"
variant="outline"
size="sm"
className={bindings.length === 0 ? styles.emptyRouteHint : undefined}
className={
bindings.length === 0 ? styles.emptyRouteHint : undefined
}
>
<Plus className="h-4 w-4 mr-1" />
{t('bots.addBehavior')}
@@ -1882,7 +1898,13 @@ export default function EventBindingsEditor({
eventOptions={dryRunEventOptions}
agentOptions={agentOptions}
/>
<Button type="button" variant="outline" size="sm" disabled={!botId} onClick={() => setRecordsOpen(true)}>
<Button
type="button"
variant="outline"
size="sm"
disabled={!botId}
onClick={() => setRecordsOpen(true)}
>
<ListChecks className="mr-1 size-4" />
{t('bots.matchRecords')}
</Button>
@@ -1890,38 +1912,86 @@ export default function EventBindingsEditor({
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('bots.matchRecords')}</DialogTitle>
<DialogDescription>{t('bots.matchRecordsDescription')}</DialogDescription>
<DialogDescription>
{t('bots.matchRecordsDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={refreshRouteStatuses} disabled={routeStatusLoading}>
<RefreshCw className={`mr-2 size-4 ${routeStatusLoading ? 'animate-spin' : ''}`} />
<Button
type="button"
variant="outline"
size="sm"
onClick={refreshRouteStatuses}
disabled={routeStatusLoading}
>
<RefreshCw
className={`mr-2 size-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
/>
{t('monitoring.refreshData')}
</Button>
</div>
{routeStatusError && <p role="alert" className="text-sm text-destructive">{routeStatusError}</p>}
{routeStatusError && (
<p role="alert" className="text-sm text-destructive">
{routeStatusError}
</p>
)}
<div className="max-h-[60vh] space-y-3 overflow-y-auto">
{routeStatuses.map((record, index) => (
<div key={`${record.binding_id}:${record.seq_id}:${index}`} className="space-y-2 rounded-lg border p-3">
<div
key={`${record.binding_id}:${record.seq_id}:${index}`}
className="space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-sm font-medium">{eventLabel(record.event_type || record.event_pattern || '*', t)}</span>
<Badge variant="outline" className={routeStatusBadgeClass(record.last_status)}>{routeStatusLabel(record.last_status, t)}</Badge>
<span className="flex-1 text-sm font-medium">
{eventLabel(
record.event_type || record.event_pattern || '*',
t,
)}
</span>
<Badge
variant="outline"
className={routeStatusBadgeClass(record.last_status)}
>
{routeStatusLabel(record.last_status, t)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">{formatRouteStatusTime(record.timestamp)}</div>
{record.target_type && <div className="break-words text-sm">{record.target_type}{record.target_uuid ? ` · ${agentOptions.find((agent) => agent.uuid === record.target_uuid)?.name || record.target_uuid}` : ''}</div>}
<p className="text-sm text-muted-foreground">{routeStatusDetail(record, t)}</p>
<div className="text-xs text-muted-foreground">
{formatRouteStatusTime(record.timestamp)}
</div>
{record.target_type && (
<div className="break-words text-sm">
{record.target_type}
{record.target_uuid
? ` · ${agentOptions.find((agent) => agent.uuid === record.target_uuid)?.name || record.target_uuid}`
: ''}
</div>
)}
<p className="text-sm text-muted-foreground">
{routeStatusDetail(record, t)}
</p>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{t('monitoring.unified.metadata')}</summary>
<pre className="mt-2 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{JSON.stringify(record, null, 2)}</pre>
<summary className="cursor-pointer">
{t('monitoring.unified.metadata')}
</summary>
<pre className="mt-2 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{JSON.stringify(record, null, 2)}
</pre>
</details>
</div>
))}
{!routeStatuses.length && <p className="py-8 text-center text-sm text-muted-foreground">{t(routeStatusLoading ? 'common.loading' : 'monitoring.unified.noRecords')}</p>}
{!routeStatuses.length && (
<p className="py-8 text-center text-sm text-muted-foreground">
{t(
routeStatusLoading
? 'common.loading'
: 'monitoring.unified.noRecords',
)}
</p>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
@@ -2,7 +2,11 @@
import { useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import './EventRoutingHelp.css';
const routeColors = [
@@ -18,40 +22,90 @@ export default function EventRoutingHelp() {
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" aria-label={text('title')} className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M12 11v6" /><circle cx="12" cy="7.5" r=".7" fill="currentColor" />
<button
type="button"
aria-label={text('title')}
className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6" />
<circle cx="12" cy="7.5" r=".7" fill="currentColor" />
</svg>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(32rem,calc(100vw-2rem))] max-h-[80vh] overflow-y-auto p-5">
<PopoverContent
align="start"
className="w-[min(32rem,calc(100vw-2rem))] max-h-[80vh] overflow-y-auto p-5"
>
<h3 className="text-sm font-semibold">{text('title')}</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{text('intro')}</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{text('intro')}
</p>
<div className="my-4 overflow-hidden rounded-xl border bg-muted/20 p-3">
<div className="mb-3 grid grid-cols-2 text-xs text-muted-foreground">
<span>{text('events')}</span><span className="text-right">{text('agents')}</span>
<span>{text('events')}</span>
<span className="text-right">{text('agents')}</span>
</div>
<div className={`routing-help space-y-3 ${paused ? 'routing-help-paused' : ''}`}>
<div
className={`routing-help space-y-3 ${paused ? 'routing-help-paused' : ''}`}
>
{['message', 'friend', 'group'].map((kind, index) => (
<div key={kind} className={`grid grid-cols-[minmax(0,1fr)_48px_minmax(0,1fr)] items-center ${routeColors[index]}`} style={{ '--route-delay': `${index * 1.6}s` } as CSSProperties}>
<div className="route-node rounded-lg border px-2 py-3 text-center text-xs leading-5">{text(kind)}</div>
<div
key={kind}
className={`grid grid-cols-[minmax(0,1fr)_48px_minmax(0,1fr)] items-center ${routeColors[index]}`}
style={{ '--route-delay': `${index * 1.6}s` } as CSSProperties}
>
<div className="route-node rounded-lg border px-2 py-3 text-center text-xs leading-5">
{text(kind)}
</div>
<div className="relative h-8" aria-hidden="true">
<svg viewBox="0 0 48 32" className="h-full w-full opacity-30"><path d="M0 16H45m-5-4 5 4-5 4" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>
<svg viewBox="0 0 48 32" className="h-full w-full opacity-30">
<path
d="M0 16H45m-5-4 5 4-5 4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
<span className="routing-help-dot absolute left-0 top-[13px] size-1.5 rounded-full bg-current" />
</div>
<div className="route-node routing-help-target rounded-lg border px-2 py-3 text-center text-xs leading-5">{text(`${kind}Action`)}</div>
<div className="route-node routing-help-target rounded-lg border px-2 py-3 text-center text-xs leading-5">
{text(`${kind}Action`)}
</div>
</div>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{text('rules')}</span>
<button type="button" className="shrink-0 rounded px-1 underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hidden" onClick={() => setPaused(!paused)}>{text(paused ? 'play' : 'pause')}</button>
<button
type="button"
className="shrink-0 rounded px-1 underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hidden"
onClick={() => setPaused(!paused)}
>
{text(paused ? 'play' : 'pause')}
</button>
</div>
</div>
<p className="text-sm leading-6">{text('instructions')}</p>
<div className="mt-4 border-t pt-3 text-xs leading-6 text-muted-foreground">
<p>{text('plugin')}</p>
<a href="https://langbot.app/docs/zh/plugin/dev/components/runner" target="_blank" rel="noopener noreferrer" className="font-medium text-foreground underline underline-offset-4">{text('docs')} ↗</a>
<a
href="https://langbot.app/docs/zh/plugin/dev/components/runner"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-4"
>
{text('docs')} ↗
</a>
</div>
</PopoverContent>
</Popover>
@@ -1,18 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
export default function LegacyAdapterHelp() {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" aria-label={t('bots.legacyAdapters')} className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 11v6" /><circle cx="12" cy="7.5" r=".7" fill="currentColor" /></svg>
<button
type="button"
aria-label={t('bots.legacyAdapters')}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6" />
<circle cx="12" cy="7.5" r=".7" fill="currentColor" />
</svg>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)]">
<h4 className="mb-2 text-sm font-medium">{t('bots.legacyAdapters')}</h4>
<p className="whitespace-pre-line text-sm leading-6 text-muted-foreground">{t('bots.legacyAdaptersHint')}</p>
<p className="whitespace-pre-line text-sm leading-6 text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
</PopoverContent>
</Popover>
);
@@ -1,6 +1,13 @@
import { useCurrentWorkspace } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import LoadErrorState from '@/components/LoadErrorState';
import React, {
useState,
@@ -436,14 +443,18 @@ const BotSessionMonitor = forwardRef<
if (parsed && typeof parsed === 'object') {
const chain = parsed.message_chain ?? parsed.event?.message_chain;
if (Array.isArray(chain)) return chain as MessageChainComponent[];
if (Array.isArray(chain?.root)) return chain.root as MessageChainComponent[];
if (Array.isArray(chain?.root))
return chain.root as MessageChainComponent[];
if (Array.isArray(parsed.contents)) {
const components: MessageChainComponent[] = [];
for (const part of parsed.contents) {
if (part.type === 'text' && typeof part.text === 'string') {
components.push({ type: 'Plain', text: part.text } as Plain);
} else if (part.type === 'image' && part.image_url) {
components.push({ type: 'Image', url: part.image_url } as MessageChainComponent);
components.push({
type: 'Image',
url: part.image_url,
} as MessageChainComponent);
}
}
if (components.length) return components;
@@ -785,7 +796,11 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.loading')}
</div>
) : sessionError ? (
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={loadSessions} />
<LoadErrorState
compact
title={t('monitoring.loadError')}
onRetry={loadSessions}
/>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
@@ -949,8 +964,13 @@ const BotSessionMonitor = forwardRef<
</div>
</div>
{canReset && (
<Button variant="outline" size="sm" className="h-7 shrink-0 gap-1.5"
disabled={resetting} onClick={() => setResetTarget(selectedSessionId)}>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5"
disabled={resetting}
onClick={() => setResetTarget(selectedSessionId)}
>
<RotateCcw className="size-3.5" />
{t('bots.sessionMonitor.resetContext')}
</Button>
@@ -964,14 +984,25 @@ const BotSessionMonitor = forwardRef<
>
<div className="space-y-4">
{analysisError && !loadingMessages && (
<LoadErrorState compact title={`${t('monitoring.toolCalls.title')}: ${t('monitoring.loadError')}`} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
<LoadErrorState
compact
title={`${t('monitoring.toolCalls.title')}: ${t('monitoring.loadError')}`}
onRetry={() =>
loadMessages(selectedSessionId, messagePage)
}
/>
)}
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messageError ? (
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
<LoadErrorState
title={t('monitoring.loadError')}
onRetry={() =>
loadMessages(selectedSessionId, messagePage)
}
/>
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
@@ -1226,16 +1257,34 @@ const BotSessionMonitor = forwardRef<
</div>
</div>
<Dialog open={resetTarget !== null} onOpenChange={(open) => { if (!open && !resetting) setResetTarget(null); }}>
<Dialog
open={resetTarget !== null}
onOpenChange={(open) => {
if (!open && !resetting) setResetTarget(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bots.sessionMonitor.resetContext')}</DialogTitle>
<DialogDescription>{t('bots.sessionMonitor.resetDescription')}</DialogDescription>
<DialogDescription>
{t('bots.sessionMonitor.resetDescription')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" disabled={resetting} onClick={() => setResetTarget(null)}>{t('common.cancel')}</Button>
<Button
variant="outline"
disabled={resetting}
onClick={() => setResetTarget(null)}
>
{t('common.cancel')}
</Button>
<Button disabled={resetting} onClick={resetContext}>
<RotateCcw className={cn('size-4', resetting && 'animate-spin motion-reduce:animate-none')} />
<RotateCcw
className={cn(
'size-4',
resetting && 'animate-spin motion-reduce:animate-none',
)}
/>
{t('bots.sessionMonitor.resetContext')}
</Button>
</DialogFooter>
@@ -318,7 +318,8 @@ function DownloadLinkField({
if (/^https?:\/\//i.test(url)) {
// External templates must not receive backend credentials.
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
if (!response.ok)
throw new Error(`Download failed: ${response.status}`);
blob = await response.blob();
} else {
const response = await backendClient.downloadFile(url);
@@ -327,7 +328,8 @@ function DownloadLinkField({
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename || url.split('/').pop()?.split('?')[0] || 'download';
link.download =
filename || url.split('/').pop()?.split('?')[0] || 'download';
document.body.appendChild(link);
link.click();
link.remove();
@@ -752,33 +754,39 @@ function DynamicFormFieldList({
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogPlatform, setQrDialogPlatform] =
useState<QrLoginPlatform | 'slack'>('feishu');
const [qrDialogPlatform, setQrDialogPlatform] = useState<
QrLoginPlatform | 'slack'
>('feishu');
return (
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
{/* QR code login dialog */}
{qrDialogPlatform === 'slack' ? <SlackSetupDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
botId={systemContext?.bot_uuid as string | undefined}
webhookUrl={(systemContext?.webhook_url as string) || ''}
socketMode={!!form.getValues('socket_mode')}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) setFormValue(key, value);
}}
/> : <QrCodeLoginDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
platform={qrDialogPlatform}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) {
if (value) {
form.setValue(key as keyof FormValues, value as never);
{qrDialogPlatform === 'slack' ? (
<SlackSetupDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
botId={systemContext?.bot_uuid as string | undefined}
webhookUrl={(systemContext?.webhook_url as string) || ''}
socketMode={!!form.getValues('socket_mode')}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials))
setFormValue(key, value);
}}
/>
) : (
<QrCodeLoginDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
platform={qrDialogPlatform}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) {
if (value) {
form.setValue(key as keyof FormValues, value as never);
}
}
}
}}
/>}
}}
/>
)}
{itemConfigList.map((config, index) => {
if (hiddenItemNames?.includes(config.name)) return null;
@@ -964,7 +972,8 @@ function DynamicFormFieldList({
onClick={() => {
if (!isEditing) {
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform | 'slack') || 'feishu',
(config.login_platform as QrLoginPlatform | 'slack') ||
'feishu',
);
setQrDialogOpen(true);
}
@@ -996,7 +1005,8 @@ function DynamicFormFieldList({
onClick={(e) => {
e.stopPropagation();
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform | 'slack') || 'feishu',
(config.login_platform as QrLoginPlatform | 'slack') ||
'feishu',
);
setQrDialogOpen(true);
}}
@@ -656,9 +656,13 @@ function NavItems({
return `/home/skills?id=${encodeURIComponent(item.id)}`;
}
const processorTab = searchParams.get('tab');
if (hasDetailPages && (routePrefix === '/home/agents' || routePrefix === '/home/pipelines') &&
(pathname === '/home/agents' || pathname === '/home/pipelines') &&
(processorTab === 'logs' || processorTab === 'config')) {
if (
hasDetailPages &&
(routePrefix === '/home/agents' ||
routePrefix === '/home/pipelines') &&
(pathname === '/home/agents' || pathname === '/home/pipelines') &&
(processorTab === 'logs' || processorTab === 'config')
) {
return `${routePrefix}?id=${encodeURIComponent(item.id)}&tab=${processorTab}`;
}
return hasDetailPages
@@ -241,7 +241,12 @@ export default function ModelItem({
loaded={availabilityLoaded}
/>
)}
{modelType === 'llm' && <ModelStar uuid={model.uuid} disabled={!canManage || isPopoverDisabled} />}
{modelType === 'llm' && (
<ModelStar
uuid={model.uuid}
disabled={!canManage || isPopoverDisabled}
/>
)}
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
@@ -5,44 +5,79 @@ import { toast } from 'sonner';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
const records = new Map<string, { uuid: string | null; busy: boolean; loaded: boolean }>();
const records = new Map<
string,
{ uuid: string | null; busy: boolean; loaded: boolean }
>();
const listeners = new Set<() => void>();
const pending = new Map<string, Promise<void>>();
const empty = { uuid: null, busy: false, loaded: false };
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
function update(workspace: string, value: typeof empty | { uuid: string | null; busy: boolean; loaded: boolean }) {
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
function update(
workspace: string,
value: typeof empty | { uuid: string | null; busy: boolean; loaded: boolean },
) {
records.set(workspace, value);
listeners.forEach((listener) => listener());
}
export default function ModelStar({ uuid, disabled }: { uuid: string; disabled: boolean }) {
export default function ModelStar({
uuid,
disabled,
}: {
uuid: string;
disabled: boolean;
}) {
const workspace = useCurrentWorkspace()?.workspace.uuid ?? '';
const { t } = useTranslation();
const state = useSyncExternalStore(subscribe, () => records.get(workspace) ?? empty);
const state = useSyncExternalStore(
subscribe,
() => records.get(workspace) ?? empty,
);
useEffect(() => {
if (!workspace || pending.has(workspace)) return;
const request = backendClient.getStarredModel().then((result) => {
update(workspace, { uuid: result.uuid, busy: false, loaded: true });
}).catch(() => { update(workspace, { uuid: null, busy: false, loaded: false }); })
const request = backendClient
.getStarredModel()
.then((result) => {
update(workspace, { uuid: result.uuid, busy: false, loaded: true });
})
.catch(() => {
update(workspace, { uuid: null, busy: false, loaded: false });
})
.finally(() => pending.delete(workspace));
pending.set(workspace, request);
}, [workspace]);
const starred = state.uuid === uuid;
return <Button type="button" variant="ghost" size="icon" className={`size-7 shrink-0 ${starred ? 'text-amber-500 hover:text-amber-600' : 'text-muted-foreground'}`}
disabled={disabled || state.busy || !state.loaded}
aria-pressed={starred} aria-label={t(starred ? 'models.unstarModel' : 'models.starModel')}
title={t(starred ? 'models.unstarModel' : 'models.starModel')}
onPointerDown={(event) => event.stopPropagation()}
onClick={async (event) => {
event.stopPropagation();
const next = starred ? null : uuid;
update(workspace, { ...state, busy: true });
try {
await backendClient.setStarredModel(next);
update(workspace, { uuid: next, busy: false, loaded: true });
} catch {
update(workspace, { ...state, busy: false });
toast.error(t('models.starModelFailed'));
}
}}><Star className={`size-3.5 ${starred ? 'fill-current' : ''}`} /></Button>;
return (
<Button
type="button"
variant="ghost"
size="icon"
className={`size-7 shrink-0 ${starred ? 'text-amber-500 hover:text-amber-600' : 'text-muted-foreground'}`}
disabled={disabled || state.busy || !state.loaded}
aria-pressed={starred}
aria-label={t(starred ? 'models.unstarModel' : 'models.starModel')}
title={t(starred ? 'models.unstarModel' : 'models.starModel')}
onPointerDown={(event) => event.stopPropagation()}
onClick={async (event) => {
event.stopPropagation();
const next = starred ? null : uuid;
update(workspace, { ...state, busy: true });
try {
await backendClient.setStarredModel(next);
update(workspace, { uuid: next, busy: false, loaded: true });
} catch {
update(workspace, { ...state, busy: false });
toast.error(t('models.starModelFailed'));
}
}}
>
<Star className={`size-3.5 ${starred ? 'fill-current' : ''}`} />
</Button>
);
}
@@ -89,7 +89,8 @@ export default function ProcessorDetailWorkbench({
onViewChange,
}: ProcessorDetailWorkbenchProps) {
const [searchParams, setSearchParams] = useSearchParams();
const activeView = searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
const activeView =
searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
const hasDebug = Boolean(debugTitle && debugContent);
const configPanel = (
@@ -148,11 +149,14 @@ export default function ProcessorDetailWorkbench({
value={view ?? activeView}
onValueChange={(value) => {
const view = value as 'workbench' | 'monitoring';
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set('tab', view === 'monitoring' ? 'logs' : 'config');
return next;
}, { preventScrollReset: true });
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set('tab', view === 'monitoring' ? 'logs' : 'config');
return next;
},
{ preventScrollReset: true },
);
onViewChange?.(view);
}}
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
@@ -20,7 +20,11 @@ function formatDuration(ms: number) {
function parsePayload(value?: string): unknown {
if (!value) return null;
try { return JSON.parse(value); } catch { return value; }
try {
return JSON.parse(value);
} catch {
return value;
}
}
function MessageBody({ content }: { content: string }) {
@@ -31,7 +35,11 @@ function MessageBody({ content }: { content: string }) {
);
}
export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: ConversationTurnListProps) {
export function ConversationTurnList({
turns,
expandedTurnId,
onToggleTurn,
}: ConversationTurnListProps) {
const { t } = useTranslation();
const turn = turns.find((item) => item.id === expandedTurnId) ?? turns[0];
if (!turn) return null;
@@ -62,7 +70,10 @@ export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: Co
aria-pressed={turn.id === item.id}
onClick={() => onToggleTurn(item.id)}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) {
if (
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
onToggleTurn(item.id);
}
@@ -78,8 +89,13 @@ export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: Co
</div>
<div className="pointer-events-none max-h-12 overflow-hidden break-words text-sm">
{item.userMessage ? (
<MessageContentRenderer content={item.userMessage.messageContent} maxLines={2} />
) : t('monitoring.messageList.noUserMessage')}
<MessageContentRenderer
content={item.userMessage.messageContent}
maxLines={2}
/>
) : (
t('monitoring.messageList.noUserMessage')
)}
</div>
<div className="mt-2 text-xs tabular-nums text-muted-foreground">
{item.startedAt.toLocaleString()}
@@ -89,18 +105,30 @@ export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: Co
</div>
</aside>
<article key={turn.id} className="min-w-0 space-y-5 lg:overflow-y-auto lg:pr-2">
<article
key={turn.id}
className="min-w-0 space-y-5 lg:overflow-y-auto lg:pr-2"
>
<header className="space-y-3 border-b pb-4">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">{turn.botName}</h3>
{statusBadge(turn.status)}
<span className="text-sm text-muted-foreground">{turn.userName || turn.userId}</span>
<span className="text-sm text-muted-foreground">
{turn.userName || turn.userId}
</span>
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2 text-xs tabular-nums text-muted-foreground">
<span>{turn.startedAt.toLocaleString()}</span>
<span>{t('monitoring.llmCalls.title')} {turn.llmCalls.length}</span>
<span>{t('monitoring.toolCalls.title')} {turn.toolCalls.length}</span>
<span>{t('monitoring.llmCalls.totalTokens')} {turn.totalTokens.toLocaleString()}</span>
<span>
{t('monitoring.llmCalls.title')} {turn.llmCalls.length}
</span>
<span>
{t('monitoring.toolCalls.title')} {turn.toolCalls.length}
</span>
<span>
{t('monitoring.llmCalls.totalTokens')}{' '}
{turn.totalTokens.toLocaleString()}
</span>
</div>
</header>
@@ -109,54 +137,130 @@ export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: Co
<MessageCircle className="size-4 text-muted-foreground" />
{t('monitoring.pipelineTrace.received')}
</h4>
{turn.userMessage ? <MessageBody content={turn.userMessage.messageContent} /> : (
<p className="text-sm text-muted-foreground">{t('monitoring.messageList.noUserMessage')}</p>
{turn.userMessage ? (
<MessageBody content={turn.userMessage.messageContent} />
) : (
<p className="text-sm text-muted-foreground">
{t('monitoring.messageList.noUserMessage')}
</p>
)}
</section>
<section>
<h4 className="mb-3 text-sm font-medium">
{t('monitoring.pipelineTrace.processing')}
<span className="ml-2 font-normal text-muted-foreground">{steps.length}</span>
<span className="ml-2 font-normal text-muted-foreground">
{steps.length}
</span>
</h4>
{steps.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('monitoring.pipelineTrace.noSteps')}</p>
<p className="text-sm text-muted-foreground">
{t('monitoring.pipelineTrace.noSteps')}
</p>
) : (
<ol className="space-y-3">
{steps.map((step, index) => {
const { call } = step;
const parameters = step.kind === 'tool' ? parsePayload(step.call.arguments) : null;
const summary = step.kind === 'tool' && parameters && typeof parameters === 'object' && !Array.isArray(parameters)
? toolSummary(step.call.toolName, parameters as Record<string, unknown>).text : '';
const parameters =
step.kind === 'tool'
? parsePayload(step.call.arguments)
: null;
const summary =
step.kind === 'tool' &&
parameters &&
typeof parameters === 'object' &&
!Array.isArray(parameters)
? toolSummary(
step.call.toolName,
parameters as Record<string, unknown>,
).text
: '';
return (
<li key={`${step.kind}:${call.id}`} className="relative flex gap-3">
{index < steps.length - 1 && <div className="absolute bottom-[-12px] left-4 top-8 w-px bg-border" />}
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs tabular-nums text-muted-foreground">{index + 1}</span>
<li
key={`${step.kind}:${call.id}`}
className="relative flex gap-3"
>
{index < steps.length - 1 && (
<div className="absolute bottom-[-12px] left-4 top-8 w-px bg-border" />
)}
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 flex-1 overflow-hidden rounded-xl border">
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-3 text-sm">
{step.kind === 'model' ? <Cpu className="size-4" /> : step.kind === 'tool' ? <Wrench className="size-4" /> : <AlertCircle className="size-4 text-destructive" />}
{step.kind === 'model' ? (
<Cpu className="size-4" />
) : step.kind === 'tool' ? (
<Wrench className="size-4" />
) : (
<AlertCircle className="size-4 text-destructive" />
)}
<span className="min-w-0 flex-1 break-words font-medium">
{step.kind === 'model' ? step.call.modelName : step.kind === 'tool' ? step.call.toolName : step.call.errorType}
{step.kind === 'model'
? step.call.modelName
: step.kind === 'tool'
? step.call.toolName
: step.call.errorType}
</span>
{statusBadge(step.kind === 'error' ? 'error' : step.call.status)}
{statusBadge(
step.kind === 'error' ? 'error' : step.call.status,
)}
</div>
<div className="space-y-3 p-4">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs tabular-nums text-muted-foreground">
<span>{call.timestamp.toLocaleTimeString()}</span>
{step.kind !== 'error' && <span>{formatDuration(step.call.duration)}</span>}
{step.kind === 'tool' && <span>{step.call.toolSource}</span>}
{step.kind === 'model' && <>
<span>{t('monitoring.llmCalls.inputTokens')} {step.call.tokens.input.toLocaleString()}</span>
<span>{t('monitoring.llmCalls.outputTokens')} {step.call.tokens.output.toLocaleString()}</span>
</>}
{step.kind !== 'error' && (
<span>{formatDuration(step.call.duration)}</span>
)}
{step.kind === 'tool' && (
<span>{step.call.toolSource}</span>
)}
{step.kind === 'model' && (
<>
<span>
{t('monitoring.llmCalls.inputTokens')}{' '}
{step.call.tokens.input.toLocaleString()}
</span>
<span>
{t('monitoring.llmCalls.outputTokens')}{' '}
{step.call.tokens.output.toLocaleString()}
</span>
</>
)}
</div>
{summary && <p className="whitespace-pre-wrap break-words text-sm [overflow-wrap:anywhere]">{summary}</p>}
{call.errorMessage && <p className="whitespace-pre-wrap break-words text-sm text-destructive [overflow-wrap:anywhere]">{call.errorMessage}</p>}
{step.kind === 'tool' && <div>
{step.call.arguments && <ProcessorPayload title={t('monitoring.toolCalls.arguments')} value={parameters} />}
{step.call.result && step.call.result !== 'null' && <ProcessorPayload title={t('monitoring.toolCalls.result')} value={parsePayload(step.call.result)} />}
</div>}
{step.kind === 'error' && step.call.stackTrace && <ProcessorPayload title={t('monitoring.unified.metadata')} value={step.call.stackTrace} />}
{summary && (
<p className="whitespace-pre-wrap break-words text-sm [overflow-wrap:anywhere]">
{summary}
</p>
)}
{call.errorMessage && (
<p className="whitespace-pre-wrap break-words text-sm text-destructive [overflow-wrap:anywhere]">
{call.errorMessage}
</p>
)}
{step.kind === 'tool' && (
<div>
{step.call.arguments && (
<ProcessorPayload
title={t('monitoring.toolCalls.arguments')}
value={parameters}
/>
)}
{step.call.result &&
step.call.result !== 'null' && (
<ProcessorPayload
title={t('monitoring.toolCalls.result')}
value={parsePayload(step.call.result)}
/>
)}
</div>
)}
{step.kind === 'error' && step.call.stackTrace && (
<ProcessorPayload
title={t('monitoring.unified.metadata')}
value={step.call.stackTrace}
/>
)}
</div>
</div>
</li>
@@ -170,23 +274,42 @@ export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: Co
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Send className="size-4 text-muted-foreground" />
{t('monitoring.pipelineTrace.replies')}
<span className="font-normal text-muted-foreground">{turn.assistantMessages.length}</span>
<span className="font-normal text-muted-foreground">
{turn.assistantMessages.length}
</span>
</h4>
{turn.assistantMessages.length ? turn.assistantMessages.map((message) => (
<div key={message.id} className="space-y-2 border-t py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{message.timestamp.toLocaleTimeString()}</span>
{statusBadge(message.status)}
{turn.assistantMessages.length ? (
turn.assistantMessages.map((message) => (
<div
key={message.id}
className="space-y-2 border-t py-3 first:border-t-0 first:pt-0 last:pb-0"
>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{message.timestamp.toLocaleTimeString()}</span>
{statusBadge(message.status)}
</div>
<MessageBody content={message.messageContent} />
</div>
<MessageBody content={message.messageContent} />
</div>
)) : <p className="text-sm text-muted-foreground">{t('monitoring.messageList.noAssistantMessage')}</p>}
))
) : (
<p className="text-sm text-muted-foreground">
{t('monitoring.messageList.noAssistantMessage')}
</p>
)}
</section>
<ProcessorPayload title={t('monitoring.unified.metadata')} value={{
turn_id: turn.id, session_id: turn.sessionId, bot: turn.botName,
pipeline: turn.pipelineName, runner: turn.runnerName, platform: turn.platform,
user_id: turn.userId, messages: turn.messages,
}} />
<ProcessorPayload
title={t('monitoring.unified.metadata')}
value={{
turn_id: turn.id,
session_id: turn.sessionId,
bot: turn.botName,
pipeline: turn.pipelineName,
runner: turn.runnerName,
platform: turn.platform,
user_id: turn.userId,
messages: turn.messages,
}}
/>
</article>
</div>
);
@@ -39,7 +39,8 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
const { t } = useTranslation();
const [exporting, setExporting] = useState<ExportType | null>(null);
const getDateRangeParams = () => resolveMonitoringWindow(filterState.timeRange, filterState.customDateRange);
const getDateRangeParams = () =>
resolveMonitoringWindow(filterState.timeRange, filterState.customDateRange);
const handleExport = async (type: ExportType) => {
setExporting(type);
@@ -174,7 +174,12 @@ export default function TokenMonitoring({
if (error) {
return (
<LoadErrorState title={t('monitoring.loadError')} description={error} onRetry={fetchStats} busy={loading} />
<LoadErrorState
title={t('monitoring.loadError')}
description={error}
onRetry={fetchStats}
busy={loading}
/>
);
}
@@ -41,14 +41,24 @@ const mainSections: ExecutionSection[] = ['inputs', 'outputs', 'deliveries'];
function messageBody(value: unknown): unknown {
if (typeof value === 'string') {
if (/^\s*[\[{]/.test(value)) {
try { return messageBody(JSON.parse(value)); } catch { /* Plain text. */ }
try {
return messageBody(JSON.parse(value));
} catch {
/* Plain text. */
}
}
return value;
}
if (Array.isArray(value)) return value.map(messageBody).filter((item) => item != null && item !== '');
if (Array.isArray(value))
return value.map(messageBody).filter((item) => item != null && item !== '');
if (!value || typeof value !== 'object') return null;
const data = value as Record<string, unknown>;
if (['Image', 'File', 'Voice', 'At', 'AtAll', 'Quote'].includes(String(data.type))) return value;
if (
['Image', 'File', 'Voice', 'At', 'AtAll', 'Quote'].includes(
String(data.type),
)
)
return value;
for (const key of ['text', 'content', 'message_chain', 'message', 'root']) {
if (data[key] != null) return messageBody(data[key]);
}
@@ -225,17 +235,24 @@ export default function ExecutionDetailSheet({
: ''}
</span>
{item.status && (
<Badge
status={item.status}
>
{t(`monitoring.execution.status.${['success', 'delivered'].includes(item.status) ? 'completed' : item.status}`, { defaultValue: item.status })}
<Badge status={item.status}>
{t(
`monitoring.execution.status.${['success', 'delivered'].includes(item.status) ? 'completed' : item.status}`,
{ defaultValue: item.status },
)}
</Badge>
)}
</div>
{content ? (
<>
<div className="text-sm leading-7">
{hasExecutionValue(body) ? <Value value={body} /> : <p className="text-muted-foreground">{t('monitoring.unified.noRecords')}</p>}
{hasExecutionValue(body) ? (
<Value value={body} />
) : (
<p className="text-muted-foreground">
{t('monitoring.unified.noRecords')}
</p>
)}
</div>
<Value value={item.attachments} />
{item.origin === 'generated' && section === 'conversation' && (
@@ -247,7 +264,9 @@ export default function ExecutionDetailSheet({
</p>
) : null}
<details className="border-t pt-2">
<summary className="cursor-pointer text-xs text-muted-foreground">{t('monitoring.unified.payload')}</summary>
<summary className="cursor-pointer text-xs text-muted-foreground">
{t('monitoring.unified.payload')}
</summary>
<div className="mt-3 max-h-80 overflow-auto rounded-md bg-muted/40 p-3">
<Value value={item} />
</div>
@@ -300,7 +319,11 @@ export default function ExecutionDetailSheet({
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
))}
{sectionError === section && (
<LoadErrorState compact title={t('monitoring.execution.detail.loadError')} onRetry={() => loadMore(section)} />
<LoadErrorState
compact
title={t('monitoring.execution.detail.loadError')}
onRetry={() => loadMore(section)}
/>
)}
{page.has_more && (
<Button
@@ -318,13 +341,19 @@ export default function ExecutionDetailSheet({
return primary ? (
<section key={section} className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">{mainSections.indexOf(section) + 1}</span>
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
{mainSections.indexOf(section) + 1}
</span>
{title}
</h3>
{body}
</section>
) : (
<details key={section} open={section === 'errors' ? true : undefined} className={`rounded-lg border p-3 ${section === 'errors' ? 'border-red-500/25 bg-red-500/5' : ''}`}>
<details
key={section}
open={section === 'errors' ? true : undefined}
className={`rounded-lg border p-3 ${section === 'errors' ? 'border-red-500/25 bg-red-500/5' : ''}`}
>
<summary className="cursor-pointer text-sm font-medium">
{title}
</summary>
@@ -369,15 +398,24 @@ export default function ExecutionDetailSheet({
size="icon"
disabled={loading}
aria-label={t('monitoring.refreshData')}
title={error ? t('monitoring.execution.detail.loadError') : t('monitoring.refreshData')}
title={
error
? t('monitoring.execution.detail.loadError')
: t('monitoring.refreshData')
}
onClick={() => setRefresh((value) => value + 1)}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''} ${error ? 'text-destructive' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''} ${error ? 'text-destructive' : ''}`}
/>
</Button>
</div>
{loading && !visible && <Skeleton className="h-36 w-full" />}
{error && !visible && (
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={() => setRefresh((value) => value + 1)} />
<LoadErrorState
title={t('monitoring.execution.detail.loadError')}
onRetry={() => setRefresh((value) => value + 1)}
/>
)}
{record && (
<>
@@ -410,26 +448,48 @@ export default function ExecutionDetailSheet({
</p>
<div className="grid grid-cols-3 divide-x rounded-lg border bg-muted/20">
<div className="space-y-1 p-3">
<p className="text-xs text-muted-foreground">{t('monitoring.execution.detail.duration')}</p>
<p className="text-sm font-semibold tabular-nums">{record.duration_ms != null ? formatRunDuration(record.duration_ms) : '—'}</p>
<p className="text-xs text-muted-foreground">
{t('monitoring.execution.detail.duration')}
</p>
<p className="text-sm font-semibold tabular-nums">
{record.duration_ms != null
? formatRunDuration(record.duration_ms)
: '—'}
</p>
</div>
<div className="space-y-1 p-3">
<p className="text-xs text-muted-foreground">{t('monitoring.llmCalls.totalTokens')}</p>
<p className="text-sm font-semibold tabular-nums">{record.usage?.total_tokens?.toLocaleString() ?? '—'}</p>
<p className="text-xs text-muted-foreground">
{t('monitoring.llmCalls.totalTokens')}
</p>
<p className="text-sm font-semibold tabular-nums">
{record.usage?.total_tokens?.toLocaleString() ?? '—'}
</p>
</div>
<div className="space-y-1 p-3">
<p className="text-xs text-muted-foreground">{t('monitoring.unified.deliveries')}</p>
<p className="text-sm font-semibold tabular-nums">{visible?.pages.deliveries?.items.length ?? 0}{visible?.pages.deliveries?.has_more ? '+' : ''}</p>
<p className="text-xs text-muted-foreground">
{t('monitoring.unified.deliveries')}
</p>
<p className="text-sm font-semibold tabular-nums">
{visible?.pages.deliveries?.items.length ?? 0}
{visible?.pages.deliveries?.has_more ? '+' : ''}
</p>
</div>
</div>
{record.status_reason && !['stop', 'completed', 'success'].includes(record.status_reason) && (
<p className="rounded-md bg-muted p-3 text-sm">
{record.status_reason.split('; ').map((reason) =>
reason === 'failure_reason_not_recorded'
? t('common.failureReasonNotRecorded') : reason,
).join('; ')}
</p>
)}
{record.status_reason &&
!['stop', 'completed', 'success'].includes(
record.status_reason,
) && (
<p className="rounded-md bg-muted p-3 text-sm">
{record.status_reason
.split('; ')
.map((reason) =>
reason === 'failure_reason_not_recorded'
? t('common.failureReasonNotRecorded')
: reason,
)
.join('; ')}
</p>
)}
{mainSections.map((section) => renderSection(section, true))}
{secondarySections.map((section) => renderSection(section))}
<details className="rounded-lg border p-3">
@@ -66,17 +66,22 @@ export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
export function ExecutionKindBadge({ row }: { row: ExecutionRow }) {
const { t } = useTranslation();
const Icon = row.target_kind === 'pipeline' ? Workflow
: row.target_kind === 'agent' ? Bot
: ['processor', 'event_processor'].includes(row.target_kind) ? Puzzle
: CircleDashed;
const color = row.target_kind === 'pipeline'
? 'border-blue-200/70 bg-blue-50/70 text-blue-700 dark:border-blue-800/50 dark:bg-blue-950/30 dark:text-blue-300'
: row.target_kind === 'agent'
? 'border-emerald-200/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-300'
: ['processor', 'event_processor'].includes(row.target_kind)
? 'border-amber-200/70 bg-amber-50/70 text-amber-700 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-300'
: 'border-border bg-muted/40 text-muted-foreground';
const Icon =
row.target_kind === 'pipeline'
? Workflow
: row.target_kind === 'agent'
? Bot
: ['processor', 'event_processor'].includes(row.target_kind)
? Puzzle
: CircleDashed;
const color =
row.target_kind === 'pipeline'
? 'border-blue-200/70 bg-blue-50/70 text-blue-700 dark:border-blue-800/50 dark:bg-blue-950/30 dark:text-blue-300'
: row.target_kind === 'agent'
? 'border-emerald-200/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-300'
: ['processor', 'event_processor'].includes(row.target_kind)
? 'border-amber-200/70 bg-amber-50/70 text-amber-700 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-300'
: 'border-border bg-muted/40 text-muted-foreground';
return (
<Badge variant="outline" className={`gap-1 whitespace-nowrap ${color}`}>
<Icon className="size-3 shrink-0" aria-hidden="true" />
@@ -7,10 +7,20 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ChevronDown } from 'lucide-react';
import { DateRange } from '../../types/monitoring';
@@ -162,22 +172,32 @@ export default function MonitoringFilters({
const [dateOpen, setDateOpen] = useState(false);
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const localDate = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const localDate = (date: Date) =>
new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
const openCustom = () => {
setFromDate(localDate(customDateRange?.from ?? new Date(Date.now() - 86400000)));
setFromDate(
localDate(customDateRange?.from ?? new Date(Date.now() - 86400000)),
);
setToDate(localDate(customDateRange?.to ?? new Date()));
setDateOpen(true);
};
const from = new Date(fromDate);
const to = new Date(toDate);
const validDates = Number.isFinite(from.getTime()) && Number.isFinite(to.getTime()) && from < to && to.getTime() - from.getTime() <= 365 * 86400000 && to.getTime() <= Date.now();
const validDates =
Number.isFinite(from.getTime()) &&
Number.isFinite(to.getTime()) &&
from < to &&
to.getTime() - from.getTime() <= 365 * 86400000 &&
to.getTime() <= Date.now();
const handleTimeRangeChange = (value: string) => {
if (value === 'custom') customPending.current = true;
else onTimeRangeChange(value as TimeRangeOption);
};
const toggleProcessors = (ids: string[], checked: boolean) => {
const next = new Set(selectedPipelines);
ids.forEach((id) => checked ? next.add(id) : next.delete(id));
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
onPipelinesChange([...next]);
};
return (
@@ -237,28 +257,82 @@ export default function MonitoringFilters({
</Label>
<Popover>
<PopoverTrigger asChild>
<Button id="monitoring-filter-processor" variant="outline" disabled={loadingProcessors} className="h-9 w-full justify-between font-normal">
<span className="truncate">{selectedPipelines.length ? t('agents.apiToolsSelected', { count: selectedPipelines.length }) : t('monitoring.filters.allProcessors')}</span>
<Button
id="monitoring-filter-processor"
variant="outline"
disabled={loadingProcessors}
className="h-9 w-full justify-between font-normal"
>
<span className="truncate">
{selectedPipelines.length
? t('agents.apiToolsSelected', {
count: selectedPipelines.length,
})
: t('monitoring.filters.allProcessors')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="max-h-96 w-72 overflow-y-auto p-2">
<Button variant="ghost" size="sm" className="mb-1 w-full justify-start" onClick={() => onPipelinesChange([])}>{t('monitoring.filters.allProcessors')}</Button>
<PopoverContent
align="start"
className="max-h-96 w-72 overflow-y-auto p-2"
>
<Button
variant="ghost"
size="sm"
className="mb-1 w-full justify-start"
onClick={() => onPipelinesChange([])}
>
{t('monitoring.filters.allProcessors')}
</Button>
{['pipeline', 'agent', 'event_processor'].map((kind) => {
const group = processors.filter((item) => item.kind === kind);
if (!group.length) return null;
const selected = group.filter((item) => selectedPipelines.includes(item.uuid)).length;
return <div key={kind} className="border-t py-2">
<label className="mb-1 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted">
<Checkbox checked={selected === group.length ? true : selected ? 'indeterminate' : false} onCheckedChange={(checked) => toggleProcessors(group.map((item) => item.uuid), checked === true)} />
<span className="flex-1">{t(`monitoring.execution.kind.${kind === 'event_processor' ? 'processor' : kind}`)}</span>
<span>{t('agents.apiToolsSelectAll')}</span>
</label>
{group.map((item) => <label key={item.uuid} className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm hover:bg-muted">
<Checkbox checked={selectedPipelines.includes(item.uuid)} onCheckedChange={(checked) => toggleProcessors([item.uuid], checked === true)} />
<span aria-hidden="true">{item.emoji}</span><span className="truncate">{item.name}</span>
</label>)}
</div>;
const selected = group.filter((item) =>
selectedPipelines.includes(item.uuid),
).length;
return (
<div key={kind} className="border-t py-2">
<label className="mb-1 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted">
<Checkbox
checked={
selected === group.length
? true
: selected
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
toggleProcessors(
group.map((item) => item.uuid),
checked === true,
)
}
/>
<span className="flex-1">
{t(
`monitoring.execution.kind.${kind === 'event_processor' ? 'processor' : kind}`,
)}
</span>
<span>{t('agents.apiToolsSelectAll')}</span>
</label>
{group.map((item) => (
<label
key={item.uuid}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm hover:bg-muted"
>
<Checkbox
checked={selectedPipelines.includes(item.uuid)}
onCheckedChange={(checked) =>
toggleProcessors([item.uuid], checked === true)
}
/>
<span aria-hidden="true">{item.emoji}</span>
<span className="truncate">{item.name}</span>
</label>
))}
</div>
);
})}
</PopoverContent>
</Popover>
@@ -349,21 +423,44 @@ export default function MonitoringFilters({
>
{t('monitoring.filters.timeRange')}
</Label>
<Select value={timeRange === 'custom' ? '' : timeRange} onValueChange={handleTimeRangeChange}>
<Select
value={timeRange === 'custom' ? '' : timeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger
id="monitoring-filter-time"
className="h-9 w-full min-w-0 gap-2 [&>svg]:shrink-0"
title={timeRange === 'custom' && customDateRange ? `${customDateRange.from.toLocaleString()} – ${customDateRange.to.toLocaleString()}` : undefined}
title={
timeRange === 'custom' && customDateRange
? `${customDateRange.from.toLocaleString()} – ${customDateRange.to.toLocaleString()}`
: undefined
}
>
{timeRange === 'custom' && customDateRange ? (
<span className="min-w-0 truncate text-xs tabular-nums">
{customDateRange.from.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' })}
{customDateRange.from.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
})}
{' – '}
{customDateRange.to.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' })}
{customDateRange.to.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
})}
</span>
) : <SelectValue />}
) : (
<SelectValue />
)}
</SelectTrigger>
<SelectContent onCloseAutoFocus={(event) => { if (customPending.current) { event.preventDefault(); customPending.current = false; openCustom(); } }}>
<SelectContent
onCloseAutoFocus={(event) => {
if (customPending.current) {
event.preventDefault();
customPending.current = false;
openCustom();
}
}}
>
<SelectItem value="lastHour">
{t('monitoring.filters.lastHour')}
</SelectItem>
@@ -376,17 +473,64 @@ export default function MonitoringFilters({
<SelectItem value="last7Days">
{t('monitoring.filters.last7Days')}
</SelectItem>
{[30, 90, 180, 365].map((days) => <SelectItem key={days} value={`last${days}Days`}>{t('monitoring.rangeDays', { days })}</SelectItem>)}
{onCustomDateRangeChange && <SelectItem value="custom">{t('monitoring.customRange')}</SelectItem>} </SelectContent>
</Select> {onCustomDateRangeChange && <>
<Dialog open={dateOpen} onOpenChange={setDateOpen}><DialogContent className="sm:max-w-md space-y-3">
<DialogHeader><DialogTitle>{t('monitoring.customRange')}</DialogTitle><DialogDescription>{t('monitoring.rangeHint')}</DialogDescription></DialogHeader>
<label className="block space-y-1 text-xs">{t('monitoring.rangeStart')}<Input type="datetime-local" value={fromDate} max={toDate} onChange={(event) => setFromDate(event.target.value)} /></label>
<label className="block space-y-1 text-xs">{t('monitoring.rangeEnd')}<Input type="datetime-local" value={toDate} min={fromDate} max={localDate(new Date())} onChange={(event) => setToDate(event.target.value)} /></label>
{[30, 90, 180, 365].map((days) => (
<SelectItem key={days} value={`last${days}Days`}>
{t('monitoring.rangeDays', { days })}
</SelectItem>
))}
{onCustomDateRangeChange && (
<SelectItem value="custom">
{t('monitoring.customRange')}
</SelectItem>
)}{' '}
</SelectContent>
</Select>{' '}
{onCustomDateRangeChange && (
<>
<Dialog open={dateOpen} onOpenChange={setDateOpen}>
<DialogContent className="sm:max-w-md space-y-3">
<DialogHeader>
<DialogTitle>{t('monitoring.customRange')}</DialogTitle>
<DialogDescription>
{t('monitoring.rangeHint')}
</DialogDescription>
</DialogHeader>
<label className="block space-y-1 text-xs">
{t('monitoring.rangeStart')}
<Input
type="datetime-local"
value={fromDate}
max={toDate}
onChange={(event) => setFromDate(event.target.value)}
/>
</label>
<label className="block space-y-1 text-xs">
{t('monitoring.rangeEnd')}
<Input
type="datetime-local"
value={toDate}
min={fromDate}
max={localDate(new Date())}
onChange={(event) => setToDate(event.target.value)}
/>
</label>
<Button size="sm" className="w-full" disabled={!validDates} onClick={() => { onCustomDateRangeChange({ from, to }); onTimeRangeChange('custom'); setDateOpen(false); }}>{t('common.confirm')}</Button>
</DialogContent></Dialog>
</>}
<Button
size="sm"
className="w-full"
disabled={!validDates}
onClick={() => {
onCustomDateRangeChange({ from, to });
onTimeRangeChange('custom');
setDateOpen(false);
}}
>
{t('common.confirm')}
</Button>
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
);
@@ -11,7 +11,11 @@ import {
ResponsiveContainer,
} from 'recharts';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { MonitoringData } from '../../types/monitoring';
@@ -23,24 +27,50 @@ interface TrafficChartProps {
export default function TrafficChart(props: TrafficChartProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('langbot-traffic-chart-open') !== 'false'; }
catch { return true; }
try {
return localStorage.getItem('langbot-traffic-chart-open') !== 'false';
} catch {
return true;
}
});
return (
<Collapsible open={open} onOpenChange={(value) => {
setOpen(value);
try { localStorage.setItem('langbot-traffic-chart-open', String(value)); } catch { /* Storage may be unavailable. */ }
}} asChild>
<Collapsible
open={open}
onOpenChange={(value) => {
setOpen(value);
try {
localStorage.setItem('langbot-traffic-chart-open', String(value));
} catch {
/* Storage may be unavailable. */
}
}}
asChild
>
<Card className="gap-0 overflow-hidden py-0">
<CollapsibleTrigger asChild>
<button type="button" className="group flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400"><BarChart3 className="size-4" /></span>
<span className="flex-1 text-sm font-semibold">{t('monitoring.trafficChart.title')}</span>
<span className="hidden items-center gap-4 text-xs text-muted-foreground sm:flex">
<span className="flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-blue-500" />{t('monitoring.trafficChart.messages')}</span>
<span className="flex items-center gap-1.5"><span className="size-1.5 rounded-full bg-violet-500" />{t('monitoring.trafficChart.llmCalls')}</span>
<button
type="button"
className="group flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
<BarChart3 className="size-4" />
</span>
<ChevronDown className={`ml-2 size-4 text-muted-foreground transition-transform motion-reduce:transition-none ${open ? 'rotate-180' : ''}`} />
<span className="flex-1 text-sm font-semibold">
{t('monitoring.trafficChart.title')}
</span>
<span className="hidden items-center gap-4 text-xs text-muted-foreground sm:flex">
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-blue-500" />
{t('monitoring.trafficChart.messages')}
</span>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-violet-500" />
{t('monitoring.trafficChart.llmCalls')}
</span>
</span>
<ChevronDown
className={`ml-2 size-4 text-muted-foreground transition-transform motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -31,7 +31,10 @@ export function useExecutions(params: {
const [nonce, setNonce] = useState(0);
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
const requestIdRef = useRef(0);
const scope = JSON.stringify([workspaceUuid, { ...params, refreshKey: undefined }]);
const scope = JSON.stringify([
workspaceUuid,
{ ...params, refreshKey: undefined },
]);
const [requestScope, setRequestScope] = useState<string | null>(null);
// Memoize filter parameters to prevent unnecessary re-renders
@@ -90,7 +93,7 @@ export function useExecutions(params: {
} catch (err) {
if (!isCurrent()) return;
setRequestScope(scope);
setResult((current) => requestScope === scope ? current : null);
setResult((current) => (requestScope === scope ? current : null));
setError(err as Error);
console.error('Failed to fetch executions:', err);
} finally {
@@ -391,7 +391,7 @@ export function useMonitoringData(filterState: FilterState) {
} catch (err) {
if (!isCurrent()) return;
setRequestScope(scope);
setData((current) => requestScope === scope ? current : null);
setData((current) => (requestScope === scope ? current : null));
setError(err as Error);
console.error('Failed to fetch monitoring data:', err);
} finally {
@@ -55,7 +55,8 @@ function restore(key: string): FilterState {
if (
Number.isFinite(from.getTime()) &&
Number.isFinite(to.getTime()) &&
from < to && to.getTime() - from.getTime() <= 365 * 86400000
from < to &&
to.getTime() - from.getTime() <= 365 * 86400000
)
state.customDateRange = { from, to };
}
+133 -67
View File
@@ -9,7 +9,12 @@ import React, {
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import LoadErrorState from '@/components/LoadErrorState';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -62,7 +67,12 @@ function MonitoringPageContent() {
} = useMonitoringFilters();
const executionMode = filterState.mode ?? 'all';
const executionStatus = filterState.statusGroup ?? 'all';
const { data, loading: monitoringRefreshing, error, refetch } = useMonitoringData({
const {
data,
loading: monitoringRefreshing,
error,
refetch,
} = useMonitoringData({
...filterState,
mode: executionMode,
statusGroup: executionStatus,
@@ -140,7 +150,8 @@ function MonitoringPageContent() {
const loading = monitoringRefreshing && !data;
const executionLoading = executionRefreshing && !executionResult;
const feedbackLoading = feedbackRefreshing && !feedbackStats;
const refreshing = monitoringRefreshing || executionRefreshing || feedbackRefreshing;
const refreshing =
monitoringRefreshing || executionRefreshing || feedbackRefreshing;
const countdown = useRef(0);
const intervalRef = useRef(0);
const [remainingSeconds, setRemainingSeconds] = useState(0);
@@ -155,9 +166,13 @@ function MonitoringPageContent() {
const [refreshInterval, setRefreshInterval] = useState(() => {
try {
const saved = Number(localStorage.getItem('langbot-dashboard-refresh-interval'));
const saved = Number(
localStorage.getItem('langbot-dashboard-refresh-interval'),
);
return [5, 15, 30, 60, 300].includes(saved) ? saved : 0;
} catch { return 0; }
} catch {
return 0;
}
});
const refreshState = useRef({ handleRefresh, busy: false });
useEffect(() => {
@@ -169,7 +184,10 @@ function MonitoringPageContent() {
setRemainingSeconds(refreshInterval);
if (!refreshInterval || error) return;
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible' && !refreshState.current.busy) {
if (
document.visibilityState === 'visible' &&
!refreshState.current.busy
) {
countdown.current = Math.max(0, countdown.current - 1);
setRemainingSeconds(countdown.current);
if (countdown.current === 0) {
@@ -204,71 +222,115 @@ function MonitoringPageContent() {
return (
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
{/* Filters and Refresh Button - Sticky */}
{!error && <div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<Card className="flex flex-col gap-4 p-4 xl:flex-row xl:items-end">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
timeRange={filterState.timeRange}
onBotsChange={setSelectedBots}
onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange}
customDateRange={filterState.customDateRange}
onCustomDateRangeChange={setCustomDateRange}
mode={executionMode}
onModeChange={setExecutionMode}
statusGroup={executionStatus}
onStatusGroupChange={setExecutionStatus}
/>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-3 xl:border-l xl:border-t-0 xl:pl-4 xl:pt-0 [&_button]:h-9">
<Button variant="outline" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('monitoring.filters.reset')}
</Button>
{canExport && <ExportDropdown filterState={filterState} />}
<div className="relative flex items-center overflow-hidden rounded-lg border bg-background shadow-xs">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="gap-2 rounded-none px-3"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
{t('monitoring.refreshData')}
{refreshInterval > 0 && <span className="min-w-8 text-right text-xs tabular-nums text-muted-foreground">{remainingSeconds}s</span>}
</Button>
<Select value={String(refreshInterval)} onValueChange={(value) => {
setRefreshInterval(Number(value));
try { localStorage.setItem('langbot-dashboard-refresh-interval', value); } catch { /* Storage may be unavailable. */ }
}}>
<SelectTrigger className="w-11 justify-center rounded-l-none border-0 border-l px-2 shadow-none [&>svg:last-child]:hidden" aria-label={t('monitoring.autoRefreshLabel')} title={refreshInterval ? t('monitoring.autoRefreshEvery', { seconds: refreshInterval }) : t('monitoring.autoRefreshOff')}>
<span aria-hidden="true"><ChevronDown className="size-3.5" /></span>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('monitoring.autoRefreshOff')}</SelectItem>
{[5, 15, 30, 60, 300].map((seconds) => (
<SelectItem key={seconds} value={String(seconds)}>
{t('monitoring.autoRefreshEvery', { seconds })}
</SelectItem>
))}
</SelectContent>
</Select>
{refreshInterval > 0 && <div className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-blue-500/10" aria-hidden="true">
<div className="h-full origin-left bg-blue-500/70 transition-transform duration-700 ease-linear motion-reduce:transition-none" style={{ transform: `scaleX(${remainingSeconds / refreshInterval})` }} />
</div>}
{!error && (
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<Card className="flex flex-col gap-4 p-4 xl:flex-row xl:items-end">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
timeRange={filterState.timeRange}
onBotsChange={setSelectedBots}
onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange}
customDateRange={filterState.customDateRange}
onCustomDateRangeChange={setCustomDateRange}
mode={executionMode}
onModeChange={setExecutionMode}
statusGroup={executionStatus}
onStatusGroupChange={setExecutionStatus}
/>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-3 xl:border-l xl:border-t-0 xl:pl-4 xl:pt-0 [&_button]:h-9">
<Button variant="outline" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('monitoring.filters.reset')}
</Button>
{canExport && <ExportDropdown filterState={filterState} />}
<div className="relative flex items-center overflow-hidden rounded-lg border bg-background shadow-xs">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="gap-2 rounded-none px-3"
>
<RefreshCw
className={cn('size-3.5', refreshing && 'animate-spin')}
/>
{t('monitoring.refreshData')}
{refreshInterval > 0 && (
<span className="min-w-8 text-right text-xs tabular-nums text-muted-foreground">
{remainingSeconds}s
</span>
)}
</Button>
<Select
value={String(refreshInterval)}
onValueChange={(value) => {
setRefreshInterval(Number(value));
try {
localStorage.setItem(
'langbot-dashboard-refresh-interval',
value,
);
} catch {
/* Storage may be unavailable. */
}
}}
>
<SelectTrigger
className="w-11 justify-center rounded-l-none border-0 border-l px-2 shadow-none [&>svg:last-child]:hidden"
aria-label={t('monitoring.autoRefreshLabel')}
title={
refreshInterval
? t('monitoring.autoRefreshEvery', {
seconds: refreshInterval,
})
: t('monitoring.autoRefreshOff')
}
>
<span aria-hidden="true">
<ChevronDown className="size-3.5" />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
{t('monitoring.autoRefreshOff')}
</SelectItem>
{[5, 15, 30, 60, 300].map((seconds) => (
<SelectItem key={seconds} value={String(seconds)}>
{t('monitoring.autoRefreshEvery', { seconds })}
</SelectItem>
))}
</SelectContent>
</Select>
{refreshInterval > 0 && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-blue-500/10"
aria-hidden="true"
>
<div
className="h-full origin-left bg-blue-500/70 transition-transform duration-700 ease-linear motion-reduce:transition-none"
style={{
transform: `scaleX(${remainingSeconds / refreshInterval})`,
}}
/>
</div>
)}
</div>
</div>
</div>
</Card>
</Card>
</div>
</div>
</div>
}
)}
{/* Content Area */}
{error ? (
<LoadErrorState title={t('monitoring.loadError')} onRetry={handleRefresh} busy={refreshing} />
<LoadErrorState
title={t('monitoring.loadError')}
onRetry={handleRefresh}
busy={refreshing}
/>
) : (
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section: the execution metrics and the runtime status share
@@ -360,7 +422,11 @@ function MonitoringPageContent() {
{executionError ? (
<div className="px-3 pt-4 sm:px-6">
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={handleRefresh} busy={refreshing} />
<LoadErrorState
title={t('monitoring.execution.detail.loadError')}
onRetry={handleRefresh}
busy={refreshing}
/>
</div>
) : (
<>
@@ -26,7 +26,9 @@ export function getPresetDateRange(option: TimeRangeOption): DateRange | null {
case 'last180Days':
case 'last365Days':
case 'last30Days':
from.setTime(now.getTime() - Number(option.match(/\d+/)?.[0] ?? 30) * 86400000);
from.setTime(
now.getTime() - Number(option.match(/\d+/)?.[0] ?? 30) * 86400000,
);
break;
default:
return null;
@@ -150,7 +152,9 @@ export function resolveMonitoringWindow(
case 'last180Days':
case 'last365Days':
case 'last30Days':
startTime = new Date(now.getTime() - Number(timeRange.match(/\d+/)?.[0] ?? 30) * 86400000);
startTime = new Date(
now.getTime() - Number(timeRange.match(/\d+/)?.[0] ?? 30) * 86400000,
);
break;
case 'custom':
if (customDateRange) {
@@ -52,7 +52,8 @@ export default function PipelineDetailContent({
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [searchParams] = useSearchParams();
const activeView = searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
const activeView =
searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
+5 -1
View File
@@ -203,7 +203,11 @@ function PluginPageIframe({
return (
<div className="flex flex-col h-full w-full">
{assetError ? (
<LoadErrorState title={t('plugins.loadFailed')} className="flex-1" onRetry={() => window.location.reload()} />
<LoadErrorState
title={t('plugins.loadFailed')}
className="flex-1"
onRetry={() => window.location.reload()}
/>
) : loading || !assetUrl ? (
<EntityLoadState />
) : null}
+6 -1
View File
@@ -168,7 +168,12 @@ function PluginListView() {
}
if (statusError) {
return <LoadErrorState title={t('plugins.failedToGetStatus')} onRetry={() => setStatusRevision((value) => value + 1)} />;
return (
<LoadErrorState
title={t('plugins.failedToGetStatus')}
onRetry={() => setStatusRevision((value) => value + 1)}
/>
);
}
if (!pluginSystemStatus?.is_enable) {
+21 -6
View File
@@ -822,9 +822,13 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
}
public resetSessionContext(botId: string, sessionId: string): Promise<{ reset: boolean }> {
public resetSessionContext(
botId: string,
sessionId: string,
): Promise<{ reset: boolean }> {
return this.post('/api/v1/monitoring/sessions/reset-context', {
bot_id: botId, session_id: sessionId,
bot_id: botId,
session_id: sessionId,
});
}
@@ -2210,10 +2214,19 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
}
public async streamInflight(onFrame: (frame: InflightFrame) => void, signal: AbortSignal): Promise<void> {
public async streamInflight(
onFrame: (frame: InflightFrame) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.instance.get<ReadableStream<Uint8Array>>(
'/api/v1/monitoring/in-flight/stream',
{ adapter: 'fetch', responseType: 'stream', timeout: 0, signal, headers: { Accept: 'text/event-stream' } },
{
adapter: 'fetch',
responseType: 'stream',
timeout: 0,
signal,
headers: { Accept: 'text/event-stream' },
},
);
const reader = response.data.getReader();
const decoder = new TextDecoder();
@@ -2227,9 +2240,11 @@ export class BackendClient extends BaseHttpClient {
while ((end = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
if (frame.startsWith('data: ')) onFrame(JSON.parse(frame.slice(6)) as InflightFrame);
if (frame.startsWith('data: '))
onFrame(JSON.parse(frame.slice(6)) as InflightFrame);
}
if (buffer.length > 2_000_000) throw new Error('Monitoring frame exceeds limit');
if (buffer.length > 2_000_000)
throw new Error('Monitoring frame exceeds limit');
}
} finally {
await reader.cancel().catch(() => undefined);
+2 -1
View File
@@ -1,4 +1,5 @@
export const MODEL_CONFIGURATION_CHANGED = 'langbot:model-configuration-changed';
export const MODEL_CONFIGURATION_CHANGED =
'langbot:model-configuration-changed';
export async function notifyAfterModelConfigurationChange<T>(
request: Promise<T>,