feat(web): surface agent basics and runner status

This commit is contained in:
RockChinQ
2026-08-25 12:17:59 +08:00
parent f65cca3f40
commit ac31f1f006
6 changed files with 164 additions and 130 deletions
+12 -1
View File
@@ -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
+3
View File
@@ -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/);
});