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