feat: add standalone ops ai workspace setup
This commit is contained in:
parent
6c119aae04
commit
71979526cf
|
|
@ -8,6 +8,7 @@ from plane.app.views import (
|
||||||
AIWorkspaceExecutorDetailEndpoint,
|
AIWorkspaceExecutorDetailEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
AIWorkspaceThreadDetailEndpoint,
|
AIWorkspaceThreadDetailEndpoint,
|
||||||
AIWorkspaceThreadListEndpoint,
|
AIWorkspaceThreadListEndpoint,
|
||||||
AIWorkspaceThreadMessagesEndpoint,
|
AIWorkspaceThreadMessagesEndpoint,
|
||||||
|
|
@ -30,6 +31,11 @@ urlpatterns = [
|
||||||
AIWorkspaceExecutorSelectEndpoint.as_view(),
|
AIWorkspaceExecutorSelectEndpoint.as_view(),
|
||||||
name="ai-workspace-executor-select",
|
name="ai-workspace-executor-select",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/windows.ps1",
|
||||||
|
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
||||||
|
name="ai-workspace-executor-windows-agent",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/ai-workspace/threads/",
|
"workspaces/<str:slug>/ai-workspace/threads/",
|
||||||
AIWorkspaceThreadListEndpoint.as_view(),
|
AIWorkspaceThreadListEndpoint.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ from .ai_workspace import (
|
||||||
AIWorkspaceExecutorDetailEndpoint,
|
AIWorkspaceExecutorDetailEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
AIWorkspaceThreadDetailEndpoint,
|
AIWorkspaceThreadDetailEndpoint,
|
||||||
AIWorkspaceThreadListEndpoint,
|
AIWorkspaceThreadListEndpoint,
|
||||||
AIWorkspaceThreadMessagesEndpoint,
|
AIWorkspaceThreadMessagesEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import os
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import requests
|
import requests
|
||||||
|
from django.http import HttpResponse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
@ -138,6 +139,48 @@ class AIWorkspaceExecutorSelectEndpoint(BaseAPIView):
|
||||||
return ai_workspace_request(request, "POST", f"/executors/{executor_id}/select")
|
return ai_workspace_request(request, "POST", f"/executors/{executor_id}/select")
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def get(self, request, slug, executor_id):
|
||||||
|
config, error_response = require_ai_workspace_config()
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
base_url, token, timeout = config
|
||||||
|
params = {}
|
||||||
|
if request.query_params.get("port"):
|
||||||
|
params["port"] = request.query_params.get("port")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/octet-stream, text/plain, */*",
|
||||||
|
**user_headers(request.user),
|
||||||
|
},
|
||||||
|
params=params,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "ai_workspace_assistant_unavailable",
|
||||||
|
"message": "NODE.DC AI Workspace Assistant is unavailable.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "text/plain"))
|
||||||
|
|
||||||
|
output = HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "application/octet-stream"))
|
||||||
|
for header in ("Content-Disposition", "Cache-Control", "Pragma", "Expires", "Surrogate-Control", "X-AI-Workspace-Installer-SHA256"):
|
||||||
|
if response.headers.get(header):
|
||||||
|
output[header] = response.headers[header]
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceThreadListEndpoint(BaseAPIView):
|
class AIWorkspaceThreadListEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,29 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, 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 { CheckCircle2, MessageCircle, Monitor, RefreshCw, X } from "lucide-react";
|
import { CheckCircle2, Download, MessageCircle, Monitor, Plus, RefreshCw, Settings, Trash2, X } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { Button } from "@plane/propel/button";
|
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// services
|
// services
|
||||||
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
|
import { WorkspaceAIWorkspaceService } from "@/services/workspace-ai-workspace.service";
|
||||||
|
import type {
|
||||||
|
TAIWorkspaceExecutor,
|
||||||
|
TAIWorkspaceExecutorConnectionMode,
|
||||||
|
TAIWorkspaceExecutorInput,
|
||||||
|
} from "@/services/workspace-ai-workspace.service";
|
||||||
|
|
||||||
const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService();
|
const workspaceAIWorkspaceService = new WorkspaceAIWorkspaceService();
|
||||||
|
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
|
||||||
|
const capabilityOptions = [
|
||||||
|
{ value: "engine", label: "Engine" },
|
||||||
|
{ value: "ops", label: "Ops" },
|
||||||
|
{ value: "ndc-agent-core", label: "NDC Agent" },
|
||||||
|
];
|
||||||
|
|
||||||
const aiWorkspaceCloseButtonClassName =
|
const aiWorkspaceCloseButtonClassName =
|
||||||
"absolute top-0.5 right-0.5 flex h-12 w-12 items-center justify-center rounded-full border-0 bg-[#17181B] text-white shadow-none ring-0 transition-transform outline-none hover:scale-[1.03] hover:bg-[#0F1012]";
|
"absolute top-0.5 right-0.5 flex h-12 w-12 items-center justify-center rounded-full border-0 bg-[#17181B] text-white shadow-none ring-0 transition-transform outline-none hover:scale-[1.03] hover:bg-[#0F1012]";
|
||||||
|
|
@ -25,9 +36,72 @@ 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 [notice, setNotice] = useState("");
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
const { data, error, isLoading, mutate } = useSWR(
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
isOpen && workspaceSlug ? `AI_WORKSPACE_EXECUTORS_${workspaceSlug}` : null,
|
isOpen && workspaceSlug ? `AI_WORKSPACE_EXECUTORS_${workspaceSlug}` : null,
|
||||||
|
|
@ -54,10 +128,23 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedExecutor = useMemo(
|
const executors = useMemo(() => data?.executors ?? [], [data?.executors]);
|
||||||
() => data?.executors?.find((executor) => executor.id === data.selectedExecutorId) ?? data?.executors?.[0] ?? null,
|
const activeExecutor = useMemo(
|
||||||
[data?.executors, data?.selectedExecutorId]
|
() => 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(() => {
|
const handleOpen = useCallback(() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
@ -65,8 +152,116 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setDetailsMode("context");
|
||||||
|
setNotice("");
|
||||||
|
setActionError("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const runMutation = useCallback(
|
||||||
|
async (id: string, action: () => Promise<unknown>, successText: string) => {
|
||||||
|
setBusyId(id);
|
||||||
|
setActionError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
setNotice(successText);
|
||||||
|
} catch (mutationError: any) {
|
||||||
|
setActionError(String(mutationError?.message || mutationError?.error || mutationError || "AI Workspace request failed"));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startNew = () => {
|
||||||
|
setDetailsMode("new");
|
||||||
|
setEditingId("");
|
||||||
|
setDraft(emptyDraft());
|
||||||
|
setNotice("");
|
||||||
|
setActionError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
|
||||||
|
setDetailsMode("context");
|
||||||
|
setEditingId(executor.id);
|
||||||
|
setDraft(draftFromExecutor(executor));
|
||||||
|
if (executor.id !== data?.selectedExecutorId) {
|
||||||
|
void runMutation(
|
||||||
|
`select:${executor.id}`,
|
||||||
|
async () => {
|
||||||
|
const payload = await workspaceAIWorkspaceService.selectExecutor(workspaceSlug, executor.id);
|
||||||
|
await mutate(payload, { revalidate: false });
|
||||||
|
},
|
||||||
|
"Устройство выбрано"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editActive = () => {
|
||||||
|
if (!activeExecutor) return;
|
||||||
|
setDetailsMode("edit");
|
||||||
|
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", async () => {
|
||||||
|
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");
|
||||||
|
}, "Устройство создано");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingExecutor) return;
|
||||||
|
await runMutation("save", async () => {
|
||||||
|
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, input);
|
||||||
|
await mutate(payload, { revalidate: false });
|
||||||
|
setDetailsMode("context");
|
||||||
|
}, "Устройство сохранено");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEditingExecutor = async () => {
|
||||||
|
if (!editingExecutor) return;
|
||||||
|
await runMutation(`delete:${editingExecutor.id}`, async () => {
|
||||||
|
const payload = await workspaceAIWorkspaceService.deleteExecutor(workspaceSlug, editingExecutor.id);
|
||||||
|
await mutate(payload, { revalidate: false });
|
||||||
|
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 runMutation("download", async () => {
|
||||||
|
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, editingExecutor.id, toInput(draft));
|
||||||
|
await mutate(payload, { revalidate: false });
|
||||||
|
window.location.href = workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, editingExecutor.id, {
|
||||||
|
port: draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT,
|
||||||
|
});
|
||||||
|
}, "Installer подготовлен");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dockSlot
|
{dockSlot
|
||||||
|
|
@ -89,79 +284,217 @@ export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalCo
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
position={EModalPosition.CENTER}
|
position={EModalPosition.CENTER}
|
||||||
width={EModalWidth.MD}
|
width={EModalWidth.XXL}
|
||||||
className="overflow-visible"
|
className="overflow-visible"
|
||||||
>
|
>
|
||||||
<div className="relative p-5">
|
<div className="nodedc-ai-workspace-modal relative">
|
||||||
<div className="flex items-start justify-between gap-4 pr-12">
|
<div className="nodedc-ai-workspace-modal__head">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-18 font-medium text-primary">AI Workspace</h3>
|
<div className="nodedc-ai-workspace-kicker">AI Space</div>
|
||||||
<div className="mt-1 text-12 text-tertiary">Ops</div>
|
<h3>AI Workspace</h3>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={aiWorkspaceCloseButtonClassName} onClick={handleClose}>
|
<button type="button" className={aiWorkspaceCloseButtonClassName} onClick={handleClose} aria-label="Закрыть AI Workspace">
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
|
<div className="nodedc-ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<aside className="nodedc-ai-settings-device-list">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="nodedc-ai-settings-devices-head">
|
||||||
<span className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]">
|
<div>
|
||||||
<MessageCircle className="size-5" />
|
<div className="nodedc-ai-workspace-kicker">Devices</div>
|
||||||
</span>
|
<div className="nodedc-ai-settings-devices-title">Устройства</div>
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-15 font-medium text-primary">
|
|
||||||
{selectedExecutor?.name ?? "Codex assistant"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-12 text-tertiary">
|
<button type="button" className="nodedc-ai-icon-button" aria-label="Подключить устройство" onClick={startNew}>
|
||||||
{selectedExecutor?.model || selectedExecutor?.accountLabel || "AI Workspace"}
|
<Plus className="size-4" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => mutate()} disabled={isLoading}>
|
|
||||||
<RefreshCw className={cn("mr-2 size-3.5", isLoading && "animate-spin")} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2">
|
{isLoading ? <div className="nodedc-ai-settings-empty-list">Загрузка</div> : null}
|
||||||
{error ? (
|
{executors.map((executor) => (
|
||||||
<div className="border-red-500/25 bg-red-500/10 text-red-500 rounded-[20px] border px-4 py-3 text-12">
|
<button
|
||||||
{error?.message || error?.error || "AI Workspace недоступен"}
|
key={executor.id}
|
||||||
</div>
|
type="button"
|
||||||
) : isLoading ? (
|
className="nodedc-ai-settings-executor-row"
|
||||||
<div className="rounded-[20px] bg-white/[0.035] px-4 py-3 text-12 text-tertiary">Загрузка</div>
|
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
|
||||||
) : data?.executors?.length ? (
|
onClick={() => selectExecutor(executor)}
|
||||||
data.executors.map((executor) => (
|
|
||||||
<div key={executor.id} className="flex items-center justify-between gap-3 rounded-[20px] bg-white/[0.035] px-4 py-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<Monitor className="size-4 flex-shrink-0 text-tertiary" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-13 font-medium text-primary">{executor.name}</div>
|
|
||||||
<div className="mt-0.5 truncate text-11 text-tertiary">
|
|
||||||
{executor.connectionMode === "hub" ? executor.pairingCode || "hub" : "direct"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-11",
|
|
||||||
executor.status === "online"
|
|
||||||
? "bg-green-500/10 text-green-300"
|
|
||||||
: "bg-white/[0.045] text-tertiary"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-3" />
|
<span>
|
||||||
{executor.status}
|
<Monitor className="size-4" />
|
||||||
|
<b>{executor.name}</b>
|
||||||
</span>
|
</span>
|
||||||
|
<small>
|
||||||
|
{executor.connectionMode === "hub" ? executor.pairingCode || "Cloud Hub" : "Direct"} · {statusLabel(executor.status)}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!isLoading && !executors.length ? (
|
||||||
|
<div className="nodedc-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="nodedc-ai-settings-context-pane">
|
||||||
|
<div className="nodedc-ai-settings-form-top">
|
||||||
|
<div>
|
||||||
|
<div className="nodedc-ai-settings-section-title">{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}</div>
|
||||||
|
<div className="nodedc-ai-settings-subtitle">Общий пользовательский ассистент для Ops и Engine</div>
|
||||||
|
</div>
|
||||||
|
{activeExecutor ? <span className="nodedc-ai-status-pill">{statusLabel(activeExecutor.status)}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeExecutor && detailsMode !== "new" ? (
|
||||||
|
<>
|
||||||
|
<div className="nodedc-ai-settings-active-model">
|
||||||
|
<span>Активное устройство</span>
|
||||||
|
<b>{activeExecutor.name}</b>
|
||||||
|
<small>{activeExecutor.connectionMode === "hub" ? activeExecutor.pairingCode || "Cloud Hub" : activeExecutor.endpoint}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-ai-settings-note" data-ready="true">
|
||||||
|
Это устройство сохранено в общем AI Workspace registry. Оно будет видно в Engine и Ops для этого же пользователя.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-ai-settings-actions">
|
||||||
|
<button type="button" className="modal-btn" disabled={isBusy} onClick={() => void mutate()}>
|
||||||
|
<RefreshCw className={cn("size-4", isLoading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
Настройки
|
||||||
|
</button>
|
||||||
|
<button type="button" className="modal-btn btn-primary" onClick={handleClose}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : detailsMode === "new" ? (
|
||||||
|
<div className="nodedc-ai-settings-empty-context">
|
||||||
|
<span>Новое устройство</span>
|
||||||
|
<small>Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer агента.</small>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="nodedc-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>
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[20px] bg-white/[0.035] px-4 py-3 text-12 text-tertiary">
|
<div className="nodedc-ai-settings-empty-context">
|
||||||
Устройства не подключены
|
<span>Устройство не выбрано</span>
|
||||||
|
<small>Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.</small>
|
||||||
|
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
|
||||||
|
Подключить устройство
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{notice ? <div className="nodedc-ai-settings-note" data-tone="success">{notice}</div> : null}
|
||||||
|
{actionError ? <div className="nodedc-ai-settings-error">{actionError}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{detailsOpen ? (
|
||||||
|
<form className="nodedc-ai-settings-details-pane" onSubmit={saveDraft}>
|
||||||
|
<div className="nodedc-ai-settings-form-top">
|
||||||
|
<div>
|
||||||
|
<div className="nodedc-ai-settings-section-title">{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}</div>
|
||||||
|
<div className="nodedc-ai-settings-subtitle">Cloud Hub или прямой endpoint</div>
|
||||||
</div>
|
</div>
|
||||||
|
{detailsMode === "edit" ? (
|
||||||
|
<button type="button" className="nodedc-ai-mini-close" aria-label="Закрыть параметры" onClick={() => setDetailsMode("context")}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Имя устройства</span>
|
||||||
|
<input value={draft.name} placeholder="MACPRO Codex" onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Подключение</span>
|
||||||
|
<select 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="nodedc-ai-settings-download-row">
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Pairing code</span>
|
||||||
|
<input value={editingExecutor.pairingCode || ""} readOnly placeholder="XXXX-XXXX-XXXX" />
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
className="modal-btn btn-primary nodedc-ai-settings-download"
|
||||||
|
href={workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, editingExecutor.id)}
|
||||||
|
onClick={downloadAgent}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Скачать агент
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : draft.connectionMode === "hub" ? (
|
||||||
|
<div className="nodedc-ai-settings-note">
|
||||||
|
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft.connectionMode === "direct" ? (
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Адрес агента</span>
|
||||||
|
<input value={draft.endpoint} placeholder="http://192.168.1.50:8787" onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Workspace path</span>
|
||||||
|
<input value={draft.workspacePath} placeholder="C:\\Projects\\NODEDC" onChange={(event) => setDraft((current) => ({ ...current, workspacePath: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Порт агента</span>
|
||||||
|
<input value={draft.agentPort} inputMode="numeric" onChange={(event) => setDraft((current) => ({ ...current, agentPort: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="nodedc-ai-settings-field">
|
||||||
|
<span>Аккаунт</span>
|
||||||
|
<input value={draft.accountLabel} placeholder="OpenAI account" onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="nodedc-ai-settings-field">
|
||||||
|
<span>Capabilities</span>
|
||||||
|
<div className="nodedc-ai-settings-capabilities">
|
||||||
|
{capabilityOptions.map((option) => (
|
||||||
|
<label key={option.value}>
|
||||||
|
<input type="checkbox" checked={draft.capabilities.includes(option.value)} onChange={() => toggleCapability(option.value)} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-ai-settings-actions">
|
||||||
|
{detailsMode === "edit" && editingExecutor ? (
|
||||||
|
<button type="button" className="modal-btn nodedc-ai-danger" disabled={isBusy} onClick={deleteEditingExecutor}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Удалить
|
||||||
|
</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>
|
</div>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,24 @@ import { API_BASE_URL } from "@plane/constants";
|
||||||
import { APIService } from "@/services/api.service";
|
import { APIService } from "@/services/api.service";
|
||||||
|
|
||||||
export type TAIWorkspaceExecutorStatus = "unknown" | "online" | "offline" | "checking" | "error";
|
export type TAIWorkspaceExecutorStatus = "unknown" | "online" | "offline" | "checking" | "error";
|
||||||
|
export type TAIWorkspaceExecutorConnectionMode = "hub" | "direct";
|
||||||
|
|
||||||
export type TAIWorkspaceExecutor = {
|
export type TAIWorkspaceExecutor = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
connectionMode: "hub" | "direct";
|
connectionMode: TAIWorkspaceExecutorConnectionMode;
|
||||||
|
endpoint?: string | null;
|
||||||
|
workspacePath?: string | null;
|
||||||
|
agentPort?: number | null;
|
||||||
pairingCode?: string | null;
|
pairingCode?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
accountLabel?: string | null;
|
accountLabel?: string | null;
|
||||||
|
capabilities?: string[];
|
||||||
status: TAIWorkspaceExecutorStatus;
|
status: TAIWorkspaceExecutorStatus;
|
||||||
|
statusDetail?: string | null;
|
||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -28,6 +35,21 @@ export type TAIWorkspaceExecutorListResponse = {
|
||||||
executors: TAIWorkspaceExecutor[];
|
executors: TAIWorkspaceExecutor[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TAIWorkspaceExecutorInput = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
connectionMode?: TAIWorkspaceExecutorConnectionMode;
|
||||||
|
endpoint?: string | null;
|
||||||
|
workspacePath?: string | null;
|
||||||
|
agentPort?: number | null;
|
||||||
|
pairingCode?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
accountLabel?: string | null;
|
||||||
|
capabilities?: string[];
|
||||||
|
status?: TAIWorkspaceExecutorStatus;
|
||||||
|
};
|
||||||
|
|
||||||
export class WorkspaceAIWorkspaceService extends APIService {
|
export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(API_BASE_URL);
|
super(API_BASE_URL);
|
||||||
|
|
@ -40,4 +62,43 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
throw error?.response?.data ?? error;
|
throw error?.response?.data ?? error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createExecutor(workspaceSlug: string, input: TAIWorkspaceExecutorInput): Promise<TAIWorkspaceExecutorListResponse> {
|
||||||
|
await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`, { ...input, select: true }).catch((error) => {
|
||||||
|
throw error?.response?.data ?? error;
|
||||||
|
});
|
||||||
|
return this.listExecutors(workspaceSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExecutor(
|
||||||
|
workspaceSlug: string,
|
||||||
|
executorId: string,
|
||||||
|
input: Partial<TAIWorkspaceExecutorInput>
|
||||||
|
): Promise<TAIWorkspaceExecutorListResponse> {
|
||||||
|
await this.patch(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`, input).catch((error) => {
|
||||||
|
throw error?.response?.data ?? error;
|
||||||
|
});
|
||||||
|
return this.listExecutors(workspaceSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExecutor(workspaceSlug: string, executorId: string): Promise<TAIWorkspaceExecutorListResponse> {
|
||||||
|
await this.delete(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`).catch((error) => {
|
||||||
|
throw error?.response?.data ?? error;
|
||||||
|
});
|
||||||
|
return this.listExecutors(workspaceSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectExecutor(workspaceSlug: string, executorId: string): Promise<TAIWorkspaceExecutorListResponse> {
|
||||||
|
await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/select/`).catch((error) => {
|
||||||
|
throw error?.response?.data ?? error;
|
||||||
|
});
|
||||||
|
return this.listExecutors(workspaceSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWindowsAgentInstallerUrl(workspaceSlug: string, executorId: string, options: { port?: string | number } = {}) {
|
||||||
|
const params = new URLSearchParams({ v: `${Date.now()}` });
|
||||||
|
const port = String(options.port || "").trim();
|
||||||
|
if (port) params.set("port", port);
|
||||||
|
return `${API_BASE_URL}/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/windows.ps1?${params.toString()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,309 @@
|
||||||
color: rgb(var(--nodedc-card-active-rgb));
|
color: rgb(var(--nodedc-card-active-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-workspace-modal {
|
||||||
|
width: min(74rem, calc(100vw - 2rem));
|
||||||
|
min-height: min(42rem, calc(100vh - 4rem));
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 2rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.025)),
|
||||||
|
rgba(22, 23, 26, 0.94);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 2rem 5rem rgba(0, 0, 0, 0.42);
|
||||||
|
-webkit-backdrop-filter: blur(34px);
|
||||||
|
backdrop-filter: blur(34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-workspace-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-right: 3.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-workspace-modal__head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-workspace-kicker {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 0.9fr) minmax(18rem, 1.2fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-workspace[data-details-open="true"] {
|
||||||
|
grid-template-columns: minmax(13rem, 0.8fr) minmax(17rem, 1fr) minmax(19rem, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-device-list,
|
||||||
|
.nodedc-ai-settings-context-pane,
|
||||||
|
.nodedc-ai-settings-details-pane {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 28rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.075);
|
||||||
|
border-radius: 1.55rem;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-details-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-devices-head,
|
||||||
|
.nodedc-ai-settings-form-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-devices-title,
|
||||||
|
.nodedc-ai-settings-section-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-subtitle {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-icon-button,
|
||||||
|
.nodedc-ai-mini-close {
|
||||||
|
display: grid;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 7, 10, 0.78);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-executor-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4rem;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-executor-row[data-active="true"] {
|
||||||
|
border-color: rgba(var(--nodedc-accent-rgb), 0.52);
|
||||||
|
background: rgba(var(--nodedc-accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-executor-row span {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-executor-row b,
|
||||||
|
.nodedc-ai-settings-active-model b {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-executor-row small,
|
||||||
|
.nodedc-ai-settings-empty-list small,
|
||||||
|
.nodedc-ai-settings-empty-context small,
|
||||||
|
.nodedc-ai-settings-active-model small {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-empty-list,
|
||||||
|
.nodedc-ai-settings-empty-context,
|
||||||
|
.nodedc-ai-settings-active-model,
|
||||||
|
.nodedc-ai-settings-note,
|
||||||
|
.nodedc-ai-settings-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-empty-context {
|
||||||
|
min-height: 18rem;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-empty-context .modal-btn {
|
||||||
|
margin: 0.7rem auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-active-model span {
|
||||||
|
color: rgba(255, 255, 255, 0.52);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-note {
|
||||||
|
color: rgba(255, 255, 255, 0.66);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-note[data-ready="true"],
|
||||||
|
.nodedc-ai-settings-note[data-tone="success"] {
|
||||||
|
border: 1px solid rgba(var(--nodedc-accent-rgb), 0.18);
|
||||||
|
background: rgba(var(--nodedc-accent-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-error,
|
||||||
|
.nodedc-ai-settings-empty-context[data-tone="error"] {
|
||||||
|
border: 1px solid rgba(255, 89, 89, 0.22);
|
||||||
|
background: rgba(255, 89, 89, 0.1);
|
||||||
|
color: rgb(255, 144, 144);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-status-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-actions .modal-btn,
|
||||||
|
.nodedc-ai-settings-download {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-actions .btn-primary,
|
||||||
|
.nodedc-ai-settings-download.btn-primary {
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-danger {
|
||||||
|
color: rgb(255, 144, 144) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-field input,
|
||||||
|
.nodedc-ai-settings-field select {
|
||||||
|
min-height: 2.55rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgba(7, 7, 10, 0.54);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
outline: none;
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-download-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-capabilities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-capabilities label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.nodedc-ai-workspace-modal {
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-workspace,
|
||||||
|
.nodedc-ai-settings-workspace[data-details-open="true"] {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-ai-settings-device-list,
|
||||||
|
.nodedc-ai-settings-context-pane,
|
||||||
|
.nodedc-ai-settings-details-pane {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-bottom-dock-aware-padding {
|
.nodedc-bottom-dock-aware-padding {
|
||||||
padding-bottom: calc(var(--nodedc-quick-add-reserve, 2.5rem) + 0.5rem);
|
padding-bottom: calc(var(--nodedc-quick-add-reserve, 2.5rem) + 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue