mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
feat(web): surface agent basics and runner status
This commit is contained in:
@@ -9,7 +9,9 @@ import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/Pro
|
||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||
import AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
import AgentFormComponent from './components/AgentFormComponent';
|
||||
import AgentFormComponent, {
|
||||
AgentRunnerStatus,
|
||||
} from './components/AgentFormComponent';
|
||||
|
||||
export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
@@ -25,6 +27,9 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
@@ -37,6 +42,10 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunnerStatus(null);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
let cancelled = false;
|
||||
@@ -81,6 +90,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={t('agents.editAgent')}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
@@ -100,6 +110,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
/>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bot,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Power,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Unplug,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -54,7 +41,12 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
export interface AgentRunnerStatus {
|
||||
label: string;
|
||||
description?: string;
|
||||
tone: 'neutral' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
interface AgentFormComponentProps {
|
||||
agentId: string;
|
||||
@@ -62,6 +54,7 @@ interface AgentFormComponentProps {
|
||||
onDeleted: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
|
||||
}
|
||||
|
||||
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
|
||||
@@ -72,6 +65,7 @@ export default function AgentFormComponent({
|
||||
onDeleted,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
onRunnerStatusChange,
|
||||
}: AgentFormComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||
@@ -83,7 +77,7 @@ export default function AgentFormComponent({
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AgentConfigSection>('runner');
|
||||
useState<AgentConfigSection>('basic');
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -201,6 +195,11 @@ export default function AgentFormComponent({
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}> = [
|
||||
{
|
||||
name: 'basic',
|
||||
label: t('agents.basicInfo'),
|
||||
icon: Info,
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
label: t('agents.bindableEvents'),
|
||||
@@ -220,116 +219,76 @@ export default function AgentFormComponent({
|
||||
},
|
||||
];
|
||||
|
||||
function renderRunnerStatusActions(showRetry = true) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{showRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadPluginSystemStatus()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link to="/home/extensions">{t('plugins.title')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRunnerStatus() {
|
||||
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
|
||||
if (pluginStatusLoading) {
|
||||
return (
|
||||
<Alert>
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.runnerStatusLoading'),
|
||||
tone: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
if (pluginStatusError || !pluginSystemStatus) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerStatusCheckFailedDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.runnerStatusCheckFailed'),
|
||||
description: t('agents.runnerStatusCheckFailedDescription'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_enable) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Power />
|
||||
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.systemDisabledDesc')}
|
||||
{renderRunnerStatusActions(false)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('plugins.systemDisabled'),
|
||||
description: t('plugins.systemDisabledDesc'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_connected) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Unplug />
|
||||
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.connectionErrorDesc')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('plugins.connectionError'),
|
||||
description: t('plugins.connectionErrorDesc'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (runnerOptions.length === 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.noRunnersAvailableDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.noRunnersAvailable'),
|
||||
description: t('agents.noRunnersAvailableDescription'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentRunner || !selectedRunnerOption) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.selectedRunnerUnavailableDescription', {
|
||||
runner: currentRunner || t('agents.noRunnerSelected'),
|
||||
})}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.selectedRunnerUnavailable'),
|
||||
description: t('agents.selectedRunnerUnavailableDescription', {
|
||||
runner: currentRunner || t('agents.noRunnerSelected'),
|
||||
}),
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
|
||||
<CircleCheck className="text-emerald-600" />
|
||||
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerReadyDescription', {
|
||||
runner: extractI18nObject(selectedRunnerOption.label),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return {
|
||||
label: t('agents.runnerReady'),
|
||||
description: t('agents.runnerReadyDescription', {
|
||||
runner: extractI18nObject(selectedRunnerOption.label),
|
||||
}),
|
||||
tone: 'success',
|
||||
};
|
||||
}, [
|
||||
currentRunner,
|
||||
pluginStatusError,
|
||||
pluginStatusLoading,
|
||||
pluginSystemStatus,
|
||||
runnerOptions.length,
|
||||
selectedRunnerOption,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onRunnerStatusChange?.(runnerStatus);
|
||||
}, [onRunnerStatusChange, runnerStatus]);
|
||||
|
||||
function updateSnapshotIfInitial(stageKey: string) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
@@ -472,7 +431,7 @@ export default function AgentFormComponent({
|
||||
>
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<div className="overflow-x-auto">
|
||||
<ol className="grid min-w-[34rem] grid-cols-3 gap-2">
|
||||
<ol className="grid min-w-[44rem] grid-cols-4 gap-2">
|
||||
{primarySections.map((section, index) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
@@ -505,25 +464,12 @@ export default function AgentFormComponent({
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection('basic')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
activeSection === 'basic'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
{t('agents.basicInfo')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
|
||||
{activeSection === 'runner' && (
|
||||
<div className="space-y-6">
|
||||
{renderRunnerStatus()}
|
||||
{runnerSelectorStage
|
||||
? renderDynamicStage(runnerSelectorStage)
|
||||
: !runnerConfigSchema && (
|
||||
@@ -628,6 +574,7 @@ export default function AgentFormComponent({
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
ariaLabel={t('common.icon')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { BarChart3, Bug, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProcessorMonitoringView {
|
||||
@@ -8,8 +13,15 @@ interface ProcessorMonitoringView {
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export interface ProcessorDetailStatus {
|
||||
label: string;
|
||||
description?: string;
|
||||
tone: 'neutral' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
interface ProcessorDetailWorkbenchProps {
|
||||
title: string;
|
||||
status?: ProcessorDetailStatus | null;
|
||||
saveLabel: string;
|
||||
saveFormId: string;
|
||||
canSave: boolean;
|
||||
@@ -28,6 +40,7 @@ interface ProcessorDetailWorkbenchProps {
|
||||
|
||||
export default function ProcessorDetailWorkbench({
|
||||
title,
|
||||
status,
|
||||
saveLabel,
|
||||
saveFormId,
|
||||
canSave,
|
||||
@@ -51,7 +64,51 @@ export default function ProcessorDetailWorkbench({
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||
{status && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="status"
|
||||
aria-label={status.label}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium',
|
||||
status.tone === 'success' &&
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||
status.tone === 'warning' &&
|
||||
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||
status.tone === 'error' &&
|
||||
'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
status.tone === 'neutral' &&
|
||||
'border-border bg-muted/50 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status.tone === 'success' && 'bg-emerald-500',
|
||||
status.tone === 'warning' && 'bg-amber-500',
|
||||
status.tone === 'error' && 'bg-destructive',
|
||||
status.tone === 'neutral' &&
|
||||
'animate-pulse bg-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{status.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-72">
|
||||
<p className="font-medium">{status.label}</p>
|
||||
{status.description && (
|
||||
<p className="mt-1 font-normal opacity-80">
|
||||
{status.description}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{monitoring && (
|
||||
<Button
|
||||
|
||||
@@ -10,6 +10,7 @@ interface EmojiPickerProps {
|
||||
value?: string;
|
||||
onChange: (emoji: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// 扩展的emoji分类
|
||||
@@ -179,6 +180,7 @@ export default function EmojiPicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
}: EmojiPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('common');
|
||||
@@ -199,6 +201,7 @@ export default function EmojiPicker({
|
||||
disabled={disabled}
|
||||
className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{value || '😀'}
|
||||
</Button>
|
||||
|
||||
@@ -35,16 +35,32 @@ test.describe('processor detail workbench', () => {
|
||||
|
||||
const flow = configPanel.locator('ol');
|
||||
await expect(flow.getByRole('button').nth(0)).toContainText(
|
||||
'Basic Information',
|
||||
);
|
||||
await expect(flow.getByRole('button').nth(1)).toContainText(
|
||||
'Bindable Event Range',
|
||||
);
|
||||
await expect(flow.getByRole('button').nth(1)).toContainText('Runner');
|
||||
await expect(flow.getByRole('button').nth(2)).toContainText('Local Agent');
|
||||
await expect(flow.getByRole('button').nth(2)).toContainText('Runner');
|
||||
await expect(flow.getByRole('button').nth(3)).toContainText('Local Agent');
|
||||
|
||||
await flow.getByRole('button').nth(0).click();
|
||||
await expect(configPanel.getByLabel('Name')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Icon')).toBeVisible();
|
||||
await expect(configPanel.getByLabel('Description')).toBeVisible();
|
||||
|
||||
const runnerStatus = page.getByRole('status', { name: 'Runner ready' });
|
||||
await expect(runnerStatus).toBeVisible();
|
||||
await runnerStatus.hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Local Agent is registered and the plugin runtime is connected.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await flow.getByRole('button').nth(1).click();
|
||||
await expect(
|
||||
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await flow.getByRole('button').nth(2).click();
|
||||
await flow.getByRole('button').nth(3).click();
|
||||
await expect(
|
||||
configPanel.getByText('Local Agent', { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -59,12 +59,12 @@ test('processor forms expose their primary orchestration flow horizontally', ()
|
||||
|
||||
assert.match(
|
||||
agentForm,
|
||||
/name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
|
||||
/name: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
|
||||
);
|
||||
assert.match(
|
||||
pipelineForm,
|
||||
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
|
||||
);
|
||||
assert.match(agentForm, /grid min-w-\[34rem\] grid-cols-3/);
|
||||
assert.match(agentForm, /grid min-w-\[44rem\] grid-cols-4/);
|
||||
assert.match(pipelineForm, /grid min-w-\[34rem\] grid-cols-3/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user