feat(foundry): add managed data consumers and agent settings
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user