feat: complete ops ai workspace bridge

This commit is contained in:
DCCONSTRUCTIONS
2026-06-13 08:46:55 +03:00
parent 8b1e356494
commit 3806848b07
7 changed files with 2040 additions and 45 deletions
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ import type {
TAIWorkspaceExecutorConnectionMode,
TAIWorkspaceExecutorInput,
TAIWorkspaceExecutorListResponse,
TAIWorkspaceOpsProject,
TAIWorkspaceOpsWorkspace,
} from "@/services/workspace-ai-workspace.service";
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
@@ -43,8 +45,15 @@ type TExecutorDraft = {
type TAIWorkspaceProductSettingsModalProps = {
isOpen: boolean;
workspaceSlug: string;
executors: TAIWorkspaceExecutor[];
selectedExecutorId?: string | null;
workspaceOptions: TAIWorkspaceOpsWorkspace[];
selectedOpsWorkspaceSlug: string;
workspaceLoadError?: string;
projectOptions: TAIWorkspaceOpsProject[];
selectedProjectId: string;
projectLoadError?: string;
isLoading?: boolean;
error?: unknown;
notice?: string;
@@ -53,11 +62,17 @@ type TAIWorkspaceProductSettingsModalProps = {
onClose: () => void;
onReload: () => Promise<unknown> | unknown;
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
onCheck: (executor: TAIWorkspaceExecutor) => Promise<TAIWorkspaceExecutorListResponse | null>;
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
onOpsWorkspaceChange: (workspaceSlug: string) => void;
onProjectChange: (projectId: string) => void;
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
getWindowsAgentInstallerUrl: (executorId: string, options?: { port?: string | number }) => string;
getWindowsAgentInstallerUrl: (
executorId: string,
options?: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string }
) => string;
};
function emptyDraft(): TExecutorDraft {
@@ -108,11 +123,37 @@ function statusLabel(status: string | null | undefined) {
return "Unknown";
}
function formatCheckTime(value: string | null | undefined) {
if (!value) return "";
const time = Date.parse(value);
if (!Number.isFinite(time)) return "";
return new Date(time).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function errorText(error: unknown) {
if (!error) return "";
return String((error as any)?.message || (error as any)?.error || error);
}
function projectLabel(project: TAIWorkspaceOpsProject | null | undefined) {
if (!project) return "";
return String(project.title || project.name || project.identifier || project.projectIdentifier || project.id || "").trim();
}
function projectIdentifier(project: TAIWorkspaceOpsProject | null | undefined) {
return String(project?.identifier || project?.projectIdentifier || "").trim();
}
function workspaceLabel(workspace: TAIWorkspaceOpsWorkspace | null | undefined) {
if (!workspace) return "";
return String(workspace.title || workspace.name || workspace.slug || workspace.id || "").trim();
}
function AIWorkspaceGlassSelect<T extends string>({
value,
options,
@@ -260,8 +301,15 @@ function AIWorkspaceGlassSelect<T extends string>({
export function AIWorkspaceProductSettingsModal({
isOpen,
workspaceSlug,
executors,
selectedExecutorId,
workspaceOptions,
selectedOpsWorkspaceSlug,
workspaceLoadError,
projectOptions,
selectedProjectId,
projectLoadError,
isLoading,
error,
notice,
@@ -270,9 +318,12 @@ export function AIWorkspaceProductSettingsModal({
onClose,
onReload,
onSelect,
onCheck,
onCreate,
onUpdate,
onDelete,
onOpsWorkspaceChange,
onProjectChange,
onDownloadAgent,
getWindowsAgentInstallerUrl,
}: TAIWorkspaceProductSettingsModalProps) {
@@ -290,6 +341,37 @@ export function AIWorkspaceProductSettingsModal({
);
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
const isBusy = Boolean(busyId);
const selectedProject = useMemo(
() => projectOptions.find((project) => project.id === selectedProjectId) || null,
[projectOptions, selectedProjectId]
);
const selectedOpsWorkspace = useMemo(
() => workspaceOptions.find((workspace) => workspace.slug === selectedOpsWorkspaceSlug) || null,
[selectedOpsWorkspaceSlug, workspaceOptions]
);
const workspaceSelectOptions = useMemo(
() =>
workspaceOptions.map((workspace) => ({
value: workspace.slug,
label: workspaceLabel(workspace),
})),
[workspaceOptions]
);
const projectSelectOptions = useMemo(
() => [
{ value: "", label: "Не выбран" },
...projectOptions.map((project) => {
const identifier = projectIdentifier(project);
const label = projectLabel(project);
return {
value: project.id,
label: identifier ? `${label} · ${identifier}` : label,
};
}),
],
[projectOptions]
);
const contextReady = Boolean(activeExecutor && selectedOpsWorkspace && selectedProject);
useEffect(() => {
if (!isOpen) {
@@ -321,6 +403,11 @@ export function AIWorkspaceProductSettingsModal({
setDraft(draftFromExecutor(activeExecutor));
};
const checkActive = () => {
if (!activeExecutor) return;
void onCheck(activeExecutor);
};
const saveDraft = async (event: FormEvent) => {
event.preventDefault();
const input = toInput(draft);
@@ -363,6 +450,10 @@ export function AIWorkspaceProductSettingsModal({
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
};
const activeCheckDetails = activeExecutor
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
: "";
return (
<ModalCore
isOpen={isOpen}
@@ -451,18 +542,62 @@ export function AIWorkspaceProductSettingsModal({
<div>
<span>Активное устройство</span>
<b>{activeExecutor.name}</b>
<small>Codex worker · Codex Remote</small>
<small>Codex worker · Ops</small>
</div>
</div>
<div className="ai-settings-context-note ai-settings-context-note--remote">
Прямое управление Codex-сеансами устройства.
<div className="ai-settings-field-row">
<label className="ai-settings-field">
<span>Mode</span>
<input className="ai-settings-input" value="Ops" readOnly />
</label>
<label className="ai-settings-field">
<span>Workspace</span>
<AIWorkspaceGlassSelect
value={selectedOpsWorkspaceSlug}
options={workspaceSelectOptions}
ariaLabel="Ops workspace"
disabled={workspaceSelectOptions.length <= 1}
onChange={onOpsWorkspaceChange}
/>
</label>
</div>
{workspaceLoadError ? <div className="ai-settings-error">{workspaceLoadError}</div> : null}
<label className="ai-settings-field">
<span>Ops project</span>
<AIWorkspaceGlassSelect
value={selectedProjectId}
options={projectSelectOptions}
ariaLabel="Ops project"
disabled={!selectedOpsWorkspaceSlug || projectOptions.length === 0}
onChange={onProjectChange}
/>
</label>
{projectLoadError ? <div className="ai-settings-error">{projectLoadError}</div> : null}
<div
className="ai-settings-context-note"
data-ready={contextReady ? "true" : undefined}
>
{contextReady
? `Ops-контекст готов: ${workspaceLabel(selectedOpsWorkspace)} / ${projectLabel(selectedProject)}`
: "Для записи задач в Ops выбери project. Чат доступен всегда, но запись задач будет ограничена выбранным проектом."}
</div>
{activeCheckDetails ? (
<div className="ai-settings-check-note">Last check: {activeCheckDetails}</div>
) : null}
<div className="modal-actions ai-settings-actions">
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
Настройки
</button>
<button type="button" className="modal-btn" disabled={isBusy} onClick={checkActive}>
{busyId === `check:${activeExecutor.id}` ? "Проверка..." : "Проверить"}
</button>
<button type="button" className="modal-btn btn-primary" onClick={onClose}>
Готово
</button>
@@ -97,6 +97,66 @@ export type TAIWorkspaceThreadMessageResponse = {
message: TAIWorkspaceThreadMessage;
};
export type TAIWorkspaceBridgeDispatchResponse = {
ok: boolean;
threadId: string;
executor?: TAIWorkspaceExecutor;
message?: TAIWorkspaceThreadMessage;
assistantMessage?: TAIWorkspaceThreadMessage;
bridge?: {
ok?: boolean;
accepted?: boolean;
requestId?: string | null;
threadId?: string | null;
mode?: string;
error?: string;
response?: Record<string, any>;
};
};
export type TAIWorkspaceBridgeEvent = {
id?: number | string;
requestId?: string | null;
threadId?: string | null;
sessionId?: string | null;
threadTitle?: string | null;
userMessage?: string | null;
command?: string | null;
cwd?: string | null;
kind?: string | null;
message?: string | null;
text?: string | null;
at?: string | null;
status?: string | null;
[key: string]: any;
};
export type TAIWorkspaceBridgeEventsResponse = {
ok: boolean;
executorId: string;
online?: boolean;
latestEventId?: number;
events: TAIWorkspaceBridgeEvent[];
};
export type TAIWorkspaceOpsProject = {
id: string;
identifier?: string | null;
slug?: string | null;
name?: string | null;
title?: string | null;
projectIdentifier?: string | null;
[key: string]: any;
};
export type TAIWorkspaceOpsWorkspace = {
id: string;
slug: string;
name?: string | null;
title?: string | null;
[key: string]: any;
};
export type TAIWorkspaceThreadInput = {
title: string;
originSurface?: "engine" | "ops" | "global";
@@ -113,6 +173,65 @@ export class WorkspaceAIWorkspaceService extends APIService {
super(API_BASE_URL);
}
private arrayFromApiPayload<T = any>(payload: any): T[] {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.results)) return payload.results;
if (Array.isArray(payload?.data)) return payload.data;
if (Array.isArray(payload?.projects)) return payload.projects;
if (Array.isArray(payload?.workspaces)) return payload.workspaces;
return [];
}
async listProjects(workspaceSlug: string): Promise<TAIWorkspaceOpsProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/`)
.then((response) =>
this.arrayFromApiPayload<TAIWorkspaceOpsProject>(response?.data)
.map((project) => ({
...project,
id: String(project.id || "").trim(),
identifier: String(project.identifier || project.projectIdentifier || "").trim(),
title: String(project.title || project.name || project.identifier || project.id || "Project").trim(),
}))
.filter((project) => project.id)
)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async listWorkspaces(): Promise<TAIWorkspaceOpsWorkspace[]> {
return this.get("/api/users/me/workspaces/")
.then((response) =>
this.arrayFromApiPayload<TAIWorkspaceOpsWorkspace>(response?.data)
.map((workspace) => ({
...workspace,
id: String(workspace.id || workspace.slug || "").trim(),
slug: String(workspace.slug || "").trim(),
title: String(workspace.title || workspace.name || workspace.slug || "Workspace").trim(),
}))
.filter((workspace) => workspace.slug)
)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async updateSettings(
workspaceSlug: string,
input: {
selectedExecutorId?: string | null;
activeContext?: Record<string, any>;
enabledToolPacks?: string[];
metadata?: Record<string, any>;
}
): Promise<Record<string, any>> {
return this.patch(`/api/workspaces/${workspaceSlug}/ai-workspace/settings/`, input)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async listExecutors(workspaceSlug: string): Promise<TAIWorkspaceExecutorListResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`)
.then((response) => response?.data)
@@ -158,10 +277,25 @@ export class WorkspaceAIWorkspaceService extends APIService {
return this.listExecutors(workspaceSlug);
}
getWindowsAgentInstallerUrl(workspaceSlug: string, executorId: string, options: { port?: string | number } = {}) {
async checkExecutor(workspaceSlug: string, executorId: string): Promise<TAIWorkspaceExecutorListResponse> {
await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/check/`).catch((error) => {
throw error?.response?.data ?? error;
});
return this.listExecutors(workspaceSlug);
}
getWindowsAgentInstallerUrl(
workspaceSlug: string,
executorId: string,
options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {}
) {
const params = new URLSearchParams({ v: `${Date.now()}` });
const port = String(options.port || "").trim();
if (port) params.set("port", port);
const opsWorkspaceSlug = String(options.opsWorkspaceSlug || "").trim();
if (opsWorkspaceSlug) params.set("ops_workspace_slug", opsWorkspaceSlug);
const opsProjectId = String(options.opsProjectId || "").trim();
if (opsProjectId) params.set("ops_project_id", opsProjectId);
return `${API_BASE_URL}/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/windows.ps1?${params.toString()}`;
}
@@ -170,7 +304,7 @@ export class WorkspaceAIWorkspaceService extends APIService {
options: { surface?: "ops" | "engine" | "global"; limit?: number } = {}
): Promise<TAIWorkspaceThreadListResponse> {
const params = new URLSearchParams();
params.set("surface", options.surface || "ops");
if (options.surface) params.set("surface", options.surface);
params.set("limit", String(options.limit || 100));
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/?${params.toString()}`)
.then((response) => response?.data)
@@ -182,7 +316,7 @@ export class WorkspaceAIWorkspaceService extends APIService {
async createThread(workspaceSlug: string, input: TAIWorkspaceThreadInput): Promise<TAIWorkspaceThreadResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/`, {
originSurface: "ops",
enabledToolPacks: ["ops"],
enabledToolPacks: ["ops", "engine"],
...input,
})
.then((response) => response?.data)
@@ -228,4 +362,40 @@ export class WorkspaceAIWorkspaceService extends APIService {
throw error?.response?.data ?? error;
});
}
async dispatchThreadMessage(
workspaceSlug: string,
threadId: string,
input: {
messageId: string;
selectedExecutorId?: string | null;
context?: Record<string, any>;
modeId?: string;
modeTitle?: string;
remoteControlMode?: string;
}
): Promise<TAIWorkspaceBridgeDispatchResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/${threadId}/dispatch/`, input)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async listBridgeEvents(
workspaceSlug: string,
executorId: string,
options: { since?: number | "latest"; limit?: number } = {}
): Promise<TAIWorkspaceBridgeEventsResponse> {
const params = new URLSearchParams();
params.set("since", String(options.since ?? 0));
params.set("limit", String(options.limit || 100));
return this.get(
`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/events/?${params.toString()}`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
}
+34 -1
View File
@@ -968,6 +968,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
overflow: visible;
}
.ai-settings-executor-row__title span,
@@ -984,7 +985,7 @@
}
.ai-settings-executor-row[data-active="true"] small {
color: rgba(255, 255, 255, 0.76);
color: color-mix(in srgb, var(--brand-contrast) 72%, transparent);
}
.ai-status-dot {
@@ -1013,6 +1014,20 @@
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.ai-settings-executor-row[data-active="true"] .ai-status-dot {
background: color-mix(in srgb, var(--brand-contrast) 58%, transparent);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-contrast) 16%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--brand-contrast) 14%, transparent);
}
.ai-settings-executor-row[data-active="true"] .ai-status-dot[data-status="online"] {
background: #65d99b;
box-shadow:
0 0 0 3px rgba(101, 217, 155, 0.22),
0 0 18px rgba(101, 217, 155, 0.35);
}
.ai-executor-status {
width: 12px;
height: 12px;
@@ -1904,6 +1919,24 @@
overflow-wrap: anywhere;
}
.nodedc-ai-workspace-console .ai-console-process__body {
display: grid;
gap: 8px;
color: rgba(255, 255, 255, 0.78);
font-size: 13px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.nodedc-ai-workspace-console .ai-console-process__body p {
margin: 0;
white-space: pre-line;
}
.nodedc-ai-workspace-console .ai-console-process__body code {
font-size: inherit;
}
.nodedc-ai-workspace-console .ai-console-rich {
display: grid;
gap: 9px;