mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
fix(skill): pin revisions per run and preserve shared Box state
This commit is contained in:
@@ -195,3 +195,28 @@
|
||||
body:has([data-tour-modal='true']) [data-tour-modal='false'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reserve native scrollbar space before content overflows. Extending the
|
||||
utilities also covers responsive variants, nested panels and popup menus.
|
||||
Horizontal-only scrollers and custom overlay scroll areas stay unchanged. */
|
||||
@utility overflow-auto {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-y-auto {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility overflow-y-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
textarea {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,43 @@ 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 +118,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 +293,7 @@ export default function AgentDetailContent({
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
onRunnerStatusChange={updateRunnerStatus}
|
||||
onSupportedEventPatternsChange={setSupportedEventPatterns}
|
||||
onPlatformToolsChange={setPlatformTools}
|
||||
guideEnabled={canManage}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
|
||||
import { RefreshCw, Trash2, Clock3, Puzzle } from 'lucide-react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||
@@ -29,6 +30,10 @@ import PluginProcessorTrace, {
|
||||
ProcessorPayload,
|
||||
} from './components/PluginProcessorTrace';
|
||||
import ProcessorRunList from './components/ProcessorRunList';
|
||||
import {
|
||||
processorRunDuration,
|
||||
formatRunDuration,
|
||||
} from './components/processor-run-timing';
|
||||
import PluginProcessorSettings from './components/PluginProcessorSettings';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
|
||||
@@ -52,9 +57,19 @@ export default function PluginProcessorDetailContent({
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
searchParams.get('tab') === 'logs' ? 'logs' : 'config',
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const setActiveTab = useCallback(
|
||||
(tab: string) => {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set('tab', tab);
|
||||
return next;
|
||||
},
|
||||
{ preventScrollReset: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
||||
const toolLabels = Object.fromEntries(
|
||||
@@ -74,7 +89,11 @@ export default function PluginProcessorDetailContent({
|
||||
componentRef,
|
||||
parameters: initialParameters,
|
||||
});
|
||||
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[]>([]);
|
||||
@@ -132,7 +151,7 @@ export default function PluginProcessorDetailContent({
|
||||
: []),
|
||||
{
|
||||
id: 'logs',
|
||||
target: '[data-guide="event-processor-form-tab-logs"]',
|
||||
target: '[data-guide="event-processor-form-monitoring"]',
|
||||
title: t('guidedTour.pluginProcessor.logs.title'),
|
||||
description: t('guidedTour.pluginProcessor.logs.description'),
|
||||
onEnter: () => setActiveTab('logs'),
|
||||
@@ -149,7 +168,27 @@ export default function PluginProcessorDetailContent({
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[t, available, hasParameters, canOperate],
|
||||
[t, available, hasParameters, canOperate, setActiveTab],
|
||||
);
|
||||
|
||||
const applyInstalledProcessor = useCallback(
|
||||
(component: RunnerDescriptor) => {
|
||||
setComponents((current) => [
|
||||
...current.filter((item) => item.id !== component.id),
|
||||
component,
|
||||
]);
|
||||
setComponentRef(component.id);
|
||||
setParameters(
|
||||
Object.fromEntries(
|
||||
(component.config_schema ?? [])
|
||||
.filter((field) => field.default !== undefined)
|
||||
.map((field) => [field.name, field.default]),
|
||||
),
|
||||
);
|
||||
setActiveTab('config');
|
||||
validate.current = null;
|
||||
},
|
||||
[setActiveTab],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -324,6 +363,7 @@ export default function PluginProcessorDetailContent({
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
onSaved();
|
||||
setSavedConfig({ componentRef, parameters });
|
||||
setNeedsInitialSave(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch {
|
||||
@@ -335,20 +375,13 @@ export default function PluginProcessorDetailContent({
|
||||
}
|
||||
|
||||
const logsContent = (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<form
|
||||
id="event-processor-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
|
||||
{failed && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('agents.eventProcessor.loadError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState
|
||||
compact
|
||||
title={t('agents.eventProcessor.loadError')}
|
||||
onRetry={refreshLatestRun}
|
||||
/>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -356,98 +389,123 @@ export default function PluginProcessorDetailContent({
|
||||
<span className="text-muted-foreground">({runs.length})</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t('agents.eventProcessor.refresh')}
|
||||
onClick={() => void refreshLatestRun()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('agents.eventProcessor.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{runs.length > 0 && (
|
||||
<ProcessorRunList
|
||||
runs={runs}
|
||||
selectedId={selected?.run_id}
|
||||
onSelect={(run) => void openRun(run)}
|
||||
footer={
|
||||
cursor !== null ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
disabled={pagingRuns}
|
||||
onClick={() => void loadMoreRuns()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-2 pr-3">
|
||||
{!selected ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{loading
|
||||
? t('common.loading')
|
||||
: t('agents.eventProcessor.noRuns')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/bots">
|
||||
{t('agents.eventProcessor.bindBot')}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-b pb-2">
|
||||
<p className="text-sm font-medium">
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === 'failed' ? 'destructive' : 'outline'
|
||||
}
|
||||
>
|
||||
{t(`agents.eventProcessor.status_${selected.status}`, {
|
||||
defaultValue: selected.status,
|
||||
})}
|
||||
</Badge>
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.input')}
|
||||
value={selected.metadata.input_event}
|
||||
/>
|
||||
{selected.metadata.delivery != null && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.destination')}
|
||||
value={selected.metadata.delivery}
|
||||
/>
|
||||
)}
|
||||
<PluginProcessorTrace events={events} toolLabels={toolLabels} />
|
||||
{selected.status === 'failed' && selected.status_reason && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="break-words">
|
||||
{selected.status_reason}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{eventCursor !== null && (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
{runs.length > 0 && (
|
||||
<ProcessorRunList
|
||||
className="max-h-64 overflow-y-auto rounded-lg border lg:max-h-none lg:h-full"
|
||||
runs={runs}
|
||||
selectedId={selected?.run_id}
|
||||
onSelect={(run) => void openRun(run)}
|
||||
footer={
|
||||
cursor !== null ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
disabled={pagingEvents}
|
||||
onClick={() => void loadMoreEvents()}
|
||||
disabled={pagingRuns}
|
||||
onClick={() => void loadMoreRuns()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 pr-2">
|
||||
{!selected ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{loading
|
||||
? t('common.loading')
|
||||
: t('agents.eventProcessor.noRuns')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/bots">
|
||||
{t('agents.eventProcessor.bindBot')}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{processorRunDuration(selected) !== null && (
|
||||
<span className="flex items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
<Clock3 className="size-3.5" />
|
||||
{formatRunDuration(processorRunDuration(selected)!)}
|
||||
</span>
|
||||
)}
|
||||
<Badge status={selected.status}>
|
||||
{t(`agents.eventProcessor.status_${selected.status}`, {
|
||||
defaultValue: selected.status,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.eventProcessor.input')}
|
||||
</h3>
|
||||
<ProcessorPayload
|
||||
title={t('agents.monitoring.eventData')}
|
||||
value={selected.metadata.input_event}
|
||||
/>
|
||||
{selected.metadata.delivery != null && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.eventProcessor.destination')}
|
||||
value={selected.metadata.delivery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
{t('agents.eventProcessor.type')} ·{' '}
|
||||
{t('agents.eventProcessor.logsTab')}
|
||||
</h2>
|
||||
<PluginProcessorTrace
|
||||
events={events}
|
||||
toolLabels={toolLabels}
|
||||
/>
|
||||
</section>
|
||||
{selected.status === 'failed' && selected.status_reason && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="break-words">
|
||||
{selected.status_reason}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{eventCursor !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pagingEvents}
|
||||
onClick={() => void loadMoreEvents()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -456,6 +514,13 @@ export default function PluginProcessorDetailContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
id="event-processor-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
/>
|
||||
<GuidedTour
|
||||
enabled={canManage}
|
||||
storageKey="langbot_plugin_processor_setup_guide_v1"
|
||||
@@ -464,11 +529,22 @@ export default function PluginProcessorDetailContent({
|
||||
/>
|
||||
<ProcessorDetailWorkbench
|
||||
title={`${agent.emoji || '🧩'} ${agent.name}`}
|
||||
titleBadge={
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<Puzzle className="size-3.5" />
|
||||
{t('agents.eventProcessor.type')}
|
||||
</Badge>
|
||||
}
|
||||
titleAction={
|
||||
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
|
||||
}
|
||||
titleControls={
|
||||
<PluginProcessorSettings
|
||||
installScope={`event-processor:${id}`}
|
||||
onInstalled={applyInstalledProcessor}
|
||||
components={components}
|
||||
value={componentRef}
|
||||
disabled={!canManage || saving || loading}
|
||||
@@ -506,50 +582,41 @@ export default function PluginProcessorDetailContent({
|
||||
) : undefined
|
||||
}
|
||||
configTitle={t('agents.eventProcessor.type')}
|
||||
configTabs={{
|
||||
value: activeTab,
|
||||
onValueChange: setActiveTab,
|
||||
items: [
|
||||
{
|
||||
value: 'config',
|
||||
label: t('agents.eventProcessor.configTab'),
|
||||
icon: <Settings2 className="size-4" />,
|
||||
content: (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{!component ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.selectComponent')}
|
||||
</p>
|
||||
) : component.config_schema.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noSettings')}
|
||||
</p>
|
||||
) : (
|
||||
<fieldset disabled={!canManage || saving}>
|
||||
<DynamicFormComponent
|
||||
key={componentRef}
|
||||
itemConfigList={component.config_schema}
|
||||
initialValues={parameters}
|
||||
onSubmit={(values) =>
|
||||
setParameters(values as Record<string, unknown>)
|
||||
}
|
||||
onValidate={(fn) => {
|
||||
validate.current = fn;
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'logs',
|
||||
label: t('agents.eventProcessor.logsTab'),
|
||||
icon: <ScrollText className="size-4" />,
|
||||
content: logsContent,
|
||||
},
|
||||
],
|
||||
configIcon={
|
||||
<Puzzle className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
}
|
||||
monitoring={{
|
||||
label: t('pipelines.monitoring.title'),
|
||||
workbenchLabel: t('pipelines.monitoring.workbench'),
|
||||
content: logsContent,
|
||||
}}
|
||||
configContent={
|
||||
<div className="h-full overflow-y-auto">
|
||||
{!component ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.selectComponent')}
|
||||
</p>
|
||||
) : component.config_schema.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noSettings')}
|
||||
</p>
|
||||
) : (
|
||||
<fieldset disabled={!canManage || saving}>
|
||||
<DynamicFormComponent
|
||||
key={componentRef}
|
||||
itemConfigList={component.config_schema}
|
||||
initialValues={parameters}
|
||||
onSubmit={(values) =>
|
||||
setParameters(values as Record<string, unknown>)
|
||||
}
|
||||
onValidate={(fn) => {
|
||||
validate.current = fn;
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugDescription={t('agents.eventProcessor.debugNotice')}
|
||||
debugContent={
|
||||
@@ -568,7 +635,6 @@ export default function PluginProcessorDetailContent({
|
||||
hasUnsavedChanges={dirty}
|
||||
beforeRun={save}
|
||||
onRunFinished={() => {
|
||||
setActiveTab('logs');
|
||||
void refreshLatestRun();
|
||||
}}
|
||||
supportedEventPatterns={component.supported_event_patterns}
|
||||
|
||||
@@ -206,6 +206,22 @@ 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 +291,24 @@ 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) => {
|
||||
@@ -287,8 +319,7 @@ export default function AgentApiToolPicker({
|
||||
const expanded = expandedTool === tool.key;
|
||||
const parameterNames = Object.keys(
|
||||
(tool.parameters.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? {},
|
||||
Record<string, unknown> | undefined) ?? {},
|
||||
);
|
||||
return (
|
||||
<div key={tool.key} className="bg-background px-3 py-2.5">
|
||||
|
||||
@@ -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,17 +27,55 @@ 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,
|
||||
allowedKinds,
|
||||
embedded = false,
|
||||
}: {
|
||||
onCreated: (agentId: string) => void;
|
||||
allowedKinds?: AgentKind[];
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = useState<AgentKind>('agent');
|
||||
const [kind, setKind] = useState<AgentKind>(allowedKinds?.[0] ?? '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, zh_Hans: item.id, ...item.label },
|
||||
}))
|
||||
: (metadata?.runner_config?.stages
|
||||
.find((stage) => stage.name === 'runner')
|
||||
?.config.find((item) => item.name === 'id')?.options ?? []);
|
||||
const selectorLabel = t(
|
||||
kind === 'event_processor'
|
||||
? 'agents.selectProcessorPlugin'
|
||||
: 'agents.selectAgentRunner',
|
||||
);
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -45,11 +83,25 @@ 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 +111,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 +122,93 @@ 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' &&
|
||||
runnerStage?.config.some((field) =>
|
||||
[
|
||||
'llm-model-selector',
|
||||
'select-llm-model',
|
||||
'model-fallback-selector',
|
||||
].includes(field.type),
|
||||
)
|
||||
) {
|
||||
const { uuid } = await httpClient.getDefaultModel();
|
||||
if (uuid) {
|
||||
for (const field of runnerStage.config) {
|
||||
if (
|
||||
['llm-model-selector', 'select-llm-model'].includes(field.type) &&
|
||||
!parameters[field.name]
|
||||
)
|
||||
parameters[field.name] = uuid;
|
||||
if (
|
||||
field.type === 'model-fallback-selector' &&
|
||||
!parameters[field.name]?.primary
|
||||
) {
|
||||
parameters[field.name] = {
|
||||
...parameters[field.name],
|
||||
primary: uuid,
|
||||
fallbacks: parameters[field.name]?.fallbacks ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (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,
|
||||
@@ -111,14 +231,22 @@ export default function AgentCreateContent({
|
||||
];
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('agents.eventProcessor.createPageTitle')}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? 'order-last flex justify-end border-t pt-4 shrink-0'
|
||||
: 'flex items-center justify-between pb-4 shrink-0'
|
||||
}
|
||||
>
|
||||
{!embedded && (
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('agents.eventProcessor.createPageTitle')}
|
||||
</h1>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="agent-create-form"
|
||||
disabled={form.formState.isSubmitting}
|
||||
disabled={form.formState.isSubmitting || loading || !runner}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
@@ -139,9 +267,11 @@ export default function AgentCreateContent({
|
||||
>
|
||||
{t('agents.chooseType')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('agents.chooseTypeDescription')}
|
||||
</p>
|
||||
{!embedded && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('agents.chooseTypeDescription')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
@@ -154,30 +284,36 @@ export default function AgentCreateContent({
|
||||
spacing={3}
|
||||
className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-1"
|
||||
>
|
||||
{typeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
key={option.kind}
|
||||
value={option.kind}
|
||||
data-processor-kind={option.kind}
|
||||
aria-label={`${option.title} ${option.description}`}
|
||||
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 space-y-1.5">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
{typeOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!allowedKinds || allowedKinds.includes(option.kind),
|
||||
)
|
||||
.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div key={option.kind} className="relative w-full">
|
||||
<ToggleGroupItem
|
||||
value={option.kind}
|
||||
data-processor-kind={option.kind}
|
||||
aria-label={`${option.title} ${option.description}`}
|
||||
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 space-y-1.5">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</section>
|
||||
|
||||
@@ -185,7 +321,7 @@ export default function AgentCreateContent({
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
{t('agents.createBasicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -195,6 +331,58 @@ 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,
|
||||
zh_Hans: component.id,
|
||||
...component.label,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -229,27 +417,19 @@ 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>
|
||||
|
||||
<Card className="min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]">
|
||||
<Card
|
||||
className={
|
||||
embedded
|
||||
? 'min-h-[360px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[480px]'
|
||||
: 'min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]'
|
||||
}
|
||||
>
|
||||
<CardContent className="flex h-full items-center p-0">
|
||||
<ProcessorTypeDiagram kind={kind} />
|
||||
</CardContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty';
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -157,7 +158,6 @@ function AgentFormComponent(
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
const loadedHostToolPolicyRef = useRef<string[] | undefined>(undefined);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -197,18 +197,20 @@ function AgentFormComponent(
|
||||
|
||||
const savedSnapshotRef = useRef('');
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = (() => {
|
||||
if (!savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
})();
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||
const {
|
||||
isDirty: hasUnsavedChanges,
|
||||
dirtyRef: hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
} = useSnapshotDirty(form, savedSnapshotRef);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
const supportedEventPatterns = form.watch('supported_event_patterns');
|
||||
const supportedEventPatterns = useWatch({
|
||||
control: form.control,
|
||||
name: 'supported_event_patterns',
|
||||
});
|
||||
useEffect(() => {
|
||||
onSupportedEventPatternsChange?.(supportedEventPatterns);
|
||||
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
|
||||
@@ -279,6 +281,7 @@ function AgentFormComponent(
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
refreshDirty();
|
||||
initializedStagesRef.current.clear();
|
||||
setInitialDataLoaded(true);
|
||||
})
|
||||
@@ -290,7 +293,7 @@ function AgentFormComponent(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, form, t, loadAttempt]);
|
||||
}, [agentId, form, t, loadAttempt, refreshDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
|
||||
@@ -340,7 +343,11 @@ function AgentFormComponent(
|
||||
void loadPluginSystemStatus();
|
||||
}, [loadPluginSystemStatus]);
|
||||
|
||||
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||
const currentRunner = useWatch({
|
||||
control: form.control,
|
||||
name: 'runner',
|
||||
compute: (runner) => runner?.id,
|
||||
});
|
||||
const runnerOptions = useMemo(() => {
|
||||
const runnerStage = runnerConfigSchema?.stages.find(
|
||||
(stage) => stage.name === 'runner',
|
||||
@@ -358,24 +365,38 @@ function AgentFormComponent(
|
||||
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
||||
(stage) => stage.name === currentRunner,
|
||||
);
|
||||
const runnerConfigValues = form.watch('runner_config') as Record<
|
||||
const runnerConfigValues = form.getValues('runner_config') as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const activeRunnerValues = useMemo(
|
||||
() => runnerConfigValues?.[currentRunner] ?? {},
|
||||
[currentRunner, runnerConfigValues],
|
||||
);
|
||||
const missingRunnerFields = useMemo(
|
||||
() =>
|
||||
(activeRunnerStage?.config ?? []).filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(field, activeRunnerValues) &&
|
||||
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
|
||||
),
|
||||
[activeRunnerStage, activeRunnerValues],
|
||||
useWatch({
|
||||
control: form.control,
|
||||
compute: (values) =>
|
||||
(activeRunnerStage?.config ?? [])
|
||||
.filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(
|
||||
field,
|
||||
values.runner_config?.[currentRunner] ?? {},
|
||||
) &&
|
||||
isRequiredRunnerValueMissing(
|
||||
values.runner_config?.[currentRunner]?.[field.name],
|
||||
),
|
||||
)
|
||||
.map((field) => field.name)
|
||||
.join('\n'),
|
||||
});
|
||||
const activeRunnerValues = runnerConfigValues?.[currentRunner] ?? {};
|
||||
const missingRunnerFields = (activeRunnerStage?.config ?? []).filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(field, activeRunnerValues) &&
|
||||
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
|
||||
);
|
||||
const missingRunnerFieldNames = missingRunnerFields
|
||||
.map((field) => extractI18nObject(field.label))
|
||||
.join(', ');
|
||||
const primarySections: Array<{
|
||||
name: AgentConfigSection;
|
||||
label: string;
|
||||
@@ -448,9 +469,7 @@ function AgentFormComponent(
|
||||
return {
|
||||
label: t('agents.runnerConfigIncomplete'),
|
||||
description: t('agents.runnerConfigIncompleteDescription', {
|
||||
fields: missingRunnerFields
|
||||
.map((field) => extractI18nObject(field.label))
|
||||
.join(', '),
|
||||
fields: missingRunnerFieldNames,
|
||||
}),
|
||||
tone: 'warning',
|
||||
};
|
||||
@@ -471,7 +490,8 @@ function AgentFormComponent(
|
||||
pluginStatusLoading,
|
||||
pluginSystemStatus,
|
||||
runnerOptions.length,
|
||||
missingRunnerFields,
|
||||
missingRunnerFieldNames,
|
||||
missingRunnerFields.length,
|
||||
selectedRunnerOption,
|
||||
t,
|
||||
]);
|
||||
@@ -520,11 +540,12 @@ function AgentFormComponent(
|
||||
onRunnerStatusChange?.(runnerStatus);
|
||||
}, [onRunnerStatusChange, runnerStatus]);
|
||||
|
||||
function updateSnapshotIfInitial(stageKey: string) {
|
||||
function updateSnapshotIfInitial(stageKey: string, wasDirty: boolean) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
if (!hasUnsavedChanges) {
|
||||
if (!wasDirty) {
|
||||
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||
refreshDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,9 +555,10 @@ function AgentFormComponent(
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
const wasDirty = hasUnsavedChangesRef.current;
|
||||
if (formName === 'runner') {
|
||||
form.setValue('runner', values, { shouldDirty: true });
|
||||
updateSnapshotIfInitial(`runner.${stageName}`);
|
||||
updateSnapshotIfInitial(`runner.${stageName}`, wasDirty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -550,7 +572,7 @@ function AgentFormComponent(
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
updateSnapshotIfInitial(`runner_config.${stageName}`);
|
||||
updateSnapshotIfInitial(`runner_config.${stageName}`, wasDirty);
|
||||
}
|
||||
|
||||
function renderDynamicStage(stage: PipelineConfigStage) {
|
||||
@@ -558,8 +580,8 @@ function AgentFormComponent(
|
||||
if (!isRunnerSelector && stage.name !== currentRunner) return null;
|
||||
|
||||
const initialValues = isRunnerSelector
|
||||
? (form.watch('runner') as Record<string, unknown>) || {}
|
||||
: ((form.watch('runner_config') as Record<string, any>) || {})[
|
||||
? (form.getValues('runner') as Record<string, unknown>) || {}
|
||||
: ((form.getValues('runner_config') as Record<string, any>) || {})[
|
||||
stage.name
|
||||
] || {};
|
||||
|
||||
@@ -640,6 +662,7 @@ function AgentFormComponent(
|
||||
loadedHostToolPolicyRef.current = [...values.allowed_tools];
|
||||
}
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
refreshDirty();
|
||||
onFinish(agent);
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
return true;
|
||||
@@ -655,7 +678,14 @@ function AgentFormComponent(
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
},
|
||||
[agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t],
|
||||
[
|
||||
agentId,
|
||||
hostToolCatalogAvailable,
|
||||
onFinish,
|
||||
onSavingChange,
|
||||
refreshDirty,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
@@ -682,6 +712,7 @@ function AgentFormComponent(
|
||||
emoji: values.emoji || '🤖',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
refreshDirty();
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
@@ -693,7 +724,14 @@ function AgentFormComponent(
|
||||
return (await saveValues(form.getValues())) ?? false;
|
||||
},
|
||||
}),
|
||||
[form, initialDataLoaded, loadFailed, saveValues],
|
||||
[
|
||||
form,
|
||||
initialDataLoaded,
|
||||
loadFailed,
|
||||
saveValues,
|
||||
hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
],
|
||||
);
|
||||
|
||||
if (loadFailed)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, ChevronRight, RefreshCw, Wrench } from 'lucide-react';
|
||||
import { ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import AgentRunTimeline, { isReplyStep, isMockStep } from './AgentRunTimeline';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type {
|
||||
AgentPlatformTool,
|
||||
@@ -13,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
@@ -206,7 +208,17 @@ export default function AgentMonitoringTab({
|
||||
const labels = Object.fromEntries(
|
||||
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
|
||||
);
|
||||
const steps = executionSteps(events);
|
||||
const steps = executionSteps(events).filter(
|
||||
(step) => step.kind === 'tool' || step.text.trim() || step.reasoning.trim(),
|
||||
);
|
||||
const toolSteps = steps.filter((step) => step.kind === 'tool');
|
||||
const sentCount = toolSteps.filter(
|
||||
(step) =>
|
||||
isReplyStep(step) && !isMockStep(step) && step.status === 'completed',
|
||||
).length;
|
||||
const sendFailed = toolSteps.some(
|
||||
(step) => isReplyStep(step) && step.status === 'failed',
|
||||
);
|
||||
const duration = selected ? processorRunDuration(selected) : null;
|
||||
const failureReason =
|
||||
selected?.status_reason ||
|
||||
@@ -257,11 +269,13 @@ export default function AgentMonitoringTab({
|
||||
</Button>
|
||||
</div>
|
||||
{listError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{t('monitoring.loadError')}</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState
|
||||
compact
|
||||
title={t('monitoring.loadError')}
|
||||
onRetry={refresh}
|
||||
/>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(16rem,0.8fr)_minmax(0,2fr)]">
|
||||
<div className="grid min-h-0 flex-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<section
|
||||
className="flex min-h-0 min-w-0 flex-col gap-2"
|
||||
aria-label={t('agents.eventProcessor.runs')}
|
||||
@@ -279,13 +293,14 @@ export default function AgentMonitoringTab({
|
||||
variant="ghost"
|
||||
aria-pressed={selectedId === run.run_id}
|
||||
onClick={() => setSelectedId(run.run_id)}
|
||||
className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b p-3 text-left font-normal last:border-b-0 aria-pressed:bg-accent"
|
||||
className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b border-l-2 border-l-transparent p-4 text-left font-normal last:border-b-0 aria-pressed:border-l-primary aria-pressed:bg-primary/5"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{eventPatternLabel(run.metadata.event_type ?? '', t)}
|
||||
</span>
|
||||
<Badge
|
||||
status={run.status}
|
||||
variant={
|
||||
failedStatuses.has(run.status) ? 'destructive' : 'outline'
|
||||
}
|
||||
@@ -303,7 +318,7 @@ export default function AgentMonitoringTab({
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</time>
|
||||
{debugRun(run) && (
|
||||
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
|
||||
<Badge status="debug">{t('agents.debugTab')}</Badge>
|
||||
)}
|
||||
{processorRunDuration(run) !== null && (
|
||||
<span>{formatRunDuration(processorRunDuration(run)!)}</span>
|
||||
@@ -343,6 +358,7 @@ export default function AgentMonitoringTab({
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</h2>
|
||||
<Badge
|
||||
status={selected.status}
|
||||
variant={
|
||||
failedStatuses.has(selected.status)
|
||||
? 'destructive'
|
||||
@@ -352,7 +368,7 @@ export default function AgentMonitoringTab({
|
||||
{stateLabel(selected)}
|
||||
</Badge>
|
||||
{debugRun(selected) && (
|
||||
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
|
||||
<Badge status="debug">{t('agents.debugTab')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
@@ -373,9 +389,37 @@ export default function AgentMonitoringTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{selected.runner_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 divide-y rounded-lg border bg-muted/20 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||||
<div className="space-y-1 p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.monitoring.runStatus')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{stateLabel(selected)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.monitoring.toolCalls')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{toolSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agents.monitoring.delivery')}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm font-semibold ${sendFailed ? 'text-destructive' : ''}`}
|
||||
>
|
||||
{t(
|
||||
`agents.monitoring.${sendFailed ? 'sendFailed' : sentCount ? 'sendCount' : 'noSend'}`,
|
||||
{ count: sentCount },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{failedStatuses.has(selected.status) && failureReason && (
|
||||
<Alert variant="destructive">
|
||||
@@ -418,72 +462,22 @@ export default function AgentMonitoringTab({
|
||||
</Card>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.monitoring.execution')}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{steps.length}
|
||||
</span>
|
||||
</h3>
|
||||
{steps.map((step, index) => (
|
||||
<Card key={index} className="gap-2 py-3">
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
{step.kind === 'tool' ? (
|
||||
<>
|
||||
<Wrench className="size-4 shrink-0" />
|
||||
<span className="break-all">
|
||||
{labels[step.name] || step.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
step.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`agents.debugTool${step.status === 'running' ? (activeStatuses.has(selected.status) || eventCursor !== null ? 'Running' : 'Interrupted') : step.result && typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true ? (step.status === 'failed' ? 'MockFailed' : 'Simulated') : step.status === 'failed' ? 'Failed' : 'Completed'}`,
|
||||
)}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Brain className="size-4" />
|
||||
{t('agents.debugTextOutput')}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-3">
|
||||
{step.kind === 'message' ? (
|
||||
<>
|
||||
{step.text && (
|
||||
<p className={textClass}>{step.text}</p>
|
||||
)}
|
||||
{step.reasoning && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugReasoning')}
|
||||
value={step.reasoning}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step.error && (
|
||||
<p className={`${textClass} text-destructive`}>
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolArguments')}
|
||||
value={step.parameters}
|
||||
/>
|
||||
{step.result !== undefined && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolResult')}
|
||||
value={step.result}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{!steps.length && (
|
||||
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||
{t('agents.monitoring.noSteps')}
|
||||
</p>
|
||||
)}
|
||||
<AgentRunTimeline
|
||||
steps={steps}
|
||||
labels={labels}
|
||||
active={
|
||||
activeStatuses.has(selected.status) || eventCursor !== null
|
||||
}
|
||||
/>
|
||||
{events
|
||||
.filter((event) => event.type === 'processor.log')
|
||||
.map((event) => (
|
||||
@@ -520,6 +514,7 @@ export default function AgentMonitoringTab({
|
||||
>
|
||||
{json({
|
||||
run_id: selected.run_id,
|
||||
runner_id: selected.runner_id,
|
||||
usage: selected.usage,
|
||||
events,
|
||||
})}
|
||||
@@ -530,17 +525,10 @@ export default function AgentMonitoringTab({
|
||||
)
|
||||
)}
|
||||
{traceError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('monitoring.loadError')}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setTraceRevision((value) => value + 1)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LoadErrorState
|
||||
title={t('monitoring.loadError')}
|
||||
onRetry={() => setTraceRevision((value) => value + 1)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MessageSquare, Send, Wrench } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProcessorPayload } from './PluginProcessorTrace';
|
||||
import type { ExecutionStep } from './debug-execution';
|
||||
import { toolDisplay, toolSummary } from './tool-summary';
|
||||
|
||||
export function isReplyStep(step: ExecutionStep) {
|
||||
return step.kind === 'tool' && toolDisplay(step.name)?.kind === 'reply';
|
||||
}
|
||||
|
||||
export function isMockStep(step: ExecutionStep) {
|
||||
return (
|
||||
step.kind === 'tool' &&
|
||||
!!step.result &&
|
||||
typeof step.result === 'object' &&
|
||||
'mock' in step.result &&
|
||||
step.result.mock === true
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentRunTimeline({
|
||||
steps,
|
||||
labels,
|
||||
active,
|
||||
}: {
|
||||
steps: ExecutionStep[];
|
||||
labels: Record<string, string>;
|
||||
active: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ol className="space-y-0">
|
||||
{steps.map((step, index) => {
|
||||
const reply = isReplyStep(step);
|
||||
const mock = isMockStep(step);
|
||||
const params =
|
||||
step.kind === 'tool' &&
|
||||
step.parameters &&
|
||||
typeof step.parameters === 'object'
|
||||
? (step.parameters as Record<string, unknown>)
|
||||
: {};
|
||||
const summary = toolSummary(
|
||||
step.kind === 'tool' ? step.name : '',
|
||||
params,
|
||||
);
|
||||
const replyText = reply ? summary.text : '';
|
||||
const actionText = reply ? '' : summary.text;
|
||||
const Icon =
|
||||
step.kind === 'message' ? MessageSquare : reply ? Send : Wrench;
|
||||
const status = step.kind === 'tool' ? step.status : null;
|
||||
const statusKey =
|
||||
status === 'running'
|
||||
? active
|
||||
? 'Running'
|
||||
: 'Interrupted'
|
||||
: mock
|
||||
? status === 'failed'
|
||||
? 'MockFailed'
|
||||
: 'Simulated'
|
||||
: status === 'failed'
|
||||
? 'Failed'
|
||||
: 'Completed';
|
||||
return (
|
||||
<li
|
||||
key={step.kind === 'tool' ? step.id : `message-${index}`}
|
||||
className="relative flex min-w-0 gap-3 pb-5 last:pb-0"
|
||||
>
|
||||
{index < steps.length - 1 && (
|
||||
<span
|
||||
className="absolute bottom-0 left-4 top-8 w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 rounded-lg border bg-background">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/30 px-4 py-3">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<h4 className="min-w-0 flex-1 break-words text-sm font-medium">
|
||||
{step.kind === 'message'
|
||||
? t('agents.debugTextOutput')
|
||||
: labels[step.name] || step.name}
|
||||
</h4>
|
||||
{status && (
|
||||
<Badge
|
||||
status={
|
||||
status === 'failed'
|
||||
? 'failed'
|
||||
: mock
|
||||
? 'simulated'
|
||||
: status === 'running' && !active
|
||||
? 'cancelled'
|
||||
: status
|
||||
}
|
||||
>
|
||||
{t(`agents.debugTool${statusKey}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 p-4 text-sm">
|
||||
{step.kind === 'message' ? (
|
||||
<>
|
||||
{step.text && (
|
||||
<p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">
|
||||
{step.text}
|
||||
</p>
|
||||
)}
|
||||
{step.reasoning && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugReasoning')}
|
||||
value={step.reasoning}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{replyText && (
|
||||
<p className="whitespace-pre-wrap break-words leading-7 [overflow-wrap:anywhere]">
|
||||
{replyText}
|
||||
</p>
|
||||
)}
|
||||
{!reply && actionText && (
|
||||
<p className="whitespace-pre-wrap break-words rounded-md bg-muted/50 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
|
||||
{actionText}
|
||||
</p>
|
||||
)}
|
||||
{summary.recipient && (
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{t('agents.monitoring.recipient')}: {summary.recipient}
|
||||
</p>
|
||||
)}
|
||||
{step.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="whitespace-pre-wrap break-words text-destructive"
|
||||
>
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
{status === 'completed' && (mock || !reply) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
`agents.monitoring.${mock ? 'simulatedAction' : 'actionCompleted'}`,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{(step.parameters != null || step.result != null) && (
|
||||
<div className="rounded-md border border-dashed text-muted-foreground">
|
||||
{step.parameters != null && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolArguments')}
|
||||
value={step.parameters}
|
||||
/>
|
||||
)}
|
||||
{step.result != null && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolResult')}
|
||||
value={step.result}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +1,50 @@
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { RunnerDescriptor } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function ProcessorComponentContent({
|
||||
component,
|
||||
option = false,
|
||||
}: {
|
||||
component: RunnerDescriptor;
|
||||
option?: boolean;
|
||||
}) {
|
||||
const label = extractI18nObject({
|
||||
en_US: component.id,
|
||||
zh_Hans: component.id,
|
||||
...component.label,
|
||||
});
|
||||
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
option
|
||||
? 'grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left'
|
||||
: 'flex min-w-0 items-center gap-2'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
component.plugin_author,
|
||||
component.plugin_name,
|
||||
)}
|
||||
alt=""
|
||||
className={
|
||||
option
|
||||
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
|
||||
: 'size-5 shrink-0 rounded object-cover'
|
||||
}
|
||||
/>
|
||||
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
|
||||
{label}
|
||||
</span>
|
||||
{option && (
|
||||
<span
|
||||
className="truncate text-xs leading-4 text-muted-foreground"
|
||||
title={pluginId}
|
||||
>
|
||||
{pluginId}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
import RunnerSelect from './RunnerSelect';
|
||||
|
||||
export default function PluginProcessorSettings({
|
||||
components,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
installScope,
|
||||
onInstalled,
|
||||
}: {
|
||||
components: RunnerDescriptor[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
installScope: string;
|
||||
onInstalled: (component: RunnerDescriptor) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selected = components.find((item) => item.id === value);
|
||||
const options = components.map((component) => ({
|
||||
name: component.id,
|
||||
label: { en_US: component.id, zh_Hans: component.id, ...component.label },
|
||||
}));
|
||||
if (value && !components.some((component) => component.id === value))
|
||||
options.push({
|
||||
name: value,
|
||||
label: {
|
||||
en_US: t('agents.eventProcessor.unavailable'),
|
||||
zh_Hans: t('agents.eventProcessor.unavailable'),
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="event-processor-component">
|
||||
{t('agents.eventProcessor.component')}
|
||||
</Label>
|
||||
<Select value={value} disabled={disabled} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
id="event-processor-component"
|
||||
className="w-[13.2rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
|
||||
>
|
||||
{selected ? (
|
||||
<ProcessorComponentContent component={selected} />
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
|
||||
<SelectValue
|
||||
placeholder={t('agents.eventProcessor.selectComponent')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||
{value && !selected && (
|
||||
<SelectItem value={value}>
|
||||
{t('agents.eventProcessor.unavailable')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{components.map((component) => (
|
||||
<SelectItem
|
||||
key={component.id}
|
||||
value={component.id}
|
||||
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
|
||||
>
|
||||
<ProcessorComponentContent component={component} option />
|
||||
</SelectItem>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<div className="p-2 text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noComponents')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/add-extension?type=plugin&component=Runner&runner_usage=event">
|
||||
{t('agents.eventProcessor.installPlugin')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
id="event-processor-component"
|
||||
className="w-[20rem] max-w-[calc(100vw-8rem)]"
|
||||
>
|
||||
<RunnerSelect
|
||||
usage="event"
|
||||
options={options}
|
||||
value={value}
|
||||
label={t('agents.eventProcessor.component')}
|
||||
disabled={disabled}
|
||||
installScope={installScope}
|
||||
onValueChange={onChange}
|
||||
onEventProcessorInstalled={onInstalled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ChevronRight, ScrollText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -47,38 +46,50 @@ export default function PluginProcessorTrace({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
{events.map((event, index) =>
|
||||
event.type === 'processor.log' ? (
|
||||
<Alert
|
||||
<div
|
||||
key={event.sequence ?? index}
|
||||
variant={event.data.level === 'error' ? 'destructive' : 'default'}
|
||||
className="min-w-0 overflow-hidden rounded-lg border bg-background"
|
||||
>
|
||||
<AlertDescription className="flex min-w-0 items-start gap-2">
|
||||
<Badge variant="outline">{String(event.data.level)}</Badge>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{String(event.data.text)}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-3">
|
||||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{t('agents.eventProcessor.logsTab')}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Badge status={String(event.data.level)}>
|
||||
{String(event.data.level)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
className={`min-w-0 whitespace-pre-wrap break-words p-4 text-sm leading-7 [overflow-wrap:anywhere] ${event.data.level === 'error' ? 'text-destructive' : ''}`}
|
||||
>
|
||||
{String(event.data.text)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProcessorPayload
|
||||
<div
|
||||
key={event.sequence ?? index}
|
||||
title={
|
||||
<>
|
||||
{t(
|
||||
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
|
||||
{ defaultValue: event.type },
|
||||
)}
|
||||
{typeof event.data.tool_name === 'string' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{toolLabels[event.data.tool_name] || event.data.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
value={event.data}
|
||||
/>
|
||||
className="rounded-lg border bg-background p-3"
|
||||
>
|
||||
<ProcessorPayload
|
||||
title={
|
||||
<>
|
||||
{t(
|
||||
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
|
||||
{ defaultValue: event.type },
|
||||
)}
|
||||
{typeof event.data.tool_name === 'string' && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{toolLabels[event.data.tool_name] || event.data.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
value={event.data}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,18 +15,22 @@ export default function ProcessorRunList({
|
||||
selectedId,
|
||||
onSelect,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
runs: ProcessorRun[];
|
||||
selectedId?: string;
|
||||
onSelect: (run: ProcessorRun) => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('agents.eventProcessor.runs')}
|
||||
className="max-h-56 shrink-0 overflow-y-auto rounded-md border"
|
||||
className={
|
||||
className ?? 'max-h-56 shrink-0 overflow-y-auto rounded-md border'
|
||||
}
|
||||
>
|
||||
{runs.map((run) => {
|
||||
const duration = processorRunDuration(run);
|
||||
@@ -41,7 +45,7 @@ export default function ProcessorRunList({
|
||||
onClick={() => onSelect(run)}
|
||||
aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`}
|
||||
title={event}
|
||||
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent"
|
||||
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b border-l-2 border-l-transparent p-4 text-left text-sm font-normal last:border-b-0 aria-pressed:border-l-primary aria-pressed:bg-primary/5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block truncate font-medium">{label}</span>
|
||||
@@ -54,6 +58,7 @@ export default function ProcessorRunList({
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<Badge
|
||||
status={run.status}
|
||||
variant={
|
||||
run.status === 'failed' || run.status === 'timeout'
|
||||
? 'destructive'
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Bot, ExternalLink, Loader2, Store } from 'lucide-react';
|
||||
import { Bot, Puzzle, ExternalLink, Loader2, Store } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getCloudServiceClientSync, httpClient } from '@/app/infra/http';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
|
||||
import type { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import type { RunnerDescriptor } from '@/app/infra/entities/api';
|
||||
import {
|
||||
RunnerMarketplaceError,
|
||||
getErrorMessage,
|
||||
installMarketplaceRunner,
|
||||
installMarketplaceEventProcessor,
|
||||
resumePendingEventProcessorInstall,
|
||||
loadRunnerCatalog,
|
||||
marketplacePluginId,
|
||||
runnerPluginPrefix,
|
||||
@@ -51,21 +55,25 @@ function installErrorMessage(
|
||||
return getErrorMessage(error) || t('wizard.aiEngine.installFailed');
|
||||
}
|
||||
|
||||
function installedRunnerIconURL(option: IDynamicFormItemOption) {
|
||||
return option.name.startsWith('plugin:')
|
||||
? (() => {
|
||||
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
return match ? httpClient.getPluginIconURL(match[1], match[2]) : null;
|
||||
})()
|
||||
: null;
|
||||
function pluginOptionParts(option: IDynamicFormItemOption): {
|
||||
author: string;
|
||||
name: string;
|
||||
} | null {
|
||||
if (!option.name.startsWith('plugin:')) return null;
|
||||
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
return match ? { author: match[1], name: match[2] } : null;
|
||||
}
|
||||
|
||||
function InstalledRunnerContent({
|
||||
option,
|
||||
eventProcessor = false,
|
||||
}: {
|
||||
option: IDynamicFormItemOption;
|
||||
eventProcessor?: boolean;
|
||||
}) {
|
||||
const iconURL = installedRunnerIconURL(option);
|
||||
const Icon = eventProcessor ? Puzzle : Bot;
|
||||
const parts = pluginOptionParts(option);
|
||||
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -76,7 +84,7 @@ function InstalledRunnerContent({
|
||||
className="size-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{extractI18nObject(option.label)}</span>
|
||||
</span>
|
||||
@@ -86,11 +94,15 @@ function InstalledRunnerContent({
|
||||
function InstalledRunnerOptionContent({
|
||||
option,
|
||||
description,
|
||||
eventProcessor = false,
|
||||
}: {
|
||||
option: IDynamicFormItemOption;
|
||||
description: string;
|
||||
eventProcessor?: boolean;
|
||||
}) {
|
||||
const iconURL = installedRunnerIconURL(option);
|
||||
const Icon = eventProcessor ? Puzzle : Bot;
|
||||
const parts = pluginOptionParts(option);
|
||||
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
|
||||
|
||||
return (
|
||||
<span className="grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left">
|
||||
@@ -101,7 +113,7 @@ function InstalledRunnerOptionContent({
|
||||
className="row-span-2 size-7 shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="row-span-2 size-5 justify-self-center text-muted-foreground" />
|
||||
<Icon className="row-span-2 size-5 justify-self-center text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-medium leading-5">
|
||||
{extractI18nObject(option.label)}
|
||||
@@ -199,13 +211,19 @@ export default function RunnerSelect({
|
||||
onValueChange,
|
||||
installScope,
|
||||
onInstalled,
|
||||
usage = 'agent',
|
||||
disabled = false,
|
||||
onEventProcessorInstalled,
|
||||
}: {
|
||||
options: IDynamicFormItemOption[];
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
installScope: string;
|
||||
onInstalled: (installed: InstalledRunner) => void;
|
||||
onInstalled?: (installed: InstalledRunner) => void;
|
||||
usage?: 'agent' | 'event';
|
||||
disabled?: boolean;
|
||||
onEventProcessorInstalled?: (component: RunnerDescriptor) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { addTask, tasks } = usePluginInstallTasks();
|
||||
@@ -227,7 +245,7 @@ export default function RunnerSelect({
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(false);
|
||||
try {
|
||||
const catalog = await loadRunnerCatalog('agent');
|
||||
const catalog = await loadRunnerCatalog(usage);
|
||||
setMarketplaceRunners(catalog.marketplaceRunners);
|
||||
setInstalledPluginIds(catalog.installedPluginIds);
|
||||
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
|
||||
@@ -237,7 +255,37 @@ export default function RunnerSelect({
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [usage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
usage !== 'event' ||
|
||||
disabled ||
|
||||
!readPendingRunnerInstall(installScope)
|
||||
)
|
||||
return;
|
||||
let cancelled = false;
|
||||
void resumePendingEventProcessorInstall(installScope)
|
||||
.then((component) => {
|
||||
if (!cancelled && component) {
|
||||
onEventProcessorInstalled?.(component);
|
||||
void loadCatalog();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setInstallError(installErrorMessage(error, t));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
installScope,
|
||||
usage,
|
||||
disabled,
|
||||
onEventProcessorInstalled,
|
||||
loadCatalog,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
@@ -286,22 +334,28 @@ export default function RunnerSelect({
|
||||
const handleInstall = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
const pluginId = marketplacePluginId(plugin);
|
||||
if (pendingInstall || installingPluginId) return;
|
||||
if (disabled || pendingInstall || installingPluginId) return;
|
||||
|
||||
setInstallingPluginId(pluginId);
|
||||
setInstallError(null);
|
||||
try {
|
||||
const installed = await installMarketplaceRunner(plugin, {
|
||||
const installOptions = {
|
||||
scope: installScope,
|
||||
onTaskCreated: (taskId) =>
|
||||
onTaskCreated: (taskId: number) =>
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: marketplacePluginId(plugin),
|
||||
source: 'marketplace',
|
||||
extensionType: 'plugin',
|
||||
}),
|
||||
});
|
||||
onInstalled(installed);
|
||||
};
|
||||
if (usage === 'event') {
|
||||
onEventProcessorInstalled?.(
|
||||
await installMarketplaceEventProcessor(plugin, installOptions),
|
||||
);
|
||||
} else {
|
||||
onInstalled?.(await installMarketplaceRunner(plugin, installOptions));
|
||||
}
|
||||
await loadCatalog();
|
||||
toast.success(
|
||||
t('agents.runnerInstallSuccess', {
|
||||
@@ -326,12 +380,16 @@ export default function RunnerSelect({
|
||||
onInstalled,
|
||||
pendingInstall,
|
||||
t,
|
||||
usage,
|
||||
disabled,
|
||||
onEventProcessorInstalled,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[22rem] space-y-2">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
onOpenChange={(open) => {
|
||||
@@ -343,17 +401,34 @@ export default function RunnerSelect({
|
||||
className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"
|
||||
>
|
||||
{selectedOption ? (
|
||||
<InstalledRunnerContent option={selectedOption} />
|
||||
<InstalledRunnerContent
|
||||
option={selectedOption}
|
||||
eventProcessor={usage === 'event'}
|
||||
/>
|
||||
) : (
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
usage === 'event'
|
||||
? 'agents.eventProcessor.selectComponent'
|
||||
: 'common.select',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[70] max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||
<SelectGroup>
|
||||
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Bot className="size-3.5" />
|
||||
{t('agents.installedRunners')}
|
||||
{usage === 'event' ? (
|
||||
<Puzzle className="size-3.5" />
|
||||
) : (
|
||||
<Bot className="size-3.5" />
|
||||
)}
|
||||
{t(
|
||||
usage === 'event'
|
||||
? 'plugins.installed'
|
||||
: 'agents.installedRunners',
|
||||
)}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{options.length > 0 ? (
|
||||
@@ -371,6 +446,7 @@ export default function RunnerSelect({
|
||||
>
|
||||
<InstalledRunnerOptionContent
|
||||
option={option}
|
||||
eventProcessor={usage === 'event'}
|
||||
description={description}
|
||||
/>
|
||||
</SelectItem>
|
||||
@@ -378,7 +454,11 @@ export default function RunnerSelect({
|
||||
})
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t('agents.noInstalledRunners')}
|
||||
{t(
|
||||
usage === 'event'
|
||||
? 'agents.eventProcessor.noComponents'
|
||||
: 'agents.noInstalledRunners',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectGroup>
|
||||
@@ -389,10 +469,14 @@ export default function RunnerSelect({
|
||||
<SelectLabel className="flex items-center justify-between gap-2 px-2 py-1 text-[11px] font-medium">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Store className="size-3.5" />
|
||||
{t('agents.marketplaceRunners')}
|
||||
{t(
|
||||
usage === 'event'
|
||||
? 'plugins.marketplace'
|
||||
: 'agents.marketplaceRunners',
|
||||
)}
|
||||
</span>
|
||||
<a
|
||||
href="https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent"
|
||||
href={`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=${usage}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
|
||||
@@ -437,6 +521,22 @@ export default function RunnerSelect({
|
||||
</div>
|
||||
)}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<a
|
||||
href="https://langbot.app/docs/zh/plugin/dev/components/runner"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{t(
|
||||
usage === 'event'
|
||||
? 'bots.routingHelp.docs'
|
||||
: 'bots.routingHelp.runnerDocs',
|
||||
)}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -90,6 +90,90 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
|
||||
messageField: 'text',
|
||||
},
|
||||
'message.deleted': deletion,
|
||||
'dingtalk.card_action': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
space_id: 'dtv1.card//IM_GROUP.debug-group',
|
||||
card_instance_id: 'debug-card',
|
||||
action: 'confirm',
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
'wecombot.enter_chat': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
chat_id: 'debug-user',
|
||||
chat_type: 'private',
|
||||
message_id: 'debug-entry',
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
'wecombot.template_card_event': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
chat_id: 'debug-user',
|
||||
chat_type: 'private',
|
||||
message_id: 'debug-card-click',
|
||||
task_id: 'debug-card',
|
||||
event_key: 'confirm',
|
||||
card_type: 'button_interaction',
|
||||
selected_items: [],
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
'wecomcs.enter_session': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
open_kfid: 'debug-kf',
|
||||
external_userid: 'debug-user',
|
||||
chat_id: 'debug-user|debug-kf',
|
||||
scene: 'debug',
|
||||
scene_param: '',
|
||||
welcome_code: '',
|
||||
wechat_channels: {},
|
||||
},
|
||||
},
|
||||
'wecomcs.msg_send_fail': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
open_kfid: 'debug-kf',
|
||||
external_userid: 'debug-user',
|
||||
chat_id: 'debug-user|debug-kf',
|
||||
fail_msgid: 'debug-message',
|
||||
fail_type: 4,
|
||||
},
|
||||
},
|
||||
'wecomcs.servicer_status_change': {
|
||||
fields: [],
|
||||
defaults: {
|
||||
open_kfid: 'debug-kf',
|
||||
servicer_userid: 'debug-servicer',
|
||||
status: 2,
|
||||
stop_type: 0,
|
||||
},
|
||||
},
|
||||
'wecomcs.session_status_change': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
open_kfid: 'debug-kf',
|
||||
external_userid: 'debug-user',
|
||||
chat_id: 'debug-user|debug-kf',
|
||||
change_type: 2,
|
||||
old_servicer_userid: 'debug-servicer-1',
|
||||
new_servicer_userid: 'debug-servicer-2',
|
||||
msg_code: '',
|
||||
},
|
||||
},
|
||||
'wecomcs.reject_customer_msg_switch_change': {
|
||||
fields: [user],
|
||||
defaults: {
|
||||
open_kfid: 'debug-kf',
|
||||
external_userid: 'debug-user',
|
||||
chat_id: 'debug-user|debug-kf',
|
||||
servicer_userid: 'debug-servicer',
|
||||
reject_switch: 1,
|
||||
},
|
||||
},
|
||||
'message.recalled': deletion,
|
||||
'message.reaction': {
|
||||
fields: [
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
type ToolDisplay = {
|
||||
fields: readonly string[];
|
||||
kind?: 'reply';
|
||||
recipient?: string;
|
||||
};
|
||||
|
||||
// Explicit display declarations for built-in tools. Unknown tools have no
|
||||
// summary: their parameters remain available in the payload disclosure.
|
||||
const TOOL_DISPLAYS: Readonly<Record<string, ToolDisplay>> = {
|
||||
exec: { fields: ['command'] },
|
||||
read: { fields: ['path'] },
|
||||
write: { fields: ['path'] },
|
||||
edit: { fields: ['path'] },
|
||||
glob: { fields: ['pattern', 'path'] },
|
||||
grep: { fields: ['pattern', 'path'] },
|
||||
event_reply: { fields: ['text'], kind: 'reply' },
|
||||
platform_send_message: {
|
||||
fields: ['text'],
|
||||
kind: 'reply',
|
||||
recipient: 'target_id',
|
||||
},
|
||||
event_delete_message: { fields: [] },
|
||||
event_get_actor: { fields: [] },
|
||||
event_get_group: { fields: [] },
|
||||
event_get_group_member: { fields: [] },
|
||||
event_mute_member: { fields: ['duration'] },
|
||||
event_unmute_member: { fields: [] },
|
||||
event_kick_member: { fields: [] },
|
||||
event_respond_friend_request: { fields: ['approve', 'remark'] },
|
||||
event_respond_group_invite: { fields: ['approve'] },
|
||||
platform_get_message: { fields: ['chat_id', 'message_id'] },
|
||||
platform_delete_message: { fields: ['chat_id', 'message_id'] },
|
||||
platform_get_group_info: { fields: ['group_id'] },
|
||||
platform_get_group_list: { fields: [] },
|
||||
platform_get_group_member_list: { fields: ['group_id'] },
|
||||
platform_get_group_member_info: { fields: ['group_id', 'user_id'] },
|
||||
platform_set_group_name: { fields: ['group_id', 'name'] },
|
||||
platform_mute_member: { fields: ['group_id', 'user_id', 'duration'] },
|
||||
platform_unmute_member: { fields: ['group_id', 'user_id'] },
|
||||
platform_kick_member: { fields: ['group_id', 'user_id'] },
|
||||
platform_leave_group: { fields: ['group_id'] },
|
||||
platform_get_user_info: { fields: ['user_id'] },
|
||||
platform_get_friend_list: { fields: [] },
|
||||
};
|
||||
|
||||
export function toolDisplay(name: string): ToolDisplay | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(TOOL_DISPLAYS, name)
|
||||
? TOOL_DISPLAYS[name]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toolSummary(name: string, parameters: Record<string, unknown>) {
|
||||
const display = toolDisplay(name);
|
||||
const fields = (display?.fields ?? []).flatMap((field) => {
|
||||
const value = parameters[field];
|
||||
if (!['string', 'number', 'boolean'].includes(typeof value) || value === '')
|
||||
return [];
|
||||
return [{ field, text: String(value) }];
|
||||
});
|
||||
return {
|
||||
text: fields
|
||||
.map(({ field, text }) =>
|
||||
display?.kind === 'reply' || display?.fields.length === 1
|
||||
? text
|
||||
: `${field}: ${text}`,
|
||||
)
|
||||
.join('\n'),
|
||||
recipient:
|
||||
display?.recipient && typeof parameters[display.recipient] === 'string'
|
||||
? String(parameters[display.recipient])
|
||||
: '',
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PluginV4,
|
||||
type RunnerUsage,
|
||||
} from '@/app/infra/entities/plugin';
|
||||
import type { AsyncTask } from '@/app/infra/entities/api';
|
||||
import type { AsyncTask, RunnerDescriptor } from '@/app/infra/entities/api';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export const RUNNER_COMPONENT_FILTER = 'Runner';
|
||||
@@ -20,9 +20,7 @@ const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-runner-install';
|
||||
const RUNNER_INSTALL_INTENT_EVENT = 'langbot-runner-install-change';
|
||||
|
||||
export type RunnerMarketplaceErrorCode =
|
||||
| 'version-unavailable'
|
||||
| 'install-timeout'
|
||||
| 'registration-timeout';
|
||||
'version-unavailable' | 'install-timeout' | 'registration-timeout';
|
||||
|
||||
export class RunnerMarketplaceError extends Error {
|
||||
constructor(public readonly code: RunnerMarketplaceErrorCode) {
|
||||
@@ -232,6 +230,14 @@ export async function installMarketplaceRunner(
|
||||
plugin: PluginV4,
|
||||
options: InstallRunnerOptions,
|
||||
): Promise<InstalledRunner> {
|
||||
const pending = await startRunnerInstall(plugin, options);
|
||||
return finishRunnerInstall(pending, options.onProgress);
|
||||
}
|
||||
|
||||
async function startRunnerInstall(
|
||||
plugin: PluginV4,
|
||||
options: InstallRunnerOptions,
|
||||
): Promise<PendingRunnerInstall> {
|
||||
if (!plugin.latest_version) {
|
||||
throw new RunnerMarketplaceError('version-unavailable');
|
||||
}
|
||||
@@ -252,7 +258,7 @@ export async function installMarketplaceRunner(
|
||||
};
|
||||
writePendingRunnerInstall(pending);
|
||||
options.onTaskCreated?.(taskId);
|
||||
return finishRunnerInstall(pending, options.onProgress);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function extractPluginLabel(plugin: PluginV4) {
|
||||
@@ -271,6 +277,14 @@ async function finishRunnerInstall(
|
||||
pending: PendingRunnerInstall,
|
||||
onProgress?: (task: AsyncTask) => void,
|
||||
): Promise<InstalledRunner> {
|
||||
await waitForRunnerInstall(pending, onProgress);
|
||||
return waitForRunnerRegistration(pending);
|
||||
}
|
||||
|
||||
async function waitForRunnerInstall(
|
||||
pending: PendingRunnerInstall,
|
||||
onProgress?: (task: AsyncTask) => void,
|
||||
) {
|
||||
// A refreshed page receives a fresh observation window. The backend task is
|
||||
// authoritative; `startedAt` is display metadata, not a reason to abandon a
|
||||
// still-running installation immediately after recovery.
|
||||
@@ -293,7 +307,11 @@ async function finishRunnerInstall(
|
||||
if (!installCompleted) {
|
||||
throw new RunnerMarketplaceError('install-timeout');
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunnerRegistration(
|
||||
pending: PendingRunnerInstall,
|
||||
): Promise<InstalledRunner> {
|
||||
const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
|
||||
const prefix = runnerPluginPrefix({
|
||||
author: pending.pluginAuthor,
|
||||
@@ -333,3 +351,45 @@ export async function resumePendingRunnerInstall(
|
||||
if (!pending) return null;
|
||||
return finishRunnerInstall(pending, onProgress);
|
||||
}
|
||||
|
||||
export async function installMarketplaceEventProcessor(
|
||||
plugin: PluginV4,
|
||||
options: InstallRunnerOptions,
|
||||
): Promise<RunnerDescriptor> {
|
||||
const pending = await startRunnerInstall(plugin, options);
|
||||
return finishEventProcessorInstall(pending, options.onProgress);
|
||||
}
|
||||
|
||||
export async function resumePendingEventProcessorInstall(
|
||||
scope: string,
|
||||
): Promise<RunnerDescriptor | null> {
|
||||
const pending = readPendingRunnerInstall(scope);
|
||||
return pending ? finishEventProcessorInstall(pending) : null;
|
||||
}
|
||||
|
||||
async function finishEventProcessorInstall(
|
||||
pending: PendingRunnerInstall,
|
||||
onProgress?: (task: AsyncTask) => void,
|
||||
): Promise<RunnerDescriptor> {
|
||||
await waitForRunnerInstall(pending, onProgress);
|
||||
const deadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const metadata = await httpClient.getAgentMetadata();
|
||||
// Only the event registry is authoritative; Agent-only Runners are invalid.
|
||||
const candidates = (metadata.event_processors ?? []).filter(
|
||||
(component) =>
|
||||
component.plugin_author === pending.pluginAuthor &&
|
||||
component.plugin_name === pending.pluginName,
|
||||
);
|
||||
const component =
|
||||
candidates.find((component) => component.id.endsWith('/default')) ??
|
||||
candidates[0];
|
||||
if (component) {
|
||||
clearPendingRunnerInstall(pending.scope, pending.taskId);
|
||||
return component;
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
clearPendingRunnerInstall(pending.scope, pending.taskId);
|
||||
throw new RunnerMarketplaceError('registration-timeout');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,7 +22,14 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Settings,
|
||||
FileText,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { showBotError } from './bot-error';
|
||||
@@ -56,7 +63,23 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, bots, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const activeTab =
|
||||
!isCreateMode &&
|
||||
canViewMonitoring &&
|
||||
['logs', 'sessions'].includes(requestedTab || '')
|
||||
? requestedTab!
|
||||
: 'config';
|
||||
const setActiveTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
next.set('tab', tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
const [adapterLabel, setAdapterLabel] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
@@ -72,6 +95,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [isTogglingEnable, setIsTogglingEnable] = useState(false);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch bot enable state
|
||||
@@ -91,20 +115,27 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (isTogglingEnable) return;
|
||||
const prev = botEnabled;
|
||||
setIsTogglingEnable(true);
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
await httpClient.updateBot(id, { enable: checked });
|
||||
setBot((current) =>
|
||||
current ? { ...current, enable: checked } : current,
|
||||
);
|
||||
toast.success(
|
||||
t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'),
|
||||
);
|
||||
refreshBots();
|
||||
} catch (error) {
|
||||
setBotEnabled(prev);
|
||||
showBotError(error, t('bots.setBotEnableError'), t);
|
||||
} finally {
|
||||
setIsTogglingEnable(false);
|
||||
}
|
||||
},
|
||||
[id, botEnabled, refreshBots, t],
|
||||
[id, botEnabled, isTogglingEnable, refreshBots, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
@@ -210,36 +241,68 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={
|
||||
isTogglingEnable
|
||||
? 'pending'
|
||||
: botEnabled
|
||||
? 'enabled'
|
||||
: 'disabled'
|
||||
}
|
||||
className="gap-1.5 rounded-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTogglingEnable ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-current"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{t(
|
||||
isTogglingEnable
|
||||
? botEnabled
|
||||
? 'bots.enabling'
|
||||
: 'bots.disabling'
|
||||
: botEnabled
|
||||
? 'bots.enableConfirmed'
|
||||
: 'bots.disableConfirmed',
|
||||
)}
|
||||
</Badge>
|
||||
<Switch
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
disabled={!canManage || isTogglingEnable}
|
||||
aria-label={t('common.enable')}
|
||||
aria-busy={isTogglingEnable}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AdapterEventDebugDialog
|
||||
key={id}
|
||||
botId={id}
|
||||
adapterLabel={adapterLabel}
|
||||
/>
|
||||
{activeTab === 'config' && (
|
||||
<AdapterEventDebugDialog
|
||||
key={id}
|
||||
botId={id}
|
||||
adapterLabel={adapterLabel}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
disabled={!enableLoaded || !formDirty || isTogglingEnable}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
data-guide="bot-config-save"
|
||||
>
|
||||
{t('common.save')}
|
||||
{t(
|
||||
formDirty && !botEnabled
|
||||
? 'bots.saveAndEnable'
|
||||
: 'common.save',
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -27,18 +27,22 @@ import { Agent, Bot } from '@/app/infra/entities/api';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import {
|
||||
Cable,
|
||||
TriangleAlert,
|
||||
Check,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Webhook,
|
||||
Search,
|
||||
} 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';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -152,7 +156,8 @@ function detectConnectionMode(
|
||||
if (!webhookField.show_if) return 'webhook';
|
||||
return conditionMatches(
|
||||
webhookField.show_if,
|
||||
values[webhookField.show_if.field],
|
||||
values[webhookField.show_if.field] ??
|
||||
config.find((item) => item.name === webhookField.show_if?.field)?.default,
|
||||
)
|
||||
? 'webhook'
|
||||
: 'persistent';
|
||||
@@ -273,7 +278,26 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
useEffect(() => {
|
||||
onAdapterLabelChange?.(adapterLabel);
|
||||
}, [adapterLabel, onAdapterLabelChange]);
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
const currentAdapterConfig = form.getValues('adapter_config');
|
||||
const embedTitle = useWatch({
|
||||
control: form.control,
|
||||
name: 'adapter_config',
|
||||
compute: (config) => config?.title,
|
||||
});
|
||||
const dynamicFormSystemContext = useMemo(
|
||||
() => ({
|
||||
bot_uuid: initBotId,
|
||||
webhook_url: webhookUrl,
|
||||
onebot_ws_url: webhookUrl
|
||||
? webhookUrl.replace(/^http/, 'ws').replace(/\/$/, '') +
|
||||
'/onebot/v11/ws'
|
||||
: '',
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
adapter_config: { title: embedTitle },
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}),
|
||||
[initBotId, webhookUrl, extraWebhookUrl, embedTitle],
|
||||
);
|
||||
|
||||
// Group adapters by category for the Select dropdown. Legacy adapters are
|
||||
// split out and shown in a collapsed group at the bottom so they're
|
||||
@@ -290,6 +314,16 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
() => groupByCategory(activeAdapters),
|
||||
[activeAdapters],
|
||||
);
|
||||
const [adapterSearch, setAdapterSearch] = useState('');
|
||||
const adapterQuery = adapterSearch.trim().toLocaleLowerCase();
|
||||
const matchesAdapterSearch = (adapter: IChooseAdapterEntity) =>
|
||||
`${adapter.label} ${adapter.value} ${adapterDescriptionList[adapter.value] ?? ''}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(adapterQuery);
|
||||
const galleryGroups = groupByCategory(
|
||||
activeAdapters.filter(matchesAdapterSearch),
|
||||
);
|
||||
const galleryLegacyAdapters = legacyAdapters.filter(matchesAdapterSearch);
|
||||
|
||||
// Whether the collapsed legacy adapter group is expanded in the Select.
|
||||
const [showLegacyAdapters, setShowLegacyAdapters] = useState(false);
|
||||
@@ -464,8 +498,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
.then((res) => {
|
||||
const bot = res.bot;
|
||||
const runtimeValues = bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
resolve({
|
||||
adapter: bot.adapter,
|
||||
description: bot.description,
|
||||
@@ -475,11 +508,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
event_bindings: bot.event_bindings ?? [],
|
||||
plugin_processors: bot.plugin_processors ?? [],
|
||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -522,6 +553,17 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
}
|
||||
|
||||
function selectAdapter(adapterName: string) {
|
||||
if (!form.getValues('name')?.trim()) {
|
||||
const selectedAdapter = adapterNameList.find(
|
||||
(adapter) => adapter.value === adapterName,
|
||||
);
|
||||
if (selectedAdapter) {
|
||||
form.setValue('name', selectedAdapter.label, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
form.setValue('adapter', adapterName, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
shouldValidate: true,
|
||||
@@ -552,6 +594,14 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
[adapterNameToDynamicConfigMap, currentAdapter],
|
||||
);
|
||||
|
||||
const connectionModeControllerNames = useMemo(() => {
|
||||
if (supportedConnectionModes.length <= 1) return [];
|
||||
const webhookField = dynamicFormConfigList.find(
|
||||
(item) => item.type === DynamicFormItemType.WEBHOOK_URL,
|
||||
);
|
||||
return webhookField?.show_if?.field ? [webhookField.show_if.field] : [];
|
||||
}, [dynamicFormConfigList, supportedConnectionModes.length]);
|
||||
|
||||
const botGuideSteps = useMemo<GuidedTourStep[]>(() => {
|
||||
const steps: GuidedTourStep[] = [];
|
||||
|
||||
@@ -649,7 +699,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
description: form.getValues().description ?? '',
|
||||
adapter: form.getValues().adapter,
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
enable: form.getValues().enable,
|
||||
enable: true,
|
||||
event_bindings: form.getValues().event_bindings ?? [],
|
||||
plugin_processors: form.getValues().plugin_processors ?? [],
|
||||
};
|
||||
@@ -657,7 +707,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
.updateBot(initBotId, updateBot)
|
||||
.then(() => {
|
||||
// Reset dirty baseline to current values so isDirty becomes false
|
||||
form.reset(form.getValues());
|
||||
form.reset({ ...form.getValues(), enable: true });
|
||||
onFormSubmit(form.getValues());
|
||||
toast.success(t('bots.saveSuccess'));
|
||||
})
|
||||
@@ -669,6 +719,18 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
});
|
||||
}
|
||||
|
||||
const hasProcessing =
|
||||
(form.watch('event_bindings') ?? []).some(
|
||||
(binding) =>
|
||||
binding.enabled !== false &&
|
||||
!!binding.event_pattern &&
|
||||
(binding.target_type === 'discard' ||
|
||||
agentNameList.some((agent) => agent.uuid === binding.target_uuid)),
|
||||
) ||
|
||||
(form.watch('plugin_processors') ?? []).some(
|
||||
(processor) => processor.enabled !== false && !!processor.processor_uuid,
|
||||
);
|
||||
|
||||
if (loadFailed)
|
||||
return (
|
||||
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||
@@ -740,17 +802,44 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
data-testid="adapter-gallery"
|
||||
className="min-w-0 space-y-8 py-2"
|
||||
>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('guidedTour.bot.adapter.title')}
|
||||
</h2>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('guidedTour.bot.adapter.title')}
|
||||
</h2>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={adapterSearch}
|
||||
onChange={(event) =>
|
||||
setAdapterSearch(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.preventDefault();
|
||||
}}
|
||||
placeholder={t('bots.searchAdapters')}
|
||||
aria-label={t('bots.searchAdapters')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{t('guidedTour.bot.adapter.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{groupedAdapters.map((group) => (
|
||||
{galleryGroups.length === 0 &&
|
||||
galleryLegacyAdapters.length === 0 && (
|
||||
<p
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
{t('bots.noMatchingAdapters')}
|
||||
</p>
|
||||
)}
|
||||
{galleryGroups.map((group) => (
|
||||
<section
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
data-adapter-category={
|
||||
@@ -805,30 +894,35 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
</section>
|
||||
))}
|
||||
|
||||
{legacyAdapters.length > 0 && (
|
||||
{galleryLegacyAdapters.length > 0 && (
|
||||
<section
|
||||
className="border-t pt-5"
|
||||
data-adapter-category="legacy"
|
||||
>
|
||||
<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"
|
||||
aria-expanded={showLegacyAdapters}
|
||||
>
|
||||
{showLegacyAdapters ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
{t('bots.legacyAdapters')}
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
||||
{legacyAdapters.length}
|
||||
</span>
|
||||
</button>
|
||||
{showLegacyAdapters && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowLegacyAdapters((value) => !value)
|
||||
}
|
||||
className="flex items-center gap-2 py-2 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showLegacyAdapters || !!adapterQuery}
|
||||
>
|
||||
{showLegacyAdapters || adapterQuery ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
{t('bots.legacyAdapters')}
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
||||
{galleryLegacyAdapters.length}
|
||||
</span>
|
||||
</button>
|
||||
<LegacyAdapterHelp />
|
||||
</div>
|
||||
{(showLegacyAdapters || !!adapterQuery) && (
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{legacyAdapters.map((item) => (
|
||||
{galleryLegacyAdapters.map((item) => (
|
||||
<button
|
||||
key={`legacy:${item.value}`}
|
||||
type="button"
|
||||
@@ -1140,19 +1234,14 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
<div data-guide="bot-adapter-parameters">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
hiddenItemNames={connectionModeControllerNames}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1166,11 +1255,27 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
data-guide="bot-routing"
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
!hasProcessing && 'pt-0',
|
||||
initBotId && 'lg:min-h-0 lg:overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!hasProcessing && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex shrink-0 items-start gap-2.5 rounded-t-xl border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm leading-relaxed text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
>
|
||||
<TriangleAlert
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{t('botSetup.warning')}</p>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
{t('bots.eventRouting')}
|
||||
<EventRoutingHelp />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventRoutingDescription')}
|
||||
</CardDescription>
|
||||
@@ -1183,6 +1288,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
>
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
onAgentCreated={(agent) =>
|
||||
setAgentNameList((items) => [
|
||||
...items.filter((item) => item.uuid !== agent.uuid),
|
||||
agent,
|
||||
])
|
||||
}
|
||||
botId={initBotId}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList.filter(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.emptyRouteHint {
|
||||
--route-glow-soft: 6%;
|
||||
--route-glow-strong: 30%;
|
||||
animation: route-hint-glow 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .emptyRouteHint {
|
||||
--route-glow-soft: 4%;
|
||||
--route-glow-strong: 22%;
|
||||
}
|
||||
|
||||
.emptyRouteHint:hover,
|
||||
.emptyRouteHint:focus-visible,
|
||||
.emptyRouteHint[data-state='open'] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes route-hint-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--color-amber-400) var(--route-glow-soft), transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 14px 4px color-mix(in srgb, var(--color-amber-400) var(--route-glow-strong), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.emptyRouteHint {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import styles from './EventBindingsEditor.module.css';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import {
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Settings2,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
@@ -115,6 +120,9 @@ import {
|
||||
BotEventRouteStatus,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import AgentCreateContent from '@/app/home/agents/components/AgentCreateContent';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
eventGroupLabel,
|
||||
eventNamespaces,
|
||||
@@ -132,20 +140,13 @@ interface EventBindingsEditorProps {
|
||||
botId?: string;
|
||||
supportedEvents: string[];
|
||||
agentOptions: Agent[];
|
||||
onAgentCreated: (agent: Agent) => void;
|
||||
}
|
||||
|
||||
type FilterField =
|
||||
| 'chat_type'
|
||||
| 'chat_id'
|
||||
| 'message_text'
|
||||
| 'message_element_types';
|
||||
'chat_type' | 'chat_id' | 'message_text' | 'message_element_types';
|
||||
type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'contains'
|
||||
| 'not_contains'
|
||||
| 'starts_with'
|
||||
| 'regex';
|
||||
'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'regex';
|
||||
|
||||
interface FilterRow {
|
||||
field: FilterField;
|
||||
@@ -218,7 +219,7 @@ const BEHAVIOR_PRESETS = [
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isMessageEventPattern(p: string) {
|
||||
return p === 'message.*' || p.startsWith('message.');
|
||||
return p === 'message.received';
|
||||
}
|
||||
|
||||
interface RouteConflict {
|
||||
@@ -327,7 +328,14 @@ function eventLabel(event: string, t: TFunction) {
|
||||
|
||||
function eventDescription(event: string, t: TFunction) {
|
||||
if (event === '*') return t('bots.eventDescriptions.all');
|
||||
if (event.endsWith('.*')) return t('bots.eventDescriptions.namespace');
|
||||
if (event.endsWith('.*')) {
|
||||
const group = event.slice(0, -2);
|
||||
const key = `bots.eventDescriptions.namespace_${group}`;
|
||||
const description = t(key);
|
||||
return description === key
|
||||
? t('bots.eventDescriptions.namespace', { group })
|
||||
: description;
|
||||
}
|
||||
const key = `bots.eventDescriptions.${event.replace(/\./g, '_')}`;
|
||||
const description = t(key);
|
||||
return description === key ? t('bots.eventDescriptions.custom') : description;
|
||||
@@ -485,15 +493,23 @@ function TargetCombobox({
|
||||
binding,
|
||||
agentOptions,
|
||||
onUpdate,
|
||||
onAgentCreated,
|
||||
}: {
|
||||
binding: EventBinding;
|
||||
agentOptions: Agent[];
|
||||
onUpdate: (patch: Partial<EventBinding>) => void;
|
||||
onAgentCreated: (agent: Agent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const pipelineAllowed = isMessageEventPattern(binding.event_pattern);
|
||||
const targetType = binding.target_type || 'agent';
|
||||
const selectedTarget =
|
||||
targetType !== 'discard'
|
||||
? agentOptions.find((agent) => agent.uuid === binding.target_uuid)
|
||||
: undefined;
|
||||
|
||||
const current =
|
||||
targetType === 'discard'
|
||||
@@ -544,63 +560,147 @@ function TargetCombobox({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-[200px] justify-between text-sm font-normal px-3"
|
||||
<div className="relative w-[200px] shrink-0">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'h-8 w-full justify-between text-sm font-normal px-3',
|
||||
selectedTarget && 'pr-10',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{currentLabel()}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t('bots.searchTarget')}
|
||||
className="h-8"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('bots.noTargetFound')}</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetAgent')}>
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`agent:${a.uuid}:${a.name}`}
|
||||
onSelect={() =>
|
||||
select(encodeTarget('agent', a.uuid || ''))
|
||||
}
|
||||
>
|
||||
<Bot className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('agent', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{pipelines.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||
{pipelines.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`pipeline:${a.uuid}:${a.name}`}
|
||||
onSelect={() =>
|
||||
select(encodeTarget('pipeline', a.uuid || ''))
|
||||
}
|
||||
>
|
||||
<Workflow className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('pipeline', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<div className="border-t p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{t('botSetup.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Dialog open={creating} onOpenChange={setCreating}>
|
||||
<DialogContent
|
||||
className="flex max-h-[90dvh] w-[calc(100%-2rem)] flex-col overflow-y-auto sm:max-w-6xl"
|
||||
onSubmit={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="truncate">{currentLabel()}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('bots.searchTarget')} className="h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('bots.noTargetFound')}</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetAgent')}>
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`agent:${a.uuid}:${a.name}`}
|
||||
onSelect={() => select(encodeTarget('agent', a.uuid || ''))}
|
||||
>
|
||||
<Bot className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('agent', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{pipelines.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||
{pipelines.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`pipeline:${a.uuid}:${a.name}`}
|
||||
onSelect={() =>
|
||||
select(encodeTarget('pipeline', a.uuid || ''))
|
||||
}
|
||||
>
|
||||
<Workflow className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('pipeline', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('botSetup.create')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('botSetup.createDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AgentCreateContent
|
||||
embedded
|
||||
allowedKinds={pipelineAllowed ? ['pipeline', 'agent'] : ['agent']}
|
||||
onCreated={async (id) => {
|
||||
void refreshPipelines();
|
||||
try {
|
||||
const { agent } = await backendClient.getAgent(id);
|
||||
onAgentCreated(agent);
|
||||
const compatible =
|
||||
agent.kind === 'pipeline'
|
||||
? pipelineAllowed
|
||||
: agentSupportsEventPattern(agent, binding.event_pattern);
|
||||
if (compatible) {
|
||||
onUpdate({ target_type: agent.kind, target_uuid: id });
|
||||
} else {
|
||||
toast.warning(t('botSetup.incompatible'));
|
||||
}
|
||||
setCreating(false);
|
||||
} catch {
|
||||
toast.error(t('agents.createError'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{selectedTarget && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 size-6"
|
||||
>
|
||||
<Link
|
||||
to={`/home/agents?id=${encodeURIComponent(selectedTarget.uuid || '')}&tab=config`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('bots.pluginSubscriptions.configure')}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('bots.pluginSubscriptions.configure')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1179,9 +1279,10 @@ function RouteDryRunDialog({
|
||||
interface BindingCardProps {
|
||||
binding: EventBinding;
|
||||
globalIndex: number;
|
||||
routeStatus?: BotEventRouteStatus;
|
||||
|
||||
eventOptions: string[];
|
||||
agentOptions: Agent[];
|
||||
onAgentCreated: (agent: Agent) => void;
|
||||
expandedIds: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onUpdate: (globalIndex: number, patch: Partial<EventBinding>) => void;
|
||||
@@ -1193,9 +1294,9 @@ interface BindingCardProps {
|
||||
function BindingCardContent({
|
||||
binding,
|
||||
globalIndex,
|
||||
routeStatus,
|
||||
eventOptions,
|
||||
agentOptions,
|
||||
onAgentCreated,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onUpdate,
|
||||
@@ -1209,12 +1310,10 @@ 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
|
||||
className={`rounded-lg border bg-card ${
|
||||
className={`rounded-lg border ${isEnabled ? 'bg-card' : 'bg-muted/30 border-dashed'} ${
|
||||
isOverlay ? 'pointer-events-none shadow-lg ring-1 ring-primary/20' : ''
|
||||
}`}
|
||||
data-drag-overlay={isOverlay ? 'true' : undefined}
|
||||
@@ -1233,6 +1332,7 @@ function BindingCardContent({
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{!isEnabled && <span className="h-4 w-4 shrink-0" aria-hidden="true" />}
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -1309,6 +1409,7 @@ function BindingCardContent({
|
||||
<TargetCombobox
|
||||
binding={binding}
|
||||
agentOptions={agentOptions}
|
||||
onAgentCreated={onAgentCreated}
|
||||
onUpdate={(patch) => onUpdate(globalIndex, patch)}
|
||||
/>
|
||||
|
||||
@@ -1319,16 +1420,11 @@ function BindingCardContent({
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{/* disable/enable toggle */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onUpdate(globalIndex, { enabled: !isEnabled })}
|
||||
>
|
||||
{isEnabled ? t('bots.disable') : t('bots.enable')}
|
||||
</Button>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(enabled) => onUpdate(globalIndex, { enabled })}
|
||||
aria-label={`${t('bots.enable')} · ${t('bots.dryRunRuleIndex', { index: globalIndex + 1 })}`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1342,23 +1438,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">
|
||||
@@ -1421,14 +1500,15 @@ export default function EventBindingsEditor({
|
||||
botId,
|
||||
supportedEvents,
|
||||
agentOptions,
|
||||
onAgentCreated,
|
||||
}: EventBindingsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const watchedBindings: EventBinding[] | undefined =
|
||||
form.watch('event_bindings');
|
||||
const bindings = useMemo(() => watchedBindings ?? [], [watchedBindings]);
|
||||
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 +1535,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 +1570,15 @@ 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,11 +1588,15 @@ 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 });
|
||||
const ordered = [
|
||||
...next.filter((binding) => binding.enabled ?? true),
|
||||
...next.filter((binding) => !(binding.enabled ?? true)),
|
||||
];
|
||||
form.setValue('event_bindings', ordered, { shouldDirty: true });
|
||||
}
|
||||
|
||||
function addBinding(eventPattern = 'message.received') {
|
||||
@@ -1661,7 +1747,7 @@ export default function EventBindingsEditor({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{enabledBindings.length === 0 && (
|
||||
{bindings.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-5 text-center text-sm text-muted-foreground">
|
||||
{t('bots.noEventBindings')}
|
||||
</div>
|
||||
@@ -1674,13 +1760,9 @@ 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}
|
||||
onAgentCreated={onAgentCreated}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onUpdate={updateBinding}
|
||||
@@ -1688,6 +1770,20 @@ export default function EventBindingsEditor({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{disabledBindings.map(({ b, i }) => (
|
||||
<BindingCardContent
|
||||
key={b.id ?? `disabled-${i}`}
|
||||
binding={b}
|
||||
globalIndex={i}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
onAgentCreated={onAgentCreated}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onUpdate={updateBinding}
|
||||
onRemove={removeBinding}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
@@ -1695,13 +1791,9 @@ export default function EventBindingsEditor({
|
||||
<BindingCardContent
|
||||
binding={activeBinding}
|
||||
globalIndex={activeGlobalIdx}
|
||||
routeStatus={
|
||||
activeBinding.id
|
||||
? routeStatusByBinding.get(String(activeBinding.id))
|
||||
: undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
onAgentCreated={onAgentCreated}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onUpdate={updateBinding}
|
||||
@@ -1715,7 +1807,14 @@ export default function EventBindingsEditor({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
bindings.length === 0 ? styles.emptyRouteHint : undefined
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('bots.addBehavior')}
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5" />
|
||||
@@ -1791,68 +1890,100 @@ 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>
|
||||
</div>
|
||||
|
||||
{/* disabled section */}
|
||||
{disabledBindings.length > 0 && (
|
||||
<div className="rounded-lg border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDisabledSectionOpen((v) => !v)}
|
||||
>
|
||||
{disabledSectionOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t('bots.disabledBindings')}
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{disabledBindings.length}
|
||||
</Badge>
|
||||
</button>
|
||||
{disabledSectionOpen && (
|
||||
<div className="border-t p-2 space-y-2">
|
||||
{disabledBindings.map(({ b, i }) => (
|
||||
<BindingCardContent
|
||||
key={b.id ?? i}
|
||||
binding={b}
|
||||
globalIndex={i}
|
||||
routeStatus={
|
||||
b.id ? routeStatusByBinding.get(String(b.id)) : undefined
|
||||
}
|
||||
eventOptions={eventOptions}
|
||||
agentOptions={agentOptions}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onUpdate={updateBinding}
|
||||
onRemove={removeBinding}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,113 @@
|
||||
'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,41 @@
|
||||
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,3 +1,14 @@
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -12,6 +23,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
RotateCcw,
|
||||
Ban,
|
||||
Bot,
|
||||
Copy,
|
||||
@@ -142,6 +154,23 @@ const BotSessionMonitor = forwardRef<
|
||||
BotSessionMonitorProps
|
||||
>(function BotSessionMonitor({ botId }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const canReset = workspace?.permissions.includes('resource.manage') ?? false;
|
||||
const [resetTarget, setResetTarget] = useState<string | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const resetContext = async () => {
|
||||
if (!resetTarget || resetting) return;
|
||||
setResetting(true);
|
||||
try {
|
||||
await httpClient.resetSessionContext(botId, resetTarget);
|
||||
toast.success(t('bots.sessionMonitor.resetSuccess'));
|
||||
setResetTarget(null);
|
||||
} catch {
|
||||
toast.error(t('bots.sessionMonitor.resetError'));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [sessionTotal, setSessionTotal] = useState(0);
|
||||
const [sessionPage, setSessionPage] = useState(0);
|
||||
@@ -319,7 +348,7 @@ const BotSessionMonitor = forwardRef<
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
const userMsgIds = new Set(
|
||||
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
||||
sorted.filter(isUserMessage).map((m) => m.id),
|
||||
);
|
||||
|
||||
if (userMsgIds.size > 0) {
|
||||
@@ -389,9 +418,10 @@ const BotSessionMonitor = forwardRef<
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingMessages || messageError) return;
|
||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
||||
// Wait for DOM to render the new messages before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
// The timeline is hidden until all message data has finished loading.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const viewport = container.querySelector(
|
||||
@@ -401,7 +431,8 @@ const BotSessionMonitor = forwardRef<
|
||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [messages, toolCalls]);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [loadingMessages, messageError, messages, toolCalls]);
|
||||
|
||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||
try {
|
||||
@@ -409,17 +440,44 @@ const BotSessionMonitor = forwardRef<
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as MessageChainComponent[];
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const chain = parsed.message_chain ?? parsed.event?.message_chain;
|
||||
if (Array.isArray(chain)) return chain as MessageChainComponent[];
|
||||
if (Array.isArray(chain?.root))
|
||||
return chain.root as MessageChainComponent[];
|
||||
if (Array.isArray(parsed.contents)) {
|
||||
const components: MessageChainComponent[] = [];
|
||||
for (const part of parsed.contents) {
|
||||
if (part.type === 'text' && typeof part.text === 'string') {
|
||||
components.push({ type: 'Plain', text: part.text } as Plain);
|
||||
} else if (part.type === 'image' && part.image_url) {
|
||||
components.push({
|
||||
type: 'Image',
|
||||
url: part.image_url,
|
||||
} as MessageChainComponent);
|
||||
}
|
||||
}
|
||||
if (components.length) return components;
|
||||
}
|
||||
if (typeof parsed.text === 'string') {
|
||||
return [{ type: 'Plain', text: parsed.text } as Plain];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, return as plain text
|
||||
}
|
||||
return [{ type: 'Plain', text: content } as Plain];
|
||||
};
|
||||
|
||||
const isUserMessage = (msg: SessionMessage): boolean => {
|
||||
function isUserMessage(msg: SessionMessage): boolean {
|
||||
// Older processing-error records inherited the default user role.
|
||||
if (msg.status === 'error' && msg.message_content.startsWith('Error: ')) {
|
||||
return false;
|
||||
}
|
||||
if (msg.role === 'assistant') return false;
|
||||
if (msg.role === 'user') return true;
|
||||
return !msg.runner_name;
|
||||
};
|
||||
}
|
||||
|
||||
const renderMessageComponent = (
|
||||
component: MessageChainComponent,
|
||||
@@ -664,7 +722,7 @@ const BotSessionMonitor = forwardRef<
|
||||
{/* Left Panel: Session List */}
|
||||
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
||||
{/* Admin header */}
|
||||
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
|
||||
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
|
||||
@@ -738,19 +796,11 @@ const BotSessionMonitor = forwardRef<
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState
|
||||
compact
|
||||
title={t('monitoring.loadError')}
|
||||
onRetry={loadSessions}
|
||||
/>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -850,7 +900,7 @@ const BotSessionMonitor = forwardRef<
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
@@ -913,6 +963,18 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canReset && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5"
|
||||
disabled={resetting}
|
||||
onClick={() => setResetTarget(selectedSessionId)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{t('bots.sessionMonitor.resetContext')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
@@ -922,45 +984,25 @@ const BotSessionMonitor = forwardRef<
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState
|
||||
compact
|
||||
title={`${t('monitoring.toolCalls.title')}: ${t('monitoring.loadError')}`}
|
||||
onRetry={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
<LoadErrorState
|
||||
title={t('monitoring.loadError')}
|
||||
onRetry={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
/>
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
@@ -1181,38 +1223,73 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={messagePage === 0 || loadingMessages}
|
||||
onClick={() =>
|
||||
setMessagePage((page) => Math.max(0, page - 1))
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('common.previous')}
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{messagePage + 1} / {messagePageCount} · {messageTotal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
messagePage + 1 >= messagePageCount || loadingMessages
|
||||
}
|
||||
onClick={() => setMessagePage((page) => page + 1)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
{t('common.next')}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{messagePageCount > 1 && (
|
||||
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={messagePage === 0 || loadingMessages}
|
||||
onClick={() =>
|
||||
setMessagePage((page) => Math.max(0, page - 1))
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('operationTrace.previousPage')}
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{messagePage + 1} / {messagePageCount} · {messageTotal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
messagePage + 1 >= messagePageCount || loadingMessages
|
||||
}
|
||||
onClick={() => setMessagePage((page) => page + 1)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
{t('operationTrace.nextPage')}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={resetTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !resetting) setResetTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bots.sessionMonitor.resetContext')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('bots.sessionMonitor.resetDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={resetting}
|
||||
onClick={() => setResetTarget(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={resetting} onClick={resetContext}>
|
||||
<RotateCcw
|
||||
className={cn(
|
||||
'size-4',
|
||||
resetting && 'animate-spin motion-reduce:animate-none',
|
||||
)}
|
||||
/>
|
||||
{t('bots.sessionMonitor.resetContext')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BotAdminsDialog
|
||||
botId={botId}
|
||||
open={adminsDialogOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
MinusCircle,
|
||||
LoaderCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -16,11 +17,11 @@ export type AssistantTool = {
|
||||
export default function AssistantToolResult({
|
||||
tool,
|
||||
content,
|
||||
defaultCollapsed = false,
|
||||
defaultCollapsed = true,
|
||||
}: {
|
||||
tool?: AssistantTool;
|
||||
content: string;
|
||||
/** Turn finished: collapse to one line until the user expands it again. */
|
||||
/** Keep tool steps quiet until the user requests details. */
|
||||
defaultCollapsed?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,11 +37,17 @@ export default function AssistantToolResult({
|
||||
result && typeof result === 'object' && !Array.isArray(result)
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
const running = data.status === 'running';
|
||||
const failed = !!data.error;
|
||||
const denied = data.status === 'denied';
|
||||
const partial = !!data.truncated;
|
||||
const Icon =
|
||||
failed || partial ? CircleAlert : denied ? MinusCircle : CheckCircle2;
|
||||
const Icon = running
|
||||
? LoaderCircle
|
||||
: failed || partial
|
||||
? CircleAlert
|
||||
: denied
|
||||
? MinusCircle
|
||||
: CheckCircle2;
|
||||
const items = Array.isArray(result)
|
||||
? result
|
||||
: Array.isArray(data.items)
|
||||
@@ -56,13 +63,15 @@ export default function AssistantToolResult({
|
||||
: t(`assistant.operations.${tool?.name}`, {
|
||||
defaultValue: t('assistant.toolResult'),
|
||||
});
|
||||
const status = failed
|
||||
? 'failed'
|
||||
: denied
|
||||
? 'denied'
|
||||
: partial
|
||||
? 'partial'
|
||||
: 'completed';
|
||||
const status = running
|
||||
? 'toolRunning'
|
||||
: failed
|
||||
? 'failed'
|
||||
: denied
|
||||
? 'denied'
|
||||
: partial
|
||||
? 'partial'
|
||||
: 'completed';
|
||||
const url =
|
||||
typeof data.url === 'string' &&
|
||||
/^\/home\/(pipelines|knowledge)\?id=[\w-]+$/.test(data.url)
|
||||
@@ -79,7 +88,7 @@ export default function AssistantToolResult({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-left text-xs hover:bg-muted"
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-0.5 py-1 text-left text-[11px] leading-4 text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
|
||||
aria-expanded={false}
|
||||
onClick={() => {
|
||||
setManual(true);
|
||||
@@ -87,25 +96,25 @@ export default function AssistantToolResult({
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
className={`size-3 shrink-0 ${running ? 'animate-spin motion-reduce:animate-none' : ''} ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="truncate font-medium">{label}</span>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">
|
||||
{tool && t(`assistant.${status}`)}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2 rounded-xl border bg-background p-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<section className="min-w-0 space-y-1.5 px-0.5 py-1 text-[11px] leading-4 text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={`size-4 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
className={`size-3 shrink-0 ${running ? 'animate-spin motion-reduce:animate-none' : ''} ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 break-words">{label}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
|
||||
{tool && t(`assistant.${status}`)}
|
||||
</span>
|
||||
<button
|
||||
@@ -118,10 +127,12 @@ export default function AssistantToolResult({
|
||||
setCollapsed(true);
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="size-3.5 rotate-180" />
|
||||
<ChevronDown className="size-3 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
{failed ? (
|
||||
{running ? (
|
||||
<p>{t('assistant.toolRunning')}</p>
|
||||
) : failed ? (
|
||||
<p className="text-destructive">{t('assistant.operationFailed')}</p>
|
||||
) : denied ? (
|
||||
<p className="text-muted-foreground">
|
||||
@@ -136,7 +147,7 @@ export default function AssistantToolResult({
|
||||
)}
|
||||
{name && <p className="break-words">{name}</p>}
|
||||
{items && (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
<ul className="space-y-0.5 text-[11px] text-muted-foreground">
|
||||
{items.slice(0, 6).map((item: unknown, index: number) => {
|
||||
const entry =
|
||||
item && typeof item === 'object'
|
||||
@@ -168,9 +179,9 @@ export default function AssistantToolResult({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<details className="text-[11px] text-muted-foreground">
|
||||
<summary className="cursor-pointer">{t('assistant.details')}</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all">
|
||||
<pre className="mt-1.5 max-h-32 overflow-auto whitespace-pre-wrap break-all">
|
||||
{tool ? JSON.stringify(tool.result, null, 2) : content}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -131,9 +131,11 @@ export default function AccountSettingsPanel({
|
||||
async function loadTotpAccounts() {
|
||||
try {
|
||||
const res = await httpClient.getTotpAccounts();
|
||||
const list = res.accounts || [];
|
||||
setIsManager(list.length > 1);
|
||||
setTotpRows(list);
|
||||
// The role rule lives in the API: owners and admins receive the Workspace
|
||||
// list, everyone else receives 403. Deriving manager state from the list
|
||||
// length would hide oversight in a single-member Workspace.
|
||||
setIsManager(true);
|
||||
setTotpRows(res.accounts || []);
|
||||
} catch {
|
||||
// A non-manager receives 403 here; the panel keeps working on own state.
|
||||
setIsManager(false);
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Pure geometry + state helpers for the floating workspace assistant button.
|
||||
*
|
||||
* The component keeps these decisions here so the dock/rail transitions stay
|
||||
* testable without a DOM. The key invariant is that the *same* edge value
|
||||
* drives the collapsed strip and the expanded button, so they can never drift
|
||||
* apart or fight each other (the race the UI is careful to avoid).
|
||||
*/
|
||||
|
||||
export const ASSISTANT_BUTTON_SIZE = 48;
|
||||
/** Distance to the viewport edge that snaps the button into the rail. */
|
||||
export const ASSISTANT_SNAP_THRESHOLD = 24;
|
||||
/** Rail strip that stays visible while collapsed, in pixels. */
|
||||
export const ASSISTANT_RAIL_WIDTH = 6;
|
||||
|
||||
export type AssistantDragPosition = { x: number; y: number };
|
||||
export type AssistantEdge = 'left' | 'right' | null;
|
||||
|
||||
/** Viewport box the button may occupy. Kept explicit so tests can pin a size. */
|
||||
export type AssistantViewport = { width: number; height: number };
|
||||
|
||||
export function clampAssistantPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
viewport: AssistantViewport,
|
||||
): AssistantDragPosition {
|
||||
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
|
||||
const maxY = Math.max(0, viewport.height - ASSISTANT_BUTTON_SIZE);
|
||||
return {
|
||||
x: Math.min(Math.max(0, x), maxX),
|
||||
y: Math.min(Math.max(0, y), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a resting position is docked against a vertical edge.
|
||||
* The left edge wins ties so a centred drag always resolves deterministically.
|
||||
*/
|
||||
export function resolveAssistantEdge(
|
||||
x: number,
|
||||
viewport: AssistantViewport,
|
||||
): AssistantEdge {
|
||||
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
|
||||
const leftGap = x;
|
||||
const rightGap = maxX - x;
|
||||
if (leftGap <= ASSISTANT_SNAP_THRESHOLD && leftGap <= rightGap) return 'left';
|
||||
if (rightGap <= ASSISTANT_SNAP_THRESHOLD) return 'right';
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AssistantHoverInput = {
|
||||
dockedEdge: AssistantEdge;
|
||||
railExpanded: boolean;
|
||||
/** A long press has armed the drag, or the pointer is actively moving. */
|
||||
dragging: boolean;
|
||||
};
|
||||
|
||||
/** Distance to the viewport edge that snaps the button into the rail. */
|
||||
|
||||
/**
|
||||
* Hovering any part of the control must reveal the full button again.
|
||||
* Only a docked *and still collapsed* button needs to expand, so a pointer that
|
||||
* merely jitters inside an already expanded button does no state churn.
|
||||
*/
|
||||
export function shouldExpandRail(input: AssistantHoverInput): boolean {
|
||||
if (input.dragging) return false;
|
||||
return !!input.dockedEdge && !input.railExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* A docked button collapses to the strip when the pointer leaves. This is the
|
||||
* mirror of `shouldExpandRail` and is keyed on `railExpanded` being *true* —
|
||||
* the earlier `!railExpanded` form made a revealed button impossible to
|
||||
* re-collapse, which is the bug this guards against. The panel being open pins
|
||||
* it, because the popover stays anchored to the button.
|
||||
*/
|
||||
export function shouldCollapseRail(input: AssistantHoverInput): boolean {
|
||||
if (input.dragging) return false;
|
||||
return !!input.dockedEdge && input.railExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a drop the button always starts collapsed, even though the pointer is
|
||||
* still over it. The rail is then held collapsed until the pointer exits, which
|
||||
* avoids the "drops under the cursor and never comes back" race.
|
||||
*/
|
||||
export function restingRailExpanded(): boolean {
|
||||
return false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@ import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
|
||||
import { MODEL_CONFIGURATION_CHANGED } from '@/app/infra/http/modelConfigEvents';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import {
|
||||
LLMModel,
|
||||
Bot,
|
||||
@@ -94,19 +96,6 @@ function hasUsableOptionName(option: { name?: string | null }): boolean {
|
||||
return typeof option.name === 'string' && option.name.trim().length > 0;
|
||||
}
|
||||
|
||||
function getPluginComponentIconURL(value?: string): string | null {
|
||||
if (!value?.startsWith('plugin:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return httpClient.getPluginIconURL(match[1], match[2]);
|
||||
}
|
||||
|
||||
function SelectOptionContent({
|
||||
label,
|
||||
value,
|
||||
@@ -114,7 +103,10 @@ function SelectOptionContent({
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const iconURL = getPluginComponentIconURL(value);
|
||||
const match = value?.startsWith('plugin:')
|
||||
? value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/)
|
||||
: null;
|
||||
const iconURL = useInstalledPluginIcon(match?.[1], match?.[2]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -141,6 +133,7 @@ export default function DynamicFormItemComponent({
|
||||
systemContext,
|
||||
requiredModelAbility,
|
||||
compactModelSelector = false,
|
||||
selectedModelLabel,
|
||||
}: {
|
||||
config: IDynamicFormItemSchema;
|
||||
field: ControllerRenderProps<any, any>;
|
||||
@@ -150,6 +143,7 @@ export default function DynamicFormItemComponent({
|
||||
systemContext?: Record<string, unknown>;
|
||||
requiredModelAbility?: string;
|
||||
compactModelSelector?: boolean;
|
||||
selectedModelLabel?: string;
|
||||
}) {
|
||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
@@ -313,6 +307,10 @@ export default function DynamicFormItemComponent({
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||
fetchLlmModels();
|
||||
window.addEventListener(MODEL_CONFIGURATION_CHANGED, fetchLlmModels);
|
||||
return () => {
|
||||
window.removeEventListener(MODEL_CONFIGURATION_CHANGED, fetchLlmModels);
|
||||
};
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
@@ -648,11 +646,25 @@ export default function DynamicFormItemComponent({
|
||||
aria-label={t('models.selectModel')}
|
||||
className={
|
||||
compactModelSelector
|
||||
? 'w-full min-w-0 gap-1 border-0 bg-transparent px-1 text-xs text-muted-foreground shadow-none hover:bg-muted data-[size=default]:h-7 [&_[data-slot=select-value]_svg]:hidden'
|
||||
? 'w-full min-w-0 gap-1 rounded-lg border border-border bg-background px-2 text-xs text-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04)] hover:border-muted-foreground/40 hover:bg-background dark:bg-background dark:hover:bg-background data-[state=open]:border-muted-foreground/50 focus-visible:border-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-muted-foreground/20 data-[size=default]:h-7 [&_[data-slot=select-value]_svg]:hidden'
|
||||
: MODEL_SELECT_TRIGGER_CLASS
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
<SelectValue placeholder={t('models.selectModel')}>
|
||||
{/* Keep compact labels explicitly rendered even when a stream
|
||||
omits the model name. Switching to Radix's ItemText portal
|
||||
would give two renderers ownership of the same DOM node. */}
|
||||
{compactModelSelector ? (
|
||||
<span className="truncate">
|
||||
{selectedModelLabel ||
|
||||
selectableModels.find(
|
||||
(model) => model.uuid === field.value,
|
||||
)?.name ||
|
||||
field.value ||
|
||||
t('models.selectModel')}
|
||||
</span>
|
||||
) : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type PromptMessage = {
|
||||
role: string;
|
||||
content?: string | null | JsonValue[];
|
||||
|
||||
@@ -204,19 +204,17 @@ function buildToolSourceGroups(
|
||||
(rightIndex === -1 ? TOOL_SOURCE_ORDER.length : rightIndex)
|
||||
);
|
||||
})
|
||||
.map(
|
||||
([source, providerGroups]): ToolSourceGroup => ({
|
||||
key: source,
|
||||
label: sourceLabels[source] || source,
|
||||
groups: Array.from(providerGroups.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
total: Array.from(providerGroups.values()).reduce(
|
||||
(count, group) => count + group.tools.length,
|
||||
0,
|
||||
),
|
||||
}),
|
||||
);
|
||||
.map(([source, providerGroups]): ToolSourceGroup => ({
|
||||
key: source,
|
||||
label: sourceLabels[source] || source,
|
||||
groups: Array.from(providerGroups.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
total: Array.from(providerGroups.values()).reduce(
|
||||
(count, group) => count + group.tools.length,
|
||||
0,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildMCPToolOwners(servers: MCPServer[]) {
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function GuidedTour({
|
||||
useState<PopoverPosition | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const previousStorageKeyRef = useRef(storageKey);
|
||||
const enteredStepRef = useRef<string | null>(null);
|
||||
|
||||
const activeIndex = steps.findIndex((step) => step.id === activeStepId);
|
||||
const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined;
|
||||
@@ -86,8 +87,17 @@ export default function GuidedTour({
|
||||
}, [activeStepId, enabled, finished, steps, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !finished) activeStep?.onEnter?.();
|
||||
}, [activeStep, enabled, finished]);
|
||||
if (!enabled || finished || !activeStep) {
|
||||
enteredStepRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Callback identities can change during navigation. Enter each step once,
|
||||
// so a URL update cannot replay its navigation and override the user's tab.
|
||||
const stepKey = JSON.stringify([storageKey, activeStep.id]);
|
||||
if (enteredStepRef.current === stepKey) return;
|
||||
enteredStepRef.current = stepKey;
|
||||
activeStep.onEnter?.();
|
||||
}, [activeStep, enabled, finished, storageKey]);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!activeStep) return;
|
||||
|
||||
@@ -45,6 +45,12 @@ export function getTourPosition(
|
||||
? rect.bottom + VIEWPORT_GAP
|
||||
: rect.top - height - VIEWPORT_GAP;
|
||||
}
|
||||
// The mobile sidebar can still be sliding in when its target is measured.
|
||||
// Clamp the card independently of that temporarily off-screen target.
|
||||
left = Math.max(
|
||||
VIEWPORT_GAP,
|
||||
Math.min(left, viewportWidth - width - VIEWPORT_GAP),
|
||||
);
|
||||
top = Math.max(
|
||||
VIEWPORT_GAP,
|
||||
Math.min(top, viewportHeight - height - VIEWPORT_GAP),
|
||||
|
||||
@@ -655,6 +655,16 @@ function NavItems({
|
||||
if (item.extensionType === 'skill') {
|
||||
return `/home/skills?id=${encodeURIComponent(item.id)}`;
|
||||
}
|
||||
const processorTab = searchParams.get('tab');
|
||||
if (
|
||||
hasDetailPages &&
|
||||
(routePrefix === '/home/agents' ||
|
||||
routePrefix === '/home/pipelines') &&
|
||||
(pathname === '/home/agents' || pathname === '/home/pipelines') &&
|
||||
(processorTab === 'logs' || processorTab === 'config')
|
||||
) {
|
||||
return `${routePrefix}?id=${encodeURIComponent(item.id)}&tab=${processorTab}`;
|
||||
}
|
||||
return hasDetailPages
|
||||
? `${routePrefix}?id=${encodeURIComponent(item.id)}`
|
||||
: routePrefix;
|
||||
@@ -794,7 +804,7 @@ function NavItems({
|
||||
{(isBot || isMCP) && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
|
||||
'absolute -bottom-1 -right-1 size-3 rounded-full border-2 border-popover',
|
||||
isMCP
|
||||
? mcpStatusColor(item)
|
||||
: item.enabled === false
|
||||
@@ -862,7 +872,7 @@ function NavItems({
|
||||
{(isBot || isMCP) && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
|
||||
'absolute -bottom-1 -right-1 size-3 rounded-full border-2 border-sidebar',
|
||||
isMCP
|
||||
? mcpStatusColor(item)
|
||||
: item.enabled === false
|
||||
@@ -1871,6 +1881,9 @@ export default function HomeSidebar({
|
||||
const canViewStorageAnalysis =
|
||||
currentWorkspace?.workspace.source !== 'cloud_projection' &&
|
||||
currentWorkspace?.permissions.includes('audit.view');
|
||||
// The operation log is an audit surface: owner / admin only.
|
||||
const canViewOperationTrace =
|
||||
currentWorkspace?.permissions.includes('audit.view') ?? false;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
@@ -2221,19 +2234,8 @@ export default function HomeSidebar({
|
||||
<span>{t('models.title')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/* Operation traceability is an audit surface: only roles holding
|
||||
audit.view (owner / admin) get a direct sidebar entry. */}
|
||||
{currentWorkspace?.permissions.includes('audit.view') && (
|
||||
<SidebarMenuItem data-sidebar-guide="operation-trace">
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('operationTrace')}
|
||||
tooltip={t('operationTrace.title')}
|
||||
>
|
||||
<History className="size-4 text-blue-500" />
|
||||
<span>{t('operationTrace.title')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{/* Operation log entry moved to the account menu: it is an audit
|
||||
surface, not a primary navigation target. */}
|
||||
</SidebarMenu>
|
||||
|
||||
{/* API-key management is available only to authorized Workspace roles. */}
|
||||
@@ -2358,6 +2360,17 @@ export default function HomeSidebar({
|
||||
{t('storageAnalysis.title')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canViewOperationTrace && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('operationTrace');
|
||||
}}
|
||||
>
|
||||
<History />
|
||||
{t('operationTrace.title')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
|
||||
@@ -1,81 +1,20 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { isNewerVersion } from '@/app/utils/versionCompare';
|
||||
|
||||
// Lightweight entity item for sidebar display
|
||||
export interface SidebarEntityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
iconURL?: string;
|
||||
updatedAt?: string; // ISO timestamp for sorting by most recently edited
|
||||
// Bot-specific fields
|
||||
enabled?: boolean;
|
||||
legacyAdapter?: boolean;
|
||||
// MCP-specific fields
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error';
|
||||
// Plugin-specific fields
|
||||
installSource?: string;
|
||||
installInfo?: Record<string, unknown>;
|
||||
hasUpdate?: boolean;
|
||||
debug?: boolean;
|
||||
// Set when this item appears in the unified extensions list
|
||||
extensionType?: 'plugin' | 'mcp' | 'skill';
|
||||
// Agent-specific: distinguishes Agent processors from Pipelines
|
||||
kind?: 'agent' | 'pipeline' | 'event_processor';
|
||||
}
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
name: string; // display label
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
pluginLabel: string; // human-readable plugin display name
|
||||
pluginIconURL: string; // plugin icon URL
|
||||
pageId: string;
|
||||
path: string; // asset path (HTML file)
|
||||
}
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
pluginCount: number;
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
quotaDataLoaded: boolean;
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
refreshPlugins: () => Promise<void>;
|
||||
refreshMCPServers: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
refreshAll: () => Promise<void>;
|
||||
// Breadcrumb: entity name shown when viewing a detail page
|
||||
detailEntityName: string | null;
|
||||
setDetailEntityName: (name: string | null) => void;
|
||||
// Whether the extensions list is grouped by type (shared between page and sidebar)
|
||||
extensionsGroupByType: boolean;
|
||||
setExtensionsGroupByType: (enabled: boolean) => void;
|
||||
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
|
||||
agentsGroupByKind: boolean;
|
||||
setAgentsGroupByKind: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
|
||||
// Keep the context identity independent of provider dependencies during HMR.
|
||||
import {
|
||||
SidebarDataContext,
|
||||
type SidebarEntityItem,
|
||||
type PluginPageItem,
|
||||
} from './sidebarDataStore';
|
||||
export { useSidebarData } from './sidebarDataStore';
|
||||
export type {
|
||||
SidebarEntityItem,
|
||||
PluginPageItem,
|
||||
SidebarDataContextValue,
|
||||
} from './sidebarDataStore';
|
||||
|
||||
export function SidebarDataProvider({
|
||||
children,
|
||||
@@ -432,11 +371,3 @@ export function SidebarDataProvider({
|
||||
</SidebarDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarData(): SidebarDataContextValue {
|
||||
const ctx = useContext(SidebarDataContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSidebarData must be used within a SidebarDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
// Lightweight entity item for sidebar display
|
||||
export interface SidebarEntityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
iconURL?: string;
|
||||
updatedAt?: string; // ISO timestamp for sorting by most recently edited
|
||||
// Bot-specific fields
|
||||
enabled?: boolean;
|
||||
legacyAdapter?: boolean;
|
||||
// MCP-specific fields
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error';
|
||||
// Plugin-specific fields
|
||||
installSource?: string;
|
||||
installInfo?: Record<string, unknown>;
|
||||
hasUpdate?: boolean;
|
||||
debug?: boolean;
|
||||
// Set when this item appears in the unified extensions list
|
||||
extensionType?: 'plugin' | 'mcp' | 'skill';
|
||||
// Agent-specific: distinguishes Agent processors from Pipelines
|
||||
kind?: 'agent' | 'pipeline' | 'event_processor';
|
||||
}
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
name: string; // display label
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
pluginLabel: string; // human-readable plugin display name
|
||||
pluginIconURL: string; // plugin icon URL
|
||||
pageId: string;
|
||||
path: string; // asset path (HTML file)
|
||||
}
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
pluginCount: number;
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
quotaDataLoaded: boolean;
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
refreshPlugins: () => Promise<void>;
|
||||
refreshMCPServers: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
refreshAll: () => Promise<void>;
|
||||
// Breadcrumb: entity name shown when viewing a detail page
|
||||
detailEntityName: string | null;
|
||||
setDetailEntityName: (name: string | null) => void;
|
||||
// Whether the extensions list is grouped by type (shared between page and sidebar)
|
||||
extensionsGroupByType: boolean;
|
||||
setExtensionsGroupByType: (enabled: boolean) => void;
|
||||
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
|
||||
agentsGroupByKind: boolean;
|
||||
setAgentsGroupByKind: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const SidebarDataContext = createContext<SidebarDataContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useSidebarData(): SidebarDataContextValue {
|
||||
const ctx = useContext(SidebarDataContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSidebarData must be used within a SidebarDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export default function DockIcon({ kind }: { kind: string }) {
|
||||
const paths: Record<string, string> = {
|
||||
event: 'M13 2 4 14h7l-1 8 10-12h-7l0-8z',
|
||||
activity: 'M3 12h4l3-8 4 16 3-8h4',
|
||||
bot: 'M8 4h8v3H8z M5 7h14v13H5z M9 12h.01 M15 12h.01 M9 16h6',
|
||||
pipeline: 'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7',
|
||||
agent: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M4 21v-2a8 8 0 0 1 16 0v2',
|
||||
processor: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
|
||||
completed: 'm5 12 4 4L19 6',
|
||||
failed: 'm6 6 12 12 M6 18 18 6',
|
||||
cancelled: 'm6 6 12 12 M6 18 18 6',
|
||||
queued: 'M8 5v14 M16 5v14',
|
||||
waiting: 'M8 5v14 M16 5v14',
|
||||
move: 'm8 6 4-4 4 4 M12 2v20 m-4-4 4 4 4-4',
|
||||
collapse: 'm9 5 7 7-7 7',
|
||||
};
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={paths[kind] || paths.activity} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useSidebarData } from '../home-sidebar/SidebarDataContext';
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { executionProcessorLabel } from '../../monitoring/components/executions/ExecutionTable';
|
||||
import DockIcon from './DockIcon';
|
||||
import styles from './inflight.module.css';
|
||||
|
||||
export default function ExecutionNodes({
|
||||
row,
|
||||
processorUrl,
|
||||
running,
|
||||
}: {
|
||||
row: ExecutionRow;
|
||||
processorUrl: string | null;
|
||||
running: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { bots, pipelines } = useSidebarData();
|
||||
const bot = bots.find((item) => item.id === row.bot_id);
|
||||
const processor = pipelines.find((item) => item.id === row.target_id);
|
||||
const nodes = [
|
||||
{
|
||||
name: bot?.name || row.bot_name || t('inflight.debug'),
|
||||
iconURL: bot?.iconURL,
|
||||
emoji: bot?.emoji,
|
||||
kind: 'bot',
|
||||
url: row.bot_id
|
||||
? `/home/bots?id=${encodeURIComponent(row.bot_id)}&tab=logs`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
name: processor?.name || executionProcessorLabel(row),
|
||||
iconURL: processor?.iconURL,
|
||||
emoji: processor?.emoji,
|
||||
kind:
|
||||
row.target_kind === 'event_processor' ? 'processor' : row.target_kind,
|
||||
url: processorUrl,
|
||||
},
|
||||
];
|
||||
const node = (index: number) => {
|
||||
const item = nodes[index];
|
||||
const content = (
|
||||
<>
|
||||
<Avatar className="size-5 shrink-0 rounded">
|
||||
{item.iconURL && (
|
||||
<AvatarImage src={item.iconURL} alt="" className="object-contain" />
|
||||
)}
|
||||
<AvatarFallback className="rounded bg-transparent text-base [&_svg]:size-4">
|
||||
{item.emoji || <DockIcon kind={item.kind} />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-[11px]">{item.name}</span>
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
'flex h-8 min-w-0 max-w-[125px] items-center gap-1.5 rounded-md bg-muted/40 px-1.5';
|
||||
return item.url ? (
|
||||
<Link
|
||||
to={item.url}
|
||||
title={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
|
||||
aria-label={`${index === 0 ? t('inflight.botLogs') : t('inflight.processorLogs')}: ${item.name}`}
|
||||
className={`${className} hover:bg-muted hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center">
|
||||
{node(0)}
|
||||
<span
|
||||
className={`w-5 shrink-0 ${styles.connection} ${running ? styles.flowing : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i />
|
||||
</span>
|
||||
{node(1)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { InflightSnapshot } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import ExecutionDetailSheet, {
|
||||
type ExecutionSelection,
|
||||
} from '@/app/home/monitoring/components/executions/ExecutionDetailSheet';
|
||||
import { executionStatusLabel } from '@/app/home/monitoring/components/executions/ExecutionTable';
|
||||
import styles from './inflight.module.css';
|
||||
import ExecutionNodes from './ExecutionNodes';
|
||||
import { useEdgeDock } from './useEdgeDock';
|
||||
import DockIcon from './DockIcon';
|
||||
|
||||
type Row = InflightSnapshot['items'][number] & { settledAt?: number };
|
||||
const keyOf = (row: Row) => `${row.source}:${row.id}`;
|
||||
const active = (row: Row) => ['running', 'queued'].includes(row.status_group);
|
||||
|
||||
function stage(row: Row): string {
|
||||
if (!active(row)) return row.status_group;
|
||||
if (row.status_group === 'queued') return 'queued';
|
||||
const event = row.progress_event || '';
|
||||
if (event.startsWith('interaction.')) return 'waiting';
|
||||
if (event.startsWith('tool.') && !event.endsWith('completed')) return 'tool';
|
||||
if (event.includes('reasoning')) return 'thinking';
|
||||
if (event === 'message.completed') return 'replying';
|
||||
if (event.startsWith('message.')) return 'generating';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function WorkspaceMonitor() {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const dock = useEdgeDock(true);
|
||||
const { collapsed, setCollapsed } = dock;
|
||||
const [connected, setConnected] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [selected, setSelected] = useState<ExecutionSelection | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const known = useRef(new Map<string, Row>());
|
||||
const [autoCollapse, setAutoCollapse] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('langbot.inflight.autoCollapse') !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// Keep terminal results visible until their exit animation has finished.
|
||||
const hasWork = total > 0 || rows.length > 0;
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'langbot.inflight.autoCollapse',
|
||||
String(autoCollapse),
|
||||
);
|
||||
} catch {
|
||||
/* Optional preference. */
|
||||
}
|
||||
}, [autoCollapse]);
|
||||
useEffect(() => {
|
||||
if (autoCollapse) setCollapsed(!hasWork);
|
||||
}, [autoCollapse, hasWork, setCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let first = true;
|
||||
let controller: AbortController | undefined;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
let failures = 0;
|
||||
const seen = new Set<string>();
|
||||
const accept = (snapshot: InflightSnapshot) => {
|
||||
const time = Date.now();
|
||||
const present = new Set(snapshot.items.map(keyOf));
|
||||
// A reconnect is a fresh authoritative snapshot. Do not leave vanished
|
||||
// executions spinning forever or infer successful completion from absence.
|
||||
if (!snapshot.truncated) {
|
||||
for (const [key, row] of known.current) {
|
||||
if (active(row) && !present.has(key)) known.current.delete(key);
|
||||
}
|
||||
}
|
||||
for (const row of snapshot.items) {
|
||||
const key = keyOf(row);
|
||||
const previous = known.current.get(key);
|
||||
if (active(row) || previous || (!first && !seen.has(key))) {
|
||||
known.current.set(key, {
|
||||
...row,
|
||||
settledAt: active(row) ? undefined : (previous?.settledAt ?? time),
|
||||
});
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
// Retain terminal results briefly, including runs completed between frames.
|
||||
for (const [key, row] of known.current) {
|
||||
if (row.settledAt && time - row.settledAt > 8000)
|
||||
known.current.delete(key);
|
||||
}
|
||||
while (seen.size > 2000) seen.delete(seen.values().next().value!);
|
||||
while (known.current.size > 200)
|
||||
known.current.delete(known.current.keys().next().value!);
|
||||
first = false;
|
||||
setRows([...known.current.values()]);
|
||||
setTotal(snapshot.active_total);
|
||||
setTruncated(snapshot.truncated);
|
||||
};
|
||||
const connect = async () => {
|
||||
if (disposed || document.hidden) return;
|
||||
controller = new AbortController();
|
||||
const connection = controller;
|
||||
try {
|
||||
await backendClient.streamInflight((frame) => {
|
||||
if (disposed || connection.signal.aborted) return;
|
||||
failures = 0;
|
||||
if (frame.kind !== 'heartbeat')
|
||||
setConnected(frame.kind !== 'unavailable');
|
||||
if (frame.kind === 'snapshot') accept(frame.data);
|
||||
}, connection.signal);
|
||||
} catch {
|
||||
if (!disposed && !connection.signal.aborted) {
|
||||
setConnected(false);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
if (!disposed && !connection.signal.aborted && !document.hidden) {
|
||||
retry = setTimeout(
|
||||
connect,
|
||||
Math.min(30000, 1000 * 2 ** failures) + Math.random() * 500,
|
||||
);
|
||||
}
|
||||
};
|
||||
const visibility = () => {
|
||||
clearTimeout(retry);
|
||||
controller?.abort();
|
||||
if (!document.hidden) retry = setTimeout(connect, 100);
|
||||
};
|
||||
void connect();
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
const tick = setInterval(() => {
|
||||
const time = Date.now();
|
||||
let changed = false;
|
||||
for (const [key, row] of known.current) {
|
||||
if (row.settledAt && time - row.settledAt > 8000) {
|
||||
known.current.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) setRows([...known.current.values()]);
|
||||
if (known.current.size) setNow(time);
|
||||
}, 1000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
clearTimeout(retry);
|
||||
clearInterval(tick);
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ordered = [...rows].sort(
|
||||
(a, b) =>
|
||||
Number(active(b)) - Number(active(a)) ||
|
||||
(b.created_at_ms ?? 0) - (a.created_at_ms ?? 0),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={dock.panel}
|
||||
style={{ width: collapsed ? 26 : 'min(320px, calc(100vw - 24px))' }}
|
||||
className={`fixed top-0 right-0 z-40 max-h-[calc(100dvh-24px)] ${collapsed ? 'overflow-hidden' : 'overflow-y-auto'} rounded-l-xl border border-r-0 bg-popover text-popover-foreground shadow-lg ${styles.panel}`}
|
||||
aria-label={t('inflight.title')}
|
||||
>
|
||||
<div
|
||||
key={collapsed ? 'collapsed' : 'expanded'}
|
||||
className={styles.dockContent}
|
||||
>
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
{...dock.handle}
|
||||
className="flex h-12 w-full touch-none select-none flex-col items-center justify-center gap-0.5 bg-[#2288ee] text-white hover:bg-[#2277e0] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
|
||||
title={`${t('inflight.expand')} · ${t('inflight.move')}`}
|
||||
aria-label={`${t('inflight.title')}: ${total}. ${t('inflight.expand')}. ${t('inflight.move')}`}
|
||||
aria-expanded={false}
|
||||
onClick={(event) => {
|
||||
if (event.detail === 0 || !dock.suppressClick.current)
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<DockIcon kind="activity" />
|
||||
<span
|
||||
className={`text-[10px] font-mono ${connected ? '' : 'text-amber-100'}`}
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
{...dock.handle}
|
||||
aria-label={t('inflight.move')}
|
||||
title={t('inflight.move')}
|
||||
className="flex min-w-0 flex-1 touch-none select-none items-center gap-2 rounded py-1 text-xs font-medium cursor-grab active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className={connected ? 'text-primary' : 'text-amber-600'}>
|
||||
<DockIcon kind="activity" />
|
||||
</span>
|
||||
{t('inflight.title')}{' '}
|
||||
<span className="font-mono text-muted-foreground">{total}</span>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
<DockIcon kind="move" />
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 shrink-0 gap-1 px-2 text-[10px] ${autoCollapse ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground'}`}
|
||||
aria-pressed={autoCollapse}
|
||||
title={t('inflight.autoCollapse')}
|
||||
onClick={() => setAutoCollapse((enabled) => !enabled)}
|
||||
>
|
||||
{autoCollapse && <DockIcon kind="completed" />}
|
||||
{t('inflight.autoCollapse')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('inflight.collapse')}
|
||||
title={t('inflight.collapse')}
|
||||
aria-expanded={true}
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<DockIcon kind="collapse" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
{!connected && (
|
||||
<p
|
||||
className="border-t px-4 py-2 text-xs text-amber-600"
|
||||
role="status"
|
||||
>
|
||||
{t('inflight.reconnecting')}
|
||||
</p>
|
||||
)}
|
||||
<div className="max-h-[min(420px,55dvh)] overflow-y-auto border-t">
|
||||
{rows.length === 0 && connected && (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-4 py-8 text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<DockIcon kind="completed" />
|
||||
<p className="text-xs">{t('inflight.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
{ordered.slice(0, 20).map((row) => {
|
||||
const phase = stage(row);
|
||||
const stageKey = `inflight.${phase}`;
|
||||
const label = t(stageKey, {
|
||||
defaultValue: executionStatusLabel(row, t),
|
||||
});
|
||||
const elapsed = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
((row.finished_at_ms ?? row.settledAt ?? now) -
|
||||
(row.started_at_ms ?? row.created_at_ms ?? now)) /
|
||||
1000,
|
||||
),
|
||||
);
|
||||
const eventKey = `bots.eventNames.${(row.event_type || row.title).replaceAll('.', '_')}`;
|
||||
const processorUrl =
|
||||
row.target_id &&
|
||||
[
|
||||
'pipeline',
|
||||
'agent',
|
||||
'processor',
|
||||
'event_processor',
|
||||
].includes(row.target_kind)
|
||||
? `/home/${row.target_kind === 'pipeline' ? 'pipelines' : 'agents'}?id=${encodeURIComponent(row.target_id)}&tab=logs`
|
||||
: null;
|
||||
const leaving =
|
||||
!!row.settledAt && now - row.settledAt >= 7000;
|
||||
return (
|
||||
<div
|
||||
key={keyOf(row)}
|
||||
className={`${styles.task} ${leaving ? styles.leaving : ''} border-b px-3 py-2 last:border-b-0`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
row.has_error ? 'text-destructive' : 'text-primary'
|
||||
}
|
||||
title={label}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<DockIcon kind={active(row) ? 'event' : phase} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(row)}
|
||||
className="min-w-0 flex-1 truncate rounded py-1 text-left text-xs hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`${t('inflight.details')}: ${row.input_preview || row.event_type || row.title}`}
|
||||
aria-label={`${t('inflight.details')}: ${row.event_type || row.title}`}
|
||||
>
|
||||
{t(eventKey, {
|
||||
defaultValue: row.event_type || row.title,
|
||||
})}
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{elapsed}s
|
||||
</span>
|
||||
</div>
|
||||
<ExecutionNodes
|
||||
row={row}
|
||||
processorUrl={processorUrl}
|
||||
running={active(row) && connected}
|
||||
/>
|
||||
<div
|
||||
className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
{...(!active(row)
|
||||
? {
|
||||
'aria-valuenow': 100,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${row.has_error ? 'bg-destructive' : 'bg-primary'} ${active(row) && connected ? styles.active : 'w-full'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(truncated || rows.length > 20) && (
|
||||
<p className="border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
{t('inflight.limited')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<ExecutionDetailSheet
|
||||
row={selected}
|
||||
onSelect={setSelected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InFlightMonitor() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
if (!workspace?.permissions.includes('resource.view')) return null;
|
||||
return <WorkspaceMonitor key={workspace.workspace.uuid} />;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.active {
|
||||
width: 40%;
|
||||
animation: travel 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes travel {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(350%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.active {
|
||||
animation: none;
|
||||
width: 40%;
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
contain: layout;
|
||||
overflow-x: hidden;
|
||||
transition:
|
||||
width 180ms ease-out,
|
||||
transform 180ms ease-out;
|
||||
}
|
||||
.task {
|
||||
animation: arrive 180ms ease-out both;
|
||||
}
|
||||
.leaving {
|
||||
animation: depart 180ms ease-in both;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes arrive {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes depart {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.task,
|
||||
.leaving {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel[data-dragging='true'] {
|
||||
transition: none;
|
||||
}
|
||||
.dockContent {
|
||||
animation: dockReveal 160ms ease-out both;
|
||||
}
|
||||
@keyframes dockReveal {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel {
|
||||
transition: none;
|
||||
}
|
||||
.dockContent {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.connection {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.connection::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: -2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-top: 1.5px solid var(--muted-foreground);
|
||||
border-right: 1.5px solid var(--muted-foreground);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.flowing {
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
.flowing i {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
animation: nodeFlow 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nodeFlow {
|
||||
from {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
to {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flowing i {
|
||||
animation: none;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
|
||||
const KEY = 'langbot.inflight.dock';
|
||||
function saved() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function useEdgeDock(visible: boolean) {
|
||||
const [preference] = useState(saved);
|
||||
const [collapsed, setCollapsed] = useState(preference?.collapsed === true);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const position = useRef(
|
||||
Number.isFinite(preference?.position)
|
||||
? Math.min(1, Math.max(0, preference.position))
|
||||
: 0.55,
|
||||
);
|
||||
const drag = useRef<{
|
||||
id: number;
|
||||
y: number;
|
||||
top: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
// Anchor the top edge to the viewport, never to the changing content height.
|
||||
const range = () => Math.max(0, window.innerHeight - 48 - 24);
|
||||
const place = (top = 12 + position.current * range()) => {
|
||||
const bounded = Math.max(12, Math.min(top, 12 + range()));
|
||||
if (panel.current) {
|
||||
panel.current.style.transform = `translateY(${bounded}px)`;
|
||||
panel.current.style.maxHeight = `${Math.max(48, window.innerHeight - bounded - 12)}px`;
|
||||
}
|
||||
return bounded;
|
||||
};
|
||||
const persist = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ collapsed, position: position.current }),
|
||||
);
|
||||
} catch {
|
||||
/* Optional preference. */
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
persist();
|
||||
}, [collapsed]);
|
||||
useLayoutEffect(() => {
|
||||
if (!visible || !panel.current) return;
|
||||
const update = () => {
|
||||
place();
|
||||
};
|
||||
window.addEventListener('resize', update);
|
||||
update();
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [visible, collapsed]);
|
||||
const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (panel.current) panel.current.dataset.dragging = 'true';
|
||||
suppressClick.current = false;
|
||||
drag.current = {
|
||||
id: event.pointerId,
|
||||
y: event.clientY,
|
||||
top: panel.current?.getBoundingClientRect().top || 12,
|
||||
moved: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
const current = drag.current;
|
||||
if (!current || current.id !== event.pointerId) return;
|
||||
const delta = event.clientY - current.y;
|
||||
if (Math.abs(delta) > 5) current.moved = true;
|
||||
if (current.moved) {
|
||||
const top = place(current.top + delta);
|
||||
position.current = range() ? (top - 12) / range() : 0;
|
||||
}
|
||||
};
|
||||
const finish = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!drag.current || drag.current.id !== event.pointerId) return;
|
||||
suppressClick.current = drag.current.moved;
|
||||
drag.current = null;
|
||||
if (panel.current) delete panel.current.dataset.dragging;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId))
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
persist();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const top =
|
||||
event.key === 'Home'
|
||||
? 12
|
||||
: event.key === 'End'
|
||||
? 12 + range()
|
||||
: (panel.current?.getBoundingClientRect().top || 12) +
|
||||
(event.key === 'ArrowUp' ? -24 : 24);
|
||||
const bounded = place(top);
|
||||
position.current = range() ? (bounded - 12) / range() : 0;
|
||||
persist();
|
||||
};
|
||||
return {
|
||||
panel,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
suppressClick,
|
||||
handle: {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: finish,
|
||||
onPointerCancel: finish,
|
||||
onLostPointerCapture: finish,
|
||||
onKeyDown,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default function LangBotModelMetadata({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs tabular-nums text-muted-foreground"
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap text-xs tabular-nums text-muted-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Coins className="size-3" />
|
||||
@@ -59,12 +59,12 @@ export default function LangBotModelMetadata({
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
<div className="space-y-0.5">
|
||||
<p>{t('models.pricing.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs">
|
||||
{t('models.pricing.input', {
|
||||
credits: input,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs">
|
||||
{t('models.pricing.output', {
|
||||
credits: output,
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
@@ -270,9 +271,7 @@ export default function ProviderForm({
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
<LoadErrorState compact title={t('models.loadError')} />
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TestResult,
|
||||
} from '../types';
|
||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
import ModelStar from './ModelStar';
|
||||
import { userInfo } from '@/app/infra/http';
|
||||
import LangBotModelMetadata from '../../model-availability/LangBotModelMetadata';
|
||||
|
||||
@@ -197,14 +198,14 @@ export default function ModelItem({
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={`flex items-center justify-between py-2 px-3 rounded-md border bg-background ${
|
||||
className={`flex items-center gap-3 py-2 px-3 rounded-md border bg-background ${
|
||||
isPopoverDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'hover:bg-accent cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 flex-wrap">
|
||||
<span className="break-all text-sm font-medium">{model.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modelType === 'llm'
|
||||
? t('models.chat')
|
||||
@@ -240,6 +241,12 @@ export default function ModelItem({
|
||||
loaded={availabilityLoaded}
|
||||
/>
|
||||
)}
|
||||
{modelType === 'llm' && (
|
||||
<ModelStar
|
||||
uuid={model.uuid}
|
||||
disabled={!canManage || isPopoverDisabled}
|
||||
/>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<Popover
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const records = new Map<
|
||||
string,
|
||||
{ uuid: string | null; busy: boolean; loaded: boolean }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
const pending = new Map<string, Promise<void>>();
|
||||
const empty = { uuid: null, busy: false, loaded: false };
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
function update(
|
||||
workspace: string,
|
||||
value: typeof empty | { uuid: string | null; busy: boolean; loaded: boolean },
|
||||
) {
|
||||
records.set(workspace, value);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export default function ModelStar({
|
||||
uuid,
|
||||
disabled,
|
||||
}: {
|
||||
uuid: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const workspace = useCurrentWorkspace()?.workspace.uuid ?? '';
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => records.get(workspace) ?? empty,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!workspace || pending.has(workspace)) return;
|
||||
const request = backendClient
|
||||
.getStarredModel()
|
||||
.then((result) => {
|
||||
update(workspace, { uuid: result.uuid, busy: false, loaded: true });
|
||||
})
|
||||
.catch(() => {
|
||||
update(workspace, { uuid: null, busy: false, loaded: false });
|
||||
})
|
||||
.finally(() => pending.delete(workspace));
|
||||
pending.set(workspace, request);
|
||||
}, [workspace]);
|
||||
const starred = state.uuid === uuid;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 shrink-0 ${starred ? 'text-amber-500 hover:text-amber-600' : 'text-muted-foreground'}`}
|
||||
disabled={disabled || state.busy || !state.loaded}
|
||||
aria-pressed={starred}
|
||||
aria-label={t(starred ? 'models.unstarModel' : 'models.starModel')}
|
||||
title={t(starred ? 'models.unstarModel' : 'models.starModel')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
const next = starred ? null : uuid;
|
||||
update(workspace, { ...state, busy: true });
|
||||
try {
|
||||
await backendClient.setStarredModel(next);
|
||||
update(workspace, { uuid: next, busy: false, loaded: true });
|
||||
} catch {
|
||||
update(workspace, { ...state, busy: false });
|
||||
toast.error(t('models.starModelFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Star className={`size-3.5 ${starred ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -195,9 +195,18 @@ export default function ProviderCard({
|
||||
{t('models.modelsCount', { count: totalModels })}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<p
|
||||
className={`text-xs text-muted-foreground ${isLangBotModels ? 'leading-relaxed' : 'truncate'}`}
|
||||
>
|
||||
{isLangBotModels ? (
|
||||
t('models.langbotModelsDescription')
|
||||
<>
|
||||
{t('models.langbotModelsDescription')}
|
||||
{!ownerSpaceBound && (
|
||||
<span className="mt-1 block">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{provider.base_url}
|
||||
@@ -222,7 +231,7 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-4 w-4 mr-1" />
|
||||
{t('models.ownerMustBindSpace')}
|
||||
{t('models.loginWithSpace')}
|
||||
</Button>
|
||||
)}
|
||||
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
|
||||
@@ -253,11 +262,6 @@ export default function ProviderCard({
|
||||
{t('models.usesOwnerSpaceBilling')}
|
||||
</span>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<>
|
||||
<Button
|
||||
@@ -363,7 +367,7 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
}
|
||||
onOpen={() => {
|
||||
@@ -390,14 +394,15 @@ export default function ProviderCard({
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddModelMode('scan');
|
||||
}}
|
||||
>
|
||||
<Radar className="h-3 w-3" />
|
||||
<Radar className="h-3 w-3 mr-1" />
|
||||
{t('models.scanModels')}
|
||||
</Button>
|
||||
}
|
||||
onOpen={() => {
|
||||
|
||||
@@ -28,8 +28,7 @@ export interface ProviderModels {
|
||||
}
|
||||
|
||||
export type TestResult =
|
||||
| { success: true; duration: number }
|
||||
| { success: false; message: string };
|
||||
{ success: true; duration: number } | { success: false; message: string };
|
||||
|
||||
export type SelectedScannedModel = {
|
||||
model: ScannedProviderModel;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { BarChart3, Bug, Info, Settings } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -56,6 +57,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
debugDisconnectedLabel?: string;
|
||||
unsavedLabel?: string;
|
||||
monitoring?: ProcessorMonitoringView;
|
||||
view?: 'workbench' | 'monitoring';
|
||||
onViewChange?: (view: 'workbench' | 'monitoring') => void;
|
||||
}
|
||||
|
||||
@@ -83,11 +85,12 @@ export default function ProcessorDetailWorkbench({
|
||||
debugDisconnectedLabel,
|
||||
unsavedLabel,
|
||||
monitoring,
|
||||
view,
|
||||
onViewChange,
|
||||
}: ProcessorDetailWorkbenchProps) {
|
||||
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||
'workbench',
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeView =
|
||||
searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
|
||||
const hasDebug = Boolean(debugTitle && debugContent);
|
||||
|
||||
const configPanel = (
|
||||
@@ -143,10 +146,17 @@ export default function ProcessorDetailWorkbench({
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeView}
|
||||
value={view ?? activeView}
|
||||
onValueChange={(value) => {
|
||||
const view = value as 'workbench' | 'monitoring';
|
||||
setActiveView(view);
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set('tab', view === 'monitoring' ? 'logs' : 'config');
|
||||
return next;
|
||||
},
|
||||
{ preventScrollReset: true },
|
||||
);
|
||||
onViewChange?.(view);
|
||||
}}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
||||
@@ -226,7 +236,7 @@ export default function ProcessorDetailWorkbench({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canSave && activeView === 'workbench' && (
|
||||
{canSave && (view ?? activeView) === 'workbench' && (
|
||||
<Button
|
||||
type="submit"
|
||||
form={saveFormId}
|
||||
@@ -236,7 +246,7 @@ export default function ProcessorDetailWorkbench({
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{activeView === 'workbench' && headerActions}
|
||||
{(view ?? activeView) === 'workbench' && headerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
import { getBackendBaseUrl } from '@/app/infra/http/backendUrl';
|
||||
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'qqofficial';
|
||||
'feishu' | 'weixin' | 'dingtalk' | 'wecombot' | 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
@@ -83,7 +79,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
client_id: data.client_id,
|
||||
client_secret: data.client_secret,
|
||||
}),
|
||||
successNoteKey: 'dingtalk.robotCodeNote',
|
||||
},
|
||||
wecombot: {
|
||||
titleKey: 'wecombot.createBot',
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getBackendBaseUrl } from '@/app/infra/http/backendUrl';
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
|
||||
type Config = Record<string, string | boolean>;
|
||||
|
||||
export default function SlackSetupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
botId,
|
||||
webhookUrl,
|
||||
socketMode,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
botId?: string;
|
||||
webhookUrl: string;
|
||||
socketMode: boolean;
|
||||
onSuccess: (config: Config) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [access, setAccess] = useState('');
|
||||
const [refresh, setRefresh] = useState('');
|
||||
const [name, setName] = useState('LangBot');
|
||||
const [url, setUrl] = useState(webhookUrl);
|
||||
const [redirect, setRedirect] = useState('');
|
||||
const [appToken, setAppToken] = useState('');
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [error, setError] = useState('');
|
||||
const [authorizeUrl, setAuthorizeUrl] = useState('');
|
||||
const [appId, setAppId] = useState('');
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const generation = useRef(0);
|
||||
const session = useRef<{
|
||||
id: string;
|
||||
base: string;
|
||||
headers: Record<string, string>;
|
||||
} | null>(null);
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
|
||||
const cleanup = () => {
|
||||
generation.current++;
|
||||
const current = session.current;
|
||||
session.current = null;
|
||||
if (current)
|
||||
void fetch(`${current.base}/${current.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: current.headers,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUrl(webhookUrl);
|
||||
const backend = new URL(
|
||||
getBackendBaseUrl() || window.location.origin,
|
||||
window.location.origin,
|
||||
);
|
||||
if (backend.hostname === '127.0.0.1') backend.hostname = 'localhost';
|
||||
setRedirect(
|
||||
`${backend.href.replace(/\/$/, '')}/api/v1/platform/adapters/slack/setup/callback`,
|
||||
);
|
||||
setStatus('idle');
|
||||
setError('');
|
||||
setConfig(null);
|
||||
setAppId('');
|
||||
setAuthorizeUrl('');
|
||||
}
|
||||
return () => {
|
||||
cleanup();
|
||||
setAccess('');
|
||||
setRefresh('');
|
||||
setAppToken('');
|
||||
};
|
||||
}, [open, webhookUrl]);
|
||||
|
||||
const start = async () => {
|
||||
cleanup();
|
||||
const version = generation.current;
|
||||
setStatus('creating');
|
||||
setError('');
|
||||
const workspace = getActiveWorkspaceUuid();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
...(workspace ? { 'X-Workspace-Id': workspace } : {}),
|
||||
};
|
||||
const base = `${getBackendBaseUrl()}/api/v1/platform/adapters/slack/setup`;
|
||||
try {
|
||||
const response = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
bot_uuid: botId,
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
name,
|
||||
webhook_url: url,
|
||||
socket_mode: socketMode,
|
||||
redirect_url: redirect,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0)
|
||||
throw new Error(result.msg || `HTTP ${response.status}`);
|
||||
const current = { id: result.data.session_id as string, base, headers };
|
||||
if (generation.current !== version) {
|
||||
void fetch(`${base}/${current.id}`, { method: 'DELETE', headers });
|
||||
return;
|
||||
}
|
||||
session.current = current;
|
||||
setAccess('');
|
||||
setRefresh('');
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
while (generation.current === version && Date.now() < deadline) {
|
||||
const poll = await fetch(`${base}/${current.id}`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const json = await poll.json();
|
||||
if (generation.current !== version) return;
|
||||
if (!poll.ok || json.code !== 0)
|
||||
throw new Error(json.msg || `HTTP ${poll.status}`);
|
||||
const data = json.data;
|
||||
setStatus(data.status);
|
||||
if (data.app_id) setAppId(data.app_id);
|
||||
if (data.authorize_url) setAuthorizeUrl(data.authorize_url);
|
||||
if (data.status === 'error') throw new Error(data.error);
|
||||
if (data.status === 'success') {
|
||||
setConfig(data.config);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
if (generation.current === version) throw new Error('session_expired');
|
||||
} catch (cause) {
|
||||
if (generation.current !== version) return;
|
||||
setError(cause instanceof Error ? cause.message : 'setup_failed');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
if (!config) return;
|
||||
onSuccessRef.current({
|
||||
...config,
|
||||
...(socketMode ? { app_token: appToken.trim() } : {}),
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('slackSetup.title')}</DialogTitle>
|
||||
<DialogDescription>{t('slackSetup.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!botId ? (
|
||||
<p>{t('slackSetup.saveFirst')}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{status === 'idle' && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('slackSetup.permissionsHint')}
|
||||
</p>
|
||||
<a
|
||||
className="text-sm text-primary underline"
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('slackSetup.generate')}
|
||||
</a>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-name">{t('slackSetup.name')}</Label>
|
||||
<Input
|
||||
id="slack-name"
|
||||
value={name}
|
||||
maxLength={35}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-access">Access Token</Label>
|
||||
<Input
|
||||
id="slack-access"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={access}
|
||||
onChange={(e) => setAccess(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-refresh">Refresh Token</Label>
|
||||
<Input
|
||||
id="slack-refresh"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={refresh}
|
||||
onChange={(e) => setRefresh(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-url">
|
||||
{t(
|
||||
socketMode ? 'slackSetup.redirect' : 'slackSetup.webhook',
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-url"
|
||||
value={socketMode ? redirect : url}
|
||||
onChange={(e) =>
|
||||
socketMode
|
||||
? setRedirect(e.target.value)
|
||||
: setUrl(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
socketMode
|
||||
? 'slackSetup.redirectHint'
|
||||
: 'slackSetup.webhookHint',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={!access.trim() || !refresh.trim() || !name.trim()}
|
||||
>
|
||||
{t('slackSetup.create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(status === 'creating' || status === 'authorizing') && (
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t(`slackSetup.${status}`)}
|
||||
</p>
|
||||
)}
|
||||
{status === 'waiting' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{t('slackSetup.waiting')}</p>
|
||||
<Button asChild>
|
||||
<a href={authorizeUrl} target="_blank" rel="noreferrer">
|
||||
{t('slackSetup.authorize')}
|
||||
<ExternalLink className="ml-2 size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{socketMode && appId && (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<p className="text-sm">{t('slackSetup.appTokenHint')}</p>
|
||||
<a
|
||||
className="text-sm text-primary underline"
|
||||
href={`https://api.slack.com/apps/${appId}/general`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('slackSetup.openApp')}
|
||||
</a>
|
||||
<Label htmlFor="slack-app-token" className="block">
|
||||
App-Level Token
|
||||
</Label>
|
||||
<Input
|
||||
id="slack-app-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="xapp-…"
|
||||
value={appToken}
|
||||
onChange={(e) => setAppToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="space-y-2">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('slackSetup.failed')} {error}
|
||||
</p>
|
||||
{appId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('slackSetup.appCreated')} {appId}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
cleanup();
|
||||
setStatus('idle');
|
||||
}}
|
||||
>
|
||||
{t('slackSetup.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{t('slackSetup.success')}</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={apply}
|
||||
disabled={socketMode && !appToken.trim().startsWith('xapp-')}
|
||||
>
|
||||
{t('slackSetup.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -105,13 +105,6 @@ export default function SettingsDialog({
|
||||
description: t('workspace.description'),
|
||||
icon: <UsersRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'operationTrace',
|
||||
label: t('settingsDialog.nav.operationTrace'),
|
||||
title: t('operationTrace.title'),
|
||||
description: t('operationTrace.description'),
|
||||
icon: <History className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'models',
|
||||
label: t('settingsDialog.nav.models'),
|
||||
@@ -140,6 +133,13 @@ export default function SettingsDialog({
|
||||
description: t('account.settingsDescription'),
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'operationTrace',
|
||||
label: t('settingsDialog.nav.operationTrace'),
|
||||
title: t('operationTrace.title'),
|
||||
description: t('operationTrace.description'),
|
||||
icon: <History className="size-4" />,
|
||||
},
|
||||
];
|
||||
const permissions = currentWorkspace?.permissions ?? [];
|
||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||
@@ -191,7 +191,7 @@ export default function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
|
||||
className="flex h-[80dvh] max-h-[800px] min-h-0 flex-col gap-0 overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
|
||||
// Fixed height so switching sections never resizes the dialog; each
|
||||
// panel scrolls its own content internally.
|
||||
>
|
||||
@@ -202,7 +202,7 @@ export default function SettingsDialog({
|
||||
|
||||
{/* Override the SidebarProvider wrapper's default h-svh so the two
|
||||
columns fill the dialog's fixed height instead of the viewport. */}
|
||||
<SidebarProvider className="!min-h-0 h-full">
|
||||
<SidebarProvider className="!min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden h-full w-44 shrink-0 border-r md:flex"
|
||||
@@ -231,7 +231,7 @@ export default function SettingsDialog({
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile section switcher (sidebar is hidden on small screens) */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
|
||||
{navItems.map((item) => (
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/* Brief gestures separated by long rests keep the collapsed dock quiet. */
|
||||
.magic {
|
||||
animation: assistant-wave 9s ease-in-out infinite;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
animation: assistant-sparkle 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dock:hover .magic,
|
||||
.dock:hover .sparkle,
|
||||
.dock:focus-visible .magic,
|
||||
.dock:focus-visible .sparkle {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes assistant-wave {
|
||||
0%,
|
||||
84%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0);
|
||||
}
|
||||
87% {
|
||||
transform: translateY(-2px) rotate(-10deg);
|
||||
}
|
||||
90% {
|
||||
transform: translateY(-2px) rotate(8deg);
|
||||
}
|
||||
93% {
|
||||
transform: translateY(-1px) rotate(-5deg);
|
||||
}
|
||||
96% {
|
||||
transform: translateY(0) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes assistant-sparkle {
|
||||
0%,
|
||||
86%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
90% {
|
||||
transform: scale(1.2) rotate(12deg);
|
||||
}
|
||||
96% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.magic,
|
||||
.sparkle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
box-shadow: 0 -4px 20px #0000000d;
|
||||
transform-origin: bottom center;
|
||||
animation: assistant-arrive 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
.closing {
|
||||
animation: assistant-leave 180ms ease-in both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dock {
|
||||
animation: assistant-arrive 160ms ease-out both;
|
||||
}
|
||||
.conversationPanel {
|
||||
height: min(420px, calc(100dvh - 48px));
|
||||
}
|
||||
.header {
|
||||
background: var(--background);
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 10px;
|
||||
color: #2288ee;
|
||||
background: color-mix(in srgb, #2288ee 8%, var(--background));
|
||||
}
|
||||
.welcome {
|
||||
padding: 6px 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.prompt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--muted);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
.promptIcon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: #2288ee;
|
||||
}
|
||||
.prompt:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 2px solid #2288ee;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.prompt:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.userMessage {
|
||||
background: color-mix(in srgb, #2288ee 8%, var(--background));
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.assistantMessage {
|
||||
background: var(--muted);
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
/* Use the debug dialog's Markdown styling, scaled to the compact assistant. */
|
||||
.messageContent:global(.markdown-body) {
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.messageContent:global(.markdown-body) :is(p, ul, ol, blockquote, pre, table) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.messageContent:global(.markdown-body) :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.messageContent:global(.markdown-body) > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.messageContent:global(.markdown-body) > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.messageContent:global(.markdown-body) :is(ul, ol) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.messageContent:global(.markdown-body) li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.messageContent:global(.markdown-body) li > p {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.messageContent:global(.markdown-body) pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
font-size: 12px;
|
||||
}
|
||||
.messageContent:global(.markdown-body) table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.messageContent:global(.markdown-body) :is(th, td) {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: var(--muted);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.send,
|
||||
.dock {
|
||||
background: #2288ee;
|
||||
box-shadow: none;
|
||||
}
|
||||
.send:hover:not(:disabled),
|
||||
.dock:hover {
|
||||
background: #1878d8;
|
||||
}
|
||||
@keyframes assistant-arrive {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes assistant-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.closing,
|
||||
.dock {
|
||||
animation: none;
|
||||
}
|
||||
.prompt,
|
||||
.composer {
|
||||
transition: none;
|
||||
}
|
||||
.prompt:hover:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
@@ -46,27 +47,28 @@ export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
||||
skills,
|
||||
quotaDataLoaded,
|
||||
} = useSidebarData();
|
||||
const limitation = systemInfo.limitation;
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const cloud = currentWorkspace?.workspace.source === 'cloud_projection';
|
||||
const limitation = cloud
|
||||
? currentWorkspace.limitation
|
||||
: systemInfo.limitation;
|
||||
const loaded = quotaDataLoaded && (!cloud || limitation != null);
|
||||
|
||||
const botQuota = quotaItem(
|
||||
bots.length,
|
||||
limitation?.max_bots,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const botQuota = quotaItem(bots.length, limitation?.max_bots, loaded);
|
||||
const pipelineQuota = quotaItem(
|
||||
pipelines.length,
|
||||
limitation?.max_pipelines,
|
||||
quotaDataLoaded,
|
||||
loaded,
|
||||
);
|
||||
const knowledgeBaseQuota = quotaItem(
|
||||
knowledgeBases.length,
|
||||
limitation?.max_knowledge_bases,
|
||||
quotaDataLoaded,
|
||||
loaded,
|
||||
);
|
||||
const extensionQuota = quotaItem(
|
||||
pluginCount + mcpServers.length + skills.length,
|
||||
limitation?.max_extensions,
|
||||
quotaDataLoaded,
|
||||
loaded,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import { useEffect, useState, type ReactNode, type ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Info,
|
||||
Layers,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import * as RemixIcons from '@remixicon/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
|
||||
import {
|
||||
cloudPlanCheckoutURL,
|
||||
cloudPlanPlatformIconURL,
|
||||
cloudSubscriptionManagementURL,
|
||||
cloudPlanPrice,
|
||||
cloudPlanCurrencyForLanguage,
|
||||
fetchCloudPlanRevision,
|
||||
fetchPublicCloudPlans,
|
||||
localizedPlanText,
|
||||
type CloudPlanBillingCycle,
|
||||
type PublicCloudPlan,
|
||||
} from './cloud-plans';
|
||||
import { revisionRows } from './plan-revisions';
|
||||
import { supportedPlanPlatforms } from './cloud-plan-platforms';
|
||||
import PlanRevisionStack from './PlanRevisionStack';
|
||||
|
||||
function PlanIcon({ name }: { name?: string }) {
|
||||
const key = name
|
||||
?.replace(/^ri-/, '')
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
const Icon =
|
||||
name && /^ri-[a-z0-9-]+-(line|fill)$/.test(name)
|
||||
? (
|
||||
RemixIcons as unknown as Record<
|
||||
string,
|
||||
ComponentType<{ className?: string }>
|
||||
>
|
||||
)[`Ri${key}`]
|
||||
: undefined;
|
||||
return Icon ? (
|
||||
<Icon className="size-4 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<Check className="size-4 shrink-0 text-blue-500" />
|
||||
);
|
||||
}
|
||||
|
||||
function Help({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex h-5 shrink-0 items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 whitespace-pre-line">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Benefits({
|
||||
plan,
|
||||
spaceURL,
|
||||
}: {
|
||||
plan: Pick<
|
||||
PublicCloudPlan,
|
||||
| 'limits'
|
||||
| 'features'
|
||||
| 'disabled_adapters'
|
||||
| 'display_config'
|
||||
| 'chat_history_retention_days'
|
||||
| 'monthly_bonus_credits'
|
||||
>;
|
||||
spaceURL: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const text = (values: Parameters<typeof localizedPlanText>[0]) =>
|
||||
localizedPlanText(values, i18n.language);
|
||||
const config = plan.display_config;
|
||||
const platforms = supportedPlanPlatforms(plan.disabled_adapters);
|
||||
const quotaLabel = text(config?.quota_summary) || t('cloudPlans.quotas');
|
||||
const legacyFeatures: Record<string, string> = {
|
||||
'Multi-user workspace': 'multiUserWorkspace',
|
||||
'Remote E2B sandbox configuration': 'remoteE2BSandbox',
|
||||
'Community support': 'communitySupport',
|
||||
'Everything in Free': 'everythingInFree',
|
||||
'One always-on global sandbox': 'alwaysOnSandbox',
|
||||
'Higher workspace limits': 'higherWorkspaceLimits',
|
||||
};
|
||||
const items =
|
||||
config?.items ??
|
||||
(plan.features ?? []).map((feature) => ({
|
||||
label: {
|
||||
en_US: legacyFeatures[feature]
|
||||
? t(`cloudPlans.features.${legacyFeatures[feature]}`)
|
||||
: feature,
|
||||
},
|
||||
icon: 'ri-check-line',
|
||||
tooltip: {},
|
||||
}));
|
||||
const rows: Record<string, ReactNode> = {
|
||||
quota_summary: (
|
||||
<>
|
||||
<Layers className="size-4 shrink-0 text-blue-500" />
|
||||
<span className="min-w-0 break-words">{quotaLabel}</span>
|
||||
<Help label={quotaLabel}>
|
||||
{text(config?.tooltips?.quota_summary) && (
|
||||
<p className="mb-2">{text(config?.tooltips?.quota_summary)}</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{Object.entries(plan.limits)
|
||||
.filter(([key]) => key !== 'chat_history.retention_days')
|
||||
.map(([key, limit]) => {
|
||||
const resource = key.replace(/\.max$/, '');
|
||||
const label =
|
||||
text(config?.quotas?.[key]?.label) ||
|
||||
t(`cloudPlans.limits.${resource}`, {
|
||||
defaultValue: resource.replace(/_/g, ' '),
|
||||
});
|
||||
return (
|
||||
<li key={key}>
|
||||
<div className="flex justify-between gap-5">
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{limit < 0
|
||||
? t('cloudPlans.unlimited')
|
||||
: limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{text(config?.quotas?.[key]?.tooltip) && (
|
||||
<p className="mt-1 opacity-75">
|
||||
{text(config?.quotas?.[key]?.tooltip)}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Help>
|
||||
</>
|
||||
),
|
||||
retention: (
|
||||
<>
|
||||
<PlanIcon name="ri-history-line" />
|
||||
<span className="min-w-0 break-words">
|
||||
{t('cloudPlans.retention', {
|
||||
count: plan.chat_history_retention_days,
|
||||
})}
|
||||
</span>
|
||||
<Help label={t('cloudPlans.retentionLabel')}>
|
||||
{text(config?.tooltips?.retention) || t('cloudPlans.retentionHelp')}
|
||||
</Help>
|
||||
</>
|
||||
),
|
||||
monthly_credits: (
|
||||
<>
|
||||
<PlanIcon name="ri-coin-line" />
|
||||
<span className="min-w-0 break-words">
|
||||
{t('cloudPlans.credits', { count: plan.monthly_bonus_credits })}
|
||||
</span>
|
||||
<Help label={t('cloudPlans.creditsLabel')}>
|
||||
{text(config?.tooltips?.monthly_credits) ||
|
||||
t('cloudPlans.creditsHelp')}
|
||||
</Help>
|
||||
</>
|
||||
),
|
||||
supported_platforms: (
|
||||
<>
|
||||
<PlanIcon name="ri-apps-2-line" />
|
||||
<span className="min-w-0 break-words">{t('cloudPlans.platforms')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={t('cloudPlans.platforms')}
|
||||
className="flex h-5 shrink-0 items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType === 'touch') event.preventDefault();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-[min(360px,calc(100vw-2rem))] bg-foreground p-3 text-background"
|
||||
arrowClassName="bg-foreground fill-foreground"
|
||||
>
|
||||
{text(config?.tooltips?.supported_platforms) && (
|
||||
<p className="mb-2 whitespace-pre-line break-words">
|
||||
{text(config?.tooltips?.supported_platforms)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5" data-supported-platforms>
|
||||
{platforms.map((platform) => (
|
||||
<span
|
||||
key={platform.name}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-background/30 px-1.5 py-1 text-xs"
|
||||
>
|
||||
<img
|
||||
src={cloudPlanPlatformIconURL(spaceURL, platform.icon)}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="size-4 shrink-0 rounded-sm bg-white p-0.5 object-contain"
|
||||
/>
|
||||
{t(
|
||||
`cloudPlans.platformNames.${platform.name.replace('LINE', 'line').replace('openclaw-weixin', 'openclaw_weixin')}`,
|
||||
{ defaultValue: platform.label },
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{platforms.length === 0 && <p>{t('common.none')}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
};
|
||||
items.forEach((item, index) => {
|
||||
const label = text(item.label);
|
||||
rows[`item:${index}`] = (
|
||||
<>
|
||||
<PlanIcon name={item.icon} />
|
||||
<span className="min-w-0 whitespace-pre-line break-words">{label}</span>
|
||||
{text(item.tooltip) && <Help label={label}>{text(item.tooltip)}</Help>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
const order = [
|
||||
...new Set([
|
||||
...(config?.order ?? []),
|
||||
'quota_summary',
|
||||
'supported_platforms',
|
||||
'retention',
|
||||
'monthly_credits',
|
||||
...items.map((_, index) => `item:${index}`),
|
||||
]),
|
||||
].filter((key) => key in rows);
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{order.map((key) => (
|
||||
<li key={key} className="flex items-start gap-2 break-words">
|
||||
{rows[key]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CloudPlansDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspace,
|
||||
spaceURL,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspace: CurrentWorkspace;
|
||||
spaceURL: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [plans, setPlans] = useState<PublicCloudPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [cycle, setCycle] = useState<CloudPlanBillingCycle>('annual');
|
||||
const currency = cloudPlanCurrencyForLanguage(i18n.language);
|
||||
const canManage = ['owner', 'admin'].includes(workspace.membership.role);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
fetchPublicCloudPlans(spaceURL, controller.signal)
|
||||
.then((value) => {
|
||||
if (active) setPlans(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timeout);
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [open, spaceURL, attempt]);
|
||||
const owned = workspace.current_plan;
|
||||
const latest = plans.find((plan) => plan.name === owned?.name);
|
||||
// Catalog copy of the purchased revision, so its card renders exactly like
|
||||
// the latest one. Benefits still come from the entitlement snapshot below.
|
||||
const [ownedRevision, setOwnedRevision] = useState<PublicCloudPlan | null>(
|
||||
null,
|
||||
);
|
||||
const ownedName = owned?.name;
|
||||
const ownedRevisionNumber = owned?.revision;
|
||||
const needsOwnedRevision =
|
||||
!!latest && !!ownedName && latest.revision !== ownedRevisionNumber;
|
||||
useEffect(() => {
|
||||
setOwnedRevision(null);
|
||||
if (!open || !needsOwnedRevision || !ownedName || !ownedRevisionNumber)
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
fetchCloudPlanRevision(
|
||||
spaceURL,
|
||||
ownedName,
|
||||
ownedRevisionNumber,
|
||||
controller.signal,
|
||||
)
|
||||
.then(setOwnedRevision)
|
||||
// Fall back to the snapshot-only card when the revision is unavailable.
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [open, spaceURL, needsOwnedRevision, ownedName, ownedRevisionNumber]);
|
||||
const rows = owned ? revisionRows(owned, latest) : [];
|
||||
const distinct =
|
||||
!!owned &&
|
||||
(!latest ||
|
||||
(owned.revision !== latest.revision && rows.some((row) => row.changed)));
|
||||
const entries: (PublicCloudPlan | 'owned')[] = [...plans];
|
||||
if (distinct) {
|
||||
const index = entries.findIndex(
|
||||
(plan) => plan !== 'owned' && plan.name === owned.name,
|
||||
);
|
||||
entries.splice(index < 0 ? 0 : index, 0, 'owned');
|
||||
}
|
||||
// Copy, prices and display items come from the purchased revision itself;
|
||||
// limits, platforms, retention and credits come only from the workspace
|
||||
// entitlement snapshot, never from the latest revision.
|
||||
const ownedCatalog =
|
||||
ownedRevision?.revision === owned?.revision ? ownedRevision : null;
|
||||
const ownedPlan: PublicCloudPlan | null =
|
||||
distinct && owned
|
||||
? {
|
||||
uuid: `owned-${owned.name}-${owned.revision}`,
|
||||
name: owned.name,
|
||||
revision: owned.revision,
|
||||
display_name:
|
||||
ownedCatalog?.display_name ?? latest?.display_name ?? {},
|
||||
description: ownedCatalog?.description ?? {},
|
||||
price_cny: ownedCatalog?.price_cny ?? '0',
|
||||
price_usd: ownedCatalog?.price_usd ?? '0',
|
||||
price_jpy: ownedCatalog?.price_jpy ?? '0',
|
||||
limits: owned.limits,
|
||||
features: ownedCatalog?.features ?? [],
|
||||
is_active: true,
|
||||
sort_order: latest?.sort_order ?? 0,
|
||||
highlight: ownedCatalog?.highlight ?? false,
|
||||
chat_history_retention_days:
|
||||
owned.limits['chat_history.retention_days'] ?? 30,
|
||||
monthly_bonus_credits: owned.monthly_bonus_credits,
|
||||
disabled_adapters: Object.entries(owned.entitlement_features)
|
||||
.filter(([key, enabled]) => key.startsWith('platform.') && !enabled)
|
||||
.map(([key]) => key.slice(9)),
|
||||
display_config: ownedCatalog?.display_config
|
||||
? ownedCatalog.display_config
|
||||
: latest?.display_config
|
||||
? {
|
||||
icon: latest.display_config.icon,
|
||||
order: latest.display_config.order,
|
||||
tooltips: latest.display_config.tooltips,
|
||||
quotas: latest.display_config.quotas,
|
||||
items: [],
|
||||
}
|
||||
: { items: [] },
|
||||
}
|
||||
: null;
|
||||
const subscribedOn = owned?.subscription_started_at
|
||||
? t('planRevision.subscribedOn', {
|
||||
interpolation: { escapeValue: false },
|
||||
date: new Date(owned.subscription_started_at * 1000).toLocaleDateString(
|
||||
i18n.language.replace('_', '-'),
|
||||
),
|
||||
})
|
||||
: null;
|
||||
const formatPrice = (price: number) =>
|
||||
new Intl.NumberFormat(i18n.language.replace('_', '-'), {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: currency === 'JPY' ? 0 : 2,
|
||||
}).format(price);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[90dvh] flex-col gap-5 overflow-hidden sm:max-w-5xl"
|
||||
data-cloud-plans
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudPlans.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudPlans.description', {
|
||||
workspace: workspace.workspace.name,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div
|
||||
className="inline-flex rounded-lg bg-muted p-1"
|
||||
role="group"
|
||||
aria-label={t('cloudPlans.billing')}
|
||||
>
|
||||
{(['monthly', 'annual'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-pressed={cycle === value}
|
||||
className={cn(cycle === value && 'bg-background shadow-sm')}
|
||||
onClick={() => setCycle(value)}
|
||||
>
|
||||
{t(`cloudPlans.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
{!loading && (
|
||||
<>
|
||||
{!workspace.current_plan && (
|
||||
<p className="mb-4 text-sm" role="status">
|
||||
{t('planRevision.unknown')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex min-h-48 items-center justify-center"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<span className="sr-only">{t('cloudPlans.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-3 py-10 text-center" role="alert">
|
||||
<p>{t('cloudPlans.error')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw />
|
||||
{t('cloudPlans.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : !entries.length ? (
|
||||
<p className="py-10 text-center text-muted-foreground">
|
||||
{t('cloudPlans.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-4 overflow-x-auto p-1 [&>*]:w-72 [&>*]:shrink-0 [&>*]:grow',
|
||||
)}
|
||||
>
|
||||
{entries.map((plan) => {
|
||||
const renderCard = (
|
||||
plan: PublicCloudPlan,
|
||||
ownedCard = false,
|
||||
) => {
|
||||
const samePlan = workspace.plan_name === plan.name;
|
||||
const current =
|
||||
ownedCard || (samePlan && !distinct && !!owned);
|
||||
// Both stacked revisions share one card style; only the
|
||||
// badge text tells them apart.
|
||||
const stacked = distinct && plan.name === owned?.name;
|
||||
const downgradeBlocked =
|
||||
Boolean(
|
||||
workspace.plan_name && workspace.plan_name !== 'free',
|
||||
) && plan.name === 'free';
|
||||
const price = cloudPlanPrice(plan, cycle, currency);
|
||||
const name =
|
||||
localizedPlanText(plan.display_name, i18n.language) ||
|
||||
plan.name.toUpperCase();
|
||||
return (
|
||||
<article
|
||||
key={plan.uuid}
|
||||
data-owned-plan={ownedCard || undefined}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col rounded-xl border p-5',
|
||||
current &&
|
||||
!stacked &&
|
||||
'border-blue-500/50 bg-blue-500/5',
|
||||
)}
|
||||
aria-label={name}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<PlanIcon name={plan.display_config?.icon} />
|
||||
{name}
|
||||
</h3>
|
||||
{current ? (
|
||||
<Badge variant={stacked ? 'default' : 'secondary'}>
|
||||
{t('cloudPlans.current')}
|
||||
</Badge>
|
||||
) : plan.highlight ? (
|
||||
<Badge>{t('cloudPlans.recommended')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{localizedPlanText(plan.description, i18n.language)}
|
||||
{distinct &&
|
||||
!ownedCard &&
|
||||
plan.name === owned?.name && (
|
||||
<span className="block" data-latest-quota>
|
||||
{t('planRevision.latestQuota')}
|
||||
</span>
|
||||
)}
|
||||
{ownedCard && subscribedOn && (
|
||||
<span className="block" data-subscribed-on>
|
||||
{subscribedOn}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{ownedCard && !ownedCatalog ? (
|
||||
<div className="mb-5" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-5 text-2xl font-semibold">
|
||||
{price === 0
|
||||
? t('cloudPlans.free')
|
||||
: formatPrice(price)}
|
||||
{price > 0 && (
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
/ {t(`cloudPlans.${cycle}`)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
|
||||
{cycle === 'annual' && price > 0
|
||||
? t('cloudPlans.annualNote', {
|
||||
price: formatPrice(price / 12),
|
||||
})
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Benefits plan={plan} spaceURL={spaceURL} />
|
||||
</div>
|
||||
{plan.subscription_disabled && (
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
{t('cloudPlans.subscriptionUnavailableHint')}
|
||||
</p>
|
||||
)}
|
||||
{plan.subscription_disabled && !current ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.subscriptionUnavailable')}
|
||||
</Button>
|
||||
) : downgradeBlocked ? null : !canManage ? (
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
variant="outline"
|
||||
disabled
|
||||
>
|
||||
{t('cloudPlans.ownerOnly')}
|
||||
</Button>
|
||||
) : distinct &&
|
||||
!ownedCard &&
|
||||
plan.name === owned?.name ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full"
|
||||
variant={plan.highlight ? 'default' : 'outline'}
|
||||
>
|
||||
<a
|
||||
href={cloudSubscriptionManagementURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('cloudPlans.manageSubscription')}
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('planRevision.cancelToSubscribe')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full"
|
||||
variant={plan.highlight ? 'default' : 'outline'}
|
||||
>
|
||||
<a
|
||||
href={
|
||||
workspace.plan_name &&
|
||||
workspace.plan_name !== 'free'
|
||||
? cloudSubscriptionManagementURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
)
|
||||
: cloudPlanCheckoutURL(
|
||||
spaceURL,
|
||||
workspace.workspace.uuid,
|
||||
plan.name,
|
||||
cycle,
|
||||
currency,
|
||||
)
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(
|
||||
workspace.plan_name &&
|
||||
workspace.plan_name !== 'free'
|
||||
? 'cloudPlans.manageSubscription'
|
||||
: 'cloudPlans.choose',
|
||||
)}
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{ownedCard && !latest && (
|
||||
<p className="mt-4 text-sm">
|
||||
{t('planRevision.unavailable')}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
if (plan === 'owned')
|
||||
return ownedPlan && !latest
|
||||
? renderCard(ownedPlan, true)
|
||||
: null;
|
||||
return ownedPlan && latest === plan ? (
|
||||
<PlanRevisionStack
|
||||
key={plan.uuid}
|
||||
current={renderCard(ownedPlan, true)}
|
||||
latest={renderCard(plan)}
|
||||
/>
|
||||
) : (
|
||||
renderCard(plan)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('cloudPlans.handoff')}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, SlidersHorizontal, Timer } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type {
|
||||
OperationGovernance,
|
||||
OperationLevel,
|
||||
} from '@/app/infra/entities/operation-log';
|
||||
|
||||
export interface OperationTraceCollectionSettings {
|
||||
level: OperationLevel;
|
||||
retentionDays: number;
|
||||
maxRows: number;
|
||||
dedupeSeconds: number;
|
||||
}
|
||||
|
||||
export interface OperationTraceCollectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
governance: OperationGovernance | null;
|
||||
canConfigure: boolean;
|
||||
saving: boolean;
|
||||
onSave: (settings: OperationTraceCollectionSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection settings for the operation log: capture level and retention.
|
||||
*
|
||||
* Edits stay local until Save is pressed, so a click on a level card never
|
||||
* changes what the instance collects. The Save button stays disabled while the
|
||||
* draft matches the persisted policy.
|
||||
*/
|
||||
export default function OperationTraceCollectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
governance,
|
||||
canConfigure,
|
||||
saving,
|
||||
onSave,
|
||||
}: OperationTraceCollectionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [level, setLevel] = useState<OperationLevel>(0);
|
||||
const [retentionDays, setRetentionDays] = useState<number>(30);
|
||||
const [maxRows, setMaxRows] = useState<number>(20000);
|
||||
const [dedupeSeconds, setDedupeSeconds] = useState<number>(60);
|
||||
|
||||
// Every open starts from the persisted policy, so a dialog dismissed with
|
||||
// unsaved edits never leaks them into a later session.
|
||||
useEffect(() => {
|
||||
if (!open || !governance) {
|
||||
return;
|
||||
}
|
||||
setLevel(governance.configured_level);
|
||||
setRetentionDays(governance.retention_days);
|
||||
setMaxRows(governance.max_rows);
|
||||
setDedupeSeconds(governance.dedupe_window_seconds);
|
||||
}, [open, governance]);
|
||||
|
||||
const levelOptions = governance?.supported_levels ?? [];
|
||||
const dirty =
|
||||
governance !== null &&
|
||||
(level !== governance.configured_level ||
|
||||
retentionDays !== governance.retention_days ||
|
||||
maxRows !== governance.max_rows ||
|
||||
dedupeSeconds !== governance.dedupe_window_seconds);
|
||||
|
||||
function resetDraft() {
|
||||
if (!governance) {
|
||||
return;
|
||||
}
|
||||
setLevel(governance.configured_level);
|
||||
setRetentionDays(governance.retention_days);
|
||||
setMaxRows(governance.max_rows);
|
||||
setDedupeSeconds(governance.dedupe_window_seconds);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('operationTrace.collectionSettings')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('operationTrace.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('operationTrace.captureLevel')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{levelOptions.map((option) => {
|
||||
const selected = level === option.level;
|
||||
return (
|
||||
<button
|
||||
key={option.level}
|
||||
type="button"
|
||||
disabled={!canConfigure || saving}
|
||||
onClick={() => setLevel(option.level)}
|
||||
className={`rounded-lg border p-3 text-left transition-colors disabled:opacity-60 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{t(`${option.i18n_key}.label`)}
|
||||
</span>
|
||||
<Badge variant={selected ? 'default' : 'outline'}>
|
||||
L{option.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Timer className="size-4" />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('operationTrace.retention')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.retentionDays')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
min={governance?.limits.min_retention_days ?? 1}
|
||||
max={governance?.limits.max_retention_days ?? 3650}
|
||||
value={retentionDays}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) =>
|
||||
setRetentionDays(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.maxRows')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-32"
|
||||
min={governance?.limits.min_max_rows ?? 100}
|
||||
max={governance?.limits.max_max_rows ?? 500000}
|
||||
value={maxRows}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) => setMaxRows(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.dedupeWindow')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
min={governance?.limits.min_dedupe_window_seconds ?? 0}
|
||||
max={governance?.limits.max_dedupe_window_seconds ?? 3600}
|
||||
value={dedupeSeconds}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) =>
|
||||
setDedupeSeconds(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetDraft}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{t('operationTrace.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onSave({ level, retentionDays, maxRows, dedupeSeconds })
|
||||
}
|
||||
disabled={!dirty || saving || !canConfigure}
|
||||
>
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{t('operationTrace.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,6 @@ import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Item, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item';
|
||||
import {
|
||||
Select,
|
||||
@@ -41,6 +35,7 @@ import type {
|
||||
} from '@/app/infra/entities/operation-log';
|
||||
import { Download } from 'lucide-react';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import OperationTraceCollectionDialog from './OperationTraceCollectionDialog';
|
||||
import {
|
||||
PanelBody,
|
||||
PanelToolbar,
|
||||
@@ -343,12 +338,7 @@ export default function OperationTracePanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [query, setQuery] = useState<OperationLogQuery>({});
|
||||
const [retentionDays, setRetentionDays] = useState<number>(30);
|
||||
const [maxRows, setMaxRows] = useState<number>(20000);
|
||||
const [dedupeSeconds, setDedupeSeconds] = useState<number>(60);
|
||||
// Retention is an advanced, rarely-touched policy: it collapses by default so
|
||||
// the capture level and the records list stay the focus of the panel.
|
||||
const [retentionOpen, setRetentionOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
// Only one record shows its full change detail at a time, so the list stays
|
||||
// scannable: the payload diff is long and is opt-in per row.
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
@@ -395,9 +385,6 @@ export default function OperationTracePanel({
|
||||
setGovernance(governanceResponse);
|
||||
setFilters(filtersResponse);
|
||||
setPage(pageResponse);
|
||||
setRetentionDays(governanceResponse.retention_days);
|
||||
setMaxRows(governanceResponse.max_rows);
|
||||
setDedupeSeconds(governanceResponse.dedupe_window_seconds);
|
||||
} catch {
|
||||
toast.error(t('operationTrace.loadFailed'));
|
||||
} finally {
|
||||
@@ -463,16 +450,28 @@ export default function OperationTracePanel({
|
||||
}));
|
||||
}
|
||||
|
||||
async function changeLevel(level: OperationLevel) {
|
||||
async function saveCollectionSettings(settings: {
|
||||
level: OperationLevel;
|
||||
retentionDays: number;
|
||||
maxRows: number;
|
||||
dedupeSeconds: number;
|
||||
}) {
|
||||
if (!canConfigure) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await backendClient.updateOperationGovernance({ level });
|
||||
// One governed write per dialog save: the level and the retention policy
|
||||
// are applied together, and the maintenance loop enforces them after.
|
||||
const updated = await backendClient.updateOperationGovernance({
|
||||
level: settings.level,
|
||||
retention_days: settings.retentionDays,
|
||||
max_rows: settings.maxRows,
|
||||
dedupe_window_seconds: settings.dedupeSeconds,
|
||||
});
|
||||
setGovernance(updated);
|
||||
toast.success(t('operationTrace.levelUpdated'));
|
||||
toast.success(t('operationTrace.settingsUpdated'));
|
||||
await load({ ...query, offset: 0 });
|
||||
} catch {
|
||||
toast.error(t('operationTrace.levelUpdateFailed'));
|
||||
toast.error(t('operationTrace.settingsUpdateFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -496,8 +495,7 @@ export default function OperationTracePanel({
|
||||
});
|
||||
const response = await backendClient.downloadFile(url);
|
||||
const disposition = response.headers['content-disposition'] as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const match = disposition?.match(/filename="?([^";\n]+)"?/);
|
||||
const filename = match?.[1] ?? `operation-logs-${Date.now()}.csv`;
|
||||
const blob = new Blob([response.data], {
|
||||
@@ -518,33 +516,6 @@ export default function OperationTracePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRetention() {
|
||||
if (!canConfigure || !governance) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Saving the retention policy applies it immediately and keeps the
|
||||
// maintenance loop enforcing it afterwards; there is no manual prune.
|
||||
const updated = await backendClient.updateOperationGovernance({
|
||||
level: governance.configured_level,
|
||||
retention_days: retentionDays,
|
||||
max_rows: maxRows,
|
||||
dedupe_window_seconds: dedupeSeconds,
|
||||
});
|
||||
setGovernance(updated);
|
||||
toast.success(t('operationTrace.retentionUpdated'));
|
||||
await load({ ...query, offset: 0 });
|
||||
} catch {
|
||||
toast.error(t('operationTrace.retentionUpdateFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const levelOptions = useMemo(
|
||||
() => governance?.supported_levels ?? [],
|
||||
[governance],
|
||||
);
|
||||
|
||||
if (loading && !governance) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -556,23 +527,33 @@ export default function OperationTracePanel({
|
||||
return (
|
||||
<>
|
||||
<PanelToolbar>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{t('operationTrace.title')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Keep the actions in one right-aligned cluster: the panel toolbar
|
||||
spreads direct children apart, so they are grouped instead. */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{governance && (
|
||||
<Badge
|
||||
variant={governance.configured_level > 0 ? 'default' : 'outline'}
|
||||
>
|
||||
{t(
|
||||
`operationTrace.levelNames.${governance.configured_level_name}`,
|
||||
)}
|
||||
{t('operationTrace.levelBadge', {
|
||||
level: t(
|
||||
`operationTrace.levelNames.${governance.configured_level_name}`,
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Keep the actions in one right-aligned cluster: the panel toolbar
|
||||
spreads direct children apart, so they are grouped instead. */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{t('operationTrace.collectionSettings')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -603,121 +584,14 @@ export default function OperationTracePanel({
|
||||
</PanelToolbar>
|
||||
|
||||
<PanelBody className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('operationTrace.captureLevel')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{levelOptions.map((option) => {
|
||||
const selected = governance?.configured_level === option.level;
|
||||
return (
|
||||
<button
|
||||
key={option.level}
|
||||
type="button"
|
||||
disabled={!canConfigure || saving}
|
||||
onClick={() => void changeLevel(option.level)}
|
||||
className={`rounded-lg border p-3 text-left transition-colors disabled:opacity-60 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{t(`${option.i18n_key}.label`)}
|
||||
</span>
|
||||
<Badge variant={selected ? 'default' : 'outline'}>
|
||||
L{option.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Collapsible open={retentionOpen} onOpenChange={setRetentionOpen}>
|
||||
<section className="space-y-3">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-left">
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-muted-foreground transition-transform ${
|
||||
retentionOpen ? '' : '-rotate-90'
|
||||
}`}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('operationTrace.retention')}
|
||||
</h3>
|
||||
{!retentionOpen && (
|
||||
<span className="ml-auto truncate text-xs text-muted-foreground">
|
||||
{t('operationTrace.retentionSummary', {
|
||||
days: retentionDays,
|
||||
rows: maxRows,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-wrap items-end gap-2 pt-3">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.retentionDays')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
min={governance?.limits.min_retention_days ?? 1}
|
||||
max={governance?.limits.max_retention_days ?? 3650}
|
||||
value={retentionDays}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) =>
|
||||
setRetentionDays(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.maxRows')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-32"
|
||||
min={governance?.limits.min_max_rows ?? 100}
|
||||
max={governance?.limits.max_max_rows ?? 500000}
|
||||
value={maxRows}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) => setMaxRows(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t('operationTrace.dedupeWindow')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
min={governance?.limits.min_dedupe_window_seconds ?? 0}
|
||||
max={governance?.limits.max_dedupe_window_seconds ?? 3600}
|
||||
value={dedupeSeconds}
|
||||
disabled={!canConfigure}
|
||||
onChange={(event) =>
|
||||
setDedupeSeconds(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void saveRetention()}
|
||||
disabled={!canConfigure || saving}
|
||||
>
|
||||
{t('operationTrace.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
<OperationTraceCollectionDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
governance={governance}
|
||||
canConfigure={canConfigure}
|
||||
saving={saving}
|
||||
onSave={(settings) => void saveCollectionSettings(settings)}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Layer = 'current' | 'latest';
|
||||
|
||||
/**
|
||||
* Stacks the purchased revision in front of the plan's latest revision. The
|
||||
* toggle only changes which card is shown; it never changes the subscription.
|
||||
* Layers stay opaque; the back card is dimmed with an overlay instead of
|
||||
* opacity so the two cards never show through each other while swapping.
|
||||
*/
|
||||
export default function PlanRevisionStack({
|
||||
current,
|
||||
latest,
|
||||
}: {
|
||||
current: ReactNode;
|
||||
latest: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [front, setFront] = useState<Layer>('current');
|
||||
const back: Layer = front === 'current' ? 'latest' : 'current';
|
||||
const label = t(
|
||||
back === 'latest' ? 'planRevision.showLatest' : 'planRevision.showCurrent',
|
||||
);
|
||||
const layer = (key: Layer, node: ReactNode) => {
|
||||
const isFront = front === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-revision-layer={key}
|
||||
data-front={isFront || undefined}
|
||||
aria-hidden={!isFront || undefined}
|
||||
inert={!isFront}
|
||||
className={cn(
|
||||
'relative mr-8 flex min-w-0 origin-right rounded-xl bg-background transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:bg-background after:transition-opacity after:duration-500 [grid-area:stack] motion-reduce:transition-none [&>article]:flex-1',
|
||||
isFront
|
||||
? 'z-10 after:opacity-0'
|
||||
: 'pointer-events-none z-0 translate-x-8 scale-[0.95] after:opacity-50 [&>article]:shadow-none',
|
||||
)}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className='relative grid min-w-0 [grid-template-areas:"stack"]'
|
||||
data-plan-revision-stack
|
||||
data-front-layer={front}
|
||||
>
|
||||
{layer('current', current)}
|
||||
{layer('latest', latest)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-revision-toggle
|
||||
onClick={(event) => {
|
||||
setFront(back);
|
||||
// Pointer clicks should not leave the strip highlighted.
|
||||
if (event.detail > 0) event.currentTarget.blur();
|
||||
}}
|
||||
className="group absolute inset-y-2 right-0 z-20 flex w-8 flex-col items-center justify-center gap-2 rounded-r-xl text-muted-foreground outline-none transition-colors hover:bg-primary/5 hover:text-primary focus-visible:bg-primary/10 focus-visible:text-primary"
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-4 shrink-0 transition-transform group-hover:-translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium [writing-mode:vertical-rl]"
|
||||
aria-hidden
|
||||
>
|
||||
{t(
|
||||
back === 'latest'
|
||||
? 'planRevision.latestTab'
|
||||
: 'planRevision.currentTab',
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function WorkspacePlanBadge({
|
||||
planName,
|
||||
}: {
|
||||
planName?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPaidPlan =
|
||||
Boolean(planName?.trim()) && planName?.trim().toLowerCase() !== 'free';
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[11px] uppercase',
|
||||
isPaidPlan
|
||||
? 'border-blue-200 bg-blue-50 font-semibold text-blue-700 dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-300'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{planName || t('workspace.planUnavailable')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
@@ -39,6 +46,9 @@ import {
|
||||
PanelBody,
|
||||
PanelToolbar,
|
||||
} from '@/app/home/components/settings-dialog/panel-layout';
|
||||
import WorkspacePlanBadge from './WorkspacePlanBadge';
|
||||
|
||||
const CloudPlansDialog = lazy(() => import('./CloudPlansDialog'));
|
||||
|
||||
interface WorkspaceSettingsPanelProps {
|
||||
active: boolean;
|
||||
@@ -51,16 +61,31 @@ const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
|
||||
'viewer',
|
||||
];
|
||||
|
||||
const MESSAGE_LANGUAGES: Record<string, string> = {
|
||||
en_US: 'English',
|
||||
zh_Hans: '简体中文',
|
||||
zh_Hant: '繁體中文',
|
||||
ja_JP: '日本語',
|
||||
es_ES: 'Español',
|
||||
ru_RU: 'Русский',
|
||||
th_TH: 'ไทย',
|
||||
vi_VN: 'Tiếng Việt',
|
||||
};
|
||||
|
||||
export default function WorkspaceSettingsPanel({
|
||||
active,
|
||||
}: WorkspaceSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [plansOpen, setPlansOpen] = useState(false);
|
||||
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
|
||||
null,
|
||||
);
|
||||
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
|
||||
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messageLanguage, setMessageLanguage] = useState('en_US');
|
||||
const [supportedLanguages, setSupportedLanguages] = useState<string[]>([]);
|
||||
const [savingLanguage, setSavingLanguage] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] =
|
||||
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
|
||||
@@ -79,15 +104,17 @@ export default function WorkspaceSettingsPanel({
|
||||
const canInvite = permissions.has('member.invite');
|
||||
const canUpdateMembers = permissions.has('member.update_role');
|
||||
const canRemoveMembers = permissions.has('member.remove');
|
||||
const cloudPortalURL = workspaceInfo
|
||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
||||
: '';
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const current = await backendClient.getCurrentWorkspace();
|
||||
const [current, languageSettings] = await Promise.all([
|
||||
backendClient.getCurrentWorkspace(),
|
||||
backendClient.getWorkspaceLanguage(),
|
||||
]);
|
||||
setWorkspaceInfo(current);
|
||||
setMessageLanguage(languageSettings.language);
|
||||
setSupportedLanguages(languageSettings.supported_languages);
|
||||
|
||||
const [memberResponse, invitationResponse] = await Promise.all([
|
||||
current.permissions.includes('member.view')
|
||||
@@ -106,6 +133,19 @@ export default function WorkspaceSettingsPanel({
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
async function saveLanguage(locale: string) {
|
||||
setSavingLanguage(true);
|
||||
try {
|
||||
const result = await backendClient.setWorkspaceLanguage(locale);
|
||||
setMessageLanguage(result.language);
|
||||
toast.success(t('workspaceLanguage.saved'));
|
||||
} catch {
|
||||
toast.error(t('workspaceLanguage.saveFailed'));
|
||||
} finally {
|
||||
setSavingLanguage(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void loadWorkspace();
|
||||
}, [active, loadWorkspace]);
|
||||
@@ -193,11 +233,21 @@ export default function WorkspaceSettingsPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelToolbar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
<PanelToolbar className="gap-4 py-4">
|
||||
<div className="min-w-0 flex-1 basis-56 space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
{workspaceInfo && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[11px] font-normal"
|
||||
>
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
isCloudProjection
|
||||
@@ -206,22 +256,62 @@ export default function WorkspaceSettingsPanel({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{workspaceInfo && (
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isCloudProjection && workspaceInfo && (
|
||||
<Button asChild size="sm">
|
||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-3">
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('workspace.currentPlan')}
|
||||
</p>
|
||||
<WorkspacePlanBadge planName={workspaceInfo.plan_name} />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setPlansOpen(true)}
|
||||
>
|
||||
{t('cloudPlans.title')}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
<PanelBody className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 id="workspace-language-label" className="text-sm font-semibold">
|
||||
{t('workspaceLanguage.label')}
|
||||
</h3>
|
||||
<p
|
||||
id="workspace-language-description"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t('workspaceLanguage.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={messageLanguage}
|
||||
onValueChange={saveLanguage}
|
||||
disabled={
|
||||
loading || savingLanguage || !permissions.has('workspace.update')
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full sm:w-64"
|
||||
aria-labelledby="workspace-language-label"
|
||||
aria-describedby="workspace-language-description"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedLanguages.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{MESSAGE_LANGUAGES[locale] || locale}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</section>
|
||||
{canInvite && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
@@ -404,6 +494,17 @@ export default function WorkspaceSettingsPanel({
|
||||
</section>
|
||||
)}
|
||||
</PanelBody>
|
||||
{isCloudProjection && workspaceInfo && plansOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CloudPlansDialog
|
||||
key={workspaceInfo.workspace.uuid}
|
||||
open={plansOpen}
|
||||
onOpenChange={setPlansOpen}
|
||||
workspace={workspaceInfo}
|
||||
spaceURL={systemInfo.cloud_service_url}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Building2, Check, Settings } from 'lucide-react';
|
||||
import { Building2, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import WorkspacePlanBadge from './WorkspacePlanBadge';
|
||||
|
||||
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
|
||||
|
||||
@@ -56,7 +57,10 @@ export default function WorkspaceSwitcher({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.workspace.uuid}
|
||||
className="min-h-11 gap-2 px-2 py-1.5"
|
||||
className={cn(
|
||||
'min-h-11 gap-2 px-2 py-1.5',
|
||||
selected && 'bg-muted',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!selected)
|
||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||
@@ -67,11 +71,8 @@ export default function WorkspaceSwitcher({
|
||||
{entry.workspace.name}
|
||||
</span>
|
||||
{entry.workspace.source === 'cloud_projection' && (
|
||||
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{entry.plan_name || t('workspace.planUnavailable')}
|
||||
</span>
|
||||
<WorkspacePlanBadge planName={entry.plan_name} />
|
||||
)}
|
||||
{selected && <Check className="size-4" />}
|
||||
{selected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Keep this public catalog aligned with Space web/lib/platform-adapters.ts.
|
||||
// Do not use the workspace-filtered adapter API to describe other plans.
|
||||
export const KNOWN_ADAPTERS = [
|
||||
{
|
||||
name: 'LINE',
|
||||
label: 'LINE',
|
||||
icon: '/platforms/line.png',
|
||||
},
|
||||
{
|
||||
name: 'aiocqhttp',
|
||||
label: 'OneBot v11',
|
||||
icon: '/platforms/aiocqhttp.svg',
|
||||
},
|
||||
{
|
||||
name: 'aiocqhttp-omni',
|
||||
label: 'OneBot v11 (Omni)',
|
||||
icon: '/platforms/aiocqhttp.svg',
|
||||
},
|
||||
{
|
||||
name: 'dingtalk',
|
||||
label: 'DingTalk',
|
||||
icon: '/platforms/dingtalk.svg',
|
||||
},
|
||||
{
|
||||
name: 'dingtalk-omni',
|
||||
label: 'DingTalk (Omni)',
|
||||
icon: '/platforms/dingtalk.svg',
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
label: 'Discord',
|
||||
icon: '/platforms/discord.svg',
|
||||
},
|
||||
{
|
||||
name: 'discord-omni',
|
||||
label: 'Discord (Omni)',
|
||||
icon: '/platforms/discord.svg',
|
||||
},
|
||||
{
|
||||
name: 'http_bot',
|
||||
label: 'HTTP Bot',
|
||||
icon: '/platforms/http_bot.svg',
|
||||
},
|
||||
{
|
||||
name: 'kook',
|
||||
label: 'KOOK',
|
||||
icon: '/platforms/kook.png',
|
||||
},
|
||||
{
|
||||
name: 'kook-omni',
|
||||
label: 'KOOK (Omni)',
|
||||
icon: '/platforms/kook.png',
|
||||
},
|
||||
{
|
||||
name: 'lark',
|
||||
label: 'Lark',
|
||||
icon: '/platforms/lark.svg',
|
||||
},
|
||||
{
|
||||
name: 'lark-omni',
|
||||
label: 'Lark / Feishu (Omni)',
|
||||
icon: '/platforms/lark.svg',
|
||||
},
|
||||
{
|
||||
name: 'matrix',
|
||||
label: 'Matrix',
|
||||
icon: '/platforms/matrix.png',
|
||||
},
|
||||
{
|
||||
name: 'mattermost',
|
||||
label: 'Mattermost',
|
||||
icon: '/platforms/mattermost.svg',
|
||||
},
|
||||
{
|
||||
name: 'officialaccount',
|
||||
label: 'Official Account',
|
||||
icon: '/platforms/officialaccount.png',
|
||||
},
|
||||
{
|
||||
name: 'officialaccount-omni',
|
||||
label: 'Official Account (Omni)',
|
||||
icon: '/platforms/officialaccount.png',
|
||||
},
|
||||
{
|
||||
name: 'openclaw-weixin',
|
||||
label: 'OpenClaw WeChat',
|
||||
icon: '/platforms/openclaw_weixin.png',
|
||||
},
|
||||
{
|
||||
name: 'qqofficial',
|
||||
label: 'QQ Official API',
|
||||
icon: '/platforms/qqofficial.svg',
|
||||
},
|
||||
{
|
||||
name: 'qqofficial-omni',
|
||||
label: 'QQ Official API (Omni)',
|
||||
icon: '/platforms/qqofficial.svg',
|
||||
},
|
||||
{
|
||||
name: 'satori',
|
||||
label: 'Satori',
|
||||
icon: '/platforms/satori.png',
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
label: 'Slack',
|
||||
icon: '/platforms/slack.png',
|
||||
},
|
||||
{
|
||||
name: 'slack-omni',
|
||||
label: 'Slack (Omni)',
|
||||
icon: '/platforms/slack.png',
|
||||
},
|
||||
{
|
||||
name: 'telegram',
|
||||
label: 'Telegram',
|
||||
icon: '/platforms/telegram.svg',
|
||||
},
|
||||
{
|
||||
name: 'telegram-omni',
|
||||
label: 'Telegram (Omni)',
|
||||
icon: '/platforms/telegram.svg',
|
||||
},
|
||||
{
|
||||
name: 'web_page_bot',
|
||||
label: 'Page Bot',
|
||||
icon: '/platforms/webpage.webp',
|
||||
},
|
||||
{
|
||||
name: 'wechatpad',
|
||||
label: 'WeChatPad',
|
||||
icon: '/platforms/wechatpad.png',
|
||||
},
|
||||
{
|
||||
name: 'wecom',
|
||||
label: 'WeCom',
|
||||
icon: '/platforms/wecom.png',
|
||||
},
|
||||
{
|
||||
name: 'wecom-omni',
|
||||
label: 'WeCom (Omni)',
|
||||
icon: '/platforms/wecom.png',
|
||||
},
|
||||
{
|
||||
name: 'wecombot',
|
||||
label: 'WeComBot',
|
||||
icon: '/platforms/wecombot.png',
|
||||
},
|
||||
{
|
||||
name: 'wecombot-omni',
|
||||
label: 'WeComBot (Omni)',
|
||||
icon: '/platforms/wecombot.png',
|
||||
},
|
||||
{
|
||||
name: 'wecomcs',
|
||||
label: 'WeComCustomerService',
|
||||
icon: '/platforms/wecomcs.png',
|
||||
},
|
||||
{
|
||||
name: 'wecomcs-omni',
|
||||
label: 'WeCom Customer Service (Omni)',
|
||||
icon: '/platforms/wecomcs.png',
|
||||
},
|
||||
];
|
||||
|
||||
export function supportedPlanAdapters(disabled: string[] = []) {
|
||||
const blocked = new Set(
|
||||
disabled.map((name) =>
|
||||
name === 'line'
|
||||
? 'LINE'
|
||||
: name.endsWith('-eba')
|
||||
? name.slice(0, -4) + '-omni'
|
||||
: name,
|
||||
),
|
||||
);
|
||||
return KNOWN_ADAPTERS.filter(
|
||||
(adapter) =>
|
||||
!blocked.has(adapter.name) &&
|
||||
!(
|
||||
adapter.name.endsWith('-omni') && blocked.has(adapter.name.slice(0, -5))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapse transport variants into one platform logo after applying restrictions. */
|
||||
export function supportedPlanPlatforms(disabled: string[] = []) {
|
||||
const platforms = new Map<
|
||||
string,
|
||||
{ name: string; label: string; icon: string }
|
||||
>();
|
||||
for (const adapter of supportedPlanAdapters(disabled)) {
|
||||
const name = adapter.name.replace(/-omni$/, '');
|
||||
if (!platforms.has(name))
|
||||
platforms.set(name, {
|
||||
...adapter,
|
||||
name,
|
||||
label: adapter.label.replace(/ \(Omni\)$/, ''),
|
||||
});
|
||||
}
|
||||
return [...platforms.values()];
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
export type CloudPlanBillingCycle = 'monthly' | 'annual';
|
||||
export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY';
|
||||
export type PlanLocalizedText = Partial<Record<string, string>>;
|
||||
export interface PublicCloudPlan {
|
||||
entitlement_features?: Record<string, boolean>;
|
||||
uuid: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
display_name: PlanLocalizedText;
|
||||
description: PlanLocalizedText;
|
||||
price_cny: string;
|
||||
price_usd: string;
|
||||
price_jpy: string;
|
||||
limits: Record<string, number>;
|
||||
features: string[];
|
||||
is_active: boolean;
|
||||
subscription_disabled?: boolean;
|
||||
sort_order: number;
|
||||
highlight: boolean;
|
||||
chat_history_retention_days: number;
|
||||
monthly_bonus_credits: number;
|
||||
disabled_adapters: string[];
|
||||
display_config?: {
|
||||
icon?: string;
|
||||
order?: string[];
|
||||
quota_summary?: PlanLocalizedText;
|
||||
tooltips?: Partial<Record<string, PlanLocalizedText>>;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{ label?: PlanLocalizedText; tooltip?: PlanLocalizedText }
|
||||
>;
|
||||
items?: Array<{
|
||||
label: PlanLocalizedText;
|
||||
tooltip?: PlanLocalizedText;
|
||||
icon: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function localizedPlanText(
|
||||
values: PlanLocalizedText | undefined,
|
||||
language: string,
|
||||
): string {
|
||||
if (!values) return '';
|
||||
const locale = language.replace(/-/g, '_');
|
||||
const key = Object.keys(values).find(
|
||||
(key) => key.toLowerCase() === locale.toLowerCase(),
|
||||
);
|
||||
return (
|
||||
(key && values[key]) ||
|
||||
values.en_US ||
|
||||
values.zh_Hans ||
|
||||
Object.values(values).find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function cloudPlanCurrencyForLanguage(
|
||||
language: string,
|
||||
): CloudPlanCurrency {
|
||||
const locale = language.replace(/_/g, '-').toLowerCase();
|
||||
if (locale === 'zh-hans' || locale.startsWith('zh-hans-')) return 'CNY';
|
||||
if (locale === 'ja' || locale.startsWith('ja-')) return 'JPY';
|
||||
return 'USD';
|
||||
}
|
||||
|
||||
export function cloudPlanPrice(
|
||||
plan: PublicCloudPlan,
|
||||
cycle: CloudPlanBillingCycle,
|
||||
currency: CloudPlanCurrency,
|
||||
): number {
|
||||
const amount = Number(
|
||||
plan[
|
||||
currency === 'CNY'
|
||||
? 'price_cny'
|
||||
: currency === 'JPY'
|
||||
? 'price_jpy'
|
||||
: 'price_usd'
|
||||
],
|
||||
);
|
||||
if (!Number.isFinite(amount) || amount < 0)
|
||||
throw new Error('Invalid public plan price');
|
||||
return amount * (cycle === 'annual' ? 10 : 1);
|
||||
}
|
||||
|
||||
function spaceURL(base: string, path: string): URL {
|
||||
const url = new URL(base);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password
|
||||
)
|
||||
throw new Error('Invalid Space URL');
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}${path}`;
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
export function cloudPlanCheckoutURL(
|
||||
base: string,
|
||||
workspace: string,
|
||||
plan: string,
|
||||
billing: CloudPlanBillingCycle,
|
||||
currency: CloudPlanCurrency,
|
||||
): string {
|
||||
const url = spaceURL(base, '/cloud');
|
||||
url.search = new URLSearchParams({
|
||||
environment: 'new',
|
||||
step: 'plan',
|
||||
workspace,
|
||||
plan,
|
||||
billing,
|
||||
currency,
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Workspace navigation only; Space authenticates and authorizes independently. */
|
||||
export function cloudSubscriptionManagementURL(
|
||||
base: string,
|
||||
workspace: string,
|
||||
): string {
|
||||
const url = spaceURL(base, '/cloud');
|
||||
url.search = new URLSearchParams({
|
||||
environment: 'new',
|
||||
step: 'subscription',
|
||||
workspace,
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function cloudPlanPlatformIconURL(base: string, icon: string): string {
|
||||
return spaceURL(base, icon).toString();
|
||||
}
|
||||
|
||||
/** This request is deliberately independent of Core and Space authenticated clients. */
|
||||
export async function fetchPublicCloudPlans(
|
||||
base: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicCloudPlan[]> {
|
||||
const response = await fetch(spaceURL(base, '/api/v1/cloud/v2/plans'), {
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not load public plans');
|
||||
const result = await response.json();
|
||||
if (result.code !== 0 || !Array.isArray(result.data))
|
||||
throw new Error('Invalid plan catalog');
|
||||
const plans = (result.data as PublicCloudPlan[]).filter(
|
||||
(plan) => plan.is_active,
|
||||
);
|
||||
for (const plan of plans) {
|
||||
if (!plan.uuid || !plan.name || !plan.limits)
|
||||
throw new Error('Invalid public plan');
|
||||
for (const currency of ['CNY', 'USD', 'JPY'] as const)
|
||||
cloudPlanPrice(plan, 'monthly', currency);
|
||||
}
|
||||
return plans.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
|
||||
// Catalog metadata (copy, prices, display items) of one historical revision.
|
||||
export async function fetchCloudPlanRevision(
|
||||
base: string,
|
||||
name: string,
|
||||
revision: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicCloudPlan> {
|
||||
const response = await fetch(
|
||||
spaceURL(
|
||||
base,
|
||||
`/api/v1/cloud/v2/plans/${encodeURIComponent(name)}/revisions/${revision}`,
|
||||
),
|
||||
{ credentials: 'omit', referrerPolicy: 'no-referrer', signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not load plan revision');
|
||||
const result = await response.json();
|
||||
const plan = result?.data as PublicCloudPlan | undefined;
|
||||
if (result?.code !== 0 || !plan?.name || plan.name !== name)
|
||||
throw new Error('Invalid plan revision');
|
||||
return plan;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Only trusted subscription data may populate the current side of a comparison. */
|
||||
export interface PlanRevisionBenefits {
|
||||
name: string;
|
||||
revision: number;
|
||||
limits: Record<string, number>;
|
||||
entitlement_features?: Record<string, boolean>;
|
||||
disabled_adapters?: string[];
|
||||
chat_history_retention_days?: number;
|
||||
monthly_bonus_credits?: number;
|
||||
}
|
||||
|
||||
export function benefitValues(plan: PlanRevisionBenefits) {
|
||||
const values: Record<string, number | boolean> = {};
|
||||
for (const [key, value] of Object.entries(plan.limits))
|
||||
values[`limit:${key}`] = value;
|
||||
values['limit:chat_history.retention_days'] =
|
||||
plan.limits['chat_history.retention_days'] ??
|
||||
plan.chat_history_retention_days ??
|
||||
30;
|
||||
values.monthly_credits = plan.monthly_bonus_credits ?? 0;
|
||||
for (const [key, value] of Object.entries(plan.entitlement_features ?? {}))
|
||||
values[`feature:${key}`] = value;
|
||||
for (const adapter of plan.disabled_adapters ?? [])
|
||||
values[`feature:platform.${adapter}`] = false;
|
||||
return values;
|
||||
}
|
||||
|
||||
export function revisionRows(
|
||||
current: PlanRevisionBenefits,
|
||||
latest?: PlanRevisionBenefits,
|
||||
) {
|
||||
const left = benefitValues(current);
|
||||
const right = latest ? benefitValues(latest) : {};
|
||||
return [...new Set([...Object.keys(left), ...Object.keys(right)])]
|
||||
.sort()
|
||||
.map((key) => {
|
||||
// Platform adapters are allowed unless explicitly disabled. Other feature
|
||||
// switches must be explicitly granted, matching Core's admission semantics.
|
||||
const fallback = key.startsWith('feature:platform.')
|
||||
? true
|
||||
: key.startsWith('feature:')
|
||||
? false
|
||||
: undefined;
|
||||
const before = left[key] ?? fallback;
|
||||
const after = right[key] ?? fallback;
|
||||
return { key, before, after, changed: !!latest && before !== after };
|
||||
});
|
||||
}
|
||||
@@ -112,6 +112,8 @@ export default function KBForm({
|
||||
// Dirty tracking: snapshot of saved state for comparison
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
const isInitializing = useRef(true);
|
||||
const loadVersion = useRef(0);
|
||||
const initialSettings = useRef(new Set<'config' | 'retrieval'>());
|
||||
const suppressNextAutoSelectRef = useRef(false);
|
||||
|
||||
// Refs to store validation functions from dynamic forms
|
||||
@@ -135,36 +137,65 @@ export default function KBForm({
|
||||
(e) => e.plugin_id === selectedEngineId,
|
||||
);
|
||||
|
||||
// Dirty tracking: compare current form + dynamic settings against saved snapshot
|
||||
const watchedFormValues = form.watch();
|
||||
useEffect(() => {
|
||||
const draftRef = useRef({
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
draftRef.current = { config: configSettings, retrieval: retrievalSettings };
|
||||
const lastDirtyRef = useRef(false);
|
||||
const refreshDirty = useCallback(() => {
|
||||
if (!savedSnapshotRef.current || isInitializing.current) return;
|
||||
const currentSnapshot = JSON.stringify({
|
||||
form: watchedFormValues,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
const dirty = currentSnapshot !== savedSnapshotRef.current;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
|
||||
const next =
|
||||
!engineInitialized ||
|
||||
JSON.stringify({
|
||||
form: form.getValues(),
|
||||
...draftRef.current,
|
||||
}) !== savedSnapshotRef.current;
|
||||
if (next !== lastDirtyRef.current) {
|
||||
lastDirtyRef.current = next;
|
||||
onDirtyChange?.(next);
|
||||
}
|
||||
}, [engineInitialized, form, onDirtyChange]);
|
||||
useEffect(() => {
|
||||
refreshDirty();
|
||||
const subscription = form.watch(refreshDirty);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, refreshDirty, configSettings, retrievalSettings]);
|
||||
|
||||
const captureSnapshot = () => {
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: form.getValues(),
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
};
|
||||
function updateSettings(
|
||||
slot: 'config' | 'retrieval',
|
||||
values: Record<string, unknown>,
|
||||
) {
|
||||
// Normalize only this form's initial emission. Never absorb another field's
|
||||
// edits or depend on a timer to decide when the user may start typing.
|
||||
if (initialSettings.current.delete(slot) && savedSnapshotRef.current) {
|
||||
const baseline = JSON.parse(savedSnapshotRef.current);
|
||||
baseline[slot] = values;
|
||||
savedSnapshotRef.current = JSON.stringify(baseline);
|
||||
}
|
||||
draftRef.current = { ...draftRef.current, [slot]: values };
|
||||
if (slot === 'config') setConfigSettings(values);
|
||||
else setRetrievalSettings(values);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++loadVersion.current;
|
||||
setInitialDataLoaded(false);
|
||||
setLoadFailed(false);
|
||||
loadRagEngines()
|
||||
loadRagEngines(version)
|
||||
.then(() => {
|
||||
if (initKbId) return loadKbConfig(initKbId);
|
||||
if (version === loadVersion.current && initKbId)
|
||||
return loadKbConfig(initKbId, version);
|
||||
})
|
||||
.catch(() => setLoadFailed(true))
|
||||
.finally(() => setInitialDataLoaded(true));
|
||||
.catch(() => {
|
||||
if (version === loadVersion.current) setLoadFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === loadVersion.current) setInitialDataLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
loadVersion.current += 1;
|
||||
};
|
||||
}, [initKbId, loadAttempt]);
|
||||
|
||||
// Auto-select first engine when engines are loaded and no selection
|
||||
@@ -188,54 +219,63 @@ export default function KBForm({
|
||||
}
|
||||
}, [ragEngines, selectedEngineId, isEditing]);
|
||||
|
||||
const loadRagEngines = async () => {
|
||||
const loadRagEngines = async (version: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeEngines();
|
||||
setRagEngines(resp.engines);
|
||||
if (version === loadVersion.current) setRagEngines(resp.engines);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (version === loadVersion.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadKbConfig = async (kbId: string) => {
|
||||
const loadKbConfig = async (kbId: string, version: number) => {
|
||||
try {
|
||||
isInitializing.current = true;
|
||||
setIsEditing(true);
|
||||
|
||||
const res = await httpClient.getKnowledgeBase(kbId);
|
||||
if (version !== loadVersion.current) return;
|
||||
const kb = res.base;
|
||||
|
||||
const engineId = kb.knowledge_engine_plugin_id || '';
|
||||
setSelectedEngineId(engineId);
|
||||
|
||||
form.reset({
|
||||
const loadedForm = {
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji || '📚',
|
||||
ragEngineId: engineId,
|
||||
});
|
||||
};
|
||||
form.reset(loadedForm);
|
||||
|
||||
setConfigSettings(kb.creation_settings || {});
|
||||
setRetrievalSettings(kb.retrieval_settings || {});
|
||||
setEngineInitialized(kb.initialized !== false);
|
||||
|
||||
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
||||
setTimeout(() => {
|
||||
captureSnapshot();
|
||||
isInitializing.current = false;
|
||||
onDirtyChange?.(kb.initialized === false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
draftRef.current = {
|
||||
config: kb.creation_settings || {},
|
||||
retrieval: kb.retrieval_settings || {},
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: loadedForm,
|
||||
...draftRef.current,
|
||||
});
|
||||
initialSettings.current = new Set(['config', 'retrieval']);
|
||||
isInitializing.current = false;
|
||||
lastDirtyRef.current = kb.initialized === false;
|
||||
onDirtyChange?.(lastDirtyRef.current);
|
||||
} catch (err) {
|
||||
if (version === loadVersion.current) isInitializing.current = false;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEngineChange = useCallback(
|
||||
(engineId: string, installedEngine?: KnowledgeEngine) => {
|
||||
initialSettings.current.clear();
|
||||
setSelectedEngineId(engineId);
|
||||
form.setValue('ragEngineId', engineId, {
|
||||
shouldDirty: true,
|
||||
@@ -300,13 +340,22 @@ export default function KBForm({
|
||||
: { defer_initialization: true }),
|
||||
};
|
||||
|
||||
const submittedSnapshot = JSON.stringify({
|
||||
form: data,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
if (initKbId) {
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, kbData)
|
||||
.then((res) => {
|
||||
setEngineInitialized(true);
|
||||
captureSnapshot();
|
||||
onDirtyChange?.(false);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
const dirty =
|
||||
JSON.stringify({ form: form.getValues(), ...draftRef.current }) !==
|
||||
submittedSnapshot;
|
||||
lastDirtyRef.current = dirty;
|
||||
onDirtyChange?.(dirty);
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
})
|
||||
@@ -515,7 +564,7 @@ export default function KBForm({
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
updateSettings('config', val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={engineInitialized}
|
||||
externalDependentValues={retrievalSettings}
|
||||
@@ -542,7 +591,7 @@ export default function KBForm({
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
updateSettings('retrieval', val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
onValidate={(validateFn) =>
|
||||
|
||||
@@ -13,9 +13,7 @@ const INSTALL_INTENT_KEY_PREFIX = 'langbot-knowledge-engine-install';
|
||||
const INSTALL_INTENT_EVENT = 'langbot-knowledge-engine-install-change';
|
||||
|
||||
export type KnowledgeEngineMarketplaceErrorCode =
|
||||
| 'version-unavailable'
|
||||
| 'install-timeout'
|
||||
| 'registration-timeout';
|
||||
'version-unavailable' | 'install-timeout' | 'registration-timeout';
|
||||
|
||||
export class KnowledgeEngineMarketplaceError extends Error {
|
||||
constructor(public readonly code: KnowledgeEngineMarketplaceErrorCode) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||
import InFlightMonitor from '@/app/home/components/inflight/InFlightMonitor';
|
||||
import WorkspaceAssistant from '@/app/home/components/WorkspaceAssistant';
|
||||
import React, {
|
||||
useState,
|
||||
@@ -280,6 +281,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<SurveyWidget />
|
||||
<WorkspaceAssistant />
|
||||
<InFlightMonitor />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -31,11 +31,7 @@ import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||
type MCPConnectionState =
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'error'
|
||||
| 'disabled'
|
||||
| 'disconnected';
|
||||
'connected' | 'connecting' | 'error' | 'disabled' | 'disconnected';
|
||||
|
||||
export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
|
||||
@@ -1,23 +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 { 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[];
|
||||
@@ -25,124 +14,23 @@ 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 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';
|
||||
function parsePayload(value?: string): unknown {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="min-w-0 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
|
||||
<MessageContentRenderer content={content} maxLines={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,497 +41,276 @@ export function ConversationTurnList({
|
||||
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"
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
Layers,
|
||||
ThumbsUp,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -19,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 =
|
||||
@@ -37,44 +39,8 @@ 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);
|
||||
@@ -182,26 +148,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('monitoring.export.exporting')}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Heart,
|
||||
Smile,
|
||||
} from 'lucide-react';
|
||||
import { MetricCard } from './overview-cards/MetricCard';
|
||||
|
||||
interface FeedbackCardProps {
|
||||
title: string;
|
||||
@@ -23,6 +24,18 @@ interface FeedbackCardProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// The variant only tints the icon tile; the card surface stays neutral so the
|
||||
// feedback row matches the token-monitoring cards.
|
||||
const variantAccents: Record<
|
||||
NonNullable<FeedbackCardProps['variant']>,
|
||||
string
|
||||
> = {
|
||||
default: '#64748b',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
};
|
||||
|
||||
export function FeedbackCard({
|
||||
title,
|
||||
value,
|
||||
@@ -32,89 +45,37 @@ export function FeedbackCard({
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: FeedbackCardProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
|
||||
success:
|
||||
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
warning:
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
default: 'text-gray-500 dark:text-gray-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-500 dark:text-yellow-400',
|
||||
danger: 'text-red-500 dark:text-red-400',
|
||||
};
|
||||
|
||||
const trendStyles = {
|
||||
up: 'text-green-500',
|
||||
down: 'text-red-500',
|
||||
neutral: 'text-gray-500',
|
||||
down: 'text-destructive',
|
||||
neutral: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}
|
||||
<MetricCard
|
||||
label={title}
|
||||
value={value}
|
||||
hint={subtitle}
|
||||
icon={icon}
|
||||
accent={variantAccents[variant]}
|
||||
loading={loading}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div
|
||||
className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}
|
||||
>
|
||||
{trend.direction === 'up' && (
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'down' && (
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'neutral' && (
|
||||
<Minus className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{trend && (
|
||||
<div
|
||||
className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}
|
||||
className={`flex items-center text-sm ${trendStyles[trend.direction]}`}
|
||||
>
|
||||
{icon}
|
||||
{trend.direction === 'up' && <TrendingUp className="mr-1 h-4 w-4" />}
|
||||
{trend.direction === 'down' && (
|
||||
<TrendingDown className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{trend.direction === 'neutral' && <Minus className="mr-1 h-4 w-4" />}
|
||||
<span>
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,35 +96,37 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
|
||||
{
|
||||
title: t('monitoring.feedback.totalFeedback'),
|
||||
value: stats?.totalFeedback ?? 0,
|
||||
icon: <Heart className="w-6 h-6" />,
|
||||
icon: <Heart className="h-4 w-4" />,
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalLikes'),
|
||||
value: stats?.totalLikes ?? 0,
|
||||
icon: <ThumbsUp className="w-6 h-6" />,
|
||||
icon: <ThumbsUp className="h-4 w-4" />,
|
||||
variant: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalDislikes'),
|
||||
value: stats?.totalDislikes ?? 0,
|
||||
icon: <ThumbsDown className="w-6 h-6" />,
|
||||
icon: <ThumbsDown className="h-4 w-4" />,
|
||||
variant: 'danger' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.satisfactionRate'),
|
||||
value: stats ? `${stats.satisfactionRate}%` : '0%',
|
||||
icon: <Smile className="w-6 h-6" />,
|
||||
icon: <Smile className="h-4 w-4" />,
|
||||
// The rate is the one feedback metric that is good or bad on its own, so
|
||||
// its tile keeps encoding the verdict (as it did before the restyle).
|
||||
variant: (stats && stats.satisfactionRate >= 80
|
||||
? 'success'
|
||||
: stats && stats.satisfactionRate >= 50
|
||||
? 'warning'
|
||||
: 'danger') as 'default' | 'success' | 'warning' | 'danger',
|
||||
: 'danger') as 'success' | 'warning' | 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card, index) => (
|
||||
<FeedbackCard
|
||||
key={index}
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { FeedbackRecord } from '../types/monitoring';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { TabState } from './TabState';
|
||||
|
||||
interface FeedbackListProps {
|
||||
feedback: FeedbackRecord[];
|
||||
@@ -31,33 +33,25 @@ export function FeedbackList({
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
return <TabState loading rows={6} />;
|
||||
}
|
||||
|
||||
if (!feedback || feedback.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-base font-medium mb-2">
|
||||
{t('monitoring.feedback.noFeedback')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.feedback.noFeedbackDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<TabState
|
||||
icon={<Heart className="w-16 h-16 text-muted-foreground/50" />}
|
||||
title={t('monitoring.feedback.noFeedback')}
|
||||
hint={t('monitoring.feedback.noFeedbackDescription')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{feedback.map((item) => (
|
||||
<div
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
className={`gap-0 py-0 overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
@@ -105,16 +99,19 @@ export function FeedbackList({
|
||||
</span>
|
||||
{item.botName && (
|
||||
<>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.botName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.platform && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-muted text-muted-foreground"
|
||||
>
|
||||
{item.platform}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{item.streamId && onViewMessage && (
|
||||
<Button
|
||||
@@ -133,7 +130,7 @@ export function FeedbackList({
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
)}
|
||||
@@ -142,12 +139,13 @@ export function FeedbackList({
|
||||
item.inaccurateReasons.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{item.inaccurateReasons.map((reason, idx) => (
|
||||
<span
|
||||
<Badge
|
||||
key={idx}
|
||||
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
variant="outline"
|
||||
className="bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{reason}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -156,7 +154,7 @@ export function FeedbackList({
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{item.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -166,75 +164,75 @@ export function FeedbackList({
|
||||
{/* Expanded Details */}
|
||||
{expandedId === item.id && (
|
||||
<div
|
||||
className={`border-t p-5 bg-white dark:bg-gray-900 ${
|
||||
className={`border-t p-5 bg-background ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.feedback.contextInfo')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
{item.botName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.botName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.pipelineName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.userId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.messageId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.messageId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.streamId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.feedback.streamId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{item.streamId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,11 +242,11 @@ export function FeedbackList({
|
||||
|
||||
{/* Feedback Content */}
|
||||
{item.feedbackContent && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.feedback.feedbackContent')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
</div>
|
||||
@@ -256,7 +254,7 @@ export function FeedbackList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Voice,
|
||||
Quote,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
|
||||
|
||||
interface MessageContentRendererProps {
|
||||
@@ -48,23 +49,23 @@ export function MessageContentRenderer({
|
||||
const displayName =
|
||||
atComponent.display || atComponent.target?.toString() || '';
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
>
|
||||
@{displayName}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'AtAll':
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
|
||||
>
|
||||
@All
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
case 'Image': {
|
||||
@@ -73,12 +74,12 @@ export function MessageContentRenderer({
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[Image]
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,41 +102,41 @@ export function MessageContentRenderer({
|
||||
case 'File': {
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-1" />
|
||||
<Paperclip className="mr-1" />
|
||||
{file.name || 'File'}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Voice': {
|
||||
const voice = component as Voice;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm [&>svg]:size-3.5"
|
||||
>
|
||||
<AudioLines className="w-3.5 h-3.5 mr-1" />
|
||||
<AudioLines className="mr-1" />
|
||||
Voice{voice.length ? ` ${voice.length}s` : ''}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Quote': {
|
||||
const quote = component as Quote;
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-l-muted-foreground/50"
|
||||
>
|
||||
{quote.origin
|
||||
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
|
||||
.map((c) => (c as MessageChainComponent as Plain).text)
|
||||
.join('') || '[Quote]'}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,12 +145,12 @@ export function MessageContentRenderer({
|
||||
|
||||
default:
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
className="mx-0.5 px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[{component.type}]
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageDetails } from '../types/monitoring';
|
||||
|
||||
interface MessageDetailsCardProps {
|
||||
@@ -24,7 +27,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info Section */}
|
||||
{details.message && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
@@ -79,12 +82,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LLM Calls Section */}
|
||||
{details.llmCalls && details.llmCalls.length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
|
||||
@@ -127,15 +130,16 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
<span className="font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 text-xs px-2 py-0.5 rounded ${
|
||||
<Badge
|
||||
className={cn(
|
||||
'ml-2',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{call.duration}ms
|
||||
@@ -163,12 +167,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Errors Section */}
|
||||
{details.errors && details.errors.length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.errors.title')} ({details.errors.length})
|
||||
@@ -198,12 +202,12 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Query Variables Section */}
|
||||
{queryVariables && Object.keys(queryVariables).length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Card className="bg-muted rounded-lg border-0 p-3 gap-0">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Braces className="w-4 h-4 mr-2" />
|
||||
{t('monitoring.queryVariables.title')}
|
||||
@@ -233,7 +237,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No data message */}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface TabStateProps {
|
||||
/** Skeleton rows while the tab's data is in flight. */
|
||||
loading?: boolean;
|
||||
/** Number of skeleton rows; the rhythm matches the loaded content. */
|
||||
rows?: number;
|
||||
/** Icon for the empty state, in the same muted tone as the copy. */
|
||||
icon?: React.ReactNode;
|
||||
/** Empty-state headline. */
|
||||
title?: string;
|
||||
/** Optional second line explaining what would fill the tab. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one loading/empty treatment every detail tab uses.
|
||||
*
|
||||
* Tabs are peers on one fixed-height surface, so they must not each invent their
|
||||
* own placeholder shape: a reader switching tabs sees the same skeleton rhythm
|
||||
* and the same centred empty card regardless of which record type is empty.
|
||||
*/
|
||||
export function TabState({
|
||||
loading,
|
||||
rows = 6,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: TabStateProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" role="status" aria-busy="true">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
{icon}
|
||||
{title && <div className="text-sm">{title}</div>}
|
||||
{hint && <div className="text-xs">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,19 @@ import {
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MetricCard } from './overview-cards/MetricCard';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { TabState } from './TabState';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getErrorMessage } from '../utils';
|
||||
|
||||
@@ -64,6 +77,8 @@ interface TokenStatistics {
|
||||
}
|
||||
|
||||
interface TokenMonitoringProps {
|
||||
mode?: 'all' | 'real' | 'debug';
|
||||
statusGroup?: string;
|
||||
botIds?: string[];
|
||||
pipelineIds?: string[];
|
||||
startTime?: string;
|
||||
@@ -89,42 +104,9 @@ const TOOLTIP_STYLE: React.CSSProperties = {
|
||||
color: 'var(--foreground)',
|
||||
};
|
||||
|
||||
function MetricTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<span
|
||||
className="flex items-center justify-center h-7 w-7 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
|
||||
color: accent || 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenMonitoring({
|
||||
mode,
|
||||
statusGroup,
|
||||
botIds,
|
||||
pipelineIds,
|
||||
startTime,
|
||||
@@ -145,6 +127,9 @@ export default function TokenMonitoring({
|
||||
setError(null);
|
||||
try {
|
||||
const result = await httpClient.getTokenStatistics({
|
||||
mode,
|
||||
status:
|
||||
statusGroup && statusGroup !== 'all' ? [statusGroup] : undefined,
|
||||
botId: botIds,
|
||||
pipelineId: pipelineIds,
|
||||
startTime,
|
||||
@@ -158,7 +143,16 @@ export default function TokenMonitoring({
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [botIdsKey, pipelineIdsKey, startTime, endTime, bucket, refreshKey]);
|
||||
}, [
|
||||
mode,
|
||||
statusGroup,
|
||||
botIdsKey,
|
||||
pipelineIdsKey,
|
||||
startTime,
|
||||
endTime,
|
||||
bucket,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
@@ -174,39 +168,27 @@ export default function TokenMonitoring({
|
||||
}));
|
||||
}, [stats]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-card rounded-xl border p-4 h-24 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-6 h-[320px] animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
if (loading && !stats) {
|
||||
return <TabState loading rows={6} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6 text-sm text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('monitoring.tokens.loadError', { error })}
|
||||
</div>
|
||||
<LoadErrorState
|
||||
title={t('monitoring.loadError')}
|
||||
description={error}
|
||||
onRetry={fetchStats}
|
||||
busy={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats || !stats.summary || stats.summary.total_calls === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<div className="h-[260px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<Coins className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">{t('monitoring.tokens.noData')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabState
|
||||
icon={<Coins className="h-12 w-12" />}
|
||||
title={t('monitoring.tokens.noData')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,70 +211,69 @@ export default function TokenMonitoring({
|
||||
|
||||
{/* Summary tiles */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<Coins className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.totalTokens')}
|
||||
value={formatNumber(summary.total_tokens)}
|
||||
sub={t('monitoring.tokens.acrossCalls', {
|
||||
hint={t('monitoring.tokens.acrossCalls', {
|
||||
count: summary.total_calls,
|
||||
})}
|
||||
accent="#8b5cf6"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<ArrowDownToLine className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.inputTokens')}
|
||||
value={formatNumber(summary.total_input_tokens)}
|
||||
accent="#3b82f6"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<ArrowUpFromLine className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.outputTokens')}
|
||||
value={formatNumber(summary.total_output_tokens)}
|
||||
accent="#10b981"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.avgPerCall')}
|
||||
value={formatNumber(summary.avg_tokens_per_call)}
|
||||
accent="#f59e0b"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<Gauge className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.throughput')}
|
||||
value={`${summary.avg_tokens_per_second}`}
|
||||
sub={t('monitoring.tokens.tokensPerSec')}
|
||||
hint={t('monitoring.tokens.tokensPerSec')}
|
||||
accent="#06b6d4"
|
||||
/>
|
||||
<MetricTile
|
||||
<MetricCard
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label={t('monitoring.tokens.errorCalls')}
|
||||
value={`${summary.error_calls}`}
|
||||
sub={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
|
||||
hint={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
|
||||
accent="#ef4444"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token usage over time */}
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t('monitoring.tokens.usageOverTime')}
|
||||
</h3>
|
||||
<div className="inline-flex rounded-lg border p-0.5 text-sm">
|
||||
{(['hour', 'day'] as const).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBucket(b)}
|
||||
className={`px-3 py-1 rounded-md transition-colors ${
|
||||
bucket === b
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`monitoring.tokens.bucket.${b}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs
|
||||
value={bucket}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'hour' || value === 'day') setBucket(value);
|
||||
}}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
{(['hour', 'day'] as const).map((b) => (
|
||||
<TabsTrigger key={b} value={b} className="text-xs">
|
||||
{t(`monitoring.tokens.bucket.${b}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -372,93 +353,86 @@ export default function TokenMonitoring({
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Per-model breakdown */}
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.tokens.byModel')}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground border-b">
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t('monitoring.tokens.model')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.calls')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.inputTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.outputTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.totalTokens')}
|
||||
</th>
|
||||
<th className="py-2 px-4 font-medium text-right">
|
||||
{t('monitoring.tokens.avgPerCall')}
|
||||
</th>
|
||||
<th className="py-2 pl-4 font-medium text-right">
|
||||
{t('monitoring.tokens.avgLatency')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{by_model.map((m) => {
|
||||
const share =
|
||||
summary.total_tokens > 0
|
||||
? (m.total_tokens / summary.total_tokens) * 100
|
||||
: 0;
|
||||
return (
|
||||
<tr
|
||||
key={m.model_name}
|
||||
className="border-b last:border-0 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 pr-4">
|
||||
<div className="font-medium text-foreground">
|
||||
{m.model_name}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500"
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{m.calls}
|
||||
{m.error_calls > 0 && (
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
({m.error_calls}✕)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.input_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.output_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums font-medium">
|
||||
{formatNumber(m.total_tokens)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatNumber(m.avg_tokens_per_call)}
|
||||
</td>
|
||||
<td className="py-2.5 pl-4 text-right tabular-nums">
|
||||
{m.avg_duration_ms}ms
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('monitoring.tokens.model')}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.calls')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.inputTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.outputTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.totalTokens')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.avgPerCall')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.tokens.avgLatency')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{by_model.map((m) => {
|
||||
const share =
|
||||
summary.total_tokens > 0
|
||||
? (m.total_tokens / summary.total_tokens) * 100
|
||||
: 0;
|
||||
return (
|
||||
<TableRow key={m.model_name}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-foreground">
|
||||
{m.model_name}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-500"
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{m.calls}
|
||||
{m.error_calls > 0 && (
|
||||
<span className="text-destructive">
|
||||
{' '}
|
||||
({m.error_calls}✕)
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.input_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{formatNumber(m.total_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(m.avg_tokens_per_call)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{m.avg_duration_ms}ms
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type {
|
||||
ExecutionDetail,
|
||||
ExecutionDetailItem,
|
||||
ExecutionRow,
|
||||
ExecutionSection,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { ExecutionValue as Value, hasExecutionValue } from './ExecutionValue';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
import {
|
||||
executionKindLabel,
|
||||
ExecutionKindBadge,
|
||||
executionProcessorLabel,
|
||||
executionStatusLabel,
|
||||
} from './ExecutionTable';
|
||||
|
||||
export type ExecutionSelection = Pick<ExecutionRow, 'id'> & {
|
||||
source: ExecutionRow['source'] | 'auto';
|
||||
};
|
||||
|
||||
const mainSections: ExecutionSection[] = ['inputs', 'outputs', 'deliveries'];
|
||||
|
||||
// Keep transport metadata in the payload disclosure, not in the message body.
|
||||
function messageBody(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (/^\s*[\[{]/.test(value)) {
|
||||
try {
|
||||
return messageBody(JSON.parse(value));
|
||||
} catch {
|
||||
/* Plain text. */
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map(messageBody).filter((item) => item != null && item !== '');
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const data = value as Record<string, unknown>;
|
||||
if (
|
||||
['Image', 'File', 'Voice', 'At', 'AtAll', 'Quote'].includes(
|
||||
String(data.type),
|
||||
)
|
||||
)
|
||||
return value;
|
||||
for (const key of ['text', 'content', 'message_chain', 'message', 'root']) {
|
||||
if (data[key] != null) return messageBody(data[key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const secondarySections: ExecutionSection[] = [
|
||||
'related',
|
||||
'conversation',
|
||||
'llm_calls',
|
||||
'tool_calls',
|
||||
'errors',
|
||||
'events',
|
||||
];
|
||||
|
||||
export default function ExecutionDetailSheet({
|
||||
row,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
row: ExecutionSelection | null;
|
||||
onClose: () => void;
|
||||
onSelect: (row: ExecutionSelection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const workspace = useCurrentWorkspace()?.workspace.uuid;
|
||||
const [detail, setDetail] = useState<ExecutionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [sectionError, setSectionError] = useState<ExecutionSection | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState<ExecutionSection | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const identity = `${workspace}:${row?.source}:${row?.id}`;
|
||||
const requestedIdentity = useRef('');
|
||||
const [loadedIdentity, setLoadedIdentity] = useState('');
|
||||
const visible = loadedIdentity === identity ? detail : null;
|
||||
const id = row?.id;
|
||||
const source = row?.source;
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
// Refresh the current trace in place so its scroll position and expanded
|
||||
// disclosures survive polling. Only a different selection starts empty.
|
||||
if (requestedIdentity.current !== identity) {
|
||||
setDetail(null);
|
||||
requestedIdentity.current = identity;
|
||||
}
|
||||
setError(false);
|
||||
setSectionError(null);
|
||||
setBusy(null);
|
||||
if (!id || !source) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
backendClient
|
||||
.getExecutionDetail(source, id)
|
||||
.then((result) => {
|
||||
if (version !== request.current) return;
|
||||
setDetail(result);
|
||||
setLoadedIdentity(identity);
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === request.current) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === request.current) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
request.current += 1;
|
||||
};
|
||||
}, [id, source, workspace, identity, refresh]);
|
||||
const status = visible?.row.status_group;
|
||||
useEffect(() => {
|
||||
if (!['running', 'queued'].includes(status ?? '')) return;
|
||||
// Do not discard paged history with a background refresh.
|
||||
if (
|
||||
Object.values(visible?.pages ?? {}).some((page) => page.next_offset > 100)
|
||||
)
|
||||
return;
|
||||
const timer = setInterval(() => setRefresh((value) => value + 1), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [status, visible]);
|
||||
const loadMore = async (section: ExecutionSection) => {
|
||||
if (!visible || !row || busy) return;
|
||||
const version = request.current;
|
||||
setBusy(section);
|
||||
setSectionError(null);
|
||||
try {
|
||||
const result = await backendClient.getExecutionDetail(
|
||||
row.source,
|
||||
row.id,
|
||||
{ section, offset: visible.pages[section]?.next_offset ?? 0 },
|
||||
);
|
||||
if (version !== request.current) return;
|
||||
const next = result.pages[section];
|
||||
if (next)
|
||||
setDetail(
|
||||
(previous) =>
|
||||
previous && {
|
||||
...previous,
|
||||
pages: {
|
||||
...previous.pages,
|
||||
[section]: {
|
||||
...next,
|
||||
items: [
|
||||
...(previous.pages[section]?.items ?? []),
|
||||
...next.items,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
if (version === request.current) setSectionError(section);
|
||||
} finally {
|
||||
if (version === request.current) setBusy(null);
|
||||
}
|
||||
};
|
||||
const renderItem = (section: ExecutionSection, item: ExecutionDetailItem) => {
|
||||
if (section === 'related') {
|
||||
const related = item as unknown as ExecutionRow;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-between whitespace-normal py-3 text-left"
|
||||
onClick={() => onSelect(related)}
|
||||
>
|
||||
<span>
|
||||
{executionProcessorLabel(related)} ·{' '}
|
||||
{executionKindLabel(related, t)}
|
||||
</span>
|
||||
<Badge status={related.status_group}>
|
||||
{executionStatusLabel(related, t)}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const content = [
|
||||
'inputs',
|
||||
'outputs',
|
||||
'deliveries',
|
||||
'conversation',
|
||||
].includes(section);
|
||||
const body = messageBody(item.content);
|
||||
const title = content
|
||||
? [
|
||||
item.actor_name || item.actor_id,
|
||||
item.event_type,
|
||||
item.role &&
|
||||
t(`monitoring.unified.roles.${item.role}`, {
|
||||
defaultValue: item.role,
|
||||
}),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: String(
|
||||
item.model_name ||
|
||||
item.tool_name ||
|
||||
item.error_type ||
|
||||
item.type ||
|
||||
item.id,
|
||||
);
|
||||
return (
|
||||
<div className="min-w-0 space-y-3 rounded-lg border bg-background p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="break-all">{title}</span>
|
||||
<span>
|
||||
{item.timestamp_ms
|
||||
? formatDateTime(new Date(item.timestamp_ms))
|
||||
: typeof item.timestamp === 'string'
|
||||
? item.timestamp
|
||||
: ''}
|
||||
</span>
|
||||
{item.status && (
|
||||
<Badge status={item.status}>
|
||||
{t(
|
||||
`monitoring.execution.status.${['success', 'delivered'].includes(item.status) ? 'completed' : item.status}`,
|
||||
{ defaultValue: item.status },
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{content ? (
|
||||
<>
|
||||
<div className="text-sm leading-7">
|
||||
{hasExecutionValue(body) ? (
|
||||
<Value value={body} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t('monitoring.unified.noRecords')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Value value={item.attachments} />
|
||||
{item.origin === 'generated' && section === 'conversation' && (
|
||||
<Badge variant="outline">{t('monitoring.unified.outputs')}</Badge>
|
||||
)}
|
||||
{item.delivery?.delivery_error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{String(item.delivery.delivery_error)}
|
||||
</p>
|
||||
) : null}
|
||||
<details className="border-t pt-2">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
{t('monitoring.unified.payload')}
|
||||
</summary>
|
||||
<div className="mt-3 max-h-80 overflow-auto rounded-md bg-muted/40 p-3">
|
||||
<Value value={item} />
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{typeof item.duration === 'number' && (
|
||||
<span>{formatRunDuration(item.duration)}</span>
|
||||
)}
|
||||
{typeof item.total_tokens === 'number' && (
|
||||
<span>{item.total_tokens.toLocaleString()} Token</span>
|
||||
)}
|
||||
</div>
|
||||
<Value value={item.error_message} />
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
{t('monitoring.unified.payload')}
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<Value value={section === 'events' ? item.data : item} />
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderSection = (section: ExecutionSection, primary = false) => {
|
||||
const page = visible?.pages[section];
|
||||
if (
|
||||
!page ||
|
||||
(!primary && page.items.length === 0 && section !== 'conversation')
|
||||
)
|
||||
return null;
|
||||
const body = (
|
||||
<div className="mt-3 space-y-3">
|
||||
{section === 'conversation' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('monitoring.unified.contextHint')}
|
||||
</p>
|
||||
)}
|
||||
{page.items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('monitoring.unified.noRecords')}
|
||||
</p>
|
||||
)}
|
||||
{page.items.map((item, i) => (
|
||||
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
|
||||
))}
|
||||
{sectionError === section && (
|
||||
<LoadErrorState
|
||||
compact
|
||||
title={t('monitoring.execution.detail.loadError')}
|
||||
onRetry={() => loadMore(section)}
|
||||
/>
|
||||
)}
|
||||
{page.has_more && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy !== null}
|
||||
onClick={() => loadMore(section)}
|
||||
>
|
||||
{t('monitoring.unified.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const title = `${t(`monitoring.unified.${section}`)} (${page.items.length}${page.has_more ? '+' : ''})`;
|
||||
return primary ? (
|
||||
<section key={section} className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
|
||||
{mainSections.indexOf(section) + 1}
|
||||
</span>
|
||||
{title}
|
||||
</h3>
|
||||
{body}
|
||||
</section>
|
||||
) : (
|
||||
<details
|
||||
key={section}
|
||||
open={section === 'errors' ? true : undefined}
|
||||
className={`rounded-lg border p-3 ${section === 'errors' ? 'border-red-500/25 bg-red-500/5' : ''}`}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{title}
|
||||
</summary>
|
||||
{body}
|
||||
</details>
|
||||
);
|
||||
};
|
||||
const record = visible?.row;
|
||||
const href = record?.target_id
|
||||
? record.target_kind === 'pipeline'
|
||||
? `/home/pipelines?id=${encodeURIComponent(record.target_id)}`
|
||||
: ['agent', 'event_processor', 'processor'].includes(record.target_kind)
|
||||
? `/home/agents?id=${encodeURIComponent(record.target_id)}`
|
||||
: null
|
||||
: null;
|
||||
return (
|
||||
<Sheet
|
||||
open={row !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" className="w-full p-0 sm:max-w-3xl">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>{t('monitoring.execution.detail.title')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('monitoring.execution.detail.a11yDescription')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-5 p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{record
|
||||
? record.source === 'event'
|
||||
? t('monitoring.unified.unhandled')
|
||||
: executionProcessorLabel(record)
|
||||
: t('monitoring.execution.detail.title')}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={loading}
|
||||
aria-label={t('monitoring.refreshData')}
|
||||
title={
|
||||
error
|
||||
? t('monitoring.execution.detail.loadError')
|
||||
: t('monitoring.refreshData')
|
||||
}
|
||||
onClick={() => setRefresh((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? 'animate-spin' : ''} ${error ? 'text-destructive' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{loading && !visible && <Skeleton className="h-36 w-full" />}
|
||||
{error && !visible && (
|
||||
<LoadErrorState
|
||||
title={t('monitoring.execution.detail.loadError')}
|
||||
onRetry={() => setRefresh((value) => value + 1)}
|
||||
/>
|
||||
)}
|
||||
{record && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge status={record.status_group}>
|
||||
{executionStatusLabel(record, t)}
|
||||
</Badge>
|
||||
<ExecutionKindBadge row={record} />
|
||||
{record.debug && (
|
||||
<Badge status="debug">
|
||||
{t('monitoring.execution.debug')}
|
||||
</Badge>
|
||||
)}
|
||||
{href && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={href}>
|
||||
{t('monitoring.execution.detail.openObject')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{[
|
||||
record.bot_name,
|
||||
record.platform,
|
||||
record.user_name || record.user_id,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 divide-x rounded-lg border bg-muted/20">
|
||||
<div className="space-y-1 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.detail.duration')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{record.duration_ms != null
|
||||
? formatRunDuration(record.duration_ms)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('monitoring.llmCalls.totalTokens')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{record.usage?.total_tokens?.toLocaleString() ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('monitoring.unified.deliveries')}
|
||||
</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{visible?.pages.deliveries?.items.length ?? 0}
|
||||
{visible?.pages.deliveries?.has_more ? '+' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{record.status_reason &&
|
||||
!['stop', 'completed', 'success'].includes(
|
||||
record.status_reason,
|
||||
) && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm">
|
||||
{record.status_reason
|
||||
.split('; ')
|
||||
.map((reason) =>
|
||||
reason === 'failure_reason_not_recorded'
|
||||
? t('common.failureReasonNotRecorded')
|
||||
: reason,
|
||||
)
|
||||
.join('; ')}
|
||||
</p>
|
||||
)}
|
||||
{mainSections.map((section) => renderSection(section, true))}
|
||||
{secondarySections.map((section) => renderSection(section))}
|
||||
<details className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t('monitoring.unified.metadata')}
|
||||
</summary>
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
{[
|
||||
[t('monitoring.execution.detail.executionId'), record.id],
|
||||
[
|
||||
t('monitoring.execution.detail.eventId'),
|
||||
record.event_id,
|
||||
],
|
||||
[
|
||||
t('monitoring.unified.conversation'),
|
||||
record.conversation_id || record.session_id,
|
||||
],
|
||||
[
|
||||
t('monitoring.execution.detail.runner'),
|
||||
record.runner_id,
|
||||
],
|
||||
[
|
||||
t('monitoring.execution.detail.created'),
|
||||
record.created_at_ms
|
||||
? formatDateTime(new Date(record.created_at_ms))
|
||||
: null,
|
||||
],
|
||||
[
|
||||
t('monitoring.execution.detail.duration'),
|
||||
record.duration_ms != null
|
||||
? formatRunDuration(record.duration_ms)
|
||||
: null,
|
||||
],
|
||||
]
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="break-all">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import { Bot, Workflow, Puzzle, CircleDashed } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
|
||||
interface ExecutionTableProps {
|
||||
rows: ExecutionRow[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (row: ExecutionRow) => void;
|
||||
}
|
||||
|
||||
const STATUS_GROUPS = [
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
'queued',
|
||||
'cancelled',
|
||||
'ignored',
|
||||
];
|
||||
|
||||
export function executionStatusLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (STATUS_GROUPS.includes(row.status_group)) {
|
||||
return t(`monitoring.execution.status.${row.status_group}`);
|
||||
}
|
||||
return row.status;
|
||||
}
|
||||
|
||||
export function executionStatusVariant(
|
||||
statusGroup: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (statusGroup === 'failed') return 'destructive';
|
||||
if (statusGroup === 'ignored' || statusGroup === 'queued') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
const KIND_GROUPS = [
|
||||
'agent',
|
||||
'pipeline',
|
||||
'processor',
|
||||
'event_processor',
|
||||
'event',
|
||||
];
|
||||
|
||||
export function executionKindLabel(row: ExecutionRow, t: TFunction): string {
|
||||
if (KIND_GROUPS.includes(row.target_kind)) {
|
||||
if (row.target_kind === 'event_processor')
|
||||
return t('monitoring.execution.kind.processor');
|
||||
if (row.target_kind === 'event') return t('monitoring.unified.unhandled');
|
||||
return t(`monitoring.execution.kind.${row.target_kind}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
const title = row.title ?? '';
|
||||
return title.length > 48 ? `${title.slice(0, 48)}…` : title;
|
||||
}
|
||||
|
||||
// The processor the row ran: the Agent or Pipeline that handled it. A run whose
|
||||
// Agent was deleted still names the Runner that executed it.
|
||||
export function executionProcessorLabel(row: ExecutionRow): string {
|
||||
return row.target_name || row.runner_id || row.target_id || '—';
|
||||
}
|
||||
|
||||
export default function ExecutionTable({
|
||||
rows,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: ExecutionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
// The page hands this table a fixed-height region: the table's own scroll
|
||||
// container takes that height so the header stays sticky while rows scroll
|
||||
// under it.
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border">
|
||||
<Table containerClassName="min-h-0 flex-1 overflow-y-auto">
|
||||
<TableHeader className="bg-card sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead>{t('monitoring.execution.columns.time')}</TableHead>
|
||||
<TableHead>{t('monitoring.execution.columns.target')}</TableHead>
|
||||
<TableHead>{t('monitoring.unified.inputs')}</TableHead>
|
||||
<TableHead>{t('monitoring.execution.columns.status')}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.execution.columns.duration')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t('monitoring.execution.columns.tokens')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const selected = selectedId === row.id;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${row.source}-${row.id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
data-state={selected ? 'selected' : undefined}
|
||||
className="cursor-pointer data-[state=selected]:bg-muted"
|
||||
onClick={() => onSelect(row)}
|
||||
onKeyDown={(
|
||||
event: React.KeyboardEvent<HTMLTableRowElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(row);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{row.created_at_ms == null
|
||||
? '—'
|
||||
: formatDateTime(new Date(row.created_at_ms))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExecutionKindBadge row={row} />
|
||||
<span className="truncate font-medium">
|
||||
{row.source === 'event'
|
||||
? t('monitoring.unified.unhandled')
|
||||
: executionProcessorLabel(row)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.triggeredBy', {
|
||||
trigger: executionTargetLabel(row, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<div className="line-clamp-2 break-words text-sm">
|
||||
{row.input_preview || executionTargetLabel(row, t) || '—'}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{[row.bot_name, row.user_name || row.user_id]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge status={row.status_group}>
|
||||
{executionStatusLabel(row, t)}
|
||||
</Badge>
|
||||
{row.debug && (
|
||||
<Badge status="debug">
|
||||
{t('monitoring.execution.debug')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.duration_ms == null
|
||||
? '—'
|
||||
: formatRunDuration(row.duration_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.usage?.total_tokens != null
|
||||
? row.usage.total_tokens.toLocaleString()
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { MessageContentRenderer } from '../MessageContentRenderer';
|
||||
|
||||
// Keep arbitrary event properties, including false and zero. Empty transport
|
||||
// fields are not messages. This also applies to historical serialized payloads.
|
||||
export function hasExecutionValue(value: unknown): boolean {
|
||||
if (value == null) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.some(hasExecutionValue);
|
||||
if (typeof value === 'object')
|
||||
return Object.values(value).some(hasExecutionValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
function textProjection(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value.map(textProjection).join('');
|
||||
if (value && typeof value === 'object') {
|
||||
const item = value as Record<string, unknown>;
|
||||
if (item.type === 'text' || item.type === 'Plain')
|
||||
return typeof item.text === 'string' ? item.text : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const messageTypes = new Set([
|
||||
'Plain',
|
||||
'At',
|
||||
'AtAll',
|
||||
'Image',
|
||||
'File',
|
||||
'Voice',
|
||||
'Quote',
|
||||
]);
|
||||
const componentFields: Record<string, string[]> = {
|
||||
Plain: ['text'],
|
||||
At: ['target', 'display'],
|
||||
AtAll: [],
|
||||
Image: ['url', 'base64', 'imageId'],
|
||||
File: ['name', 'id', 'url', 'size'],
|
||||
Voice: ['url', 'base64', 'voiceId', 'length'],
|
||||
Quote: ['origin'],
|
||||
};
|
||||
|
||||
export function ExecutionValue({ value }: { value: unknown }) {
|
||||
if (!hasExecutionValue(value)) return null;
|
||||
if (typeof value === 'string') {
|
||||
// Do not reinterpret ordinary strings such as "false", "0", or quoted
|
||||
// user text. Only historical structured JSON needs decoding.
|
||||
if (/^\s*[\[{]/.test(value)) {
|
||||
try {
|
||||
return <ExecutionValue value={JSON.parse(value)} />;
|
||||
} catch {
|
||||
/* Text. */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm [overflow-wrap:anywhere]">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{value.map((item, index) => (
|
||||
<ExecutionValue key={index} value={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.type === 'string' && messageTypes.has(record.type)) {
|
||||
const fields = ['type', ...componentFields[record.type]];
|
||||
return (
|
||||
<>
|
||||
<MessageContentRenderer
|
||||
content={JSON.stringify([record])}
|
||||
maxLines={12}
|
||||
/>
|
||||
<ExecutionValue
|
||||
value={Object.fromEntries(
|
||||
Object.entries(record).filter(([key]) => !fields.includes(key)),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (record.type === 'text') {
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
([key, item]) =>
|
||||
key !== 'type' && key !== 'text' && hasExecutionValue(item),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ExecutionValue value={record.text} />
|
||||
<ExecutionValue value={rest} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (['image', 'image_url', 'image_base64'].includes(String(record.type))) {
|
||||
const image = record.image_url;
|
||||
const url =
|
||||
typeof image === 'string'
|
||||
? image
|
||||
: image && typeof image === 'object'
|
||||
? (image as { url?: string }).url
|
||||
: typeof record.image_base64 === 'string'
|
||||
? record.image_base64.startsWith('data:')
|
||||
? record.image_base64
|
||||
: `data:image/png;base64,${record.image_base64}`
|
||||
: null;
|
||||
if (url)
|
||||
return (
|
||||
<>
|
||||
<MessageContentRenderer
|
||||
content={JSON.stringify([{ type: 'Image', url }])}
|
||||
maxLines={12}
|
||||
/>
|
||||
<ExecutionValue
|
||||
value={Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
([key]) =>
|
||||
!['type', 'image_url', 'image_base64'].includes(key),
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (['file', 'file_url', 'file_base64'].includes(String(record.type))) {
|
||||
const name = record.file_name ?? record.name;
|
||||
return (
|
||||
<>
|
||||
<MessageContentRenderer
|
||||
content={JSON.stringify([{ type: 'File', name }])}
|
||||
maxLines={12}
|
||||
/>
|
||||
<ExecutionValue
|
||||
value={Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
([key]) =>
|
||||
!['type', 'file_name', 'name', 'file_base64'].includes(key),
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Only equal text projections are aliases. Never deduplicate distinct
|
||||
// records, or hide different text and structured content from one event.
|
||||
const bodyKeys = ['chain', 'message_chain', 'contents', 'content'];
|
||||
const bodyKey = bodyKeys.find((key) => hasExecutionValue(record[key]));
|
||||
const body = bodyKey ? record[bodyKey] : undefined;
|
||||
const duplicateText =
|
||||
bodyKey &&
|
||||
typeof record.text === 'string' &&
|
||||
record.text === textProjection(body);
|
||||
const entries = Object.entries(record).filter(
|
||||
([key, item]) =>
|
||||
hasExecutionValue(item) && !(key === 'text' && duplicateText),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, item]) => (
|
||||
<div key={key}>
|
||||
{!['text', ...bodyKeys, 'attachments'].includes(key) && (
|
||||
<div className="text-xs text-muted-foreground">{key}</div>
|
||||
)}
|
||||
<ExecutionValue value={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm">{String(value)}</span>;
|
||||
}
|
||||
@@ -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,44 +7,99 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
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';
|
||||
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
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;
|
||||
mode?: ExecutionModeFilter;
|
||||
onModeChange?: (value: ExecutionModeFilter) => void;
|
||||
statusGroup?: string;
|
||||
onStatusGroupChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_GROUP_OPTIONS = [
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
'queued',
|
||||
'cancelled',
|
||||
'ignored',
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
completed: 'text-emerald-700 dark:text-emerald-400',
|
||||
failed: 'text-red-600 dark:text-red-400',
|
||||
running: 'text-blue-600 dark:text-blue-400',
|
||||
queued: 'text-amber-700 dark:text-amber-400',
|
||||
cancelled: 'text-slate-600 dark:text-slate-400',
|
||||
ignored: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
interface Bot {
|
||||
uuid: string;
|
||||
name: string;
|
||||
iconURL: string;
|
||||
}
|
||||
|
||||
interface Pipeline {
|
||||
interface Processor {
|
||||
uuid: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export default function MonitoringFilters({
|
||||
selectedBots,
|
||||
selectedPipelines,
|
||||
timeRange,
|
||||
customDateRange,
|
||||
onCustomDateRangeChange,
|
||||
onBotsChange,
|
||||
onPipelinesChange,
|
||||
onTimeRangeChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
statusGroup,
|
||||
onStatusGroupChange,
|
||||
}: MonitoringFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const workspace = useCurrentWorkspace()?.workspace.uuid;
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||
const [processors, setProcessors] = useState<Processor[]>([]);
|
||||
const [loadingBots, setLoadingBots] = useState(false);
|
||||
const [loadingPipelines, setLoadingPipelines] = useState(false);
|
||||
const [loadingProcessors, setLoadingProcessors] = useState(false);
|
||||
|
||||
// Fetch bots list
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setBots([]);
|
||||
const fetchBots = async () => {
|
||||
setLoadingBots(true);
|
||||
try {
|
||||
@@ -52,41 +107,58 @@ export default function MonitoringFilters({
|
||||
// Filter out bots without uuid and map to local Bot interface
|
||||
const validBots = (response.bots || [])
|
||||
.filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)
|
||||
.map((bot) => ({ uuid: bot.uuid, name: bot.name }));
|
||||
setBots(validBots);
|
||||
.map((bot) => ({
|
||||
uuid: bot.uuid,
|
||||
name: bot.name,
|
||||
iconURL: backendClient.getAdapterIconURL(bot.adapter),
|
||||
}));
|
||||
if (active) setBots(validBots);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bots:', error);
|
||||
} finally {
|
||||
setLoadingBots(false);
|
||||
if (active) setLoadingBots(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBots();
|
||||
}, []);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [workspace]);
|
||||
|
||||
// Fetch pipelines list
|
||||
// Fetch all processor kinds from the same catalog as the sidebar
|
||||
useEffect(() => {
|
||||
const fetchPipelines = async () => {
|
||||
setLoadingPipelines(true);
|
||||
let active = true;
|
||||
setProcessors([]);
|
||||
const fetchProcessors = async () => {
|
||||
setLoadingProcessors(true);
|
||||
try {
|
||||
const response = await backendClient.getPipelines();
|
||||
// Filter out pipelines without uuid and map to local Pipeline interface
|
||||
const validPipelines = (response.pipelines || [])
|
||||
const response = await backendClient.getAgents();
|
||||
// Preserve the custom icon displayed to the left of the sidebar name.
|
||||
const validProcessors = (response.agents || [])
|
||||
.filter(
|
||||
(pipeline): pipeline is typeof pipeline & { uuid: string } =>
|
||||
!!pipeline.uuid,
|
||||
)
|
||||
.map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));
|
||||
setPipelines(validPipelines);
|
||||
.map((pipeline) => ({
|
||||
uuid: pipeline.uuid,
|
||||
name: pipeline.name,
|
||||
emoji: pipeline.emoji,
|
||||
kind: pipeline.kind,
|
||||
}));
|
||||
if (active) setProcessors(validProcessors);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipelines:', error);
|
||||
console.error('Failed to fetch processors:', error);
|
||||
} finally {
|
||||
setLoadingPipelines(false);
|
||||
if (active) setLoadingProcessors(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPipelines();
|
||||
}, []);
|
||||
fetchProcessors();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [workspace]);
|
||||
|
||||
const handleBotChange = (value: string) => {
|
||||
if (value === 'all') {
|
||||
@@ -96,31 +168,57 @@ 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="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-6">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
{/* Bot Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-bot"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('monitoring.filters.bot')}
|
||||
</label>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
|
||||
onValueChange={handleBotChange}
|
||||
disabled={loadingBots}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-bot"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingBots
|
||||
@@ -135,55 +233,234 @@ export default function MonitoringFilters({
|
||||
</SelectItem>
|
||||
{bots.map((bot) => (
|
||||
<SelectItem key={bot.uuid} value={bot.uuid}>
|
||||
{bot.name}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
src={bot.iconURL}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded"
|
||||
/>
|
||||
<span className="truncate">{bot.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
{t('monitoring.filters.pipeline')}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
|
||||
onValueChange={handlePipelineChange}
|
||||
disabled={loadingPipelines}
|
||||
{/* Processor Filter */}
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-processor"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingPipelines
|
||||
? t('monitoring.filters.loading')
|
||||
: t('monitoring.filters.selectPipeline')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.filters.allPipelines')}
|
||||
</SelectItem>
|
||||
{pipelines.map((pipeline) => (
|
||||
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
|
||||
{pipeline.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{t('monitoring.filters.processor')}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="monitoring-filter-processor"
|
||||
variant="outline"
|
||||
disabled={loadingProcessors}
|
||||
className="h-9 w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedPipelines.length
|
||||
? t('agents.apiToolsSelected', {
|
||||
count: selectedPipelines.length,
|
||||
})
|
||||
: t('monitoring.filters.allProcessors')}
|
||||
</span>
|
||||
<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 */}
|
||||
{onModeChange && (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-mode"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('monitoring.execution.filters.mode')}
|
||||
</Label>
|
||||
<Select
|
||||
value={mode ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
onModeChange(value as ExecutionModeFilter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-mode"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.execution.filters.modeAll')}
|
||||
</SelectItem>
|
||||
<SelectItem value="real">
|
||||
{t('monitoring.execution.filters.modeLive')}
|
||||
</SelectItem>
|
||||
<SelectItem value="debug">
|
||||
{t('monitoring.execution.filters.modeDebug')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
{onStatusGroupChange && (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-status"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('monitoring.execution.filters.status')}
|
||||
</Label>
|
||||
<Select
|
||||
value={statusGroup ?? 'all'}
|
||||
onValueChange={(value) => onStatusGroupChange(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-status"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.execution.filters.statusAll')}
|
||||
</SelectItem>
|
||||
{STATUS_GROUP_OPTIONS.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
<span
|
||||
className={`flex items-center gap-2 ${STATUS_COLORS[group]}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2 shrink-0 rounded-full bg-current"
|
||||
/>
|
||||
{t(`monitoring.execution.status.${group}`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Range Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-time"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('monitoring.filters.timeRange')}
|
||||
</label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[150px]">
|
||||
<SelectValue />
|
||||
</Label>
|
||||
<Select
|
||||
value={timeRange === 'custom' ? '' : timeRange}
|
||||
onValueChange={handleTimeRangeChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-time"
|
||||
className="h-9 w-full min-w-0 gap-2 [&>svg]:shrink-0"
|
||||
title={
|
||||
timeRange === 'custom' && customDateRange
|
||||
? `${customDateRange.from.toLocaleString()} – ${customDateRange.to.toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{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>
|
||||
@@ -196,11 +473,64 @@ export default function MonitoringFilters({
|
||||
<SelectItem value="last7Days">
|
||||
{t('monitoring.filters.last7Days')}
|
||||
</SelectItem>
|
||||
<SelectItem value="last30Days">
|
||||
{t('monitoring.filters.last30Days')}
|
||||
</SelectItem>
|
||||
{[30, 90, 180, 365].map((days) => (
|
||||
<SelectItem key={days} value={`last${days}Days`}>
|
||||
{t('monitoring.rangeDays', { days })}
|
||||
</SelectItem>
|
||||
))}
|
||||
{onCustomDateRangeChange && (
|
||||
<SelectItem value="custom">
|
||||
{t('monitoring.customRange')}
|
||||
</SelectItem>
|
||||
)}{' '}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Timer,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ExecutionSummary } from '@/app/infra/entities/api/monitoring-executions';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface ExecutionOverviewCardsProps {
|
||||
summary: ExecutionSummary | null;
|
||||
loading?: boolean;
|
||||
/** Active bot conversation sessions from the message rollup endpoint. */
|
||||
activeSessions?: number | null;
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-bold tabular-nums text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExecutionOverviewCards({
|
||||
summary,
|
||||
loading,
|
||||
activeSessions,
|
||||
}: ExecutionOverviewCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const overview = summary?.executions ?? null;
|
||||
const tokens = summary?.tokens ?? null;
|
||||
|
||||
// Bare cards, not a grid: the page owns the grid so every metric and the
|
||||
// runtime status card land in the same rows.
|
||||
return (
|
||||
<>
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.totalExecutions')}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
accent="#8b5cf6"
|
||||
loading={loading}
|
||||
value={(overview?.total ?? 0).toLocaleString()}
|
||||
hint={t('monitoring.execution.cards.bySource', {
|
||||
agent: (overview?.by_source.agent ?? 0).toLocaleString(),
|
||||
pipeline: (overview?.by_source.pipeline ?? 0).toLocaleString(),
|
||||
})}
|
||||
>
|
||||
{!!overview?.debug && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.cards.debugRuns', {
|
||||
count: overview.debug,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.successRate')}
|
||||
description={t('monitoring.execution.cards.successRateHint')}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
accent="#3b82f6"
|
||||
loading={loading}
|
||||
value={
|
||||
overview?.success_rate != null ? `${overview.success_rate}%` : '—'
|
||||
}
|
||||
hint={t('monitoring.execution.cards.denominator', {
|
||||
completed: (overview?.completed ?? 0).toLocaleString(),
|
||||
denominator: (overview?.denominator ?? 0).toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.running')}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
accent="#06b6d4"
|
||||
loading={loading}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.running')}
|
||||
value={(overview?.running ?? 0).toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.queued')}
|
||||
value={(overview?.queued ?? 0).toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.waiting')}
|
||||
value={(overview?.waiting ?? 0).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.failed')}
|
||||
icon={<TriangleAlert className="h-4 w-4" />}
|
||||
accent="#ef4444"
|
||||
loading={loading}
|
||||
value={
|
||||
<span className="flex items-center gap-3">
|
||||
{(overview?.failed ?? 0).toLocaleString()}
|
||||
<Badge variant="secondary">
|
||||
{t('monitoring.execution.cards.ignored')}{' '}
|
||||
{(overview?.ignored ?? 0).toLocaleString()}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.latency')}
|
||||
description={t('monitoring.execution.cards.latencyHint')}
|
||||
icon={<Timer className="h-4 w-4" />}
|
||||
accent="#f59e0b"
|
||||
loading={loading}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.p50')}
|
||||
value={
|
||||
overview?.p50_duration_ms != null
|
||||
? formatRunDuration(overview.p50_duration_ms)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label={t('monitoring.execution.cards.p95')}
|
||||
value={
|
||||
overview?.p95_duration_ms != null
|
||||
? formatRunDuration(overview.p95_duration_ms)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* The sample size explains the percentiles above, so it stays under
|
||||
them rather than in the card's hint slot. */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('monitoring.execution.cards.sample', {
|
||||
count: overview?.duration_sample ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.execution.cards.tokens')}
|
||||
description={t('monitoring.execution.cards.tokensHint')}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
accent="#10b981"
|
||||
loading={loading}
|
||||
value={(tokens?.total_tokens ?? 0).toLocaleString()}
|
||||
hint={t('monitoring.execution.cards.coverage', {
|
||||
withUsage: (tokens?.calls_with_usage ?? 0).toLocaleString(),
|
||||
calls: (tokens?.calls ?? 0).toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label={t('monitoring.activeSessions')}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
accent="#6366f1"
|
||||
loading={loading}
|
||||
value={(activeSessions ?? 0).toLocaleString()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,116 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
'use client';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
direction: 'up' | 'down';
|
||||
};
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
export interface MetricCardProps {
|
||||
/** Small label, sitting next to the icon. */
|
||||
label: string;
|
||||
description?: string;
|
||||
/** The card's headline value; a node so it can carry a badge. */
|
||||
value?: React.ReactNode;
|
||||
/** Icon of the tinted tile on the leading edge. */
|
||||
icon?: React.ReactNode;
|
||||
/** Muted line under the value. */
|
||||
hint?: React.ReactNode;
|
||||
/** Tile colour as a hex value; defaults to the muted token. */
|
||||
accent?: string;
|
||||
/** Rendered at the card's top-right (tooltip trigger, help dialog, badge). */
|
||||
action?: React.ReactNode;
|
||||
/** Extra body under the value, for composite cards (stat grids, status rows). */
|
||||
children?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MetricCard({
|
||||
title,
|
||||
/**
|
||||
* The dashboard's one metric card: tinted icon tile and label on the leading
|
||||
* edge, value, then a muted hint. Token, execution, feedback and runtime
|
||||
* counters all render through it, so their rows and internal spacing match
|
||||
* instead of each surface inventing its own card.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
hint,
|
||||
accent,
|
||||
action,
|
||||
children,
|
||||
loading,
|
||||
className,
|
||||
}: MetricCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">{icon}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground mb-2">{value}</div>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Card className={cn('gap-3 p-4', className)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
trend.direction === 'up'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
|
||||
color: accent || 'var(--muted-foreground)',
|
||||
}}
|
||||
>
|
||||
{trend.direction === 'up' ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{Math.abs(trend.value)}%
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs previous period
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
{description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={label}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
className="max-w-[min(20rem,calc(100vw-2rem))] text-sm leading-relaxed"
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{value != null && value !== '' && (
|
||||
<div className="text-2xl font-semibold tabular-nums text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
{hint != null && hint !== '' && (
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricCard;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
|
||||
import MetricCard from './MetricCard';
|
||||
import SystemStatusCard from './SystemStatusCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface OverviewCardsProps {
|
||||
metrics: OverviewMetrics | null;
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function OverviewCards({
|
||||
metrics,
|
||||
traffic,
|
||||
loading,
|
||||
refreshKey,
|
||||
}: OverviewCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('monitoring.totalMessages'),
|
||||
value: metrics?.totalMessages || 0,
|
||||
icon: <MessageSquare />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.messages,
|
||||
direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.modelCallsCount'),
|
||||
value: metrics?.modelCalls || 0,
|
||||
icon: <Sparkles />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.llmCalls,
|
||||
direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.successRate'),
|
||||
value: metrics ? `${metrics.successRate}%` : '0%',
|
||||
icon: <Check />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.successRate,
|
||||
direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.activeSessions'),
|
||||
value: metrics?.activeSessions || 0,
|
||||
icon: <Users />,
|
||||
trend: metrics?.trends
|
||||
? {
|
||||
value: metrics.trends.sessions,
|
||||
direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
|
||||
| 'up'
|
||||
| 'down',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metric Cards + System Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<MetricCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
icon={card.icon}
|
||||
trend={card.trend}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
<SystemStatusCard refreshKey={refreshKey} />
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart traffic={traffic} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Box,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Loader2,
|
||||
Server,
|
||||
Info,
|
||||
Container,
|
||||
Clock,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Image,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -38,6 +38,10 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
type StatusState = 'ok' | 'disabled' | 'failed' | null;
|
||||
|
||||
// Neutral tile: the status dots already carry the health colour, so the card's
|
||||
// own tile must not encode a second, conflicting verdict.
|
||||
const SYSTEM_STATUS_ACCENT = '#64748b';
|
||||
|
||||
function StatusDot({ state }: { state: StatusState }) {
|
||||
if (state === null)
|
||||
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
|
||||
@@ -118,28 +122,22 @@ export default function SystemStatusCard({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('monitoring.systemStatus')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricCard
|
||||
label={t('monitoring.systemStatus')}
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
accent={SYSTEM_STATUS_ACCENT}
|
||||
loading
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('monitoring.systemStatus')}
|
||||
</CardTitle>
|
||||
<MetricCard
|
||||
label={t('monitoring.systemStatus')}
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
accent={SYSTEM_STATUS_ACCENT}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -148,8 +146,9 @@ export default function SystemStatusCard({
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={pluginState} />
|
||||
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -162,8 +161,8 @@ export default function SystemStatusCard({
|
||||
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
|
||||
@@ -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,8 +9,14 @@ 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';
|
||||
|
||||
interface TrafficChartProps {
|
||||
@@ -18,7 +24,66 @@ 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(
|
||||
() =>
|
||||
@@ -39,18 +104,11 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
[traffic],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
if (loading && !traffic) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
|
||||
<Skeleton className="w-full h-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -58,10 +116,7 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border 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">
|
||||
@@ -77,10 +132,7 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border 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')}
|
||||
@@ -139,16 +191,8 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
}}
|
||||
itemStyle={{ padding: '4px 0' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
fontSize: '13px',
|
||||
paddingTop: '16px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
iconType="circle"
|
||||
iconSize={10}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="messages"
|
||||
name={t('monitoring.trafficChart.messages')}
|
||||
@@ -160,6 +204,7 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
/>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey="llmCalls"
|
||||
name={t('monitoring.trafficChart.llmCalls')}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { TimeRangeOption, DateRange } from '../types/monitoring';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { resolveMonitoringWindow } from '../utils/dateUtils';
|
||||
import type {
|
||||
ExecutionListResult,
|
||||
ExecutionModeFilter,
|
||||
ExecutionSourceFilter,
|
||||
} from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching the unified execution list
|
||||
*/
|
||||
export function useExecutions(params: {
|
||||
selectedBots: string[];
|
||||
selectedPipelines: string[];
|
||||
selectedAgents: string[];
|
||||
source: ExecutionSourceFilter;
|
||||
mode: ExecutionModeFilter;
|
||||
statusGroup: string;
|
||||
timeRange: TimeRangeOption;
|
||||
customDateRange: DateRange | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const [result, setResult] = useState<ExecutionListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([
|
||||
workspaceUuid,
|
||||
{ ...params, refreshKey: undefined },
|
||||
]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
const selectedBotsStr = useMemo(
|
||||
() => JSON.stringify(params.selectedBots),
|
||||
[params.selectedBots],
|
||||
);
|
||||
const selectedPipelinesStr = useMemo(
|
||||
() => JSON.stringify(params.selectedPipelines),
|
||||
[params.selectedPipelines],
|
||||
);
|
||||
const selectedAgentsStr = useMemo(
|
||||
() => JSON.stringify(params.selectedAgents),
|
||||
[params.selectedAgents],
|
||||
);
|
||||
const customDateRangeStr = useMemo(
|
||||
() => JSON.stringify(params.customDateRange),
|
||||
[params.customDateRange],
|
||||
);
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { startTime, endTime } = resolveMonitoringWindow(
|
||||
params.timeRange,
|
||||
params.customDateRange,
|
||||
);
|
||||
|
||||
const response = await backendClient.getExecutions({
|
||||
botId: params.selectedBots.length > 0 ? params.selectedBots : undefined,
|
||||
pipelineId:
|
||||
params.selectedPipelines.length > 0
|
||||
? params.selectedPipelines
|
||||
: undefined,
|
||||
agentId:
|
||||
params.selectedAgents.length > 0 ? params.selectedAgents : undefined,
|
||||
status: params.statusGroup === 'all' ? undefined : [params.statusGroup],
|
||||
source: params.source,
|
||||
mode: params.mode,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
setResult(response);
|
||||
setRequestScope(scope);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setRequestScope(scope);
|
||||
setResult((current) => (requestScope === scope ? current : null));
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch executions:', err);
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [
|
||||
params.selectedBots,
|
||||
params.selectedPipelines,
|
||||
params.selectedAgents,
|
||||
params.source,
|
||||
params.mode,
|
||||
params.statusGroup,
|
||||
params.timeRange,
|
||||
params.customDateRange,
|
||||
params.limit,
|
||||
params.offset,
|
||||
scope,
|
||||
requestScope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Fetch data when filter state changes
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
selectedAgentsStr,
|
||||
params.source,
|
||||
params.mode,
|
||||
params.statusGroup,
|
||||
params.timeRange,
|
||||
customDateRangeStr,
|
||||
params.limit,
|
||||
params.offset,
|
||||
params.refreshKey,
|
||||
nonce,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Manual refetch function
|
||||
const refetch = () => {
|
||||
setNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
result: requestScope === scope ? result : null,
|
||||
loading: requestScope !== scope || loading,
|
||||
error: requestScope === scope ? error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
interface UseFeedbackDataParams {
|
||||
mode?: 'all' | 'real' | 'debug';
|
||||
statusGroup?: string;
|
||||
botIds?: string[];
|
||||
pipelineIds?: string[];
|
||||
startTime?: string;
|
||||
@@ -60,6 +62,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
if (params.statusGroup && params.statusGroup !== 'all')
|
||||
queryParams.append('status', params.statusGroup);
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
@@ -99,7 +104,14 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch feedback stats:', err);
|
||||
}
|
||||
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
|
||||
}, [
|
||||
params.mode,
|
||||
params.statusGroup,
|
||||
params.botIds,
|
||||
params.pipelineIds,
|
||||
params.startTime,
|
||||
params.endTime,
|
||||
]);
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -107,6 +119,9 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
if (params.statusGroup && params.statusGroup !== 'all')
|
||||
queryParams.append('status', params.statusGroup);
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../types/monitoring';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
import { parseUTCTimestamp, resolveMonitoringWindow } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing monitoring data
|
||||
@@ -36,60 +36,28 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
[filterState.customDateRange],
|
||||
);
|
||||
|
||||
// Convert time range to datetime strings
|
||||
const getTimeRange = useCallback(() => {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
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;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
filterState.timeRange === 'custom' && filterState.customDateRange
|
||||
? filterState.customDateRange.to
|
||||
: now;
|
||||
|
||||
return {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Keep the failure view during retries; restore the dashboard only after
|
||||
// a successful response, without briefly restarting its polling timer.
|
||||
|
||||
try {
|
||||
const { startTime, endTime } = getTimeRange();
|
||||
const { startTime, endTime } = resolveMonitoringWindow(
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
);
|
||||
|
||||
const response = await backendClient.getMonitoringData({
|
||||
mode: filterState.mode,
|
||||
status:
|
||||
filterState.statusGroup && filterState.statusGroup !== 'all'
|
||||
? [filterState.statusGroup]
|
||||
: undefined,
|
||||
botId:
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
@@ -418,18 +386,26 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
setData(transformedData);
|
||||
setError(null);
|
||||
setRequestScope(scope);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setRequestScope(scope);
|
||||
setData((current) => (requestScope === scope ? current : null));
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch monitoring data:', err);
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.mode,
|
||||
filterState.statusGroup,
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
requestScope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
@@ -443,6 +419,8 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
filterState.mode,
|
||||
filterState.statusGroup,
|
||||
filterState.timeRange,
|
||||
customDateRangeStr,
|
||||
workspaceUuid,
|
||||
|
||||
@@ -1,65 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
|
||||
import { getPresetDateRange } from '../utils/dateUtils';
|
||||
import type { ExecutionModeFilter } from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
/**
|
||||
* Custom hook for managing monitoring filters
|
||||
*/
|
||||
export function useMonitoringFilters() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const defaults = (): FilterState => ({
|
||||
selectedBots: [],
|
||||
selectedPipelines: [],
|
||||
timeRange: 'last24Hours',
|
||||
customDateRange: null,
|
||||
mode: 'all',
|
||||
statusGroup: 'all',
|
||||
});
|
||||
const timeRanges = [
|
||||
'lastHour',
|
||||
'last6Hours',
|
||||
'last24Hours',
|
||||
'last7Days',
|
||||
'last30Days',
|
||||
'last90Days',
|
||||
'last180Days',
|
||||
'last365Days',
|
||||
'custom',
|
||||
];
|
||||
const statuses = [
|
||||
'all',
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
'queued',
|
||||
'cancelled',
|
||||
'ignored',
|
||||
];
|
||||
|
||||
// Initialize filters from URL params
|
||||
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
|
||||
const botId = searchParams.get('botId');
|
||||
return botId ? [botId] : [];
|
||||
});
|
||||
|
||||
const [selectedPipelines, setSelectedPipelines] = useState<string[]>(() => {
|
||||
const pipelineId = searchParams.get('pipelineId');
|
||||
return pipelineId ? [pipelineId] : [];
|
||||
});
|
||||
|
||||
const [timeRange, setTimeRange] = useState<TimeRangeOption>('last24Hours');
|
||||
const [customDateRange, setCustomDateRange] = useState<DateRange | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Get the active date range (either preset or custom)
|
||||
const getActiveDateRange = (): DateRange | null => {
|
||||
if (timeRange === 'custom' && customDateRange) {
|
||||
return customDateRange;
|
||||
function restore(key: string): FilterState {
|
||||
const state = defaults();
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) || 'null');
|
||||
if (!saved || typeof saved !== 'object') return state;
|
||||
for (const name of ['selectedBots', 'selectedPipelines'] as const) {
|
||||
if (Array.isArray(saved[name]))
|
||||
state[name] = saved[name].filter(
|
||||
(id: unknown) => typeof id === 'string' && id.length > 0,
|
||||
);
|
||||
}
|
||||
return getPresetDateRange(timeRange);
|
||||
};
|
||||
if (timeRanges.includes(saved.timeRange)) state.timeRange = saved.timeRange;
|
||||
if (['all', 'real', 'debug'].includes(saved.mode)) state.mode = saved.mode;
|
||||
if (statuses.includes(saved.statusGroup))
|
||||
state.statusGroup = saved.statusGroup;
|
||||
if (saved.customDateRange) {
|
||||
const from = new Date(saved.customDateRange.from);
|
||||
const to = new Date(saved.customDateRange.to);
|
||||
if (
|
||||
Number.isFinite(from.getTime()) &&
|
||||
Number.isFinite(to.getTime()) &&
|
||||
from < to &&
|
||||
to.getTime() - from.getTime() <= 365 * 86400000
|
||||
)
|
||||
state.customDateRange = { from, to };
|
||||
}
|
||||
if (state.timeRange === 'custom' && !state.customDateRange)
|
||||
state.timeRange = 'last24Hours';
|
||||
} catch {
|
||||
// Invalid or unavailable browser storage must not prevent monitoring.
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Reset all filters
|
||||
/** The page is keyed by Workspace so saved resource IDs never cross scopes. */
|
||||
export function useMonitoringFilters() {
|
||||
const workspace = useCurrentWorkspace()?.workspace.uuid;
|
||||
const key = `monitoring.filters.v1:${workspace ?? 'pending'}`;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [filterState, setFilters] = useState<FilterState>(() => {
|
||||
const state = workspace ? restore(key) : defaults();
|
||||
// Explicit links take precedence over saved resource filters.
|
||||
if (searchParams.has('botId'))
|
||||
state.selectedBots = searchParams.getAll('botId').filter(Boolean);
|
||||
if (searchParams.has('pipelineId'))
|
||||
state.selectedPipelines = searchParams
|
||||
.getAll('pipelineId')
|
||||
.filter(Boolean);
|
||||
return state;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(filterState));
|
||||
} catch {
|
||||
/* Storage may be disabled. */
|
||||
}
|
||||
}, [key, workspace, filterState]);
|
||||
const update = (value: Partial<FilterState>) =>
|
||||
setFilters((current) => ({ ...current, ...value }));
|
||||
const clearLinkedFilter = (name: string) => {
|
||||
if (!searchParams.has(name)) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(name);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setSelectedBots([]);
|
||||
setSelectedPipelines([]);
|
||||
setTimeRange('last24Hours');
|
||||
setCustomDateRange(null);
|
||||
setFilters(defaults());
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('botId');
|
||||
next.delete('pipelineId');
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
// Get the current filter state
|
||||
const filterState: FilterState = {
|
||||
selectedBots,
|
||||
selectedPipelines,
|
||||
timeRange,
|
||||
customDateRange,
|
||||
};
|
||||
|
||||
return {
|
||||
selectedBots,
|
||||
setSelectedBots,
|
||||
selectedPipelines,
|
||||
setSelectedPipelines,
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
customDateRange,
|
||||
setCustomDateRange,
|
||||
getActiveDateRange,
|
||||
resetFilters,
|
||||
...filterState,
|
||||
filterState,
|
||||
setSelectedBots: (selectedBots: string[]) => {
|
||||
update({ selectedBots });
|
||||
clearLinkedFilter('botId');
|
||||
},
|
||||
setSelectedPipelines: (selectedPipelines: string[]) => {
|
||||
update({ selectedPipelines });
|
||||
clearLinkedFilter('pipelineId');
|
||||
},
|
||||
setTimeRange: (timeRange: TimeRangeOption) => update({ timeRange }),
|
||||
setCustomDateRange: (customDateRange: DateRange | null) =>
|
||||
update({ customDateRange }),
|
||||
setExecutionMode: (mode: ExecutionModeFilter) => update({ mode }),
|
||||
setExecutionStatus: (statusGroup: string) => update({ statusGroup }),
|
||||
getActiveDateRange: () =>
|
||||
filterState.timeRange === 'custom'
|
||||
? filterState.customDateRange
|
||||
: getPresetDateRange(filterState.timeRange),
|
||||
resetFilters,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -164,6 +164,8 @@ export interface OverviewMetrics {
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
mode?: 'all' | 'real' | 'debug';
|
||||
statusGroup?: string;
|
||||
selectedBots: string[];
|
||||
selectedPipelines: string[];
|
||||
timeRange: TimeRangeOption;
|
||||
@@ -176,6 +178,9 @@ export type TimeRangeOption =
|
||||
| 'last24Hours'
|
||||
| 'last7Days'
|
||||
| 'last30Days'
|
||||
| 'last90Days'
|
||||
| 'last180Days'
|
||||
| 'last365Days'
|
||||
| 'custom';
|
||||
|
||||
export interface DateRange {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ExecutionRow } from '@/app/infra/entities/api/monitoring-executions';
|
||||
|
||||
/**
|
||||
* The identity a recorded call carries: every call kind (model, tool) has a time,
|
||||
* a session and - when a Pipeline recorded it - the message it belongs to.
|
||||
*/
|
||||
export interface CallLinkSource {
|
||||
timestamp: Date;
|
||||
sessionId?: string | null;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
/** A record the dashboard can open for a recorded model or tool call. */
|
||||
export type CallTarget =
|
||||
{ kind: 'execution'; row: ExecutionRow } | { kind: 'message'; id: string };
|
||||
|
||||
/** Calls are matched against a run's window; clock skew must not lose the link. */
|
||||
const WINDOW_SLACK_MS = 2000;
|
||||
|
||||
function containsCall(row: ExecutionRow, callTime: number): boolean {
|
||||
if (row.started_at_ms == null) {
|
||||
return false;
|
||||
}
|
||||
const started = row.started_at_ms;
|
||||
if (callTime < started - WINDOW_SLACK_MS) {
|
||||
return false;
|
||||
}
|
||||
if (row.finished_at_ms == null) {
|
||||
// A run with no end time is still open: anything after its start belongs to it.
|
||||
return true;
|
||||
}
|
||||
return callTime <= row.finished_at_ms + WINDOW_SLACK_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The execution a recorded call ran inside.
|
||||
*
|
||||
* A Pipeline call names its message, which is the same id the execution list
|
||||
* uses for that query. An Agent call names nothing, so it is matched to the run
|
||||
* sharing its session whose window contains the call; the latest such start
|
||||
* wins, so a session with consecutive runs links to the run that was live.
|
||||
*/
|
||||
export function findCallExecution(
|
||||
call: CallLinkSource,
|
||||
executions: ExecutionRow[],
|
||||
): ExecutionRow | null {
|
||||
if (executions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageId = call.messageId;
|
||||
if (messageId) {
|
||||
const direct = executions.find((row) => row.id === messageId);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = call.sessionId;
|
||||
const callTime = call.timestamp.getTime();
|
||||
let matched: ExecutionRow | null = null;
|
||||
for (const row of executions) {
|
||||
if (
|
||||
!sessionId ||
|
||||
row.session_id !== sessionId ||
|
||||
!containsCall(row, callTime)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
matched == null ||
|
||||
(row.started_at_ms ?? 0) > (matched.started_at_ms ?? 0)
|
||||
) {
|
||||
matched = row;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a call's card jumps to: its execution record when the loaded list knows
|
||||
* it, else the pipeline message it names. An Agent call carries its run id, so
|
||||
* the message fallback applies only to ids the message list actually holds.
|
||||
*/
|
||||
export function resolveCallTarget(
|
||||
call: CallLinkSource,
|
||||
executions: ExecutionRow[],
|
||||
messageIds?: ReadonlySet<string>,
|
||||
): CallTarget | null {
|
||||
const row = findCallExecution(call, executions);
|
||||
if (row) {
|
||||
return { kind: 'execution', row };
|
||||
}
|
||||
const messageId = call.messageId;
|
||||
if (messageId && messageIds?.has(messageId)) {
|
||||
return { kind: 'message', id: messageId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -22,8 +22,13 @@ 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;
|
||||
@@ -116,3 +121,53 @@ export function parseUTCTimestamp(timestamp: string): Date {
|
||||
}
|
||||
return new Date(timestamp + 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a monitoring time window to ISO start/end strings.
|
||||
*
|
||||
* Single source of the window semantics for the dashboard, the unified
|
||||
* execution list, and the feedback queries.
|
||||
*/
|
||||
export function resolveMonitoringWindow(
|
||||
timeRange: TimeRangeOption,
|
||||
customDateRange: DateRange | null,
|
||||
): { startTime: string; endTime: string } {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (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 'last90Days':
|
||||
case 'last180Days':
|
||||
case 'last365Days':
|
||||
case 'last30Days':
|
||||
startTime = new Date(
|
||||
now.getTime() - Number(timeRange.match(/\d+/)?.[0] ?? 30) * 86400000,
|
||||
);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customDateRange) {
|
||||
startTime = customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
timeRange === 'custom' && customDateRange ? customDateRange.to : now;
|
||||
|
||||
return {
|
||||
startTime: (startTime ?? now).toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { isCurrentPipelineConfig } from './pipeline-config-safety';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -51,10 +51,9 @@ export default function PipelineDetailContent({
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||
'workbench',
|
||||
);
|
||||
useEffect(() => setActiveView('workbench'), [id]);
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeView =
|
||||
searchParams.get('tab') === 'logs' ? 'monitoring' : 'workbench';
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
@@ -187,7 +186,6 @@ export default function PipelineDetailContent({
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
onViewChange={setActiveView}
|
||||
title={`${pipelineEmoji} ${pipelineName}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
|
||||
@@ -147,7 +147,15 @@ export default function PipelineMigration({
|
||||
['migrated', 'already_current'].includes(item.state),
|
||||
);
|
||||
const rows = preview?.items ?? [];
|
||||
// Custom 4.10 runners cannot be migrated automatically; they are listed
|
||||
// for attention but never submitted.
|
||||
const count = rows.filter(
|
||||
(item) =>
|
||||
!['already_current', 'not_legacy', 'unsupported_runner'].includes(
|
||||
item.state,
|
||||
),
|
||||
).length;
|
||||
const attention = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
).length;
|
||||
|
||||
@@ -189,7 +197,10 @@ export default function PipelineMigration({
|
||||
)
|
||||
return;
|
||||
let items = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
(item) =>
|
||||
!['already_current', 'not_legacy', 'unsupported_runner'].includes(
|
||||
item.state,
|
||||
),
|
||||
);
|
||||
setInstallations([]);
|
||||
setDataOnly(!installPlugins);
|
||||
@@ -340,16 +351,12 @@ export default function PipelineMigration({
|
||||
|
||||
return (
|
||||
<>
|
||||
{(count > 0 || previewError || results.length > 0) && (
|
||||
{(attention > 0 || results.length > 0) && (
|
||||
<Alert className="mb-4 shrink-0 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-3 text-amber-800 dark:text-amber-200">
|
||||
<span>
|
||||
{previewError
|
||||
? t('pipelineMigration.previewError')
|
||||
: t('pipelineMigration.detected', { count })}
|
||||
</span>
|
||||
<span>{t('pipelineMigration.detected', { count: attention })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -408,7 +415,7 @@ export default function PipelineMigration({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelineMigration.detected', { count })}
|
||||
{t('pipelineMigration.detected', { count: attention })}
|
||||
</p>
|
||||
)}
|
||||
<MigrationInstallProgress installations={installations} />
|
||||
@@ -421,7 +428,7 @@ export default function PipelineMigration({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{(count > 0 || results.length > 0) && (
|
||||
{(attention > 0 || results.length > 0) && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
@@ -474,6 +481,11 @@ export default function PipelineMigration({
|
||||
{item.target_plugin.name}
|
||||
</p>
|
||||
)}
|
||||
{item.state === 'unsupported_runner' && (
|
||||
<p className="text-xs text-destructive break-words">
|
||||
{t('pipelineMigration.notices.customRunner')}
|
||||
</p>
|
||||
)}
|
||||
{item.warnings.some(
|
||||
(issue) =>
|
||||
issue.code === 'local.box_state_reset',
|
||||
|
||||
@@ -231,6 +231,7 @@ export default function DebugDialog({
|
||||
wsClient
|
||||
.onConnected(() => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
errorReported = false;
|
||||
setIsConnected(true);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
@@ -261,11 +262,12 @@ export default function DebugDialog({
|
||||
})
|
||||
.onError((error) => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
const notify = !errorReported;
|
||||
errorReported = true;
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||
if (notify) toast.error(t('pipelines.debugDialog.connectionError'));
|
||||
})
|
||||
.onClose(() => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
@@ -332,7 +334,22 @@ export default function DebugDialog({
|
||||
loadMessages(selectedPipelineId);
|
||||
initWebSocket(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
|
||||
return () => {
|
||||
invalidateHistoryRequests();
|
||||
const client = wsClientRef.current;
|
||||
wsClientRef.current = null;
|
||||
client?.disconnect();
|
||||
isInitializingRef.current = false;
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [
|
||||
invalidateHistoryRequests,
|
||||
sessionType,
|
||||
selectedPipelineId,
|
||||
open,
|
||||
loadMessages,
|
||||
initWebSocket,
|
||||
]);
|
||||
|
||||
// Notify parent of connection status changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
CircleHelp,
|
||||
Plus,
|
||||
X,
|
||||
Server,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -24,6 +32,27 @@ import {
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
|
||||
function PluginThumbnail({ author, name }: { author: string; name: string }) {
|
||||
const iconURL = useInstalledPluginIcon(author, name);
|
||||
|
||||
if (!iconURL) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Puzzle className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTooltip({ label }: { label: string }) {
|
||||
return (
|
||||
@@ -366,13 +395,9 @@ export default function PipelineExtension({
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
<PluginThumbnail
|
||||
author={metadata.author || ''}
|
||||
name={metadata.name}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
@@ -653,13 +678,9 @@ export default function PipelineExtension({
|
||||
onClick={() => handleTogglePlugin(pluginId)}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
<PluginThumbnail
|
||||
author={metadata.author || ''}
|
||||
name={metadata.name}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty';
|
||||
import GuidedTour, {
|
||||
type GuidedTourStep,
|
||||
} from '@/app/home/components/guided-tour/GuidedTour';
|
||||
@@ -25,7 +26,7 @@ import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicForm
|
||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -324,6 +325,13 @@ const PipelineFormComponent = forwardRef<
|
||||
output: {},
|
||||
},
|
||||
});
|
||||
useWatch({
|
||||
control: form.control,
|
||||
compute: (values) => [
|
||||
values.ai?.runner?.id,
|
||||
values.output?.misc?.['remove-think'],
|
||||
],
|
||||
});
|
||||
const runnerInstallScope = `pipeline:${pipelineId || 'new'}`;
|
||||
const applyInstalledRunner = useCallback((installed: InstalledRunner) => {
|
||||
setAIConfigTabSchema(installed.configTab);
|
||||
@@ -337,15 +345,11 @@ const PipelineFormComponent = forwardRef<
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
// Track which dynamic form stages have completed their initial mount emission.
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = (() => {
|
||||
if (!isEditMode || !savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
})();
|
||||
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
|
||||
// read the latest dirty state without stale closures.
|
||||
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||
const {
|
||||
isDirty: hasUnsavedChanges,
|
||||
dirtyRef: hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
} = useSnapshotDirty(form, savedSnapshotRef, isEditMode);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
useEffect(() => {
|
||||
@@ -405,6 +409,7 @@ const PipelineFormComponent = forwardRef<
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
refreshDirty();
|
||||
initializedStagesRef.current.clear();
|
||||
setPipelineLoaded(true);
|
||||
})
|
||||
@@ -415,7 +420,14 @@ const PipelineFormComponent = forwardRef<
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
|
||||
}, [
|
||||
form,
|
||||
isEditMode,
|
||||
pipelineId,
|
||||
loadAttempt,
|
||||
onLegacyPipeline,
|
||||
refreshDirty,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -538,6 +550,7 @@ const PipelineFormComponent = forwardRef<
|
||||
try {
|
||||
await httpClient.updatePipeline(pipelineId || '', pipeline);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
refreshDirty();
|
||||
onFinish();
|
||||
toast.success(t('pipelines.saveSuccess'));
|
||||
return true;
|
||||
@@ -572,6 +585,7 @@ const PipelineFormComponent = forwardRef<
|
||||
emoji: values.emoji || '⚙️',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
refreshDirty();
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
@@ -595,6 +609,7 @@ const PipelineFormComponent = forwardRef<
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
const wasDirty = hasUnsavedChangesRef.current;
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission =
|
||||
!initializedStagesRef.current.has(stageKey) &&
|
||||
@@ -665,8 +680,9 @@ const PipelineFormComponent = forwardRef<
|
||||
// runner), the snapshot must remain at the last-saved state so that
|
||||
// hasUnsavedChanges stays true.
|
||||
const currentSnapshot = JSON.stringify(form.getValues());
|
||||
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
|
||||
if (savedSnapshotRef.current === '' || !wasDirty) {
|
||||
savedSnapshotRef.current = currentSnapshot;
|
||||
refreshDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,7 +714,7 @@ const PipelineFormComponent = forwardRef<
|
||||
) {
|
||||
// Special handling for AI config section
|
||||
if (formName === 'ai') {
|
||||
const runnerConfig = (form.watch('ai.runner') as any) || {};
|
||||
const runnerConfig = (form.getValues('ai.runner') as any) || {};
|
||||
const currentRunner = runnerConfig.id;
|
||||
|
||||
// If this is the runner selector stage, render it directly
|
||||
@@ -717,7 +733,7 @@ const PipelineFormComponent = forwardRef<
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
(form.watch(formName) as Record<string, unknown>)?.[
|
||||
(form.getValues(formName) as Record<string, unknown>)?.[
|
||||
stage.name
|
||||
] || {}
|
||||
}
|
||||
@@ -752,7 +768,7 @@ const PipelineFormComponent = forwardRef<
|
||||
const isPluginRunner =
|
||||
currentRunner && currentRunner.startsWith('plugin:');
|
||||
if (isPluginRunner) {
|
||||
const runnerConfigs = (form.watch('ai.runner_config') as any) || {};
|
||||
const runnerConfigs = (form.getValues('ai.runner_config') as any) || {};
|
||||
const stageInitialValues = runnerConfigs[stage.name] || {};
|
||||
return (
|
||||
<Card key={stage.name}>
|
||||
@@ -784,7 +800,7 @@ const PipelineFormComponent = forwardRef<
|
||||
? PersistedRunnerForm
|
||||
: DynamicFormComponent;
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
(form.getValues(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
|
||||
return (
|
||||
<Card key={stage.name}>
|
||||
|
||||
@@ -31,6 +31,21 @@ const issueKeys: Record<string, string> = {
|
||||
runner_schema_incompatible: 'schemaChanged',
|
||||
plugin_version_incompatible: 'pluginVersion',
|
||||
'runtime.pending_interaction': 'pendingInteraction',
|
||||
'legacy.custom_runner_unsupported': 'customRunner',
|
||||
'migration.unknown_field_dropped': 'fieldDropped',
|
||||
'migration.unknown_section_archived': 'fieldDropped',
|
||||
'migration.value_coerced': 'valueRepaired',
|
||||
'migration.invalid_value_defaulted': 'valueRepaired',
|
||||
'migration.missing_default_filled': 'valueRepaired',
|
||||
'migration.alias_conflict_resolved': 'aliasRepaired',
|
||||
'local.prompt_normalized': 'valueRepaired',
|
||||
'local.reasoning_dropped': 'valueRepaired',
|
||||
'local.box_template_defaulted': 'valueRepaired',
|
||||
'langflow.tweaks_invalid_defaulted': 'valueRepaired',
|
||||
'migration.mixed_runner_selection': 'mixedRunner',
|
||||
'migration.runner_config_replaced': 'runnerConfigReplaced',
|
||||
'extensions.runner_added': 'runnerAdded',
|
||||
invalid_extension_preferences: 'extensionPrefs',
|
||||
};
|
||||
|
||||
/** Only locally reviewed codes may select localized copy. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import LoadErrorState from '@/components/LoadErrorState';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
@@ -202,9 +203,11 @@ function PluginPageIframe({
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{assetError ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('plugins.loadFailed')}
|
||||
</div>
|
||||
<LoadErrorState
|
||||
title={t('plugins.loadFailed')}
|
||||
className="flex-1"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
) : loading || !assetUrl ? (
|
||||
<EntityLoadState />
|
||||
) : null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user