mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(acceptance): refine processor setup and dashboard monitoring
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useCurrentWorkspace, userInfo } from '@/app/infra/http';
|
||||
import { Agent, AgentPlatformTool } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
@@ -33,6 +33,9 @@ import AgentFormComponent, {
|
||||
RunnerStatus,
|
||||
} from './components/AgentFormComponent';
|
||||
|
||||
// Keep the last checked display state across navigation within this app session.
|
||||
const runnerStatusCache = new Map<string, RunnerStatus>();
|
||||
|
||||
export default function AgentDetailContent({
|
||||
id,
|
||||
pipelineRevision,
|
||||
@@ -42,7 +45,7 @@ export default function AgentDetailContent({
|
||||
}) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
@@ -59,7 +62,32 @@ export default function AgentDetailContent({
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [runnerStatus, setRunnerStatus] = useState<RunnerStatus | null>(null);
|
||||
const runnerStatusKey = JSON.stringify([
|
||||
userInfo?.account_uuid, currentWorkspace?.workspace.uuid, id, i18n.language,
|
||||
]);
|
||||
const [runnerStatusState, setRunnerStatusState] = useState<{
|
||||
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 };
|
||||
}
|
||||
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]);
|
||||
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
|
||||
'message.received',
|
||||
]);
|
||||
@@ -79,10 +107,6 @@ export default function AgentDetailContent({
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunnerStatus(null);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
let cancelled = false;
|
||||
@@ -258,7 +282,7 @@ export default function AgentDetailContent({
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
onRunnerStatusChange={updateRunnerStatus}
|
||||
onSupportedEventPatternsChange={setSupportedEventPatterns}
|
||||
onPlatformToolsChange={setPlatformTools}
|
||||
guideEnabled={canManage}
|
||||
|
||||
@@ -74,7 +74,10 @@ export default function PluginProcessorDetailContent({
|
||||
componentRef,
|
||||
parameters: initialParameters,
|
||||
});
|
||||
const dirty =
|
||||
const [needsInitialSave, setNeedsInitialSave] = useState(
|
||||
Boolean(agent.component_ref) && !agent.supported_event_patterns?.length,
|
||||
);
|
||||
const dirty = needsInitialSave ||
|
||||
componentRef !== savedConfig.componentRef ||
|
||||
!isEqual(parameters, savedConfig.parameters);
|
||||
const [runs, setRuns] = useState<ProcessorRun[]>([]);
|
||||
@@ -341,6 +344,7 @@ export default function PluginProcessorDetailContent({
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
onSaved();
|
||||
setSavedConfig({ componentRef, parameters });
|
||||
setNeedsInitialSave(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -206,6 +206,21 @@ export default function AgentApiToolPicker({
|
||||
? platformCatalogAvailable
|
||||
: hostCatalogAvailable;
|
||||
|
||||
const isSelected = (tool: ToolEntry) =>
|
||||
(tool.kind === 'platform' ? selectedPlatform : selectedHost).has(tool.name);
|
||||
|
||||
const setGroup = (tools: ToolEntry[], checked: boolean) => {
|
||||
const platform = new Set(platformValue);
|
||||
const host = new Set(hostValue);
|
||||
for (const tool of tools) {
|
||||
const selection = tool.kind === 'platform' ? platform : host;
|
||||
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 === 'host')) onHostChange([...host]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -275,8 +290,16 @@ export default function AgentApiToolPicker({
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
{groupedEntries.map(([group, groupTools], groupIndex) => (
|
||||
<section key={group} className={cn(groupIndex > 0 && 'border-t')}>
|
||||
<div className="bg-muted/30 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{groupLabel(group)}
|
||||
<div className="flex items-center justify-between gap-3 bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<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)}
|
||||
aria-label={`${groupLabel(group)} · ${t('agents.apiToolsSelectAll')}`}
|
||||
/>
|
||||
{t('agents.apiToolsSelectAll')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{groupTools.map((tool) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
|
||||
import RunnerSelect from './RunnerSelect';
|
||||
import type { GetAgentMetadataResponseData } from '@/app/infra/entities/api';
|
||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
export default function AgentCreateContent({
|
||||
onCreated,
|
||||
@@ -34,10 +38,24 @@ export default function AgentCreateContent({
|
||||
onCreated: (agentId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = useState<AgentKind>('agent');
|
||||
const [kind, setKind] = useState<AgentKind>('pipeline');
|
||||
const [metadata, setMetadata] = useState<GetAgentMetadataResponseData>();
|
||||
const [runner, setRunner] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
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); }
|
||||
}, [t]);
|
||||
useEffect(() => { void loadMetadata(); }, [loadMetadata]);
|
||||
const runnerOptions = kind === 'event_processor'
|
||||
? (metadata?.event_processors ?? []).map((item) => ({ name: item.id, label: { en_US: 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') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -45,11 +63,21 @@ export default function AgentCreateContent({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
emoji: '⚙️',
|
||||
},
|
||||
});
|
||||
|
||||
function selectRunner(value: string, name?: string) {
|
||||
setRunner(value);
|
||||
if (!form.getValues('name').trim()) {
|
||||
const option = runnerOptions.find((item) => item.name === value);
|
||||
const displayName = name || (option ? extractI18nObject(option.label) : '');
|
||||
if (displayName) {
|
||||
form.setValue('name', displayName, { shouldDirty: true, shouldValidate: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKindChange(nextKind: AgentKind) {
|
||||
const previousDefaultEmoji =
|
||||
kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '🧩' : '🤖';
|
||||
@@ -59,6 +87,9 @@ export default function AgentCreateContent({
|
||||
: nextKind === 'event_processor'
|
||||
? '🧩'
|
||||
: '🤖';
|
||||
if ((kind === 'event_processor') !== (nextKind === 'event_processor')) {
|
||||
setRunner('');
|
||||
}
|
||||
setKind(nextKind);
|
||||
const currentEmoji = form.getValues('emoji');
|
||||
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
|
||||
@@ -67,28 +98,34 @@ export default function AgentCreateContent({
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
return httpClient
|
||||
.createAgent({
|
||||
kind,
|
||||
name: values.name,
|
||||
description: values.description ?? '',
|
||||
emoji:
|
||||
values.emoji ||
|
||||
(kind === 'pipeline'
|
||||
? '⚙️'
|
||||
: kind === 'event_processor'
|
||||
? '🧩'
|
||||
: '🤖'),
|
||||
})
|
||||
.then((resp) => {
|
||||
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 } };
|
||||
try {
|
||||
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;
|
||||
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 } },
|
||||
});
|
||||
toast.success(t('agents.createSuccess'));
|
||||
onCreated(resp.uuid);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.createError') + err.msg);
|
||||
});
|
||||
onCreated(uuid);
|
||||
} else {
|
||||
const response = await httpClient.createAgent({
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
kind: 'pipeline' as const,
|
||||
@@ -118,7 +155,7 @@ export default function AgentCreateContent({
|
||||
<Button
|
||||
type="submit"
|
||||
form="agent-create-form"
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={form.formState.isSubmitting || loading || !runner}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
@@ -157,8 +194,8 @@ export default function AgentCreateContent({
|
||||
{typeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div key={option.kind} className="relative w-full">
|
||||
<ToggleGroupItem
|
||||
key={option.kind}
|
||||
value={option.kind}
|
||||
data-processor-kind={option.kind}
|
||||
aria-label={`${option.title} ${option.description}`}
|
||||
@@ -176,6 +213,7 @@ export default function AgentCreateContent({
|
||||
</span>
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
@@ -185,7 +223,7 @@ export default function AgentCreateContent({
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
{t('agents.createBasicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -195,6 +233,27 @@ export default function AgentCreateContent({
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{selectorLabel}</p>
|
||||
<RunnerSelect
|
||||
key={kind === 'event_processor' ? 'event' : 'agent'}
|
||||
usage={kind === 'event_processor' ? 'event' : 'agent'}
|
||||
options={runnerOptions}
|
||||
label={selectorLabel}
|
||||
value={runner}
|
||||
onValueChange={selectRunner}
|
||||
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));
|
||||
}}
|
||||
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, ...component.label }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -230,21 +289,9 @@ export default function AgentCreateContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -521,6 +521,18 @@ 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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import EventBindingsEditor from './EventBindingsEditor';
|
||||
import EventRoutingHelp from './EventRoutingHelp';
|
||||
import LegacyAdapterHelp from './LegacyAdapterHelp';
|
||||
import PluginProcessorBindings from './PluginProcessorBindings';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -866,10 +868,11 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
className="border-t pt-5"
|
||||
data-adapter-category="legacy"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLegacyAdapters((value) => !value)}
|
||||
className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
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 ? (
|
||||
@@ -882,6 +885,8 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
{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">
|
||||
{galleryLegacyAdapters.map((item) => (
|
||||
@@ -1220,7 +1225,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||
<CardTitle className="flex items-center gap-1.5">{t('bots.eventRouting')}<EventRoutingHelp /></CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventRoutingDescription')}
|
||||
</CardDescription>
|
||||
|
||||
@@ -1179,7 +1179,7 @@ function RouteDryRunDialog({
|
||||
interface BindingCardProps {
|
||||
binding: EventBinding;
|
||||
globalIndex: number;
|
||||
routeStatus?: BotEventRouteStatus;
|
||||
|
||||
eventOptions: string[];
|
||||
agentOptions: Agent[];
|
||||
expandedIds: Set<string>;
|
||||
@@ -1193,7 +1193,6 @@ interface BindingCardProps {
|
||||
function BindingCardContent({
|
||||
binding,
|
||||
globalIndex,
|
||||
routeStatus,
|
||||
eventOptions,
|
||||
agentOptions,
|
||||
expandedIds,
|
||||
@@ -1209,8 +1208,6 @@ function BindingCardContent({
|
||||
const isExpanded = expandedIds.has(id);
|
||||
const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0;
|
||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||
const statusTime = formatRouteStatusTime(routeStatus?.timestamp);
|
||||
const statusDetail = routeStatusDetail(routeStatus, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1342,23 +1339,6 @@ function BindingCardContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t px-3 py-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${routeStatusBadgeClass(
|
||||
routeStatus?.last_status,
|
||||
)}`}
|
||||
>
|
||||
{routeStatusLabel(routeStatus?.last_status, t)}
|
||||
</Badge>
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] text-muted-foreground"
|
||||
title={statusTime ? `${statusDetail} · ${statusTime}` : statusDetail}
|
||||
>
|
||||
{statusDetail || statusTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* conditions panel */}
|
||||
{isExpanded && (
|
||||
<div className="border-t px-3 py-2.5">
|
||||
@@ -1429,6 +1409,7 @@ export default function EventBindingsEditor({
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [disabledSectionOpen, setDisabledSectionOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [recordsOpen, setRecordsOpen] = useState(false);
|
||||
const [routeStatuses, setRouteStatuses] = useState<BotEventRouteStatus[]>([]);
|
||||
const [routeStatusLoading, setRouteStatusLoading] = useState(false);
|
||||
const [routeStatusError, setRouteStatusError] = useState<string | null>(null);
|
||||
@@ -1455,13 +1436,7 @@ export default function EventBindingsEditor({
|
||||
() => (supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS),
|
||||
[supportedEvents],
|
||||
);
|
||||
const routeStatusByBinding = useMemo(() => {
|
||||
const map = new Map<string, BotEventRouteStatus>();
|
||||
routeStatuses.forEach((status) => {
|
||||
if (status.binding_id) map.set(String(status.binding_id), status);
|
||||
});
|
||||
return map;
|
||||
}, [routeStatuses]);
|
||||
|
||||
const routeConflicts = useMemo(
|
||||
() => findRouteConflicts(bindings),
|
||||
[bindings],
|
||||
@@ -1496,7 +1471,9 @@ export default function EventBindingsEditor({
|
||||
setRouteStatusError(null);
|
||||
try {
|
||||
const response = await backendClient.getBotEventRouteStatuses(botId);
|
||||
setRouteStatuses(response.routes || []);
|
||||
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'));
|
||||
@@ -1506,8 +1483,8 @@ export default function EventBindingsEditor({
|
||||
}, [botId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRouteStatuses();
|
||||
}, [refreshRouteStatuses]);
|
||||
if (recordsOpen) void refreshRouteStatuses();
|
||||
}, [recordsOpen, refreshRouteStatuses]);
|
||||
|
||||
function updateBindings(next: EventBinding[]) {
|
||||
form.setValue('event_bindings', next, { shouldDirty: true });
|
||||
@@ -1674,11 +1651,6 @@ export default function EventBindingsEditor({
|
||||
sortableId={idsRef.current[sortIdx]}
|
||||
binding={binding}
|
||||
globalIndex={globalIdx}
|
||||
routeStatus={
|
||||
binding.id
|
||||
? routeStatusByBinding.get(String(binding.id))
|
||||
: undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
@@ -1695,11 +1667,6 @@ export default function EventBindingsEditor({
|
||||
<BindingCardContent
|
||||
binding={activeBinding}
|
||||
globalIndex={activeGlobalIdx}
|
||||
routeStatus={
|
||||
activeBinding.id
|
||||
? routeStatusByBinding.get(String(activeBinding.id))
|
||||
: undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
@@ -1791,26 +1758,43 @@ export default function EventBindingsEditor({
|
||||
eventOptions={dryRunEventOptions}
|
||||
agentOptions={agentOptions}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
aria-label={t('bots.refreshRouteStatus')}
|
||||
onClick={refreshRouteStatuses}
|
||||
disabled={!botId || routeStatusLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{routeStatusError || t('bots.refreshRouteStatus')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!botId} onClick={() => setRecordsOpen(true)}>
|
||||
<ListChecks className="mr-1 size-4" />
|
||||
{t('bots.matchRecords')}
|
||||
</Button>
|
||||
<Dialog open={recordsOpen} onOpenChange={setRecordsOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bots.matchRecords')}</DialogTitle>
|
||||
<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' : ''}`} />
|
||||
{t('monitoring.refreshData')}
|
||||
</Button>
|
||||
</div>
|
||||
{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 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>
|
||||
</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>
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
))}
|
||||
{!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>
|
||||
|
||||
{/* disabled section */}
|
||||
@@ -1838,9 +1822,6 @@ export default function EventBindingsEditor({
|
||||
key={b.id ?? i}
|
||||
binding={b}
|
||||
globalIndex={i}
|
||||
routeStatus={
|
||||
b.id ? routeStatusByBinding.get(String(b.id)) : undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.routing-help-dot {
|
||||
animation: route-travel 4.8s ease-in-out infinite;
|
||||
animation-delay: var(--route-delay);
|
||||
opacity: 0;
|
||||
}
|
||||
.routing-help-target {
|
||||
animation: route-receive 4.8s ease-in-out infinite;
|
||||
animation-delay: var(--route-delay);
|
||||
}
|
||||
.routing-help-paused * { animation-play-state: paused; }
|
||||
@keyframes route-travel {
|
||||
0%, 4% { opacity: 0; transform: translateX(0); }
|
||||
8% { opacity: 1; }
|
||||
25% { opacity: 1; transform: translateX(40px); }
|
||||
30%, 100% { opacity: 0; transform: translateX(40px); }
|
||||
}
|
||||
@keyframes route-receive {
|
||||
0%, 24%, 45%, 100% { transform: translateY(0); }
|
||||
30%, 36% { transform: translateY(-2px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.routing-help-dot, .routing-help-target { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import './EventRoutingHelp.css';
|
||||
|
||||
const routeColors = [
|
||||
'text-sky-700 dark:text-sky-300 [&_.route-node]:border-sky-200 [&_.route-node]:bg-sky-50 dark:[&_.route-node]:border-sky-800 dark:[&_.route-node]:bg-sky-950/40',
|
||||
'text-amber-700 dark:text-amber-300 [&_.route-node]:border-amber-200 [&_.route-node]:bg-amber-50 dark:[&_.route-node]:border-amber-800 dark:[&_.route-node]:bg-amber-950/40',
|
||||
'text-emerald-700 dark:text-emerald-300 [&_.route-node]:border-emerald-200 [&_.route-node]:bg-emerald-50 dark:[&_.route-node]:border-emerald-800 dark:[&_.route-node]:bg-emerald-950/40',
|
||||
];
|
||||
|
||||
export default function EventRoutingHelp() {
|
||||
const { t } = useTranslation();
|
||||
const [paused, setPaused] = useState(false);
|
||||
const text = (key: string) => t(`bots.routingHelp.${key}`);
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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 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>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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>
|
||||
</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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Cpu,
|
||||
Hash,
|
||||
User,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { AlertCircle, Cpu, MessageCircle, Send, Wrench } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProcessorPayload } from '@/app/home/agents/components/PluginProcessorTrace';
|
||||
import { toolSummary } from '@/app/home/agents/components/tool-summary';
|
||||
import { MessageContentRenderer } from './MessageContentRenderer';
|
||||
import {
|
||||
ConversationTurn,
|
||||
hasRenderableMessageContent,
|
||||
} from '../utils/conversationTurns';
|
||||
import { MonitoringMessage } from '../types/monitoring';
|
||||
import { ConversationTurn } from '../utils/conversationTurns';
|
||||
|
||||
interface ConversationTurnListProps {
|
||||
turns: ConversationTurn[];
|
||||
@@ -26,626 +14,180 @@ interface ConversationTurnListProps {
|
||||
onToggleTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
function shortId(id?: string) {
|
||||
if (!id) return '-';
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (!ms) return '0ms';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function truncateDetail(value?: string) {
|
||||
if (!value) return '';
|
||||
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
|
||||
function parsePayload(value?: string): unknown {
|
||||
if (!value) return null;
|
||||
try { return JSON.parse(value); } catch { return value; }
|
||||
}
|
||||
|
||||
function roleLabel(message: MonitoringMessage | undefined) {
|
||||
const role = message?.role?.toLowerCase();
|
||||
if (role === 'assistant') return 'assistant';
|
||||
if (role === 'user') return 'user';
|
||||
return 'message';
|
||||
}
|
||||
|
||||
function statusClass(level: ConversationTurn['level']) {
|
||||
if (level === 'error') {
|
||||
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
|
||||
}
|
||||
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
tone?: 'default' | 'error';
|
||||
}) {
|
||||
function MessageBody({ content }: { content: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
|
||||
tone === 'error'
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'border-border bg-background text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-background px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{value || '-'}
|
||||
</div>
|
||||
<div className="min-w-0 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
|
||||
<MessageContentRenderer content={content} maxLines={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageLane({
|
||||
label,
|
||||
icon,
|
||||
content,
|
||||
empty,
|
||||
maxLines,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
content?: string;
|
||||
empty: string;
|
||||
maxLines: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
|
||||
{content && hasRenderableMessageContent(content) ? (
|
||||
<MessageContentRenderer content={content} maxLines={maxLines} />
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">{empty}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedMessage({
|
||||
message,
|
||||
label,
|
||||
}: {
|
||||
message: MonitoringMessage;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span>{message.timestamp.toLocaleString()}</span>
|
||||
<span className="font-mono">ID: {shortId(message.id)}</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-foreground">
|
||||
<MessageContentRenderer content={message.messageContent} maxLines={4} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationTurnList({
|
||||
turns,
|
||||
expandedTurnId,
|
||||
onToggleTurn,
|
||||
}: ConversationTurnListProps) {
|
||||
export function ConversationTurnList({ turns, expandedTurnId, onToggleTurn }: ConversationTurnListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const turn = turns.find((item) => item.id === expandedTurnId) ?? turns[0];
|
||||
if (!turn) return null;
|
||||
|
||||
const toggleToolCallDetails = (toolCallKey: string) => {
|
||||
setExpandedToolCallIds((previous) => ({
|
||||
...previous,
|
||||
[toolCallKey]: !previous[toolCallKey],
|
||||
}));
|
||||
};
|
||||
const statusBadge = (status: ConversationTurn['status']) => (
|
||||
<Badge status={status} className="shrink-0">
|
||||
{t(`monitoring.pipelineTrace.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
const steps = [
|
||||
...turn.llmCalls.map((call) => ({ kind: 'model' as const, call })),
|
||||
...turn.toolCalls.map((call) => ({ kind: 'tool' as const, call })),
|
||||
...turn.errors.map((call) => ({ kind: 'error' as const, call })),
|
||||
].sort((a, b) => a.call.timestamp.getTime() - b.call.timestamp.getTime());
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{t('monitoring.messageList.turns', {
|
||||
defaultValue: '{{count}} 轮对话',
|
||||
count: turns.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{turns.map((turn) => {
|
||||
const expanded = expandedTurnId === turn.id;
|
||||
const firstAssistant = turn.assistantMessages[0];
|
||||
const assistantOverflow = Math.max(
|
||||
turn.assistantMessages.length - 1,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={turn.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border bg-card transition-colors',
|
||||
turn.level === 'error' && 'border-red-200 dark:border-red-900',
|
||||
)}
|
||||
>
|
||||
<div className="grid min-h-0 gap-5 lg:h-full lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<div className="mb-3 text-sm font-medium">
|
||||
{t('monitoring.messageList.turns', { count: turns.length })}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-xl border lg:max-h-none">
|
||||
{turns.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
|
||||
onClick={() => onToggleTurn(turn.id)}
|
||||
aria-pressed={turn.id === item.id}
|
||||
onClick={() => onToggleTurn(item.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onToggleTurn(turn.id);
|
||||
onToggleTurn(item.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer border-b border-l-2 border-l-transparent p-3 text-left last:border-b-0 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||
turn.id === item.id && 'border-l-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
Turn: {shortId(turn.id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{turn.botName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{turn.pipelineName}
|
||||
</span>
|
||||
{turn.runnerName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{turn.runnerName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MessageLane
|
||||
label={t('monitoring.messageList.userMessage', {
|
||||
defaultValue: '用户',
|
||||
})}
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
content={turn.userMessage?.messageContent}
|
||||
empty={t('monitoring.messageList.noUserMessage', {
|
||||
defaultValue: '未记录用户输入',
|
||||
})}
|
||||
maxLines={2}
|
||||
/>
|
||||
<MessageLane
|
||||
label={
|
||||
assistantOverflow > 0
|
||||
? t('monitoring.messageList.assistantMessageCount', {
|
||||
defaultValue: '助手 +{{count}}',
|
||||
count: assistantOverflow,
|
||||
})
|
||||
: t('monitoring.messageList.assistantMessage', {
|
||||
defaultValue: '助手',
|
||||
})
|
||||
}
|
||||
icon={<Bot className="h-3.5 w-3.5" />}
|
||||
content={firstAssistant?.messageContent}
|
||||
empty={t('monitoring.messageList.noAssistantMessage', {
|
||||
defaultValue: '未记录助手回复',
|
||||
})}
|
||||
maxLines={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{turn.lastActivityAt.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
|
||||
statusClass(turn.level),
|
||||
)}
|
||||
>
|
||||
{turn.level}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<Metric
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
label={`${turn.llmCalls.length} LLM`}
|
||||
/>
|
||||
{turn.toolCalls.length > 0 && (
|
||||
<Metric
|
||||
icon={<Wrench className="h-3.5 w-3.5" />}
|
||||
label={`${turn.toolCalls.length} tools`}
|
||||
/>
|
||||
)}
|
||||
<Metric
|
||||
icon={<Hash className="h-3.5 w-3.5" />}
|
||||
label={`${turn.totalTokens.toLocaleString()} tokens`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="h-3.5 w-3.5" />}
|
||||
label={formatDuration(turn.totalDuration)}
|
||||
/>
|
||||
{turn.errors.length > 0 && (
|
||||
<Metric
|
||||
icon={<AlertCircle className="h-3.5 w-3.5" />}
|
||||
label={`${turn.errors.length} errors`}
|
||||
tone="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-medium">{item.botName}</span>
|
||||
{statusBadge(item.status)}
|
||||
</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')}
|
||||
</div>
|
||||
<div className="mt-2 text-xs tabular-nums text-muted-foreground">
|
||||
{item.startedAt.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t bg-muted/40 p-3 sm:p-5">
|
||||
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.platform', {
|
||||
defaultValue: '平台',
|
||||
})}
|
||||
value={turn.platform}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.user', {
|
||||
defaultValue: '用户',
|
||||
})}
|
||||
value={turn.userName || turn.userId}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.runner', {
|
||||
defaultValue: '执行器',
|
||||
})}
|
||||
value={turn.runnerName}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.sessions.sessionId', {
|
||||
defaultValue: '会话 ID',
|
||||
})}
|
||||
value={turn.sessionId}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.messageCount', {
|
||||
defaultValue: '消息数',
|
||||
})}
|
||||
value={String(turn.messages.length)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t('monitoring.messageList.conversationTrace', {
|
||||
defaultValue: '消息链路',
|
||||
})}
|
||||
</h4>
|
||||
<div className="rounded-lg bg-background px-3 py-3">
|
||||
{turn.messages.map((message) => (
|
||||
<ExpandedMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
label={t(
|
||||
`monitoring.messageList.roles.${roleLabel(message)}`,
|
||||
{
|
||||
defaultValue:
|
||||
roleLabel(message) === 'assistant'
|
||||
? '助手'
|
||||
: roleLabel(message) === 'user'
|
||||
? '用户'
|
||||
: '消息',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('monitoring.llmCalls.title', {
|
||||
defaultValue: 'LLM 调用',
|
||||
})}{' '}
|
||||
({turn.llmCalls.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.totalTokens', {
|
||||
defaultValue: '总 Token',
|
||||
})}
|
||||
value={turn.totalTokens.toLocaleString()}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.inputTokens', {
|
||||
defaultValue: '输入 Token',
|
||||
})}
|
||||
value={turn.inputTokens.toLocaleString()}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.duration', {
|
||||
defaultValue: '耗时',
|
||||
})}
|
||||
value={formatDuration(turn.totalDuration)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
||||
{turn.llmCalls.length > 0 ? (
|
||||
turn.llmCalls.map((call, index) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-xs font-medium',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>In: {call.tokens.input}</span>
|
||||
<span>Out: {call.tokens.output}</span>
|
||||
<span>Total: {call.tokens.total}</span>
|
||||
<span className="font-mono">
|
||||
ID: {shortId(call.id)}
|
||||
</span>
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
|
||||
{call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('monitoring.messageList.noLlmCalls', {
|
||||
defaultValue: '未记录模型调用',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Wrench className="h-4 w-4" />
|
||||
{t('monitoring.toolCalls.title', {
|
||||
defaultValue: '工具调用',
|
||||
})}{' '}
|
||||
({turn.toolCalls.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.totalCalls', {
|
||||
defaultValue: '调用次数',
|
||||
})}
|
||||
value={String(turn.toolCalls.length)}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.duration', {
|
||||
defaultValue: '工具耗时',
|
||||
})}
|
||||
value={formatDuration(turn.totalToolDuration)}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.errorCalls', {
|
||||
defaultValue: '失败次数',
|
||||
})}
|
||||
value={String(
|
||||
turn.toolCalls.filter(
|
||||
(call) => call.status === 'error',
|
||||
).length,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
||||
{turn.toolCalls.length > 0 ? (
|
||||
turn.toolCalls.map((call, index) => {
|
||||
const toolCallKey = `${turn.id}:${call.id}`;
|
||||
const hasToolDetails = Boolean(
|
||||
call.arguments || call.result || call.errorMessage,
|
||||
);
|
||||
const expandedToolCall = Boolean(
|
||||
expandedToolCallIds[toolCallKey],
|
||||
);
|
||||
const detailsId = `monitoring-tool-call-details-${call.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex h-auto w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left',
|
||||
hasToolDetails &&
|
||||
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
aria-expanded={
|
||||
hasToolDetails ? expandedToolCall : undefined
|
||||
}
|
||||
aria-controls={
|
||||
hasToolDetails ? detailsId : undefined
|
||||
}
|
||||
aria-disabled={!hasToolDetails}
|
||||
onClick={() =>
|
||||
hasToolDetails &&
|
||||
toggleToolCallDetails(toolCallKey)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{hasToolDetails &&
|
||||
(expandedToolCall ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
#{index + 1} {call.toolName}
|
||||
</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{call.toolSource}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-xs font-medium',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
ID: {shortId(call.id)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{hasToolDetails && expandedToolCall && (
|
||||
<div
|
||||
id={detailsId}
|
||||
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
|
||||
>
|
||||
{call.arguments && (
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 font-medium text-foreground">
|
||||
{t('monitoring.toolCalls.arguments', {
|
||||
defaultValue: '参数',
|
||||
})}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
||||
{truncateDetail(call.arguments)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{call.result && (
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 font-medium text-foreground">
|
||||
{t('monitoring.toolCalls.result', {
|
||||
defaultValue: '结果',
|
||||
})}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
||||
{truncateDetail(call.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{call.errorMessage && (
|
||||
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
|
||||
{call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('monitoring.toolCalls.noToolCalls', {
|
||||
defaultValue: '未记录工具调用',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{turn.errors.length > 0 && (
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t('monitoring.errors.title', {
|
||||
defaultValue: '错误日志',
|
||||
})}{' '}
|
||||
({turn.errors.length})
|
||||
</h4>
|
||||
<div className="rounded-lg bg-background px-3 py-3">
|
||||
{turn.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl border p-4">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</h4>
|
||||
{steps.length === 0 ? (
|
||||
<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 : '';
|
||||
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>
|
||||
<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" />}
|
||||
<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}
|
||||
</span>
|
||||
{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>
|
||||
</>}
|
||||
</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} />}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border p-4">
|
||||
<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>
|
||||
</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)}
|
||||
</div>
|
||||
<MessageBody content={message.messageContent} />
|
||||
</div>
|
||||
)) : <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,
|
||||
}} />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { resolveMonitoringWindow } from '../utils/dateUtils';
|
||||
import { FilterState } from '../types/monitoring';
|
||||
|
||||
export type ExportType =
|
||||
@@ -38,44 +39,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [exporting, setExporting] = useState<ExportType | null>(null);
|
||||
|
||||
const getDateRangeParams = (): { startTime: string; endTime: string } => {
|
||||
const now = new Date();
|
||||
let startTime: Date;
|
||||
let endTime: Date = now;
|
||||
|
||||
switch (filterState.timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (filterState.customDateRange) {
|
||||
startTime = filterState.customDateRange.from;
|
||||
endTime = filterState.customDateRange.to;
|
||||
} else {
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
};
|
||||
const getDateRangeParams = () => resolveMonitoringWindow(filterState.timeRange, filterState.customDateRange);
|
||||
|
||||
const handleExport = async (type: ExportType) => {
|
||||
setExporting(type);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatRunDuration } from '@/app/home/agents/components/processor-run-ti
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
import {
|
||||
executionKindLabel,
|
||||
ExecutionKindBadge,
|
||||
executionProcessorLabel,
|
||||
executionStatusLabel,
|
||||
} from './ExecutionTable';
|
||||
@@ -384,9 +385,7 @@ export default function ExecutionDetailSheet({
|
||||
<Badge status={record.status_group}>
|
||||
{executionStatusLabel(record, t)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(record, t)}
|
||||
</Badge>
|
||||
<ExecutionKindBadge row={record} />
|
||||
{record.debug && (
|
||||
<Badge status="debug">
|
||||
{t('monitoring.execution.debug')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Bot, Workflow, Puzzle, CircleDashed } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
@@ -63,6 +64,27 @@ export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
|
||||
return row.target_kind;
|
||||
}
|
||||
|
||||
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';
|
||||
return (
|
||||
<Badge variant="outline" className={`gap-1 whitespace-nowrap ${color}`}>
|
||||
<Icon className="size-3 shrink-0" aria-hidden="true" />
|
||||
{executionKindLabel(row, t)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function executionTargetLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (row.source !== 'pipeline') {
|
||||
return eventPatternLabel(row.title, t);
|
||||
@@ -133,9 +155,7 @@ export default function ExecutionTable({
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(row, t)}
|
||||
</Badge>
|
||||
<ExecutionKindBadge row={row} />
|
||||
<span className="truncate font-medium">
|
||||
{row.source === 'event'
|
||||
? t('monitoring.unified.unhandled')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DateRange } from '../../types/monitoring';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { TimeRangeOption } from '../../types/monitoring';
|
||||
@@ -16,6 +23,8 @@ interface MonitoringFiltersProps {
|
||||
selectedBots: string[];
|
||||
selectedPipelines: string[];
|
||||
timeRange: TimeRangeOption;
|
||||
customDateRange?: DateRange | null;
|
||||
onCustomDateRangeChange?: (range: DateRange) => void;
|
||||
onBotsChange: (bots: string[]) => void;
|
||||
onPipelinesChange: (pipelines: string[]) => void;
|
||||
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
|
||||
@@ -53,12 +62,15 @@ interface Processor {
|
||||
uuid: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export default function MonitoringFilters({
|
||||
selectedBots,
|
||||
selectedPipelines,
|
||||
timeRange,
|
||||
customDateRange,
|
||||
onCustomDateRangeChange,
|
||||
onBotsChange,
|
||||
onPipelinesChange,
|
||||
onTimeRangeChange,
|
||||
@@ -122,6 +134,7 @@ export default function MonitoringFilters({
|
||||
uuid: pipeline.uuid,
|
||||
name: pipeline.name,
|
||||
emoji: pipeline.emoji,
|
||||
kind: pipeline.kind,
|
||||
}));
|
||||
if (active) setProcessors(validProcessors);
|
||||
} catch (error) {
|
||||
@@ -145,18 +158,28 @@ export default function MonitoringFilters({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePipelineChange = (value: string) => {
|
||||
if (value === 'all') {
|
||||
onPipelinesChange([]);
|
||||
} else {
|
||||
onPipelinesChange([value]);
|
||||
}
|
||||
const customPending = useRef(false);
|
||||
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 openCustom = () => {
|
||||
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 handleTimeRangeChange = (value: string) => {
|
||||
onTimeRangeChange(value as TimeRangeOption);
|
||||
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));
|
||||
onPipelinesChange([...next]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{/* Bot Filter */}
|
||||
@@ -212,41 +235,33 @@ export default function MonitoringFilters({
|
||||
>
|
||||
{t('monitoring.filters.processor')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
|
||||
onValueChange={handlePipelineChange}
|
||||
disabled={loadingProcessors}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-processor"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingProcessors
|
||||
? t('monitoring.filters.loading')
|
||||
: t('monitoring.filters.selectProcessor')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.filters.allProcessors')}
|
||||
</SelectItem>
|
||||
{processors.map((pipeline) => (
|
||||
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{pipeline.emoji && (
|
||||
<span className="shrink-0 text-sm" aria-hidden="true">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{pipeline.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
<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>
|
||||
{['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>;
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Mode Filter */}
|
||||
@@ -334,14 +349,21 @@ export default function MonitoringFilters({
|
||||
>
|
||||
{t('monitoring.filters.timeRange')}
|
||||
</Label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<Select value={timeRange === 'custom' ? '' : timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-time"
|
||||
className="h-9 w-full min-w-0"
|
||||
className="h-9 w-full min-w-0 gap-2 [&>svg]:shrink-0"
|
||||
title={timeRange === 'custom' && customDateRange ? `${customDateRange.from.toLocaleString()} – ${customDateRange.to.toLocaleString()}` : undefined}
|
||||
>
|
||||
<SelectValue />
|
||||
{timeRange === 'custom' && customDateRange ? (
|
||||
<span className="min-w-0 truncate text-xs tabular-nums">
|
||||
{customDateRange.from.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' })}
|
||||
{' – '}
|
||||
{customDateRange.to.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' })}
|
||||
</span>
|
||||
) : <SelectValue />}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onCloseAutoFocus={(event) => { if (customPending.current) { event.preventDefault(); customPending.current = false; openCustom(); } }}>
|
||||
<SelectItem value="lastHour">
|
||||
{t('monitoring.filters.lastHour')}
|
||||
</SelectItem>
|
||||
@@ -354,11 +376,17 @@ export default function MonitoringFilters({
|
||||
<SelectItem value="last7Days">
|
||||
{t('monitoring.filters.last7Days')}
|
||||
</SelectItem>
|
||||
<SelectItem value="last30Days">
|
||||
{t('monitoring.filters.last30Days')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{[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>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { BarChart3, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
@@ -20,7 +20,40 @@ interface TrafficChartProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function TrafficChart({ traffic, loading }: 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; }
|
||||
});
|
||||
return (
|
||||
<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>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 size-4 text-muted-foreground transition-transform motion-reduce:transition-none ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-3 pb-4 pt-4 sm:px-5">
|
||||
<TrafficChartContent {...props} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficChartContent({ traffic, loading }: TrafficChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
@@ -43,27 +76,17 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-5 w-32 rounded" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Skeleton className="w-full h-full rounded" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BarChart3 className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
@@ -74,15 +97,12 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div>
|
||||
{traffic?.truncated && (
|
||||
<p role="status" className="text-sm text-muted-foreground mb-3">
|
||||
{t('monitoring.trafficChart.truncated')}
|
||||
@@ -141,15 +161,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
}}
|
||||
itemStyle={{ padding: '4px 0' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
fontSize: '13px',
|
||||
paddingTop: '16px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
iconType="circle"
|
||||
iconSize={10}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="messages"
|
||||
@@ -175,6 +186,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const timeRanges = [
|
||||
'last24Hours',
|
||||
'last7Days',
|
||||
'last30Days',
|
||||
'last90Days',
|
||||
'last180Days',
|
||||
'last365Days',
|
||||
'custom',
|
||||
];
|
||||
const statuses = [
|
||||
@@ -52,7 +55,7 @@ function restore(key: string): FilterState {
|
||||
if (
|
||||
Number.isFinite(from.getTime()) &&
|
||||
Number.isFinite(to.getTime()) &&
|
||||
from <= to
|
||||
from < to && to.getTime() - from.getTime() <= 365 * 86400000
|
||||
)
|
||||
state.customDateRange = { from, to };
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from '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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -53,6 +55,7 @@ function MonitoringPageContent() {
|
||||
setSelectedBots,
|
||||
setSelectedPipelines,
|
||||
setTimeRange,
|
||||
setCustomDateRange,
|
||||
setExecutionMode,
|
||||
setExecutionStatus,
|
||||
resetFilters,
|
||||
@@ -134,13 +137,46 @@ function MonitoringPageContent() {
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const countdown = useRef(0);
|
||||
const intervalRef = useRef(0);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
// Combined refresh handler for both monitoring and feedback data
|
||||
const handleRefresh = useCallback(() => {
|
||||
countdown.current = intervalRef.current;
|
||||
setRemainingSeconds(countdown.current);
|
||||
refetch();
|
||||
refetchExecutions();
|
||||
setFeedbackRefreshKey((k) => k + 1);
|
||||
}, [refetch, refetchExecutions]);
|
||||
|
||||
const [refreshInterval, setRefreshInterval] = useState(() => {
|
||||
try {
|
||||
const saved = Number(localStorage.getItem('langbot-dashboard-refresh-interval'));
|
||||
return [5, 15, 30, 60, 300].includes(saved) ? saved : 0;
|
||||
} catch { return 0; }
|
||||
});
|
||||
const refreshState = useRef({ handleRefresh, busy: false });
|
||||
useEffect(() => {
|
||||
refreshState.current = { handleRefresh, busy: loading || executionLoading || feedbackLoading };
|
||||
}, [handleRefresh, loading, executionLoading, feedbackLoading]);
|
||||
useEffect(() => {
|
||||
intervalRef.current = refreshInterval;
|
||||
countdown.current = refreshInterval;
|
||||
setRemainingSeconds(refreshInterval);
|
||||
if (!refreshInterval) return;
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !refreshState.current.busy) {
|
||||
countdown.current = Math.max(0, countdown.current - 1);
|
||||
setRemainingSeconds(countdown.current);
|
||||
if (countdown.current === 0) {
|
||||
refreshState.current.busy = true;
|
||||
refreshState.current.handleRefresh();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshInterval]);
|
||||
|
||||
// State for expanded errors
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||
|
||||
@@ -174,6 +210,8 @@ function MonitoringPageContent() {
|
||||
onBotsChange={setSelectedBots}
|
||||
onPipelinesChange={setSelectedPipelines}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
customDateRange={filterState.customDateRange}
|
||||
onCustomDateRangeChange={setCustomDateRange}
|
||||
mode={executionMode}
|
||||
onModeChange={setExecutionMode}
|
||||
statusGroup={executionStatus}
|
||||
@@ -185,15 +223,38 @@ function MonitoringPageContent() {
|
||||
{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="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
className="shadow-sm"
|
||||
disabled={loading || executionLoading || feedbackLoading}
|
||||
className="gap-2 rounded-none px-3"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className={cn('size-3.5', (loading || executionLoading || feedbackLoading) && '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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +178,9 @@ export type TimeRangeOption =
|
||||
| 'last24Hours'
|
||||
| 'last7Days'
|
||||
| 'last30Days'
|
||||
| 'last90Days'
|
||||
| 'last180Days'
|
||||
| 'last365Days'
|
||||
| 'custom';
|
||||
|
||||
export interface DateRange {
|
||||
|
||||
@@ -22,8 +22,11 @@ export function getPresetDateRange(option: TimeRangeOption): DateRange | null {
|
||||
case 'last7Days':
|
||||
from.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'last90Days':
|
||||
case 'last180Days':
|
||||
case 'last365Days':
|
||||
case 'last30Days':
|
||||
from.setDate(now.getDate() - 30);
|
||||
from.setTime(now.getTime() - Number(option.match(/\d+/)?.[0] ?? 30) * 86400000);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
@@ -143,8 +146,11 @@ export function resolveMonitoringWindow(
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last90Days':
|
||||
case 'last180Days':
|
||||
case 'last365Days':
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
startTime = new Date(now.getTime() - Number(timeRange.match(/\d+/)?.[0] ?? 30) * 86400000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customDateRange) {
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function PipelineMonitoringTab({
|
||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||
|
||||
const toggleTurnExpand = (turnId: string) => {
|
||||
setExpandedTurnId((current) => (current === turnId ? null : turnId));
|
||||
setExpandedTurnId(turnId);
|
||||
};
|
||||
|
||||
const toggleErrorExpand = (errorId: string) => {
|
||||
@@ -83,7 +83,7 @@ export default function PipelineMonitoringTab({
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{/* Header with refresh button */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b pb-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('pipelines.monitoring.description')}
|
||||
</p>
|
||||
@@ -113,8 +113,8 @@ export default function PipelineMonitoringTab({
|
||||
|
||||
{/* Overview Stats */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="mb-4 grid grid-cols-3 divide-x rounded-xl border bg-card">
|
||||
<div className="p-3">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.totalMessages')}
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@ export default function PipelineMonitoringTab({
|
||||
{data.overview.totalMessages}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="p-3">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.successRate')}
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export default function PipelineMonitoringTab({
|
||||
{data.overview.successRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="p-3">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</div>
|
||||
|
||||
@@ -824,7 +824,7 @@ export default function WizardPage() {
|
||||
|
||||
if (!previewPipelineUuid) {
|
||||
const pipelineResp = await httpClient.createPipeline({
|
||||
name: `${botName} Pipeline`,
|
||||
name: `${botName} ${t('agents.pipelineType')}`,
|
||||
description: botDescription || '',
|
||||
config: {},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user