diff --git a/plane-src/apps/api/plane/app/urls/ai_workspace.py b/plane-src/apps/api/plane/app/urls/ai_workspace.py index 8d8edd4..ea63151 100644 --- a/plane-src/apps/api/plane/app/urls/ai_workspace.py +++ b/plane-src/apps/api/plane/app/urls/ai_workspace.py @@ -10,6 +10,7 @@ from plane.app.views import ( AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, + AIWorkspaceExecutorSetupCommandEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceSettingsEndpoint, AIWorkspaceThreadDispatchEndpoint, @@ -55,6 +56,11 @@ urlpatterns = [ AIWorkspaceExecutorWindowsAgentEndpoint.as_view(), name="ai-workspace-executor-windows-agent", ), + path( + "workspaces//ai-workspace/executors//agent/setup-command/", + AIWorkspaceExecutorSetupCommandEndpoint.as_view(), + name="ai-workspace-executor-setup-command", + ), path( "workspaces//ai-workspace/threads/", AIWorkspaceThreadListEndpoint.as_view(), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 7a1d5da..a453c78 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -180,6 +180,7 @@ from .ai_workspace import ( AIWorkspaceExecutorEventsEndpoint, AIWorkspaceExecutorListEndpoint, AIWorkspaceExecutorSelectEndpoint, + AIWorkspaceExecutorSetupCommandEndpoint, AIWorkspaceExecutorWindowsAgentEndpoint, AIWorkspaceSettingsEndpoint, AIWorkspaceThreadDispatchEndpoint, diff --git a/plane-src/apps/api/plane/app/views/ai_workspace.py b/plane-src/apps/api/plane/app/views/ai_workspace.py index 1061d7f..1f4f110 100644 --- a/plane-src/apps/api/plane/app/views/ai_workspace.py +++ b/plane-src/apps/api/plane/app/views/ai_workspace.py @@ -505,6 +505,51 @@ def ops_settings_payload(raw_payload, slug): return payload +def sync_ops_installer_context(request, slug, source): + request_data = request.data if isinstance(request.data, dict) else {} + ops_project_id = str( + request_data.get("opsProjectId") + or request_data.get("ops_project_id") + or request.query_params.get("ops_project_id") + or "" + ).strip() + if not ops_project_id: + return None + + ops_workspace_slug = str( + request_data.get("opsWorkspaceSlug") + or request_data.get("ops_workspace_slug") + or request.query_params.get("ops_workspace_slug") + or slug + or "" + ).strip() + active_context = { + "surface": "ops", + "workspaceSlug": ops_workspace_slug, + "opsWorkspaceSlug": ops_workspace_slug, + "opsRouteWorkspaceSlug": slug, + "opsProjectId": ops_project_id, + } + sync_payload = ops_settings_payload( + { + "activeContext": active_context, + "enabledToolPacks": ["ops", "engine"], + "metadata": { + "source": source, + "updatedAt": timezone.now().isoformat(), + }, + }, + slug, + ) + sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload) + if ops_grant_error is not None: + return ops_grant_error + sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload) + if sync_response.status_code >= 400: + return sync_response + return None + + class AIWorkspaceSettingsEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): @@ -584,33 +629,9 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView): params = {} if request.query_params.get("port"): params["port"] = request.query_params.get("port") - ops_project_id = (request.query_params.get("ops_project_id") or "").strip() - if ops_project_id: - ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip() - active_context = { - "surface": "ops", - "workspaceSlug": ops_workspace_slug, - "opsWorkspaceSlug": ops_workspace_slug, - "opsRouteWorkspaceSlug": slug, - "opsProjectId": ops_project_id, - } - sync_payload = ops_settings_payload( - { - "activeContext": active_context, - "enabledToolPacks": ["ops", "engine"], - "metadata": { - "source": "ops-ai-workspace-installer", - "updatedAt": timezone.now().isoformat(), - }, - }, - slug, - ) - sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload) - if ops_grant_error is not None: - return ops_grant_error - sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload) - if sync_response.status_code >= 400: - return sync_response + sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-installer") + if sync_error is not None: + return sync_error try: response = requests.get( @@ -643,6 +664,27 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView): return output +class AIWorkspaceExecutorSetupCommandEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, executor_id): + sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-npm-setup") + if sync_error is not None: + return sync_error + + request_data = request.data if isinstance(request.data, dict) else {} + payload = {} + port = request_data.get("port") or request.query_params.get("port") + if port: + payload["port"] = port + return ai_workspace_request( + request, + "POST", + f"/executors/{executor_id}/agent/setup-command", + payload, + timeout_override=15, + ) + + class AIWorkspaceThreadListEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): diff --git a/plane-src/apps/web/core/components/ai-workspace/product-console.tsx b/plane-src/apps/web/core/components/ai-workspace/product-console.tsx index 2260ad5..975ef8f 100644 --- a/plane-src/apps/web/core/components/ai-workspace/product-console.tsx +++ b/plane-src/apps/web/core/components/ai-workspace/product-console.tsx @@ -1547,6 +1547,29 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW [opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug] ); + const copySetupCommand = useCallback( + async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => { + await runRegistryAction( + "setup-command", + async () => { + const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input); + await updateRegistry(payload); + const setup = await workspaceAIWorkspaceService.createAgentSetupCommand(workspaceSlug, executorId, { + port, + opsWorkspaceSlug, + opsProjectId: selectedProjectId, + }); + const command = String(setup.install?.command || "").trim(); + if (!command) throw new Error("setup_command_empty"); + if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable"); + await navigator.clipboard.writeText(command); + }, + "npm setup command скопирована" + ); + }, + [opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug] + ); + const createThread = async () => { try { setThreadError(""); @@ -2236,6 +2259,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW onDelete={deleteExecutor} onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug} onProjectChange={setSelectedProjectId} + onCopySetupCommand={copySetupCommand} onDownloadAgent={downloadAgent} getWindowsAgentInstallerUrl={(executorId, options) => workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, { diff --git a/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx b/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx index d5ad49f..c462417 100644 --- a/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx +++ b/plane-src/apps/web/core/components/ai-workspace/product-settings-modal.tsx @@ -68,6 +68,7 @@ type TAIWorkspaceProductSettingsModalProps = { onDelete: (executorId: string) => Promise; onOpsWorkspaceChange: (workspaceSlug: string) => void; onProjectChange: (projectId: string) => void; + onCopySetupCommand: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise; onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise; getWindowsAgentInstallerUrl: ( executorId: string, @@ -324,6 +325,7 @@ export function AIWorkspaceProductSettingsModal({ onDelete, onOpsWorkspaceChange, onProjectChange, + onCopySetupCommand, onDownloadAgent, getWindowsAgentInstallerUrl, }: TAIWorkspaceProductSettingsModalProps) { @@ -450,6 +452,11 @@ export function AIWorkspaceProductSettingsModal({ await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT); }; + const copySetupCommand = async () => { + if (!editingExecutor) return; + await onCopySetupCommand(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT); + }; + const activeCheckDetails = activeExecutor ? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ") : ""; @@ -696,18 +703,26 @@ export function AIWorkspaceProductSettingsModal({ placeholder="XXXX-XXXX-XXXX" /> + - Скачать агент + Legacy .ps1 ) : draft.connectionMode === "hub" ? (
- После сохранения общий AI Workspace создаст pairing code и installer агента. + После сохранения общий AI Workspace создаст pairing code и npm setup command.
) : null} diff --git a/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts b/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts index 3a66312..fa45ee1 100644 --- a/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts +++ b/plane-src/apps/web/core/services/workspace-ai-workspace.service.ts @@ -139,6 +139,21 @@ export type TAIWorkspaceBridgeEventsResponse = { events: TAIWorkspaceBridgeEvent[]; }; +export type TAIWorkspaceAgentSetupCommandResponse = { + ok: boolean; + executorId?: string; + install?: { + command?: string; + packageName?: string; + gatewayUrl?: string; + expiresAt?: string; + }; + setupCode?: { + suffix?: string; + expiresAt?: string; + }; +}; + export type TAIWorkspaceOpsProject = { id: string; identifier?: string | null; @@ -284,6 +299,22 @@ export class WorkspaceAIWorkspaceService extends APIService { return this.listExecutors(workspaceSlug); } + async createAgentSetupCommand( + workspaceSlug: string, + executorId: string, + options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {} + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/setup-command/`, { + port: String(options.port || "").trim() || undefined, + opsWorkspaceSlug: String(options.opsWorkspaceSlug || "").trim() || undefined, + opsProjectId: String(options.opsProjectId || "").trim() || undefined, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data ?? error; + }); + } + getWindowsAgentInstallerUrl( workspaceSlug: string, executorId: string, diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 3f15f7a..7417944 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -1163,7 +1163,7 @@ .ai-settings-field-row--download { align-items: end; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(96px, 112px) auto; } .ai-settings-field > span { @@ -1409,7 +1409,7 @@ .ai-settings-actions .modal-btn:hover:not(:disabled), .ai-settings-empty-context .modal-btn:hover:not(:disabled), - .ai-settings-inline-download:hover { + .ai-settings-inline-download:hover:not(:disabled) { background: var(--nodedc-glass-control-hover); color: #ffffff; } @@ -1420,11 +1420,21 @@ color: var(--brand-contrast); } + .ai-settings-inline-download.btn-primary { + background: var(--brand); + color: var(--brand-contrast); + } + .ai-settings-actions .modal-btn:disabled { cursor: default; opacity: 0.48; } + .ai-settings-inline-download:disabled { + cursor: default; + opacity: 0.48; + } + .ai-settings-context-pane > .ai-settings-actions, .ai-settings-details-pane > .ai-settings-actions { margin-top: auto;