mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
fix: resolve master conflicts and align connection mode regression
This commit is contained in:
@@ -55,6 +55,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@remixicon/react": "^4.9.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
|
||||
Generated
+11
@@ -93,6 +93,9 @@ dependencies:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.8(@types/[email protected])(@types/[email protected])([email protected])([email protected])
|
||||
'@remixicon/react':
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0([email protected])
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
@@ -2921,6 +2924,14 @@ packages:
|
||||
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
|
||||
dev: false
|
||||
|
||||
/@remixicon/[email protected]([email protected]):
|
||||
resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==}
|
||||
peerDependencies:
|
||||
react: '>=18.2.0'
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@rolldown/[email protected]:
|
||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -152,6 +152,23 @@ export default function PluginProcessorDetailContent({
|
||||
[t, available, hasParameters, canOperate],
|
||||
);
|
||||
|
||||
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;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
@@ -469,6 +486,8 @@ export default function PluginProcessorDetailContent({
|
||||
}
|
||||
titleControls={
|
||||
<PluginProcessorSettings
|
||||
installScope={`event-processor:${id}`}
|
||||
onInstalled={applyInstalledProcessor}
|
||||
components={components}
|
||||
value={componentRef}
|
||||
disabled={!canManage || saving || loading}
|
||||
|
||||
@@ -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,128 +1,47 @@
|
||||
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 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,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"
|
||||
|
||||
@@ -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';
|
||||
@@ -232,6 +232,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 +260,7 @@ export async function installMarketplaceRunner(
|
||||
};
|
||||
writePendingRunnerInstall(pending);
|
||||
options.onTaskCreated?.(taskId);
|
||||
return finishRunnerInstall(pending, options.onProgress);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function extractPluginLabel(plugin: PluginV4) {
|
||||
@@ -271,6 +279,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 +309,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 +353,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');
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import EventBindingsEditor from './EventBindingsEditor';
|
||||
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';
|
||||
@@ -273,7 +273,22 @@ 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,
|
||||
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
|
||||
@@ -1155,13 +1170,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
DynamicFormItemType,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch, type UseFormReturn } from 'react-hook-form';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -469,7 +469,7 @@ export default function DynamicFormComponent({
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||
// and `__system.*`-named fields) that should not participate in form state,
|
||||
@@ -493,28 +493,32 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema = getValueSchema(item);
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object(
|
||||
editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema = getValueSchema(item);
|
||||
|
||||
if (
|
||||
item.required &&
|
||||
(fieldSchema instanceof z.ZodString ||
|
||||
fieldSchema instanceof z.ZodArray)
|
||||
) {
|
||||
fieldSchema = fieldSchema.min(1, {
|
||||
message: t('common.fieldRequired'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
item.required &&
|
||||
(fieldSchema instanceof z.ZodString ||
|
||||
fieldSchema instanceof z.ZodArray)
|
||||
) {
|
||||
fieldSchema = fieldSchema.min(1, {
|
||||
message: t('common.fieldRequired'),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: fieldSchema,
|
||||
};
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
),
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: fieldSchema,
|
||||
};
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
),
|
||||
),
|
||||
[editableValueSpecs, t],
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -580,21 +584,12 @@ export default function DynamicFormComponent({
|
||||
}
|
||||
}, [initialValues, form, editableValueSpecs]);
|
||||
|
||||
// Get reactive form values for conditional rendering
|
||||
const watchedValues = form.watch();
|
||||
const setFormValue = (name: string, value: unknown) => {
|
||||
form.setValue(name as keyof FormValues, value as never, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Stable ref for onSubmit to avoid re-triggering the effect when the
|
||||
// parent passes a new closure on every render.
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
// 监听表单值变化
|
||||
// Keep parent drafts synchronous with field changes.
|
||||
useEffect(() => {
|
||||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||||
// even if the user saves without modifying any field.
|
||||
@@ -621,325 +616,463 @@ export default function DynamicFormComponent({
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
onSubmitRef.current?.(finalValues);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, editableValueSpecs]);
|
||||
|
||||
return (
|
||||
<DynamicFormFields
|
||||
form={form}
|
||||
itemConfigList={itemConfigList}
|
||||
onFileUploaded={onFileUploaded}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={externalDependentValues}
|
||||
systemContext={systemContext}
|
||||
renderItem={renderItem}
|
||||
hiddenItemNames={hiddenItemNames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DynamicFormProps = Parameters<typeof DynamicFormComponent>[0];
|
||||
|
||||
type DynamicFormFieldsProps = Omit<
|
||||
DynamicFormProps,
|
||||
'initialValues' | 'onSubmit' | 'onValidate'
|
||||
> & {
|
||||
form: UseFormReturn<Record<string, any>>;
|
||||
};
|
||||
|
||||
// The stable provider also isolates useWatch/useController from an outer form's
|
||||
// context updates. The field list itself must subscribe inside this provider.
|
||||
const DynamicFormFields = memo(function DynamicFormFields(
|
||||
props: DynamicFormFieldsProps,
|
||||
) {
|
||||
return (
|
||||
<Form {...props.form}>
|
||||
<DynamicFormFieldList {...props} />
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
function DynamicFormFieldList({
|
||||
form,
|
||||
itemConfigList,
|
||||
onFileUploaded,
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
renderItem,
|
||||
hiddenItemNames,
|
||||
}: DynamicFormFieldsProps) {
|
||||
type FormValues = Record<string, any>;
|
||||
const { t, i18n } = useTranslation();
|
||||
const dependencies = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
itemConfigList
|
||||
.flatMap((item) => [
|
||||
item.show_if?.field,
|
||||
item.disable_if?.field,
|
||||
...(item.disabled_tooltip_overrides ?? []).map(
|
||||
(rule) => rule.when.field,
|
||||
),
|
||||
...(item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
item.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
? getValueSpecs(item).map((spec) => spec.name)
|
||||
: []),
|
||||
])
|
||||
.filter(
|
||||
(name): name is string =>
|
||||
!!name && !name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||
),
|
||||
),
|
||||
],
|
||||
[itemConfigList],
|
||||
);
|
||||
const watchedValues = useWatch({
|
||||
control: form.control,
|
||||
// Custom controls can read arbitrary fields; regular controls only need
|
||||
// their declared conditions and composite-field dependencies here.
|
||||
compute: (values: Record<string, any>) =>
|
||||
renderItem
|
||||
? values
|
||||
: Object.fromEntries(dependencies.map((name) => [name, values[name]])),
|
||||
});
|
||||
const setFormValue = useCallback(
|
||||
(name: string, value: unknown) => {
|
||||
form.setValue(name, value, { shouldDirty: true, shouldValidate: true });
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
// State for QR code login dialog
|
||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||
useState<QrLoginPlatform>('feishu');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config, index) => {
|
||||
if (hiddenItemNames?.includes(config.name)) return null;
|
||||
|
||||
// Create a normalized config with type converted to frontend format
|
||||
const normalizedConfig = {
|
||||
...config,
|
||||
type: normalizeItemType(config.type),
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
if (
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config, index) => {
|
||||
if (hiddenItemNames?.includes(config.name)) return null;
|
||||
|
||||
// Create a normalized config with type converted to frontend format
|
||||
const normalizedConfig = {
|
||||
...config,
|
||||
type: normalizeItemType(config.type),
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={disabledTooltip} />
|
||||
) : null;
|
||||
|
||||
// `__system.*` fields are display-only; their value is resolved
|
||||
// from systemContext (same namespace as show_if), not user input.
|
||||
// Hidden entirely when the deployment doesn't provide the value.
|
||||
if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const rawValue =
|
||||
systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)];
|
||||
const values = (Array.isArray(rawValue) ? rawValue : [rawValue])
|
||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||
.map(String);
|
||||
if (values.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SystemInfoField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (normalizedConfig.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
|
||||
if (normalizedConfig.type === 'embed-code') {
|
||||
const botUuid = (systemContext?.bot_uuid as string) || '';
|
||||
if (!botUuid) return null;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const widgetTitle =
|
||||
((systemContext?.adapter_config as Record<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'download-link') {
|
||||
if (!config.url) return null;
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<DownloadLinkField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={config.url}
|
||||
filename={config.download_filename}
|
||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||
helpLabel={
|
||||
config.help_label
|
||||
? extractI18nObject(config.help_label)
|
||||
: t('bots.viewAdapterDocs')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={fieldKey}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={disabledTooltip} />
|
||||
) : null;
|
||||
|
||||
// `__system.*` fields are display-only; their value is resolved
|
||||
// from systemContext (same namespace as show_if), not user input.
|
||||
// Hidden entirely when the deployment doesn't provide the value.
|
||||
if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const rawValue =
|
||||
systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)];
|
||||
const values = (Array.isArray(rawValue) ? rawValue : [rawValue])
|
||||
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||
.map(String);
|
||||
if (values.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SystemInfoField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (normalizedConfig.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedConfig.type === 'embed-code') {
|
||||
const botUuid = (systemContext?.bot_uuid as string) || '';
|
||||
if (!botUuid) return null;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const widgetTitle =
|
||||
((systemContext?.adapter_config as Record<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'download-link') {
|
||||
if (!config.url) return null;
|
||||
|
||||
return (
|
||||
<DownloadLinkField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={config.url}
|
||||
filename={config.download_filename}
|
||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||
helpLabel={
|
||||
config.help_label
|
||||
? extractI18nObject(config.help_label)
|
||||
: t('bots.viewAdapterDocs')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={fieldKey}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (normalizedConfig.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
if (
|
||||
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (normalizedConfig.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => {
|
||||
const customItem = renderItem?.({
|
||||
config: normalizedConfig,
|
||||
field,
|
||||
formValues: watchedValues as Record<string, unknown>,
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && (
|
||||
<span className="text-red-500">*</span>
|
||||
)}
|
||||
</span>
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{customItem !== undefined ? (
|
||||
customItem
|
||||
) : (
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
@@ -948,77 +1081,21 @@ export default function DynamicFormComponent({
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => {
|
||||
const customItem = renderItem?.({
|
||||
config: normalizedConfig,
|
||||
field,
|
||||
formValues: watchedValues as Record<string, unknown>,
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && (
|
||||
<span className="text-red-500">*</span>
|
||||
)}
|
||||
</span>
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{customItem !== undefined ? (
|
||||
customItem
|
||||
) : (
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
formValues={
|
||||
watchedValues as Record<string, unknown>
|
||||
}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import {
|
||||
LLMModel,
|
||||
Bot,
|
||||
@@ -94,19 +95,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 +102,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 +132,7 @@ export default function DynamicFormItemComponent({
|
||||
systemContext,
|
||||
requiredModelAbility,
|
||||
compactModelSelector = false,
|
||||
selectedModelLabel,
|
||||
}: {
|
||||
config: IDynamicFormItemSchema;
|
||||
field: ControllerRenderProps<any, any>;
|
||||
@@ -150,6 +142,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[]>([]);
|
||||
@@ -648,11 +641,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,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,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;
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MessageContentRenderer } from './MessageContentRenderer';
|
||||
import {
|
||||
ConversationTurn,
|
||||
@@ -507,10 +508,11 @@ export function ConversationTurnList({
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
|
||||
'flex h-auto w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left',
|
||||
hasToolDetails &&
|
||||
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
@@ -556,7 +558,7 @@ export function ConversationTurnList({
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{hasToolDetails && expandedToolCall && (
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
Layers,
|
||||
ThumbsUp,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -182,26 +183,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 { Alert, AlertTitle } from '@/components/ui/alert';
|
||||
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();
|
||||
@@ -175,38 +169,23 @@ 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>
|
||||
);
|
||||
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>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('monitoring.tokens.loadError', { error })}</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
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 +208,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 +350,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,441 @@
|
||||
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 } from './ExecutionValue';
|
||||
import { formatRunDuration } from '@/app/home/agents/components/processor-run-timing';
|
||||
import { formatDateTime } from '../../utils/dateUtils';
|
||||
import {
|
||||
executionKindLabel,
|
||||
executionProcessorLabel,
|
||||
executionStatusLabel,
|
||||
executionStatusVariant,
|
||||
} from './ExecutionTable';
|
||||
|
||||
export type ExecutionSelection = Pick<ExecutionRow, 'id'> & {
|
||||
source: ExecutionRow['source'] | 'auto';
|
||||
};
|
||||
|
||||
const mainSections: ExecutionSection[] = ['inputs', 'outputs', 'deliveries'];
|
||||
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 [loadedIdentity, setLoadedIdentity] = useState('');
|
||||
const visible = loadedIdentity === identity ? detail : null;
|
||||
const id = row?.id;
|
||||
const source = row?.source;
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setDetail(null);
|
||||
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 variant={executionStatusVariant(related.status_group)}>
|
||||
{executionStatusLabel(related, t)}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const content = [
|
||||
'inputs',
|
||||
'outputs',
|
||||
'deliveries',
|
||||
'conversation',
|
||||
].includes(section);
|
||||
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-2 rounded-lg border p-3">
|
||||
<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
|
||||
variant={executionStatusVariant(
|
||||
['error', 'failed'].includes(item.status)
|
||||
? 'failed'
|
||||
: item.status,
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{content ? (
|
||||
<>
|
||||
<Value value={item.content} />
|
||||
<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}
|
||||
{Array.isArray(item.metadata?.routes) && (
|
||||
<Value value={item.metadata.routes} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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 && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{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}>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{body}
|
||||
</section>
|
||||
) : (
|
||||
<details key={section} className="rounded-lg border p-3">
|
||||
<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')}
|
||||
onClick={() => setRefresh((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{loading && <Skeleton className="h-36 w-full" />}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t('monitoring.execution.detail.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{record && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={executionStatusVariant(record.status_group)}>
|
||||
{executionStatusLabel(record, t)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(record, t)}
|
||||
</Badge>
|
||||
{record.debug && (
|
||||
<Badge variant="secondary">
|
||||
{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>
|
||||
{record.status_reason && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm">
|
||||
{record.status_reason}
|
||||
</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,191 @@
|
||||
import React from '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 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">
|
||||
<Badge variant="outline">
|
||||
{executionKindLabel(row, t)}
|
||||
</Badge>
|
||||
<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 || '—'}
|
||||
</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 variant={executionStatusVariant(row.status_group)}>
|
||||
{executionStatusLabel(row, t)}
|
||||
</Badge>
|
||||
{row.debug && (
|
||||
<Badge variant="secondary">
|
||||
{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>;
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
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[];
|
||||
@@ -17,16 +19,40 @@ interface MonitoringFiltersProps {
|
||||
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;
|
||||
}
|
||||
|
||||
export default function MonitoringFilters({
|
||||
@@ -36,15 +62,22 @@ export default function MonitoringFilters({
|
||||
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 +85,57 @@ 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,
|
||||
}));
|
||||
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') {
|
||||
@@ -109,18 +158,24 @@ export default function MonitoringFilters({
|
||||
};
|
||||
|
||||
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-1 gap-3 sm:grid-cols-2 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,52 +190,155 @@ 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>
|
||||
{/* Processor Filter */}
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="monitoring-filter-processor"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('monitoring.filters.processor')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
|
||||
onValueChange={handlePipelineChange}
|
||||
disabled={loadingPipelines}
|
||||
disabled={loadingProcessors}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[140px]">
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-processor"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingPipelines
|
||||
loadingProcessors
|
||||
? t('monitoring.filters.loading')
|
||||
: t('monitoring.filters.selectPipeline')
|
||||
: t('monitoring.filters.selectProcessor')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('monitoring.filters.allPipelines')}
|
||||
{t('monitoring.filters.allProcessors')}
|
||||
</SelectItem>
|
||||
{pipelines.map((pipeline) => (
|
||||
{processors.map((pipeline) => (
|
||||
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
|
||||
{pipeline.name}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{pipeline.emoji && (
|
||||
<span className="shrink-0 text-sm" aria-hidden="true">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{pipeline.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</Label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[150px]">
|
||||
<SelectTrigger
|
||||
id="monitoring-filter-time"
|
||||
className="h-9 w-full min-w-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface TrafficChartProps {
|
||||
@@ -41,24 +43,24 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
||||
<Skeleton className="h-5 w-32 rounded" />
|
||||
<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>
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<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.trafficChart.title')}
|
||||
</h3>
|
||||
@@ -72,12 +74,12 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
|
||||
<Card className="p-6 transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
@@ -173,6 +175,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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]);
|
||||
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;
|
||||
setRequestScope(scope);
|
||||
setResult(null);
|
||||
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);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
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,
|
||||
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,45 +36,6 @@ 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;
|
||||
@@ -87,9 +48,17 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
setError(null);
|
||||
|
||||
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
|
||||
@@ -426,7 +395,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.mode,
|
||||
filterState.statusGroup,
|
||||
filterState.timeRange,
|
||||
filterState.customDateRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
@@ -443,6 +415,8 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
filterState.mode,
|
||||
filterState.statusGroup,
|
||||
filterState.timeRange,
|
||||
customDateRangeStr,
|
||||
workspaceUuid,
|
||||
|
||||
@@ -1,65 +1,128 @@
|
||||
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',
|
||||
'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
|
||||
)
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
}
|
||||
@@ -116,3 +116,48 @@ 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 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -26,6 +34,27 @@ import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
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 (
|
||||
@@ -373,13 +402,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>
|
||||
@@ -665,13 +690,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}>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -39,10 +39,9 @@ export default function ExtensionCardComponent({
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const authenticatedIcon = useAuthenticatedPluginIcon(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
cardVO.type === 'plugin',
|
||||
const pluginIconURL = useInstalledPluginIcon(
|
||||
cardVO.type === 'plugin' ? cardVO.author : null,
|
||||
cardVO.type === 'plugin' ? cardVO.name : null,
|
||||
);
|
||||
|
||||
const FallbackIcon =
|
||||
@@ -51,9 +50,8 @@ export default function ExtensionCardComponent({
|
||||
: cardVO.type === 'skill'
|
||||
? Sparkles
|
||||
: Puzzle;
|
||||
const iconSrc =
|
||||
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
|
||||
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
|
||||
const iconSrc = cardVO.type === 'plugin' ? pluginIconURL : cardVO.iconURL;
|
||||
const showFallback = iconFailed || !iconSrc;
|
||||
|
||||
const getTypeLabel = (type: ExtensionType) => {
|
||||
switch (type) {
|
||||
|
||||
@@ -157,7 +157,9 @@ const PluginInstalledComponent = forwardRef<
|
||||
version: meta.version ?? '',
|
||||
enabled: plugin.enabled,
|
||||
type: marketplacePlugin?.type || 'plugin',
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
// Icon is resolved by ExtensionCardComponent through the shared
|
||||
// installed-plugin hook (authenticated fetch in multi-Workspace
|
||||
// deployments); the public /icon route 404s there.
|
||||
install_source: plugin.install_source,
|
||||
install_info: plugin.install_info,
|
||||
status: plugin.status,
|
||||
|
||||
+21
-7
@@ -2,9 +2,16 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
BugIcon,
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,6 +34,7 @@ export default function PluginCardComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const iconURL = useInstalledPluginIcon(cardVO.author, cardVO.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,11 +44,17 @@ export default function PluginCardComponent({
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* Icon - fixed width */}
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
{iconURL ? (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center">
|
||||
<Puzzle className="w-12 h-12 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area - flexible width with min-width to prevent overflow */}
|
||||
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
|
||||
@@ -950,3 +950,20 @@ export interface ApiRespSkills {
|
||||
export interface ApiRespSkill {
|
||||
skill: Skill;
|
||||
}
|
||||
|
||||
export type {
|
||||
ExecutionSource,
|
||||
ExecutionSourceFilter,
|
||||
ExecutionModeFilter,
|
||||
ExecutionUsage,
|
||||
ExecutionRow,
|
||||
ExecutionOverview,
|
||||
TokenCoverage,
|
||||
ExecutionSummary,
|
||||
ExecutionListResult,
|
||||
PipelineMessageRow,
|
||||
ExecutionCallRow,
|
||||
AgentExecutionDetail,
|
||||
PipelineExecutionDetail,
|
||||
ExecutionDetail,
|
||||
} from './monitoring-executions';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
export type ExecutionSource = 'agent' | 'pipeline' | 'event';
|
||||
export type ExecutionSourceFilter = 'all' | ExecutionSource;
|
||||
export type ExecutionModeFilter = 'all' | 'real' | 'debug';
|
||||
|
||||
export interface ExecutionUsage {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
}
|
||||
|
||||
export interface ExecutionRow {
|
||||
source: ExecutionSource;
|
||||
id: string;
|
||||
event_id: string | null;
|
||||
status: string;
|
||||
status_group:
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'running'
|
||||
| 'queued'
|
||||
| 'cancelled'
|
||||
| 'ignored'
|
||||
| string;
|
||||
title: string;
|
||||
input_preview?: string;
|
||||
event_type?: string;
|
||||
status_reason?: string | null;
|
||||
target_kind: 'agent' | 'pipeline' | 'processor' | string;
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
bot_id: string | null;
|
||||
bot_name: string | null;
|
||||
pipeline_id: string | null;
|
||||
pipeline_name: string | null;
|
||||
runner_id: string | null;
|
||||
conversation_id: string | null;
|
||||
session_id: string | null;
|
||||
created_at_ms: number | null;
|
||||
started_at_ms: number | null;
|
||||
finished_at_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
usage: ExecutionUsage | null;
|
||||
cost: Record<string, unknown> | null;
|
||||
queue_name: string | null;
|
||||
debug: boolean;
|
||||
has_error: boolean;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionOverview {
|
||||
total: number;
|
||||
by_source: { agent: number; pipeline: number };
|
||||
by_status: Record<string, number>;
|
||||
completed: number;
|
||||
failed: number;
|
||||
running: number;
|
||||
queued: number;
|
||||
cancelled: number;
|
||||
ignored: number;
|
||||
waiting: number;
|
||||
success_rate: number | null;
|
||||
denominator: number;
|
||||
p50_duration_ms: number | null;
|
||||
p95_duration_ms: number | null;
|
||||
duration_sample: number;
|
||||
debug: number;
|
||||
real: number;
|
||||
}
|
||||
|
||||
export interface TokenCoverage {
|
||||
total_tokens: number;
|
||||
calls: number;
|
||||
calls_with_usage: number;
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
executions: ExecutionOverview;
|
||||
tokens: TokenCoverage;
|
||||
}
|
||||
|
||||
export interface ExecutionListResult {
|
||||
items: ExecutionRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
summary: ExecutionSummary;
|
||||
}
|
||||
|
||||
export interface PipelineMessageRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
message_content: string;
|
||||
session_id: string;
|
||||
status: string;
|
||||
level: string;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
runner_name?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionCallRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
/** LLM/tool calls report a status; error records carry `error_type` instead. */
|
||||
status?: string | null;
|
||||
duration?: number | null;
|
||||
error_type?: string | null;
|
||||
error_message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ExecutionSection =
|
||||
| 'inputs'
|
||||
| 'outputs'
|
||||
| 'deliveries'
|
||||
| 'conversation'
|
||||
| 'related'
|
||||
| 'events'
|
||||
| 'llm_calls'
|
||||
| 'tool_calls'
|
||||
| 'errors';
|
||||
|
||||
export interface ExecutionDetailItem {
|
||||
id: string | number;
|
||||
timestamp_ms?: number | null;
|
||||
role?: string;
|
||||
origin?: string;
|
||||
event_type?: string;
|
||||
actor_name?: string;
|
||||
actor_id?: string;
|
||||
content?: unknown;
|
||||
attachments?: unknown[];
|
||||
status?: string;
|
||||
type?: string;
|
||||
sequence?: number;
|
||||
data?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
delivery?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExecutionDetailPage {
|
||||
items: ExecutionDetailItem[];
|
||||
has_more: boolean;
|
||||
next_offset: number;
|
||||
}
|
||||
|
||||
export interface ExecutionDetail {
|
||||
source: ExecutionSource;
|
||||
row: ExecutionRow;
|
||||
pages: Partial<Record<ExecutionSection, ExecutionDetailPage>>;
|
||||
legacy_context: boolean;
|
||||
}
|
||||
|
||||
export type AgentExecutionDetail = ExecutionDetail & { source: 'agent' };
|
||||
export type PipelineExecutionDetail = ExecutionDetail & { source: 'pipeline' };
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
* Resolve the icon of an *installed* plugin in every deployment.
|
||||
*
|
||||
* The Core's public `/icon` route only exists in single-workspace (OSS)
|
||||
* installs; in multi-Workspace deployments it 404s, so installed-plugin
|
||||
* surfaces must go through the authenticated `/authenticated-icon` route and
|
||||
* stream the bytes as a blob object URL.
|
||||
*
|
||||
* - Core backend client: blob-fetch `getAuthenticatedPluginIconURL` and cache
|
||||
* the resulting object URL per `author/name` (module-level, shared across
|
||||
* every mounted consumer of the same icon).
|
||||
* - Cloud/Space marketplace surfaces: callers that already resolved a
|
||||
* marketplace URL pass it as `marketplaceURL`; it is returned verbatim and
|
||||
* no blob fetch happens.
|
||||
* - Returns `null` while loading / when the plugin has no icon, so callers
|
||||
* keep rendering their existing fallback.
|
||||
*
|
||||
* Object URLs are revoked once the last subscriber unmounts (or the last
|
||||
* subscriber's fetch settles with no remaining subscriber).
|
||||
*/
|
||||
|
||||
type CachedIcon = { url: string };
|
||||
|
||||
// Resolved object URLs, keyed by `author/name`.
|
||||
const iconCache = new Map<string, CachedIcon>();
|
||||
// In-flight authenticated fetches, de-duplicated per key.
|
||||
const inflightIcons = new Map<string, Promise<string | null>>();
|
||||
// Mounted subscriber count per key, so we only revoke when nobody needs it.
|
||||
const iconRefCounts = new Map<string, number>();
|
||||
|
||||
function loadIcon(
|
||||
key: string,
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const existing = inflightIcons.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inflightIcons.delete(key);
|
||||
});
|
||||
inflightIcons.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useInstalledPluginIcon(
|
||||
author?: string | null,
|
||||
name?: string | null,
|
||||
marketplaceURL?: string | null,
|
||||
): string | null {
|
||||
const key = author && name ? `${author}/${name}` : null;
|
||||
const [url, setUrl] = useState<string | null>(
|
||||
() => marketplaceURL ?? (key ? (iconCache.get(key)?.url ?? null) : null),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (marketplaceURL) {
|
||||
// Cloud/Space marketplace surface: already resolved upstream.
|
||||
setUrl(marketplaceURL);
|
||||
return;
|
||||
}
|
||||
if (!key || !author || !name) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
iconRefCounts.set(key, (iconRefCounts.get(key) ?? 0) + 1);
|
||||
|
||||
const cached = iconCache.get(key);
|
||||
if (cached) {
|
||||
setUrl(cached.url);
|
||||
} else {
|
||||
setUrl(null);
|
||||
void loadIcon(key, author, name).then((objectURL) => {
|
||||
if (!objectURL) return;
|
||||
// The last subscriber may have left while the fetch was in flight.
|
||||
if (iconRefCounts.get(key) === undefined) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
return;
|
||||
}
|
||||
const current = iconCache.get(key);
|
||||
if (current) {
|
||||
// Another subscriber resolved first; share its object URL.
|
||||
if (current.url !== objectURL) URL.revokeObjectURL(objectURL);
|
||||
if (active) setUrl(current.url);
|
||||
return;
|
||||
}
|
||||
iconCache.set(key, { url: objectURL });
|
||||
if (active) setUrl(objectURL);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
const remaining = (iconRefCounts.get(key) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
iconRefCounts.set(key, remaining);
|
||||
return;
|
||||
}
|
||||
iconRefCounts.delete(key);
|
||||
const entry = iconCache.get(key);
|
||||
if (entry) {
|
||||
iconCache.delete(key);
|
||||
URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
};
|
||||
}, [key, author, name, marketplaceURL]);
|
||||
|
||||
return marketplaceURL ?? url;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { FieldValues, UseFormReturn } from 'react-hook-form';
|
||||
|
||||
/** Subscribe without rendering the form for every keystroke. Values remain synchronous. */
|
||||
export function useSnapshotDirty<T extends FieldValues>(
|
||||
form: UseFormReturn<T>,
|
||||
savedSnapshot: RefObject<string>,
|
||||
enabled = true,
|
||||
) {
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const dirtyRef = useRef(false);
|
||||
const refreshDirty = useCallback(() => {
|
||||
const next =
|
||||
enabled &&
|
||||
!!savedSnapshot.current &&
|
||||
JSON.stringify(form.getValues()) !== savedSnapshot.current;
|
||||
if (next !== dirtyRef.current) {
|
||||
dirtyRef.current = next;
|
||||
setIsDirty(next);
|
||||
}
|
||||
return next;
|
||||
}, [enabled, form, savedSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDirty();
|
||||
const subscription = form.watch(refreshDirty);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, refreshDirty]);
|
||||
|
||||
return { isDirty, dirtyRef, refreshDirty };
|
||||
}
|
||||
@@ -4,6 +4,13 @@ import type {
|
||||
PipelineMigrationRequest,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import type {
|
||||
ExecutionDetail,
|
||||
ExecutionListResult,
|
||||
ExecutionModeFilter,
|
||||
ExecutionSource,
|
||||
ExecutionSourceFilter,
|
||||
} from '../entities/api/monitoring-executions';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
CodexDeviceAuthorization,
|
||||
@@ -432,6 +439,58 @@ export class BackendClient extends BaseHttpClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async streamAssistant<T>(
|
||||
uuid: string,
|
||||
payload: {
|
||||
revision: number;
|
||||
text?: string;
|
||||
approved?: boolean;
|
||||
model_uuid?: string;
|
||||
},
|
||||
onEvent: (event: { kind: string; data: unknown }) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
let offset = 0;
|
||||
let result: T | undefined;
|
||||
let failure: { code: string; msg: string } | undefined;
|
||||
const consume = (text: string) => {
|
||||
let end: number;
|
||||
while ((end = text.indexOf('\n', offset)) !== -1) {
|
||||
const line = text.slice(offset, end).trim();
|
||||
offset = end + 1;
|
||||
if (!line) continue;
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.kind === 'completed') result = frame.data;
|
||||
else if (frame.kind === 'error') failure = frame;
|
||||
else onEvent(frame);
|
||||
}
|
||||
};
|
||||
const response = await this.instance.post<string>(
|
||||
`/api/v1/assistant/conversations/${encodeURIComponent(uuid)}/turn/stream`,
|
||||
payload,
|
||||
{
|
||||
adapter: 'xhr',
|
||||
responseType: 'text',
|
||||
timeout: 0,
|
||||
signal,
|
||||
headers: { Accept: 'application/x-ndjson' },
|
||||
transformResponse: [(data) => data],
|
||||
onDownloadProgress: (progress) => {
|
||||
const xhr = progress.event?.target as XMLHttpRequest | undefined;
|
||||
if (xhr?.status === 200) consume(xhr.responseText);
|
||||
},
|
||||
},
|
||||
);
|
||||
consume(response.data);
|
||||
if (failure) throw failure;
|
||||
if (!result)
|
||||
throw {
|
||||
code: 'assistant_protocol_error',
|
||||
msg: 'Assistant stream ended before completion',
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
|
||||
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
|
||||
return this.get('/api/v1/pipelines/_/metadata');
|
||||
@@ -1973,6 +2032,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
// ============ Monitoring API ============
|
||||
public getMonitoringData(params: {
|
||||
mode?: ExecutionModeFilter;
|
||||
status?: string[];
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
startTime?: string;
|
||||
@@ -2096,6 +2157,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
};
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
params.status?.forEach((status) => queryParams.append('status', status));
|
||||
if (params.botId) {
|
||||
params.botId.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
@@ -2115,6 +2178,66 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getExecutions(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
agentId?: string[];
|
||||
status?: string[];
|
||||
source?: ExecutionSourceFilter;
|
||||
mode?: ExecutionModeFilter;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ExecutionListResult> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botId) {
|
||||
params.botId.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineId) {
|
||||
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
|
||||
}
|
||||
if (params.agentId) {
|
||||
params.agentId.forEach((id) => queryParams.append('agentId', id));
|
||||
}
|
||||
if (params.status) {
|
||||
params.status.forEach((status) => queryParams.append('status', status));
|
||||
}
|
||||
if (params.source) {
|
||||
queryParams.append('source', params.source);
|
||||
}
|
||||
if (params.mode) {
|
||||
queryParams.append('mode', params.mode);
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append('limit', params.limit.toString());
|
||||
}
|
||||
if (params.offset) {
|
||||
queryParams.append('offset', params.offset.toString());
|
||||
}
|
||||
|
||||
return this.get(`/api/v1/monitoring/executions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getExecutionDetail(
|
||||
source: ExecutionSource | 'auto',
|
||||
executionId: string,
|
||||
options: { section?: string; offset?: number; limit?: number } = {},
|
||||
): Promise<ExecutionDetail> {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries(options).map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
return this.get(
|
||||
`/api/v1/monitoring/executions/${encodeURIComponent(source)}/${encodeURIComponent(executionId)}?${params}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getMonitoringOverview(params: {
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
@@ -2144,6 +2267,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public getTokenStatistics(params: {
|
||||
mode?: ExecutionModeFilter;
|
||||
status?: string[];
|
||||
botId?: string[];
|
||||
pipelineId?: string[];
|
||||
startTime?: string;
|
||||
@@ -2184,6 +2309,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
bucket: string;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
params.status?.forEach((status) => queryParams.append('status', status));
|
||||
if (params.botId) {
|
||||
params.botId.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import {
|
||||
systemInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
@@ -1904,6 +1905,30 @@ function runnerPluginId(runner: string) {
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function InstalledRunnerIcon({
|
||||
author,
|
||||
name,
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
}) {
|
||||
const iconURL = useInstalledPluginIcon(author, name);
|
||||
|
||||
if (!iconURL) {
|
||||
return (
|
||||
<Blocks className="size-10 shrink-0 rounded-md border bg-muted p-2 text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-md border bg-muted object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StepAIEngine({
|
||||
choice,
|
||||
onChoiceChange,
|
||||
@@ -2298,11 +2323,7 @@ function StepAIEngine({
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start gap-3 px-4">
|
||||
{author && name ? (
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(author, name)}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-md border bg-muted object-cover"
|
||||
/>
|
||||
<InstalledRunnerIcon author={author} name={name} />
|
||||
) : (
|
||||
<Blocks className="size-10 shrink-0 rounded-md border bg-muted p-2 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,18 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
// `containerClassName` styles the scroll container shadcn wraps every table in.
|
||||
// A caller that owns the scrolling region (a fixed-height panel) uses it to make
|
||||
// that container the scroller, which is what keeps a sticky header working.
|
||||
function Table({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<'table'> & { containerClassName?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
className={cn('relative w-full overflow-x-auto', containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
|
||||
type AuthenticatedResourceState = {
|
||||
key: string;
|
||||
@@ -14,50 +15,21 @@ const EMPTY_RESOURCE: AuthenticatedResourceState = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Backwards-compatible wrapper around the shared installed-plugin icon hook.
|
||||
* Kept for callers that want a `{ url, error }` shape; the url is '' while
|
||||
* loading or when the icon is unavailable.
|
||||
*/
|
||||
export function useAuthenticatedPluginIcon(
|
||||
author: string,
|
||||
name: string,
|
||||
enabled = true,
|
||||
): { url: string; error: boolean } {
|
||||
const [resource, setResource] =
|
||||
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||
} else {
|
||||
URL.revokeObjectURL(nextURL);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: '', error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, enabled, name, resourceKey]);
|
||||
|
||||
return {
|
||||
url: resource.key === resourceKey ? resource.url : '',
|
||||
error: resource.key === resourceKey && resource.error,
|
||||
};
|
||||
const url = useInstalledPluginIcon(
|
||||
enabled ? author : null,
|
||||
enabled ? name : null,
|
||||
);
|
||||
return { url: url ?? '', error: false };
|
||||
}
|
||||
|
||||
export function useAuthenticatedPluginAsset(
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import pipelineMigration from './pipeline-migration/en-US';
|
||||
const enUS = {
|
||||
assistant: {
|
||||
movePosition: 'Move assistant horizontally',
|
||||
resizeHeight: 'Resize assistant height',
|
||||
sessions: 'Conversations',
|
||||
currentChat: 'Current conversation',
|
||||
stop: 'Stop task',
|
||||
stopped:
|
||||
'Stopped. An in-progress tool may already have taken effect. Check its resource before retrying.',
|
||||
setupRequired:
|
||||
'Sign in to LangBot Account to use LangBot Models, or configure your own model provider to start chatting.',
|
||||
loginAccount: 'Sign in to LangBot Account',
|
||||
configureModels: 'Configure models',
|
||||
providerCheckFailed: 'Could not check model providers. Please try again.',
|
||||
retrySetup: 'Retry',
|
||||
preparing: 'Preparing assistant…',
|
||||
thinking: 'Model responding · Round {{round}}',
|
||||
executing: 'Executing tool…',
|
||||
toolRunning: 'Running',
|
||||
recommendationFailed:
|
||||
'Could not load a recommended model. Please select a model.',
|
||||
expand: 'Expand workspace assistant',
|
||||
collapse: 'Collapse workspace assistant',
|
||||
modelHint: 'Switch assistant model for your next message',
|
||||
details: 'View raw data',
|
||||
found: 'Found {{count}} items',
|
||||
@@ -35,7 +56,7 @@ const enUS = {
|
||||
newChat: 'New conversation',
|
||||
close: 'Close',
|
||||
welcome:
|
||||
'Tell me what you want to build. I can inspect this Workspace and propose knowledge-base and Pipeline changes for you to confirm.',
|
||||
'Tell me what you want to build. I can inspect this Workspace and propose configurations for bots, Agents, knowledge bases, Pipelines and more, then apply them after your confirmation.',
|
||||
discover: 'What models, knowledge bases and Pipelines are available?',
|
||||
build: 'Help me create a knowledge-base question-answering application.',
|
||||
toolResult: 'Operation result',
|
||||
@@ -2456,6 +2477,116 @@ const enUS = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'Event / input',
|
||||
outputs: 'Generated results',
|
||||
deliveries: 'Delivery records',
|
||||
conversation: 'Conversation context',
|
||||
related: 'Related executions',
|
||||
events: 'Execution events',
|
||||
llm_calls: 'Model calls',
|
||||
tool_calls: 'Tool calls',
|
||||
errors: 'Errors',
|
||||
payload: 'Details',
|
||||
metadata: 'Technical details',
|
||||
loadMore: 'Load more',
|
||||
noRecords: 'No records',
|
||||
contextHint:
|
||||
'Conversation context is not necessarily part of this execution.',
|
||||
unhandled: 'No processor run',
|
||||
roles: {
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
system: 'System',
|
||||
tool: 'Tool',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: 'Executions',
|
||||
subtitle:
|
||||
'Every inbound event and the execution it triggered, across agents and pipelines.',
|
||||
tabs: { executions: 'Executions' },
|
||||
filters: {
|
||||
source: 'Type',
|
||||
sourceAll: 'All types',
|
||||
sourceAgent: 'Agents',
|
||||
sourcePipeline: 'Pipelines',
|
||||
mode: 'Traffic',
|
||||
modeAll: 'Live + debug',
|
||||
modeLive: 'Live only',
|
||||
modeDebug: 'Debug only',
|
||||
status: 'Status',
|
||||
statusAll: 'All statuses',
|
||||
},
|
||||
status: {
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
running: 'Running',
|
||||
queued: 'Queued',
|
||||
cancelled: 'Cancelled',
|
||||
ignored: 'Ignored',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Agent',
|
||||
pipeline: 'Pipeline',
|
||||
processor: 'Processor',
|
||||
},
|
||||
columns: {
|
||||
time: 'Time',
|
||||
target: 'Processor',
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
tokens: 'Tokens',
|
||||
},
|
||||
debug: 'Debug',
|
||||
empty: 'No executions in this range',
|
||||
loading: 'Loading executions...',
|
||||
triggeredBy: 'Triggered by {{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95: about 50%/95% of runs finish within this time. Based on recent completed Agent runs, excluding queue time.',
|
||||
tokensHint:
|
||||
'Only recorded usage is included. Totals may be incomplete when models do not return usage.',
|
||||
totalExecutions: 'Executions',
|
||||
bySource: '{{agent}} agent · {{pipeline}} pipeline',
|
||||
successRate: 'Execution Success Rate',
|
||||
successRateHint:
|
||||
'Successes ÷ (successes + failures). In-progress, ignored and cancelled runs are excluded.',
|
||||
denominator: '{{completed}} succeeded out of {{denominator}} finished',
|
||||
running: 'Running',
|
||||
queued: 'Queued',
|
||||
waiting: 'Waiting input',
|
||||
failed: 'Failed',
|
||||
ignored: 'Ignored',
|
||||
latency: 'Execution time',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'Based on {{count}} runs',
|
||||
tokens: 'Tokens',
|
||||
coverage: 'usage on {{withUsage}}/{{calls}} calls',
|
||||
debugRuns: 'Includes {{count}} debug runs',
|
||||
},
|
||||
detail: {
|
||||
title: 'Execution',
|
||||
timeline: 'Timeline',
|
||||
executionId: 'Execution ID',
|
||||
eventId: 'Event',
|
||||
queue: 'Queue',
|
||||
runner: 'Runner',
|
||||
created: 'Created',
|
||||
duration: 'Duration',
|
||||
usage: 'Usage',
|
||||
noEvents: 'No trace events recorded',
|
||||
query: 'Inbound message',
|
||||
modelCalls: 'Model calls',
|
||||
toolCalls: 'Tool calls',
|
||||
errors: 'Errors',
|
||||
loadError: 'Failed to load execution trace',
|
||||
openObject: 'Open object',
|
||||
a11yDescription: 'Full trace of this execution',
|
||||
},
|
||||
},
|
||||
title: 'Dashboard',
|
||||
description: 'Monitor bot activities, LLM calls, and system performance',
|
||||
overview: 'Overview',
|
||||
@@ -2471,6 +2602,9 @@ const enUS = {
|
||||
pipeline: 'Pipeline',
|
||||
allBots: 'All Bots',
|
||||
selectBot: 'Select Bot',
|
||||
processor: 'Processor',
|
||||
allProcessors: 'All Processors',
|
||||
selectProcessor: 'Select Processor',
|
||||
allPipelines: 'All Pipelines',
|
||||
selectPipeline: 'Select Pipeline',
|
||||
loading: 'Loading...',
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import pipelineMigration from './pipeline-migration/es-ES';
|
||||
const esES = {
|
||||
assistant: {
|
||||
movePosition: 'Mover el asistente horizontalmente',
|
||||
resizeHeight: 'Ajustar la altura del asistente',
|
||||
sessions: 'Conversaciones',
|
||||
currentChat: 'Conversación actual',
|
||||
stop: 'Detener tarea',
|
||||
stopped:
|
||||
'Detenido. Una herramienta en curso puede haber surtido efecto. Comprueba el recurso antes de reintentar.',
|
||||
setupRequired:
|
||||
'Inicia sesión en LangBot Account para usar LangBot Models o configura tu proveedor de modelos para empezar a conversar.',
|
||||
loginAccount: 'Iniciar sesión en LangBot Account',
|
||||
configureModels: 'Configurar modelos',
|
||||
providerCheckFailed:
|
||||
'No se pudieron comprobar los proveedores. Inténtalo de nuevo.',
|
||||
retrySetup: 'Reintentar',
|
||||
preparing: 'Preparando asistente…',
|
||||
thinking: 'Modelo respondiendo · Ronda {{round}}',
|
||||
executing: 'Ejecutando herramienta…',
|
||||
toolRunning: 'En ejecución',
|
||||
recommendationFailed:
|
||||
'No se pudo cargar el modelo recomendado. Selecciona un modelo.',
|
||||
expand: 'Expandir asistente del espacio de trabajo',
|
||||
collapse: 'Contraer asistente del espacio de trabajo',
|
||||
modelHint: 'Cambia el modelo del asistente para el próximo mensaje',
|
||||
details: 'Ver datos sin procesar',
|
||||
found: 'Se encontraron {{count}} elementos',
|
||||
@@ -35,7 +57,7 @@ const esES = {
|
||||
newChat: 'Nueva conversación',
|
||||
close: 'Cerrar',
|
||||
welcome:
|
||||
'Dime qué quieres crear. Puedo inspeccionar este espacio de trabajo y proponer cambios en la base de conocimiento y el Pipeline para que los confirmes.',
|
||||
'Dime qué quieres crear. Puedo revisar este espacio de trabajo y proponer configuraciones para bots, Agents, bases de conocimiento, Pipelines y más, y aplicarlas tras tu confirmación.',
|
||||
discover:
|
||||
'¿Qué modelos, bases de conocimiento y Pipelines hay disponibles?',
|
||||
build:
|
||||
@@ -2508,6 +2530,116 @@ const esES = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'Evento y entrada',
|
||||
outputs: 'Resultados generados',
|
||||
deliveries: 'Registros de envío',
|
||||
conversation: 'Contexto de conversación',
|
||||
related: 'Ejecuciones relacionadas',
|
||||
events: 'Eventos de ejecución',
|
||||
llm_calls: 'Llamadas al modelo',
|
||||
tool_calls: 'Llamadas a herramientas',
|
||||
errors: 'Errores',
|
||||
payload: 'Detalles',
|
||||
metadata: 'Información técnica',
|
||||
loadMore: 'Cargar más',
|
||||
noRecords: 'Sin registros',
|
||||
contextHint:
|
||||
'Otros mensajes no pertenecen necesariamente a esta ejecución.',
|
||||
unhandled: 'Sin ejecución',
|
||||
roles: {
|
||||
user: 'Usuario',
|
||||
assistant: 'Asistente',
|
||||
system: 'Sistema',
|
||||
tool: 'Herramienta',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: 'Ejecuciones',
|
||||
subtitle:
|
||||
'Cada evento entrante y la ejecución que activó, en agentes y pipelines.',
|
||||
tabs: { executions: 'Ejecuciones' },
|
||||
filters: {
|
||||
source: 'Tipo',
|
||||
sourceAll: 'Todos los tipos',
|
||||
sourceAgent: 'Agentes',
|
||||
sourcePipeline: 'Pipelines',
|
||||
mode: 'Tráfico',
|
||||
modeAll: 'Real + depuración',
|
||||
modeLive: 'Solo real',
|
||||
modeDebug: 'Solo depuración',
|
||||
status: 'Estado',
|
||||
statusAll: 'Todos los estados',
|
||||
},
|
||||
status: {
|
||||
completed: 'Completada',
|
||||
failed: 'Fallida',
|
||||
running: 'En curso',
|
||||
queued: 'En cola',
|
||||
cancelled: 'Cancelada',
|
||||
ignored: 'Ignorada',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Agente',
|
||||
pipeline: 'Pipeline',
|
||||
processor: 'Procesador',
|
||||
},
|
||||
columns: {
|
||||
time: 'Hora',
|
||||
target: 'Procesador',
|
||||
status: 'Estado',
|
||||
duration: 'Duración',
|
||||
tokens: 'Tokens',
|
||||
},
|
||||
debug: 'Depuración',
|
||||
empty: 'No hay ejecuciones en este intervalo',
|
||||
loading: 'Cargando ejecuciones...',
|
||||
triggeredBy: 'Activada por {{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95: cerca del 50%/95% termina dentro de este tiempo. Solo ejecuciones recientes de Agent completadas, sin espera en cola.',
|
||||
tokensHint:
|
||||
'Solo se suma el uso registrado. El total puede estar incompleto si el modelo no devuelve el uso.',
|
||||
totalExecutions: 'Ejecuciones',
|
||||
bySource: '{{agent}} de agentes · {{pipeline}} de pipelines',
|
||||
successRate: 'Tasa de éxito de ejecución',
|
||||
successRateHint:
|
||||
'Éxitos ÷ (éxitos + fallos). Se excluyen ejecuciones en curso, ignoradas y canceladas.',
|
||||
denominator: '{{completed}} de {{denominator}} finalizadas',
|
||||
running: 'En curso',
|
||||
queued: 'En cola',
|
||||
waiting: 'Esperando entrada',
|
||||
failed: 'Fallida',
|
||||
ignored: 'Ignorada',
|
||||
latency: 'Tiempo de ejecución',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'muestra de {{count}} ejecuciones',
|
||||
tokens: 'Tokens',
|
||||
coverage: 'uso registrado en {{withUsage}}/{{calls}} llamadas',
|
||||
debugRuns: 'Incluye {{count}} ejecuciones de depuración',
|
||||
},
|
||||
detail: {
|
||||
title: 'Ejecución',
|
||||
timeline: 'Cronología',
|
||||
executionId: 'ID de ejecución',
|
||||
eventId: 'Evento',
|
||||
queue: 'Cola',
|
||||
runner: 'Runner',
|
||||
created: 'Creada',
|
||||
duration: 'Duración',
|
||||
usage: 'Uso',
|
||||
noEvents: 'No se registraron eventos de traza',
|
||||
query: 'Mensaje entrante',
|
||||
modelCalls: 'Llamadas al modelo',
|
||||
toolCalls: 'Llamadas a herramientas',
|
||||
errors: 'Errores',
|
||||
loadError: 'No se pudo cargar la traza de ejecución',
|
||||
openObject: 'Abrir objeto',
|
||||
a11yDescription: 'Traza completa de esta ejecución',
|
||||
},
|
||||
},
|
||||
title: 'Panel de control',
|
||||
description:
|
||||
'Monitorea las actividades de los Bots, llamadas LLM y rendimiento del sistema',
|
||||
@@ -2524,6 +2656,9 @@ const esES = {
|
||||
pipeline: 'Pipeline',
|
||||
allBots: 'Todos los Bots',
|
||||
selectBot: 'Seleccionar Bot',
|
||||
processor: 'Procesador',
|
||||
allProcessors: 'Todos los procesadores',
|
||||
selectProcessor: 'Seleccionar procesador',
|
||||
allPipelines: 'Todos los Pipelines',
|
||||
selectPipeline: 'Seleccionar Pipeline',
|
||||
loading: 'Cargando...',
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import pipelineMigration from './pipeline-migration/ja-JP';
|
||||
const jaJP = {
|
||||
assistant: {
|
||||
movePosition: 'アシスタントを左右に移動',
|
||||
resizeHeight: 'アシスタントの高さを調整',
|
||||
sessions: '会話を切り替え',
|
||||
currentChat: '現在の会話',
|
||||
stop: 'タスクを停止',
|
||||
stopped:
|
||||
'停止しました。実行中のツールが反映済みの場合があります。再試行前にリソースを確認してください。',
|
||||
setupRequired:
|
||||
'LangBot Account にログインして LangBot Models を使うか、モデルプロバイダーを設定すると会話を始められます。',
|
||||
loginAccount: 'LangBot Account にログイン',
|
||||
configureModels: 'モデルを設定',
|
||||
providerCheckFailed:
|
||||
'モデルプロバイダーを確認できません。再試行してください。',
|
||||
retrySetup: '再試行',
|
||||
preparing: '準備中…',
|
||||
thinking: 'モデル応答中 · {{round}} 回目',
|
||||
executing: 'ツール実行中…',
|
||||
toolRunning: '実行中',
|
||||
recommendationFailed:
|
||||
'推奨モデルを取得できません。モデルを選択してください。',
|
||||
expand: 'ワークスペースアシスタントを展開',
|
||||
collapse: 'ワークスペースアシスタントを折りたたむ',
|
||||
modelHint: '次のメッセージからモデルを切り替え',
|
||||
details: '元のデータを見る',
|
||||
found: '{{count}} 件見つかりました',
|
||||
@@ -35,7 +57,7 @@ const jaJP = {
|
||||
newChat: '新しい会話',
|
||||
close: '閉じる',
|
||||
welcome:
|
||||
'作りたいアプリを教えてください。現在のリソースを確認し、承認後にナレッジベースや Pipeline を設定します。',
|
||||
'作りたいアプリを教えてください。現在のワークスペースを確認し、ボット、Agent、ナレッジベース、Pipeline などの設定を提案し、承認後に適用します。',
|
||||
discover: '利用できるモデル、ナレッジベース、Pipeline は?',
|
||||
build: 'ナレッジベースを使う質問応答アプリを作りたい。',
|
||||
toolResult: '操作結果',
|
||||
@@ -2472,6 +2494,115 @@ const jaJP = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'イベントと入力',
|
||||
outputs: '生成結果',
|
||||
deliveries: '送信記録',
|
||||
conversation: '会話の前後関係',
|
||||
related: '関連する実行',
|
||||
events: '実行イベント',
|
||||
llm_calls: 'モデル呼び出し',
|
||||
tool_calls: 'ツール呼び出し',
|
||||
errors: 'エラー',
|
||||
payload: '詳細',
|
||||
metadata: '技術情報',
|
||||
loadMore: 'さらに読み込む',
|
||||
noRecords: '記録なし',
|
||||
contextHint: '会話内の他のメッセージがこの実行に属するとは限りません。',
|
||||
unhandled: '処理なし',
|
||||
roles: {
|
||||
user: 'ユーザー',
|
||||
assistant: 'アシスタント',
|
||||
system: 'システム',
|
||||
tool: 'ツール',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: '実行履歴',
|
||||
subtitle:
|
||||
'エージェントとパイプラインに届いた各イベントと、それによって開始された実行を確認します。',
|
||||
tabs: { executions: '実行履歴' },
|
||||
filters: {
|
||||
source: '種類',
|
||||
sourceAll: 'すべての種類',
|
||||
sourceAgent: 'エージェント',
|
||||
sourcePipeline: 'パイプライン',
|
||||
mode: '実行モード',
|
||||
modeAll: '通常+デバッグ',
|
||||
modeLive: '通常のみ',
|
||||
modeDebug: 'デバッグのみ',
|
||||
status: '状態',
|
||||
statusAll: 'すべての状態',
|
||||
},
|
||||
status: {
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
running: '実行中',
|
||||
queued: '待機中',
|
||||
cancelled: 'キャンセル済み',
|
||||
ignored: '無視',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'エージェント',
|
||||
pipeline: 'パイプライン',
|
||||
processor: '処理対象',
|
||||
},
|
||||
columns: {
|
||||
time: '時刻',
|
||||
target: '処理対象',
|
||||
status: '状態',
|
||||
duration: '所要時間',
|
||||
tokens: 'トークン',
|
||||
},
|
||||
debug: 'デバッグ',
|
||||
empty: 'この期間の実行履歴はありません',
|
||||
loading: '実行履歴を読み込み中...',
|
||||
triggeredBy: '{{trigger}} により開始',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95:実行の約 50%/95% がこの時間内に完了します。最近の完了済み Agent 実行が対象で、キュー待ちは除きます。',
|
||||
tokensHint:
|
||||
'記録済みの使用量のみ集計します。モデルが使用量を返さない場合、合計は不完全になります。',
|
||||
totalExecutions: '実行履歴',
|
||||
bySource: 'エージェント {{agent}} 件 · パイプライン {{pipeline}} 件',
|
||||
successRate: '実行成功率',
|
||||
successRateHint:
|
||||
'成功数 ÷(成功数 + 失敗数)。実行中・無視・キャンセルは除きます。',
|
||||
denominator: '終了済み {{denominator}} 件中 {{completed}} 件が完了',
|
||||
running: '実行中',
|
||||
queued: '待機中',
|
||||
waiting: '入力待ち',
|
||||
failed: '失敗',
|
||||
ignored: '無視',
|
||||
latency: '実行時間',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: '{{count}} 件の実行を集計',
|
||||
tokens: 'トークン',
|
||||
coverage: '{{calls}} 回中 {{withUsage}} 回で使用量を取得',
|
||||
debugRuns: 'うちデバッグ実行 {{count}} 件',
|
||||
},
|
||||
detail: {
|
||||
title: '実行',
|
||||
timeline: 'タイムライン',
|
||||
executionId: '実行 ID',
|
||||
eventId: 'イベント',
|
||||
queue: 'キュー',
|
||||
runner: 'ランナー',
|
||||
created: '作成日時',
|
||||
duration: '所要時間',
|
||||
usage: '使用量',
|
||||
noEvents: '記録されたトレースイベントはありません',
|
||||
query: '受信メッセージ',
|
||||
modelCalls: 'モデル呼び出し',
|
||||
toolCalls: 'ツール呼び出し',
|
||||
errors: 'エラー',
|
||||
loadError: '実行トレースを読み込めませんでした',
|
||||
openObject: '対象を開く',
|
||||
a11yDescription: 'この実行の完全なトレース',
|
||||
},
|
||||
},
|
||||
title: 'ダッシュボード',
|
||||
description:
|
||||
'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視',
|
||||
@@ -2488,6 +2619,9 @@ const jaJP = {
|
||||
pipeline: 'パイプライン',
|
||||
allBots: 'すべてのボット',
|
||||
selectBot: 'ボットを選択',
|
||||
processor: 'プロセッサー',
|
||||
allProcessors: 'すべてのプロセッサー',
|
||||
selectProcessor: 'プロセッサーを選択',
|
||||
allPipelines: 'すべてのパイプライン',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
loading: '読み込み中...',
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import pipelineMigration from './pipeline-migration/ru-RU';
|
||||
const ruRU = {
|
||||
assistant: {
|
||||
movePosition: 'Переместить помощника по горизонтали',
|
||||
resizeHeight: 'Изменить высоту помощника',
|
||||
sessions: 'Диалоги',
|
||||
currentChat: 'Текущий диалог',
|
||||
stop: 'Остановить задачу',
|
||||
stopped:
|
||||
'Остановлено. Инструмент мог уже внести изменения. Проверьте ресурс перед повтором.',
|
||||
setupRequired:
|
||||
'Войдите в LangBot Account для доступа к LangBot Models или настройте своего провайдера моделей, чтобы начать диалог.',
|
||||
loginAccount: 'Войти в LangBot Account',
|
||||
configureModels: 'Настроить модели',
|
||||
providerCheckFailed:
|
||||
'Не удалось проверить провайдеров. Попробуйте ещё раз.',
|
||||
retrySetup: 'Повторить',
|
||||
preparing: 'Подготовка помощника…',
|
||||
thinking: 'Ответ модели · Шаг {{round}}',
|
||||
executing: 'Выполнение инструмента…',
|
||||
toolRunning: 'Выполняется',
|
||||
recommendationFailed:
|
||||
'Не удалось загрузить рекомендуемую модель. Выберите модель.',
|
||||
expand: 'Развернуть помощника рабочего пространства',
|
||||
collapse: 'Свернуть помощника рабочего пространства',
|
||||
modelHint: 'Сменить модель ассистента для следующего сообщения',
|
||||
details: 'Показать исходные данные',
|
||||
found: 'Найдено элементов: {{count}}',
|
||||
@@ -35,7 +57,7 @@ const ruRU = {
|
||||
newChat: 'Новый диалог',
|
||||
close: 'Закрыть',
|
||||
welcome:
|
||||
'Расскажите, что вы хотите создать. Я изучу это рабочее пространство и предложу изменения для базы знаний и Pipeline, которые вы подтвердите.',
|
||||
'Расскажите, что вы хотите создать. Я могу изучить рабочее пространство и предложить настройки ботов, Agents, баз знаний, Pipelines и других ресурсов, а затем применить их после вашего подтверждения.',
|
||||
discover: 'Какие модели, базы знаний и Pipelines доступны?',
|
||||
build: 'Помогите создать приложение для ответов на вопросы по базе знаний.',
|
||||
toolResult: 'Результат операции',
|
||||
@@ -2480,6 +2502,115 @@ const ruRU = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'Событие и ввод',
|
||||
outputs: 'Сгенерированные результаты',
|
||||
deliveries: 'Записи отправки',
|
||||
conversation: 'Контекст диалога',
|
||||
related: 'Связанные запуски',
|
||||
events: 'События выполнения',
|
||||
llm_calls: 'Вызовы модели',
|
||||
tool_calls: 'Вызовы инструментов',
|
||||
errors: 'Ошибки',
|
||||
payload: 'Подробности',
|
||||
metadata: 'Технические сведения',
|
||||
loadMore: 'Загрузить ещё',
|
||||
noRecords: 'Нет записей',
|
||||
contextHint: 'Другие сообщения могут не относиться к этому запуску.',
|
||||
unhandled: 'Обработчик не запущен',
|
||||
roles: {
|
||||
user: 'Пользователь',
|
||||
assistant: 'Ассистент',
|
||||
system: 'Система',
|
||||
tool: 'Инструмент',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: 'Выполнения',
|
||||
subtitle:
|
||||
'Все входящие события и вызванные ими выполнения агентов и конвейеров.',
|
||||
tabs: { executions: 'Выполнения' },
|
||||
filters: {
|
||||
source: 'Тип',
|
||||
sourceAll: 'Все типы',
|
||||
sourceAgent: 'Агенты',
|
||||
sourcePipeline: 'Конвейеры',
|
||||
mode: 'Режим выполнения',
|
||||
modeAll: 'Обычные и отладочные',
|
||||
modeLive: 'Только обычные',
|
||||
modeDebug: 'Только отладочные',
|
||||
status: 'Статус',
|
||||
statusAll: 'Все статусы',
|
||||
},
|
||||
status: {
|
||||
completed: 'Завершено',
|
||||
failed: 'Ошибка',
|
||||
running: 'Выполняется',
|
||||
queued: 'В очереди',
|
||||
cancelled: 'Отменено',
|
||||
ignored: 'Пропущено',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Агент',
|
||||
pipeline: 'Конвейер',
|
||||
processor: 'Обработчик',
|
||||
},
|
||||
columns: {
|
||||
time: 'Время',
|
||||
target: 'Обработчик',
|
||||
status: 'Статус',
|
||||
duration: 'Длительность',
|
||||
tokens: 'Токены',
|
||||
},
|
||||
debug: 'Отладка',
|
||||
empty: 'За этот период выполнений нет',
|
||||
loading: 'Загрузка выполнений...',
|
||||
triggeredBy: 'Запущено: {{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95: около 50%/95% запусков завершаются за это время. Только недавние завершённые запуски Agent, без ожидания в очереди.',
|
||||
tokensHint:
|
||||
'Учитывается только записанный расход. Итог может быть неполным, если модель не возвращает расход.',
|
||||
totalExecutions: 'Выполнения',
|
||||
bySource: 'Агенты: {{agent}} · Конвейеры: {{pipeline}}',
|
||||
successRate: 'Доля успешных выполнений',
|
||||
successRateHint:
|
||||
'Успешные ÷ (успешные + неудачные). Текущие, пропущенные и отменённые запуски исключены.',
|
||||
denominator: 'Успешно: {{completed}} из {{denominator}} завершившихся',
|
||||
running: 'Выполняется',
|
||||
queued: 'В очереди',
|
||||
waiting: 'Ожидание ввода',
|
||||
failed: 'Ошибка',
|
||||
ignored: 'Пропущено',
|
||||
latency: 'Время выполнения',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'выборка: {{count}} выполнений',
|
||||
tokens: 'Токены',
|
||||
coverage: 'расход указан для {{withUsage}}/{{calls}} вызовов',
|
||||
debugRuns: 'В том числе отладочных выполнений: {{count}}',
|
||||
},
|
||||
detail: {
|
||||
title: 'Выполнение',
|
||||
timeline: 'Хронология',
|
||||
executionId: 'ID выполнения',
|
||||
eventId: 'Событие',
|
||||
queue: 'Очередь',
|
||||
runner: 'Исполнитель',
|
||||
created: 'Создано',
|
||||
duration: 'Длительность',
|
||||
usage: 'Расход',
|
||||
noEvents: 'События трассировки не записаны',
|
||||
query: 'Входящее сообщение',
|
||||
modelCalls: 'Вызовы моделей',
|
||||
toolCalls: 'Вызовы инструментов',
|
||||
errors: 'Ошибки',
|
||||
loadError: 'Не удалось загрузить трассировку выполнения',
|
||||
openObject: 'Открыть объект',
|
||||
a11yDescription: 'Полная трассировка этого выполнения',
|
||||
},
|
||||
},
|
||||
title: 'Мониторинг',
|
||||
description:
|
||||
'Мониторинг активности ботов, вызовов LLM и производительности системы',
|
||||
@@ -2496,6 +2627,9 @@ const ruRU = {
|
||||
pipeline: 'Конвейер',
|
||||
allBots: 'Все боты',
|
||||
selectBot: 'Выберите бота',
|
||||
processor: 'Обработчик',
|
||||
allProcessors: 'Все обработчики',
|
||||
selectProcessor: 'Выберите обработчик',
|
||||
allPipelines: 'Все конвейеры',
|
||||
selectPipeline: 'Выберите конвейер',
|
||||
loading: 'Загрузка...',
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import pipelineMigration from './pipeline-migration/th-TH';
|
||||
const thTH = {
|
||||
assistant: {
|
||||
movePosition: 'เลื่อนผู้ช่วยในแนวนอน',
|
||||
resizeHeight: 'ปรับความสูงของผู้ช่วย',
|
||||
sessions: 'การสนทนา',
|
||||
currentChat: 'การสนทนาปัจจุบัน',
|
||||
stop: 'หยุดงาน',
|
||||
stopped:
|
||||
'หยุดแล้ว เครื่องมืออาจดำเนินการไปแล้ว โปรดตรวจสอบทรัพยากรก่อนลองอีกครั้ง',
|
||||
setupRequired:
|
||||
'เข้าสู่ระบบ LangBot Account เพื่อใช้ LangBot Models หรือตั้งค่าผู้ให้บริการโมเดลของคุณเพื่อเริ่มสนทนา',
|
||||
loginAccount: 'เข้าสู่ระบบ LangBot Account',
|
||||
configureModels: 'ตั้งค่าโมเดล',
|
||||
providerCheckFailed: 'ไม่สามารถตรวจสอบผู้ให้บริการโมเดลได้ โปรดลองอีกครั้ง',
|
||||
retrySetup: 'ลองอีกครั้ง',
|
||||
preparing: 'กำลังเตรียมผู้ช่วย…',
|
||||
thinking: 'โมเดลกำลังตอบ · รอบ {{round}}',
|
||||
executing: 'กำลังใช้เครื่องมือ…',
|
||||
toolRunning: 'กำลังทำงาน',
|
||||
recommendationFailed: 'โหลดโมเดลแนะนำไม่สำเร็จ โปรดเลือกโมเดล',
|
||||
expand: 'ขยายผู้ช่วยพื้นที่ทำงาน',
|
||||
collapse: 'ย่อผู้ช่วยพื้นที่ทำงาน',
|
||||
modelHint: 'สลับโมเดลผู้ช่วยสำหรับข้อความถัดไป',
|
||||
details: 'ดูข้อมูลดิบ',
|
||||
found: 'พบ {{count}} รายการ',
|
||||
@@ -35,7 +55,7 @@ const thTH = {
|
||||
newChat: 'บทสนทนาใหม่',
|
||||
close: 'ปิด',
|
||||
welcome:
|
||||
'บอกฉันว่าคุณอยากสร้างอะไร ฉันตรวจสอบพื้นที่ทำงานนี้และเสนอการตั้งค่าฐานความรู้และ Pipeline ให้คุณยืนยันก่อนดำเนินการ',
|
||||
'บอกฉันว่าคุณต้องการสร้างแอปอะไร ฉันสามารถตรวจสอบพื้นที่ทำงานนี้และเสนอการตั้งค่าสำหรับบอต, Agent, ฐานความรู้, Pipeline และอื่น ๆ แล้วดำเนินการหลังจากคุณยืนยันได้',
|
||||
discover: 'มีโมเดล ฐานความรู้ และ Pipeline อะไรที่ใช้ได้บ้าง?',
|
||||
build: 'ช่วยสร้างแอปตอบคำถามจากฐานความรู้ให้หน่อย',
|
||||
toolResult: 'ผลลัพธ์การดำเนินการ',
|
||||
@@ -2407,6 +2427,116 @@ const thTH = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'เหตุการณ์และข้อมูลเข้า',
|
||||
outputs: 'ผลลัพธ์ที่สร้าง',
|
||||
deliveries: 'บันทึกการส่ง',
|
||||
conversation: 'บริบทการสนทนา',
|
||||
related: 'การทำงานที่เกี่ยวข้อง',
|
||||
events: 'เหตุการณ์การทำงาน',
|
||||
llm_calls: 'การเรียกโมเดล',
|
||||
tool_calls: 'การเรียกเครื่องมือ',
|
||||
errors: 'ข้อผิดพลาด',
|
||||
payload: 'รายละเอียด',
|
||||
metadata: 'ข้อมูลทางเทคนิค',
|
||||
loadMore: 'โหลดเพิ่มเติม',
|
||||
noRecords: 'ไม่มีบันทึก',
|
||||
contextHint: 'ข้อความอื่นอาจไม่ใช่ส่วนหนึ่งของการทำงานนี้',
|
||||
unhandled: 'ไม่ได้เริ่มตัวประมวลผล',
|
||||
roles: {
|
||||
user: 'ผู้ใช้',
|
||||
assistant: 'ผู้ช่วย',
|
||||
system: 'ระบบ',
|
||||
tool: 'เครื่องมือ',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: 'การทำงาน',
|
||||
subtitle:
|
||||
'เหตุการณ์ขาเข้าแต่ละรายการและการทำงานที่ถูกเรียกใช้ ทั้งในเอเจนต์และไปป์ไลน์',
|
||||
tabs: { executions: 'การทำงาน' },
|
||||
filters: {
|
||||
source: 'ประเภท',
|
||||
sourceAll: 'ทุกประเภท',
|
||||
sourceAgent: 'เอเจนต์',
|
||||
sourcePipeline: 'ไปป์ไลน์',
|
||||
mode: 'โหมดการทำงาน',
|
||||
modeAll: 'ใช้งานจริง + ดีบัก',
|
||||
modeLive: 'เฉพาะใช้งานจริง',
|
||||
modeDebug: 'เฉพาะดีบัก',
|
||||
status: 'สถานะ',
|
||||
statusAll: 'ทุกสถานะ',
|
||||
},
|
||||
status: {
|
||||
completed: 'เสร็จสิ้น',
|
||||
failed: 'ล้มเหลว',
|
||||
running: 'กำลังทำงาน',
|
||||
queued: 'อยู่ในคิว',
|
||||
cancelled: 'ยกเลิกแล้ว',
|
||||
ignored: 'ละเว้นแล้ว',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'เอเจนต์',
|
||||
pipeline: 'ไปป์ไลน์',
|
||||
processor: 'ตัวประมวลผล',
|
||||
},
|
||||
columns: {
|
||||
time: 'เวลา',
|
||||
target: 'ตัวประมวลผล',
|
||||
status: 'สถานะ',
|
||||
duration: 'ระยะเวลา',
|
||||
tokens: 'โทเคน',
|
||||
},
|
||||
debug: 'ดีบัก',
|
||||
empty: 'ไม่มีการทำงานในช่วงเวลานี้',
|
||||
loading: 'กำลังโหลดการทำงาน...',
|
||||
triggeredBy: 'เรียกใช้โดย {{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95: ประมาณ 50%/95% ทำงานเสร็จภายในเวลานี้ ใช้เฉพาะ Agent ที่เสร็จล่าสุด ไม่รวมเวลารอคิว',
|
||||
tokensHint:
|
||||
'รวมเฉพาะการใช้ที่บันทึกไว้ ผลรวมอาจไม่ครบหากโมเดลไม่ส่งข้อมูลการใช้กลับมา',
|
||||
totalExecutions: 'การทำงาน',
|
||||
bySource: 'เอเจนต์ {{agent}} · ไปป์ไลน์ {{pipeline}}',
|
||||
successRate: 'อัตราการทำงานสำเร็จ',
|
||||
successRateHint:
|
||||
'สำเร็จ ÷ (สำเร็จ + ล้มเหลว) ไม่รวมงานที่กำลังทำงาน ถูกละเว้น หรือยกเลิก',
|
||||
denominator:
|
||||
'สำเร็จ {{completed}} จาก {{denominator}} รายการที่สิ้นสุดแล้ว',
|
||||
running: 'กำลังทำงาน',
|
||||
queued: 'อยู่ในคิว',
|
||||
waiting: 'รอการป้อนข้อมูล',
|
||||
failed: 'ล้มเหลว',
|
||||
ignored: 'ละเว้นแล้ว',
|
||||
latency: 'เวลาทำงาน',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'ตัวอย่างการทำงาน {{count}} ครั้ง',
|
||||
tokens: 'โทเคน',
|
||||
coverage: 'มีข้อมูลการใช้งาน {{withUsage}}/{{calls}} ครั้ง',
|
||||
debugRuns: 'รวมการทำงานดีบัก {{count}} ครั้ง',
|
||||
},
|
||||
detail: {
|
||||
title: 'การทำงาน',
|
||||
timeline: 'ลำดับเวลา',
|
||||
executionId: 'ID การทำงาน',
|
||||
eventId: 'เหตุการณ์',
|
||||
queue: 'คิว',
|
||||
runner: 'รันเนอร์',
|
||||
created: 'สร้างเมื่อ',
|
||||
duration: 'ระยะเวลา',
|
||||
usage: 'การใช้งาน',
|
||||
noEvents: 'ไม่มีการบันทึกเหตุการณ์ติดตาม',
|
||||
query: 'ข้อความขาเข้า',
|
||||
modelCalls: 'การเรียกใช้โมเดล',
|
||||
toolCalls: 'การเรียกใช้เครื่องมือ',
|
||||
errors: 'ข้อผิดพลาด',
|
||||
loadError: 'โหลดการติดตามการทำงานไม่สำเร็จ',
|
||||
openObject: 'เปิดรายการ',
|
||||
a11yDescription: 'การติดตามทั้งหมดของการทำงานนี้',
|
||||
},
|
||||
},
|
||||
title: 'แดชบอร์ด',
|
||||
description: 'ตรวจสอบกิจกรรม Bot, การเรียก LLM และประสิทธิภาพของระบบ',
|
||||
overview: 'ภาพรวม',
|
||||
@@ -2422,6 +2552,9 @@ const thTH = {
|
||||
pipeline: 'Pipeline',
|
||||
allBots: 'Bot ทั้งหมด',
|
||||
selectBot: 'เลือก Bot',
|
||||
processor: 'ตัวประมวลผล',
|
||||
allProcessors: 'ตัวประมวลผลทั้งหมด',
|
||||
selectProcessor: 'เลือกตัวประมวลผล',
|
||||
allPipelines: 'Pipeline ทั้งหมด',
|
||||
selectPipeline: 'เลือก Pipeline',
|
||||
loading: 'กำลังโหลด...',
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import pipelineMigration from './pipeline-migration/vi-VN';
|
||||
const viVN = {
|
||||
assistant: {
|
||||
movePosition: 'Di chuyển trợ lý theo chiều ngang',
|
||||
resizeHeight: 'Điều chỉnh chiều cao trợ lý',
|
||||
sessions: 'Cuộc trò chuyện',
|
||||
currentChat: 'Cuộc trò chuyện hiện tại',
|
||||
stop: 'Dừng tác vụ',
|
||||
stopped:
|
||||
'Đã dừng. Công cụ có thể đã thực hiện thay đổi. Kiểm tra tài nguyên trước khi thử lại.',
|
||||
setupRequired:
|
||||
'Đăng nhập LangBot Account để dùng LangBot Models hoặc cấu hình nhà cung cấp mô hình riêng để bắt đầu trò chuyện.',
|
||||
loginAccount: 'Đăng nhập LangBot Account',
|
||||
configureModels: 'Cấu hình mô hình',
|
||||
providerCheckFailed: 'Không thể kiểm tra nhà cung cấp. Vui lòng thử lại.',
|
||||
retrySetup: 'Thử lại',
|
||||
preparing: 'Đang chuẩn bị trợ lý…',
|
||||
thinking: 'Mô hình đang trả lời · Vòng {{round}}',
|
||||
executing: 'Đang chạy công cụ…',
|
||||
toolRunning: 'Đang chạy',
|
||||
recommendationFailed:
|
||||
'Không tải được mô hình đề xuất. Vui lòng chọn mô hình.',
|
||||
expand: 'Mở rộng trợ lý không gian làm việc',
|
||||
collapse: 'Thu gọn trợ lý không gian làm việc',
|
||||
modelHint: 'Đổi mô hình trợ lý cho tin nhắn tiếp theo',
|
||||
details: 'Xem dữ liệu thô',
|
||||
found: 'Tìm thấy {{count}} mục',
|
||||
@@ -36,7 +57,7 @@ const viVN = {
|
||||
newChat: 'Cuộc trò chuyện mới',
|
||||
close: 'Đóng',
|
||||
welcome:
|
||||
'Hãy cho tôi biết bạn muốn xây dựng gì. Tôi có thể xem không gian làm việc này và đề xuất cấu hình cơ sở tri thức cùng Pipeline để bạn xác nhận.',
|
||||
'Hãy cho tôi biết bạn muốn xây dựng gì. Tôi có thể xem không gian làm việc này, đề xuất cấu hình cho bot, Agent, cơ sở tri thức, Pipeline và các tài nguyên khác, rồi áp dụng sau khi bạn xác nhận.',
|
||||
discover: 'Có những mô hình, cơ sở tri thức và Pipeline nào khả dụng?',
|
||||
build: 'Giúp tôi tạo ứng dụng hỏi đáp dựa trên cơ sở tri thức.',
|
||||
toolResult: 'Kết quả thao tác',
|
||||
@@ -2442,6 +2463,116 @@ const viVN = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: 'Sự kiện và đầu vào',
|
||||
outputs: 'Kết quả tạo ra',
|
||||
deliveries: 'Bản ghi gửi',
|
||||
conversation: 'Ngữ cảnh hội thoại',
|
||||
related: 'Lượt chạy liên quan',
|
||||
events: 'Sự kiện thực thi',
|
||||
llm_calls: 'Lần gọi mô hình',
|
||||
tool_calls: 'Lần gọi công cụ',
|
||||
errors: 'Lỗi',
|
||||
payload: 'Chi tiết',
|
||||
metadata: 'Thông tin kỹ thuật',
|
||||
loadMore: 'Tải thêm',
|
||||
noRecords: 'Chưa có bản ghi',
|
||||
contextHint: 'Các tin nhắn khác không nhất thiết thuộc lượt chạy này.',
|
||||
unhandled: 'Chưa chạy bộ xử lý',
|
||||
roles: {
|
||||
user: 'Người dùng',
|
||||
assistant: 'Trợ lý',
|
||||
system: 'Hệ thống',
|
||||
tool: 'Công cụ',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: 'Lượt thực thi',
|
||||
subtitle:
|
||||
'Mọi sự kiện đầu vào và lượt thực thi được kích hoạt trên các tác nhân và pipeline.',
|
||||
tabs: { executions: 'Lượt thực thi' },
|
||||
filters: {
|
||||
source: 'Loại',
|
||||
sourceAll: 'Tất cả loại',
|
||||
sourceAgent: 'Tác nhân',
|
||||
sourcePipeline: 'Pipeline',
|
||||
mode: 'Chế độ thực thi',
|
||||
modeAll: 'Thực tế + gỡ lỗi',
|
||||
modeLive: 'Chỉ thực tế',
|
||||
modeDebug: 'Chỉ gỡ lỗi',
|
||||
status: 'Trạng thái',
|
||||
statusAll: 'Tất cả trạng thái',
|
||||
},
|
||||
status: {
|
||||
completed: 'Hoàn thành',
|
||||
failed: 'Thất bại',
|
||||
running: 'Đang chạy',
|
||||
queued: 'Trong hàng đợi',
|
||||
cancelled: 'Đã hủy',
|
||||
ignored: 'Đã bỏ qua',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Tác nhân',
|
||||
pipeline: 'Pipeline',
|
||||
processor: 'Bộ xử lý',
|
||||
},
|
||||
columns: {
|
||||
time: 'Thời gian',
|
||||
target: 'Bộ xử lý',
|
||||
status: 'Trạng thái',
|
||||
duration: 'Thời lượng',
|
||||
tokens: 'Token',
|
||||
},
|
||||
debug: 'Gỡ lỗi',
|
||||
empty: 'Không có lượt thực thi trong khoảng này',
|
||||
loading: 'Đang tải lượt thực thi...',
|
||||
triggeredBy: 'Được kích hoạt bởi {{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95: khoảng 50%/95% lượt chạy hoàn tất trong thời gian này. Chỉ tính Agent đã hoàn tất gần đây, không gồm thời gian xếp hàng.',
|
||||
tokensHint:
|
||||
'Chỉ cộng mức dùng đã ghi. Tổng có thể thiếu nếu mô hình không trả về dữ liệu sử dụng.',
|
||||
totalExecutions: 'Lượt thực thi',
|
||||
bySource: 'Tác nhân {{agent}} · Pipeline {{pipeline}}',
|
||||
successRate: 'Tỷ lệ thực thi thành công',
|
||||
successRateHint:
|
||||
'Thành công ÷ (thành công + thất bại). Không tính lượt đang chạy, bỏ qua hoặc đã hủy.',
|
||||
denominator:
|
||||
'Thành công {{completed}} trong {{denominator}} lượt đã kết thúc',
|
||||
running: 'Đang chạy',
|
||||
queued: 'Trong hàng đợi',
|
||||
waiting: 'Đang chờ nhập liệu',
|
||||
failed: 'Thất bại',
|
||||
ignored: 'Đã bỏ qua',
|
||||
latency: 'Thời gian thực thi',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: 'Mẫu gồm {{count}} lượt thực thi',
|
||||
tokens: 'Token',
|
||||
coverage: 'Có dữ liệu sử dụng cho {{withUsage}}/{{calls}} lệnh gọi',
|
||||
debugRuns: 'Bao gồm {{count}} lượt gỡ lỗi',
|
||||
},
|
||||
detail: {
|
||||
title: 'Lượt thực thi',
|
||||
timeline: 'Dòng thời gian',
|
||||
executionId: 'ID thực thi',
|
||||
eventId: 'Sự kiện',
|
||||
queue: 'Hàng đợi',
|
||||
runner: 'Trình thực thi',
|
||||
created: 'Được tạo',
|
||||
duration: 'Thời lượng',
|
||||
usage: 'Mức sử dụng',
|
||||
noEvents: 'Chưa ghi nhận sự kiện truy vết',
|
||||
query: 'Tin nhắn đầu vào',
|
||||
modelCalls: 'Lệnh gọi mô hình',
|
||||
toolCalls: 'Lệnh gọi công cụ',
|
||||
errors: 'Lỗi',
|
||||
loadError: 'Không thể tải truy vết thực thi',
|
||||
openObject: 'Mở đối tượng',
|
||||
a11yDescription: 'Toàn bộ truy vết của lượt thực thi này',
|
||||
},
|
||||
},
|
||||
title: 'Bảng điều khiển',
|
||||
description: 'Giám sát hoạt động Bot, cuộc gọi LLM và hiệu suất hệ thống',
|
||||
overview: 'Tổng quan',
|
||||
@@ -2457,6 +2588,9 @@ const viVN = {
|
||||
pipeline: 'Pipeline',
|
||||
allBots: 'Tất cả Bot',
|
||||
selectBot: 'Chọn Bot',
|
||||
processor: 'Bộ xử lý',
|
||||
allProcessors: 'Tất cả bộ xử lý',
|
||||
selectProcessor: 'Chọn bộ xử lý',
|
||||
allPipelines: 'Tất cả Pipeline',
|
||||
selectPipeline: 'Chọn Pipeline',
|
||||
loading: 'Đang tải...',
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hans';
|
||||
const zhHans = {
|
||||
assistant: {
|
||||
movePosition: '左右移动助手',
|
||||
resizeHeight: '调整助手高度',
|
||||
sessions: '切换会话',
|
||||
currentChat: '当前会话',
|
||||
stop: '停止任务',
|
||||
stopped: '已停止。执行中的工具可能已经生效,请检查相关资源后再尝试。',
|
||||
setupRequired:
|
||||
'登录 LangBot Account 使用 LangBot Models,或先配置自己的模型供应商,即可开始对话。',
|
||||
loginAccount: '登录 LangBot Account',
|
||||
configureModels: '配置模型',
|
||||
providerCheckFailed: '无法检查模型供应商,请重试。',
|
||||
retrySetup: '重试',
|
||||
preparing: '正在准备助手…',
|
||||
thinking: '模型响应中 · 第 {{round}} 轮',
|
||||
executing: '正在执行工具…',
|
||||
toolRunning: '执行中',
|
||||
recommendationFailed: '推荐模型加载失败,请手动选择模型。',
|
||||
expand: '展开工作区助手',
|
||||
collapse: '收起工作区助手',
|
||||
modelHint: '切换助手模型,从下一条消息生效',
|
||||
details: '查看原始数据',
|
||||
found: '找到 {{count}} 项',
|
||||
@@ -33,7 +52,7 @@ const zhHans = {
|
||||
newChat: '新对话',
|
||||
close: '关闭',
|
||||
welcome:
|
||||
'告诉我你想构建什么应用。我可以查看当前工作区,并提出知识库和 Pipeline 配置方案,由你确认后执行。',
|
||||
'告诉我你想构建什么应用。我可以查看当前工作区,为机器人、Agent、知识库和 Pipeline 等提供配置方案,由你确认后执行。',
|
||||
discover: '当前有哪些可用的模型、知识库和 Pipeline?',
|
||||
build: '帮我创建一个知识库问答应用。',
|
||||
toolResult: '操作结果',
|
||||
@@ -2308,6 +2327,114 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: '事件与输入',
|
||||
outputs: '生成结果',
|
||||
deliveries: '发送记录',
|
||||
conversation: '会话上下文',
|
||||
related: '关联执行',
|
||||
events: '执行事件',
|
||||
llm_calls: '模型调用',
|
||||
tool_calls: '工具调用',
|
||||
errors: '错误',
|
||||
payload: '查看详情',
|
||||
metadata: '技术信息',
|
||||
loadMore: '加载更多',
|
||||
noRecords: '暂无记录',
|
||||
contextHint: '会话中的其他消息不一定属于本次执行。',
|
||||
unhandled: '未启动处理器',
|
||||
roles: {
|
||||
user: '用户',
|
||||
assistant: '助手',
|
||||
system: '系统',
|
||||
tool: '工具',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: '执行记录',
|
||||
subtitle: '每条入站事件及其触发的执行,覆盖 Agent 与 Pipeline。',
|
||||
tabs: { executions: '执行记录' },
|
||||
filters: {
|
||||
source: '类型',
|
||||
sourceAll: '全部类型',
|
||||
sourceAgent: 'Agent',
|
||||
sourcePipeline: 'Pipeline',
|
||||
mode: '流量',
|
||||
modeAll: '真实 + 调试',
|
||||
modeLive: '仅真实',
|
||||
modeDebug: '仅调试',
|
||||
status: '状态',
|
||||
statusAll: '全部状态',
|
||||
},
|
||||
status: {
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
queued: '排队中',
|
||||
cancelled: '已取消',
|
||||
ignored: '已忽略',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Agent',
|
||||
pipeline: 'Pipeline',
|
||||
processor: '处理器',
|
||||
},
|
||||
columns: {
|
||||
time: '时间',
|
||||
target: '处理器',
|
||||
status: '状态',
|
||||
duration: '耗时',
|
||||
tokens: 'Token',
|
||||
},
|
||||
debug: '调试',
|
||||
empty: '该时间范围内暂无执行记录',
|
||||
loading: '加载执行记录中...',
|
||||
triggeredBy: '触发:{{trigger}}',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95:约 50%/95% 的执行能在此时间内完成。仅统计最近已完成的 Agent 执行,不含排队时间。',
|
||||
tokensHint:
|
||||
'仅汇总已记录的用量;部分模型未返回用量时,总数可能不完整。',
|
||||
totalExecutions: '执行总数',
|
||||
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
|
||||
successRate: '执行成功率',
|
||||
successRateHint:
|
||||
'成功次数 ÷(成功 + 失败)。不计进行中、已忽略和已取消的执行。',
|
||||
denominator: '{{denominator}} 次已结束,{{completed}} 次成功',
|
||||
running: '运行中',
|
||||
queued: '排队中',
|
||||
waiting: '等待输入',
|
||||
failed: '失败',
|
||||
ignored: '已忽略',
|
||||
latency: '执行耗时',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: '根据 {{count}} 次执行计算',
|
||||
tokens: 'Token',
|
||||
coverage: '{{withUsage}}/{{calls}} 次调用有用量',
|
||||
debugRuns: '其中 {{count}} 条为调试执行',
|
||||
},
|
||||
detail: {
|
||||
title: '执行详情',
|
||||
timeline: '执行时间线',
|
||||
executionId: '执行 ID',
|
||||
eventId: '事件',
|
||||
queue: '队列',
|
||||
runner: 'Runner',
|
||||
created: '创建时间',
|
||||
duration: '耗时',
|
||||
usage: '用量',
|
||||
noEvents: '暂无追踪事件',
|
||||
query: '入站消息',
|
||||
modelCalls: '模型调用',
|
||||
toolCalls: '工具调用',
|
||||
errors: '错误',
|
||||
loadError: '加载执行轨迹失败',
|
||||
openObject: '打开对象',
|
||||
a11yDescription: '此次执行的完整追踪',
|
||||
},
|
||||
},
|
||||
title: '仪表盘',
|
||||
description: '查看机器人活动、LLM调用和系统性能',
|
||||
overview: '概览',
|
||||
@@ -2323,6 +2450,9 @@ const zhHans = {
|
||||
pipeline: '流水线',
|
||||
allBots: '全部机器人',
|
||||
selectBot: '选择机器人',
|
||||
processor: '处理器',
|
||||
allProcessors: '全部处理器',
|
||||
selectProcessor: '选择处理器',
|
||||
allPipelines: '全部流水线',
|
||||
selectPipeline: '选择流水线',
|
||||
loading: '加载中...',
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hant';
|
||||
const zhHant = {
|
||||
assistant: {
|
||||
movePosition: '左右移動助手',
|
||||
resizeHeight: '調整助手高度',
|
||||
sessions: '切換對話',
|
||||
currentChat: '目前對話',
|
||||
stop: '停止任務',
|
||||
stopped: '已停止。執行中的工具可能已經生效,請檢查相關資源後再嘗試。',
|
||||
setupRequired:
|
||||
'登入 LangBot Account 使用 LangBot Models,或先設定自己的模型供應商,即可開始對話。',
|
||||
loginAccount: '登入 LangBot Account',
|
||||
configureModels: '設定模型',
|
||||
providerCheckFailed: '無法檢查模型供應商,請重試。',
|
||||
retrySetup: '重試',
|
||||
preparing: '正在準備助手…',
|
||||
thinking: '模型回應中 · 第 {{round}} 輪',
|
||||
executing: '正在執行工具…',
|
||||
toolRunning: '執行中',
|
||||
recommendationFailed: '推薦模型載入失敗,請手動選擇模型。',
|
||||
expand: '展開工作區助手',
|
||||
collapse: '收起工作區助手',
|
||||
modelHint: '切換助手模型,從下一條訊息生效',
|
||||
details: '檢視原始資料',
|
||||
found: '找到 {{count}} 項',
|
||||
@@ -33,7 +52,7 @@ const zhHant = {
|
||||
newChat: '新對話',
|
||||
close: '關閉',
|
||||
welcome:
|
||||
'告訴我你想建構什麼應用。我可以查看目前工作區,並提出知識庫和 Pipeline 設定方案,由你確認後執行。',
|
||||
'告訴我你想建構什麼應用。我可以查看目前工作區,為機器人、Agent、知識庫和 Pipeline 等提供設定方案,由你確認後執行。',
|
||||
discover: '目前有哪些可用的模型、知識庫和 Pipeline?',
|
||||
build: '幫我建立一個知識庫問答應用。',
|
||||
toolResult: '操作結果',
|
||||
@@ -2309,6 +2328,114 @@ const zhHant = {
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
unified: {
|
||||
inputs: '事件與輸入',
|
||||
outputs: '生成結果',
|
||||
deliveries: '傳送記錄',
|
||||
conversation: '對話上下文',
|
||||
related: '關聯執行',
|
||||
events: '執行事件',
|
||||
llm_calls: '模型呼叫',
|
||||
tool_calls: '工具呼叫',
|
||||
errors: '錯誤',
|
||||
payload: '查看詳情',
|
||||
metadata: '技術資訊',
|
||||
loadMore: '載入更多',
|
||||
noRecords: '暫無記錄',
|
||||
contextHint: '對話中的其他訊息不一定屬於本次執行。',
|
||||
unhandled: '未啟動處理器',
|
||||
roles: {
|
||||
user: '使用者',
|
||||
assistant: '助手',
|
||||
system: '系統',
|
||||
tool: '工具',
|
||||
},
|
||||
},
|
||||
execution: {
|
||||
title: '執行記錄',
|
||||
subtitle: '每條入站事件及其觸發的執行,涵蓋 Agent 與 Pipeline。',
|
||||
tabs: { executions: '執行記錄' },
|
||||
filters: {
|
||||
source: '類型',
|
||||
sourceAll: '全部類型',
|
||||
sourceAgent: 'Agent',
|
||||
sourcePipeline: 'Pipeline',
|
||||
mode: '流量',
|
||||
modeAll: '真實 + 偵錯',
|
||||
modeLive: '僅真實',
|
||||
modeDebug: '僅偵錯',
|
||||
status: '狀態',
|
||||
statusAll: '全部狀態',
|
||||
},
|
||||
status: {
|
||||
completed: '已完成',
|
||||
failed: '失敗',
|
||||
running: '執行中',
|
||||
queued: '排隊中',
|
||||
cancelled: '已取消',
|
||||
ignored: '已忽略',
|
||||
},
|
||||
// prettier-ignore
|
||||
kind: {
|
||||
agent: 'Agent',
|
||||
pipeline: 'Pipeline',
|
||||
processor: '處理器',
|
||||
},
|
||||
columns: {
|
||||
time: '時間',
|
||||
target: '處理器',
|
||||
status: '狀態',
|
||||
duration: '耗時',
|
||||
tokens: 'Token',
|
||||
},
|
||||
debug: '偵錯',
|
||||
empty: '此時間範圍內暫無執行記錄',
|
||||
triggeredBy: '觸發:{{trigger}}',
|
||||
loading: '載入執行記錄中...',
|
||||
cards: {
|
||||
latencyHint:
|
||||
'P50/P95:約 50%/95% 的執行能在此時間內完成。僅統計最近已完成的 Agent 執行,不含排隊時間。',
|
||||
tokensHint:
|
||||
'僅加總已記錄的用量;部分模型未回傳用量時,總數可能不完整。',
|
||||
totalExecutions: '執行總數',
|
||||
bySource: 'Agent {{agent}} · Pipeline {{pipeline}}',
|
||||
successRate: '執行成功率',
|
||||
successRateHint:
|
||||
'成功次數 ÷(成功 + 失敗)。不計進行中、已忽略及已取消的執行。',
|
||||
denominator: '{{completed}} / {{denominator}} 已結束',
|
||||
running: '執行中',
|
||||
queued: '排隊中',
|
||||
waiting: '等待互動',
|
||||
failed: '失敗',
|
||||
ignored: '已忽略',
|
||||
latency: '執行耗時',
|
||||
p50: 'P50',
|
||||
p95: 'P95',
|
||||
sample: '樣本 {{count}} 次',
|
||||
tokens: 'Token',
|
||||
coverage: '{{withUsage}}/{{calls}} 次呼叫有用量',
|
||||
debugRuns: '其中 {{count}} 筆為偵錯執行',
|
||||
},
|
||||
detail: {
|
||||
title: '執行詳情',
|
||||
timeline: '執行時間軸',
|
||||
executionId: '執行 ID',
|
||||
eventId: '事件',
|
||||
queue: '佇列',
|
||||
runner: 'Runner',
|
||||
created: '建立時間',
|
||||
duration: '耗時',
|
||||
usage: '用量',
|
||||
noEvents: '暫無追蹤事件',
|
||||
query: '入站訊息',
|
||||
modelCalls: '模型呼叫',
|
||||
toolCalls: '工具呼叫',
|
||||
errors: '錯誤',
|
||||
loadError: '載入執行軌跡失敗',
|
||||
openObject: '開啟物件',
|
||||
a11yDescription: '此次執行的完整追蹤',
|
||||
},
|
||||
},
|
||||
title: '儀表盤',
|
||||
description: '監控機器人活動、LLM調用和系統效能',
|
||||
overview: '概覽',
|
||||
@@ -2324,6 +2451,9 @@ const zhHant = {
|
||||
pipeline: '流水線',
|
||||
allBots: '全部機器人',
|
||||
selectBot: '選擇機器人',
|
||||
processor: '處理器',
|
||||
allProcessors: '全部處理器',
|
||||
selectProcessor: '選擇處理器',
|
||||
allPipelines: '全部流水線',
|
||||
selectPipeline: '選擇流水線',
|
||||
loading: '載入中...',
|
||||
|
||||
@@ -157,12 +157,16 @@ test.describe('bot session request recovery', () => {
|
||||
.click();
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No sessions found', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'analysis') {
|
||||
await expect(page.getByRole('alert')).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -177,10 +181,13 @@ test.describe('bot session request recovery', () => {
|
||||
).toHaveCount(0);
|
||||
failing = false;
|
||||
await page
|
||||
.getByRole('tabpanel', { name: /Sessions/ })
|
||||
.getByRole('alert')
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'initial list' || failure === 'list page') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
@@ -313,7 +320,9 @@ test.describe('bot session request races', () => {
|
||||
await expect.poll(() => released).toBe(true);
|
||||
// Allow the released HTTP response and React's queued update to settle.
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -203,7 +203,8 @@ test('bot guide starts on the detail page after adapter-only creation', async ({
|
||||
const adapterCard = page.locator('[data-slot="card"]').filter({
|
||||
has: page.getByText('Adapter Configuration', { exact: true }),
|
||||
});
|
||||
await expect(adapterCard.getByRole('switch')).toBeChecked();
|
||||
await expect(adapterCard.getByRole('switch')).toHaveCount(0);
|
||||
await expect(page.getByRole('radio', { name: /^Webhook/ })).toBeChecked();
|
||||
await guide.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
for (const scenario of ['install', 'retry', 'reload'] as const) {
|
||||
test(`event processor marketplace ${scenario}: filters usage and selects registered component`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const ref = 'plugin:qa/events/default';
|
||||
let installed = false;
|
||||
let finish = scenario !== 'reload';
|
||||
let installs = 0;
|
||||
let registrations = 0;
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const descriptor = {
|
||||
id: ref,
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'events',
|
||||
label: { en_US: 'Event handler' },
|
||||
supported_event_patterns: ['group.member_joined'],
|
||||
config_schema: [
|
||||
{
|
||||
name: 'greeting',
|
||||
type: 'string',
|
||||
label: { en_US: 'Greeting' },
|
||||
default: 'Welcome',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
let processor = {
|
||||
uuid: 'event-market',
|
||||
kind: 'event_processor',
|
||||
name: 'Event marketplace',
|
||||
component_ref: '',
|
||||
config: {},
|
||||
supported_event_patterns: [],
|
||||
};
|
||||
await page.route('**/api/v1/agents**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
let data: unknown;
|
||||
if (path.endsWith('/_/metadata')) {
|
||||
if (installed) registrations++;
|
||||
data = {
|
||||
event_processors: installed && registrations > 1 ? [descriptor] : [],
|
||||
kinds: [],
|
||||
platform_tools: [],
|
||||
};
|
||||
} else if (path.endsWith('/runs')) data = { items: [], has_more: false };
|
||||
else if (path.endsWith('/event-market')) {
|
||||
if (route.request().method() === 'PUT')
|
||||
processor = { ...processor, ...route.request().postDataJSON() };
|
||||
data = { agent: processor };
|
||||
} else return route.fallback();
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.route('**/api/v1/marketplace/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (!path.endsWith('/search')) return route.fallback();
|
||||
requests.push(route.request().postDataJSON());
|
||||
const plugin = (
|
||||
name: string,
|
||||
usages?: string[],
|
||||
components = { Runner: 1 },
|
||||
) => ({
|
||||
author: 'qa',
|
||||
name,
|
||||
label: { en_US: name },
|
||||
description: { en_US: name + ' description' },
|
||||
latest_version: '1.0.0',
|
||||
type: 'plugin',
|
||||
components,
|
||||
runner_usages: usages,
|
||||
install_count: 0,
|
||||
});
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
total: 5,
|
||||
[path.includes('/extensions/') ? 'extensions' : 'plugins']: [
|
||||
plugin('events', ['event']),
|
||||
plugin('agent-only', ['agent']),
|
||||
plugin('mixed', ['agent', 'event']),
|
||||
plugin('unknown'),
|
||||
plugin('knowledge-only', ['event'], {
|
||||
KnowledgeEngine: 1,
|
||||
} as any),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
|
||||
installs++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
plugin_author: 'qa',
|
||||
plugin_name: 'events',
|
||||
plugin_version: '1.0.0',
|
||||
});
|
||||
await route.fulfill({ json: { code: 0, data: { task_id: installs } } });
|
||||
});
|
||||
await page.route('**/api/v1/system/tasks/*', async (route) => {
|
||||
const failed = scenario === 'retry' && installs === 1;
|
||||
installed = finish && !failed;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
id: installs,
|
||||
name: 'plugin-install-marketplace',
|
||||
runtime: {
|
||||
done: finish,
|
||||
exception: failed ? 'Download failed' : null,
|
||||
},
|
||||
task_context: {
|
||||
current_action: 'Downloading',
|
||||
metadata: { progress_percent: 25 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/agents?id=event-market');
|
||||
const select = page.getByRole('combobox', {
|
||||
name: 'Plugin processor',
|
||||
includeHidden: true,
|
||||
});
|
||||
await select.click();
|
||||
const install = page.getByRole('button', {
|
||||
name: 'Install events',
|
||||
exact: true,
|
||||
});
|
||||
await expect(install).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Install mixed', exact: true }),
|
||||
).toBeVisible();
|
||||
for (const excluded of ['agent-only', 'unknown', 'knowledge-only'])
|
||||
await expect(
|
||||
page.getByRole('button', { name: `Install ${excluded}`, exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
for (const request of requests)
|
||||
expect(request).toMatchObject({
|
||||
component_filter: 'Runner',
|
||||
runner_usage: 'event',
|
||||
type_filter: 'plugin',
|
||||
});
|
||||
await install.click();
|
||||
if (scenario === 'retry') {
|
||||
await expect(
|
||||
page
|
||||
.getByRole('alert', { includeHidden: true })
|
||||
.filter({ hasText: 'Download failed' }),
|
||||
).toBeVisible();
|
||||
await install.click();
|
||||
}
|
||||
if (scenario === 'reload') {
|
||||
await expect.poll(() => installs).toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.keys(sessionStorage).some((key) =>
|
||||
key.includes('event-processor:event-market'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
finish = true;
|
||||
await page.reload();
|
||||
}
|
||||
await expect(select).toContainText('Event handler', { timeout: 20000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('input[name="greeting"]')).toHaveValue('Welcome');
|
||||
expect(installs).toBe(scenario === 'retry' ? 2 : 1);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => processor.component_ref).toBe(ref);
|
||||
});
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export interface WorkspaceEntryMock {
|
||||
|
||||
interface LangBotApiMockState {
|
||||
authenticated: boolean;
|
||||
withAssistant: boolean;
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
@@ -227,6 +228,128 @@ function emptyMonitoringData() {
|
||||
};
|
||||
}
|
||||
|
||||
type RawMonitoringMessage = Record<string, unknown>;
|
||||
|
||||
/** Mirror of the backend execution status groups so fixtures stay realistic. */
|
||||
function executionStatusGroup(status: string, source: 'agent' | 'pipeline') {
|
||||
if (source === 'agent') {
|
||||
if (status === 'failed' || status === 'timeout') return 'failed';
|
||||
if (status === 'created' || status === 'queued') return 'queued';
|
||||
if (status === 'claimed' || status === 'running') return 'running';
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
return status;
|
||||
}
|
||||
if (status === 'success') return 'completed';
|
||||
if (status === 'error') return 'failed';
|
||||
if (status === 'pending') return 'running';
|
||||
if (status === 'discarded' || status === 'not_matched') return 'ignored';
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Best-effort text extraction from a serialized message chain (mirrors backend). */
|
||||
function executionText(raw: unknown): string {
|
||||
const source = String(raw ?? '');
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
const walk = (node: unknown, depth = 0): void => {
|
||||
if (depth > 6) return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item) => walk(item, depth + 1));
|
||||
} else if (node && typeof node === 'object') {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === 'text' && typeof value === 'string') parts.push(value);
|
||||
else walk(value, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(JSON.parse(source));
|
||||
return parts.join(' ').trim() || source;
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the unified execution list from a mocked monitoring payload. */
|
||||
export function buildExecutionList(monitoringData: unknown) {
|
||||
const payload = monitoringData as
|
||||
| { messages?: RawMonitoringMessage[] }
|
||||
| undefined;
|
||||
const items = (payload?.messages ?? [])
|
||||
.filter((message) => message.role !== 'assistant')
|
||||
.map((message) => {
|
||||
const status = String(message.status ?? 'success');
|
||||
return {
|
||||
source: 'pipeline' as const,
|
||||
id: String(message.id),
|
||||
event_id: null,
|
||||
status,
|
||||
status_group: executionStatusGroup(status, 'pipeline'),
|
||||
title: executionText(message.message_content),
|
||||
input_preview: executionText(message.message_content),
|
||||
target_kind: 'pipeline' as const,
|
||||
target_id: (message.pipeline_id as string) ?? null,
|
||||
target_name: (message.pipeline_name as string) ?? null,
|
||||
bot_id: (message.bot_id as string) ?? null,
|
||||
bot_name: (message.bot_name as string) ?? null,
|
||||
pipeline_id: (message.pipeline_id as string) ?? null,
|
||||
pipeline_name: (message.pipeline_name as string) ?? null,
|
||||
runner_id: (message.runner_name as string) ?? null,
|
||||
conversation_id: null,
|
||||
session_id: (message.session_id as string) ?? null,
|
||||
created_at_ms: Date.parse(String(message.timestamp ?? '')) || null,
|
||||
started_at_ms: null,
|
||||
finished_at_ms: null,
|
||||
duration_ms: null,
|
||||
usage: null,
|
||||
cost: null,
|
||||
queue_name: null,
|
||||
debug: false,
|
||||
has_error: status === 'error',
|
||||
};
|
||||
});
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
byStatus[item.status_group] = (byStatus[item.status_group] ?? 0) + 1;
|
||||
}
|
||||
const completed = byStatus.completed ?? 0;
|
||||
const failed = byStatus.failed ?? 0;
|
||||
const denominator = completed + failed;
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
limit: items.length,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
summary: {
|
||||
executions: {
|
||||
total: items.length,
|
||||
by_source: { agent: 0, pipeline: items.length },
|
||||
by_status: byStatus,
|
||||
completed,
|
||||
failed,
|
||||
running: byStatus.running ?? 0,
|
||||
queued: byStatus.queued ?? 0,
|
||||
cancelled: byStatus.cancelled ?? 0,
|
||||
ignored: byStatus.ignored ?? 0,
|
||||
waiting: 0,
|
||||
success_rate:
|
||||
denominator > 0
|
||||
? Math.round((completed / denominator) * 10000) / 100
|
||||
: null,
|
||||
denominator,
|
||||
p50_duration_ms: null,
|
||||
p95_duration_ms: null,
|
||||
duration_sample: 0,
|
||||
debug: 0,
|
||||
real: items.length,
|
||||
},
|
||||
tokens: { total_tokens: 0, calls: 0, calls_with_usage: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTokenStatistics() {
|
||||
return {
|
||||
summary: {
|
||||
@@ -610,7 +733,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
account_uuid: 'account-playwright',
|
||||
// Most tests exercise an unlinked local user. Assistant tests opt into
|
||||
// an account identity and mock its additional provider/conversation APIs.
|
||||
account_uuid: state.withAssistant ? 'account-playwright' : null,
|
||||
user: '[email protected]',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
@@ -1163,6 +1288,76 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/executions') {
|
||||
return fulfillJson(route, buildExecutionList(state.monitoringData));
|
||||
}
|
||||
|
||||
const executionDetail = path.match(
|
||||
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline|event|auto)\/(.+)$/,
|
||||
);
|
||||
if (executionDetail) {
|
||||
const [, , executionId] = executionDetail;
|
||||
const payload = state.monitoringData as {
|
||||
messages?: RawMonitoringMessage[];
|
||||
llmCalls?: RawMonitoringMessage[];
|
||||
toolCalls?: RawMonitoringMessage[];
|
||||
errors?: RawMonitoringMessage[];
|
||||
};
|
||||
const row = buildExecutionList(payload).items.find(
|
||||
(item) => item.id === executionId,
|
||||
);
|
||||
if (!row)
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
json: { code: 404, msg: 'Execution not found' },
|
||||
});
|
||||
const root = (payload.messages ?? []).find(
|
||||
(item) => item.id === executionId,
|
||||
)!;
|
||||
const content = (item: RawMonitoringMessage) => ({
|
||||
...item,
|
||||
content: item.message_content,
|
||||
timestamp_ms: Date.parse(String(item.timestamp)),
|
||||
origin: item.role === 'assistant' ? 'delivery' : 'input',
|
||||
});
|
||||
const pages = Object.fromEntries(
|
||||
Object.entries({
|
||||
inputs: [content(root)],
|
||||
outputs: [],
|
||||
deliveries: (payload.messages ?? [])
|
||||
.filter((item) => item.parent_message_id === executionId)
|
||||
.map(content),
|
||||
conversation: (payload.messages ?? [])
|
||||
.filter(
|
||||
(item) =>
|
||||
item.session_id === root.session_id &&
|
||||
item.bot_id === root.bot_id,
|
||||
)
|
||||
.map(content),
|
||||
related: [],
|
||||
events: [],
|
||||
llm_calls: (payload.llmCalls ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
tool_calls: (payload.toolCalls ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
errors: (payload.errors ?? []).filter(
|
||||
(item) => item.message_id === executionId,
|
||||
),
|
||||
}).map(([key, items]) => [
|
||||
key,
|
||||
{ items, next_offset: items.length, has_more: false },
|
||||
]),
|
||||
);
|
||||
return fulfillJson(route, {
|
||||
source: row.source,
|
||||
row,
|
||||
pages,
|
||||
legacy_context: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/token-statistics') {
|
||||
return fulfillJson(route, emptyTokenStatistics());
|
||||
}
|
||||
@@ -1267,6 +1462,7 @@ export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
withAssistant?: boolean;
|
||||
language?: string;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
@@ -1280,6 +1476,7 @@ export async function installLangBotApiMocks(
|
||||
) {
|
||||
const {
|
||||
authenticated = false,
|
||||
withAssistant = false,
|
||||
language = 'en-US',
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
@@ -1292,6 +1489,7 @@ export async function installLangBotApiMocks(
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
authenticated,
|
||||
withAssistant,
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
pipelineMetadata,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
const runner = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
async function setup(page: Page, kind: 'agent' | 'pipeline' = 'agent') {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
// Exercise the self-hosted form, without the unrelated account assistant overlay.
|
||||
await page.route('**/api/v1/user/info', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { account_uuid: null, user_id: 1, username: 'Form tester' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const metadata = pipelineMetadata();
|
||||
let pipelineSaved = false;
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'PUT' && r.url().endsWith('/pipelines/pipeline-draft'))
|
||||
pipelineSaved = true;
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/pipeline-draft', async (route) => {
|
||||
if (route.request().method() !== 'GET' || pipelineSaved)
|
||||
return route.fallback();
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'pipeline-draft',
|
||||
name: 'Pipeline draft',
|
||||
emoji: '⚙️',
|
||||
description: '',
|
||||
config: {
|
||||
ai: {
|
||||
runner: { id: runner },
|
||||
runner_config: {
|
||||
[runner]: {
|
||||
model: { primary: 'llm-valid', fallbacks: [] },
|
||||
instruction: 'Original instruction',
|
||||
advanced: false,
|
||||
detail: 'Original detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
metadata.configs[0].stages[1].config.push(
|
||||
...([
|
||||
{
|
||||
name: 'instruction',
|
||||
label: { en_US: 'Instruction' },
|
||||
type: 'text',
|
||||
required: true,
|
||||
default: 'Original instruction',
|
||||
},
|
||||
{
|
||||
name: 'advanced',
|
||||
label: { en_US: 'Advanced' },
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'detail',
|
||||
label: { en_US: 'Detail' },
|
||||
type: 'text',
|
||||
default: 'Original detail',
|
||||
show_if: { field: 'advanced', operator: 'eq', value: true },
|
||||
},
|
||||
] as any),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/_/metadata`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
kind === 'agent'
|
||||
? {
|
||||
runner_config: metadata.configs[0],
|
||||
kinds: [],
|
||||
event_processors: [],
|
||||
}
|
||||
: metadata,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
`/home/${kind === 'agent' ? 'agents' : 'pipelines'}?id=${kind}-draft`,
|
||||
);
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
const input = page.locator('textarea[name="instruction"]');
|
||||
await expect(input).toHaveValue('Original instruction');
|
||||
return {
|
||||
input,
|
||||
save: page.getByRole('button', { name: 'Save', exact: true }),
|
||||
endpoint: `/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/${kind}-draft`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const kind of ['agent', 'pipeline'] as const) {
|
||||
test(`${kind}: revert, conditional fields, immediate save and reload retain the latest draft`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page, kind);
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Changed');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original instruction');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
const detail = page.locator('textarea[name="detail"]');
|
||||
await detail.fill('Retained while hidden');
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
await input.pressSequentially(' final characters');
|
||||
const request = page.waitForRequest(
|
||||
(r) => new URL(r.url()).pathname === endpoint && r.method() === 'PUT',
|
||||
);
|
||||
await save.click();
|
||||
const payload = (await request).postDataJSON();
|
||||
const config = kind === 'agent' ? payload.config : payload.config.ai;
|
||||
expect(config.runner_config[runner]).toMatchObject({
|
||||
instruction: 'Original instruction final characters',
|
||||
detail: 'Retained while hidden',
|
||||
advanced: false,
|
||||
});
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
await expect(input).toHaveValue('Original instruction final characters');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveValue('Retained while hidden');
|
||||
});
|
||||
}
|
||||
|
||||
test('agent: save completion does not absorb edits made while the request is pending', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() === 'PUT') await gate;
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('First draft');
|
||||
const request = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
await request;
|
||||
await input.fill('Second draft typed during save');
|
||||
release();
|
||||
await expect(save).toBeEnabled();
|
||||
const next = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
expect(
|
||||
(await next).postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Second draft typed during save');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: failed save keeps the draft, and debugging saves the latest value first', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let fail = true;
|
||||
const operations: string[] = [];
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.fallback();
|
||||
if (fail)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'Deliberate save failure' },
|
||||
});
|
||||
operations.push('save');
|
||||
expect(
|
||||
route.request().postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Latest before debug');
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('Unsaved draft');
|
||||
await save.click();
|
||||
await expect(page.getByText(/Deliberate save failure/)).toBeVisible();
|
||||
await expect(save).toBeEnabled();
|
||||
await expect(input).toHaveValue('Unsaved draft');
|
||||
fail = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/debug/stream')) operations.push('debug');
|
||||
});
|
||||
await page.getByRole('textbox', { name: 'Message content' }).fill('Hello');
|
||||
await input.fill('Latest before debug');
|
||||
await page.getByRole('button', { name: 'Save and run', exact: true }).click();
|
||||
await expect(page.getByText('Mock Agent response')).toBeVisible();
|
||||
expect(operations).toEqual(['save', 'debug']);
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: required-field status still reacts after unrelated keystrokes are isolated', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input } = await setup(page);
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
await input.fill('');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toHaveCount(0);
|
||||
await input.fill('Valid again');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('knowledge: early edits are not absorbed into initialization, and save uses a submitted snapshot', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const engine = {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: { en_US: 'Test engine' },
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [
|
||||
{
|
||||
name: 'instructions',
|
||||
type: 'text',
|
||||
label: { en_US: 'Retrieval instructions' },
|
||||
default: 'Original',
|
||||
},
|
||||
],
|
||||
};
|
||||
let base = {
|
||||
uuid: 'kb-draft',
|
||||
name: 'Knowledge draft',
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
initialized: true,
|
||||
knowledge_engine_plugin_id: engine.plugin_id,
|
||||
knowledge_engine: engine,
|
||||
creation_settings: {},
|
||||
retrieval_settings: { instructions: 'Original' },
|
||||
};
|
||||
await page.route('**/api/v1/knowledge/engines', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { engines: [engine] } } }),
|
||||
);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let saving = false;
|
||||
await page.route('**/api/v1/knowledge/bases/kb-draft', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
const payload = route.request().postDataJSON();
|
||||
saving = true;
|
||||
await gate;
|
||||
base = { ...base, ...payload };
|
||||
return route.fulfill({ json: { code: 0, data: { uuid: base.uuid } } });
|
||||
}
|
||||
return route.fulfill({ json: { code: 0, data: { base } } });
|
||||
});
|
||||
await page.goto('/home/knowledge?id=kb-draft');
|
||||
const input = page.locator('textarea[name="instructions"]');
|
||||
const save = page.getByRole('button', { name: 'Save', exact: true });
|
||||
await expect(input).toHaveValue('Original');
|
||||
await input.fill('Early edit');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original');
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Submitted value');
|
||||
await save.click();
|
||||
await expect.poll(() => saving).toBe(true);
|
||||
await input.fill('Typed while saving');
|
||||
release();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Submitted value');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
await expect(input).toHaveValue('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
@@ -59,10 +59,17 @@ test.describe('authenticated app shell', () => {
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
// The unified execution view is the default entry point.
|
||||
await expect(page.getByRole('tab', { name: 'Executions' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active',
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Execution Success Rate').first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Message Records' }),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: 'Token Monitoring' }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -20,7 +20,7 @@ test('local account login reaches the authenticated home shell', async ({
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
await expect(page.getByText('Home').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
await expect(page.getByText('Execution Success Rate').first()).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
buildExecutionList,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
const monitoring = {
|
||||
messages: [
|
||||
{
|
||||
id: 'input-one',
|
||||
role: 'user',
|
||||
message_content: 'Open this execution',
|
||||
timestamp: '2026-07-02T10:00:00Z',
|
||||
bot_id: 'bot',
|
||||
pipeline_id: 'pipe',
|
||||
pipeline_name: 'Processor A',
|
||||
},
|
||||
],
|
||||
};
|
||||
const row = buildExecutionList(monitoring).items[0];
|
||||
const pageOf = (
|
||||
items: unknown[],
|
||||
has_more = false,
|
||||
next_offset = items.length,
|
||||
) => ({ items, has_more, next_offset });
|
||||
|
||||
test('renders typed and custom events without duplicate text or empty attachment messages', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: monitoring,
|
||||
});
|
||||
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
source: 'pipeline',
|
||||
row,
|
||||
pages: {
|
||||
inputs: pageOf([
|
||||
{
|
||||
id: 'message',
|
||||
content: {
|
||||
text: '你好~',
|
||||
contents: [{ type: 'text', text: '你好~', image_url: null }],
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'member',
|
||||
event_type: 'group.member_banned',
|
||||
content: {
|
||||
member: { id: 'member-42', nickname: 'Alice' },
|
||||
group: { id: 'group-9' },
|
||||
duration: 0,
|
||||
permanent: false,
|
||||
custom: { reason: 'Moderation reason' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edited',
|
||||
content: {
|
||||
message_chain: [{ type: 'Plain', text: 'Typed chain text' }],
|
||||
editor: { id: 'editor-7' },
|
||||
new_content: [{ type: 'Plain', text: 'Updated text' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
content: {
|
||||
type: 'CustomEvent',
|
||||
text: 'Distinct text',
|
||||
content: 'Different content',
|
||||
properties: { nested: ['custom-value'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'same-again',
|
||||
content: {
|
||||
text: '你好~',
|
||||
contents: [{ type: 'text', text: '你好~' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
content: {
|
||||
contents: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
outputs: pageOf([
|
||||
{
|
||||
id: 'assistant',
|
||||
content: {
|
||||
content: 'Agent answer',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
function: {
|
||||
name: 'event_reply',
|
||||
arguments: '{"text":"sent"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attachments: [],
|
||||
},
|
||||
]),
|
||||
deliveries: pageOf([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Open this execution' })
|
||||
.click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await expect(sheet.getByText('你好~', { exact: true })).toHaveCount(2);
|
||||
await expect(sheet.getByText('[Empty message]', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
for (const text of [
|
||||
'Agent answer',
|
||||
'member-42',
|
||||
'Alice',
|
||||
'group-9',
|
||||
'Moderation reason',
|
||||
'0',
|
||||
'false',
|
||||
'Typed chain text',
|
||||
'editor-7',
|
||||
'Updated text',
|
||||
'Distinct text',
|
||||
'Different content',
|
||||
'custom-value',
|
||||
'event_reply',
|
||||
]) {
|
||||
await expect(sheet.getByText(text, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
sheet.getByRole('img', { name: 'Message attachment' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('pages a long trace without dropping inputs and opens related executions', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: monitoring,
|
||||
});
|
||||
const requests: string[] = [];
|
||||
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
requests.push(url.search);
|
||||
const sibling = url.pathname.endsWith('/sibling');
|
||||
const more = url.searchParams.get('section') === 'events';
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
source: 'pipeline',
|
||||
row: sibling
|
||||
? { ...row, id: 'sibling', target_name: 'Processor B' }
|
||||
: row,
|
||||
pages: more
|
||||
? {
|
||||
events: pageOf(
|
||||
[
|
||||
{
|
||||
id: 101,
|
||||
sequence: 101,
|
||||
type: 'run.completed',
|
||||
data: { final: 'Last trace event' },
|
||||
},
|
||||
],
|
||||
false,
|
||||
101,
|
||||
),
|
||||
}
|
||||
: {
|
||||
inputs: pageOf([
|
||||
{
|
||||
id: 'in',
|
||||
content: sibling ? 'Sibling input' : 'Original input',
|
||||
},
|
||||
]),
|
||||
outputs: pageOf([
|
||||
{ id: 'out', content: 'Generated but not sent' },
|
||||
]),
|
||||
deliveries: pageOf([]),
|
||||
events: pageOf(
|
||||
[{ id: 1, sequence: 1, type: 'run.started', data: {} }],
|
||||
true,
|
||||
100,
|
||||
),
|
||||
related: pageOf(
|
||||
sibling
|
||||
? []
|
||||
: [{ ...row, id: 'sibling', target_name: 'Processor B' }],
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Open this execution' })
|
||||
.click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await expect(sheet.getByText('Generated but not sent')).toBeVisible();
|
||||
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
|
||||
await sheet.getByText('Execution events (1+)', { exact: true }).click();
|
||||
await sheet.getByRole('button', { name: 'Load more' }).click();
|
||||
await expect(sheet.getByText('run.completed', { exact: true })).toBeVisible();
|
||||
await expect(sheet.getByText('Original input')).toBeVisible();
|
||||
expect(
|
||||
requests.some(
|
||||
(query) =>
|
||||
query.includes('section=events') && query.includes('offset=100'),
|
||||
),
|
||||
).toBe(true);
|
||||
await sheet.getByText('Related executions (1)', { exact: true }).click();
|
||||
await sheet.getByRole('button', { name: /Processor B/ }).click();
|
||||
await expect(sheet.getByText('Sibling input')).toBeVisible();
|
||||
await expect(sheet.getByText('Original input')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shows non-message events without fabricating a reply or a processor', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const event = {
|
||||
...row,
|
||||
source: 'event',
|
||||
id: 'event-only',
|
||||
event_id: 'event-only',
|
||||
target_id: null,
|
||||
target_kind: 'event',
|
||||
target_name: null,
|
||||
title: 'group.member.joined',
|
||||
input_preview: 'Member joined',
|
||||
status_group: 'ignored',
|
||||
status: 'ignored',
|
||||
};
|
||||
await page.route('**/api/v1/monitoring/executions?*', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { ...buildExecutionList(monitoring), items: [event] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/monitoring/executions/event/*?*', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
source: 'event',
|
||||
row: event,
|
||||
pages: {
|
||||
inputs: pageOf([
|
||||
{
|
||||
id: 'event-only',
|
||||
event_type: 'group.member.joined',
|
||||
content: { member_id: 'member-42' },
|
||||
metadata: {
|
||||
routes: [
|
||||
{ status: 'not_matched', reason: 'No matching route' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
outputs: pageOf([]),
|
||||
deliveries: pageOf([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/monitoring');
|
||||
await page.getByRole('button').filter({ hasText: 'Member joined' }).click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await expect(
|
||||
sheet.getByRole('heading', { name: 'No processor run' }),
|
||||
).toBeVisible();
|
||||
await expect(sheet.getByText(/member-42/)).toBeVisible();
|
||||
await expect(sheet.getByText(/No matching route/)).toBeVisible();
|
||||
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
|
||||
});
|
||||
|
||||
test('filters all three processor kinds with the sidebar icons', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const processors = [
|
||||
{ uuid: 'agent-one', name: 'Agent One', kind: 'agent', emoji: '🤖' },
|
||||
{
|
||||
uuid: 'pipeline-one',
|
||||
name: 'Pipeline One',
|
||||
kind: 'pipeline',
|
||||
emoji: '⚙️',
|
||||
},
|
||||
{
|
||||
uuid: 'event-one',
|
||||
name: 'Event One',
|
||||
kind: 'event_processor',
|
||||
emoji: '🧩',
|
||||
},
|
||||
];
|
||||
await page.route('**/api/v1/agents', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { agents: processors } } }),
|
||||
);
|
||||
await page.goto('/home/monitoring');
|
||||
const filter = page.getByRole('combobox', { name: 'Processor', exact: true });
|
||||
for (const processor of processors) {
|
||||
await filter.click();
|
||||
const option = page.getByRole('option', {
|
||||
name: processor.name,
|
||||
exact: true,
|
||||
});
|
||||
await expect(
|
||||
option.getByText(processor.emoji, { exact: true }),
|
||||
).toBeVisible();
|
||||
const request = page.waitForRequest((req) => {
|
||||
const url = new URL(req.url());
|
||||
return (
|
||||
url.pathname.endsWith('/monitoring/executions') &&
|
||||
url.searchParams.get('pipelineId') === processor.uuid
|
||||
);
|
||||
});
|
||||
await option.click();
|
||||
await request;
|
||||
await expect(filter).toContainText(processor.name);
|
||||
await expect(
|
||||
filter.getByText(processor.emoji, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await filter.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'All Processors', exact: true })
|
||||
.click();
|
||||
await expect(filter).toContainText('All Processors');
|
||||
});
|
||||
|
||||
test('shares mode and status filters with dashboard charts and records', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/home/monitoring');
|
||||
for (const [id, option, parameter, value] of [
|
||||
['monitoring-filter-mode', 'Debug only', 'mode', 'debug'],
|
||||
['monitoring-filter-status', 'Failed', 'status', 'failed'],
|
||||
]) {
|
||||
await page.locator(`#${id}`).click();
|
||||
const requests = ['/monitoring/data', '/monitoring/executions'].map(
|
||||
(path) =>
|
||||
page.waitForRequest((req) => {
|
||||
const url = new URL(req.url());
|
||||
return (
|
||||
url.pathname.endsWith(path) &&
|
||||
url.searchParams.get(parameter) === value
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.getByRole('option', { name: option, exact: true }).click();
|
||||
await Promise.all(requests);
|
||||
}
|
||||
});
|
||||
|
||||
test('remembers all filters on reload and persists resetting them', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/platform/bots', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
bots: [{ uuid: 'bot-one', name: 'Saved Bot', adapter: 'aiocqhttp' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/agents', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
agents: [
|
||||
{
|
||||
uuid: 'processor-one',
|
||||
name: 'Saved Processor',
|
||||
kind: 'agent',
|
||||
emoji: '🤖',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/monitoring');
|
||||
const selections = [
|
||||
['bot', 'Saved Bot'],
|
||||
['processor', 'Saved Processor'],
|
||||
['mode', 'Debug only'],
|
||||
['status', 'Failed'],
|
||||
['time', 'Last 7 days'],
|
||||
];
|
||||
for (const [id, label] of selections) {
|
||||
await page.locator(`#monitoring-filter-${id}`).click();
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
}
|
||||
await page.reload();
|
||||
for (const [id, label] of selections)
|
||||
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset Filters', exact: true })
|
||||
.click();
|
||||
await page.reload();
|
||||
for (const [id, label] of [
|
||||
['bot', 'All Bots'],
|
||||
['processor', 'All Processors'],
|
||||
['mode', 'Live + debug'],
|
||||
['status', 'All statuses'],
|
||||
['time', 'Last 24 hours'],
|
||||
]) {
|
||||
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
|
||||
}
|
||||
});
|
||||
|
||||
test('reset removes linked resource filters from the URL', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto(
|
||||
'/home/monitoring?botId=linked-bot&pipelineId=linked-processor',
|
||||
);
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset Filters', exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/home\/monitoring$/);
|
||||
await page.reload();
|
||||
await expect(page.locator('#monitoring-filter-bot')).toContainText(
|
||||
'All Bots',
|
||||
);
|
||||
await expect(page.locator('#monitoring-filter-processor')).toContainText(
|
||||
'All Processors',
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test, Route } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
buildExecutionList,
|
||||
} from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
import {
|
||||
ErrorLog,
|
||||
@@ -254,7 +257,15 @@ function rawMonitoringData() {
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages: scenario.messages.map(rawMessage),
|
||||
messages: scenario.messages.map((item) => ({
|
||||
...rawMessage(item),
|
||||
parent_message_id:
|
||||
item.role === 'assistant'
|
||||
? item.id === 'agent-assistant-4'
|
||||
? 'agent-user-2'
|
||||
: 'agent-user-1'
|
||||
: undefined,
|
||||
})),
|
||||
llmCalls: scenario.llmCalls.map(rawLlmCall),
|
||||
toolCalls: scenario.toolCalls.map(rawToolCall),
|
||||
embeddingCalls: [],
|
||||
@@ -273,8 +284,13 @@ function rawMonitoringData() {
|
||||
|
||||
async function respond(route: Route, label: string) {
|
||||
const data = rawMonitoringData();
|
||||
data.messages = [rawMessage(message(label, 'user', 10, label))];
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
data.messages = [
|
||||
{
|
||||
...rawMessage(message(label, 'user', 10, label)),
|
||||
parent_message_id: undefined,
|
||||
},
|
||||
];
|
||||
await route.fulfill({ json: { code: 0, data: buildExecutionList(data) } });
|
||||
}
|
||||
|
||||
test.describe('monitoring request contracts', () => {
|
||||
@@ -283,7 +299,7 @@ test.describe('monitoring request contracts', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/data?*', async (route) => {
|
||||
await page.route('**/api/v1/monitoring/executions?*', async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
@@ -298,14 +314,14 @@ test.describe('monitoring request contracts', () => {
|
||||
else await respond(route, 'Recovered monitoring');
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Failed to load monitoring data',
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Failed to load execution trace'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('No message records')).toHaveCount(0);
|
||||
failing = false;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Recovered monitoring', { exact: true }),
|
||||
page.getByText('Recovered monitoring', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -315,7 +331,7 @@ test.describe('monitoring request contracts', () => {
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const pending: Route[] = [];
|
||||
await page.route('**/api/v1/monitoring/data?*', (route) => {
|
||||
await page.route('**/api/v1/monitoring/executions?*', (route) => {
|
||||
pending.push(route);
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
@@ -324,8 +340,9 @@ test.describe('monitoring request contracts', () => {
|
||||
await page.getByRole('option', { name: /Last 7 days/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(3);
|
||||
await respond(pending[2], 'Latest filter data');
|
||||
// The message-turn view now lives behind its own tab.
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
page.getByText('Latest filter data', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await respond(pending[0], 'Obsolete filter data');
|
||||
await respond(pending[1], 'Obsolete filter data');
|
||||
@@ -336,7 +353,7 @@ test.describe('monitoring request contracts', () => {
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
page.getByText('Latest filter data', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh Data', exact: true })
|
||||
@@ -350,14 +367,14 @@ test.describe('monitoring request contracts', () => {
|
||||
await expect.poll(() => pending.length).toBe(5);
|
||||
await respond(pending[4], 'Current result');
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
page.getByText('Current result', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await pending[3].fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'old failure' },
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
page.getByText('Current result', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
@@ -386,7 +403,7 @@ test.describe('monitoring request contracts', () => {
|
||||
page.getByText(
|
||||
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
|
||||
),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Traffic range truncated. Choose a shorter time range.'),
|
||||
).toBeVisible();
|
||||
@@ -589,56 +606,39 @@ test.describe('monitoring conversation turn grouping', () => {
|
||||
expect(turns[0].totalToolDuration).toBe(45);
|
||||
});
|
||||
|
||||
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
|
||||
test('replaces the messages tab with full execution inputs, deliveries and calls', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: rawMonitoringData(),
|
||||
});
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page.getByText('3 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Standalone question with no reply'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
|
||||
await expect(page.getByText('Need deployment plan')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
|
||||
await expect(page.getByText('Assistant +2')).toBeVisible();
|
||||
await expect(page.getByText('3 LLM')).toBeVisible();
|
||||
await expect(page.getByText('2 tools')).toBeVisible();
|
||||
await expect(page.getByText('790 tokens')).toBeVisible();
|
||||
await expect(page.getByText('1 errors')).toBeVisible();
|
||||
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
|
||||
await expect(page.getByText('Rollback plan ready')).toBeVisible();
|
||||
|
||||
const agentTurn = page
|
||||
.locator('div[role="button"]')
|
||||
.filter({ hasText: 'Need deployment plan' });
|
||||
await expect(agentTurn).toHaveCount(1);
|
||||
await agentTurn.click();
|
||||
|
||||
await expect(page.getByText('Conversation Trace')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
|
||||
page.getByRole('tab', { name: 'Message Records' }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'Need deployment plan' })
|
||||
.click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await expect(sheet.getByText('Event / input (1)')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Final answer: deployment plan ready'),
|
||||
sheet.getByText('Need deployment plan', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
|
||||
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
|
||||
await expect(page.getByText('In: 300')).toBeVisible();
|
||||
await expect(page.getByText('Out: 90')).toBeVisible();
|
||||
await expect(page.getByText('Total: 390')).toBeVisible();
|
||||
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
|
||||
await expect(page.getByText('#1 repo_search')).toBeVisible();
|
||||
await expect(page.getByText('#2 run_tests')).toBeVisible();
|
||||
await expect(page.getByText('Arguments')).toHaveCount(0);
|
||||
await expect(page.getByText('Result')).toHaveCount(0);
|
||||
|
||||
await page.getByText('#1 repo_search').click();
|
||||
await expect(page.getByText('Arguments').first()).toBeVisible();
|
||||
await expect(page.getByText('Result').first()).toBeVisible();
|
||||
await expect(page.getByText('Tool retry failed')).toBeVisible();
|
||||
await expect(sheet.getByText('Delivery records (3)')).toBeVisible();
|
||||
await expect(
|
||||
sheet.getByText('Final answer: deployment plan ready').first(),
|
||||
).toBeVisible();
|
||||
await sheet.getByText('Model calls (3)', { exact: true }).click();
|
||||
await expect(sheet.getByText('gpt-5.5').first()).toBeVisible();
|
||||
await sheet.getByText('Tool calls (2)', { exact: true }).click();
|
||||
await expect(
|
||||
sheet.locator('span.break-all').filter({ hasText: /^repo_search$/ }),
|
||||
).toBeVisible();
|
||||
await sheet.getByText('Errors (1)', { exact: true }).click();
|
||||
await expect(sheet.getByText('Tool retry failed').first()).toBeVisible();
|
||||
await sheet.getByText('Conversation context (6)', { exact: true }).click();
|
||||
await expect(sheet.getByText('Rollback plan ready')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const chat = {
|
||||
uuid: 'assistant-test',
|
||||
revision: 0,
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
pending: [],
|
||||
error: null,
|
||||
model_uuid: null,
|
||||
model_name: null,
|
||||
};
|
||||
|
||||
test('resizes from the top handle, keeps height on reopen, and fits small viewports', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
|
||||
}),
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/home/bots');
|
||||
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
const handle = panel.getByRole('separator', {
|
||||
name: 'Resize assistant height',
|
||||
});
|
||||
await expect(handle).toBeVisible();
|
||||
const initial = (await panel.boundingBox())!;
|
||||
const grip = (await handle.boundingBox())!;
|
||||
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
grip.x + grip.width / 2,
|
||||
grip.y + grip.height / 2 - 140,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(initial.height + 140, 0);
|
||||
const expanded = (await panel.boundingBox())!;
|
||||
expect(expanded.y + expanded.height).toBeCloseTo(
|
||||
initial.y + initial.height,
|
||||
0,
|
||||
);
|
||||
await panel
|
||||
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
|
||||
.click();
|
||||
const dock = page.getByRole('button', {
|
||||
name: 'Expand workspace assistant',
|
||||
exact: true,
|
||||
});
|
||||
await expect(dock).toBeVisible();
|
||||
expect((await dock.boundingBox())!.height).toBe(21);
|
||||
await dock.click();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(expanded.height, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBeCloseTo(expanded.height, 0);
|
||||
await handle.focus();
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(280);
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(304);
|
||||
await page.keyboard.press('End');
|
||||
const preferredHeight = page.viewportSize()!.height - 48;
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBe(page.viewportSize()!.height - 48);
|
||||
await page.setViewportSize({ width: 375, height: 580 });
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.y)
|
||||
.toBeGreaterThanOrEqual(48);
|
||||
await expect(panel.getByRole('textbox')).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('langbot-assistant-panel-height'),
|
||||
),
|
||||
).toBe(String(preferredHeight));
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.height)
|
||||
.toBe(preferredHeight);
|
||||
const moveHandle = panel.getByRole('slider', {
|
||||
name: 'Move assistant horizontally',
|
||||
});
|
||||
const beforeMove = (await panel.boundingBox())!;
|
||||
const title = (await moveHandle.boundingBox())!;
|
||||
await page.mouse.move(title.x + title.width / 2, title.y + title.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
title.x + title.width / 2 + 180,
|
||||
title.y + title.height / 2,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(beforeMove.x + 180, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(beforeMove.x + 180, 0);
|
||||
await moveHandle.focus();
|
||||
await page.keyboard.press('End');
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await panel.boundingBox())!;
|
||||
return box.x + box.width;
|
||||
})
|
||||
.toBe(1268);
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await page.setViewportSize({ width: 375, height: 580 });
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
expect((await panel.boundingBox())!.width).toBe(351);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
|
||||
await panel
|
||||
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
|
||||
.click();
|
||||
await expect(dock).toBeVisible();
|
||||
const dockBox = (await dock.boundingBox())!;
|
||||
const startX = dockBox.x + dockBox.width / 2;
|
||||
const startY = dockBox.y + dockBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 150, startY, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect(dock).toBeVisible();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await dock.boundingBox())!.x)
|
||||
.toBeCloseTo(dockBox.x + 150, 0);
|
||||
await dock.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(162, 0);
|
||||
await page.reload();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())!.x)
|
||||
.toBeCloseTo(162, 0);
|
||||
});
|
||||
|
||||
test('sending survives model labels appearing and disappearing in stream snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (/createPortal|removeChild|createRoot/.test(message.text()))
|
||||
errors.push(message.text());
|
||||
});
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
let current: Record<string, unknown> = chat;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
route.request().method() === 'POST' ? chat : { conversations: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: current },
|
||||
}),
|
||||
);
|
||||
let turn = 0;
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
turn += 1;
|
||||
current = {
|
||||
...chat,
|
||||
revision: turn,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: turn === 2 ? 'Snapshot model' : null,
|
||||
messages: [{ role: 'assistant', content: `Reply ${turn}` }],
|
||||
};
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
JSON.stringify({ kind: 'snapshot', data: current }) +
|
||||
'\n' +
|
||||
JSON.stringify({ kind: 'completed', data: current }) +
|
||||
'\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
const model = assistant.getByRole('combobox', { name: 'Select Model' });
|
||||
await expect(model).toContainText('Recommended model');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await assistant.getByRole('textbox').fill(`Hello ${i}`);
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText(`Reply ${i}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(model).toContainText(
|
||||
i === 2 ? 'Snapshot model' : 'Valid Mock Model',
|
||||
);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('selects the recommendation on entry and submits the visible model', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
|
||||
const result = {
|
||||
...chat,
|
||||
revision: 1,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hello back' },
|
||||
],
|
||||
};
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
await assistant.getByRole('textbox').fill('Hello');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Hello back', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('late recommendation cannot overwrite manual selection, including request payload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/api/v1/assistant/recommended-model', async (route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { uuid: 'recommended-other', name: 'Other recommendation' },
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
JSON.stringify({
|
||||
kind: 'completed',
|
||||
data: {
|
||||
...chat,
|
||||
revision: 1,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Valid Mock Model',
|
||||
messages: [{ role: 'assistant', content: 'Manual model used' }],
|
||||
},
|
||||
}) + '\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await assistant.getByRole('combobox', { name: 'Select Model' }).click();
|
||||
await page.getByRole('option', { name: /Valid Mock Model/ }).click();
|
||||
release();
|
||||
await expect(assistant.getByText('Preparing assistant…')).toHaveCount(0);
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Valid Mock Model');
|
||||
await assistant.getByRole('textbox').fill('Hello');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Manual model used', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders streaming text and tool progress before completion', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { createServer } = await import('node:http');
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const started = {
|
||||
...chat,
|
||||
revision: 1,
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'user', content: 'Inspect resources' }],
|
||||
};
|
||||
let currentConversation: Record<string, unknown> = started;
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
req.headers['access-control-request-headers'] || '*',
|
||||
);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
|
||||
const emit = (kind: string, data: unknown) =>
|
||||
res.write(JSON.stringify({ kind, data }) + '\n');
|
||||
emit('snapshot', started);
|
||||
emit('phase', { phase: 'thinking', round: 1 });
|
||||
emit('text', { text: 'Inspecting your resources' });
|
||||
emit('phase', {
|
||||
phase: 'tool',
|
||||
tool: { name: 'list_resources', arguments: { kind: 'models' } },
|
||||
});
|
||||
await gate;
|
||||
currentConversation = {
|
||||
...started,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
...started.messages,
|
||||
{
|
||||
role: 'tool',
|
||||
content: '{}',
|
||||
tool: {
|
||||
name: 'list_resources',
|
||||
arguments: { kind: 'models' },
|
||||
result: { total: 1, items: [] },
|
||||
},
|
||||
},
|
||||
{ role: 'assistant', content: 'Found your model' },
|
||||
],
|
||||
};
|
||||
emit('completed', currentConversation);
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
// The panel refreshes the persisted conversation after streaming finishes.
|
||||
// Return the same snapshot as the stream instead of the generic API fallback.
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({ json: { code: 0, data: currentConversation } }),
|
||||
);
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { uuid: 'llm-valid', name: 'Recommended model' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: chat } }),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
(route) =>
|
||||
route.continue({ url: `http://127.0.0.1:${address.port}/stream` }),
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await expect(
|
||||
assistant.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
await assistant.getByRole('textbox').fill('Inspect resources');
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText('Inspecting your resources', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: /Find chat models Running/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: 'Stop task', exact: true }),
|
||||
).toBeEnabled();
|
||||
release();
|
||||
await expect(
|
||||
assistant.getByText('Found your model', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
assistant.getByText('Inspecting your resources', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
const bubble = assistant
|
||||
.locator('[class*="assistantMessage"]')
|
||||
.filter({ hasText: 'Found your model' });
|
||||
await expect(
|
||||
bubble.getByRole('button', { name: /Find chat models Completed/ }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
release();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('without providers shows setup only and rechecks after model settings close', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/requesters', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
|
||||
);
|
||||
let configured = false;
|
||||
let recommendations = 0;
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
providers: configured
|
||||
? [
|
||||
{
|
||||
uuid: 'provider',
|
||||
name: 'Custom provider',
|
||||
requester: 'openai-chat-completions',
|
||||
config: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) => {
|
||||
recommendations++;
|
||||
return route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
const setup = page.getByRole('complementary', {
|
||||
name: 'Workspace assistant',
|
||||
});
|
||||
await expect(
|
||||
setup.getByRole('button', { name: 'Sign in to LangBot Account' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Workspace assistant' }),
|
||||
).toHaveCount(0);
|
||||
expect(recommendations).toBe(0);
|
||||
await setup.getByRole('button', { name: 'Configure models' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
configured = true;
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog', { name: 'Workspace assistant' })
|
||||
.getByRole('combobox', { name: 'Select Model' }),
|
||||
).toContainText('Recommended model');
|
||||
});
|
||||
|
||||
test('setup login starts the existing LangBot Account authorization flow', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [] } },
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url().includes('/space/') && request.url().includes('authorize'),
|
||||
);
|
||||
await page
|
||||
.getByRole('complementary', { name: 'Workspace assistant' })
|
||||
.getByRole('button', { name: 'Sign in to LangBot Account' })
|
||||
.click();
|
||||
expect((await request).url()).toContain('authorize');
|
||||
});
|
||||
|
||||
test('switches conversations, restores background progress after reload and stops only the selected task', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
const running = {
|
||||
...chat,
|
||||
uuid: 'running-chat',
|
||||
revision: 3,
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'user', content: 'Background request' }],
|
||||
progress: {
|
||||
text: 'Working in the background',
|
||||
phase: 'thinking',
|
||||
round: 2,
|
||||
},
|
||||
};
|
||||
const ready = {
|
||||
...chat,
|
||||
uuid: 'ready-chat',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'assistant', content: 'Previous answer' }],
|
||||
};
|
||||
let stopped = false;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
conversations: [
|
||||
{
|
||||
uuid: running.uuid,
|
||||
title: 'Background request',
|
||||
status: stopped ? 'failed' : 'running',
|
||||
},
|
||||
{
|
||||
uuid: ready.uuid,
|
||||
title: 'Previous conversation',
|
||||
status: 'ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations/*', (route) => {
|
||||
const data = route.request().url().endsWith('ready-chat')
|
||||
? ready
|
||||
: stopped
|
||||
? {
|
||||
...running,
|
||||
status: 'failed',
|
||||
error: 'stopped',
|
||||
progress: undefined,
|
||||
}
|
||||
: running;
|
||||
return route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/running-chat/stop',
|
||||
(route) => {
|
||||
expect(route.request().postDataJSON()).toEqual({ revision: 3 });
|
||||
stopped = true;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
...running,
|
||||
status: 'failed',
|
||||
error: 'stopped',
|
||||
progress: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Background request', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
assistant.getByText('Working in the background', { exact: true }),
|
||||
).toBeVisible();
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Previous conversation', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
assistant.getByText('Previous answer', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(stopped).toBe(false);
|
||||
await assistant
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Background request', exact: true })
|
||||
.click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
assistant.getByText('Working in the background', { exact: true }),
|
||||
).toBeVisible();
|
||||
await assistant
|
||||
.getByRole('button', { name: 'Stop task', exact: true })
|
||||
.click();
|
||||
await expect(assistant.getByRole('alert')).toContainText('Stopped.');
|
||||
await expect(
|
||||
assistant.getByRole('button', { name: 'Stop task', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await assistant
|
||||
.getByRole('button', { name: 'New conversation', exact: true })
|
||||
.click();
|
||||
await expect(assistant.getByRole('textbox')).toBeEmpty();
|
||||
});
|
||||
|
||||
test('keeps the reading position through background updates and resumes following at the bottom', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
|
||||
}),
|
||||
);
|
||||
let updates = 0;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
conversations: [
|
||||
{
|
||||
uuid: 'scroll-chat',
|
||||
title: 'Long conversation',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/conversations/scroll-chat', (route) => {
|
||||
updates++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
...chat,
|
||||
uuid: 'scroll-chat',
|
||||
status: 'running',
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: 'Model',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `Paragraph ${i}`,
|
||||
).join('\n\n'),
|
||||
},
|
||||
],
|
||||
progress: {
|
||||
text: Array.from({ length: updates }, (_, i) => `Update ${i}`).join(
|
||||
'\n\n',
|
||||
),
|
||||
phase: 'thinking',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
await panel
|
||||
.getByRole('combobox', { name: 'Conversations', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: '◌ Long conversation', exact: true })
|
||||
.click();
|
||||
const messages = panel.locator('[aria-live="polite"]');
|
||||
const gap = () =>
|
||||
messages.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
await messages.hover();
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect.poll(gap).toBeGreaterThan(200);
|
||||
const position = await messages.evaluate((el) => el.scrollTop);
|
||||
const previousUpdates = updates;
|
||||
await expect.poll(() => updates).toBeGreaterThan(previousUpdates + 1);
|
||||
await expect
|
||||
.poll(() => messages.evaluate((el) => el.scrollTop))
|
||||
.toBeCloseTo(position, 0);
|
||||
await messages.hover();
|
||||
await page.mouse.wheel(0, 10000);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
const beforeFollow = updates;
|
||||
await expect.poll(() => updates).toBeGreaterThan(beforeFollow + 1);
|
||||
await expect.poll(gap).toBeLessThan(2);
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The floating assistant button docks against a viewport edge, collapses into a
|
||||
// short blue rail, and re-expands on hover. The transition rules are pure so
|
||||
// they can be pinned here: the race this guards against is a button that drops
|
||||
// under the cursor and never becomes visible again.
|
||||
const sourcePath = new URL(
|
||||
'../../src/app/home/components/assistant-dock.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
|
||||
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loaded = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('assistant-dock must stay dependency free');
|
||||
},
|
||||
loaded,
|
||||
loaded.exports,
|
||||
);
|
||||
const {
|
||||
ASSISTANT_BUTTON_SIZE,
|
||||
ASSISTANT_SNAP_THRESHOLD,
|
||||
ASSISTANT_RAIL_WIDTH,
|
||||
clampAssistantPosition,
|
||||
resolveAssistantEdge,
|
||||
shouldCollapseRail,
|
||||
shouldExpandRail,
|
||||
restingRailExpanded,
|
||||
} = loaded.exports;
|
||||
|
||||
const viewport = { width: 1280, height: 800 };
|
||||
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
|
||||
|
||||
test('clamping keeps the button fully inside the viewport', () => {
|
||||
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
|
||||
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
|
||||
x: maxX,
|
||||
y: viewport.height - ASSISTANT_BUTTON_SIZE,
|
||||
});
|
||||
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
|
||||
x: 400,
|
||||
y: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('a narrow viewport never produces a negative travel range', () => {
|
||||
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('positions within the snap threshold dock to the nearest edge', () => {
|
||||
assert.equal(resolveAssistantEdge(0, viewport), 'left');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'left',
|
||||
);
|
||||
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'right',
|
||||
);
|
||||
});
|
||||
|
||||
test('a centred position stays free floating', () => {
|
||||
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the left edge wins ties so a centred drop is deterministic', () => {
|
||||
// A viewport sized so both gaps are inside the snap threshold at x = 0.
|
||||
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
|
||||
assert.equal(resolveAssistantEdge(0, tight), 'left');
|
||||
});
|
||||
|
||||
test('hovering a docked rail expands it, hovering a free button does not', () => {
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Already expanded: nothing to do, so no redundant state churn on every move.
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a drag in progress never expands or collapses the rail', () => {
|
||||
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
|
||||
assert.equal(shouldExpandRail(dragging), false);
|
||||
assert.equal(shouldCollapseRail(dragging), false);
|
||||
});
|
||||
|
||||
test('only an *expanded* docked button collapses when the pointer leaves', () => {
|
||||
// Regression: keying this on `!railExpanded` made a revealed button
|
||||
// impossible to collapse again, so it stayed open forever after one hover.
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('expand then leave round-trips back to the collapsed rail', () => {
|
||||
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
|
||||
// Hover: reveal the button.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
// Pointer leaves: the revealed button must collapse again.
|
||||
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
|
||||
// A second hover re-reveals it, so the cycle is repeatable.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
});
|
||||
|
||||
test('dropping always starts collapsed so the rail is never stuck open', () => {
|
||||
// The pointer is still over the button on drop; the next pointermove expands.
|
||||
assert.equal(restingRailExpanded(), false);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: restingRailExpanded(),
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rail strip stays thinner than the button it replaces', () => {
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/infra/hooks/useInstalledPluginIcon.ts',
|
||||
);
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
// --- minimal React + backend mocks so the hook can run under node:test ---
|
||||
let stateSlots = [];
|
||||
let lastCleanup;
|
||||
|
||||
const reactMock = {
|
||||
useState(initial) {
|
||||
const value = typeof initial === 'function' ? initial() : initial;
|
||||
const index = stateSlots.length;
|
||||
stateSlots.push(value);
|
||||
return [
|
||||
value,
|
||||
(next) => {
|
||||
stateSlots[index] =
|
||||
typeof next === 'function' ? next(stateSlots[index]) : next;
|
||||
},
|
||||
];
|
||||
},
|
||||
useEffect(effect) {
|
||||
const cleanup = effect();
|
||||
if (typeof cleanup === 'function') lastCleanup = cleanup;
|
||||
},
|
||||
};
|
||||
|
||||
const httpMock = {
|
||||
httpClient: {
|
||||
getAuthenticatedPluginIconURL: () => {
|
||||
throw new Error('fetch not configured for this test');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sourceRequire = createRequire(sourcePath);
|
||||
|
||||
function loadHook() {
|
||||
const loadedModule = { exports: {} };
|
||||
const requireShim = (id) => {
|
||||
if (id === 'react') return reactMock;
|
||||
if (id === '@/app/infra/http') return httpMock;
|
||||
return sourceRequire(id);
|
||||
};
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
requireShim,
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.useInstalledPluginIcon;
|
||||
}
|
||||
|
||||
const revoked = [];
|
||||
globalThis.URL.revokeObjectURL = (url) => revoked.push(url);
|
||||
|
||||
function reset() {
|
||||
stateSlots = [];
|
||||
lastCleanup = undefined;
|
||||
revoked.length = 0;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('caches per author/name and revokes once the last subscriber leaves', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const first = useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
assert.equal(first, null);
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:acme/demo');
|
||||
await flush();
|
||||
|
||||
// Second subscriber reuses the cached object URL synchronously.
|
||||
const second = useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(second, 'blob:acme/demo');
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:acme/demo']);
|
||||
|
||||
// Cache was dropped with the last subscriber, so a new mount refetches.
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
assert.equal(fetches, 2);
|
||||
});
|
||||
|
||||
test('de-duplicates concurrent subscribers and revokes a shared icon once', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:shared');
|
||||
await flush();
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:shared']);
|
||||
});
|
||||
|
||||
test('caches nothing and revokes nothing when the authenticated fetch fails', async () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('not found'));
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon('acme', 'missing');
|
||||
const cleanup = lastCleanup;
|
||||
assert.equal(value, null);
|
||||
|
||||
await flush();
|
||||
assert.equal(useInstalledPluginIcon('acme', 'missing'), null);
|
||||
assert.equal(fetches, 2); // failures are not negatively cached
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
});
|
||||
|
||||
test('returns a marketplace URL verbatim without fetching', () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.resolve('blob:unused');
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon(
|
||||
'acme',
|
||||
'demo',
|
||||
'https://space.langbot.app/icon',
|
||||
);
|
||||
assert.equal(value, 'https://space.langbot.app/icon');
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lastCleanup, undefined);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The resolver is pure: recorded calls carry a time and a session, the execution
|
||||
// list carries the runs' windows, so the link is decided without any re-query.
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/home/monitoring/utils/callLinks.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = { exports: {} };
|
||||
new Function(
|
||||
'exports',
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
)(module.exports);
|
||||
const { findCallExecution, resolveCallTarget } = module.exports;
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
function run(overrides) {
|
||||
return {
|
||||
source: 'agent',
|
||||
id: 'run-1',
|
||||
session_id: 'session-a',
|
||||
started_at_ms: at('2026-10-07T10:00:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:00:30Z').getTime(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function call(overrides) {
|
||||
return {
|
||||
id: 'call-1',
|
||||
timestamp: at('2026-10-07T10:00:10Z'),
|
||||
sessionId: 'session-a',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('links a pipeline call to the execution carrying its message id', () => {
|
||||
const execution = run({
|
||||
source: 'pipeline',
|
||||
id: 'message-9',
|
||||
session_id: null,
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ messageId: 'message-9', sessionId: undefined }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('links an agent call to the run whose window contains it', () => {
|
||||
const execution = run({});
|
||||
assert.equal(findCallExecution(call({}), [execution]), execution);
|
||||
});
|
||||
|
||||
test('prefers the run that was live when a session has consecutive runs', () => {
|
||||
const first = run({ id: 'run-1' });
|
||||
const second = run({
|
||||
id: 'run-2',
|
||||
started_at_ms: at('2026-10-07T10:05:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:05:30Z').getTime(),
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:05:10Z') }), [
|
||||
first,
|
||||
second,
|
||||
]).id,
|
||||
'run-2',
|
||||
);
|
||||
});
|
||||
|
||||
test('an open run owns every later call of its session', () => {
|
||||
const execution = run({ finished_at_ms: null });
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:20:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call outside every window links to nothing', () => {
|
||||
const execution = run({});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T11:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T09:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ sessionId: 'other' }), [execution]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call keeps its message target when the execution list cannot resolve it', () => {
|
||||
assert.deepEqual(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'message-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
{ kind: 'message', id: 'message-9' },
|
||||
);
|
||||
// An Agent call carries its run id in the same field: an id the message list
|
||||
// does not hold must not become a jump to the message tab.
|
||||
assert.equal(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'run-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveCallTarget(call({}), []), null);
|
||||
});
|
||||
Reference in New Issue
Block a user