mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 09:25:21 +08:00
fix(ci): align SDK dependency and validate acceptance migrations
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user