feat(foundry): add managed data consumers and agent settings

This commit is contained in:
Codex
2026-07-19 15:03:54 +03:00
parent 5f583caa05
commit aac44d057f
35 changed files with 4534 additions and 243 deletions
+14 -11
View File
@@ -18,10 +18,8 @@ import {
FieldFrame,
GlassSurface,
GlassMaterialSurface,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
HeaderWorkspace,
Icon,
IconButton,
@@ -40,6 +38,7 @@ import {
TextAreaField,
TextField,
Toolbar,
UserProfileMenu,
useApplicationWorkspace,
Window,
WindowFooterActions,
@@ -62,6 +61,7 @@ import {
type DesignProfileStatus,
} from "./applicationManifest.js";
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
type StudioContext = "visual" | "pages" | "applications";
@@ -333,6 +333,7 @@ export function CatalogApp() {
const [applicationError, setApplicationError] = useState("");
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
const [cesiumIonToken, setCesiumIonToken] = useState("");
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
@@ -1815,15 +1816,15 @@ export function CatalogApp() {
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
<HeaderProfileButton
title={sessionProfile?.user?.displayName || "Профиль NODE.DC"}
onClick={() => { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }}
>
{sessionProfile?.user?.displayName || "Профиль"}
</HeaderProfileButton>
<HeaderAvatar
label={sessionProfile?.user?.displayName || sessionProfile?.user?.initials || "DC"}
imageUrl={sessionProfile?.user?.avatarUrl || undefined}
<UserProfileMenu
displayName={sessionProfile?.user?.displayName || "Профиль"}
subtitle={sessionProfile?.user?.email || "NODE.DC Foundry"}
avatarUrl={sessionProfile?.user?.avatarUrl || undefined}
actions={[
{ id: "profile", label: "Профиль", icon: "profile", href: sessionProfile?.profileUrl || undefined, disabled: !sessionProfile?.profileUrl },
{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setFoundrySettingsOpen(true) },
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
]}
/>
</HeaderProfile>
}
@@ -2144,6 +2145,8 @@ export function CatalogApp() {
</div>
</Window>
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
<Window
open={createModalOpen}
title="Создать проект"
+4
View File
@@ -448,6 +448,10 @@ function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
return Color.fromCssColorString("#7d8491");
}
return fact.semanticType === "map.moving_object" ? accent : violet;
}
@@ -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>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { FeatureSettingsWindow } from "@nodedc/ui-react";
import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js";
interface FoundrySettingsModalProps {
open: boolean;
onClose: () => void;
}
export function FoundrySettingsModal({ open, onClose }: FoundrySettingsModalProps) {
return (
<FeatureSettingsWindow
open={open}
title="Настройки Foundry"
subtitle="NODE DC / Codex Agent API"
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]}
activeSection="codex-agent-api"
onSectionChange={() => undefined}
onClose={onClose}
>
<FoundryCodexAgentSettings />
</FeatureSettingsWindow>
);
}
+1 -1
View File
@@ -270,7 +270,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const attributes = fact.attributes;
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
.find((value) => typeof value === "string" && value.trim());
const status = typeof attributes.status === "string" ? attributes.status : undefined;
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,
+122 -1
View File
@@ -1073,6 +1073,120 @@ textarea {
gap: 0.75rem;
}
.catalog-codex-agent-settings {
display: grid;
gap: 1rem;
}
.catalog-codex-agent-settings__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.catalog-codex-agent-settings__header > div {
display: grid;
gap: 0.28rem;
}
.catalog-codex-agent-settings__header span,
.catalog-codex-agent-settings__header h2,
.catalog-codex-agent-settings__lead {
margin: 0;
}
.catalog-codex-agent-settings__header > div > span {
color: var(--nodedc-text-muted);
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0.1em;
}
.catalog-codex-agent-settings__header h2 {
font-size: clamp(1.45rem, 2.2vw, 2rem);
}
.catalog-codex-agent-settings__lead {
max-width: 58rem;
color: var(--nodedc-text-secondary);
font-size: 0.88rem;
line-height: 1.6;
}
.catalog-codex-agent-settings__message {
border-radius: var(--nodedc-radius-control-compact);
background: rgb(var(--nodedc-success-rgb) / 0.12);
color: color-mix(in srgb, rgb(var(--nodedc-success-rgb)) 80%, var(--nodedc-text-primary));
padding: 0.75rem 0.9rem;
font-size: var(--nodedc-font-size-sm);
font-weight: 700;
}
.catalog-codex-agent-settings__message[data-tone="error"] {
background: rgb(var(--nodedc-danger-rgb) / 0.12);
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 80%, var(--nodedc-text-primary));
}
.catalog-codex-agent-settings__create,
.catalog-codex-agent-settings__agent-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.8rem;
}
.catalog-codex-agent-settings__agents {
display: grid;
gap: 0.8rem;
}
.catalog-codex-agent-settings__empty {
color: var(--nodedc-text-muted);
padding: 1rem;
text-align: center;
}
.catalog-codex-agent-settings__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
}
.catalog-codex-agent-settings__setup {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem;
margin-top: 0.9rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-nested-bg);
padding: 0.85rem;
}
.catalog-codex-agent-settings__setup > div {
display: grid;
gap: 0.2rem;
}
.catalog-codex-agent-settings__setup small {
color: var(--nodedc-text-muted);
line-height: 1.4;
}
.catalog-codex-agent-settings__setup code {
grid-column: 1 / -1;
overflow: auto;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-field-bg);
color: var(--nodedc-text-primary);
padding: 0.8rem;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.catalog-modal-groups {
display: grid;
gap: 1.25rem;
@@ -1363,10 +1477,17 @@ textarea {
}
.catalog-application-draft__fields,
.catalog-application-draft__features {
.catalog-application-draft__features,
.catalog-codex-agent-settings__create,
.catalog-codex-agent-settings__agent-grid,
.catalog-codex-agent-settings__setup {
grid-template-columns: 1fr;
}
.catalog-codex-agent-settings__actions {
justify-content: flex-start;
}
.catalog-application-draft__fields .nodedc-field-frame:last-child {
grid-column: auto;
}
+79 -9
View File
@@ -17,6 +17,7 @@ export type MapRuntimeFact = {
receivedAt: string;
attributes: Record<string, unknown>;
geometry: DataProductPoint | null;
presentationStatus: string;
};
export type MapRuntimeBinding = {
@@ -42,7 +43,16 @@ type PatchEnvelope = {
cursor: string;
previousCursor: string;
emittedAt: string;
operations: Array<{ op: "upsert"; fact: MapRuntimeFact }>;
operations: Array<
| { op: "upsert"; fact: MapRuntimeFact }
| { op: "remove"; sourceId: string; semanticType: string; removedAt: string; reason: "tombstone" | "revoked" }
>;
};
type PresentationPatchEnvelope = {
schemaVersion: "nodedc.foundry.presentation-patch/v1";
generatedAt: string;
operations: Array<{ op: "upsert"; sourceId: string; semanticType: string; status: string }>;
};
type BindingState = {
@@ -97,6 +107,9 @@ function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact
receivedAt: candidate.receivedAt,
attributes,
geometry: asPoint(candidate.geometry),
presentationStatus: typeof candidate.presentationStatus === "string" && /^[a-z0-9_-]{1,64}$/.test(candidate.presentationStatus)
? candidate.presentationStatus
: "active",
};
}
@@ -121,13 +134,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
const product = candidate.dataProduct;
if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !cursorPattern.test(String(candidate.previousCursor || "")) || !isIsoTimestamp(candidate.emittedAt) || !Array.isArray(candidate.operations)) return null;
const operations = candidate.operations.flatMap((operation) => {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
const value = operation as { op?: unknown; fact?: unknown };
if (value.op !== "upsert") return [];
const fact = asFact(value.fact, binding);
return fact ? [{ op: "upsert" as const, fact }] : [];
});
const operations: PatchEnvelope["operations"] = [];
for (const operation of candidate.operations) {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue;
const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown; removedAt?: unknown; reason?: unknown };
if (value.op === "upsert") {
const fact = asFact(value.fact, binding);
if (fact) operations.push({ op: "upsert", fact });
continue;
}
if (
value.op === "remove"
&& typeof value.sourceId === "string"
&& identifier.test(value.sourceId)
&& typeof value.semanticType === "string"
&& binding.semanticTypes.includes(value.semanticType)
&& isIsoTimestamp(value.removedAt)
&& (value.reason === "tombstone" || value.reason === "revoked")
) {
operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType, removedAt: value.removedAt, reason: value.reason });
}
}
return {
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
@@ -138,6 +165,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
};
}
function asPresentationPatch(value: unknown, binding: MapDataProductBinding): PresentationPatchEnvelope | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
if (candidate.schemaVersion !== "nodedc.foundry.presentation-patch/v1" || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.operations)) return null;
const operations = candidate.operations.flatMap((operation) => {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
const item = operation as { op?: unknown; sourceId?: unknown; semanticType?: unknown; status?: unknown };
if (
item.op !== "upsert"
|| typeof item.sourceId !== "string"
|| !identifier.test(item.sourceId)
|| typeof item.semanticType !== "string"
|| !binding.semanticTypes.includes(item.semanticType)
|| typeof item.status !== "string"
|| !/^[a-z0-9_-]{1,64}$/.test(item.status)
) return [];
return [{ op: "upsert" as const, sourceId: item.sourceId, semanticType: item.semanticType, status: item.status }];
});
return { schemaVersion: "nodedc.foundry.presentation-patch/v1", generatedAt: candidate.generatedAt, operations };
}
function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
return {
...current,
@@ -153,10 +201,22 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
// browser applies more updates.
if (current.cursor !== patch.previousCursor) return null;
const facts = { ...current.facts };
for (const operation of patch.operations) facts[factKey(operation.fact)] = operation.fact;
for (const operation of patch.operations) {
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
}
return { ...current, facts, cursor: patch.cursor, state: "ready" };
}
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
const facts = { ...current.facts };
for (const operation of patch.operations) {
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
}
return { ...current, facts };
}
function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
return { binding, cursor: null, facts: {}, state };
}
@@ -263,6 +323,16 @@ export function useMapDataProductRuntime({
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
void loadSnapshotAndStream(binding);
});
source.addEventListener("nodedc.foundry.presentation-patch.v1", (event) => {
let patch: PresentationPatchEnvelope | null = null;
try {
patch = asPresentationPatch(JSON.parse((event as MessageEvent<string>).data), binding);
} catch {
return;
}
if (!patch) return;
update(binding, (current) => applyPresentationPatch(current, patch));
});
source.onerror = () => {
// EventSource will retry transient transport failures itself. A
// permanent upstream rejection closes the stream; loading a fresh