Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/FoundryCodexAgentSettings.tsx
T

203 lines
9.2 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { Button, Icon, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
interface FoundryAgentDevice {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
lastCheckAt: string | null;
revokedAt: string | null;
}
interface FoundryAgent {
id: string;
name: string;
avatarUrl: string | null;
status: "active" | "revoked";
devices: FoundryAgentDevice[];
deviceCount: number;
lastUsedAt: string | null;
lastCheckAt: string | null;
createdAt: string;
updatedAt: string;
}
interface SetupCommand {
command: string;
expiresAt: string;
}
async function api<T>(pathname: string, init?: RequestInit): Promise<T> {
const response = await fetch(pathname, {
cache: "no-store",
...init,
headers: {
accept: "application/json",
...(init?.body ? { "content-type": "application/json" } : {}),
...init?.headers,
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.ok === false) throw new Error(payload?.error || `foundry_agent_api_${response.status}`);
return payload as T;
}
function formatMoment(value: string | null) {
if (!value) return "—";
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleString("ru-RU") : value;
}
export function FoundryCodexAgentSettings() {
const [agents, setAgents] = useState<FoundryAgent[]>([]);
const [newAgentName, setNewAgentName] = useState("Foundry Codex");
const [draftNames, setDraftNames] = useState<Record<string, string>>({});
const [setupByAgent, setSetupByAgent] = useState<Record<string, SetupCommand>>({});
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const payload = await api<{ agents: FoundryAgent[] }>("/api/foundry-agent-api/agents");
setAgents(payload.agents);
setDraftNames(Object.fromEntries(payload.agents.map((agent) => [agent.id, agent.name])));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "foundry_agent_load_failed");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const run = async (key: string, action: () => Promise<void>) => {
setPending(key);
setError("");
setMessage("");
try {
await action();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : "foundry_agent_operation_failed");
} finally {
setPending("");
}
};
const createAgent = () => run("create", async () => {
const payload = await api<{ agent: FoundryAgent }>("/api/foundry-agent-api/agents", {
method: "POST",
body: JSON.stringify({ name: newAgentName.trim() || "Foundry Codex" }),
});
setAgents((current) => [payload.agent, ...current]);
setDraftNames((current) => ({ ...current, [payload.agent.id]: payload.agent.name }));
setMessage("Foundry Agent создан. Теперь можно получить одноразовую setup-команду.");
});
const saveAgent = (agent: FoundryAgent) => run(`save:${agent.id}`, async () => {
const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}`, {
method: "PATCH",
body: JSON.stringify({ name: draftNames[agent.id]?.trim() || agent.name }),
});
setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item));
setMessage("Имя агента сохранено.");
});
const revokeAgent = (agent: FoundryAgent) => run(`revoke:${agent.id}`, async () => {
const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/revoke`, {
method: "POST",
body: "{}",
});
setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item));
setSetupByAgent((current) => {
const next = { ...current };
delete next[agent.id];
return next;
});
setMessage("Agent отозван. Foundry и Ontology credentials этого агента больше не принимаются.");
});
const issueSetup = (agent: FoundryAgent) => run(`setup:${agent.id}`, async () => {
const payload = await api<{ install: SetupCommand }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/setup-code`, {
method: "POST",
body: "{}",
});
setSetupByAgent((current) => ({ ...current, [agent.id]: payload.install }));
setMessage("Одноразовая команда выпущена. Она установит Foundry MCP и отдельный read-only Ontology MCP.");
});
const copySetup = async (agentId: string) => {
const command = setupByAgent[agentId]?.command;
if (!command) return;
await navigator.clipboard.writeText(command);
setMessage("Setup-команда скопирована.");
};
return (
<div className="catalog-codex-agent-settings">
<header className="catalog-codex-agent-settings__header">
<div><span>NODE.DC / SOURCE-FREE DEVELOPMENT</span><h2>Codex Agent API</h2></div>
<StatusBadge tone="success">Foundry + Ontology</StatusBadge>
</header>
<p className="catalog-codex-agent-settings__lead">
Одна setup-команда добавляет в Codex два независимых MCP: полный текущий Foundry contour и отдельную read-only Ontology. Sharing, роли и межпользовательская видимость в этот срез не входят.
</p>
{error ? <div className="catalog-codex-agent-settings__message" data-tone="error">{error}</div> : null}
{message ? <div className="catalog-codex-agent-settings__message" data-tone="success">{message}</div> : null}
<SettingsCard eyebrow="NEW AGENT" title="Подключить Codex" description="Agent принадлежит текущему пользователю Foundry. Credentials долговечны до явного revoke.">
<div className="catalog-codex-agent-settings__create">
<TextField label="Имя агента" value={newAgentName} onChange={(event) => setNewAgentName(event.target.value)} />
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} disabled={pending === "create"} onClick={() => { void createAgent(); }}>
{pending === "create" ? "Создание…" : "Создать агента"}
</Button>
</div>
</SettingsCard>
<div className="catalog-codex-agent-settings__agents">
{loading ? <span className="catalog-codex-agent-settings__empty">Загружаю agents</span> : null}
{!loading && agents.length === 0 ? <span className="catalog-codex-agent-settings__empty">Агенты ещё не созданы.</span> : null}
{agents.map((agent) => {
const setup = setupByAgent[agent.id];
const active = agent.status === "active";
return (
<SettingsCard
key={agent.id}
eyebrow={`AGENT · ${agent.deviceCount} DEVICE`}
title={agent.name}
description={`Последнее использование: ${formatMoment(agent.lastUsedAt)} · doctor: ${formatMoment(agent.lastCheckAt)}`}
actions={<StatusBadge tone={active ? "success" : "danger"}>{active ? "Активен" : "Отозван"}</StatusBadge>}
>
<div className="catalog-codex-agent-settings__agent-grid">
<TextField
label="Имя"
value={draftNames[agent.id] ?? agent.name}
disabled={!active}
onChange={(event) => setDraftNames((current) => ({ ...current, [agent.id]: event.target.value }))}
/>
<div className="catalog-codex-agent-settings__actions">
<Button disabled={!active || pending === `save:${agent.id}`} onClick={() => { void saveAgent(agent); }}>Сохранить</Button>
<Button variant="primary" shape="pill" disabled={!active || pending === `setup:${agent.id}`} onClick={() => { void issueSetup(agent); }}>Получить команду</Button>
<Button variant="danger" disabled={!active || pending === `revoke:${agent.id}`} onClick={() => { void revokeAgent(agent); }}>Revoke</Button>
</div>
</div>
{setup ? (
<div className="catalog-codex-agent-settings__setup">
<div><strong>Одноразовая setup-команда</strong><small>Действует до {formatMoment(setup.expiresAt)}. После выполнения полностью перезапустите Codex Desktop.</small></div>
<code>{setup.command}</code>
<Button icon={<Icon name="copy" />} onClick={() => { void copySetup(agent.id); }}>Копировать</Button>
</div>
) : null}
</SettingsCard>
);
})}
</div>
</div>
);
}