feat(agents): improve processor creation choice

This commit is contained in:
RockChinQ
2026-08-27 14:31:37 +08:00
parent 823feac7e0
commit 847bfc8df5
6 changed files with 558 additions and 31 deletions
@@ -5,15 +5,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Workflow } from 'lucide-react';
import { Bot, CheckCircle2, Workflow } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
@@ -27,6 +29,7 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
export default function AgentCreateContent({
onCreated,
@@ -110,38 +113,112 @@ export default function AgentCreateContent({
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6">
<div className="grid gap-3 sm:grid-cols-2">
{typeOptions.map((option) => {
const Icon = option.icon;
const selected = kind === option.kind;
return (
<button
key={option.kind}
type="button"
onClick={() => handleKindChange(option.kind)}
className={cn(
'rounded-lg border bg-card p-4 text-left transition-colors',
selected
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/60',
)}
<div className="mx-auto max-w-5xl space-y-6 pb-6">
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)]">
<section
aria-labelledby="processor-kind-heading"
className="space-y-3"
>
<div>
<h2
id="processor-kind-heading"
className="text-base font-semibold"
>
<div className="flex items-start gap-3">
<Icon className="mt-0.5 size-5 text-blue-500" />
<div className="space-y-1">
<div className="font-medium">{option.title}</div>
<div className="text-xs text-muted-foreground">
{option.badge}
</div>
<p className="text-sm text-muted-foreground">
{option.description}
</p>
</div>
{t('agents.chooseType')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('agents.chooseTypeDescription')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{typeOptions.map((option) => {
const Icon = option.icon;
const selected = kind === option.kind;
return (
<Card
key={option.kind}
data-processor-kind={option.kind}
className={cn(
'gap-0 py-0 transition-[border-color,box-shadow,background-color]',
selected
? 'border-primary bg-primary/[0.035] shadow-sm ring-1 ring-primary/20'
: 'hover:border-primary/50',
)}
>
<CardContent className="h-full p-0">
<Button
type="button"
variant="ghost"
aria-pressed={selected}
onClick={() => handleKindChange(option.kind)}
className="h-full min-h-36 w-full items-start justify-start whitespace-normal rounded-xl p-4 text-left hover:bg-transparent"
>
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg border bg-background',
selected &&
'border-primary/30 bg-primary/10 text-primary',
)}
>
<Icon className="size-5" />
</span>
<span className="min-w-0 flex-1 space-y-2">
<span className="flex items-center justify-between gap-3">
<span className="font-semibold">
{option.title}
</span>
{selected && (
<CheckCircle2 className="size-4 shrink-0 text-primary" />
)}
</span>
<Badge
variant={selected ? 'default' : 'secondary'}
className="font-normal"
>
{option.badge}
</Badge>
<span className="block text-sm leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</Button>
</CardContent>
</Card>
);
})}
</div>
</section>
<Card className="min-h-[360px] overflow-hidden py-0">
<CardHeader className="border-b bg-muted/20 py-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>
{kind === 'agent'
? t('agents.agentDiagramTitle')
: t('agents.pipelineDiagramTitle')}
</CardTitle>
<CardDescription className="mt-1.5">
{t('agents.diagramHint')}
</CardDescription>
</div>
</button>
);
})}
<Badge variant="outline">
{kind === 'agent'
? t('agents.kindBadgeAgent')
: t('agents.kindBadgePipeline')}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 items-center p-4 sm:p-6">
<ProcessorTypeDiagram kind={kind} />
</CardContent>
<CardFooter className="border-t bg-muted/20 px-6 py-4 text-sm leading-relaxed text-muted-foreground">
{kind === 'agent'
? t('agents.agentDiagramDescription')
: t('agents.pipelineDiagramDescription')}
</CardFooter>
</Card>
</div>
<Card>
@@ -0,0 +1,364 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import type { AgentKind } from '@/app/infra/entities/api';
function DiagramArrow({ id }: { id: string }) {
return (
<marker
id={id}
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L8,4 L0,8 Z" fill="var(--muted-foreground)" />
</marker>
);
}
function AgentDiagram() {
const { t } = useTranslation();
const arrowId = `agent-arrow-${useId().replace(/:/g, '')}`;
return (
<svg
viewBox="0 0 720 340"
role="img"
aria-label={t('agents.agentDiagramTitle')}
className="h-auto w-full"
data-testid="agent-diagram"
>
<title>{t('agents.agentDiagramTitle')}</title>
<desc>{t('agents.agentDiagramDescription')}</desc>
<defs>
<linearGradient id="agent-core" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="var(--primary)" stopOpacity="0.22" />
<stop offset="1" stopColor="var(--primary)" stopOpacity="0.06" />
</linearGradient>
<DiagramArrow id={arrowId} />
</defs>
<rect
x="24"
y="42"
width="184"
height="256"
rx="22"
fill="var(--muted)"
fillOpacity="0.48"
stroke="var(--border)"
/>
<text
x="116"
y="76"
textAnchor="middle"
fill="var(--foreground)"
fontSize="16"
fontWeight="600"
>
{t('agents.diagramEvents')}
</text>
{[
{
y: 98,
label: t('agents.diagramMessages'),
icon: 'M44 116h12l8 8v-8h28v-24H44z',
},
{
y: 160,
label: t('agents.diagramMembers'),
icon: 'M51 167a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm-11 14c1-8 6-12 11-12s10 4 11 12m8-22a6 6 0 1 0 0-12m5 28c-1-6-4-9-9-10',
},
{
y: 222,
label: t('agents.diagramFeedback'),
icon: 'M52 213l4 9 10 1-7 7 2 10-9-5-9 5 2-10-7-7 10-1z',
},
].map((item) => (
<g key={item.label}>
<rect
x="38"
y={item.y}
width="156"
height="48"
rx="13"
fill="var(--card)"
stroke="var(--border)"
/>
<path
d={item.icon}
transform={`translate(0 ${item.y - 92})`}
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="120"
y={item.y + 30}
textAnchor="middle"
fill="var(--foreground)"
fontSize="14"
fontWeight="500"
>
{item.label}
</text>
</g>
))}
<path
d="M208 170H270"
fill="none"
stroke="var(--muted-foreground)"
strokeWidth="2"
markerEnd={`url(#${arrowId})`}
/>
<rect
x="284"
y="82"
width="164"
height="176"
rx="30"
fill="url(#agent-core)"
stroke="var(--primary)"
strokeWidth="2"
/>
<rect
x="331"
y="112"
width="70"
height="54"
rx="16"
fill="var(--card)"
stroke="var(--primary)"
strokeWidth="2"
/>
<circle cx="350" cy="139" r="5" fill="var(--primary)" />
<circle cx="382" cy="139" r="5" fill="var(--primary)" />
<path
d="M350 153c8 7 24 7 32 0M366 112V99m-8 0h16"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
/>
<text
x="366"
y="202"
textAnchor="middle"
fill="var(--foreground)"
fontSize="18"
fontWeight="700"
>
{t('agents.agentType')}
</text>
<text
x="366"
y="228"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="13"
>
{t('agents.diagramDecide')}
</text>
<path
d="M448 170H506"
fill="none"
stroke="var(--muted-foreground)"
strokeWidth="2"
markerEnd={`url(#${arrowId})`}
/>
{[
{ y: 70, label: t('agents.diagramModel') },
{ y: 145, label: t('agents.diagramTools') },
{ y: 220, label: t('agents.diagramActions') },
].map((item, index) => (
<g key={item.label}>
<path
d={`M476 170 C500 170 492 ${item.y + 26} 516 ${item.y + 26}`}
fill="none"
stroke="var(--border)"
strokeWidth="2"
/>
<rect
x="516"
y={item.y}
width="176"
height="52"
rx="15"
fill="var(--card)"
stroke={index === 1 ? 'var(--primary)' : 'var(--border)'}
/>
<circle
cx="544"
cy={item.y + 26}
r="10"
fill="var(--primary)"
fillOpacity={index === 1 ? 0.22 : 0.1}
/>
<text
x="608"
y={item.y + 31}
textAnchor="middle"
fill="var(--foreground)"
fontSize="14"
fontWeight="500"
>
{item.label}
</text>
</g>
))}
</svg>
);
}
function PipelineDiagram() {
const { t } = useTranslation();
const arrowId = `pipeline-arrow-${useId().replace(/:/g, '')}`;
const stages = [
t('agents.diagramPreprocess'),
t('agents.diagramAI'),
t('agents.diagramPostprocess'),
t('agents.diagramOutput'),
];
return (
<svg
viewBox="0 0 720 340"
role="img"
aria-label={t('agents.pipelineDiagramTitle')}
className="h-auto w-full"
data-testid="pipeline-diagram"
>
<title>{t('agents.pipelineDiagramTitle')}</title>
<desc>{t('agents.pipelineDiagramDescription')}</desc>
<defs>
<linearGradient id="pipeline-flow" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="var(--primary)" stopOpacity="0.06" />
<stop offset="0.5" stopColor="var(--primary)" stopOpacity="0.18" />
<stop offset="1" stopColor="var(--primary)" stopOpacity="0.06" />
</linearGradient>
<DiagramArrow id={arrowId} />
</defs>
<rect
x="24"
y="65"
width="672"
height="210"
rx="28"
fill="url(#pipeline-flow)"
stroke="var(--border)"
/>
<text
x="360"
y="104"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="14"
fontWeight="500"
>
{t('agents.pipelineDiagramFlow')}
</text>
<rect
x="40"
y="137"
width="104"
height="68"
rx="18"
fill="var(--card)"
stroke="var(--border)"
/>
<path
d="M63 156h38v25H76l-9 8v-8h-4z"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinejoin="round"
/>
<text
x="92"
y="228"
textAnchor="middle"
fill="var(--foreground)"
fontSize="13"
fontWeight="500"
>
{t('agents.diagramMessage')}
</text>
<path
d="M144 171H168"
fill="none"
stroke="var(--muted-foreground)"
strokeWidth="2"
markerEnd={`url(#${arrowId})`}
/>
{stages.map((stage, index) => {
const x = 178 + index * 128;
return (
<g key={stage}>
<rect
x={x}
y="127"
width="104"
height="88"
rx="18"
fill="var(--card)"
stroke={index === 1 ? 'var(--primary)' : 'var(--border)'}
strokeWidth={index === 1 ? 2 : 1}
/>
<circle
cx={x + 52}
cy="154"
r="14"
fill="var(--primary)"
fillOpacity={index === 1 ? 0.22 : 0.1}
/>
<text
x={x + 52}
y="159"
textAnchor="middle"
fill="var(--primary)"
fontSize="13"
fontWeight="700"
>
{index + 1}
</text>
<text
x={x + 52}
y="190"
textAnchor="middle"
fill="var(--foreground)"
fontSize="13"
fontWeight="600"
>
{stage}
</text>
{index < stages.length - 1 && (
<path
d={`M${x + 104} 171H${x + 122}`}
fill="none"
stroke="var(--muted-foreground)"
strokeWidth="2"
markerEnd={`url(#${arrowId})`}
/>
)}
</g>
);
})}
</svg>
);
}
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
}
+24
View File
@@ -711,6 +711,30 @@ const enUS = {
'Use a visual multi-stage flow to control message preprocessing, AI, postprocessing, extensions, and output. Message events only.',
allEvents: 'Supports all events',
messageEventsOnly: 'Message events only',
chooseType: 'Choose how it works',
chooseTypeDescription:
'Choose the processor model first. You can configure it further after creation.',
diagramHint: 'How it works',
agentDiagramTitle: 'Agent: decide and act',
agentDiagramDescription:
'An Agent can receive many platform events, use models and tools to understand the situation, and decide what to do next.',
pipelineDiagramTitle: 'Pipeline: follow a defined flow',
pipelineDiagramDescription:
'A Pipeline handles message events only and runs each step in the order you configure.',
pipelineDiagramFlow: 'Messages move through a fixed sequence',
diagramEvents: 'Platform events',
diagramMessages: 'Messages',
diagramMembers: 'Members',
diagramFeedback: 'Feedback',
diagramDecide: 'Understand and decide',
diagramModel: 'Use model',
diagramTools: 'Use tools',
diagramActions: 'Take action',
diagramMessage: 'Message',
diagramPreprocess: 'Prepare',
diagramAI: 'AI',
diagramPostprocess: 'Refine',
diagramOutput: 'Output',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the name, icon and description',
runnerSettings: 'Runner',
+24
View File
@@ -724,6 +724,30 @@ const jaJP = {
'ビジュアルな多段フローで、メッセージの前処理、AI、後処理、拡張、出力を制御します。メッセージイベントのみ処理できます。',
allEvents: 'すべてのイベントに対応',
messageEventsOnly: 'メッセージイベントのみ',
chooseType: '処理方法を選択',
chooseTypeDescription:
'最初にプロセッサーの動作方式を選びます。作成後に詳しく設定できます。',
diagramHint: '動作イメージ',
agentDiagramTitle: 'Agent:判断して行動',
agentDiagramDescription:
'Agent はさまざまなプラットフォームイベントを受け取り、モデルやツールを使って状況を理解し、次の行動を判断します。',
pipelineDiagramTitle: 'Pipeline:決めたフローで処理',
pipelineDiagramDescription:
'Pipeline はメッセージイベントのみを、設定した固定ステップの順番で処理します。',
pipelineDiagramFlow: 'メッセージを固定フローで順番に処理',
diagramEvents: 'イベント',
diagramMessages: 'メッセージ',
diagramMembers: 'メンバー',
diagramFeedback: 'フィードバック',
diagramDecide: '理解して判断',
diagramModel: 'モデル',
diagramTools: 'ツール',
diagramActions: 'アクション',
diagramMessage: 'メッセージ',
diagramPreprocess: '前処理',
diagramAI: 'AI',
diagramPostprocess: '後処理',
diagramOutput: '出力',
basicInfo: '基本情報',
basicInfoDescription: '名前、アイコン、説明を設定します',
runnerSettings: 'Runner',
+23
View File
@@ -680,6 +680,29 @@ const zhHans = {
'通过可视化多阶段流程控制消息的预处理、AI、后处理、扩展和输出;仅处理消息事件。',
allEvents: '支持全部事件',
messageEventsOnly: '仅支持消息事件',
chooseType: '选择处理方式',
chooseTypeDescription: '先选择处理器的工作方式,创建后可继续完善配置。',
diagramHint: '工作方式示意',
agentDiagramTitle: 'Agent:自主判断并行动',
agentDiagramDescription:
'Agent 可以接收多种平台事件,结合模型和工具理解当前情况,并自主决定下一步动作。',
pipelineDiagramTitle: 'Pipeline:按流程逐步处理',
pipelineDiagramDescription:
'Pipeline 只处理消息事件,并严格按照你编排的固定步骤依次执行。',
pipelineDiagramFlow: '消息沿固定流程依次处理',
diagramEvents: '平台事件',
diagramMessages: '消息',
diagramMembers: '群成员',
diagramFeedback: '反馈',
diagramDecide: '理解事件并决定下一步',
diagramModel: '调用模型',
diagramTools: '使用工具',
diagramActions: '执行动作',
diagramMessage: '收到消息',
diagramPreprocess: '预处理',
diagramAI: 'AI 处理',
diagramPostprocess: '后处理',
diagramOutput: '输出',
basicInfo: '基础信息',
basicInfoDescription: '设置名称、图标和描述',
runnerSettings: '运行器',
+15
View File
@@ -171,7 +171,22 @@ test.describe('frontend CRUD smoke flows', () => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=new');
const agentTypeCard = page.locator('[data-processor-kind="agent"]');
const pipelineTypeCard = page.locator('[data-processor-kind="pipeline"]');
await expect(agentTypeCard).toHaveAttribute('data-slot', 'card');
await expect(pipelineTypeCard).toHaveAttribute('data-slot', 'card');
await expect(
agentTypeCard.getByRole('button', { name: /^Agent/ }),
).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByTestId('agent-diagram')).toBeVisible();
await expect(page.getByTestId('pipeline-diagram')).toHaveCount(0);
await page.getByRole('button', { name: /^Pipeline/ }).click();
await expect(
pipelineTypeCard.getByRole('button', { name: /^Pipeline/ }),
).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByTestId('pipeline-diagram')).toBeVisible();
await expect(page.getByTestId('agent-diagram')).toHaveCount(0);
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Escalation Pipeline');