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 PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent'; import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel'; import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent from './components/AgentFormComponent'; import AgentFormComponent, {
AgentRunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) { export default function AgentDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -25,6 +27,9 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [loading, setLoading] = useState(!isCreateMode); const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
useEffect(() => { useEffect(() => {
if (isCreateMode) { if (isCreateMode) {
@@ -37,6 +42,10 @@ export default function AgentDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null); return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]); }, [id, isCreateMode, pipelines, setDetailEntityName, t]);
useEffect(() => {
setRunnerStatus(null);
}, [id]);
useEffect(() => { useEffect(() => {
if (isCreateMode) return; if (isCreateMode) return;
let cancelled = false; let cancelled = false;
@@ -81,6 +90,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
<ProcessorDetailWorkbench <ProcessorDetailWorkbench
key={id} key={id}
title={t('agents.editAgent')} title={t('agents.editAgent')}
status={runnerStatus}
saveLabel={t('common.save')} saveLabel={t('common.save')}
saveFormId="agent-form" saveFormId="agent-form"
canSave={canManage} canSave={canManage}
@@ -100,6 +110,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
}} }}
onDirtyChange={setFormDirty} onDirtyChange={setFormDirty}
onSavingChange={setFormSaving} onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
/> />
</fieldset> </fieldset>
} }
@@ -1,23 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
Bot,
CircleAlert,
CircleCheck,
Info,
LoaderCircle,
Power,
RefreshCw,
SlidersHorizontal,
Trash2,
Unplug,
Zap,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { import {
@@ -54,7 +41,12 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } 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 { interface AgentFormComponentProps {
agentId: string; agentId: string;
@@ -62,6 +54,7 @@ interface AgentFormComponentProps {
onDeleted: () => void; onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void; onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
} }
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic'; type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
@@ -72,6 +65,7 @@ export default function AgentFormComponent({
onDeleted, onDeleted,
onDirtyChange, onDirtyChange,
onSavingChange, onSavingChange,
onRunnerStatusChange,
}: AgentFormComponentProps) { }: AgentFormComponentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] = const [runnerConfigSchema, setRunnerConfigSchema] =
@@ -83,7 +77,7 @@ export default function AgentFormComponent({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<AgentConfigSection>('runner'); useState<AgentConfigSection>('basic');
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const formSchema = z.object({ const formSchema = z.object({
@@ -201,6 +195,11 @@ export default function AgentFormComponent({
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
}> = [ }> = [
{
name: 'basic',
label: t('agents.basicInfo'),
icon: Info,
},
{ {
name: 'events', name: 'events',
label: t('agents.bindableEvents'), label: t('agents.bindableEvents'),
@@ -220,116 +219,76 @@ export default function AgentFormComponent({
}, },
]; ];
function renderRunnerStatusActions(showRetry = true) { const runnerStatus = useMemo<AgentRunnerStatus>(() => {
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() {
if (pluginStatusLoading) { if (pluginStatusLoading) {
return ( return {
<Alert> label: t('agents.runnerStatusLoading'),
<LoaderCircle className="animate-spin" /> tone: 'neutral',
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle> };
</Alert>
);
} }
if (pluginStatusError || !pluginSystemStatus) { if (pluginStatusError || !pluginSystemStatus) {
return ( return {
<Alert variant="destructive"> label: t('agents.runnerStatusCheckFailed'),
<CircleAlert /> description: t('agents.runnerStatusCheckFailedDescription'),
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('agents.runnerStatusCheckFailedDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (!pluginSystemStatus.is_enable) { if (!pluginSystemStatus.is_enable) {
return ( return {
<Alert variant="destructive"> label: t('plugins.systemDisabled'),
<Power /> description: t('plugins.systemDisabledDesc'),
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('plugins.systemDisabledDesc')}
{renderRunnerStatusActions(false)}
</AlertDescription>
</Alert>
);
} }
if (!pluginSystemStatus.is_connected) { if (!pluginSystemStatus.is_connected) {
return ( return {
<Alert variant="destructive"> label: t('plugins.connectionError'),
<Unplug /> description: t('plugins.connectionErrorDesc'),
<AlertTitle>{t('plugins.connectionError')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('plugins.connectionErrorDesc')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (runnerOptions.length === 0) { if (runnerOptions.length === 0) {
return ( return {
<Alert variant="destructive"> label: t('agents.noRunnersAvailable'),
<CircleAlert /> description: t('agents.noRunnersAvailableDescription'),
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('agents.noRunnersAvailableDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (!currentRunner || !selectedRunnerOption) { if (!currentRunner || !selectedRunnerOption) {
return ( return {
<Alert variant="destructive"> label: t('agents.selectedRunnerUnavailable'),
<CircleAlert /> description: t('agents.selectedRunnerUnavailableDescription', {
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle> runner: currentRunner || t('agents.noRunnerSelected'),
<AlertDescription> }),
{t('agents.selectedRunnerUnavailableDescription', { tone: 'warning',
runner: currentRunner || t('agents.noRunnerSelected'), };
})}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
return ( return {
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100"> label: t('agents.runnerReady'),
<CircleCheck className="text-emerald-600" /> description: t('agents.runnerReadyDescription', {
<AlertTitle>{t('agents.runnerReady')}</AlertTitle> runner: extractI18nObject(selectedRunnerOption.label),
<AlertDescription> }),
{t('agents.runnerReadyDescription', { tone: 'success',
runner: extractI18nObject(selectedRunnerOption.label), };
})} }, [
</AlertDescription> currentRunner,
</Alert> pluginStatusError,
); pluginStatusLoading,
} pluginSystemStatus,
runnerOptions.length,
selectedRunnerOption,
t,
]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
}, [onRunnerStatusChange, runnerStatus]);
function updateSnapshotIfInitial(stageKey: string) { function updateSnapshotIfInitial(stageKey: string) {
if (!initializedStagesRef.current.has(stageKey)) { 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"> <nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<div className="overflow-x-auto"> <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) => { {primarySections.map((section, index) => {
const Icon = section.icon; const Icon = section.icon;
return ( return (
@@ -505,25 +464,12 @@ export default function AgentFormComponent({
})} })}
</ol> </ol>
</div> </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> </nav>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"> <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"> <div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
{activeSection === 'runner' && ( {activeSection === 'runner' && (
<div className="space-y-6"> <div className="space-y-6">
{renderRunnerStatus()}
{runnerSelectorStage {runnerSelectorStage
? renderDynamicStage(runnerSelectorStage) ? renderDynamicStage(runnerSelectorStage)
: !runnerConfigSchema && ( : !runnerConfigSchema && (
@@ -628,6 +574,7 @@ export default function AgentFormComponent({
<EmojiPicker <EmojiPicker
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
ariaLabel={t('common.icon')}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1,6 +1,11 @@
import { ReactNode, useState } from 'react'; import { ReactNode, useState } from 'react';
import { BarChart3, Bug, Settings } from 'lucide-react'; import { BarChart3, Bug, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ProcessorMonitoringView { interface ProcessorMonitoringView {
@@ -8,8 +13,15 @@ interface ProcessorMonitoringView {
content: ReactNode; content: ReactNode;
} }
export interface ProcessorDetailStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface ProcessorDetailWorkbenchProps { interface ProcessorDetailWorkbenchProps {
title: string; title: string;
status?: ProcessorDetailStatus | null;
saveLabel: string; saveLabel: string;
saveFormId: string; saveFormId: string;
canSave: boolean; canSave: boolean;
@@ -28,6 +40,7 @@ interface ProcessorDetailWorkbenchProps {
export default function ProcessorDetailWorkbench({ export default function ProcessorDetailWorkbench({
title, title,
status,
saveLabel, saveLabel,
saveFormId, saveFormId,
canSave, canSave,
@@ -51,7 +64,51 @@ export default function ProcessorDetailWorkbench({
return ( return (
<div className="flex h-full min-h-0 min-w-0 flex-col"> <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"> <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"> <div className="flex items-center gap-2">
{monitoring && ( {monitoring && (
<Button <Button
+3
View File
@@ -10,6 +10,7 @@ interface EmojiPickerProps {
value?: string; value?: string;
onChange: (emoji: string) => void; onChange: (emoji: string) => void;
disabled?: boolean; disabled?: boolean;
ariaLabel?: string;
} }
// 扩展的emoji分类 // 扩展的emoji分类
@@ -179,6 +180,7 @@ export default function EmojiPicker({
value, value,
onChange, onChange,
disabled, disabled,
ariaLabel,
}: EmojiPickerProps) { }: EmojiPickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>('common'); const [activeCategory, setActiveCategory] = useState<string>('common');
@@ -199,6 +201,7 @@ export default function EmojiPicker({
disabled={disabled} disabled={disabled}
className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800" className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
type="button" type="button"
aria-label={ariaLabel}
> >
{value || '😀'} {value || '😀'}
</Button> </Button>
@@ -35,16 +35,32 @@ test.describe('processor detail workbench', () => {
const flow = configPanel.locator('ol'); const flow = configPanel.locator('ol');
await expect(flow.getByRole('button').nth(0)).toContainText( await expect(flow.getByRole('button').nth(0)).toContainText(
'Basic Information',
);
await expect(flow.getByRole('button').nth(1)).toContainText(
'Bindable Event Range', 'Bindable Event Range',
); );
await expect(flow.getByRole('button').nth(1)).toContainText('Runner'); await expect(flow.getByRole('button').nth(2)).toContainText('Runner');
await expect(flow.getByRole('button').nth(2)).toContainText('Local Agent'); 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( await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(), configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
await flow.getByRole('button').nth(2).click(); await flow.getByRole('button').nth(3).click();
await expect( await expect(
configPanel.getByText('Local Agent', { exact: true }).last(), configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
@@ -59,12 +59,12 @@ test('processor forms expose their primary orchestration flow horizontally', ()
assert.match( assert.match(
agentForm, 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( assert.match(
pipelineForm, pipelineForm,
/const primarySectionNames = \['trigger', 'ai', 'output'\]/, /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/); assert.match(pipelineForm, /grid min-w-\[34rem\] grid-cols-3/);
}); });