Files
beaver_project/app-instance/frontend/app/(app)/agents/page.tsx
2026-03-13 16:40:08 +08:00

364 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { UiAgentDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
const EMPTY_FORM = {
id: '',
name: '',
description: '',
base_url: '',
endpoint: '',
card_url: '',
auth_env: '',
auth_mode: 'none',
auth_audience: '',
auth_scopes: '',
tags: '',
aliases: '',
};
export default function AgentsPage() {
const cachedAgents = useChatStore((s) => s.agentRegistry);
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
const [loading, setLoading] = useState(cachedAgents.length === 0);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const load = useCallback(async (background = false) => {
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await listAgents();
const nextAgents = Array.isArray(data) ? data : [];
setAgents(nextAgents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '加载智能体失败');
} finally {
if (background) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}, [setCachedAgents]);
useEffect(() => {
void load(cachedAgents.length > 0);
}, [cachedAgents.length, load]);
const handleRefresh = async () => {
setError(null);
setRefreshing(true);
try {
const data = await refreshAgents();
const nextAgents = data.agents || [];
setAgents(nextAgents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '刷新智能体失败');
} finally {
setRefreshing(false);
}
};
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (!open) {
setAdvancedOpen(false);
setForm(EMPTY_FORM);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
if (!hasAddress) {
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
return;
}
setSubmitting(true);
setError(null);
try {
await addAgent({
id: form.id || undefined,
name: form.name || undefined,
description: form.description || undefined,
protocol: 'a2a',
base_url: form.base_url || undefined,
endpoint: form.endpoint || undefined,
card_url: form.card_url || undefined,
auth_env: form.auth_env || undefined,
auth_mode: form.auth_mode || 'none',
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
auth_scopes: form.auth_mode === 'none'
? []
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
});
handleDialogOpenChange(false);
await load();
} catch (err: any) {
setError(err.message || '新增智能体失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (agentId: string) => {
try {
await deleteAgent(agentId);
await load();
} catch (err: any) {
setError(err.message || '删除智能体失败');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" />
</h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<Label htmlFor="base_url">A2A </Label>
<Input
id="base_url"
value={form.base_url}
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
placeholder="https://agent.example.com 或 agent.example.com:19090"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
<code className="mx-1">/.well-known/agent-card</code>
<code className="mx-1">/.well-known/agent-card.json</code>
<code className="mx-1">/.well-known/agent.json</code>
ID
</p>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" />
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint"></Label>
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
</div>
<div className="space-y-2">
<Label htmlFor="card_url"></Label>
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode"></Label>
<select
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="auth_audience">Audience</Label>
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
</div>
<div className="space-y-2">
<Label htmlFor="auth_scopes">Scopes</Label>
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="auth_env"></Label>
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如MY_AGENT_TOKEN" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tags"></Label>
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
</div>
<div className="space-y-2">
<Label htmlFor="aliases"></Label>
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如reviewer, audit-agent" />
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
card
<code className="mx-1">.well-known</code>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{agents.map((agent) => {
const isWorkspace = agent.source === 'workspace';
return (
<Card key={agent.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div>
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
<div className="space-y-2">
{agent.tags.length > 0 && (
<div className="flex items-start gap-2 flex-wrap">
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
{agent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
{agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
{agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
</div>
)}
</div>
)}
<div className="flex justify-end">
{isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}