Compare commits

...

24 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 43e5f570e5 feat: add Ops AI Workspace npm setup command 2026-06-24 20:40:12 +03:00
DCCONSTRUCTIONS 763b90f3ba feat: add Ops Codex terminal setup UI 2026-06-24 20:12:09 +03:00
DCCONSTRUCTIONS df4767a085 Sync BIM attachment versions from viewer storage 2026-06-23 11:32:49 +03:00
DCCONSTRUCTIONS 4ed460ae45 Build Tasker BIM iframe against production domain 2026-06-22 13:09:23 +03:00
DCCONSTRUCTIONS 477626c597 Send BIM viewer requests with credentials 2026-06-18 21:43:04 +03:00
DCCONSTRUCTIONS 3806848b07 feat: complete ops ai workspace bridge 2026-06-13 08:46:55 +03:00
DCCONSTRUCTIONS 8b1e356494 fix: connect tasker backend to ai workspace network 2026-06-09 15:48:11 +03:00
DCCONSTRUCTIONS fcd014e26d fix: align ops ai workspace settings ui 2026-06-09 14:49:50 +03:00
DCCONSTRUCTIONS b1d19cf5df fix: open ai workspace console in ops 2026-06-09 14:35:34 +03:00
DCCONSTRUCTIONS 342df60c24 refactor: split ops ai workspace settings modal 2026-06-09 14:06:14 +03:00
DCCONSTRUCTIONS 097012ae31 fix: align ops ai workspace settings UI 2026-06-09 13:51:48 +03:00
DCCONSTRUCTIONS 71979526cf feat: add standalone ops ai workspace setup 2026-06-09 13:23:02 +03:00
DCCONSTRUCTIONS 6c119aae04 feat: add ops ai workspace dock control 2026-06-09 12:48:06 +03:00
DCCONSTRUCTIONS ee4a959a53 feat: proxy ops ai workspace assistant state 2026-06-09 11:52:07 +03:00
DCCONSTRUCTIONS 14caa50088 chore: checkpoint before cross assistant work 2026-06-09 11:33:47 +03:00
DCCONSTRUCTIONS 2ad9e7f85c Sync Beam viewer resize transition 2026-06-05 18:55:54 +03:00
DCCONSTRUCTIONS e4fd2a5eff Fix Beam viewer side panel teardown 2026-06-05 18:43:16 +03:00
DCCONSTRUCTIONS 81abcb5b36 feat: refine Beam attachments and peek layouts 2026-06-05 14:45:48 +03:00
DCCONSTRUCTIONS ff30eeb8fd fix: lock Beam viewer background gestures 2026-06-05 13:11:32 +03:00
DCCONSTRUCTIONS 867f7bc558 feat: embed Beam viewer in issue peek 2026-06-05 12:46:47 +03:00
DCCONSTRUCTIONS 15c9fb1df1 beam: pass model settings source to viewer 2026-06-05 01:58:06 +03:00
DCCONSTRUCTIONS 31dca29560 beam: persist ready viewer attachments 2026-06-05 01:36:34 +03:00
DCCONSTRUCTIONS 677916389f beam: refine model attachment status icon 2026-06-04 22:49:43 +03:00
DCCONSTRUCTIONS 3e41c451c1 beam: integrate model attachments with Beam viewer 2026-06-04 19:39:36 +03:00
36 changed files with 8892 additions and 354 deletions

View File

