refactor: split ops ai workspace settings modal
This commit is contained in:
parent
097012ae31
commit
342df60c24
|
|
@ -4,101 +4,31 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { FormEvent, MouseEvent } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MessageCircle } from "lucide-react";
|
import { MessageCircle } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
// components
|
||||||
|
import { AIWorkspaceProductSettingsModal } from "@/components/ai-workspace/product-settings-modal";
|
||||||
// services
|
// services
|
||||||
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
|
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
|
||||||
import type {
|
import type {
|
||||||
TAIWorkspaceExecutor,
|
TAIWorkspaceExecutor,
|
||||||
TAIWorkspaceExecutorConnectionMode,
|
|
||||||
TAIWorkspaceExecutorInput,
|
TAIWorkspaceExecutorInput,
|
||||||
|
TAIWorkspaceExecutorListResponse,
|
||||||
} from "@/services/workspace-ai-workspace.service";
|
} from "@/services/workspace-ai-workspace.service";
|
||||||
|
|
||||||
const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService();
|
const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService();
|
||||||
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
|
|
||||||
const capabilityOptions = [
|
|
||||||
{ value: "chat", label: "Chat" },
|
|
||||||
{ value: "code", label: "Code" },
|
|
||||||
{ value: "engine", label: "Engine" },
|
|
||||||
{ value: "ops", label: "Ops" },
|
|
||||||
{ value: "mcp", label: "MCP" },
|
|
||||||
{ value: "workflows", label: "Workflows" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type TAIWorkspaceGlobalControlProps = {
|
type TAIWorkspaceGlobalControlProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TExecutorDraft = {
|
|
||||||
name: string;
|
|
||||||
connectionMode: TAIWorkspaceExecutorConnectionMode;
|
|
||||||
endpoint: string;
|
|
||||||
workspacePath: string;
|
|
||||||
agentPort: string;
|
|
||||||
accountLabel: string;
|
|
||||||
capabilities: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function emptyDraft(): TExecutorDraft {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
connectionMode: "hub",
|
|
||||||
endpoint: "",
|
|
||||||
workspacePath: "",
|
|
||||||
agentPort: DEFAULT_WINDOWS_AGENT_PORT,
|
|
||||||
accountLabel: "",
|
|
||||||
capabilities: ["ops"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): TExecutorDraft {
|
|
||||||
if (!executor) return emptyDraft();
|
|
||||||
return {
|
|
||||||
name: executor.name || "",
|
|
||||||
connectionMode: executor.connectionMode === "direct" ? "direct" : "hub",
|
|
||||||
endpoint: executor.endpoint || "",
|
|
||||||
workspacePath: executor.workspacePath || "",
|
|
||||||
agentPort: String(executor.agentPort || DEFAULT_WINDOWS_AGENT_PORT),
|
|
||||||
accountLabel: executor.accountLabel || "",
|
|
||||||
capabilities:
|
|
||||||
Array.isArray(executor.capabilities) && executor.capabilities.length ? executor.capabilities : ["ops"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
|
|
||||||
const port = Number(draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
|
||||||
return {
|
|
||||||
name: draft.name.trim(),
|
|
||||||
type: "codex-remote",
|
|
||||||
connectionMode: draft.connectionMode,
|
|
||||||
endpoint: draft.connectionMode === "direct" ? draft.endpoint.trim() || null : null,
|
|
||||||
workspacePath: draft.workspacePath.trim() || null,
|
|
||||||
agentPort: Number.isInteger(port) ? port : Number(DEFAULT_WINDOWS_AGENT_PORT),
|
|
||||||
accountLabel: draft.accountLabel.trim() || null,
|
|
||||||
capabilities: Array.from(new Set(["ops", ...draft.capabilities])).filter(Boolean),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string | null | undefined) {
|
|
||||||
if (status === "online") return "Online";
|
|
||||||
if (status === "offline") return "Offline";
|
|
||||||
if (status === "checking") return "Checking";
|
|
||||||
if (status === "error") return "Error";
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
|
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
||||||
const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context");
|
|
||||||
const [editingId, setEditingId] = useState<string>("");
|
|
||||||
const [draft, setDraft] = useState<TExecutorDraft>(() => emptyDraft());
|
|
||||||
const [busyId, setBusyId] = useState("");
|
const [busyId, setBusyId] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
@ -128,163 +58,97 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const executors = useMemo(() => data?.executors ?? [], [data?.executors]);
|
|
||||||
const activeExecutor = useMemo(
|
|
||||||
() => executors.find((executor) => executor.id === data?.selectedExecutorId) ?? executors[0] ?? null,
|
|
||||||
[data?.selectedExecutorId, executors]
|
|
||||||
);
|
|
||||||
const editingExecutor = useMemo(
|
|
||||||
() => executors.find((executor) => executor.id === editingId) ?? activeExecutor,
|
|
||||||
[activeExecutor, editingId, executors]
|
|
||||||
);
|
|
||||||
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
|
|
||||||
const isBusy = Boolean(busyId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || detailsMode === "new") return;
|
|
||||||
setEditingId(activeExecutor?.id || "");
|
|
||||||
setDraft(draftFromExecutor(activeExecutor));
|
|
||||||
}, [activeExecutor, detailsMode, isOpen]);
|
|
||||||
|
|
||||||
const handleOpen = useCallback(() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setDetailsMode("context");
|
|
||||||
setNotice("");
|
setNotice("");
|
||||||
setActionError("");
|
setActionError("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runMutation = useCallback(async (id: string, action: () => Promise<unknown>, successText: string) => {
|
const runMutation = useCallback(
|
||||||
|
async <T,>(id: string, action: () => Promise<T>, successText: string): Promise<T | null> => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
setNotice("");
|
setNotice("");
|
||||||
try {
|
try {
|
||||||
await action();
|
const result = await action();
|
||||||
setNotice(successText);
|
setNotice(successText);
|
||||||
|
return result;
|
||||||
} catch (mutationError: any) {
|
} catch (mutationError: any) {
|
||||||
setActionError(
|
setActionError(
|
||||||
String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed")
|
String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed")
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId("");
|
setBusyId("");
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const startNew = () => {
|
const updateRegistry = useCallback(
|
||||||
setDetailsMode("new");
|
async (payload: TAIWorkspaceExecutorListResponse) => {
|
||||||
setEditingId("");
|
await mutate(payload, { revalidate: false });
|
||||||
setDraft(emptyDraft());
|
return payload;
|
||||||
setNotice("");
|
},
|
||||||
setActionError("");
|
[mutate]
|
||||||
};
|
);
|
||||||
|
|
||||||
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
|
const selectExecutor = useCallback(
|
||||||
setDetailsMode("context");
|
async (executor: TAIWorkspaceExecutor) =>
|
||||||
setEditingId(executor.id);
|
runMutation(
|
||||||
setDraft(draftFromExecutor(executor));
|
|
||||||
if (executor.id !== data?.selectedExecutorId) {
|
|
||||||
void runMutation(
|
|
||||||
`select:${executor.id}`,
|
`select:${executor.id}`,
|
||||||
async () => {
|
async () => updateRegistry(await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id)),
|
||||||
const payload = await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id);
|
|
||||||
await mutate(payload, { revalidate: false });
|
|
||||||
},
|
|
||||||
"Устройство выбрано"
|
"Устройство выбрано"
|
||||||
|
),
|
||||||
|
[runMutation, updateRegistry, workspaceSlug]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const editActive = () => {
|
const createExecutor = useCallback(
|
||||||
if (!activeExecutor) return;
|
async (input: TAIWorkspaceExecutorInput) =>
|
||||||
setDetailsMode("edit");
|
runMutation(
|
||||||
setEditingId(activeExecutor.id);
|
|
||||||
setDraft(draftFromExecutor(activeExecutor));
|
|
||||||
setNotice("");
|
|
||||||
setActionError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveDraft = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = toInput(draft);
|
|
||||||
if (!input.name) {
|
|
||||||
setActionError("Укажи имя устройства");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (detailsMode === "new") {
|
|
||||||
await runMutation(
|
|
||||||
"save",
|
"save",
|
||||||
async () => {
|
async () => updateRegistry(await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input)),
|
||||||
const payload = await workspaceAIWorkspaceService.createExecutor(workspaceSlug, input);
|
|
||||||
await mutate(payload, { revalidate: false });
|
|
||||||
const next =
|
|
||||||
payload.executors.find((executor) => executor.name === input.name) ?? payload.executors[0] ?? null;
|
|
||||||
setEditingId(next?.id || "");
|
|
||||||
setDetailsMode(next ? "edit" : "context");
|
|
||||||
},
|
|
||||||
"Устройство создано"
|
"Устройство создано"
|
||||||
|
),
|
||||||
|
[runMutation, updateRegistry, workspaceSlug]
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
const updateExecutor = useCallback(
|
||||||
if (!editingExecutor) return;
|
async (executorId: string, input: TAIWorkspaceExecutorInput) =>
|
||||||
await runMutation(
|
runMutation(
|
||||||
"save",
|
"save",
|
||||||
async () => {
|
async () => updateRegistry(await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input)),
|
||||||
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, input);
|
|
||||||
await mutate(payload, { revalidate: false });
|
|
||||||
setDetailsMode("context");
|
|
||||||
},
|
|
||||||
"Устройство сохранено"
|
"Устройство сохранено"
|
||||||
|
),
|
||||||
|
[runMutation, updateRegistry, workspaceSlug]
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEditingExecutor = async () => {
|
const deleteExecutor = useCallback(
|
||||||
if (!editingExecutor) return;
|
async (executorId: string) =>
|
||||||
await runMutation(
|
runMutation(
|
||||||
`delete:${editingExecutor.id}`,
|
`delete:${executorId}`,
|
||||||
async () => {
|
async () => updateRegistry(await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, executorId)),
|
||||||
const payload = await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, editingExecutor.id);
|
|
||||||
await mutate(payload, { revalidate: false });
|
|
||||||
setDetailsMode("context");
|
|
||||||
},
|
|
||||||
"Устройство удалено"
|
"Устройство удалено"
|
||||||
|
),
|
||||||
|
[runMutation, updateRegistry, workspaceSlug]
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCapability = (value: string) => {
|
const downloadAgent = useCallback(
|
||||||
setDraft((current) => {
|
async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => {
|
||||||
const set = new Set(current.capabilities);
|
|
||||||
if (set.has(value)) set.delete(value);
|
|
||||||
else set.add(value);
|
|
||||||
return { ...current, capabilities: Array.from(set) };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
if (!editingExecutor) return;
|
|
||||||
event.preventDefault();
|
|
||||||
await runMutation(
|
await runMutation(
|
||||||
"download",
|
"download",
|
||||||
async () => {
|
async () => {
|
||||||
const payload = await workspaceAIWorkspaceService.updateExecutor(
|
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input);
|
||||||
workspaceSlug,
|
await updateRegistry(payload);
|
||||||
editingExecutor.id,
|
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {
|
||||||
toInput(draft)
|
port,
|
||||||
);
|
});
|
||||||
await mutate(payload, { revalidate: false });
|
|
||||||
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(
|
|
||||||
workspaceSlug,
|
|
||||||
editingExecutor.id,
|
|
||||||
{
|
|
||||||
port: draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
"Installer подготовлен"
|
"Installer подготовлен"
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[runMutation, updateRegistry, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -294,7 +158,7 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="nodedc-bottom-dock-voice-button nodedc-bottom-dock-ai-workspace-button"
|
className="nodedc-bottom-dock-voice-button nodedc-bottom-dock-ai-workspace-button"
|
||||||
onClick={handleOpen}
|
onClick={() => setIsOpen(true)}
|
||||||
aria-label="AI Workspace"
|
aria-label="AI Workspace"
|
||||||
>
|
>
|
||||||
<MessageCircle className="size-4" />
|
<MessageCircle className="size-4" />
|
||||||
|
|
@ -304,311 +168,26 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
<ModalCore
|
<AIWorkspaceProductSettingsModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
handleClose={handleClose}
|
executors={data?.executors ?? []}
|
||||||
position={EModalPosition.CENTER}
|
selectedExecutorId={data?.selectedExecutorId ?? null}
|
||||||
width={EModalWidth.XXL}
|
isLoading={isLoading}
|
||||||
className="overflow-visible"
|
error={error}
|
||||||
>
|
notice={notice}
|
||||||
<div className="nodedc-glass-modal ai-settings-modal ai-settings-modal--workspace nodedc-ai-workspace-product-modal">
|
actionError={actionError}
|
||||||
<div className="nodedc-glass-modal__head">
|
busyId={busyId}
|
||||||
<div className="nodedc-glass-modal__titles">
|
onClose={handleClose}
|
||||||
<div className="nodedc-glass-modal__title">AI Workspace Settings</div>
|
onReload={() => mutate()}
|
||||||
<div className="nodedc-glass-modal__accent-title" title="MODEL CONTEXT">
|
onSelect={selectExecutor}
|
||||||
MODEL CONTEXT
|
onCreate={createExecutor}
|
||||||
</div>
|
onUpdate={updateExecutor}
|
||||||
</div>
|
onDelete={deleteExecutor}
|
||||||
<button type="button" className="nodedc-glass-modal__close" onClick={handleClose} aria-label="Закрыть">
|
onDownloadAgent={downloadAgent}
|
||||||
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
|
getWindowsAgentInstallerUrl={(executorId, options) =>
|
||||||
</button>
|
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, options)
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="nodedc-glass-modal__body">
|
|
||||||
<div className="ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
|
|
||||||
<aside className="ai-settings-model-list">
|
|
||||||
<div className="ai-settings-devices-head">
|
|
||||||
<div>
|
|
||||||
<div className="ai-console-kicker">AI Space</div>
|
|
||||||
<div className="ai-settings-devices-title">Устройства</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ai-console-icon-button"
|
|
||||||
aria-label="Подключить устройство"
|
|
||||||
title="Подключить устройство"
|
|
||||||
onClick={startNew}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? <div className="ai-settings-empty-list">Загрузка</div> : null}
|
|
||||||
{executors.map((executor) => (
|
|
||||||
<button
|
|
||||||
key={executor.id}
|
|
||||||
type="button"
|
|
||||||
className="ai-settings-executor-row"
|
|
||||||
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
|
|
||||||
onClick={() => selectExecutor(executor)}
|
|
||||||
>
|
|
||||||
<span className="ai-settings-executor-row__title">
|
|
||||||
<span>{executor.name}</span>
|
|
||||||
<i className="ai-status-dot" data-status={executor.status} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<small>Codex worker · {statusLabel(executor.status)}</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!isLoading && !executors.length ? (
|
|
||||||
<div className="ai-settings-empty-list">
|
|
||||||
<span>{error ? "Registry недоступен" : "Устройств нет"}</span>
|
|
||||||
<small>
|
|
||||||
{error
|
|
||||||
? String((error as any)?.message || (error as any)?.error || error)
|
|
||||||
: "Подключенные устройства появятся здесь."}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="ai-settings-context-pane">
|
|
||||||
<div className="ai-settings-form__top">
|
|
||||||
<div>
|
|
||||||
<div className="ai-settings-section-title">
|
|
||||||
{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}
|
|
||||||
</div>
|
|
||||||
<div className="ai-settings-subtitle">Устройство, режим и контекст управления</div>
|
|
||||||
</div>
|
|
||||||
{activeExecutor ? (
|
|
||||||
<span
|
|
||||||
className="ai-executor-status ai-status-dot"
|
|
||||||
data-status={activeExecutor.status}
|
|
||||||
aria-label={statusLabel(activeExecutor.status)}
|
|
||||||
title={statusLabel(activeExecutor.status)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeExecutor && detailsMode !== "new" ? (
|
|
||||||
<>
|
|
||||||
<div className="ai-settings-active-model">
|
|
||||||
<div>
|
|
||||||
<span>Активное устройство</span>
|
|
||||||
<b>{activeExecutor.name}</b>
|
|
||||||
<small>Codex worker · Codex Remote</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ai-settings-context-note ai-settings-context-note--remote">
|
|
||||||
Прямое управление Codex-сеансами устройства.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-actions ai-settings-actions">
|
|
||||||
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
|
|
||||||
Настройки
|
|
||||||
</button>
|
|
||||||
<button type="button" className="modal-btn btn-primary" onClick={handleClose}>
|
|
||||||
Готово
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : detailsMode === "new" ? (
|
|
||||||
<div className="ai-settings-empty-context">
|
|
||||||
<span>Новое устройство</span>
|
|
||||||
<small>
|
|
||||||
Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer
|
|
||||||
агента.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="ai-settings-empty-context" data-tone="error">
|
|
||||||
<span>Registry backend недоступен</span>
|
|
||||||
<small>{String((error as any)?.message || (error as any)?.error || error)}</small>
|
|
||||||
<button type="button" className="modal-btn btn-primary" onClick={() => void mutate()}>
|
|
||||||
Повторить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="ai-settings-empty-context">
|
|
||||||
<span>Устройство не выбрано</span>
|
|
||||||
<small>
|
|
||||||
Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.
|
|
||||||
</small>
|
|
||||||
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
|
|
||||||
Подключить устройство
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<div className="ai-settings-check-note" data-tone="success">
|
|
||||||
{notice}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{actionError ? <div className="ai-settings-error">{actionError}</div> : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{detailsOpen ? (
|
|
||||||
<form className="ai-settings-form ai-settings-details-pane" onSubmit={saveDraft}>
|
|
||||||
<div className="ai-settings-form__top">
|
|
||||||
<div>
|
|
||||||
<div className="ai-settings-section-title">
|
|
||||||
{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}
|
|
||||||
</div>
|
|
||||||
<div className="ai-settings-subtitle">Cloud Hub или прямой endpoint без секретов</div>
|
|
||||||
</div>
|
|
||||||
{detailsMode === "edit" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ai-settings-mini-close"
|
|
||||||
aria-label="Закрыть параметры устройства"
|
|
||||||
onClick={() => setDetailsMode("context")}
|
|
||||||
>
|
|
||||||
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Имя устройства</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.name}
|
|
||||||
placeholder="MACPRO Codex"
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Тип</span>
|
|
||||||
<input className="ai-settings-input" value="Codex worker" readOnly />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Подключение</span>
|
|
||||||
<select
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.connectionMode}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
connectionMode: event.target.value as TAIWorkspaceExecutorConnectionMode,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<option value="hub">Cloud Hub</option>
|
|
||||||
<option value="direct">Прямое подключение</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
|
|
||||||
<div className="ai-settings-field-row ai-settings-field-row--download">
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Pairing code</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={editingExecutor.pairingCode || ""}
|
|
||||||
readOnly
|
|
||||||
placeholder="XXXX-XXXX-XXXX"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<a
|
|
||||||
className="modal-btn ai-settings-download ai-settings-inline-download"
|
|
||||||
href={workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(
|
|
||||||
workspaceSlug,
|
|
||||||
editingExecutor.id
|
|
||||||
)}
|
|
||||||
onClick={downloadAgent}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
Скачать агент
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : draft.connectionMode === "hub" ? (
|
|
||||||
<div className="ai-settings-check-note">
|
|
||||||
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{draft.connectionMode === "direct" ? (
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Адрес агента</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.endpoint}
|
|
||||||
placeholder="http://192.168.1.50:8787"
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Workspace path</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.workspacePath}
|
|
||||||
placeholder="C:\\Projects\\NODEDC"
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, workspacePath: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Порт агента</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.agentPort}
|
|
||||||
inputMode="numeric"
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, agentPort: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="ai-settings-field">
|
|
||||||
<span>Аккаунт</span>
|
|
||||||
<input
|
|
||||||
className="ai-settings-input"
|
|
||||||
value={draft.accountLabel}
|
|
||||||
placeholder="OpenAI account"
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="ai-settings-field">
|
|
||||||
<span>Capabilities</span>
|
|
||||||
<div className="ai-settings-capabilities">
|
|
||||||
{capabilityOptions.map((option) => (
|
|
||||||
<label key={option.value} className="ai-settings-chip">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.capabilities.includes(option.value)}
|
|
||||||
onChange={() => toggleCapability(option.value)}
|
|
||||||
/>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-actions ai-settings-actions">
|
|
||||||
{detailsMode === "edit" && editingExecutor ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-btn ai-settings-danger"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={deleteEditingExecutor}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button type="submit" className="modal-btn btn-primary" disabled={isBusy || !draft.name.trim()}>
|
|
||||||
{busyId === "save" ? "Сохранение..." : detailsMode === "new" ? "Подключить" : "Сохранить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalCore>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { FormEvent, MouseEvent } from "react";
|
||||||
|
// plane imports
|
||||||
|
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
|
// services
|
||||||
|
import type {
|
||||||
|
TAIWorkspaceExecutor,
|
||||||
|
TAIWorkspaceExecutorConnectionMode,
|
||||||
|
TAIWorkspaceExecutorInput,
|
||||||
|
TAIWorkspaceExecutorListResponse,
|
||||||
|
} from "@/services/workspace-ai-workspace.service";
|
||||||
|
|
||||||
|
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
|
||||||
|
const capabilityOptions = [
|
||||||
|
{ value: "chat", label: "Chat" },
|
||||||
|
{ value: "code", label: "Code" },
|
||||||
|
{ value: "engine", label: "Engine" },
|
||||||
|
{ value: "ops", label: "Ops" },
|
||||||
|
{ value: "mcp", label: "MCP" },
|
||||||
|
{ value: "workflows", label: "Workflows" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type TExecutorDraft = {
|
||||||
|
name: string;
|
||||||
|
connectionMode: TAIWorkspaceExecutorConnectionMode;
|
||||||
|
endpoint: string;
|
||||||
|
workspacePath: string;
|
||||||
|
agentPort: string;
|
||||||
|
accountLabel: string;
|
||||||
|
capabilities: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TAIWorkspaceProductSettingsModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
executors: TAIWorkspaceExecutor[];
|
||||||
|
selectedExecutorId?: string | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
notice?: string;
|
||||||
|
actionError?: string;
|
||||||
|
busyId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onReload: () => Promise<unknown> | unknown;
|
||||||
|
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
|
||||||
|
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
|
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
|
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
|
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
||||||
|
getWindowsAgentInstallerUrl: (executorId: string, options?: { port?: string | number }) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyDraft(): TExecutorDraft {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
connectionMode: "hub",
|
||||||
|
endpoint: "",
|
||||||
|
workspacePath: "",
|
||||||
|
agentPort: DEFAULT_WINDOWS_AGENT_PORT,
|
||||||
|
accountLabel: "",
|
||||||
|
capabilities: ["ops"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): TExecutorDraft {
|
||||||
|
if (!executor) return emptyDraft();
|
||||||
|
return {
|
||||||
|
name: executor.name || "",
|
||||||
|
connectionMode: executor.connectionMode === "direct" ? "direct" : "hub",
|
||||||
|
endpoint: executor.endpoint || "",
|
||||||
|
workspacePath: executor.workspacePath || "",
|
||||||
|
agentPort: String(executor.agentPort || DEFAULT_WINDOWS_AGENT_PORT),
|
||||||
|
accountLabel: executor.accountLabel || "",
|
||||||
|
capabilities:
|
||||||
|
Array.isArray(executor.capabilities) && executor.capabilities.length ? executor.capabilities : ["ops"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
|
||||||
|
const port = Number(draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||||
|
return {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
type: "codex-remote",
|
||||||
|
connectionMode: draft.connectionMode,
|
||||||
|
endpoint: draft.connectionMode === "direct" ? draft.endpoint.trim() || null : null,
|
||||||
|
workspacePath: draft.workspacePath.trim() || null,
|
||||||
|
agentPort: Number.isInteger(port) ? port : Number(DEFAULT_WINDOWS_AGENT_PORT),
|
||||||
|
accountLabel: draft.accountLabel.trim() || null,
|
||||||
|
capabilities: Array.from(new Set(["ops", ...draft.capabilities])).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string | null | undefined) {
|
||||||
|
if (status === "online") return "Online";
|
||||||
|
if (status === "offline") return "Offline";
|
||||||
|
if (status === "checking") return "Checking";
|
||||||
|
if (status === "error") return "Error";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown) {
|
||||||
|
if (!error) return "";
|
||||||
|
return String((error as any)?.message || (error as any)?.error || error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIWorkspaceProductSettingsModal({
|
||||||
|
isOpen,
|
||||||
|
executors,
|
||||||
|
selectedExecutorId,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
notice,
|
||||||
|
actionError,
|
||||||
|
busyId,
|
||||||
|
onClose,
|
||||||
|
onReload,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onDownloadAgent,
|
||||||
|
getWindowsAgentInstallerUrl,
|
||||||
|
}: TAIWorkspaceProductSettingsModalProps) {
|
||||||
|
const activeExecutor = useMemo(
|
||||||
|
() => executors.find((executor) => executor.id === selectedExecutorId) ?? executors[0] ?? null,
|
||||||
|
[executors, selectedExecutorId]
|
||||||
|
);
|
||||||
|
const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context");
|
||||||
|
const [editingId, setEditingId] = useState<string>("");
|
||||||
|
const [draft, setDraft] = useState<TExecutorDraft>(() => draftFromExecutor(activeExecutor));
|
||||||
|
|
||||||
|
const editingExecutor = useMemo(
|
||||||
|
() => executors.find((executor) => executor.id === editingId) ?? activeExecutor,
|
||||||
|
[activeExecutor, editingId, executors]
|
||||||
|
);
|
||||||
|
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
|
||||||
|
const isBusy = Boolean(busyId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setDetailsMode("context");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detailsMode === "new") return;
|
||||||
|
setEditingId(activeExecutor?.id || "");
|
||||||
|
setDraft(draftFromExecutor(activeExecutor));
|
||||||
|
}, [activeExecutor, detailsMode, isOpen]);
|
||||||
|
|
||||||
|
const startNew = () => {
|
||||||
|
setDetailsMode("new");
|
||||||
|
setEditingId("");
|
||||||
|
setDraft(emptyDraft());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
|
||||||
|
setDetailsMode("context");
|
||||||
|
setEditingId(executor.id);
|
||||||
|
setDraft(draftFromExecutor(executor));
|
||||||
|
if (executor.id !== selectedExecutorId) void onSelect(executor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editActive = () => {
|
||||||
|
if (!activeExecutor) return;
|
||||||
|
setDetailsMode("edit");
|
||||||
|
setEditingId(activeExecutor.id);
|
||||||
|
setDraft(draftFromExecutor(activeExecutor));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = toInput(draft);
|
||||||
|
if (!input.name) return;
|
||||||
|
if (detailsMode === "new") {
|
||||||
|
const payload = await onCreate(input);
|
||||||
|
if (!payload) return;
|
||||||
|
const next = payload?.executors.find((executor) => executor.name === input.name) ?? payload?.executors[0] ?? null;
|
||||||
|
setEditingId(next?.id || "");
|
||||||
|
setDetailsMode(next ? "edit" : "context");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingExecutor) return;
|
||||||
|
const payload = await onUpdate(editingExecutor.id, input);
|
||||||
|
if (payload) setDetailsMode("context");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEditingExecutor = async () => {
|
||||||
|
if (!editingExecutor) return;
|
||||||
|
const payload = await onDelete(editingExecutor.id);
|
||||||
|
if (payload) setDetailsMode("context");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCapability = (value: string) => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const set = new Set(current.capabilities);
|
||||||
|
if (set.has(value)) set.delete(value);
|
||||||
|
else set.add(value);
|
||||||
|
return { ...current, capabilities: Array.from(set) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAgent = async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!editingExecutor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalCore
|
||||||
|
isOpen={isOpen}
|
||||||
|
handleClose={onClose}
|
||||||
|
position={EModalPosition.CENTER}
|
||||||
|
width={EModalWidth.XXL}
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
<div className="nodedc-glass-modal ai-settings-modal ai-settings-modal--workspace nodedc-ai-workspace-product-modal">
|
||||||
|
<div className="nodedc-glass-modal__head">
|
||||||
|
<div className="nodedc-glass-modal__titles">
|
||||||
|
<div className="nodedc-glass-modal__title">AI Workspace Settings</div>
|
||||||
|
<div className="nodedc-glass-modal__accent-title" title="MODEL CONTEXT">
|
||||||
|
MODEL CONTEXT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="nodedc-glass-modal__close" onClick={onClose} aria-label="Закрыть">
|
||||||
|
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-glass-modal__body">
|
||||||
|
<div className="ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
|
||||||
|
<aside className="ai-settings-model-list">
|
||||||
|
<div className="ai-settings-devices-head">
|
||||||
|
<div>
|
||||||
|
<div className="ai-console-kicker">AI Space</div>
|
||||||
|
<div className="ai-settings-devices-title">Устройства</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ai-console-icon-button"
|
||||||
|
aria-label="Подключить устройство"
|
||||||
|
title="Подключить устройство"
|
||||||
|
onClick={startNew}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <div className="ai-settings-empty-list">Загрузка</div> : null}
|
||||||
|
{executors.map((executor) => (
|
||||||
|
<button
|
||||||
|
key={executor.id}
|
||||||
|
type="button"
|
||||||
|
className="ai-settings-executor-row"
|
||||||
|
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
|
||||||
|
onClick={() => selectExecutor(executor)}
|
||||||
|
>
|
||||||
|
<span className="ai-settings-executor-row__title">
|
||||||
|
<span>{executor.name}</span>
|
||||||
|
<i className="ai-status-dot" data-status={executor.status} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<small>Codex worker · {statusLabel(executor.status)}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!isLoading && !executors.length ? (
|
||||||
|
<div className="ai-settings-empty-list">
|
||||||
|
<span>{error ? "Registry недоступен" : "Устройств нет"}</span>
|
||||||
|
<small>{error ? errorText(error) : "Подключенные устройства появятся здесь."}</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ai-settings-context-pane">
|
||||||
|
<div className="ai-settings-form__top">
|
||||||
|
<div>
|
||||||
|
<div className="ai-settings-section-title">
|
||||||
|
{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}
|
||||||
|
</div>
|
||||||
|
<div className="ai-settings-subtitle">Устройство, режим и контекст управления</div>
|
||||||
|
</div>
|
||||||
|
{activeExecutor ? (
|
||||||
|
<span
|
||||||
|
className="ai-executor-status ai-status-dot"
|
||||||
|
data-status={activeExecutor.status}
|
||||||
|
aria-label={statusLabel(activeExecutor.status)}
|
||||||
|
title={statusLabel(activeExecutor.status)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeExecutor && detailsMode !== "new" ? (
|
||||||
|
<>
|
||||||
|
<div className="ai-settings-active-model">
|
||||||
|
<div>
|
||||||
|
<span>Активное устройство</span>
|
||||||
|
<b>{activeExecutor.name}</b>
|
||||||
|
<small>Codex worker · Codex Remote</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ai-settings-context-note ai-settings-context-note--remote">
|
||||||
|
Прямое управление Codex-сеансами устройства.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions ai-settings-actions">
|
||||||
|
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
|
||||||
|
Настройки
|
||||||
|
</button>
|
||||||
|
<button type="button" className="modal-btn btn-primary" onClick={onClose}>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : detailsMode === "new" ? (
|
||||||
|
<div className="ai-settings-empty-context">
|
||||||
|
<span>Новое устройство</span>
|
||||||
|
<small>
|
||||||
|
Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer
|
||||||
|
агента.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="ai-settings-empty-context" data-tone="error">
|
||||||
|
<span>Registry backend недоступен</span>
|
||||||
|
<small>{errorText(error)}</small>
|
||||||
|
<button type="button" className="modal-btn btn-primary" onClick={() => void onReload()}>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ai-settings-empty-context">
|
||||||
|
<span>Устройство не выбрано</span>
|
||||||
|
<small>
|
||||||
|
Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.
|
||||||
|
</small>
|
||||||
|
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
|
||||||
|
Подключить устройство
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className="ai-settings-check-note" data-tone="success">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{actionError ? <div className="ai-settings-error">{actionError}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{detailsOpen ? (
|
||||||
|
<form className="ai-settings-form ai-settings-details-pane" onSubmit={saveDraft}>
|
||||||
|
<div className="ai-settings-form__top">
|
||||||
|
<div>
|
||||||
|
<div className="ai-settings-section-title">
|
||||||
|
{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}
|
||||||
|
</div>
|
||||||
|
<div className="ai-settings-subtitle">Cloud Hub или прямой endpoint без секретов</div>
|
||||||
|
</div>
|
||||||
|
{detailsMode === "edit" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ai-settings-mini-close"
|
||||||
|
aria-label="Закрыть параметры устройства"
|
||||||
|
onClick={() => setDetailsMode("context")}
|
||||||
|
>
|
||||||
|
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Имя устройства</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.name}
|
||||||
|
placeholder="MACPRO Codex"
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Тип</span>
|
||||||
|
<input className="ai-settings-input" value="Codex worker" readOnly />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Подключение</span>
|
||||||
|
<select
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.connectionMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
connectionMode: event.target.value as TAIWorkspaceExecutorConnectionMode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="hub">Cloud Hub</option>
|
||||||
|
<option value="direct">Прямое подключение</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
|
||||||
|
<div className="ai-settings-field-row ai-settings-field-row--download">
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Pairing code</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={editingExecutor.pairingCode || ""}
|
||||||
|
readOnly
|
||||||
|
placeholder="XXXX-XXXX-XXXX"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
className="modal-btn ai-settings-download ai-settings-inline-download"
|
||||||
|
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
|
||||||
|
onClick={downloadAgent}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
Скачать агент
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : draft.connectionMode === "hub" ? (
|
||||||
|
<div className="ai-settings-check-note">
|
||||||
|
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft.connectionMode === "direct" ? (
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Адрес агента</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.endpoint}
|
||||||
|
placeholder="http://192.168.1.50:8787"
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Workspace path</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.workspacePath}
|
||||||
|
placeholder="C:\\Projects\\NODEDC"
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, workspacePath: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Порт агента</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.agentPort}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, agentPort: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ai-settings-field">
|
||||||
|
<span>Аккаунт</span>
|
||||||
|
<input
|
||||||
|
className="ai-settings-input"
|
||||||
|
value={draft.accountLabel}
|
||||||
|
placeholder="OpenAI account"
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="ai-settings-field">
|
||||||
|
<span>Capabilities</span>
|
||||||
|
<div className="ai-settings-capabilities">
|
||||||
|
{capabilityOptions.map((option) => (
|
||||||
|
<label key={option.value} className="ai-settings-chip">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.capabilities.includes(option.value)}
|
||||||
|
onChange={() => toggleCapability(option.value)}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-actions ai-settings-actions">
|
||||||
|
{detailsMode === "edit" && editingExecutor ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-btn ai-settings-danger"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={deleteEditingExecutor}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="submit" className="modal-btn btn-primary" disabled={isBusy || !draft.name.trim()}>
|
||||||
|
{busyId === "save" ? "Сохранение..." : detailsMode === "new" ? "Подключить" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalCore>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue