feat: proxy ops ai workspace assistant state

This commit is contained in:
DCCONSTRUCTIONS 2026-06-09 11:52:07 +03:00
parent 14caa50088
commit ee4a959a53
5 changed files with 239 additions and 0 deletions

View File

@ -82,6 +82,9 @@ x-app-env: &app-env
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL:-}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN:-}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS:-8}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details.
from .analytic import urlpatterns as analytic_urls
from .ai_workspace import urlpatterns as ai_workspace_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .codex_agents import urlpatterns as codex_agent_urls
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [
*analytic_urls,
*ai_workspace_urls,
*asset_urls,
*codex_agent_urls,
*cycle_urls,

View File

@ -0,0 +1,48 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
AIWorkspaceExecutorDetailEndpoint,
AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceThreadDetailEndpoint,
AIWorkspaceThreadListEndpoint,
AIWorkspaceThreadMessagesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/ai-workspace/executors/",
AIWorkspaceExecutorListEndpoint.as_view(),
name="ai-workspace-executors",
),
path(
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/",
AIWorkspaceExecutorDetailEndpoint.as_view(),
name="ai-workspace-executor-detail",
),
path(
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/select/",
AIWorkspaceExecutorSelectEndpoint.as_view(),
name="ai-workspace-executor-select",
),
path(
"workspaces/<str:slug>/ai-workspace/threads/",
AIWorkspaceThreadListEndpoint.as_view(),
name="ai-workspace-threads",
),
path(
"workspaces/<str:slug>/ai-workspace/threads/<uuid:thread_id>/",
AIWorkspaceThreadDetailEndpoint.as_view(),
name="ai-workspace-thread-detail",
),
path(
"workspaces/<str:slug>/ai-workspace/threads/<uuid:thread_id>/messages/",
AIWorkspaceThreadMessagesEndpoint.as_view(),
name="ai-workspace-thread-messages",
),
]

View File

@ -174,6 +174,15 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint
from .ai_workspace import (
AIWorkspaceExecutorDetailEndpoint,
AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceThreadDetailEndpoint,
AIWorkspaceThreadListEndpoint,
AIWorkspaceThreadMessagesEndpoint,
)
from .codex_agents import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,

View File

@ -0,0 +1,177 @@
# Python imports
import os
# Third party imports
import requests
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
def get_ai_workspace_config():
base_url = (
os.environ.get("PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL", "").strip()
or os.environ.get("NODEDC_AI_WORKSPACE_ASSISTANT_URL", "").strip()
or os.environ.get("AI_WORKSPACE_ASSISTANT_URL", "").strip()
).rstrip("/")
token = (
os.environ.get("PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN", "").strip()
or os.environ.get("NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN", "").strip()
or os.environ.get("AI_WORKSPACE_ASSISTANT_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
)
timeout = float(os.environ.get("PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS", "8") or "8")
return base_url, token, timeout
def require_ai_workspace_config():
base_url, token, timeout = get_ai_workspace_config()
if not base_url or not token:
return None, Response(
{
"ok": False,
"error": "ai_workspace_assistant_not_configured",
"message": "NODE.DC AI Workspace Assistant URL/token is not configured.",
},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return (base_url, token, timeout), None
def user_headers(user):
return {
"X-NODEDC-User-Id": str(user.id),
"X-NODEDC-User-Email": user.email or "",
}
def ai_workspace_request(request, method, path, payload=None, query_params=None):
config, error_response = require_ai_workspace_config()
if error_response is not None:
return error_response
base_url, token, timeout = config
try:
response = requests.request(
method,
f"{base_url}/api/ai-workspace/assistant/v1{path}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
**user_headers(request.user),
},
params=query_params,
json=payload,
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,
)
try:
data = response.json()
except ValueError:
data = {
"ok": False,
"error": "ai_workspace_assistant_invalid_response",
"message": "NODE.DC AI Workspace Assistant returned a non-JSON response.",
}
return Response(data, status=response.status_code)
def ops_thread_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 = {}
active_context = {
**active_context,
"surface": "ops",
"workspaceSlug": slug,
}
tool_packs = payload.get("enabledToolPacks") or payload.get("enabled_tool_packs") or []
if not isinstance(tool_packs, list):
tool_packs = []
if "ops" not in tool_packs:
tool_packs = [*tool_packs, "ops"]
payload["originSurface"] = payload.get("originSurface") or payload.get("origin_surface") or "ops"
payload["activeContext"] = active_context
payload["enabledToolPacks"] = tool_packs
return payload
class AIWorkspaceExecutorListEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug):
return ai_workspace_request(request, "GET", "/executors")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
return ai_workspace_request(request, "POST", "/executors", request.data)
class AIWorkspaceExecutorDetailEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, executor_id):
return ai_workspace_request(request, "PATCH", f"/executors/{executor_id}", request.data)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def delete(self, request, slug, executor_id):
return ai_workspace_request(request, "DELETE", f"/executors/{executor_id}")
class AIWorkspaceExecutorSelectEndpoint(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}/select")
class AIWorkspaceThreadListEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug):
query_params = {
"surface": request.query_params.get("surface") or "ops",
"limit": request.query_params.get("limit") or "100",
}
return ai_workspace_request(request, "GET", "/threads", query_params=query_params)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
return ai_workspace_request(request, "POST", "/threads", ops_thread_payload(request.data, slug))
class AIWorkspaceThreadDetailEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, thread_id):
return ai_workspace_request(request, "GET", f"/threads/{thread_id}")
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, thread_id):
return ai_workspace_request(request, "PATCH", f"/threads/{thread_id}", ops_thread_payload(request.data, slug))
class AIWorkspaceThreadMessagesEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, thread_id):
query_params = {
"limit": request.query_params.get("limit") or "200",
}
if request.query_params.get("before"):
query_params["before"] = request.query_params.get("before")
return ai_workspace_request(request, "GET", f"/threads/{thread_id}/messages", query_params=query_params)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, thread_id):
return ai_workspace_request(request, "POST", f"/threads/{thread_id}/messages", request.data)