@ -110,7 +110,11 @@ pnpm turbo run check:types --filter=web --filter='@plane/i18n' --filter='@plane/
```bash
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web .
docker build \
--build-arg VITE_BEAM_VIEWER_BASE_URL=http://localhost:8080 \
--build-arg VITE_BEAM_API_BASE_URL=http://localhost:8080 \
-t nodedc/plane-frontend:ru \
-f apps/web/Dockerfile.web .
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
./setup.sh stop
./setup.sh start

View File

@ -21,8 +21,11 @@ The same codebase supports local and production-like runs. The deployment mode i
Frontend brand and launcher integration:
- `VITE_NODEDC_LAUNCHER_URL` points the web/admin bundles to Launcher.
- `VITE_BEAM_VIEWER_BASE_URL` and `VITE_BEAM_API_BASE_URL` point the web bundle to BIM Viewer for issue attachments/iframe.
- Production default for NAS builds: `https://hub.nodedc.ru`
- Production BIM default for NAS builds: `https://bim.nodedc.tech`
- Local default in source helpers: `http://launcher.local.nodedc`
- Local BIM default in source helpers: `http://localhost:8080`
Backend integration:
@ -32,6 +35,19 @@ Backend integration:
- `PLANE_NODEDC_WORKSPACE_POLICY_URL`
- `PLANE_NODEDC_ACCESS_CHECK_URL`
- `PLANE_NODEDC_ACCESS_TOKEN`
- `NODEDC_ENGINE_DOCKER_NETWORK`
- `PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL`
- `PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN`
AI Workspace Console uses the shared Platform `ai-workspace-assistant` service. Tasker does not connect to AI Workspace Hub directly. The Tasker backend containers must be attached to the same Docker network as Platform/Engine through `NODEDC_ENGINE_DOCKER_NETWORK`; on NAS this is currently `nodedc-demo_default`, while local development uses `nodedc-platform_default`.
Use the internal service URL on that shared network:
```env
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
```
The Assistant generates Codex worker installers with the public Hub endpoint `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`. Do not point Tasker directly at the Hub and do not expose Assistant as a public Nginx/DSM route.
Production secrets remain in NAS env files or a secret store. They must not be committed.
@ -50,6 +66,8 @@ Local/staging-style rebuild can override integration endpoints:
```sh
VITE_NODEDC_LAUNCHER_URL=http://launcher.local.nodedc \
VITE_BEAM_VIEWER_BASE_URL=http://localhost:8080 \
VITE_BEAM_API_BASE_URL=http://localhost:8080 \
SMOKE_BASE_URL=http://task.local.nodedc:18080 \
SMOKE_RESOLVE= \
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 \

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:-}
@ -159,6 +162,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
worker:
image: nodedc/plane-backend:local
@ -179,6 +185,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
beat-worker:
image: nodedc/plane-backend:local
@ -199,6 +208,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
migrator:
image: nodedc/plane-backend:local
@ -217,6 +229,9 @@ services:
depends_on:
- plane-db
- plane-redis
networks:
- default
- nodedc-engine
# Comment this if you already have a database running
plane-db:
@ -304,3 +319,9 @@ volumes:
rabbitmq_data:
proxy_config:
proxy_data:
networks:
default:
nodedc-engine:
external: true
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}

View File

@ -120,3 +120,7 @@ PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-platform_default
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=dev-ai-workspace-assistant-token
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8

View File

@ -88,3 +88,9 @@ LIVE_SERVER_SECRET_KEY=CHANGE_ME_BEFORE_PRODUCTION
DOCKERHUB_USER=makeplane
PULL_POLICY=if_not_present
CUSTOM_BUILD=false
# NODE.DC AI Workspace Assistant
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-platform_default
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8

View File

@ -97,4 +97,8 @@ PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-demo_default
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=replace-with-ai-workspace-assistant-token
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user

View File

@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \
VITE_BEAM_VIEWER_BASE_URL="${VITE_BEAM_VIEWER_BASE_URL:-http://localhost:8080}" \
VITE_BEAM_API_BASE_URL="${VITE_BEAM_API_BASE_URL:-http://localhost:8080}" \
VITE_NODEDC_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
pnpm --filter web build

View File

@ -1,4 +1,5 @@
FROM python:3.12.10-alpine
LABEL nodedc.deploy.cache_bust="codex-agent-setup-20260624"
# set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
@ -44,6 +45,7 @@ COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
RUN grep -R "setup-codes" /code/plane/app/urls/codex_agents.py >/dev/null
COPY ./bin ./bin/

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,84 @@
# 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 (
AIWorkspaceExecutorCheckEndpoint,
AIWorkspaceExecutorDetailEndpoint,
AIWorkspaceExecutorEventsEndpoint,
AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceExecutorSetupCommandEndpoint,
AIWorkspaceExecutorWindowsAgentEndpoint,
AIWorkspaceSettingsEndpoint,
AIWorkspaceThreadDispatchEndpoint,
AIWorkspaceThreadDetailEndpoint,
AIWorkspaceThreadListEndpoint,
AIWorkspaceThreadMessagesEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/ai-workspace/settings/",
AIWorkspaceSettingsEndpoint.as_view(),
name="ai-workspace-settings",
),
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/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(
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/windows.ps1",
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
name="ai-workspace-executor-windows-agent",
),
path(
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/setup-command/",
AIWorkspaceExecutorSetupCommandEndpoint.as_view(),
name="ai-workspace-executor-setup-command",
),
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",
),
path(
"workspaces/<str:slug>/ai-workspace/threads/<uuid:thread_id>/dispatch/",
AIWorkspaceThreadDispatchEndpoint.as_view(),
name="ai-workspace-thread-dispatch",
),
]

View File

@ -12,6 +12,7 @@ from plane.app.views import (
CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupCodeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,
@ -69,4 +70,9 @@ urlpatterns = [
CodexAgentSetupEndpoint.as_view(),
name="codex-agent-api-agent-setup",
),
path(
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/setup-codes/",
CodexAgentSetupCodeEndpoint.as_view(),
name="codex-agent-api-agent-setup-codes",
),
]

View File

@ -174,6 +174,21 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint
from .ai_workspace import (
AIWorkspaceExecutorCheckEndpoint,
AIWorkspaceExecutorDetailEndpoint,
AIWorkspaceExecutorEventsEndpoint,
AIWorkspaceExecutorListEndpoint,
AIWorkspaceExecutorSelectEndpoint,
AIWorkspaceExecutorSetupCommandEndpoint,
AIWorkspaceExecutorWindowsAgentEndpoint,
AIWorkspaceSettingsEndpoint,
AIWorkspaceThreadDispatchEndpoint,
AIWorkspaceThreadDetailEndpoint,
AIWorkspaceThreadListEndpoint,
AIWorkspaceThreadMessagesEndpoint,
)
from .codex_agents import (
CodexAgentDetailEndpoint,
CodexAgentGrantListEndpoint,
@ -182,6 +197,7 @@ from .codex_agents import (
CodexAgentListEndpoint,
CodexAgentProjectAccessEndpoint,
CodexAgentRevokeEndpoint,
CodexAgentSetupCodeEndpoint,
CodexAgentSetupEndpoint,
CodexAgentTokenListEndpoint,
CodexAgentTokenRevokeEndpoint,

View File

@ -0,0 +1,750 @@
# Python imports
import os
# Third party imports
import requests
from django.http import HttpResponse
from django.utils import timezone
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
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():
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_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()
if error_response is not None:
return error_response
base_url, token, timeout = config
request_timeout = timeout_override or timeout
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=request_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 = {}
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 = []
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
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
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):
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):
@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 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):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request, slug, executor_id):
config, error_response = require_ai_workspace_config()
if error_response is not None:
return error_response
base_url, token, timeout = config
params = {}
if request.query_params.get("port"):
params["port"] = request.query_params.get("port")
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(
f"{base_url}/api/ai-workspace/assistant/v1/executors/{executor_id}/agent/windows.ps1",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/octet-stream, text/plain, */*",
**user_headers(request.user),
},
params=params,
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,
)
if response.status_code >= 400:
return HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "text/plain"))
output = HttpResponse(response.content, status=response.status_code, content_type=response.headers.get("Content-Type", "application/octet-stream"))
for header in ("Content-Disposition", "Cache-Control", "Pragma", "Expires", "Surrogate-Control", "X-AI-Workspace-Installer-SHA256"):
if response.headers.get(header):
output[header] = response.headers[header]
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):
query_params = {
"kind": request.query_params.get("kind") or "shared",
"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)
@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)
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,
)

View File

@ -563,3 +563,21 @@ class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
return entitlement_error
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")
class CodexAgentSetupCodeEndpoint(CodexAgentEntitledEndpoint):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, agent_id):
entitlement_error = self.require_entitlement(request, slug)
if entitlement_error is not None:
return entitlement_error
payload = {
"expires_in_seconds": request.data.get("expires_in_seconds") or 600,
"token_name": request.data.get("token_name") or "Local Codex token",
}
return gateway_request(
"POST",
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup-codes",
payload,
)

View File

@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
name = request.data.get("name")
type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
beam_viewer = request.data.get("beamViewer")
is_beam_viewer_reference = (
isinstance(beam_viewer, dict)
and beam_viewer.get("src")
and beam_viewer.get("downloadUrl")
)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
if is_beam_viewer_reference:
asset = FileAsset.objects.create(
attributes={
"name": name,
"type": type,
"size": size,
"beamViewer": beam_viewer,
},
asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}",
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -212,6 +257,28 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
beam_viewer = request.data.get("beamViewer")
if isinstance(beam_viewer, dict):
attributes = issue_attachment.attributes or {}
existing_beam_viewer = attributes.get("beamViewer")
if not isinstance(existing_beam_viewer, dict):
return Response(
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
attributes["beamViewer"] = {
**existing_beam_viewer,
**beam_viewer,
}
for attribute_key in ("name", "size", "type", "version"):
if attribute_key in request.data:
attributes[attribute_key] = request.data.get(attribute_key)
issue_attachment.attributes = attributes
issue_attachment.is_uploaded = True
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK)
serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
return Response(

View File

@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_BEAM_VIEWER_BASE_URL=""
ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL
ARG VITE_BEAM_API_BASE_URL=""
ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED

View File

@ -1,4 +1,5 @@
FROM node:22-alpine AS base
LABEL nodedc.deploy.cache_bust="codex-agent-setup-20260624"
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
@ -37,6 +38,7 @@ RUN corepack enable pnpm
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN grep -R "Recommended terminal setup" /app/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx >/dev/null
# Fetch dependencies to cache store, then install offline with dev deps
RUN pnpm fetch --store-dir=/pnpm/store
@ -66,6 +68,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
ARG VITE_WEB_BASE_URL=""
ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
ARG VITE_BEAM_VIEWER_BASE_URL=""
ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL
ARG VITE_BEAM_API_BASE_URL=""
ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
@ -87,6 +95,7 @@ FROM nginx:1.27-alpine AS production
COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
RUN grep -R "Recommended terminal setup" /usr/share/nginx/html/assets >/dev/null
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 3000

View File

@ -10,6 +10,7 @@ import { observer } from "mobx-react";
import { cn } from "@plane/utils";
import { AppRailRoot } from "@/components/navigation";
import { useAppRailVisibility } from "@/lib/app-rail";
import { AIWorkspaceGlobalControl } from "@/components/ai-workspace/global-control";
import { VoiceTaskerGlobalControl } from "@/components/voice-tasker/global-control";
// local imports
import { TopNavigationRoot } from "../navigations";
@ -40,6 +41,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
>
{children}
</div>
{workspaceSlug && <AIWorkspaceGlobalControl workspaceSlug={workspaceSlug} />}
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
</div>
</div>

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { MessageCircle } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
// components
import { AIWorkspaceProductConsole } from "@/components/ai-workspace/product-console";
type TAIWorkspaceGlobalControlProps = {
workspaceSlug: string;
};
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
const [isOpen, setIsOpen] = useState(false);
const [dockSlot, setDockSlot] = useState<Element | null>(null);
useEffect(() => {
if (typeof document === "undefined") return;
const updateDockSlot = () => {
setDockSlot(
document.querySelector("[data-nodedc-voice-task-toolbar-slot]") ??
document.querySelector("[data-nodedc-voice-task-dock-slot]")
);
};
updateDockSlot();
const observer = new MutationObserver(updateDockSlot);
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, []);
return (
<>
{dockSlot
? createPortal(
<Tooltip tooltipContent="AI Workspace" position="top">
<button
type="button"
className="nodedc-bottom-dock-voice-button nodedc-bottom-dock-ai-workspace-button"
onClick={() => setIsOpen(true)}
aria-label="AI Workspace"
>
<MessageCircle className="size-4" />
</button>
</Tooltip>,
dockSlot
)
: null}
<AIWorkspaceProductConsole open={isOpen} workspaceSlug={workspaceSlug} onClose={() => setIsOpen(false)} />
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,789 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, MouseEvent as ReactMouseEvent } from "react";
import { createPortal } from "react-dom";
// plane imports
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// services
import type {
TAIWorkspaceExecutor,
TAIWorkspaceExecutorConnectionMode,
TAIWorkspaceExecutorInput,
TAIWorkspaceExecutorListResponse,
TAIWorkspaceOpsProject,
TAIWorkspaceOpsWorkspace,
} from "@/services/workspace-ai-workspace.service";
const DEFAULT_WINDOWS_AGENT_PORT = "8787";
const connectionModeSelectOptions: Array<{ value: TAIWorkspaceExecutorConnectionMode; label: string }> = [
{ value: "hub", label: "Cloud Hub" },
{ value: "direct", label: "Прямое подключение" },
];
const capabilityOptions = [
{ value: "chat", label: "Chat" },
{ value: "code", label: "Code" },
{ value: "engine", label: "Engine" },
{ value: "ops", label: "Ops" },
{ value: "mcp", label: "MCP" },
{ value: "workflows", label: "Workflows" },
];
type TExecutorDraft = {
name: string;
connectionMode: TAIWorkspaceExecutorConnectionMode;
endpoint: string;
workspacePath: string;
agentPort: string;
accountLabel: string;
capabilities: string[];
};
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;
actionError?: string;
busyId?: string;
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;
onCopySetupCommand: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
getWindowsAgentInstallerUrl: (
executorId: string,
options?: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string }
) => string;
};
function emptyDraft(): TExecutorDraft {
return {
name: "",
connectionMode: "hub",
endpoint: "",
workspacePath: "",
agentPort: DEFAULT_WINDOWS_AGENT_PORT,
accountLabel: "OpenAI account",
capabilities: ["ops"],
};
}
function draftFromExecutor(executor: TAIWorkspaceExecutor | null | undefined): TExecutorDraft {
if (!executor) return emptyDraft();
return {
name: executor.name || "",
connectionMode: executor.connectionMode === "direct" ? "direct" : "hub",
endpoint: executor.endpoint || "",
workspacePath: executor.workspacePath || "",
agentPort: String(executor.agentPort || DEFAULT_WINDOWS_AGENT_PORT),
accountLabel: executor.accountLabel || "",
capabilities:
Array.isArray(executor.capabilities) && executor.capabilities.length ? executor.capabilities : ["ops"],
};
}
function toInput(draft: TExecutorDraft): TAIWorkspaceExecutorInput {
const port = Number(draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
return {
name: draft.name.trim(),
type: "codex-remote",
connectionMode: draft.connectionMode,
endpoint: draft.connectionMode === "direct" ? draft.endpoint.trim() || null : null,
workspacePath: draft.workspacePath.trim() || null,
agentPort: Number.isInteger(port) ? port : Number(DEFAULT_WINDOWS_AGENT_PORT),
accountLabel: draft.accountLabel.trim() || null,
capabilities: Array.from(new Set(["ops", ...draft.capabilities])).filter(Boolean),
};
}
function statusLabel(status: string | null | undefined) {
if (status === "online") return "Online";
if (status === "offline") return "Offline";
if (status === "checking") return "Checking";
if (status === "error") return "Error";
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,
ariaLabel,
disabled = false,
onChange,
}: {
value: T;
options: Array<{ value: T; label: string; disabled?: boolean }>;
ariaLabel: string;
disabled?: boolean;
onChange: (value: T) => void;
}) {
const [open, setOpen] = useState(false);
const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 });
const rootRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const selectId = useMemo(() => `ops-ai-select-${Math.random().toString(16).slice(2)}`, []);
const selected = options.find((option) => option.value === value) ?? options[0];
const updateMenuRect = () => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const valueRect = root.querySelector<HTMLElement>(".nodedc-select__value")?.getBoundingClientRect();
const menuMaxHeight = 232;
const bottomTop = rect.bottom + 6;
const top =
bottomTop + menuMaxHeight > window.innerHeight - 12 ? Math.max(12, rect.top - menuMaxHeight - 6) : bottomTop;
setMenuRect({
top,
left: valueRect?.left ?? rect.left,
width: Math.max(180, valueRect?.width ?? rect.width),
});
};
useEffect(() => {
if (!open) return;
updateMenuRect();
const handlePointerDown = (event: MouseEvent) => {
const root = rootRef.current;
const menu = menuRef.current;
if (
event.target instanceof Node &&
root &&
!root.contains(event.target) &&
(!menu || !menu.contains(event.target))
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const handleOtherSelectOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id !== selectId) setOpen(false);
};
const handleViewportChange = () => updateMenuRect();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("nodedc-select-open", handleOtherSelectOpen as EventListener);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [open, selectId]);
const toggleOpen = () => {
if (disabled) return;
setOpen((current) => {
if (!current) {
updateMenuRect();
window.dispatchEvent(new CustomEvent("nodedc-select-open", { detail: { id: selectId } }));
}
return !current;
});
};
return (
<div ref={rootRef} className="nodedc-select">
<div className="nodedc-select__control">
<div className="nodedc-select__value">{selected?.label}</div>
<button
type="button"
className="nodedc-select__toggle"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={toggleOpen}
>
<span className="nodedc-select__chevron" aria-hidden="true" />
</button>
</div>
{open && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
className="nodedc-dropdown-surface nodedc-select__menu nodedc-ai-workspace-settings-select-menu"
role="listbox"
style={{
position: "fixed",
top: menuRect.top,
left: menuRect.left,
width: menuRect.width,
}}
>
{options.map((option) => (
<button
key={option.value}
type="button"
className="nodedc-dropdown-option nodedc-select__option"
data-selected={option.value === value ? "true" : undefined}
data-value={option.value}
role="option"
aria-selected={option.value === value}
aria-disabled={option.disabled === true}
disabled={option.disabled === true}
onClick={() => {
if (option.disabled) return;
onChange(option.value);
setOpen(false);
}}
>
{option.label}
</button>
))}
</div>,
document.body
)
: null}
</div>
);
}
export function AIWorkspaceProductSettingsModal({
isOpen,
workspaceSlug,
executors,
selectedExecutorId,
workspaceOptions,
selectedOpsWorkspaceSlug,
workspaceLoadError,
projectOptions,
selectedProjectId,
projectLoadError,
isLoading,
error,
notice,
actionError,
busyId,
onClose,
onReload,
onSelect,
onCheck,
onCreate,
onUpdate,
onDelete,
onOpsWorkspaceChange,
onProjectChange,
onCopySetupCommand,
onDownloadAgent,
getWindowsAgentInstallerUrl,
}: TAIWorkspaceProductSettingsModalProps) {
const activeExecutor = useMemo(
() => executors.find((executor) => executor.id === selectedExecutorId) ?? executors[0] ?? null,
[executors, selectedExecutorId]
);
const [detailsMode, setDetailsMode] = useState<"context" | "new" | "edit">("context");
const [editingId, setEditingId] = useState<string>("");
const [draft, setDraft] = useState<TExecutorDraft>(() => draftFromExecutor(activeExecutor));
const editingExecutor = useMemo(
() => executors.find((executor) => executor.id === editingId) ?? activeExecutor,
[activeExecutor, editingId, executors]
);
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) {
setDetailsMode("context");
return;
}
if (detailsMode === "new") return;
setEditingId(activeExecutor?.id || "");
setDraft(draftFromExecutor(activeExecutor));
}, [activeExecutor, detailsMode, isOpen]);
const startNew = () => {
setDetailsMode("new");
setEditingId("");
setDraft(emptyDraft());
};
const selectExecutor = (executor: TAIWorkspaceExecutor) => {
setDetailsMode("context");
setEditingId(executor.id);
setDraft(draftFromExecutor(executor));
if (executor.id !== selectedExecutorId) void onSelect(executor);
};
const editActive = () => {
if (!activeExecutor) return;
setDetailsMode("edit");
setEditingId(activeExecutor.id);
setDraft(draftFromExecutor(activeExecutor));
};
const checkActive = () => {
if (!activeExecutor) return;
void onCheck(activeExecutor);
};
const saveDraft = async (event: FormEvent) => {
event.preventDefault();
const input = toInput(draft);
if (!input.name) return;
if (detailsMode === "new") {
const payload = await onCreate(input);
if (!payload) return;
const next =
payload?.executors.find((executor) => executor.id === payload.selectedExecutorId) ??
payload?.executors.find((executor) => executor.name === input.name) ??
payload?.executors[0] ??
null;
setEditingId(next?.id || "");
setDetailsMode(next ? "edit" : "context");
return;
}
if (!editingExecutor) return;
const payload = await onUpdate(editingExecutor.id, input);
if (payload) setDetailsMode("context");
};
const deleteEditingExecutor = async () => {
if (!editingExecutor) return;
const payload = await onDelete(editingExecutor.id);
if (payload) setDetailsMode("context");
};
const toggleCapability = (value: string) => {
setDraft((current) => {
const set = new Set(current.capabilities);
if (set.has(value)) set.delete(value);
else set.add(value);
return { ...current, capabilities: Array.from(set) };
});
};
const downloadAgent = async (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (!editingExecutor) return;
event.preventDefault();
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(" · ")
: "";
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="ai-workspace-settings-portal overflow-visible"
>
<div className="nodedc-glass-modal ai-settings-modal ai-settings-modal--workspace nodedc-ai-workspace-product-modal">
<div className="nodedc-glass-modal__head">
<div className="nodedc-glass-modal__titles">
<div className="nodedc-glass-modal__title">AI Workspace Settings</div>
<div className="nodedc-glass-modal__accent-title" title="MODEL CONTEXT">
MODEL CONTEXT
</div>
</div>
<button type="button" className="nodedc-glass-modal__close" onClick={onClose} aria-label="Закрыть">
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
</div>
<div className="nodedc-glass-modal__body">
<div className="ai-settings-workspace" data-details-open={detailsOpen ? "true" : undefined}>
<aside className="ai-settings-model-list">
<div className="ai-settings-devices-head">
<div>
<div className="ai-console-kicker">AI Space</div>
<div className="ai-settings-devices-title">Устройства</div>
</div>
<button
type="button"
className="ai-console-icon-button"
aria-label="Подключить устройство"
title="Подключить устройство"
onClick={startNew}
>
<span aria-hidden="true">+</span>
</button>
</div>
{isLoading ? <div className="ai-settings-empty-list">Загрузка</div> : null}
{executors.map((executor) => (
<button
key={executor.id}
type="button"
className="ai-settings-executor-row"
data-active={activeExecutor?.id === executor.id && detailsMode !== "new" ? "true" : undefined}
onClick={() => selectExecutor(executor)}
>
<span className="ai-settings-executor-row__title">
<span>{executor.name}</span>
<i className="ai-status-dot" data-status={executor.status} aria-hidden="true" />
</span>
<small>Codex worker · {statusLabel(executor.status)}</small>
</button>
))}
{!isLoading && !executors.length ? (
<div className="ai-settings-empty-list">
<span>{error ? "Registry недоступен" : "Устройств нет"}</span>
<small>{error ? errorText(error) : "Подключенные устройства появятся здесь."}</small>
</div>
) : null}
</aside>
<section className="ai-settings-context-pane">
<div className="ai-settings-form__top">
<div>
<div className="ai-settings-section-title">
{detailsMode === "new" ? "Новое устройство" : "Рабочий контекст"}
</div>
<div className="ai-settings-subtitle">Устройство, режим и контекст управления</div>
</div>
{activeExecutor ? (
<span
className="ai-executor-status ai-status-dot"
data-status={activeExecutor.status}
aria-label={statusLabel(activeExecutor.status)}
title={statusLabel(activeExecutor.status)}
/>
) : null}
</div>
{activeExecutor && detailsMode !== "new" ? (
<>
<div className="ai-settings-active-model">
<div>
<span>Активное устройство</span>
<b>{activeExecutor.name}</b>
<small>Codex worker · Ops</small>
</div>
</div>
<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>
</div>
</>
) : detailsMode === "new" ? (
<div className="ai-settings-empty-context">
<span>Новое устройство</span>
<small>
Заполни параметры справа и нажми «Подключить». После сохранения появятся pairing code и installer
агента.
</small>
</div>
) : error ? (
<div className="ai-settings-empty-context" data-tone="error">
<span>Registry backend недоступен</span>
<small>{errorText(error)}</small>
<button type="button" className="modal-btn btn-primary" onClick={() => void onReload()}>
Повторить
</button>
</div>
) : (
<div className="ai-settings-empty-context">
<span>Устройство не выбрано</span>
<small>
Подключи Codex worker. Если устройство уже добавлено в Engine, оно появится здесь автоматически.
</small>
<button type="button" className="modal-btn btn-primary" onClick={startNew}>
Подключить устройство
</button>
</div>
)}
{notice ? (
<div className="ai-settings-check-note" data-tone="success">
{notice}
</div>
) : null}
{actionError ? <div className="ai-settings-error">{actionError}</div> : null}
</section>
{detailsOpen ? (
<form className="ai-settings-form ai-settings-details-pane" onSubmit={saveDraft}>
<div className="ai-settings-form__top">
<div>
<div className="ai-settings-section-title">
{detailsMode === "new" ? "Подключить устройство" : "Параметры устройства"}
</div>
<div className="ai-settings-subtitle">Cloud Hub или прямой endpoint без секретов</div>
</div>
{detailsMode === "edit" ? (
<button
type="button"
className="ai-settings-mini-close"
aria-label="Закрыть параметры устройства"
onClick={() => setDetailsMode("context")}
>
<span className="nodedc-glass-modal__close-mark" aria-hidden="true" />
</button>
) : null}
</div>
<label className="ai-settings-field">
<span>Имя устройства</span>
<input
className="ai-settings-input"
value={draft.name}
placeholder="MACPRO Codex"
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
/>
</label>
<label className="ai-settings-field">
<span>Тип</span>
<input className="ai-settings-input" value="Codex worker" readOnly />
</label>
<label className="ai-settings-field">
<span>Подключение</span>
<AIWorkspaceGlassSelect
value={draft.connectionMode}
options={connectionModeSelectOptions}
ariaLabel="Тип подключения"
onChange={(value) => setDraft((current) => ({ ...current, connectionMode: value }))}
/>
</label>
{draft.connectionMode === "hub" && detailsMode === "edit" && editingExecutor ? (
<div className="ai-settings-field-row ai-settings-field-row--download">
<label className="ai-settings-field">
<span>Pairing code</span>
<input
className="ai-settings-input"
value={editingExecutor.pairingCode || ""}
readOnly
placeholder="XXXX-XXXX-XXXX"
/>
</label>
<button
type="button"
className="modal-btn ai-settings-download ai-settings-inline-download btn-primary"
disabled={isBusy}
onClick={() => void copySetupCommand()}
>
Copy npx
</button>
<a
className="modal-btn ai-settings-download ai-settings-inline-download"
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
onClick={downloadAgent}
download
>
Legacy .ps1
</a>
</div>
) : draft.connectionMode === "hub" ? (
<div className="ai-settings-check-note">
После сохранения общий AI Workspace создаст pairing code и npm setup command.
</div>
) : null}
{draft.connectionMode === "direct" ? (
<label className="ai-settings-field">
<span>Адрес агента</span>
<input
className="ai-settings-input"
value={draft.endpoint}
placeholder="http://192.168.1.50:8787"
onChange={(event) => setDraft((current) => ({ ...current, endpoint: event.target.value }))}
/>
</label>
) : null}
<label className="ai-settings-field">
<span>Аккаунт</span>
<input
className="ai-settings-input"
value={draft.accountLabel}
placeholder="OpenAI account"
onChange={(event) => setDraft((current) => ({ ...current, accountLabel: event.target.value }))}
/>
</label>
<div className="ai-settings-field">
<span>Capabilities</span>
<div className="ai-settings-capabilities">
{capabilityOptions.map((option) => (
<label key={option.value} className="ai-settings-chip">
<input
type="checkbox"
checked={draft.capabilities.includes(option.value)}
onChange={() => toggleCapability(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
<div className="modal-actions ai-settings-actions">
{detailsMode === "edit" && editingExecutor ? (
<button
type="button"
className="modal-btn ai-settings-danger"
disabled={isBusy}
onClick={deleteEditingExecutor}
>
Удалить
</button>
) : null}
<button type="submit" className="modal-btn btn-primary" disabled={isBusy || !draft.name.trim()}>
{busyId === "save" ? "Сохранение..." : detailsMode === "new" ? "Подключить" : "Сохранить"}
</button>
</div>
</form>
) : null}
</div>
</div>
</div>
</ModalCore>
);
}

View File

@ -213,10 +213,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"nodedc-dropdown-surface z-30 my-1 overflow-hidden",
optionsClassName
)}
className={cn("nodedc-dropdown-surface z-[760] my-1 overflow-hidden", optionsClassName)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}

View File

@ -8,11 +8,12 @@ import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { LayoutGrid, List, UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { convertBytesToSize, getFileExtension } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
@ -25,6 +26,8 @@ import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
// types
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
type TAttachmentViewMode = "grid" | "list";
type TIssueAttachmentItemList = {
workspaceSlug: string;
projectId: string;
@ -46,6 +49,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
const { t } = useTranslation();
// states
const [isUploading, setIsUploading] = useState(false);
const [attachmentViewMode, setAttachmentViewMode] = useState<TAttachmentViewMode>(() => {
if (typeof window === "undefined") return "grid";
return window.localStorage.getItem("nodedc-issue-attachment-view-mode") === "list" ? "list" : "grid";
});
// store hooks
const {
attachment: { getAttachmentsByIssueId },
@ -67,6 +74,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
activeUploads.length > 0
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
: 0;
const isListView = attachmentViewMode === "list";
// handlers
const handleFetchPropertyActivities = useCallback(() => {
@ -122,6 +130,14 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
disabled: isUploading || disabled,
});
const handleToggleAttachmentViewMode = useCallback(() => {
setAttachmentViewMode((currentMode) => {
const nextMode: TAttachmentViewMode = currentMode === "grid" ? "list" : "grid";
if (typeof window !== "undefined") window.localStorage.setItem("nodedc-issue-attachment-view-mode", nextMode);
return nextMode;
});
}, []);
return (
<div className="space-y-3">
{attachmentDeleteModalId && (
@ -173,13 +189,73 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
)}
{hasAttachmentRows && (
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
<div
className="nodedc-attachments-panel rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]"
data-view-mode={attachmentViewMode}
>
<div className="mb-2 flex items-center justify-between gap-3 px-1">
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
<div className="flex items-center gap-2">
<div className="text-12 text-tertiary">
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
</div>
<button
type="button"
className="grid size-8 place-items-center rounded-full bg-surface-2/80 text-tertiary transition hover:bg-surface-2 hover:text-primary"
onClick={handleToggleAttachmentViewMode}
title={isListView ? "Показать плитками" : "Показать списком"}
>
{isListView ? <LayoutGrid className="size-4" /> : <List className="size-4" />}
</button>
</div>
</div>
{isListView ? (
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
<div className="min-w-[840px]">
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
<div>Файл</div>
<div>Дата</div>
<div>Версия</div>
<div>Добавил</div>
<div className="text-right">Действия</div>
</div>
{uploadStatus?.map((status) => (
<div
key={status.id}
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
>
<div className="min-w-0">
<div className="truncate font-semibold text-secondary">{status.name}</div>
<div className="mt-1 text-11 text-tertiary">
{(getFileExtension(status.name) || "file").toUpperCase()} · {convertBytesToSize(status.size)}
</div>
</div>
<div className="text-tertiary">Загрузка</div>
<div className="text-secondary">v1</div>
<div className="text-tertiary">{status.progress}%</div>
<div className="h-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-[rgb(var(--nodedc-accent-rgb))]"
style={{ width: `${status.progress}%` }}
/>
</div>
</div>
))}
{issueAttachments.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueId={issueId}
issueServiceType={issueServiceType}
projectId={projectId}
viewMode="list"
workspaceSlug={workspaceSlug}
/>
))}
</div>
</div>
) : (
<div className="flex gap-3 overflow-x-auto pb-1">
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
@ -189,10 +265,14 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueId={issueId}
issueServiceType={issueServiceType}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
))}
</div>
)}
</div>
)}
</div>

View File

@ -5,34 +5,57 @@
*/
import { observer } from "mobx-react";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
import { Download, ImageIcon, Play, X } from "lucide-react";
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
import {
buildBeamViewerUrl,
dispatchBeamViewerOpenEvent,
fetchBeamConversionStatus,
fetchBeamModelVersions,
getBeamModelVersionRecords,
getBeamVersionViewerUrl,
getBeamViewerAttachment,
isBeamModelFile,
mergeBeamModelVersionRecordLists,
syncCurrentBeamVersionRecord,
type TBeamModelVersionRecord,
type TBeamViewerAttachment,
type TBeamConversionStatus,
} from "@/helpers/beam-viewer";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueAttachmentService } from "@/services/issue";
type TIssueAttachmentsListItem = {
attachmentId: string;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
issueId: string;
projectId: string;
viewMode?: "grid" | "list";
workspaceSlug: string;
};
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
@ -46,6 +69,35 @@ const appendSearchParam = (url: string | undefined, key: string, value: string):
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
};
const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url, "http://localhost");
return parsedUrl.searchParams.has("url") ||
parsedUrl.searchParams.has("src") ||
parsedUrl.searchParams.has("projectId")
? url
: undefined;
} catch (_error) {
return undefined;
}
};
const withBeamViewerSettingsSrc = (
url: string | null | undefined,
settingsSrc: string | undefined
): string | undefined => {
if (!url) return undefined;
if (!settingsSrc) return url;
try {
const parsedUrl = new URL(url, "http://localhost");
if (!parsedUrl.searchParams.has("settingsSrc")) parsedUrl.searchParams.set("settingsSrc", settingsSrc);
return parsedUrl.toString();
} catch (_error) {
return url;
}
};
const getPreviewType = (extension: string) => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
@ -54,14 +106,83 @@ const getPreviewType = (extension: string) => {
return "file";
};
const getBeamConversionStatusLabel = (status: string | undefined): string | null => {
if (!status) return null;
if (status === "ready") return "Модель готова";
if (status === "failed") return "Ошибка дерева";
if (status === "processing") return "Собираем дерево";
if (status === "conversion_required") return "Ожидает очереди";
return "Ожидает дерево";
};
const sanitizeBeamStatusMessage = (message: string | undefined): string | undefined =>
message
?.replace(/XKT-просмотр/g, "просмотр")
.replace(/XKT и дерева/g, "модели и дерева")
.replace(/GLB\/XKT/g, "модели")
.replace(/XKT/g, "просмотра");
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
version.versionId || `version-${version.version}`;
const buildSyncedBeamViewerAttachment = (
beamViewer: TBeamViewerAttachment,
status: TBeamConversionStatus,
fullFileName: string
): TBeamViewerAttachment | null => {
if (status.status !== "ready" || !status.artifactUrl) return null;
const artifactType = status.artifactType || status.targetFormat || "gltf";
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
return syncCurrentBeamVersionRecord({
...beamViewer,
assetId: beamViewer.assetId || status.assetId,
previewAvailable: true,
sha256: beamViewer.sha256 || status.sha256,
version: beamViewer.version || status.version,
versionId: beamViewer.versionId || status.versionId,
viewerUrl: buildBeamViewerUrl({
name: fullFileName,
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
src: status.artifactUrl,
type: artifactType,
}),
conversion: {
...beamViewer.conversion,
artifactSrc: status.artifactSrc,
artifactType,
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
message: status.message || beamViewer.conversion?.message,
metadataSrc: status.metadataSrc,
size: status.size ?? beamViewer.conversion?.size,
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
status: "ready",
targetFormat,
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
},
});
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation();
// props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const {
attachmentId,
disabled,
issueId,
issueServiceType = EIssueServiceType.ISSUES,
projectId,
viewMode = "grid",
workspaceSlug,
} = props;
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const {
attachment: { getAttachmentById },
fetchAttachments,
toggleDeleteAttachmentModal,
} = useIssueDetail(issueServiceType);
// derived values
@ -73,9 +194,213 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const fileIcon = getFileIcon(fileExtension, 32);
const fileURL = getFileURL(attachment?.asset_url ?? "");
const previewURL = appendSearchParam(fileURL, "preview", "true");
const beamViewer = getBeamViewerAttachment(attachment);
const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null);
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
const storedModelViewerUrl =
beamEffectiveStatus === "ready" ? getUsableBeamViewerUrl(beamViewer?.viewerUrl) : undefined;
const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
const modelViewerUrl =
(resolvedArtifactUrl
? buildBeamViewerUrl({
name: fullFileName,
settingsSrc: beamViewerSettingsSrc,
src: resolvedArtifactUrl,
type: beamConversionStatus?.artifactType || "gltf",
})
: undefined) || storedModelViewerUrlWithSettings;
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName);
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
const beamStatusErrorMessage =
beamConversionError ||
beamConversionStatus?.error ||
(beamConversionStatus?.status === "ready" && !modelViewerUrl
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
: null);
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
const rawBeamStatusTooltipContent = isBeamConversionFailed
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
: beamConversionStatus?.message ||
beamViewer?.conversion?.message ||
"Оригинальная модель загружена в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов.";
const beamStatusTooltipContent = sanitizeBeamStatusMessage(rawBeamStatusTooltipContent) || "";
const beamStatusTooltipMessage =
beamStatusTooltipContent &&
!["ready", "processing", "failed", "conversion_required"].includes(beamStatusTooltipContent)
? beamStatusTooltipContent
: "";
const beamStatusIconTooltipContent = beamConversionStatusLabel
? `${beamConversionStatusLabel}${beamStatusTooltipMessage ? `. ${beamStatusTooltipMessage}` : ""}`
: beamStatusTooltipContent;
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [deletingVersionKey, setDeletingVersionKey] = useState<string | null>(null);
const [isVersionUploading, setIsVersionUploading] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false);
const [liveBeamVersions, setLiveBeamVersions] = useState<TBeamModelVersionRecord[]>([]);
const versionUploadInputRef = useRef<HTMLInputElement>(null);
const localBeamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]);
const beamVersions = useMemo(
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
[localBeamVersions, liveBeamVersions]
);
const rawVersion = beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
const versionLabel =
typeof rawVersion === "number"
? `v${rawVersion}`
: typeof rawVersion === "string"
? rawVersion.toLowerCase().startsWith("v")
? rawVersion
: `v${rawVersion}`
: "v1";
const currentVersionId = beamViewer?.versionId;
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
const getVersionAuthorName = (version: TBeamModelVersionRecord): string => {
const userId = version.uploadedBy || attachment?.created_by;
if (!userId) return "—";
return getUserDetails(userId)?.display_name ?? "—";
};
const openModelViewer = () => {
if (!modelViewerUrl) return;
dispatchBeamViewerOpenEvent({
downloadUrl: modelDownloadUrl,
fileExtension,
fileName: fullFileName,
fileSize: attachment?.attributes.size ?? 0,
issueId,
viewerUrl: modelViewerUrl,
});
};
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
const viewerUrl = getBeamVersionViewerUrl(version);
if (!viewerUrl) return;
setIsVersionHistoryOpen(false);
dispatchBeamViewerOpenEvent({
downloadUrl: version.downloadUrl,
fileExtension: getFileExtension(version.originalFilename),
fileName: version.originalFilename,
fileSize: version.size || version.conversion?.size || 0,
issueId,
viewerUrl,
});
};
const startVersionUpload = () => {
versionUploadInputRef.current?.click();
};
const handleVersionFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !attachment || !beamViewer) return;
if (!isBeamModelFile(file.name)) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Формат не поддержан",
message: "Для новой версии нужно выбрать файл модели, который поддерживает BIM Viewer.",
});
return;
}
setIsVersionUploading(true);
try {
await attachmentService.uploadBeamIssueAttachmentVersion(
workspaceSlug,
projectId,
issueId,
attachment,
file,
undefined,
currentUser?.id
);
await fetchAttachments(workspaceSlug, projectId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Версия добавлена",
message: `${file.name} загружен как новая версия модели.`,
});
} catch (error) {
console.error("Error in uploading Beam model version:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Версия не загружена",
message: error instanceof Error ? error.message : "Не удалось загрузить новую версию модели.",
});
} finally {
setIsVersionUploading(false);
}
};
const handleDeleteVersion = async (version: TBeamModelVersionRecord) => {
if (!attachment || !beamViewer) return;
const versionKey = getBeamVersionRecordKey(version);
setDeletingVersionKey(versionKey);
try {
await attachmentService.deleteBeamIssueAttachmentVersion(workspaceSlug, projectId, issueId, attachment, version);
await fetchAttachments(workspaceSlug, projectId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Версия удалена",
message: `v${version.version} удалена из истории модели.`,
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Версия не удалена",
message: error instanceof Error ? error.message : "Не удалось удалить версию модели.",
});
} finally {
setDeletingVersionKey(null);
}
};
const menuItems: TContextMenuItem[] = [
...(modelViewerUrl
? [
{
key: "view-model",
action: openModelViewer,
title: "Посмотреть модель",
icon: Eye,
},
]
: []),
...(beamViewer
? [
{
key: "upload-model-version",
action: startVersionUpload,
title: isVersionUploading ? "Загружаем версию..." : "Добавить версию",
icon: UploadCloud,
disabled: isVersionUploading,
},
{
key: "model-version-history",
action: () => setIsVersionHistoryOpen(true),
title: "История версий",
icon: History,
},
]
: []),
...(modelDownloadUrl
? [
{
key: "download-model-original",
action: () => {
window.open(modelDownloadUrl, "_blank", "noopener,noreferrer");
},
title: "Скачать оригинал",
icon: Download,
},
]
: []),
{
key: "delete",
action: () => {
@ -87,11 +412,270 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
];
// hooks
const { isMobile } = usePlatformOS();
const versionUploadInput = beamViewer ? (
<input
ref={versionUploadInputRef}
type="file"
className="hidden"
disabled={isVersionUploading}
onChange={handleVersionFileChange}
/>
) : null;
useEffect(() => {
if (!beamViewer?.src) {
setLiveBeamVersions([]);
return;
}
let isMounted = true;
fetchBeamModelVersions(beamViewer)
.then((history) => {
if (!isMounted) return;
setLiveBeamVersions(Array.isArray(history.versions) ? history.versions : []);
})
.catch((error) => {
if (!isMounted) return;
console.warn("BIM version history unavailable:", error);
setLiveBeamVersions([]);
});
return () => {
isMounted = false;
};
}, [beamViewer?.assetId, beamViewer?.projectId, beamViewer?.src, beamViewer?.versionId]);
useEffect(() => {
if (!beamViewer || !beamViewer.src) return;
if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return;
let isMounted = true;
let timeoutId: number | undefined;
const pollStatus = async () => {
try {
const status = await fetchBeamConversionStatus(beamViewer);
if (!isMounted) return;
setBeamConversionError(null);
setBeamConversionStatus(status);
const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName);
if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) {
attachmentService
.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer)
.then(() => fetchAttachments(workspaceSlug, projectId, issueId))
.catch((error) => console.error("Error in syncing Beam attachment metadata:", error));
}
if (status.status !== "ready" && status.status !== "failed") {
timeoutId = window.setTimeout(pollStatus, 5000);
}
} catch (error) {
if (!isMounted) return;
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели.");
timeoutId = window.setTimeout(pollStatus, 8000);
}
};
pollStatus();
return () => {
isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [
attachmentId,
attachmentService,
beamViewer,
fetchAttachments,
fullFileName,
issueId,
projectId,
storedModelViewerUrl,
workspaceSlug,
]);
if (!attachment) return <></>;
if (viewMode === "list") {
return (
<>
{versionUploadInput}
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
<button
type="button"
className="flex min-w-0 items-center gap-3 text-left"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true);
}}
>
<Tooltip
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
isMobile={isMobile}
position={isBeamModel ? "left" : "top"}
sideOffset={isBeamModel ? 6 : 10}
className={isBeamModel ? "z-[220]" : ""}
>
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
{isBeamModel ? (
isBeamConversionFailed ? (
<AlertTriangle className="text-red-400 size-5" />
) : isBeamAwaitingPreview ? (
<Spinner
height="20px"
width="20px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
<ImageIcon className="size-5" />
)}
</div>
</Tooltip>
<div className="min-w-0">
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
</Tooltip>
<div className="mt-1 flex min-w-0 items-center gap-2 text-11 text-tertiary">
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
<span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
</button>
<div className="truncate text-tertiary">{uploadedAt}</div>
<div className="text-secondary">{versionLabel}</div>
<div className="truncate text-tertiary">{uploadedBy}</div>
<div className="flex items-center justify-end gap-1">
{beamViewer && (
<Tooltip tooltipContent="История версий" isMobile={isMobile}>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsVersionHistoryOpen(true);
}}
>
<History className="size-4" />
</button>
</Tooltip>
)}
{beamViewer && (
<Tooltip
tooltipContent={isVersionUploading ? "Загружаем версию..." : "Добавить версию"}
isMobile={isMobile}
>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
disabled={isVersionUploading}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startVersionUpload();
}}
>
<UploadCloud className="size-4" />
</button>
</Tooltip>
)}
{(modelViewerUrl || previewURL) && (
<Tooltip
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
isMobile={isMobile}
>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true);
}}
>
<Eye className="size-4" />
</button>
</Tooltip>
)}
{previewDownloadUrl && (
<Tooltip tooltipContent={modelDownloadUrl ? "Скачать оригинал" : "Скачать файл"} isMobile={isMobile}>
<a
href={previewDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => e.stopPropagation()}
>
<Download className="size-4" />
</a>
</Tooltip>
)}
<Tooltip tooltipContent={t("common.actions.delete")} isMobile={isMobile}>
<button
type="button"
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
disabled={!!disabled}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(attachmentId);
}}
>
<TrashIcon className="size-4" />
</button>
</Tooltip>
</div>
</div>
<ModalCore
isOpen={isPreviewOpen}
handleClose={() => setIsPreviewOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
>
<AttachmentPreviewContent
beamConversionStatusLabel={beamConversionStatusLabel}
beamStatusTooltipContent={beamStatusTooltipContent}
fileIcon={fileIcon}
fullFileName={fullFileName}
isBeamAwaitingPreview={isBeamAwaitingPreview}
isBeamConversionFailed={isBeamConversionFailed}
modelDownloadUrl={modelDownloadUrl}
previewDownloadUrl={previewDownloadUrl}
previewType={previewType}
previewURL={previewURL}
setIsPreviewOpen={setIsPreviewOpen}
size={attachment.attributes.size}
/>
</ModalCore>
<BeamVersionHistoryModal
currentVersionId={currentVersionId}
deletingVersionKey={deletingVersionKey}
fileName={fullFileName}
getVersionAuthorName={getVersionAuthorName}
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
onDeleteVersion={handleDeleteVersion}
onOpenVersion={openBeamVersionViewer}
versions={beamVersions}
/>
</>
);
}
return (
<>
{versionUploadInput}
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
<button
type="button"
@ -99,7 +683,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsPreviewOpen(true);
if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true);
}}
>
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
@ -122,7 +707,31 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
) : (
<div className="flex h-full w-full items-center justify-center">
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
{isBeamModel ? (
isBeamConversionFailed ? (
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
<div className="text-red-400 grid size-9 place-items-center">
<AlertTriangle className="size-9" />
</div>
</Tooltip>
) : isBeamAwaitingPreview ? (
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
<div className="grid size-9 place-items-center">
<Spinner
height="36px"
width="36px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
</div>
</Tooltip>
) : (
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "file" ? (
fileIcon
) : (
<ImageIcon className="size-8 text-tertiary" />
)}
</div>
)}
</div>
@ -171,11 +780,73 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
width={EModalWidth.VIIXL}
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
>
<AttachmentPreviewContent
beamConversionStatusLabel={beamConversionStatusLabel}
beamStatusTooltipContent={beamStatusTooltipContent}
fileIcon={fileIcon}
fullFileName={fullFileName}
isBeamAwaitingPreview={isBeamAwaitingPreview}
isBeamConversionFailed={isBeamConversionFailed}
modelDownloadUrl={modelDownloadUrl}
previewDownloadUrl={previewDownloadUrl}
previewType={previewType}
previewURL={previewURL}
setIsPreviewOpen={setIsPreviewOpen}
size={attachment.attributes.size}
/>
</ModalCore>
<BeamVersionHistoryModal
currentVersionId={currentVersionId}
deletingVersionKey={deletingVersionKey}
fileName={fullFileName}
getVersionAuthorName={getVersionAuthorName}
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
onDeleteVersion={handleDeleteVersion}
onOpenVersion={openBeamVersionViewer}
versions={beamVersions}
/>
</>
);
});
type TAttachmentPreviewContent = {
beamConversionStatusLabel: string | null;
beamStatusTooltipContent: string;
fileIcon: ReactNode;
fullFileName: string;
isBeamAwaitingPreview: boolean;
isBeamConversionFailed: boolean;
modelDownloadUrl: string | undefined;
previewDownloadUrl: string | undefined;
previewType: "image" | "video" | "pdf" | "file";
previewURL: string;
setIsPreviewOpen: (isOpen: boolean) => void;
size: number;
};
const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
const {
beamConversionStatusLabel,
beamStatusTooltipContent,
fileIcon,
fullFileName,
isBeamAwaitingPreview,
isBeamConversionFailed,
modelDownloadUrl,
previewDownloadUrl,
previewType,
previewURL,
setIsPreviewOpen,
size,
} = props;
return (
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{fileURL && (
{previewDownloadUrl && (
<a
href={fileURL}
href={previewDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
@ -196,7 +867,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<div className="flex-shrink-0 pr-24">
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(size)}</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
@ -212,6 +883,48 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div>
) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfPreview fileURL={previewURL} />
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div
className={`grid size-20 place-items-center rounded-3xl ${
isBeamConversionFailed
? "bg-red-500/10 text-red-400"
: "bg-[rgba(var(--nodedc-accent-rgb),0.12)] text-[rgb(var(--nodedc-accent-rgb))]"
}`}
>
{isBeamConversionFailed ? (
<AlertTriangle className="size-10" />
) : (
<Spinner
height="40px"
width="40px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
)}
</div>
<div className="max-w-md">
<div className="text-15 font-semibold text-primary">
{isBeamConversionFailed ? "Ошибка дерева" : (beamConversionStatusLabel ?? "Ожидает дерево")}
</div>
<div className="mt-2 text-14 text-secondary">
{isBeamConversionFailed
? beamStatusTooltipContent
: "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
</div>
</div>
{modelDownloadUrl && (
<a
href={modelDownloadUrl}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] px-4 py-2 text-13 font-semibold text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:brightness-110"
>
<Download className="size-4" />
Скачать оригинал
</a>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
@ -222,7 +935,152 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
)}
</div>
</div>
</ModalCore>
</>
);
});
};
type TBeamVersionHistoryModal = {
currentVersionId: string | undefined;
deletingVersionKey: string | null;
fileName: string;
getVersionAuthorName: (version: TBeamModelVersionRecord) => string;
isOpen: boolean;
onClose: () => void;
onDeleteVersion: (version: TBeamModelVersionRecord) => void;
onOpenVersion: (version: TBeamModelVersionRecord) => void;
versions: TBeamModelVersionRecord[];
};
const BeamVersionHistoryModal = (props: TBeamVersionHistoryModal) => {
const {
currentVersionId,
deletingVersionKey,
fileName,
getVersionAuthorName,
isOpen,
onClose,
onDeleteVersion,
onOpenVersion,
versions,
} = props;
const latestVersion = versions.reduce((latest, version) => Math.max(latest, version.version), 0);
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="max-h-[76vh] w-[min(96vw,1280px)] overflow-hidden bg-surface-1"
>
<div className="flex max-h-[76vh] min-h-0 flex-col bg-surface-1">
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<div className="text-15 truncate font-semibold text-primary">История версий</div>
<div className="mt-1 truncate text-12 text-tertiary">{fileName}</div>
</div>
<button
type="button"
className="hover:bg-surface-3 grid size-9 place-items-center rounded-full bg-surface-2 text-tertiary transition hover:text-primary"
onClick={onClose}
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{versions.length === 0 ? (
<div className="rounded-2xl bg-surface-2/60 px-4 py-8 text-center text-13 text-tertiary">
История версий пока не создана.
</div>
) : (
<div className="overflow-hidden rounded-2xl bg-surface-2/40">
<div className="min-w-0">
<div className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] gap-3 px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
<div>Файл</div>
<div>Версия</div>
<div>Размер</div>
<div>Автор</div>
<div>Дата</div>
<div>Статус</div>
<div className="text-right">Действия</div>
</div>
{[...versions]
.sort((first, second) => second.version - first.version)
.map((version) => {
const viewerUrl = getBeamVersionViewerUrl(version);
const isCurrent = currentVersionId
? version.versionId === currentVersionId
: version.version === latestVersion;
const statusLabel = getBeamConversionStatusLabel(version.conversion?.status) ?? "Модель готова";
const versionKey = getBeamVersionRecordKey(version);
const isDeletingVersion = deletingVersionKey === versionKey;
const authorName = getVersionAuthorName(version);
const versionSize = version.size || version.conversion?.size || 0;
return (
<div
key={versionKey}
className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition last:border-b-0 hover:bg-surface-2"
>
<div className="min-w-0">
<div className="truncate font-semibold text-secondary">{version.originalFilename}</div>
{version.sha256 && (
<div className="mt-1 truncate text-10 text-tertiary">sha256 {version.sha256}</div>
)}
</div>
<div className="font-semibold text-secondary">
v{version.version}
{isCurrent && (
<span className="mt-1 block text-10 font-medium text-[rgb(var(--nodedc-accent-rgb))]">
текущая
</span>
)}
</div>
<div className="text-tertiary">{convertBytesToSize(versionSize)}</div>
<div className="truncate text-tertiary">{authorName}</div>
<div className="truncate text-tertiary">{renderFormattedDate(version.uploadedAt)}</div>
<div className="truncate text-tertiary">{statusLabel}</div>
<div className="flex items-center justify-end gap-1">
{viewerUrl && (
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={() => onOpenVersion(version)}
>
<Eye className="size-4" />
</button>
)}
{version.downloadUrl && (
<a
href={version.downloadUrl}
download={version.originalFilename}
target="_blank"
rel="noopener noreferrer"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
>
<Download className="size-4" />
</a>
)}
<Tooltip
tooltipContent={isCurrent ? "Текущую версию нельзя удалить" : "Удалить версию из истории"}
>
<button
type="button"
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
disabled={isCurrent || isDeletingVersion}
onClick={() => onDeleteVersion(version)}
>
{isDeletingVersion ? <Spinner className="size-3.5" /> : <TrashIcon className="size-4" />}
</button>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</ModalCore>
);
};

View File

@ -6,16 +6,14 @@
import { useRef, type ReactNode } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react";
import { Maximize2, Minimize2, MoveRight, X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CenterPanelIcon, CopyLinkIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
import { CopyLinkIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -31,24 +29,6 @@ import { IconButton } from "@plane/propel/icon-button";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
i18n_title: "common.side_peek",
},
{
key: "modal",
icon: CenterPanelIcon,
i18n_title: "common.modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
i18n_title: "common.full_screen",
},
];
export type PeekOverviewHeaderProps = {
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
@ -68,7 +48,6 @@ export type PeekOverviewHeaderProps = {
actionSlot?: ReactNode;
metaSlot?: ReactNode;
showCopyLink?: boolean;
showLayoutSwitcher?: boolean;
showQuickActions?: boolean;
showSubscription?: boolean;
};
@ -82,7 +61,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
issueId,
isArchived,
disabled,
embedIssue = false,
removeRoutePeekId,
toggleDeleteIssueModal,
toggleArchiveIssueModal,
@ -93,7 +71,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
actionSlot,
metaSlot,
showCopyLink = true,
showLayoutSwitcher = !embedIssue,
showQuickActions = true,
showSubscription = true,
} = props;
@ -113,7 +90,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails = getIssueById(issueId);
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const {
issues: { removeIssue: removeArchivedIssue },
@ -168,50 +144,29 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<div
ref={parentRef}
className={`relative flex flex-wrap items-start justify-between gap-3 px-6 py-5 ${
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
peekMode === "full-screen" ? "border-b border-subtle/70" : ""
}`}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3 md:gap-4">
<Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}>
<button onClick={removeRoutePeekId}>
{peekMode === "side-peek" ? (
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
) : (
<X className="h-4 w-4 text-tertiary hover:text-secondary" />
)}
</button>
</Tooltip>
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
</Link>
</Tooltip>
{currentMode && showLayoutSwitcher && (
<div className="flex flex-shrink-0 items-center gap-2">
<SelectionDropdown
menuButton={
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}>
<span>
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</span>
</Tooltip>
}
menuButtonWrapperClassName="flex items-center"
options={PEEK_OPTIONS.map((mode) => ({
key: mode.key,
isChecked: currentMode.key === mode.key,
onClick: () => setPeekMode(mode.key),
title: (
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{t(mode.i18n_title)}
</div>
),
}))}
/>
</div>
<button type="button" onClick={() => setPeekMode(peekMode === "full-screen" ? "side-peek" : "full-screen")}>
{peekMode === "full-screen" ? (
<Minimize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
) : (
<Maximize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
)}
</button>
</Tooltip>
{metaSlot}
</div>
<div className="ml-auto flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-4 gap-y-2">

View File

@ -9,16 +9,19 @@ import {
useEffect,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { Download, Maximize2, Minimize2, X } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { cn } from "@plane/utils";
import { cn, convertBytesToSize } from "@plane/utils";
import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpers/beam-viewer";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import useKeypress from "@/hooks/use-keypress";
@ -35,6 +38,7 @@ import { IssuePeekOverviewLoader } from "./loader";
import { PeekOverviewProperties } from "./properties";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
interface IIssueView {
workspaceSlug: string;
@ -89,6 +93,9 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
if (typeof window === "undefined") return 720;
@ -101,9 +108,12 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [isResizingPeek, setIsResizingPeek] = useState(false);
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const beamPeekViewerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorRefApi>(null);
const initialPeekWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
const livePeekWidthRef = useRef<number>(sidePeekWidth);
const beamCloseTimeoutRef = useRef<number | null>(null);
// store hooks
const {
setPeekIssue,
@ -114,7 +124,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
// remove peek id
const removeRoutePeekId = () => {
@ -129,6 +138,100 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const isAnyLocalModalOpen =
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
const shouldShowBeamPeekViewer = !embedIssue && peekMode === "side-peek" && !!beamPeekViewer;
const clearBeamCloseTimeout = useCallback(() => {
if (typeof window === "undefined" || beamCloseTimeoutRef.current === null) return;
window.clearTimeout(beamCloseTimeoutRef.current);
beamCloseTimeoutRef.current = null;
}, []);
const closeBeamPeekViewer = useCallback(
(event?: ReactMouseEvent<HTMLButtonElement>) => {
event?.preventDefault();
event?.stopPropagation();
if (isBeamPeekClosing) return;
if (typeof document !== "undefined" && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
clearBeamCloseTimeout();
document.documentElement.classList.add(BEAM_VIEWER_CLOSING_CLASS_NAME);
setIsBeamPeekClosing(true);
setIsBeamPeekFullscreen(false);
beamCloseTimeoutRef.current = window.setTimeout(() => {
beamCloseTimeoutRef.current = null;
setBeamPeekViewer(null);
setIsBeamPeekClosing(false);
document.documentElement.classList.remove(BEAM_VIEWER_CLOSING_CLASS_NAME);
}, 140);
},
[clearBeamCloseTimeout, isBeamPeekClosing]
);
useEffect(
() => () => {
clearBeamCloseTimeout();
document.documentElement.classList.remove(BEAM_VIEWER_CLOSING_CLASS_NAME);
},
[clearBeamCloseTimeout]
);
useEffect(() => {
if (typeof window === "undefined" || !shouldShowBeamPeekViewer) return;
const root = document.documentElement;
root.classList.add("nodedc-beam-viewer-interaction-lock");
const isViewerEvent = (event: Event) => {
const viewerCard = beamPeekViewerRef.current?.querySelector(".nodedc-beam-peek-viewer-card");
const target = event.target instanceof Node ? event.target : null;
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
return !!viewerCard && ((target && viewerCard.contains(target)) || path.includes(viewerCard));
};
const stopViewerGestureLeak = (event: Event) => {
if (!isViewerEvent(event)) return;
event.stopPropagation();
if ((event.type === "wheel" || event.type === "touchmove") && event.cancelable) {
event.preventDefault();
}
};
const passiveOptions: AddEventListenerOptions = { capture: true, passive: true };
const activeOptions: AddEventListenerOptions = { capture: true, passive: false };
window.addEventListener("wheel", stopViewerGestureLeak, activeOptions);
window.addEventListener("touchmove", stopViewerGestureLeak, activeOptions);
window.addEventListener("pointerdown", stopViewerGestureLeak, passiveOptions);
window.addEventListener("pointermove", stopViewerGestureLeak, passiveOptions);
window.addEventListener("pointerup", stopViewerGestureLeak, passiveOptions);
return () => {
root.classList.remove("nodedc-beam-viewer-interaction-lock");
window.removeEventListener("wheel", stopViewerGestureLeak, activeOptions);
window.removeEventListener("touchmove", stopViewerGestureLeak, activeOptions);
window.removeEventListener("pointerdown", stopViewerGestureLeak, passiveOptions);
window.removeEventListener("pointermove", stopViewerGestureLeak, passiveOptions);
window.removeEventListener("pointerup", stopViewerGestureLeak, passiveOptions);
};
}, [shouldShowBeamPeekViewer]);
const getMaxSidePeekWidth = useCallback(() => {
if (typeof window === "undefined") return 720;
const minWidth = 640;
if (shouldShowBeamPeekViewer && !isBeamPeekFullscreen) {
return Math.max(minWidth, Math.floor((window.innerWidth - 24) / 2));
}
return Math.max(720, window.innerWidth - 48);
}, [isBeamPeekFullscreen, shouldShowBeamPeekViewer]);
const stopPeekResizing = useCallback(() => {
setIsResizingPeek(false);
@ -138,21 +241,25 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
(event: MouseEvent) => {
if (!isResizingPeek) return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const maxWidth = getMaxSidePeekWidth();
const minWidth = 640;
const deltaX = event.clientX - initialMouseXRef.current;
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
livePeekWidthRef.current = nextWidth;
setSidePeekWidth(nextWidth);
},
[isResizingPeek]
[getMaxSidePeekWidth, isResizingPeek]
);
const startPeekResizing = useCallback(
(event: ReactMouseEvent) => {
if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
event.preventDefault();
event.stopPropagation();
setIsResizingPeek(true);
initialPeekWidthRef.current = sidePeekWidth;
livePeekWidthRef.current = sidePeekWidth;
initialMouseXRef.current = event.clientX;
},
[peekMode, shouldAllowPeekResize, sidePeekWidth]
@ -160,18 +267,18 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
useEffect(() => {
const handleWindowResize = () => {
const maxWidth = Math.max(720, window.innerWidth - 48);
const maxWidth = getMaxSidePeekWidth();
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
};
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, []);
}, [getMaxSidePeekWidth]);
useEffect(() => {
if (typeof window === "undefined") return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const maxWidth = getMaxSidePeekWidth();
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
@ -179,8 +286,36 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
if (clampedWidth !== sidePeekWidth) {
setSidePeekWidth(clampedWidth);
}
}, [getMaxSidePeekWidth, sidePeekWidth]);
useEffect(() => {
livePeekWidthRef.current = sidePeekWidth;
}, [sidePeekWidth]);
useEffect(() => {
clearBeamCloseTimeout();
setBeamPeekViewer(null);
setIsBeamPeekFullscreen(false);
setIsBeamPeekClosing(false);
document.documentElement.classList.remove(BEAM_VIEWER_CLOSING_CLASS_NAME);
}, [clearBeamCloseTimeout, issueId]);
useEffect(() => {
const handleOpenBeamViewer = (event: Event) => {
const detail = (event as CustomEvent<TBeamViewerOpenEventDetail>).detail;
if (!detail?.viewerUrl || detail.issueId !== issueId) return;
clearBeamCloseTimeout();
setPeekMode("side-peek");
setIsBeamPeekClosing(false);
document.documentElement.classList.remove(BEAM_VIEWER_CLOSING_CLASS_NAME);
setBeamPeekViewer(detail);
setIsBeamPeekFullscreen(false);
};
window.addEventListener(BEAM_VIEWER_OPEN_EVENT, handleOpenBeamViewer);
return () => window.removeEventListener(BEAM_VIEWER_OPEN_EVENT, handleOpenBeamViewer);
}, [clearBeamCloseTimeout, issueId]);
useEffect(() => {
if (!isResizingPeek) return;
@ -219,6 +354,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
const dropdownElement = document.activeElement?.tagName === "INPUT";
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (beamPeekViewer) {
closeBeamPeekViewer();
return;
}
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
removeRoutePeekId();
const issueElement = document.getElementById(`issue-${issueId}`);
@ -240,11 +379,12 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
: "h-full w-full",
!embedIssue && "absolute z-[80]",
!embedIssue && {
"top-[5.35rem] right-3 bottom-[5.85rem] w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
"nodedc-peek-side-bound top-[5.35rem] right-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:max-w-[calc(100vw-1.5rem)] md:min-w-[640px]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
"nodedc-peek-modal-bound rounded-[28px]": peekMode === "modal",
"nodedc-peek-full-bound rounded-[28px]": peekMode === "full-screen",
},
!embedIssue && isBeamPeekClosing && "nodedc-beam-peek-closing !transition-none !duration-0",
shouldUseInteractiveEmbeddedLayout && {
"relative ml-auto h-full rounded-[28px]": peekMode === "side-peek",
"relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]":
@ -257,20 +397,17 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
const content = (
<div className="w-full text-body-sm-regular">
{issueId && (
<div
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={{
const issuePanelStyle: CSSProperties = {
width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
boxShadow:
shouldRenderPeekSurface
boxShadow: shouldRenderPeekSurface
? "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)"
: undefined,
}}
>
transition: isResizingPeek ? "none" : undefined,
willChange: isResizingPeek ? "width" : undefined,
};
const issuePanel = issueId ? (
<div ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={issuePanelStyle}>
{shouldAllowPeekResize && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
@ -312,7 +449,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
isSubmitting={isSubmitting}
disabled={disabled}
embedIssue={embedIssue}
showLayoutSwitcher={shouldAllowPeekModeToggle}
/>
)}
{/* content */}
@ -420,7 +556,81 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
</>
)}
</div>
) : null;
const content = (
<div className="w-full text-body-sm-regular">
{shouldShowBeamPeekViewer && beamPeekViewer && (
<div
ref={beamPeekViewerRef}
className="nodedc-beam-peek-viewer-layer"
data-prevent-outside-click
data-fullscreen={isBeamPeekFullscreen ? "true" : "false"}
data-resizing={isResizingPeek ? "true" : "false"}
data-closing={isBeamPeekClosing ? "true" : "false"}
style={
{
"--nodedc-beam-peek-width": `${sidePeekWidth}px`,
} as CSSProperties
}
>
<section className="nodedc-beam-peek-viewer-card" aria-label="BIM Viewer">
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div aria-hidden="true" />
<div className="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
<div className="truncate text-13 font-semibold">{beamPeekViewer.fileName}</div>
<div className="mt-0.5 text-11 text-white/70">{convertBytesToSize(beamPeekViewer.fileSize)}</div>
</div>
<div className="pointer-events-auto flex items-center justify-end gap-2">
{beamPeekViewer.downloadUrl && (
<a
href={beamPeekViewer.downloadUrl}
download={beamPeekViewer.fileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
onClick={(event) => event.stopPropagation()}
title="Скачать оригинал"
>
<Download className="size-5" />
</a>
)}
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onClick={() => setIsBeamPeekFullscreen((current) => !current)}
title={isBeamPeekFullscreen ? "Вернуть карточку" : "Во весь экран"}
>
{isBeamPeekFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</button>
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={closeBeamPeekViewer}
title="Закрыть"
>
<X className="size-5" />
</button>
</div>
</div>
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
<iframe
key={beamPeekViewer.viewerUrl}
src={beamPeekViewer.viewerUrl}
title={`BIM Viewer: ${beamPeekViewer.fileName}`}
tabIndex={-1}
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
allow="fullscreen"
/>
{isResizingPeek && <div className="nodedc-beam-peek-resize-shield" aria-hidden="true" />}
</section>
</div>
)}
{issuePanel}
</div>
);

View File

@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck } from "lucide-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck, Terminal } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -21,6 +21,7 @@ import {
type TCodexAgent,
type TCodexAgentGrant,
type TCodexAgentGrantableWorkspace,
type TCodexAgentSetupCode,
type TCodexAgentSetupPacket,
type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service";
@ -59,7 +60,9 @@ type TProps = {
type TAgentSetupCard = {
agent: TCodexAgent;
grants: TCodexAgentGrant[];
installCommand?: string;
setup?: TCodexAgentSetupPacket;
setupCode?: TCodexAgentSetupCode;
tokens: TCodexAgentToken[];
};
@ -84,6 +87,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState<Record<string, boolean>>({});
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
@ -221,19 +225,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
mode: "voluntary",
}
);
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
name: `${displayName} local token`,
});
setRevealedTokens((currentTokens) => ({
...currentTokens,
[tokenResponse.token_record.id]: tokenResponse.token,
}));
const [setupResponse, setupCodeResponse] = await Promise.all([
codexAgentService.getSetup(workspaceSlug, createResponse.agent.id),
codexAgentService.createSetupCode(workspaceSlug, createResponse.agent.id, {
expires_in_seconds: 600,
token_name: `${displayName} local token`,
}),
]);
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(
upsertSetupCardSetupCode(
currentCards,
createResponse.agent,
tokenResponse.token_record,
tokenResponse.setup,
setupCodeResponse.setup_code_record,
setupCodeResponse.install.command,
setupResponse.setup,
grantsResponse.grants
)
);
@ -242,7 +247,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Codex agent создан",
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
message:
"Скопируйте terminal-команду в локальный терминал. Setup-код действует 10 минут и используется один раз.",
});
} catch (error: any) {
setToast({
@ -255,6 +261,40 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
}
};
const handleCreateSetupCode = async (agent: TCodexAgent) => {
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
const setupCodeResponse = await codexAgentService.createSetupCode(workspaceSlug, agent.id, {
expires_in_seconds: 600,
token_name: `${agent.display_name} local token`,
});
const currentCard = setupCards.find((card) => card.agent.id === agent.id);
setCreatedSetupCards((currentCards) =>
upsertSetupCardSetupCode(
currentCards,
agent,
setupCodeResponse.setup_code_record,
setupCodeResponse.install.command,
currentCard?.setup,
currentCard?.grants ?? []
)
);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Terminal-команда выпущена",
message: "Setup-код действует 10 минут и используется один раз.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось выпустить terminal-команду",
message: error?.message ?? error?.error ?? "Проверьте Gateway и права workspace.",
});
} finally {
setCreatingSetupCodeAgentIds((current) => ({ ...current, [agent.id]: false }));
}
};
const handleCreateToken = async (agent: TCodexAgent) => {
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
@ -435,7 +475,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
<CapabilityCard
icon={KeyRound}
title="Локальный Codex"
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
description="Пользовательский Codex подключается по MCP endpoint через одноразовый setup-code или legacy agent token."
/>
</section>
@ -445,7 +485,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
<div className="flex flex-col gap-1.5">
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
<p className="max-w-3xl text-12 leading-5 text-tertiary">
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар
меняется кликом по кругу.
</p>
</div>
@ -514,11 +555,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
activeAgents.map((agent) => {
const draftName = getAgentDraftName(agentDraftNames, agent);
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
const isCreatingSetupCode = creatingSetupCodeAgentIds[agent.id] === true;
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? [];
const agentGrants = setupCard?.grants ?? [];
const installCommand = setupCard?.installCommand;
const setupCode = setupCard?.setupCode;
const currentGrantKeys = getGrantedProjectKeys(agentGrants);
const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
@ -584,15 +628,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
>
Сохранить
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip h-12"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый токен
</Button>
<Button
variant="secondary"
size="sm"
@ -604,8 +639,18 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
</div>
<AgentTerminalSetupPanel
command={installCommand}
isCreating={isCreatingSetupCode}
setupCode={setupCode}
onCopyCommand={() => installCommand && void handleCopy(installCommand, "Terminal-команда")}
onCreate={() => void handleCreateSetupCode(agent)}
/>
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">Загрузка токена...</div>
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка legacy-токенов...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
@ -617,7 +662,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
return (
<div key={token.id} className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
Legacy agent token
</div>
<div className="relative">
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
@ -647,8 +692,19 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
})}
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
<div className="nodedc-settings-field flex flex-col gap-3 px-4 py-4 text-13 text-secondary sm:flex-row sm:items-center sm:justify-between">
<span>
Legacy-токен ещё не выпущен. Используйте его только для ручной настройки config.toml.
</span>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button shrink-0"
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Сгенерировать токен
</Button>
</div>
)}
@ -704,12 +760,74 @@ type TCodexConnectionGuideProps = {
onCopyConfig: () => void;
};
type TAgentTerminalSetupPanelProps = {
command?: string;
isCreating: boolean;
onCopyCommand: () => void;
onCreate: () => void;
setupCode?: TCodexAgentSetupCode;
};
function AgentTerminalSetupPanel(props: TAgentTerminalSetupPanelProps) {
const expiresAtLabel = props.setupCode?.expires_at ? new Date(props.setupCode.expires_at).toLocaleString() : null;
return (
<div className="nodedc-settings-field p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
<Terminal className="size-4" />
</span>
<div className="min-w-0">
<div className="text-13 font-semibold text-primary">Recommended terminal setup</div>
<div className="mt-1 text-12 text-tertiary">
Одноразовая npm-команда для локального терминала: создаст token, обновит Codex config и установит
ops-context skill.
</div>
</div>
</div>
<Button
variant={props.command ? "secondary" : "primary"}
size="sm"
className={props.command ? "nodedc-settings-chip" : "nodedc-settings-save-button"}
loading={props.isCreating}
onClick={props.onCreate}
>
{props.command ? "Обновить команду" : "Получить команду"}
</Button>
</div>
{props.command ? (
<div className="mt-4 grid gap-3">
<pre className="nodedc-settings-input font-mono max-h-40 w-full overflow-auto px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
{props.command}
</pre>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-12 text-tertiary">
{expiresAtLabel ? `Действует до ${expiresAtLabel}. ` : ""}
Команда не показывает agent token, использует npm package installer и сработает только один раз.
</div>
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyCommand}>
<Copy className="mr-2 size-3.5" />
Скопировать команду
</Button>
</div>
</div>
) : (
<div className="mt-4 rounded-2xl bg-white/[0.03] px-4 py-3 text-12 leading-5 text-tertiary">
Нажмите «Получить команду» и вставьте ее в терминал на машине, где запущен локальный Codex.
</div>
)}
</div>
);
}
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
<div className="text-14 font-semibold text-primary">Legacy manual setup</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
@ -763,7 +881,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
<p className="mb-3 text-13 leading-5 text-secondary">
Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
<code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть замените только этот блок и его{" "}
<code>headers</code>.
<code>http_headers</code>.
</p>
<pre className="nodedc-settings-input font-mono w-full px-3 py-3 text-12 leading-5 break-all whitespace-pre-wrap text-primary">
{props.configSnippet}
@ -790,7 +908,7 @@ required = false
startup_timeout_sec = 20
tool_timeout_sec = 60
[mcp_servers.${CODEX_MCP_SERVER_NAME}.headers]
[mcp_servers.${CODEX_MCP_SERVER_NAME}.http_headers]
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
Accept = "application/json"
"MCP-Protocol-Version" = "2025-06-18"`;
@ -994,12 +1112,14 @@ function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgent
cardsByAgentId.set(card.agent.id, {
agent: persistedCard.agent,
grants: mergeAgentGrants(persistedCard.grants, card.grants),
installCommand: card.installCommand ?? persistedCard.installCommand,
setup: persistedCard.setup ?? card.setup,
setupCode: card.setupCode ?? persistedCard.setupCode,
tokens: mergeTokens(persistedCard.tokens, card.tokens),
});
}
return Array.from(cardsByAgentId.values()).filter((card) => card.tokens.length > 0);
return Array.from(cardsByAgentId.values());
}
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
@ -1029,13 +1149,42 @@ function upsertSetupCardToken(
? {
agent,
grants: mergeAgentGrants(card.grants, grants),
installCommand: card.installCommand,
setup: setup ?? card.setup,
setupCode: card.setupCode,
tokens: mergeTokens([token], card.tokens),
}
: card
);
}
function upsertSetupCardSetupCode(
cards: TAgentSetupCard[],
agent: TCodexAgent,
setupCode: TCodexAgentSetupCode,
installCommand: string,
setup?: TCodexAgentSetupPacket,
grants: TCodexAgentGrant[] = []
): TAgentSetupCard[] {
const existingCard = cards.find((card) => card.agent.id === agent.id);
if (!existingCard) {
return [{ agent, grants, installCommand, setup, setupCode, tokens: [] }, ...cards];
}
return cards.map((card) =>
card.agent.id === agent.id
? {
agent,
grants: mergeAgentGrants(card.grants, grants),
installCommand,
setup: setup ?? card.setup,
setupCode,
tokens: card.tokens,
}
: card
);
}
function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
return workspaceGroups.flatMap((workspaceGroup) =>
workspaceGroup.projects.map((project) => ({

View File

@ -62,7 +62,7 @@ const StatCard = (props: {
return (
<div className="nodedc-settings-card p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
@ -73,7 +73,7 @@ const StatCard = (props: {
<Icon className="size-4" />
</div>
</div>
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div>
<div className="text-3xl tracking-normal font-semibold text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div>
);
@ -92,7 +92,7 @@ const ProjectStorageRow = (props: {
<div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
<span className="mt-1 text-11 tracking-[0.16em] text-tertiary uppercase">{project.identifier}</span>
</div>
<StorageValue>{formatCount(project.file_count)}</StorageValue>
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue>
<StorageValue warning={project.stale_unuploaded_count > 0}>
{formatCount(project.stale_unuploaded_count)}
</StorageValue>
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
</div>
);
@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
);
const ProjectStorageHeader = () => (
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold tracking-[0.16em] text-tertiary uppercase">
<span>Проект</span>
<span>Файлы</span>
<span>Blob</span>
@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
title: "Квота обновлена",
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
});
} catch (err) {
} catch (_err) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Квота не сохранена",
@ -200,6 +202,7 @@ const ProjectQuotaControl = (props: {
<Database className="size-3.5" />
</button>
<div className="min-w-0 flex-1">
{isEnabled ? (
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
<input
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
@ -217,6 +220,12 @@ const ProjectQuotaControl = (props: {
/>
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
</div>
) : (
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
Без лимита
</div>
)}
{project.quota_enabled && (
<div
className={cn(
"mt-1 truncate text-[11px] text-tertiary",
@ -224,10 +233,9 @@ const ProjectQuotaControl = (props: {
project.quota_exceeded && "text-red-200"
)}
>
{project.quota_enabled
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
: "без лимита"}
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
</div>
)}
</div>
<button
type="button"
@ -269,8 +277,8 @@ const MaintenanceCard = ({
<div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div>
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
<div className="text-2xl tracking-normal mt-3 font-semibold text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
@ -300,7 +308,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
);
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
const projects = [...(data?.projects ?? [])].toSorted((a, b) => b.logical_size - a.logical_size);
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
title: "Хранилище обновлено",
message: getMaintenanceResultMessage(result.action, result),
});
} catch (err) {
} catch (_err) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Действие не выполнено",
@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
{isLoading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
))}
</div>
)}
{error && (
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200">
<div className="nodedc-settings-card bg-red-500/10 text-red-200 px-5 py-4 text-14">
Не удалось загрузить данные хранилища.
</div>
)}
@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
<section>
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2>
<h2 className="text-lg tracking-normal font-semibold text-primary">Обслуживание</h2>
<p className="mt-1 text-13 text-secondary">
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
</p>
@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
<section className="nodedc-settings-card p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
<h2 className="text-lg tracking-normal font-semibold text-primary">Проекты</h2>
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
</div>
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">

View File

@ -8,12 +8,36 @@ import type { AxiosRequestConfig } from "axios";
import { API_BASE_URL } from "@plane/constants";
// plane types
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
import type { TIssueAttachment, TIssueAttachmentUploadResponse, TIssueServiceType } from "@plane/types";
import type {
TFileMetaDataLite,
TIssueAttachment,
TIssueAttachmentUploadResponse,
TIssueServiceType,
} from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// services
import {
deleteBeamModelAsset,
deleteBeamModelVersion,
getBeamModelVersionRecords,
getBeamModelMimeType,
getBeamViewerAttachment,
getBeamViewerVersionNumber,
isBeamModelFile,
mergeBeamModelVersionRecords,
type TBeamModelVersionRecord,
type TBeamViewerAttachment,
uploadBeamModelFile,
} from "@/helpers/beam-viewer";
import { APIService } from "@/services/api.service";
import { FileUploadService } from "@/services/file-upload.service";
type TIssueAttachmentCreateResponse = {
asset_id: string;
asset_url: string;
attachment: TIssueAttachment;
};
export class IssueAttachmentService extends APIService {
private fileUploadService: FileUploadService;
private serviceType: TIssueServiceType;
@ -40,6 +64,145 @@ export class IssueAttachmentService extends APIService {
});
}
private async createBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
): Promise<TIssueAttachment> {
return this.post(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
payload
)
.then((response) => {
const createResponse: TIssueAttachmentCreateResponse = response?.data;
return createResponse.attachment;
})
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async updateBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
beamViewer: TBeamViewerAttachment,
attributes: Record<string, unknown> = {}
): Promise<TIssueAttachment> {
return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
{ ...attributes, beamViewer }
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async uploadBeamIssueAttachmentVersion(
workspaceSlug: string,
projectId: string,
issueId: string,
attachment: TIssueAttachment,
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
uploadedBy?: string
): Promise<TIssueAttachment> {
const previousBeamViewer = getBeamViewerAttachment(attachment);
if (!previousBeamViewer) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
if (!isBeamModelFile(file.name)) {
throw new Error("Формат модели не поддерживается BIM Viewer.");
}
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
const currentVersion = Math.max(
getBeamViewerVersionNumber(previousBeamViewer, attachment),
...previousVersions.map((version) => version.version)
);
const nextVersion = currentVersion + 1;
const assetId = previousBeamViewer.assetId || attachment.id;
const nextBeamViewer = await uploadBeamModelFile(file, {
assetId,
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
uploadedBy,
version: nextVersion,
workspaceSlug,
});
const nextVersions = mergeBeamModelVersionRecords(
{ ...previousBeamViewer, assetId, versions: previousVersions },
nextBeamViewer,
attachment
);
return this.updateBeamIssueAttachmentReference(
workspaceSlug,
projectId,
issueId,
attachment.id,
{
...nextBeamViewer,
assetId,
projectId: nextBeamViewer.projectId || previousBeamViewer.projectId,
previewAvailable: nextBeamViewer.previewAvailable,
uploadedBy,
version: nextVersion,
versions: nextVersions,
viewerUrl: nextBeamViewer.viewerUrl ?? null,
},
{
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
version: nextVersion,
}
);
}
async deleteBeamIssueAttachmentVersion(
workspaceSlug: string,
projectId: string,
issueId: string,
attachment: TIssueAttachment,
versionToDelete: TBeamModelVersionRecord
): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (!beamViewer) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
const currentVersion = getBeamViewerVersionNumber(beamViewer, attachment);
const isCurrentVersion = versionToDelete.versionId
? beamViewer.versionId === versionToDelete.versionId
: currentVersion === versionToDelete.version;
if (isCurrentVersion) {
throw new Error("Текущую версию нельзя удалить. Сначала переключите модель на другую версию.");
}
const versions = getBeamModelVersionRecords(beamViewer, attachment);
const nextVersions = versions.filter((version) =>
versionToDelete.versionId
? version.versionId !== versionToDelete.versionId
: version.version !== versionToDelete.version
);
if (nextVersions.length === versions.length) {
throw new Error("Версия не найдена.");
}
await deleteBeamModelVersion(versionToDelete);
return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
...beamViewer,
versions: nextVersions,
});
}
async uploadIssueAttachment(
workspaceSlug: string,
projectId: string,
@ -47,6 +210,22 @@ export class IssueAttachmentService extends APIService {
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TIssueAttachment> {
if (isBeamModelFile(file.name)) {
const beamViewer = await uploadBeamModelFile(file, {
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
workspaceSlug,
});
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
beamViewer,
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
});
}
const fileMetaData = await getFileMetaDataForUpload(file);
return this.post(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
@ -98,8 +277,16 @@ export class IssueAttachmentService extends APIService {
workspaceSlug: string,
projectId: string,
issueId: string,
assetId: string
assetId: string,
attachment?: TIssueAttachment
): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (beamViewer) {
await deleteBeamModelAsset(beamViewer).catch((error) => {
console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
});
}
return this.delete(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
)

View File

@ -0,0 +1,432 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { API_BASE_URL } from "@plane/constants";
import { APIService } from "@/services/api.service";
export type TAIWorkspaceExecutorStatus = "unknown" | "online" | "offline" | "checking" | "error";
export type TAIWorkspaceExecutorConnectionMode = "hub" | "direct";
export type TAIWorkspaceExecutor = {
id: string;
name: string;
type: string;
connectionMode: TAIWorkspaceExecutorConnectionMode;
endpoint?: string | null;
workspacePath?: string | null;
agentPort?: number | null;
pairingCode?: string | null;
model?: string | null;
accountLabel?: string | null;
capabilities?: string[];
status: TAIWorkspaceExecutorStatus;
statusDetail?: string | null;
lastSeenAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
};
export type TAIWorkspaceExecutorListResponse = {
ok: boolean;
selectedExecutorId?: string | null;
executors: TAIWorkspaceExecutor[];
};
export type TAIWorkspaceExecutorInput = {
id?: string;
name: string;
type?: string;
connectionMode?: TAIWorkspaceExecutorConnectionMode;
endpoint?: string | null;
workspacePath?: string | null;
agentPort?: number | null;
pairingCode?: string | null;
model?: string | null;
accountLabel?: string | null;
capabilities?: string[];
status?: TAIWorkspaceExecutorStatus;
};
export type TAIWorkspaceThread = {
id: string;
title: string;
originSurface: "engine" | "ops" | "global" | string;
activeContext?: Record<string, any>;
enabledToolPacks?: string[];
linkedArtifacts?: Record<string, any>[];
selectedExecutorId?: string | null;
lifecycleState?: "active" | "archived" | string;
metadata?: Record<string, any>;
createdAt?: string | null;
updatedAt?: string | null;
};
export type TAIWorkspaceThreadMessageRole = "system" | "user" | "assistant" | "tool";
export type TAIWorkspaceThreadMessage = {
id: string;
threadId: string;
sequence?: number;
role: TAIWorkspaceThreadMessageRole;
content: string;
payload?: Record<string, any>;
createdAt?: string | null;
};
export type TAIWorkspaceThreadListResponse = {
ok: boolean;
threads: TAIWorkspaceThread[];
};
export type TAIWorkspaceThreadResponse = {
ok: boolean;
thread: TAIWorkspaceThread;
};
export type TAIWorkspaceThreadMessagesResponse = {
ok: boolean;
threadId: string;
messages: TAIWorkspaceThreadMessage[];
};
export type TAIWorkspaceThreadMessageResponse = {
ok: boolean;
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 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;
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";
activeContext?: Record<string, any>;
enabledToolPacks?: string[];
linkedArtifacts?: Record<string, any>[];
selectedExecutorId?: string | null;
lifecycleState?: "active" | "archived";
metadata?: Record<string, any>;
};
export class WorkspaceAIWorkspaceService extends APIService {
constructor() {
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)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async createExecutor(
workspaceSlug: string,
input: TAIWorkspaceExecutorInput
): Promise<TAIWorkspaceExecutorListResponse> {
await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/`, { ...input, select: true }).catch(
(error) => {
throw error?.response?.data ?? error;
}
);
return this.listExecutors(workspaceSlug);
}
async updateExecutor(
workspaceSlug: string,
executorId: string,
input: Partial<TAIWorkspaceExecutorInput>
): Promise<TAIWorkspaceExecutorListResponse> {
await this.patch(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`, input).catch((error) => {
throw error?.response?.data ?? error;
});
return this.listExecutors(workspaceSlug);
}
async deleteExecutor(workspaceSlug: string, executorId: string): Promise<TAIWorkspaceExecutorListResponse> {
await this.delete(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/`).catch((error) => {
throw error?.response?.data ?? error;
});
return this.listExecutors(workspaceSlug);
}
async selectExecutor(workspaceSlug: string, executorId: string): Promise<TAIWorkspaceExecutorListResponse> {
await this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/select/`).catch((error) => {
throw error?.response?.data ?? error;
});
return this.listExecutors(workspaceSlug);
}
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);
}
async createAgentSetupCommand(
workspaceSlug: string,
executorId: string,
options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {}
): Promise<TAIWorkspaceAgentSetupCommandResponse> {
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,
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()}`;
}
async listThreads(
workspaceSlug: string,
options: { surface?: "ops" | "engine" | "global"; limit?: number } = {}
): Promise<TAIWorkspaceThreadListResponse> {
const params = new URLSearchParams();
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)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async createThread(workspaceSlug: string, input: TAIWorkspaceThreadInput): Promise<TAIWorkspaceThreadResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/`, {
originSurface: "ops",
enabledToolPacks: ["ops", "engine"],
...input,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async updateThread(
workspaceSlug: string,
threadId: string,
input: Partial<TAIWorkspaceThreadInput>
): Promise<TAIWorkspaceThreadResponse> {
return this.patch(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/${threadId}/`, input)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async listThreadMessages(
workspaceSlug: string,
threadId: string,
options: { limit?: number } = {}
): Promise<TAIWorkspaceThreadMessagesResponse> {
const params = new URLSearchParams();
params.set("limit", String(options.limit || 200));
return this.get(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/${threadId}/messages/?${params.toString()}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async createThreadMessage(
workspaceSlug: string,
threadId: string,
input: { role: TAIWorkspaceThreadMessageRole; content: string; payload?: Record<string, any> }
): Promise<TAIWorkspaceThreadMessageResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/threads/${threadId}/messages/`, input)
.then((response) => response?.data)
.catch((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;
});
}
}

View File

@ -10,6 +10,7 @@ import { APIService } from "@/services/api.service";
export type TCodexAgentStatus = "active" | "disabled" | "revoked";
export type TCodexAgentGrantMode = "voluntary" | "reporting";
export type TCodexAgentTokenStatus = "active" | "revoked" | "expired";
export type TCodexAgentSetupCodeStatus = "active" | "used" | "expired" | "revoked";
export type TCodexAgent = {
id: string;
@ -45,6 +46,19 @@ export type TCodexAgentToken = {
created_at: string;
};
export type TCodexAgentSetupCode = {
id: string;
agent_id: string;
code_suffix: string | null;
status: TCodexAgentSetupCodeStatus;
token_name: string;
created_by_user_id: string | null;
expires_at: string;
used_at: string | null;
used_token_id: string | null;
created_at: string;
};
export type TCodexAgentSetupPacket = {
agents_md?: string;
codex_config_template?: Record<string, unknown>;
@ -67,6 +81,25 @@ export type TCodexAgentCreateTokenResponse = {
token_record: TCodexAgentToken;
};
export type TCodexAgentCreateSetupCodeResponse = {
ok: boolean;
install: {
command: string;
fallback_command?: string;
installer_kind?: "npm_registry" | "npm_tarball" | "python_script";
installer_url: string;
legacy_python_installer_url?: string;
mcp_server_name: string;
npm_spec?: string;
package_url?: string;
platform: "macos_linux";
registry_command?: string;
skill_name: string;
};
setup_code: string;
setup_code_record: TCodexAgentSetupCode;
};
export type TCodexAgentTokenListResponse = {
ok: boolean;
tokens: TCodexAgentToken[];
@ -222,6 +255,18 @@ export class WorkspaceCodexAgentService extends APIService {
});
}
async createSetupCode(
workspaceSlug: string,
agentId: string,
data: { expires_in_seconds?: number; token_name?: string }
): Promise<TCodexAgentCreateSetupCodeResponse> {
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/setup-codes/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async listTokens(workspaceSlug: string, agentId: string): Promise<TCodexAgentTokenListResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
.then((response) => response?.data)

View File

@ -27,7 +27,7 @@ const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
[...attachmentIds].sort((firstId, secondId) => {
attachmentIds.toSorted((firstId, secondId) => {
const firstAttachment = attachmentMap[firstId];
const secondAttachment = attachmentMap[secondId];
if (!firstAttachment || !secondAttachment) return 0;
@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
const issueAttachmentService = await this.getIssueAttachmentService();
const attachment = this.attachmentMap[attachmentId];
const response = await issueAttachmentService.deleteIssueAttachment(
workspaceSlug,
projectId,
issueId,
attachmentId
attachmentId,
attachment
);
runInAction(() => {

View File

@ -0,0 +1,524 @@
import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerConversion = {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean;
message?: string;
metadataSrc?: string;
size?: number;
sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
updatedAt?: string;
};
export type TBeamModelVersionRecord = {
assetId?: string;
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
projectId?: string;
sha256?: string;
size: number;
sourceSrc?: string;
src: string;
status?: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version: number;
versionId?: string;
viewerUrl?: string | null;
};
export type TBeamViewerAttachment = {
assetId?: string;
backend: "beam-viewer-local" | "beam-viewer";
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
projectId?: string;
sha256?: string;
src: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version?: number;
versionId?: string;
versions?: TBeamModelVersionRecord[];
viewerUrl?: string | null;
};
export type TBeamConversionStatus = {
assetId?: string;
artifactSrc?: string;
artifactType?: string;
artifactUrl?: string;
componentCount?: number;
componentTreeRequired?: boolean;
error?: string;
message?: string;
metadataSrc?: string;
metadataUrl?: string;
sha256?: string;
sourceFormat?: string;
sourceSrc?: string;
size?: number;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat?: "glb" | "xkt";
updatedAt?: string;
version?: number;
versionId?: string;
};
export type TBeamVersionHistoryResponse = {
assetId?: string;
currentVersion?: number;
currentVersionId?: string;
ok: boolean;
projectId?: string;
updatedAt?: string;
versions: TBeamModelVersionRecord[];
};
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
export type TBeamViewerOpenEventDetail = {
downloadUrl?: string;
fileExtension?: string;
fileName: string;
fileSize: number;
issueId: string;
viewerUrl: string;
};
export const dispatchBeamViewerOpenEvent = (detail: TBeamViewerOpenEventDetail) => {
window.dispatchEvent(new CustomEvent<TBeamViewerOpenEventDetail>(BEAM_VIEWER_OPEN_EVENT, { detail }));
};
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
bim: "bim",
glb: "gltf",
gltf: "gltf",
las: "las",
laz: "las",
obj: "obj",
stl: "stl",
xkt: "xkt",
};
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
step: "step",
stp: "step",
};
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
glb: "model/gltf-binary",
gltf: "model/gltf+json",
};
const normalizeBaseUrl = (url: string | undefined): string =>
(url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, "");
const getFileExtension = (name: string): string => {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
};
const getUploadGroupId = (parts: Array<string | undefined>): string => {
const value = parts
.filter(Boolean)
.join("_")
.replace(/[^a-zA-Z0-9_-]/g, "_");
return value || "tasker";
};
const toBeamRelativeUploadSrc = (src: string): string => {
try {
const url = new URL(src);
return url.pathname.replace(/^\/+/, "");
} catch (_error) {
return src.replace(/^\/+/, "");
}
};
const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => {
if (!src) return undefined;
let url: URL;
try {
url = new URL(src);
} catch (_error) {
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`);
}
if (cacheKey) url.searchParams.set("v", cacheKey);
return url.toString();
};
export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL);
export const getBeamApiBaseUrl = (): string =>
normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL);
export const getBeamModelTypeFromName = (name: string | undefined): string | null => {
if (!name) return null;
const extension = getFileExtension(name);
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
};
export const getBeamDirectModelTypeFromName = (name: string | undefined): string | null => {
if (!name) return null;
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getFileExtension(name)] ?? null;
};
export const getBeamModelMimeType = (file: File): string =>
MIME_TYPE_BY_EXTENSION[getFileExtension(file.name)] || "application/octet-stream";
export const isBeamModelFile = (name: string | undefined): boolean => !!getBeamModelTypeFromName(name);
export const isBeamDirectViewerFile = (name: string | undefined): boolean => !!getBeamDirectModelTypeFromName(name);
export const buildBeamViewerUrl = (params: {
name?: string;
settingsSrc?: string;
src: string;
type?: string | null;
}): string => {
const viewerUrl = new URL(getBeamViewerBaseUrl());
viewerUrl.searchParams.set("url", params.src);
if (params.type) viewerUrl.searchParams.set("type", params.type);
if (params.type === "gltf" || params.type === "glb") viewerUrl.searchParams.set("edges", "false");
if (params.name) viewerUrl.searchParams.set("name", params.name);
if (params.settingsSrc) viewerUrl.searchParams.set("settingsSrc", params.settingsSrc);
return viewerUrl.toString();
};
export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined): TBeamViewerAttachment | null => {
const beamViewer = (attachment?.attributes as { beamViewer?: TBeamViewerAttachment } | undefined)?.beamViewer;
if (!beamViewer || typeof beamViewer !== "object") return null;
return beamViewer;
};
const getAttachmentVersionNumber = (attachment: TIssueAttachment | undefined): number => {
const rawVersion = (attachment?.attributes as { version?: number | string } | undefined)?.version;
if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) return rawVersion;
if (typeof rawVersion === "string") {
const parsed = Number.parseInt(rawVersion.replace(/^v/i, ""), 10);
if (Number.isFinite(parsed)) return parsed;
}
return 1;
};
export const getBeamViewerVersionNumber = (
beamViewer: TBeamViewerAttachment | null | undefined,
attachment?: TIssueAttachment
): number => {
if (typeof beamViewer?.version === "number" && Number.isFinite(beamViewer.version)) return beamViewer.version;
return getAttachmentVersionNumber(attachment);
};
export const createBeamModelVersionRecord = (
beamViewer: TBeamViewerAttachment,
options: {
attachment?: TIssueAttachment;
size?: number;
uploadedBy?: string;
} = {}
): TBeamModelVersionRecord => ({
assetId: beamViewer.assetId,
conversion: beamViewer.conversion,
downloadUrl: beamViewer.downloadUrl,
originalFilename: beamViewer.originalFilename,
previewAvailable: beamViewer.previewAvailable,
projectId: beamViewer.projectId,
sha256: beamViewer.sha256,
size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0),
sourceSrc: beamViewer.conversion?.sourceSrc || beamViewer.src,
src: beamViewer.src,
status: beamViewer.conversion?.status || "ready",
type: beamViewer.type,
uploadedBy: beamViewer.uploadedBy || options.uploadedBy || options.attachment?.created_by,
uploadedAt: beamViewer.uploadedAt,
version: getBeamViewerVersionNumber(beamViewer, options.attachment),
versionId: beamViewer.versionId,
viewerUrl: beamViewer.viewerUrl,
});
const isSameBeamVersionRecord = (first: TBeamModelVersionRecord, second: TBeamModelVersionRecord): boolean => {
if (first.versionId && second.versionId) return first.versionId === second.versionId;
return first.version === second.version;
};
export const getBeamModelVersionRecords = (
beamViewer: TBeamViewerAttachment | null | undefined,
attachment?: TIssueAttachment
): TBeamModelVersionRecord[] => {
if (!beamViewer) return [];
const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
const storedVersions = Array.isArray(beamViewer.versions) ? beamViewer.versions : [];
const versions = storedVersions.some((version) => isSameBeamVersionRecord(version, currentRecord))
? storedVersions
: [...storedVersions, currentRecord];
return versions
.filter((version) => typeof version?.version === "number" && Number.isFinite(version.version))
.sort((first, second) => first.version - second.version);
};
export const mergeBeamModelVersionRecords = (
previousBeamViewer: TBeamViewerAttachment,
nextBeamViewer: TBeamViewerAttachment,
attachment?: TIssueAttachment
): TBeamModelVersionRecord[] => {
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
const nextRecord =
nextBeamViewer.versions?.find((version) =>
nextBeamViewer.versionId
? version.versionId === nextBeamViewer.versionId
: version.version === nextBeamViewer.version
) ||
nextBeamViewer.versions?.[0] ||
createBeamModelVersionRecord(nextBeamViewer);
return [...previousVersions.filter((version) => !isSameBeamVersionRecord(version, nextRecord)), nextRecord].sort(
(first, second) => first.version - second.version
);
};
export const mergeBeamModelVersionRecordLists = (
firstVersions: TBeamModelVersionRecord[] = [],
secondVersions: TBeamModelVersionRecord[] = []
): TBeamModelVersionRecord[] => {
const byKey = new Map<string, TBeamModelVersionRecord>();
[...firstVersions, ...secondVersions].forEach((version) => {
const key = version.versionId || `version-${version.version}`;
const previous = byKey.get(key);
byKey.set(key, { ...(previous || {}), ...version });
});
return [...byKey.values()]
.filter((version) => typeof version?.version === "number" && Number.isFinite(version.version))
.sort((first, second) => first.version - second.version);
};
export const syncCurrentBeamVersionRecord = (
beamViewer: TBeamViewerAttachment,
attachment?: TIssueAttachment
): TBeamViewerAttachment => {
const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
const versions = getBeamModelVersionRecords(beamViewer, attachment).map((version) => {
if (!isSameBeamVersionRecord(version, currentRecord)) return version;
return Object.assign({}, version, currentRecord);
});
return { ...beamViewer, versions };
};
export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): string | undefined => {
if (version.viewerUrl) return version.viewerUrl;
if (!version.conversion && version.previewAvailable && version.src) {
return buildBeamViewerUrl({
name: version.originalFilename,
settingsSrc: version.sourceSrc || version.src,
src: version.src,
type: version.type,
});
}
if (version.conversion?.status !== "ready" || !version.conversion.artifactSrc) return undefined;
const artifactType = version.conversion.artifactType || version.conversion.targetFormat || "gltf";
return buildBeamViewerUrl({
name: version.originalFilename,
settingsSrc: version.conversion.sourceSrc || version.src,
src:
toBeamAbsoluteUrl(version.conversion.artifactSrc, version.conversion.updatedAt) || version.conversion.artifactSrc,
type: artifactType,
});
};
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
const response = await fetch(statusUrl.toString(), { credentials: "include" });
if (!response.ok) {
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
}
const payload = (await response.json()) as TBeamConversionStatus;
return {
...payload,
artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt),
metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc),
};
};
export const fetchBeamModelVersions = async (
beamViewer: TBeamViewerAttachment
): Promise<TBeamVersionHistoryResponse> => {
const versionsUrl = new URL("/api/uploads/versions", `${getBeamApiBaseUrl()}/`);
if (beamViewer.projectId && beamViewer.assetId) {
versionsUrl.searchParams.set("projectId", beamViewer.projectId);
versionsUrl.searchParams.set("assetId", beamViewer.assetId);
} else {
versionsUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
}
const response = await fetch(versionsUrl.toString(), { credentials: "include" });
if (!response.ok) {
throw new Error(`BIM version history failed: HTTP ${response.status}`);
}
return (await response.json()) as TBeamVersionHistoryResponse;
};
export const uploadBeamModelFile = (
file: File,
options: {
assetId?: string;
issueId?: string;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
projectId?: string;
uploadedBy?: string;
version?: number;
workspaceSlug?: string;
} = {}
): Promise<TBeamViewerAttachment> =>
new Promise((resolve, reject) => {
const type = getBeamModelTypeFromName(file.name);
if (!type) {
reject(new Error("Формат модели не поддерживается BIM Viewer"));
return;
}
const directViewerType = getBeamDirectModelTypeFromName(file.name);
const apiBaseUrl = getBeamApiBaseUrl();
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
uploadUrl.searchParams.set("filename", file.name);
uploadUrl.searchParams.set(
"projectId",
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
);
if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
if (options.version) uploadUrl.searchParams.set("version", String(options.version));
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString());
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable || !options.onUploadProgress) return;
options.onUploadProgress({
loaded: event.loaded,
progress: event.loaded / event.total,
total: event.total,
} as AxiosProgressEvent);
});
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
return;
}
try {
const response = JSON.parse(xhr.responseText) as {
assetId?: string;
conversion?: TBeamViewerAttachment["conversion"];
originalFilename?: string;
projectId?: string;
sha256?: string;
src?: string;
uploadedAt?: string;
version?: number;
versionId?: string;
};
if (!response.src) {
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
return;
}
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
const baseAttachment: TBeamViewerAttachment = {
assetId: response.assetId ?? options.assetId,
backend: "beam-viewer-local",
downloadUrl: downloadUrl.toString(),
originalFilename: response.originalFilename || file.name,
previewAvailable: !!directViewerType,
projectId: response.projectId,
sha256: response.sha256,
src: downloadUrl.toString(),
type,
uploadedBy: options.uploadedBy,
uploadedAt: response.uploadedAt || new Date().toISOString(),
version: response.version ?? options.version ?? 1,
versionId: response.versionId,
};
if (!directViewerType) {
const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
conversion: {
...response.conversion,
componentTreeRequired: true,
message:
response.conversion?.message ??
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
sourceFormat: type,
status: response.conversion?.status ?? "conversion_required",
targetFormat: response.conversion?.targetFormat ?? "xkt",
},
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
return;
}
const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
viewerUrl: buildBeamViewerUrl({
name: file.name,
settingsSrc: downloadUrl.toString(),
src: downloadUrl.toString(),
type: directViewerType,
}),
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
} catch (_error) {
reject(new Error("BIM Viewer вернул некорректный ответ"));
}
});
xhr.send(file);
});
const deleteBeamStorageResource = async (
endpoint: "/api/uploads/asset" | "/api/uploads/version",
payload: unknown
): Promise<void> => {
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
method: "DELETE",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
}
};
export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise<void> =>
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
deleteBeamStorageResource("/api/uploads/version", version);

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,67 @@
import type { TFileSignedURLResponse } from "../file";
export type TBeamViewerConversion = {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean;
message?: string;
metadataSrc?: string;
size?: number;
sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
updatedAt?: string;
};
export type TBeamModelVersionRecord = {
assetId?: string;
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
projectId?: string;
sha256?: string;
size: number;
sourceSrc?: string;
src: string;
status?: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version: number;
versionId?: string;
viewerUrl?: string | null;
};
export type TBeamViewerAttachment = {
assetId?: string;
backend: "beam-viewer-local" | "beam-viewer";
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
projectId?: string;
sha256?: string;
src: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version?: number;
versionId?: string;
versions?: TBeamModelVersionRecord[];
viewerUrl?: string | null;
};
export type TIssueAttachment = {
id: string;
attributes: {
beamViewer?: TBeamViewerAttachment;
name: string;
size: number;
type?: string;
[key: string]: unknown;
};
asset_url: string;
issue_id: string;

View File

@ -29,6 +29,8 @@ PLANE_ADMIN_IMAGE=${PLANE_ADMIN_IMAGE:-nodedc/plane-admin:ru}
VITE_NODEDC_LAUNCHER_URL=${VITE_NODEDC_LAUNCHER_URL:-https://hub.nodedc.ru}
VITE_NODEDC_OIDC_LOGIN_ENABLED=${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}
VITE_BEAM_VIEWER_BASE_URL=${VITE_BEAM_VIEWER_BASE_URL:-https://bim.nodedc.tech}
VITE_BEAM_API_BASE_URL=${VITE_BEAM_API_BASE_URL:-$VITE_BEAM_VIEWER_BASE_URL}
RUN_SMOKE=${RUN_SMOKE:-1}
SMOKE_BASE_URL=${SMOKE_BASE_URL:-https://ops.nodedc.ru}
@ -104,6 +106,8 @@ if [ "$BUILD_WEB" = "1" ]; then
$DOCKER_BUILD_NETWORK_ARGS \
--build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
--build-arg "VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED" \
--build-arg "VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL" \
--build-arg "VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL" \
-t "$PLANE_FRONTEND_IMAGE" \
-f apps/web/Dockerfile.web.nas-legacy .
RECREATE_SERVICES="$RECREATE_SERVICES web"