NODEDC_DESIGN_GUIDELINE/apps/catalog/src/FoundryCodexAgentSettings.tsx

203 lines
9.2 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.

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>
);
}