203 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|