169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { toast } from "react-toastify";
|
|
import { agents as agentsApi } from "../api/client";
|
|
import type { Agent } from "../api/types";
|
|
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
|
import { AgentAvatar } from "../components/AgentAvatar";
|
|
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
|
|
|
|
export function AgentsPage() {
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Agent | null>(null);
|
|
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
|
|
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setAgents(await agentsApi.list());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const onSaved = (agent: Agent, created: boolean) => {
|
|
load();
|
|
if (created && agent.api_key) {
|
|
setRevealedKey(agent.api_key);
|
|
setConnectAgent(agent);
|
|
}
|
|
};
|
|
|
|
const regenerate = async (agent: Agent) => {
|
|
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
|
|
try {
|
|
const updated = await agentsApi.regenerateKey(agent.id);
|
|
toast.success("API key regenerated");
|
|
setRevealedKey(updated.api_key ?? null);
|
|
setConnectAgent(updated);
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed");
|
|
}
|
|
};
|
|
|
|
const toggleDisabled = async (agent: Agent) => {
|
|
try {
|
|
await agentsApi.setDisabled(agent.id, !agent.disabled);
|
|
toast.success(agent.disabled ? "Agent enabled" : "Agent disabled");
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed");
|
|
}
|
|
};
|
|
|
|
const remove = async (agent: Agent) => {
|
|
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
|
|
try {
|
|
await agentsApi.remove(agent.id);
|
|
toast.success("Agent deleted");
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Agents"
|
|
subtitle="Each agent has its own API key. You may own up to 5 agents."
|
|
actions={
|
|
<Button
|
|
onClick={() => {
|
|
setEditing(null);
|
|
setFormOpen(true);
|
|
}}
|
|
disabled={agents.length >= 5}
|
|
>
|
|
+ New agent
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-16 text-primary">
|
|
<Spinner className="h-6 w-6" />
|
|
</div>
|
|
) : agents.length === 0 ? (
|
|
<EmptyState
|
|
icon="🤖"
|
|
title="No agents yet"
|
|
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{agents.map((a) => (
|
|
<Card key={a.id} className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate font-semibold text-text">{a.name}</h3>
|
|
{a.disabled && (
|
|
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
|
DISABLED
|
|
</span>
|
|
)}
|
|
</div>
|
|
{a.description && <p className="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
|
|
<p className="mt-1 text-xs text-faint">
|
|
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
|
|
<code>{a.api_key_masked}</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
|
|
Connect
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditing(a);
|
|
setFormOpen(true);
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => regenerate(a)}>
|
|
Regenerate key
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
|
|
{a.disabled ? "Enable" : "Disable"}
|
|
</Button>
|
|
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<AgentFormModal
|
|
open={formOpen}
|
|
onClose={() => setFormOpen(false)}
|
|
agent={editing}
|
|
onSaved={onSaved}
|
|
/>
|
|
{connectAgent && (
|
|
<ConnectModal
|
|
open={!!connectAgent}
|
|
onClose={() => {
|
|
setConnectAgent(null);
|
|
setRevealedKey(null);
|
|
}}
|
|
agent={connectAgent}
|
|
revealedKey={revealedKey}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|