diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 466ef18..9808d93 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -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:-} diff --git a/plane-src/apps/api/plane/app/urls/__init__.py b/plane-src/apps/api/plane/app/urls/__init__.py index 46b8070..e79a8eb 100644 --- a/plane-src/apps/api/plane/app/urls/__init__.py +++ b/plane-src/apps/api/plane/app/urls/__init__.py @@ -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, diff --git a/plane-src/apps/api/plane/app/urls/ai_workspace.py b/plane-src/apps/api/plane/app/urls/ai_workspace.py new file mode 100644 index 0000000..ea8e62a --- /dev/null +++ b/plane-src/apps/api/plane/app/urls/ai_workspace.py @@ -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//ai-workspace/executors/", + AIWorkspaceExecutorListEndpoint.as_view(), + name="ai-workspace-executors", + ), + path( + "workspaces//ai-workspace/executors//", + AIWorkspaceExecutorDetailEndpoint.as_view(), + name="ai-workspace-executor-detail", + ), + path( + "workspaces//ai-workspace/executors//select/", + AIWorkspaceExecutorSelectEndpoint.as_view(), + name="ai-workspace-executor-select", + ), + path( + "workspaces//ai-workspace/threads/", + AIWorkspaceThreadListEndpoint.as_view(), + name="ai-workspace-threads", + ), + path( + "workspaces//ai-workspace/threads//", + AIWorkspaceThreadDetailEndpoint.as_view(), + name="ai-workspace-thread-detail", + ), + path( + "workspaces//ai-workspace/threads//messages/", + AIWorkspaceThreadMessagesEndpoint.as_view(), + name="ai-workspace-thread-messages", + ), +] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index ca3486f..279459c 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -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, diff --git a/plane-src/apps/api/plane/app/views/ai_workspace.py b/plane-src/apps/api/plane/app/views/ai_workspace.py new file mode 100644 index 0000000..a34261f --- /dev/null +++ b/plane-src/apps/api/plane/app/views/ai_workspace.py @@ -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)