AgentListPage.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import {
  4. Bot,
  5. RefreshCw,
  6. Search,
  7. Sparkles,
  8. Trash2,
  9. } from "lucide-react";
  10. import { createAgentConfig, deleteAgent, listAgentConfigs, listAgentRuns, listModels, listSkills, updateAgent } from "@/api";
  11. import { translateApiError } from "@/api/errors";
  12. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  13. import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
  14. import { EmptyState } from "@/components/shared/EmptyState";
  15. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  16. import { PageHeader } from "@/components/shared/PageHeader";
  17. import { SearchInput } from "@/components/shared/SearchInput";
  18. import { Badge } from "@/components/ui/badge";
  19. import { Button } from "@/components/ui/button";
  20. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  21. import { Dialog } from "@/components/ui/dialog";
  22. import { Input, Textarea } from "@/components/ui/input";
  23. import { Select } from "@/components/ui/select";
  24. import { toast } from "@/components/ui/toaster";
  25. import { demoText } from "@/lib/demo-text";
  26. import { useAgentList } from "@/hooks";
  27. import type { AgentConfig, AgentDefinition, AgentRun, JSONObject, ModelDefinition, SkillDefinition } from "@/types";
  28. import { AgentOverview } from "./components/AgentOverview";
  29. import { AgentRuns } from "./components/AgentRuns";
  30. import { CreateAgentDialog } from "./components/CreateAgentDialog";
  31. export function AgentListPage() {
  32. const { t } = useTranslation();
  33. const [search, setSearch] = React.useState("");
  34. const [selectedAgentId, setSelectedAgentId] = React.useState<string>();
  35. const [configs, setConfigs] = React.useState<AgentConfig[]>([]);
  36. const [runs, setRuns] = React.useState<AgentRun[]>([]);
  37. const [skills, setSkills] = React.useState<SkillDefinition[]>([]);
  38. const [relatedLoading, setRelatedLoading] = React.useState(true);
  39. const [directoryLoading, setDirectoryLoading] = React.useState(false);
  40. const [editOpen, setEditOpen] = React.useState(false);
  41. const [deleteOpen, setDeleteOpen] = React.useState(false);
  42. const [detailTab, setDetailTab] = React.useState<"overview" | "test">("overview");
  43. const agents = useAgentList();
  44. const agentList = agents.data ?? [];
  45. const selectedAgent = agentList.find((agent) => agent.id === selectedAgentId) ?? agentList[0];
  46. const selectedConfigs = configs.filter((config) => config.agent_id === selectedAgent?.id);
  47. const selectedRuns = runs
  48. .filter((r) => r.agent_id === selectedAgent?.id)
  49. .sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime());
  50. const activeConfig = [...selectedConfigs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
  51. const configByAgent = React.useMemo(() => {
  52. const grouped = new Map<string, AgentConfig[]>();
  53. configs.forEach((config) => {
  54. grouped.set(config.agent_id, [...(grouped.get(config.agent_id) ?? []), config]);
  55. });
  56. const result = new Map<string, AgentConfig>();
  57. grouped.forEach((items, agentId) => {
  58. const config = [...items].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
  59. if (config) result.set(agentId, config);
  60. });
  61. return result;
  62. }, [configs]);
  63. const runsByAgent = React.useMemo(() => {
  64. const result = new Map<string, AgentRun[]>();
  65. runs.forEach((run) => {
  66. result.set(run.agent_id, [...(result.get(run.agent_id) ?? []), run]);
  67. });
  68. return result;
  69. }, [runs]);
  70. const filtered = agentList.filter((agent) => {
  71. const text = `${agent.name} ${agent.description ?? ""}`.toLowerCase();
  72. return text.includes(search.toLowerCase());
  73. });
  74. const loadSkills = React.useCallback(async () => {
  75. try {
  76. setSkills(await listSkills());
  77. } catch {
  78. setSkills([]);
  79. }
  80. }, []);
  81. const loadRelated = React.useCallback(async (agentId?: string, options?: { silent?: boolean }) => {
  82. if (!agentId) {
  83. setConfigs([]);
  84. setRuns([]);
  85. setRelatedLoading(false);
  86. return;
  87. }
  88. if (!options?.silent) setRelatedLoading(true);
  89. try {
  90. const [configData, runData] = await Promise.all([listAgentConfigs(agentId), listAgentRuns(agentId)]);
  91. setConfigs((current) => [...current.filter((config) => config.agent_id !== agentId), ...configData]);
  92. setRuns((current) => [...current.filter((run) => run.agent_id !== agentId), ...runData]);
  93. } catch {
  94. toast.error(t("errors.failedToLoad"));
  95. } finally {
  96. if (!options?.silent) setRelatedLoading(false);
  97. }
  98. }, [t]);
  99. const loadDirectorySummaries = React.useCallback(async (agentIds: string[]) => {
  100. if (!agentIds.length) return;
  101. setDirectoryLoading(true);
  102. try {
  103. const [configResults, runResults] = await Promise.all([
  104. Promise.allSettled(agentIds.map((agentId) => listAgentConfigs(agentId))),
  105. Promise.allSettled(agentIds.map((agentId) => listAgentRuns(agentId))),
  106. ]);
  107. const nextConfigs = configResults.flatMap((result) => result.status === "fulfilled" ? result.value : []);
  108. const nextRuns = runResults.flatMap((result) => result.status === "fulfilled" ? result.value : []);
  109. setConfigs((current) => [
  110. ...current.filter((config) => !agentIds.includes(config.agent_id)),
  111. ...nextConfigs,
  112. ]);
  113. setRuns((current) => [
  114. ...current.filter((run) => !agentIds.includes(run.agent_id)),
  115. ...nextRuns,
  116. ]);
  117. } finally {
  118. setDirectoryLoading(false);
  119. }
  120. }, []);
  121. React.useEffect(() => { void loadSkills(); }, [loadSkills]);
  122. React.useEffect(() => { void loadDirectorySummaries(agentList.map((agent) => agent.id)); }, [agentList, loadDirectorySummaries]);
  123. React.useEffect(() => { void loadRelated(selectedAgent?.id); }, [loadRelated, selectedAgent?.id]);
  124. React.useEffect(() => { if (!selectedAgentId && agentList[0]) setSelectedAgentId(agentList[0].id); }, [agentList, selectedAgentId]);
  125. React.useEffect(() => { setDetailTab("overview"); }, [selectedAgent?.id]);
  126. async function handleDelete() {
  127. if (!selectedAgent) return;
  128. try {
  129. await deleteAgent(selectedAgent.id);
  130. toast.success(t("agents.agentDeleted"));
  131. setSelectedAgentId(undefined);
  132. setDeleteOpen(false);
  133. void agents.refetch();
  134. } catch {
  135. toast.error(t("agents.failedToDeleteAgent"));
  136. }
  137. }
  138. if (agents.loading) return <LoadingSpinner label={t("common.loading")} />;
  139. if (agents.error) return <ApiErrorState message={agents.error.message} onRetry={() => void agents.refetch()} />;
  140. return (
  141. <div className="space-y-6">
  142. <PageHeader
  143. title={t("agents.title")}
  144. description={t("agents.description")}
  145. actions={
  146. <>
  147. <Button variant="outline" onClick={() => { void agents.refetch(); void loadSkills(); void loadRelated(selectedAgent?.id); }}>
  148. <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
  149. </Button>
  150. <CreateAgentDialog onCreated={() => void agents.refetch()} />
  151. </>
  152. }
  153. />
  154. <div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
  155. <Card className="overflow-hidden">
  156. <CardHeader className="border-b border-border">
  157. <div className="flex items-start justify-between gap-3">
  158. <div>
  159. <CardTitle>{t("agents.agentDirectory")}</CardTitle>
  160. <CardDescription>
  161. {directoryLoading ? t("common.loading") : t("agents.shown", { shown: filtered.length, total: agentList.length })}
  162. </CardDescription>
  163. </div>
  164. <Badge className="border-primary/20 bg-primary/10 text-primary">{agentList.length}</Badge>
  165. </div>
  166. </CardHeader>
  167. <CardContent className="space-y-3 pt-4">
  168. <SearchInput className="sm:w-full" value={search} onChange={setSearch} placeholder={t("agents.searchPlaceholder")} />
  169. {filtered.length ? (
  170. <div className="space-y-2">
  171. {filtered.map((agent) => (
  172. <AgentDirectoryRow
  173. key={agent.id}
  174. active={agent.id === selectedAgent?.id}
  175. agent={agent}
  176. config={configByAgent.get(agent.id)}
  177. runs={runsByAgent.get(agent.id) ?? []}
  178. onClick={() => setSelectedAgentId(agent.id)}
  179. />
  180. ))}
  181. </div>
  182. ) : (
  183. <EmptyState icon={Search} title={t("agents.noMatchingAgents")} description={t("agents.adjustFiltersAgent")} />
  184. )}
  185. </CardContent>
  186. </Card>
  187. <Card className="overflow-hidden">
  188. <CardHeader className="border-b border-border">
  189. <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
  190. <div className="min-w-0">
  191. <div className="flex flex-wrap items-center gap-2">
  192. <Bot className="h-5 w-5 text-primary" />
  193. <CardTitle className="truncate">{selectedAgent ? demoText(selectedAgent.name, t) : t("agents.agentDetails")}</CardTitle>
  194. </div>
  195. <CardDescription className="mt-2">
  196. {selectedAgent ? t("agents.manageDescription") : t("agents.selectAgent")}
  197. </CardDescription>
  198. </div>
  199. {selectedAgent && (
  200. <div className="flex shrink-0 items-center gap-2">
  201. <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
  202. {t("common.edit")}
  203. </Button>
  204. <Button variant="ghost" size="sm" onClick={() => setDeleteOpen(true)}>
  205. <Trash2 className="h-3 w-3 text-destructive" />
  206. </Button>
  207. </div>
  208. )}
  209. </div>
  210. </CardHeader>
  211. <CardContent className="min-w-0 pt-4">
  212. {selectedAgent ? (
  213. <div className="min-w-0 space-y-4">
  214. <div className="grid rounded-lg border border-border bg-muted/25 p-1 sm:w-fit sm:grid-cols-2">
  215. <button
  216. type="button"
  217. onClick={() => setDetailTab("overview")}
  218. className={[
  219. "rounded-md px-4 py-2 text-sm font-medium transition",
  220. detailTab === "overview" ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
  221. ].join(" ")}
  222. >
  223. {t("agents.overview")}
  224. </button>
  225. <button
  226. type="button"
  227. onClick={() => setDetailTab("test")}
  228. className={[
  229. "rounded-md px-4 py-2 text-sm font-medium transition",
  230. detailTab === "test" ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
  231. ].join(" ")}
  232. >
  233. {t("agents.test")}
  234. </button>
  235. </div>
  236. {detailTab === "overview" ? (
  237. <AgentOverview
  238. agent={selectedAgent}
  239. activeConfig={activeConfig}
  240. skills={skills}
  241. runCount={selectedRuns.length}
  242. failedRunCount={selectedRuns.filter((r) => r.status === "failed").length}
  243. />
  244. ) : (
  245. <AgentRuns
  246. agentId={selectedAgent.id}
  247. agentConfigId={activeConfig?.id}
  248. runs={selectedRuns}
  249. loading={relatedLoading}
  250. onRunCompleted={() => void loadRelated(selectedAgent.id, { silent: true })}
  251. />
  252. )}
  253. </div>
  254. ) : (
  255. <EmptyState icon={Bot} title={t("agents.noAgents")} description={t("agents.createAgentStart")} />
  256. )}
  257. </CardContent>
  258. </Card>
  259. </div>
  260. {selectedAgent && (
  261. <EditAgentDialog
  262. agent={selectedAgent}
  263. activeConfig={activeConfig}
  264. open={editOpen}
  265. onOpenChange={setEditOpen}
  266. onSaved={() => { void agents.refetch(); void loadRelated(selectedAgent.id); }}
  267. />
  268. )}
  269. <ConfirmDialog
  270. open={deleteOpen}
  271. onOpenChange={setDeleteOpen}
  272. title={t("agents.deleteAgent")}
  273. description={t("agents.deleteConfirm", { name: demoText(selectedAgent?.name, t) })}
  274. onConfirm={handleDelete}
  275. />
  276. </div>
  277. );
  278. }
  279. function AgentDirectoryRow({
  280. agent,
  281. config,
  282. active,
  283. runs,
  284. onClick,
  285. }: {
  286. agent: AgentDefinition;
  287. config?: AgentConfig;
  288. active: boolean;
  289. runs: AgentRun[];
  290. onClick: () => void;
  291. }) {
  292. const { t } = useTranslation();
  293. const model = config ? formatAgentModel(config, t) : t("agents.noModelSelected");
  294. const provider = config ? formatAgentProvider(config, t) : t("agents.providerNotSet");
  295. const skillCount = config?.skill_refs_json.length ?? 0;
  296. const failedCount = runs.filter((run) => run.status === "failed").length;
  297. const latestRun = [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
  298. return (
  299. <button
  300. type="button"
  301. onClick={onClick}
  302. className={[
  303. "grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md border p-3 text-left transition",
  304. active ? "border-primary/45 bg-primary/10" : "border-border bg-muted/30 hover:bg-muted/55",
  305. ].join(" ")}
  306. >
  307. <span className="min-w-0">
  308. <span className="block truncate text-sm font-medium">{demoText(agent.name, t)}</span>
  309. <span className="mt-1 block truncate text-xs text-muted-foreground">
  310. {model}
  311. {provider ? ` · ${provider}` : ""}
  312. </span>
  313. <span className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
  314. <span>{t("agents.runsBadge", { count: runs.length })}</span>
  315. <span className="text-border">|</span>
  316. <span>{t("agents.skillsBadge", { count: skillCount })}</span>
  317. {failedCount ? (
  318. <>
  319. <span className="text-border">|</span>
  320. <span>{t("agents.failedRuns")}: {failedCount}</span>
  321. </>
  322. ) : null}
  323. {latestRun ? (
  324. <>
  325. <span className="text-border">|</span>
  326. <span>{t("agents.latest")}: {readableAgentValue(latestRun.status, t)}</span>
  327. </>
  328. ) : null}
  329. </span>
  330. </span>
  331. </button>
  332. );
  333. }
  334. function formatAgentModel(config: AgentConfig, t: ReturnType<typeof useTranslation>["t"]) {
  335. const value = config.model_config_json.model;
  336. return typeof value === "string" && value ? value : t("agents.noModelSelected");
  337. }
  338. function formatAgentProvider(config: AgentConfig, t: ReturnType<typeof useTranslation>["t"]) {
  339. const value = config.model_config_json.provider;
  340. return typeof value === "string" && value ? readableAgentValue(value, t) : t("agents.providerNotSet");
  341. }
  342. function readableAgentValue(value: string, t: ReturnType<typeof useTranslation>["t"]) {
  343. const fallback = value.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
  344. return t(`agents.valueLabels.${value}`, fallback);
  345. }
  346. function EditAgentDialog({
  347. agent,
  348. activeConfig,
  349. open,
  350. onOpenChange,
  351. onSaved,
  352. }: {
  353. agent: AgentDefinition;
  354. activeConfig?: AgentConfig;
  355. open: boolean;
  356. onOpenChange: (open: boolean) => void;
  357. onSaved: () => void;
  358. }) {
  359. const { t } = useTranslation();
  360. const [name, setName] = React.useState(agent.name);
  361. const [systemPrompt, setSystemPrompt] = React.useState(activeConfig?.system_prompt ?? "");
  362. const [models, setModels] = React.useState<ModelDefinition[]>([]);
  363. const [selectedModelId, setSelectedModelId] = React.useState("");
  364. const [availableSkills, setAvailableSkills] = React.useState<SkillDefinition[]>([]);
  365. const [selectedSkillIds, setSelectedSkillIds] = React.useState<string[]>([]);
  366. const [skillsLoading, setSkillsLoading] = React.useState(false);
  367. const [skillsError, setSkillsError] = React.useState<string | null>(null);
  368. const [memoryScope, setMemoryScope] = React.useState("session");
  369. const [temperature, setTemperature] = React.useState("0.7");
  370. const [maxTokens, setMaxTokens] = React.useState("4096");
  371. const [timeoutSeconds, setTimeoutSeconds] = React.useState("60");
  372. const [retryAttempts, setRetryAttempts] = React.useState("2");
  373. const [retryBackoffMs, setRetryBackoffMs] = React.useState("800");
  374. const [toolCallLimit, setToolCallLimit] = React.useState("8");
  375. const [contextWindow, setContextWindow] = React.useState("");
  376. const [outputFormat, setOutputFormat] = React.useState("text");
  377. const [humanApprovalPolicy, setHumanApprovalPolicy] = React.useState("never");
  378. const [submitting, setSubmitting] = React.useState(false);
  379. const currentModel = models.find((model) => model.id === selectedModelId);
  380. const modelOptions = React.useMemo(() => {
  381. return models
  382. .filter((model) => {
  383. const capabilities = model.capabilities_json ?? [];
  384. return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
  385. })
  386. .map((model) => ({
  387. value: model.id,
  388. label: `${model.name} - ${model.model_name}`,
  389. }));
  390. }, [models]);
  391. React.useEffect(() => {
  392. if (!open) return;
  393. setName(agent.name);
  394. hydrateFromConfig(activeConfig);
  395. void listModels().then((items) => {
  396. setModels(items);
  397. hydrateModelSelection(items, activeConfig);
  398. });
  399. setSkillsLoading(true);
  400. setSkillsError(null);
  401. void listSkills()
  402. .then((skills) => setAvailableSkills(Array.isArray(skills) ? skills : []))
  403. .catch((err) => {
  404. setAvailableSkills([]);
  405. setSkillsError(translateApiError(err));
  406. })
  407. .finally(() => setSkillsLoading(false));
  408. }, [open, agent, activeConfig]);
  409. function hydrateFromConfig(config?: AgentConfig) {
  410. const modelConfig = config?.model_config_json ?? {};
  411. const memoryPolicy = config?.memory_policy_json ?? {};
  412. const runtimePolicy = config?.runtime_policy_json ?? {};
  413. const retryPolicy = getRecord(runtimePolicy, "retry_policy") ?? {};
  414. setSystemPrompt(config?.system_prompt ?? "");
  415. setSelectedSkillIds((config?.skill_refs_json ?? []).flatMap((item) => {
  416. const skillId = getString(item, "skill_id");
  417. return skillId ? [skillId] : [];
  418. }));
  419. setMemoryScope(getString(memoryPolicy, "memory_scope") ?? "session");
  420. setTemperature(getConfigString(modelConfig, "temperature", "0.7"));
  421. setMaxTokens(getConfigString(modelConfig, "max_tokens", "4096"));
  422. setTimeoutSeconds(getConfigString(modelConfig, "timeout_seconds", "60"));
  423. setContextWindow(getConfigString(modelConfig, "context_window", ""));
  424. setOutputFormat(getString(modelConfig, "output_format") ?? "text");
  425. setRetryAttempts(getConfigString(retryPolicy, "max_attempts", "2"));
  426. setRetryBackoffMs(getConfigString(retryPolicy, "backoff_ms", "800"));
  427. setToolCallLimit(getConfigString(runtimePolicy, "tool_call_limit", "8"));
  428. setHumanApprovalPolicy(getString(runtimePolicy, "human_approval_policy") ?? "never");
  429. }
  430. function hydrateModelSelection(items: ModelDefinition[], config?: AgentConfig) {
  431. const modelConfig = config?.model_config_json ?? {};
  432. const modelDefinitionId = getString(modelConfig, "model_id");
  433. const providerType = getString(modelConfig, "provider");
  434. const modelName = getString(modelConfig, "model");
  435. const matchedModel = items.find((model) =>
  436. model.id === modelDefinitionId || (model.provider_type === providerType && model.model_name === modelName)
  437. );
  438. if (matchedModel) {
  439. setSelectedModelId(matchedModel.id);
  440. return;
  441. }
  442. const firstModel = items.find((model) => {
  443. const capabilities = model.capabilities_json ?? [];
  444. return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
  445. });
  446. setSelectedModelId(firstModel?.id ?? "");
  447. }
  448. function toggleSkill(skillId: string) {
  449. setSelectedSkillIds((current) =>
  450. current.includes(skillId) ? current.filter((id) => id !== skillId) : [...current, skillId]
  451. );
  452. }
  453. async function submit(event: React.FormEvent) {
  454. event.preventDefault();
  455. setSubmitting(true);
  456. try {
  457. await updateAgent(agent.id, { name: name.trim() });
  458. await createAgentConfig({
  459. agent_id: agent.id,
  460. role: "assistant",
  461. system_prompt: systemPrompt,
  462. model_config_json: {
  463. model_id: currentModel?.id ?? null,
  464. provider: currentModel?.provider_type ?? getString(activeConfig?.model_config_json ?? {}, "provider") ?? "openai",
  465. model: currentModel?.model_name ?? getString(activeConfig?.model_config_json ?? {}, "model") ?? "",
  466. temperature: parseOptionalFloat(temperature) ?? 0.7,
  467. max_tokens: parseOptionalInteger(maxTokens) ?? 4096,
  468. timeout_seconds: parseOptionalInteger(timeoutSeconds) ?? 60,
  469. context_window: parseOptionalInteger(contextWindow),
  470. output_format: outputFormat,
  471. },
  472. memory_policy_json: {
  473. memory_scope: memoryScope,
  474. },
  475. runtime_policy_json: {
  476. retry_policy: {
  477. max_attempts: parseOptionalInteger(retryAttempts) ?? 2,
  478. backoff_ms: parseOptionalInteger(retryBackoffMs) ?? 800,
  479. },
  480. tool_call_limit: parseOptionalInteger(toolCallLimit) ?? 8,
  481. human_approval_policy: humanApprovalPolicy,
  482. },
  483. tool_refs_json: activeConfig?.tool_refs_json ?? [],
  484. skill_refs_json: selectedSkillIds.map((skillId) => ({ skill_id: skillId })),
  485. });
  486. toast.success(t("agents.agentUpdated"));
  487. onOpenChange(false);
  488. onSaved();
  489. } catch {
  490. toast.error(t("agents.failedToUpdateAgent"));
  491. } finally {
  492. setSubmitting(false);
  493. }
  494. }
  495. return (
  496. <Dialog open={open} onOpenChange={onOpenChange} title={t("agents.editAgent")} className="max-w-5xl">
  497. <form className="space-y-5" onSubmit={submit}>
  498. <div className="grid gap-5 lg:grid-cols-2">
  499. <section className="space-y-4">
  500. <div className="rounded-lg border border-border bg-muted/15 p-4">
  501. <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{t("agents.basicAgent")}</p>
  502. <div className="mt-4 space-y-4">
  503. <Field label={t("common.name")} required>
  504. <Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t("agents.namePlaceholder")} />
  505. </Field>
  506. <Field label={t("agents.systemPrompt")}>
  507. <Textarea value={systemPrompt} onChange={(event) => setSystemPrompt(event.target.value)} placeholder={t("agents.systemPromptPlaceholder")} rows={8} />
  508. </Field>
  509. </div>
  510. </div>
  511. <div className="rounded-lg border border-border bg-muted/10 p-4">
  512. <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{t("agents.skills")}</p>
  513. <div className="mt-4">
  514. {skillsLoading ? (
  515. <div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-sm text-muted-foreground">
  516. {t("agents.loadingSkills")}
  517. </div>
  518. ) : skillsError ? (
  519. <div className="rounded-md border border-dashed border-red-500/30 bg-red-500/5 p-3 text-sm text-red-500">
  520. {skillsError}
  521. </div>
  522. ) : availableSkills.length > 0 ? (
  523. <div className="flex max-h-44 flex-wrap gap-2 overflow-auto pr-1">
  524. {availableSkills.map((skill) => (
  525. <button
  526. key={skill.id}
  527. type="button"
  528. onClick={() => toggleSkill(skill.id)}
  529. className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition ${
  530. selectedSkillIds.includes(skill.id)
  531. ? "border-primary bg-primary/15 text-primary"
  532. : "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60"
  533. }`}
  534. >
  535. <Sparkles className="h-3 w-3" />
  536. {skill.name}
  537. </button>
  538. ))}
  539. </div>
  540. ) : (
  541. <div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-sm text-muted-foreground">
  542. {t("agents.noSkillsYet")}
  543. </div>
  544. )}
  545. </div>
  546. </div>
  547. </section>
  548. <aside className="space-y-4 rounded-lg border border-border bg-surface p-4 lg:sticky lg:top-24 lg:max-h-[calc(100dvh-12rem)] lg:overflow-auto">
  549. <div>
  550. <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{t("agents.modelAndMemory")}</p>
  551. <p className="mt-1 text-sm text-muted-foreground">{t("agents.modelAndMemoryHint")}</p>
  552. </div>
  553. <div className="space-y-4">
  554. <Field label={t("agents.model")}>
  555. <Select value={selectedModelId} onChange={(event) => setSelectedModelId(event.target.value)} options={modelOptions} />
  556. </Field>
  557. <div className="grid gap-3 sm:grid-cols-2">
  558. <Field label={t("agents.temperature")}>
  559. <Input value={temperature} onChange={(event) => setTemperature(event.target.value)} inputMode="decimal" />
  560. </Field>
  561. <Field label={t("agents.maxTokens")}>
  562. <Input value={maxTokens} onChange={(event) => setMaxTokens(event.target.value)} inputMode="numeric" />
  563. </Field>
  564. <Field label={t("agents.timeoutSeconds")}>
  565. <Input value={timeoutSeconds} onChange={(event) => setTimeoutSeconds(event.target.value)} inputMode="numeric" />
  566. </Field>
  567. <Field label={t("agents.contextWindow")}>
  568. <Input value={contextWindow} onChange={(event) => setContextWindow(event.target.value)} inputMode="numeric" placeholder={t("agents.auto")} />
  569. </Field>
  570. </div>
  571. </div>
  572. <div className="h-px bg-border" />
  573. <div className="space-y-4">
  574. <Field label={t("agents.memoryScope")}>
  575. <Select
  576. value={memoryScope}
  577. onChange={(event) => setMemoryScope(event.target.value)}
  578. options={[
  579. { value: "session", label: t("agents.memoryScopeSession") },
  580. { value: "persistent", label: t("agents.memoryScopePersistent") },
  581. ]}
  582. />
  583. </Field>
  584. </div>
  585. </aside>
  586. </div>
  587. <div className="rounded-lg border border-border bg-muted/10 p-4">
  588. <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{t("agents.executionPolicy")}</p>
  589. <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
  590. <Field label={t("agents.retryAttempts")}>
  591. <Input value={retryAttempts} onChange={(event) => setRetryAttempts(event.target.value)} inputMode="numeric" />
  592. </Field>
  593. <Field label={t("agents.retryBackoffMs")}>
  594. <Input value={retryBackoffMs} onChange={(event) => setRetryBackoffMs(event.target.value)} inputMode="numeric" />
  595. </Field>
  596. <Field label={t("agents.toolCallLimit")}>
  597. <Input value={toolCallLimit} onChange={(event) => setToolCallLimit(event.target.value)} inputMode="numeric" />
  598. </Field>
  599. <Field label={t("agents.outputFormat")}>
  600. <Select
  601. value={outputFormat}
  602. onChange={(event) => setOutputFormat(event.target.value)}
  603. options={[
  604. { value: "text", label: t("agents.outputText") },
  605. { value: "json", label: t("common.format.json") },
  606. { value: "markdown", label: t("common.format.markdown") },
  607. ]}
  608. />
  609. </Field>
  610. <Field label={t("agents.humanApproval")}>
  611. <Select
  612. value={humanApprovalPolicy}
  613. onChange={(event) => setHumanApprovalPolicy(event.target.value)}
  614. options={[
  615. { value: "never", label: t("agents.approvalNever") },
  616. { value: "sensitive_actions", label: t("agents.approvalSensitiveActions") },
  617. { value: "before_final", label: t("agents.approvalBeforeFinal") },
  618. { value: "always", label: t("agents.approvalAlways") },
  619. ]}
  620. />
  621. </Field>
  622. </div>
  623. </div>
  624. <div className="flex justify-end gap-2 border-t border-border pt-4">
  625. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
  626. {t("common.cancel")}
  627. </Button>
  628. <Button disabled={submitting || !name.trim()}>{submitting ? t("common.saving") : t("common.save")}</Button>
  629. </div>
  630. </form>
  631. </Dialog>
  632. );
  633. }
  634. function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
  635. return (
  636. <label className="block space-y-2 text-sm">
  637. <span className="text-muted-foreground">
  638. {label}
  639. {required && <span className="text-red-500 ml-0.5">*</span>}
  640. </span>
  641. {children}
  642. </label>
  643. );
  644. }
  645. function parseOptionalInteger(value: string) {
  646. if (!value.trim()) return null;
  647. const parsed = Number.parseInt(value, 10);
  648. return Number.isFinite(parsed) ? parsed : null;
  649. }
  650. function parseOptionalFloat(value: string) {
  651. if (!value.trim()) return null;
  652. const parsed = Number.parseFloat(value);
  653. return Number.isFinite(parsed) ? parsed : null;
  654. }
  655. function getConfigString(value: JSONObject, key: string, fallback: string) {
  656. const item = value[key];
  657. if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return String(item);
  658. return fallback;
  659. }
  660. function getString(value: JSONObject, key: string) {
  661. const item = value[key];
  662. return typeof item === "string" ? item : undefined;
  663. }
  664. function getRecord(value: JSONObject, key: string): JSONObject | undefined {
  665. const item = value[key];
  666. return item && typeof item === "object" && !Array.isArray(item) ? item as JSONObject : undefined;
  667. }