feat: add Ops Codex terminal setup UI
This commit is contained in:
+180
-31
@@ -6,7 +6,7 @@
|
||||
|
||||
import { type ChangeEvent, useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck } from "lucide-react";
|
||||
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck, Terminal } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type TCodexAgent,
|
||||
type TCodexAgentGrant,
|
||||
type TCodexAgentGrantableWorkspace,
|
||||
type TCodexAgentSetupCode,
|
||||
type TCodexAgentSetupPacket,
|
||||
type TCodexAgentToken,
|
||||
} from "@/services/workspace-codex-agent.service";
|
||||
@@ -59,7 +60,9 @@ type TProps = {
|
||||
type TAgentSetupCard = {
|
||||
agent: TCodexAgent;
|
||||
grants: TCodexAgentGrant[];
|
||||
installCommand?: string;
|
||||
setup?: TCodexAgentSetupPacket;
|
||||
setupCode?: TCodexAgentSetupCode;
|
||||
tokens: TCodexAgentToken[];
|
||||
};
|
||||
|
||||
@@ -84,6 +87,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
|
||||
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
|
||||
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
|
||||
const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState<Record<string, boolean>>({});
|
||||
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
|
||||
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
|
||||
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
|
||||
@@ -221,19 +225,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
mode: "voluntary",
|
||||
}
|
||||
);
|
||||
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
|
||||
name: `${displayName} local token`,
|
||||
});
|
||||
setRevealedTokens((currentTokens) => ({
|
||||
...currentTokens,
|
||||
[tokenResponse.token_record.id]: tokenResponse.token,
|
||||
}));
|
||||
const [setupResponse, setupCodeResponse] = await Promise.all([
|
||||
codexAgentService.getSetup(workspaceSlug, createResponse.agent.id),
|
||||
codexAgentService.createSetupCode(workspaceSlug, createResponse.agent.id, {
|
||||
expires_in_seconds: 600,
|
||||
token_name: `${displayName} local token`,
|
||||
}),
|
||||
]);
|
||||
setCreatedSetupCards((currentCards) =>
|
||||
upsertSetupCardToken(
|
||||
upsertSetupCardSetupCode(
|
||||
currentCards,
|
||||
createResponse.agent,
|
||||
tokenResponse.token_record,
|
||||
tokenResponse.setup,
|
||||
setupCodeResponse.setup_code_record,
|
||||
setupCodeResponse.install.command,
|
||||
setupResponse.setup,
|
||||
grantsResponse.grants
|
||||
)
|
||||
);
|
||||
@@ -242,7 +247,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Codex agent создан",
|
||||
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
|
||||
message:
|
||||
"Скопируйте terminal-команду в локальный терминал. Setup-код действует 10 минут и используется один раз.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
@@ -255,6 +261,40 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSetupCode = async (agent: TCodexAgent) => {
|
||||
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: true }));
|
||||
try {
|
||||
const setupCodeResponse = await codexAgentService.createSetupCode(workspaceSlug, agent.id, {
|
||||
expires_in_seconds: 600,
|
||||
token_name: `${agent.display_name} local token`,
|
||||
});
|
||||
const currentCard = setupCards.find((card) => card.agent.id === agent.id);
|
||||
setCreatedSetupCards((currentCards) =>
|
||||
upsertSetupCardSetupCode(
|
||||
currentCards,
|
||||
agent,
|
||||
setupCodeResponse.setup_code_record,
|
||||
setupCodeResponse.install.command,
|
||||
currentCard?.setup,
|
||||
currentCard?.grants ?? []
|
||||
)
|
||||
);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Terminal-команда выпущена",
|
||||
message: "Setup-код действует 10 минут и используется один раз.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось выпустить terminal-команду",
|
||||
message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
|
||||
});
|
||||
} finally {
|
||||
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateToken = async (agent: TCodexAgent) => {
|
||||
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
|
||||
try {
|
||||
@@ -435,7 +475,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
<CapabilityCard
|
||||
icon={KeyRound}
|
||||
title="Локальный Codex"
|
||||
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
|
||||
description="Пользовательский Codex подключается по MCP endpoint через одноразовый setup-code или legacy agent token."
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -445,7 +485,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
|
||||
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
||||
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
|
||||
Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар
|
||||
меняется кликом по кругу.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -514,11 +555,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
activeAgents.map((agent) => {
|
||||
const draftName = getAgentDraftName(agentDraftNames, agent);
|
||||
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
|
||||
const isCreatingSetupCode = creatingSetupCodeAgentIds[agent.id] === true;
|
||||
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
|
||||
const isAgentDirty = draftName.trim() !== agent.display_name;
|
||||
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
|
||||
const agentTokens = setupCard?.tokens ?? [];
|
||||
const agentGrants = setupCard?.grants ?? [];
|
||||
const installCommand = setupCard?.installCommand;
|
||||
const setupCode = setupCard?.setupCode;
|
||||
const currentGrantKeys = getGrantedProjectKeys(agentGrants);
|
||||
const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
|
||||
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
|
||||
@@ -584,15 +628,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="nodedc-settings-chip h-12"
|
||||
loading={isCreatingToken}
|
||||
onClick={() => void handleCreateToken(agent)}
|
||||
>
|
||||
Новый токен
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -604,8 +639,18 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentTerminalSetupPanel
|
||||
command={installCommand}
|
||||
isCreating={isCreatingSetupCode}
|
||||
setupCode={setupCode}
|
||||
onCopyCommand={() => installCommand && void handleCopy(installCommand, "Terminal-команда")}
|
||||
onCreate={() => void handleCreateSetupCode(agent)}
|
||||
/>
|
||||
|
||||
{areSetupCardsLoading && agentTokens.length === 0 ? (
|
||||
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">Загрузка токена...</div>
|
||||
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
|
||||
Загрузка legacy-токенов...
|
||||
</div>
|
||||
) : agentTokens.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{agentTokens.map((token) => {
|
||||
@@ -617,7 +662,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
return (
|
||||
<div key={token.id} className="nodedc-settings-field p-4">
|
||||
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
||||
Agent token
|
||||
Legacy agent token
|
||||
</div>
|
||||
<div className="relative">
|
||||
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
|
||||
@@ -647,8 +692,19 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
|
||||
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
|
||||
<div className="nodedc-settings-field flex flex-col gap-3 px-4 py-4 text-13 text-secondary sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
Legacy-токен ещё не выпущен. Используйте его только для ручной настройки config.toml.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="nodedc-settings-save-button shrink-0"
|
||||
loading={isCreatingToken}
|
||||
onClick={() => void handleCreateToken(agent)}
|
||||
>
|
||||
Сгенерировать токен
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -704,12 +760,74 @@ type TCodexConnectionGuideProps = {
|
||||
onCopyConfig: () => void;
|
||||
};
|
||||
|
||||
type TAgentTerminalSetupPanelProps = {
|
||||
command?: string;
|
||||
isCreating: boolean;
|
||||
onCopyCommand: () => void;
|
||||
onCreate: () => void;
|
||||
setupCode?: TCodexAgentSetupCode;
|
||||
};
|
||||
|
||||
function AgentTerminalSetupPanel(props: TAgentTerminalSetupPanelProps) {
|
||||
const expiresAtLabel = props.setupCode?.expires_at ? new Date(props.setupCode.expires_at).toLocaleString() : null;
|
||||
|
||||
return (
|
||||
<div className="nodedc-settings-field p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
|
||||
<Terminal className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-13 font-semibold text-primary">Recommended terminal setup</div>
|
||||
<div className="mt-1 text-12 text-tertiary">
|
||||
Одноразовая npm-команда для локального терминала: создаст token, обновит Codex config и установит
|
||||
ops-context skill.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={props.command ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
className={props.command ? "nodedc-settings-chip" : "nodedc-settings-save-button"}
|
||||
loading={props.isCreating}
|
||||
onClick={props.onCreate}
|
||||
>
|
||||
{props.command ? "Обновить команду" : "Получить команду"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.command ? (
|
||||
<div className="mt-4 grid gap-3">
|
||||
<pre className="nodedc-settings-input font-mono max-h-40 w-full overflow-auto px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
|
||||
{props.command}
|
||||
</pre>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-12 text-tertiary">
|
||||
{expiresAtLabel ? `Действует до ${expiresAtLabel}. ` : ""}
|
||||
Команда не показывает agent token, использует npm package installer и сработает только один раз.
|
||||
</div>
|
||||
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyCommand}>
|
||||
<Copy className="mr-2 size-3.5" />
|
||||
Скопировать команду
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl bg-white/[0.03] px-4 py-3 text-12 leading-5 text-tertiary">
|
||||
Нажмите «Получить команду» и вставьте ее в терминал на машине, где запущен локальный Codex.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
|
||||
return (
|
||||
<div className="grid gap-4 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
|
||||
<div className="text-14 font-semibold text-primary">Legacy manual setup</div>
|
||||
</div>
|
||||
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
|
||||
MCP endpoint · {props.mcpEndpoint}
|
||||
@@ -763,7 +881,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
|
||||
<p className="mb-3 text-13 leading-5 text-secondary">
|
||||
Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
|
||||
<code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть — замените только этот блок и его{" "}
|
||||
<code>headers</code>.
|
||||
<code>http_headers</code>.
|
||||
</p>
|
||||
<pre className="nodedc-settings-input font-mono w-full px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
|
||||
{props.configSnippet}
|
||||
@@ -790,7 +908,7 @@ required = false
|
||||
startup_timeout_sec = 20
|
||||
tool_timeout_sec = 60
|
||||
|
||||
[mcp_servers.${CODEX_MCP_SERVER_NAME}.headers]
|
||||
[mcp_servers.${CODEX_MCP_SERVER_NAME}.http_headers]
|
||||
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
|
||||
Accept = "application/json"
|
||||
"MCP-Protocol-Version" = "2025-06-18"`;
|
||||
@@ -994,12 +1112,14 @@ function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgent
|
||||
cardsByAgentId.set(card.agent.id, {
|
||||
agent: persistedCard.agent,
|
||||
grants: mergeAgentGrants(persistedCard.grants, card.grants),
|
||||
installCommand: card.installCommand ?? persistedCard.installCommand,
|
||||
setup: persistedCard.setup ?? card.setup,
|
||||
setupCode: card.setupCode ?? persistedCard.setupCode,
|
||||
tokens: mergeTokens(persistedCard.tokens, card.tokens),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
|
||||
return Array.from(cardsByAgentId.values());
|
||||
}
|
||||
|
||||
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
|
||||
@@ -1029,13 +1149,42 @@ function upsertSetupCardToken(
|
||||
? {
|
||||
agent,
|
||||
grants: mergeAgentGrants(card.grants, grants),
|
||||
installCommand: card.installCommand,
|
||||
setup: setup ?? card.setup,
|
||||
setupCode: card.setupCode,
|
||||
tokens: mergeTokens([token], card.tokens),
|
||||
}
|
||||
: card
|
||||
);
|
||||
}
|
||||
|
||||
function upsertSetupCardSetupCode(
|
||||
cards: TAgentSetupCard[],
|
||||
agent: TCodexAgent,
|
||||
setupCode: TCodexAgentSetupCode,
|
||||
installCommand: string,
|
||||
setup?: TCodexAgentSetupPacket,
|
||||
grants: TCodexAgentGrant[] = []
|
||||
): TAgentSetupCard[] {
|
||||
const existingCard = cards.find((card) => card.agent.id === agent.id);
|
||||
if (!existingCard) {
|
||||
return [{ agent, grants, installCommand, setup, setupCode, tokens: [] }, ...cards];
|
||||
}
|
||||
|
||||
return cards.map((card) =>
|
||||
card.agent.id === agent.id
|
||||
? {
|
||||
agent,
|
||||
grants: mergeAgentGrants(card.grants, grants),
|
||||
installCommand,
|
||||
setup: setup ?? card.setup,
|
||||
setupCode,
|
||||
tokens: card.tokens,
|
||||
}
|
||||
: card
|
||||
);
|
||||
}
|
||||
|
||||
function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
|
||||
return workspaceGroups.flatMap((workspaceGroup) =>
|
||||
workspaceGroup.projects.map((project) => ({
|
||||
|
||||
Reference in New Issue
Block a user