feat: complete ops ai workspace bridge
This commit is contained in:
parent
8b1e356494
commit
3806848b07
|
|
@ -5,10 +5,14 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from plane.app.views import (
|
from plane.app.views import (
|
||||||
|
AIWorkspaceExecutorCheckEndpoint,
|
||||||
AIWorkspaceExecutorDetailEndpoint,
|
AIWorkspaceExecutorDetailEndpoint,
|
||||||
|
AIWorkspaceExecutorEventsEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
|
AIWorkspaceSettingsEndpoint,
|
||||||
|
AIWorkspaceThreadDispatchEndpoint,
|
||||||
AIWorkspaceThreadDetailEndpoint,
|
AIWorkspaceThreadDetailEndpoint,
|
||||||
AIWorkspaceThreadListEndpoint,
|
AIWorkspaceThreadListEndpoint,
|
||||||
AIWorkspaceThreadMessagesEndpoint,
|
AIWorkspaceThreadMessagesEndpoint,
|
||||||
|
|
@ -16,6 +20,11 @@ from plane.app.views import (
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/ai-workspace/settings/",
|
||||||
|
AIWorkspaceSettingsEndpoint.as_view(),
|
||||||
|
name="ai-workspace-settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/ai-workspace/executors/",
|
"workspaces/<str:slug>/ai-workspace/executors/",
|
||||||
AIWorkspaceExecutorListEndpoint.as_view(),
|
AIWorkspaceExecutorListEndpoint.as_view(),
|
||||||
|
|
@ -31,6 +40,16 @@ 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>/check/",
|
||||||
|
AIWorkspaceExecutorCheckEndpoint.as_view(),
|
||||||
|
name="ai-workspace-executor-check",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/events/",
|
||||||
|
AIWorkspaceExecutorEventsEndpoint.as_view(),
|
||||||
|
name="ai-workspace-executor-events",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/windows.ps1",
|
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/windows.ps1",
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
||||||
|
|
@ -51,4 +70,9 @@ urlpatterns = [
|
||||||
AIWorkspaceThreadMessagesEndpoint.as_view(),
|
AIWorkspaceThreadMessagesEndpoint.as_view(),
|
||||||
name="ai-workspace-thread-messages",
|
name="ai-workspace-thread-messages",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/ai-workspace/threads/<uuid:thread_id>/dispatch/",
|
||||||
|
AIWorkspaceThreadDispatchEndpoint.as_view(),
|
||||||
|
name="ai-workspace-thread-dispatch",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -175,10 +175,14 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
from .ai_workspace import (
|
from .ai_workspace import (
|
||||||
|
AIWorkspaceExecutorCheckEndpoint,
|
||||||
AIWorkspaceExecutorDetailEndpoint,
|
AIWorkspaceExecutorDetailEndpoint,
|
||||||
|
AIWorkspaceExecutorEventsEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
|
AIWorkspaceSettingsEndpoint,
|
||||||
|
AIWorkspaceThreadDispatchEndpoint,
|
||||||
AIWorkspaceThreadDetailEndpoint,
|
AIWorkspaceThreadDetailEndpoint,
|
||||||
AIWorkspaceThreadListEndpoint,
|
AIWorkspaceThreadListEndpoint,
|
||||||
AIWorkspaceThreadMessagesEndpoint,
|
AIWorkspaceThreadMessagesEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,31 @@ import os
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import requests
|
import requests
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.app.permissions import ROLE, allow_permission
|
from plane.app.permissions import ROLE, allow_permission
|
||||||
from plane.app.views.base import BaseAPIView
|
from plane.app.views.base import BaseAPIView
|
||||||
|
from plane.app.views.codex_agents import agent_path, get_gateway_config, owner_path, require_workspace, validate_project_in_workspace
|
||||||
|
|
||||||
|
|
||||||
|
AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME = "AI Workspace Ops"
|
||||||
|
AI_WORKSPACE_OPS_MCP_SERVER_NAME = "nodedc_tasker"
|
||||||
|
AI_WORKSPACE_OPS_SCOPES = [
|
||||||
|
"workspace:read",
|
||||||
|
"project:read",
|
||||||
|
"project:member:add_existing",
|
||||||
|
"issue:read",
|
||||||
|
"issue:create",
|
||||||
|
"issue:update",
|
||||||
|
"issue:move",
|
||||||
|
"issue:comment",
|
||||||
|
"issue:label",
|
||||||
|
"issue:assign",
|
||||||
|
"issue:structured_blocks:write",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_ai_workspace_config():
|
def get_ai_workspace_config():
|
||||||
|
|
@ -49,12 +68,321 @@ def user_headers(user):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def ai_workspace_request(request, method, path, payload=None, query_params=None):
|
def ai_workspace_gateway_json(method, path, payload=None):
|
||||||
|
base_url, token, timeout = get_gateway_config()
|
||||||
|
if not base_url or not token:
|
||||||
|
return None, Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "codex_agent_gateway_not_configured",
|
||||||
|
"message": "NODE.DC Codex Agent Gateway URL/token is not configured.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method,
|
||||||
|
f"{base_url}{path}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
return None, Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "codex_agent_gateway_unavailable",
|
||||||
|
"message": "NODE.DC Codex Agent Gateway is unavailable.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {
|
||||||
|
"ok": False,
|
||||||
|
"error": "codex_agent_gateway_invalid_response",
|
||||||
|
"message": "NODE.DC Codex Agent Gateway returned a non-JSON response.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return None, Response(data, status=response.status_code)
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ops_agent(request, create=True):
|
||||||
|
owner = owner_path(request.user)
|
||||||
|
agents_payload, gateway_error = ai_workspace_gateway_json("GET", f"/api/internal/v1/owners/{owner}/agents")
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
|
||||||
|
agent = None
|
||||||
|
for candidate in agents_payload.get("agents") or []:
|
||||||
|
if (
|
||||||
|
candidate.get("display_name") == AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME
|
||||||
|
and candidate.get("status", "active") == "active"
|
||||||
|
):
|
||||||
|
agent = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if agent is None and create:
|
||||||
|
agent_payload, gateway_error = ai_workspace_gateway_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/internal/v1/owners/{owner}/agents",
|
||||||
|
{
|
||||||
|
"display_name": AI_WORKSPACE_OPS_AGENT_DISPLAY_NAME,
|
||||||
|
"owner_email": request.user.email or None,
|
||||||
|
"avatar_url": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
agent = agent_payload.get("agent") or {}
|
||||||
|
|
||||||
|
return agent, None
|
||||||
|
|
||||||
|
|
||||||
|
def replace_ops_agent_project_grants(request, agent_id, grants):
|
||||||
|
owner = owner_path(request.user)
|
||||||
|
_, gateway_error = ai_workspace_gateway_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/internal/v1/owners/{owner}/agents/{agent_path(agent_id)}/grants/replace-projects",
|
||||||
|
{
|
||||||
|
"grants": grants,
|
||||||
|
"scopes": AI_WORKSPACE_OPS_SCOPES,
|
||||||
|
"mode": "voluntary",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
return {"agent_id": agent_id, "grants": grants}, None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ops_agent_project_grant(request, route_slug, ops_workspace_slug=None, ops_project_id=None):
|
||||||
|
ops_workspace_slug = (ops_workspace_slug or request.query_params.get("ops_workspace_slug") or route_slug or "").strip()
|
||||||
|
ops_project_id = (ops_project_id or request.query_params.get("ops_project_id") or "").strip()
|
||||||
|
if not ops_project_id:
|
||||||
|
return {}, None
|
||||||
|
|
||||||
|
workspace, workspace_error = require_workspace(ops_workspace_slug)
|
||||||
|
if workspace_error is not None:
|
||||||
|
return None, workspace_error
|
||||||
|
|
||||||
|
project_error = validate_project_in_workspace(workspace, ops_project_id, request.user)
|
||||||
|
if project_error is not None:
|
||||||
|
return None, project_error
|
||||||
|
|
||||||
|
agent, gateway_error = get_ops_agent(request, create=True)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
|
||||||
|
agent_id = agent.get("id")
|
||||||
|
if not agent_id:
|
||||||
|
return None, Response(
|
||||||
|
{"ok": False, "error": "ai_workspace_ops_agent_missing"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
grant_state, gateway_error = replace_ops_agent_project_grants(
|
||||||
|
request,
|
||||||
|
agent_id,
|
||||||
|
[{"workspace_slug": ops_workspace_slug, "project_id": ops_project_id}],
|
||||||
|
)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
|
||||||
|
return {**grant_state, "ops_workspace_slug": ops_workspace_slug, "ops_project_id": ops_project_id}, None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_ops_agent_project_grants(request):
|
||||||
|
agent, gateway_error = get_ops_agent(request, create=False)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
if not agent:
|
||||||
|
return {}, None
|
||||||
|
|
||||||
|
agent_id = agent.get("id")
|
||||||
|
if not agent_id:
|
||||||
|
return {}, None
|
||||||
|
return replace_ops_agent_project_grants(request, agent_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
def create_ops_mcp_token_for_request(request, agent_id):
|
||||||
|
owner = owner_path(request.user)
|
||||||
|
token_payload, gateway_error = ai_workspace_gateway_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/internal/v1/owners/{owner}/agents/{agent_path(agent_id)}/tokens",
|
||||||
|
{"name": "AI Workspace Bridge"},
|
||||||
|
)
|
||||||
|
if gateway_error is not None:
|
||||||
|
return None, gateway_error
|
||||||
|
|
||||||
|
mcp_server = ((token_payload.get("setup") or {}).get("mcp_server") or {})
|
||||||
|
mcp_url = mcp_server.get("url")
|
||||||
|
mcp_token = token_payload.get("token")
|
||||||
|
if not mcp_url or not mcp_token:
|
||||||
|
return None, Response(
|
||||||
|
{"ok": False, "error": "ai_workspace_ops_mcp_setup_missing"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"opsMcpUrl": mcp_url,
|
||||||
|
"opsMcpToken": mcp_token,
|
||||||
|
"opsMcpServerName": mcp_server.get("name") or AI_WORKSPACE_OPS_MCP_SERVER_NAME,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ai_workspace_settings_data(request):
|
||||||
|
response = ai_workspace_request(request, "GET", "/settings")
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return None, response
|
||||||
|
data = getattr(response, "data", None)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, Response(
|
||||||
|
{"ok": False, "error": "ai_workspace_assistant_invalid_response"},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def settings_has_ops_mcp_server(settings_data):
|
||||||
|
if not isinstance(settings_data, dict):
|
||||||
|
return False
|
||||||
|
settings = settings_data.get("settings") or {}
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
return False
|
||||||
|
metadata = settings.get("metadata") or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_ops_server(server):
|
||||||
|
if not isinstance(server, dict):
|
||||||
|
return False
|
||||||
|
server_name = (server.get("serverName") or server.get("server_name") or server.get("name") or "").strip()
|
||||||
|
app_id = (server.get("appId") or server.get("app_id") or "").strip().lower()
|
||||||
|
return bool(server.get("url")) and (app_id == "ops" or server_name == AI_WORKSPACE_OPS_MCP_SERVER_NAME)
|
||||||
|
|
||||||
|
def has_ops_server(value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(is_ops_server(item) for item in value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(is_ops_server(item) for item in value.values())
|
||||||
|
return False
|
||||||
|
|
||||||
|
app_grants = metadata.get("appGrants") or {}
|
||||||
|
ops_grant = app_grants.get("ops") if isinstance(app_grants, dict) else {}
|
||||||
|
if isinstance(ops_grant, dict) and has_ops_server(ops_grant.get("mcpServers")):
|
||||||
|
return True
|
||||||
|
return has_ops_server(metadata.get("mcpServers"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_ops_app_grant(active_context, ops_mcp_params=None):
|
||||||
|
context = active_context if isinstance(active_context, dict) else {}
|
||||||
|
grant = {
|
||||||
|
"appId": "ops",
|
||||||
|
"appTitle": "NODE.DC Ops",
|
||||||
|
"surface": "ops",
|
||||||
|
"updatedAt": timezone.now().isoformat(),
|
||||||
|
"context": {
|
||||||
|
"opsWorkspaceSlug": context.get("opsWorkspaceSlug") or context.get("workspaceSlug") or "",
|
||||||
|
"opsRouteWorkspaceSlug": context.get("opsRouteWorkspaceSlug") or "",
|
||||||
|
"opsProjectId": context.get("opsProjectId") or "",
|
||||||
|
"opsProjectIdentifier": context.get("opsProjectIdentifier") or "",
|
||||||
|
"opsProjectSlug": context.get("opsProjectSlug") or "",
|
||||||
|
"opsProjectTitle": context.get("opsProjectTitle") or "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if not ops_mcp_params:
|
||||||
|
return grant
|
||||||
|
mcp_url = ops_mcp_params.get("opsMcpUrl")
|
||||||
|
mcp_token = ops_mcp_params.get("opsMcpToken")
|
||||||
|
if not mcp_url or not mcp_token:
|
||||||
|
return grant
|
||||||
|
grant["mcpServers"] = [
|
||||||
|
{
|
||||||
|
"serverName": ops_mcp_params.get("opsMcpServerName") or AI_WORKSPACE_OPS_MCP_SERVER_NAME,
|
||||||
|
"url": mcp_url,
|
||||||
|
"enabled": True,
|
||||||
|
"required": False,
|
||||||
|
"startupTimeoutSec": 20,
|
||||||
|
"toolTimeoutSec": 60,
|
||||||
|
"httpHeaders": {
|
||||||
|
"Authorization": f"Bearer {mcp_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"MCP-Protocol-Version": "2025-06-18",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return grant
|
||||||
|
|
||||||
|
|
||||||
|
def attach_ops_grant_for_active_context(request, slug, payload, settings_data=None):
|
||||||
|
active_context = payload.get("activeContext") or {}
|
||||||
|
if not isinstance(active_context, dict):
|
||||||
|
active_context = {}
|
||||||
|
|
||||||
|
ops_project_id = (active_context.get("opsProjectId") or "").strip()
|
||||||
|
if settings_data is None:
|
||||||
|
settings_data, settings_error = get_ai_workspace_settings_data(request)
|
||||||
|
if settings_error is not None:
|
||||||
|
return None, settings_error
|
||||||
|
|
||||||
|
if not ops_project_id:
|
||||||
|
if settings_has_ops_mcp_server(settings_data):
|
||||||
|
_, clear_error = clear_ops_agent_project_grants(request)
|
||||||
|
if clear_error is not None:
|
||||||
|
return None, clear_error
|
||||||
|
return attach_ops_app_grant(payload, build_ops_app_grant(active_context)), None
|
||||||
|
|
||||||
|
grant_state, grant_error = ensure_ops_agent_project_grant(
|
||||||
|
request,
|
||||||
|
slug,
|
||||||
|
ops_workspace_slug=active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug,
|
||||||
|
ops_project_id=ops_project_id,
|
||||||
|
)
|
||||||
|
if grant_error is not None:
|
||||||
|
return None, grant_error
|
||||||
|
|
||||||
|
ops_mcp_params = None
|
||||||
|
agent_id = (grant_state or {}).get("agent_id")
|
||||||
|
if agent_id and not settings_has_ops_mcp_server(settings_data):
|
||||||
|
ops_mcp_params, ops_mcp_error = create_ops_mcp_token_for_request(request, agent_id)
|
||||||
|
if ops_mcp_error is not None:
|
||||||
|
return None, ops_mcp_error
|
||||||
|
|
||||||
|
return attach_ops_app_grant(payload, build_ops_app_grant(active_context, ops_mcp_params)), None
|
||||||
|
|
||||||
|
|
||||||
|
def attach_ops_app_grant(payload, app_grant):
|
||||||
|
if not app_grant:
|
||||||
|
return payload
|
||||||
|
metadata = payload.get("metadata") or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
app_grants = metadata.get("appGrants") or {}
|
||||||
|
if not isinstance(app_grants, dict):
|
||||||
|
app_grants = {}
|
||||||
|
metadata["appGrants"] = {
|
||||||
|
**app_grants,
|
||||||
|
"ops": app_grant,
|
||||||
|
}
|
||||||
|
payload["metadata"] = metadata
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def ai_workspace_request(request, method, path, payload=None, query_params=None, timeout_override=None):
|
||||||
config, error_response = require_ai_workspace_config()
|
config, error_response = require_ai_workspace_config()
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
base_url, token, timeout = config
|
base_url, token, timeout = config
|
||||||
|
request_timeout = timeout_override or timeout
|
||||||
try:
|
try:
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
method,
|
method,
|
||||||
|
|
@ -66,7 +394,7 @@ def ai_workspace_request(request, method, path, payload=None, query_params=None)
|
||||||
},
|
},
|
||||||
params=query_params,
|
params=query_params,
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=timeout,
|
timeout=request_timeout,
|
||||||
)
|
)
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -95,10 +423,30 @@ def ops_thread_payload(raw_payload, slug):
|
||||||
active_context = payload.get("activeContext") or payload.get("active_context") or {}
|
active_context = payload.get("activeContext") or payload.get("active_context") or {}
|
||||||
if not isinstance(active_context, dict):
|
if not isinstance(active_context, dict):
|
||||||
active_context = {}
|
active_context = {}
|
||||||
|
ops_workspace_slug = active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug
|
||||||
active_context = {
|
active_context = {
|
||||||
**active_context,
|
**active_context,
|
||||||
"surface": "ops",
|
"surface": "ops",
|
||||||
"workspaceSlug": slug,
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
}
|
||||||
|
contexts = active_context.get("contexts")
|
||||||
|
if not isinstance(contexts, dict):
|
||||||
|
contexts = {}
|
||||||
|
ops_context = contexts.get("ops")
|
||||||
|
if not isinstance(ops_context, dict):
|
||||||
|
ops_context = {}
|
||||||
|
active_context["contexts"] = {
|
||||||
|
**contexts,
|
||||||
|
"ops": {
|
||||||
|
**ops_context,
|
||||||
|
**active_context,
|
||||||
|
"surface": "ops",
|
||||||
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or []
|
tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or []
|
||||||
|
|
@ -113,6 +461,64 @@ def ops_thread_payload(raw_payload, slug):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def ops_settings_payload(raw_payload, slug):
|
||||||
|
payload = dict(raw_payload or {})
|
||||||
|
active_context = payload.get("activeContext") or payload.get("active_context") or {}
|
||||||
|
if not isinstance(active_context, dict):
|
||||||
|
active_context = {}
|
||||||
|
ops_workspace_slug = active_context.get("opsWorkspaceSlug") or active_context.get("workspaceSlug") or slug
|
||||||
|
active_context = {
|
||||||
|
**active_context,
|
||||||
|
"surface": "ops",
|
||||||
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts = active_context.get("contexts")
|
||||||
|
if not isinstance(contexts, dict):
|
||||||
|
contexts = {}
|
||||||
|
ops_context = contexts.get("ops")
|
||||||
|
if not isinstance(ops_context, dict):
|
||||||
|
ops_context = {}
|
||||||
|
active_context["contexts"] = {
|
||||||
|
**contexts,
|
||||||
|
"ops": {
|
||||||
|
**ops_context,
|
||||||
|
**active_context,
|
||||||
|
"surface": "ops",
|
||||||
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or []
|
||||||
|
if not isinstance(tool_packs, list):
|
||||||
|
tool_packs = []
|
||||||
|
for tool_pack in ("ops", "engine"):
|
||||||
|
if tool_pack not in tool_packs:
|
||||||
|
tool_packs.append(tool_pack)
|
||||||
|
|
||||||
|
payload["activeContext"] = active_context
|
||||||
|
payload["enabledToolPacks"] = tool_packs
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkspaceSettingsEndpoint(BaseAPIView):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def get(self, request, slug):
|
||||||
|
return ai_workspace_request(request, "GET", "/settings")
|
||||||
|
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def patch(self, request, slug):
|
||||||
|
payload = ops_settings_payload(request.data, slug)
|
||||||
|
payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, payload)
|
||||||
|
if ops_grant_error is not None:
|
||||||
|
return ops_grant_error
|
||||||
|
return ai_workspace_request(request, "PATCH", "/settings", payload)
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceExecutorListEndpoint(BaseAPIView):
|
class AIWorkspaceExecutorListEndpoint(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):
|
||||||
|
|
@ -139,6 +545,34 @@ 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 AIWorkspaceExecutorCheckEndpoint(BaseAPIView):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def post(self, request, slug, executor_id):
|
||||||
|
return ai_workspace_request(
|
||||||
|
request,
|
||||||
|
"POST",
|
||||||
|
f"/executors/{executor_id}/check",
|
||||||
|
request.data,
|
||||||
|
timeout_override=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkspaceExecutorEventsEndpoint(BaseAPIView):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def get(self, request, slug, executor_id):
|
||||||
|
query_params = {
|
||||||
|
"since": request.query_params.get("since") or "0",
|
||||||
|
"limit": request.query_params.get("limit") or "100",
|
||||||
|
}
|
||||||
|
return ai_workspace_request(
|
||||||
|
request,
|
||||||
|
"GET",
|
||||||
|
f"/executors/{executor_id}/events",
|
||||||
|
query_params=query_params,
|
||||||
|
timeout_override=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
class AIWorkspaceExecutorWindowsAgentEndpoint(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, executor_id):
|
def get(self, request, slug, executor_id):
|
||||||
|
|
@ -150,6 +584,34 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||||
params = {}
|
params = {}
|
||||||
if request.query_params.get("port"):
|
if request.query_params.get("port"):
|
||||||
params["port"] = 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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1",
|
f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1",
|
||||||
|
|
@ -185,9 +647,11 @@ 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):
|
||||||
query_params = {
|
query_params = {
|
||||||
"surface": request.query_params.get("surface") or "ops",
|
"kind": request.query_params.get("kind") or "shared",
|
||||||
"limit": request.query_params.get("limit") or "100",
|
"limit": request.query_params.get("limit") or "100",
|
||||||
}
|
}
|
||||||
|
if request.query_params.get("surface"):
|
||||||
|
query_params["surface"] = request.query_params.get("surface")
|
||||||
return ai_workspace_request(request, "GET", "/threads", query_params=query_params)
|
return ai_workspace_request(request, "GET", "/threads", query_params=query_params)
|
||||||
|
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
|
@ -218,3 +682,27 @@ class AIWorkspaceThreadMessagesEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def post(self, request, slug, thread_id):
|
def post(self, request, slug, thread_id):
|
||||||
return ai_workspace_request(request, "POST", f"/threads/{thread_id}/messages", request.data)
|
return ai_workspace_request(request, "POST", f"/threads/{thread_id}/messages", request.data)
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkspaceThreadDispatchEndpoint(BaseAPIView):
|
||||||
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
|
def post(self, request, slug, thread_id):
|
||||||
|
payload = dict(request.data or {})
|
||||||
|
context = payload.get("context") or {}
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
context = {}
|
||||||
|
ops_workspace_slug = context.get("opsWorkspaceSlug") or context.get("workspaceSlug") or slug
|
||||||
|
payload["context"] = {
|
||||||
|
**context,
|
||||||
|
"surface": "ops",
|
||||||
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
}
|
||||||
|
return ai_workspace_request(
|
||||||
|
request,
|
||||||
|
"POST",
|
||||||
|
f"/threads/{thread_id}/dispatch",
|
||||||
|
payload,
|
||||||
|
timeout_override=20,
|
||||||
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,8 @@ import type {
|
||||||
TAIWorkspaceExecutorConnectionMode,
|
TAIWorkspaceExecutorConnectionMode,
|
||||||
TAIWorkspaceExecutorInput,
|
TAIWorkspaceExecutorInput,
|
||||||
TAIWorkspaceExecutorListResponse,
|
TAIWorkspaceExecutorListResponse,
|
||||||
|
TAIWorkspaceOpsProject,
|
||||||
|
TAIWorkspaceOpsWorkspace,
|
||||||
} from "@/services/workspace-ai-workspace.service";
|
} from "@/services/workspace-ai-workspace.service";
|
||||||
|
|
||||||
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
|
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
|
||||||
|
|
@ -43,8 +45,15 @@ type TExecutorDraft = {
|
||||||
|
|
||||||
type TAIWorkspaceProductSettingsModalProps = {
|
type TAIWorkspaceProductSettingsModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
workspaceSlug: string;
|
||||||
executors: TAIWorkspaceExecutor[];
|
executors: TAIWorkspaceExecutor[];
|
||||||
selectedExecutorId?: string | null;
|
selectedExecutorId?: string | null;
|
||||||
|
workspaceOptions: TAIWorkspaceOpsWorkspace[];
|
||||||
|
selectedOpsWorkspaceSlug: string;
|
||||||
|
workspaceLoadError?: string;
|
||||||
|
projectOptions: TAIWorkspaceOpsProject[];
|
||||||
|
selectedProjectId: string;
|
||||||
|
projectLoadError?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
notice?: string;
|
notice?: string;
|
||||||
|
|
@ -53,11 +62,17 @@ type TAIWorkspaceProductSettingsModalProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onReload: () => Promise<unknown> | unknown;
|
onReload: () => Promise<unknown> | unknown;
|
||||||
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
|
onSelect: (executor: TAIWorkspaceExecutor) => Promise<unknown> | unknown;
|
||||||
|
onCheck: (executor: TAIWorkspaceExecutor) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
onCreate: (input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
onUpdate: (executorId: string, input: TAIWorkspaceExecutorInput) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
onDelete: (executorId: string) => 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>;
|
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 {
|
function emptyDraft(): TExecutorDraft {
|
||||||
|
|
@ -108,11 +123,37 @@ function statusLabel(status: string | null | undefined) {
|
||||||
return "Unknown";
|
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) {
|
function errorText(error: unknown) {
|
||||||
if (!error) return "";
|
if (!error) return "";
|
||||||
return String((error as any)?.message || (error as any)?.error || error);
|
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>({
|
function AIWorkspaceGlassSelect<T extends string>({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
|
|
@ -260,8 +301,15 @@ function AIWorkspaceGlassSelect<T extends string>({
|
||||||
|
|
||||||
export function AIWorkspaceProductSettingsModal({
|
export function AIWorkspaceProductSettingsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
|
workspaceSlug,
|
||||||
executors,
|
executors,
|
||||||
selectedExecutorId,
|
selectedExecutorId,
|
||||||
|
workspaceOptions,
|
||||||
|
selectedOpsWorkspaceSlug,
|
||||||
|
workspaceLoadError,
|
||||||
|
projectOptions,
|
||||||
|
selectedProjectId,
|
||||||
|
projectLoadError,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
notice,
|
notice,
|
||||||
|
|
@ -270,9 +318,12 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
onClose,
|
onClose,
|
||||||
onReload,
|
onReload,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onCheck,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpsWorkspaceChange,
|
||||||
|
onProjectChange,
|
||||||
onDownloadAgent,
|
onDownloadAgent,
|
||||||
getWindowsAgentInstallerUrl,
|
getWindowsAgentInstallerUrl,
|
||||||
}: TAIWorkspaceProductSettingsModalProps) {
|
}: TAIWorkspaceProductSettingsModalProps) {
|
||||||
|
|
@ -290,6 +341,37 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
);
|
);
|
||||||
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
|
const detailsOpen = detailsMode === "new" || detailsMode === "edit";
|
||||||
const isBusy = Boolean(busyId);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
|
@ -321,6 +403,11 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
setDraft(draftFromExecutor(activeExecutor));
|
setDraft(draftFromExecutor(activeExecutor));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkActive = () => {
|
||||||
|
if (!activeExecutor) return;
|
||||||
|
void onCheck(activeExecutor);
|
||||||
|
};
|
||||||
|
|
||||||
const saveDraft = async (event: FormEvent) => {
|
const saveDraft = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const input = toInput(draft);
|
const input = toInput(draft);
|
||||||
|
|
@ -363,6 +450,10 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeCheckDetails = activeExecutor
|
||||||
|
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalCore
|
<ModalCore
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|
@ -451,18 +542,62 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
<div>
|
<div>
|
||||||
<span>Активное устройство</span>
|
<span>Активное устройство</span>
|
||||||
<b>{activeExecutor.name}</b>
|
<b>{activeExecutor.name}</b>
|
||||||
<small>Codex worker · Codex Remote</small>
|
<small>Codex worker · Ops</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ai-settings-context-note ai-settings-context-note--remote">
|
<div className="ai-settings-field-row">
|
||||||
Прямое управление Codex-сеансами устройства.
|
<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>
|
</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">
|
<div className="modal-actions ai-settings-actions">
|
||||||
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
|
<button type="button" className="modal-btn" disabled={isBusy} onClick={editActive}>
|
||||||
Настройки
|
Настройки
|
||||||
</button>
|
</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 type="button" className="modal-btn btn-primary" onClick={onClose}>
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,66 @@ export type TAIWorkspaceThreadMessageResponse = {
|
||||||
message: TAIWorkspaceThreadMessage;
|
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 = {
|
export type TAIWorkspaceThreadInput = {
|
||||||
title: string;
|
title: string;
|
||||||
originSurface?: "engine" | "ops" | "global";
|
originSurface?: "engine" | "ops" | "global";
|
||||||
|
|
@ -113,6 +173,65 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
super(API_BASE_URL);
|
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> {
|
async listExecutors(workspaceSlug: string): Promise<TAIWorkspaceExecutorListResponse> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`)
|
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
@ -158,10 +277,25 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
return this.listExecutors(workspaceSlug);
|
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 params = new URLSearchParams({ v: `${Date.now()}` });
|
||||||
const port = String(options.port || "").trim();
|
const port = String(options.port || "").trim();
|
||||||
if (port) params.set("port", port);
|
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()}`;
|
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 } = {}
|
options: { surface?: "ops" | "engine" | "global"; limit?: number } = {}
|
||||||
): Promise<TAIWorkspaceThreadListResponse> {
|
): Promise<TAIWorkspaceThreadListResponse> {
|
||||||
const params = new URLSearchParams();
|
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));
|
params.set("limit", String(options.limit || 100));
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/?${params.toString()}`)
|
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/?${params.toString()}`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
@ -182,7 +316,7 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
async createThread(workspaceSlug: string, input: TAIWorkspaceThreadInput): Promise<TAIWorkspaceThreadResponse> {
|
async createThread(workspaceSlug: string, input: TAIWorkspaceThreadInput): Promise<TAIWorkspaceThreadResponse> {
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/`, {
|
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/`, {
|
||||||
originSurface: "ops",
|
originSurface: "ops",
|
||||||
enabledToolPacks: ["ops"],
|
enabledToolPacks: ["ops", "engine"],
|
||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
@ -228,4 +362,40 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
throw error?.response?.data ?? error;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -968,6 +968,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-settings-executor-row__title span,
|
.ai-settings-executor-row__title span,
|
||||||
|
|
@ -984,7 +985,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-settings-executor-row[data-active="true"] small {
|
.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 {
|
.ai-status-dot {
|
||||||
|
|
@ -1013,6 +1014,20 @@
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
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 {
|
.ai-executor-status {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
@ -1904,6 +1919,24 @@
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.nodedc-ai-workspace-console .ai-console-rich {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue