Compare commits
24 Commits
6e74964853
...
43e5f570e5
| Author | SHA1 | Date |
|---|---|---|
|
|
43e5f570e5 | |
|
|
763b90f3ba | |
|
|
df4767a085 | |
|
|
4ed460ae45 | |
|
|
477626c597 | |
|
|
3806848b07 | |
|
|
8b1e356494 | |
|
|
fcd014e26d | |
|
|
b1d19cf5df | |
|
|
342df60c24 | |
|
|
097012ae31 | |
|
|
71979526cf | |
|
|
6c119aae04 | |
|
|
ee4a959a53 | |
|
|
14caa50088 | |
|
|
2ad9e7f85c | |
|
|
e4fd2a5eff | |
|
|
81abcb5b36 | |
|
|
ff30eeb8fd | |
|
|
867f7bc558 | |
|
|
15c9fb1df1 | |
|
|
31dca29560 | |
|
|
677916389f | |
|
|
3e41c451c1 |
|
|
@ -110,7 +110,11 @@ pnpm turbo run check:types --filter=web --filter='@plane/i18n' --filter='@plane/
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
|
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
|
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
|
||||||
./setup.sh stop
|
./setup.sh stop
|
||||||
./setup.sh start
|
./setup.sh start
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,11 @@ The same codebase supports local and production-like runs. The deployment mode i
|
||||||
Frontend brand and launcher integration:
|
Frontend brand and launcher integration:
|
||||||
|
|
||||||
- `VITE_NODEDC_LAUNCHER_URL` points the web/admin bundles to Launcher.
|
- `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 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 default in source helpers: `http://launcher.local.nodedc`
|
||||||
|
- Local BIM default in source helpers: `http://localhost:8080`
|
||||||
|
|
||||||
Backend integration:
|
Backend integration:
|
||||||
|
|
||||||
|
|
@ -32,6 +35,19 @@ Backend integration:
|
||||||
- `PLANE_NODEDC_WORKSPACE_POLICY_URL`
|
- `PLANE_NODEDC_WORKSPACE_POLICY_URL`
|
||||||
- `PLANE_NODEDC_ACCESS_CHECK_URL`
|
- `PLANE_NODEDC_ACCESS_CHECK_URL`
|
||||||
- `PLANE_NODEDC_ACCESS_TOKEN`
|
- `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.
|
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
|
```sh
|
||||||
VITE_NODEDC_LAUNCHER_URL=http://launcher.local.nodedc \
|
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_BASE_URL=http://task.local.nodedc:18080 \
|
||||||
SMOKE_RESOLVE= \
|
SMOKE_RESOLVE= \
|
||||||
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 \
|
BUILD_BACKEND=0 BUILD_WEB=1 BUILD_ADMIN=0 \
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ x-app-env: &app-env
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
|
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
|
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_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
|
GUNICORN_WORKERS: 1
|
||||||
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
||||||
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
||||||
|
|
@ -159,6 +162,9 @@ services:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
- plane-mq
|
- plane-mq
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- nodedc-engine
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: nodedc/plane-backend:local
|
image: nodedc/plane-backend:local
|
||||||
|
|
@ -179,6 +185,9 @@ services:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
- plane-mq
|
- plane-mq
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- nodedc-engine
|
||||||
|
|
||||||
beat-worker:
|
beat-worker:
|
||||||
image: nodedc/plane-backend:local
|
image: nodedc/plane-backend:local
|
||||||
|
|
@ -199,6 +208,9 @@ services:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
- plane-mq
|
- plane-mq
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- nodedc-engine
|
||||||
|
|
||||||
migrator:
|
migrator:
|
||||||
image: nodedc/plane-backend:local
|
image: nodedc/plane-backend:local
|
||||||
|
|
@ -217,6 +229,9 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- plane-db
|
- plane-db
|
||||||
- plane-redis
|
- plane-redis
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- nodedc-engine
|
||||||
|
|
||||||
# Comment this if you already have a database running
|
# Comment this if you already have a database running
|
||||||
plane-db:
|
plane-db:
|
||||||
|
|
@ -304,3 +319,9 @@ volumes:
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
proxy_config:
|
proxy_config:
|
||||||
proxy_data:
|
proxy_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
nodedc-engine:
|
||||||
|
external: true
|
||||||
|
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
|
||||||
|
|
|
||||||
|
|
@ -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_URL=http://host.docker.internal:4100
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
|
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
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
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,9 @@ LIVE_SERVER_SECRET_KEY=CHANGE_ME_BEFORE_PRODUCTION
|
||||||
DOCKERHUB_USER=makeplane
|
DOCKERHUB_USER=makeplane
|
||||||
PULL_POLICY=if_not_present
|
PULL_POLICY=if_not_present
|
||||||
CUSTOM_BUILD=false
|
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
|
||||||
|
|
|
||||||
|
|
@ -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_URL=https://codex-api.staging.nodedc.example
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
|
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
|
||||||
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
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
|
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \
|
||||||
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
|
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
|
||||||
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
|
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
|
||||||
VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \
|
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_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \
|
||||||
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
|
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
|
||||||
pnpm --filter web build
|
pnpm --filter web build
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM python:3.12.10-alpine
|
FROM python:3.12.10-alpine
|
||||||
|
LABEL nodedc.deploy.cache_bust="codex-agent-setup-20260624"
|
||||||
|
|
||||||
# set environment variables
|
# set environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
@ -44,6 +45,7 @@ COPY manage.py manage.py
|
||||||
COPY plane plane/
|
COPY plane plane/
|
||||||
COPY templates templates/
|
COPY templates templates/
|
||||||
COPY package.json package.json
|
COPY package.json package.json
|
||||||
|
RUN grep -R "setup-codes" /code/plane/app/urls/codex_agents.py >/dev/null
|
||||||
|
|
||||||
COPY ./bin ./bin/
|
COPY ./bin ./bin/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
from .analytic import urlpatterns as analytic_urls
|
from .analytic import urlpatterns as analytic_urls
|
||||||
|
from .ai_workspace import urlpatterns as ai_workspace_urls
|
||||||
from .api import urlpatterns as api_urls
|
from .api import urlpatterns as api_urls
|
||||||
from .asset import urlpatterns as asset_urls
|
from .asset import urlpatterns as asset_urls
|
||||||
from .codex_agents import urlpatterns as codex_agent_urls
|
from .codex_agents import urlpatterns as codex_agent_urls
|
||||||
|
|
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
*analytic_urls,
|
*analytic_urls,
|
||||||
|
*ai_workspace_urls,
|
||||||
*asset_urls,
|
*asset_urls,
|
||||||
*codex_agent_urls,
|
*codex_agent_urls,
|
||||||
*cycle_urls,
|
*cycle_urls,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -12,6 +12,7 @@ from plane.app.views import (
|
||||||
CodexAgentListEndpoint,
|
CodexAgentListEndpoint,
|
||||||
CodexAgentProjectAccessEndpoint,
|
CodexAgentProjectAccessEndpoint,
|
||||||
CodexAgentRevokeEndpoint,
|
CodexAgentRevokeEndpoint,
|
||||||
|
CodexAgentSetupCodeEndpoint,
|
||||||
CodexAgentSetupEndpoint,
|
CodexAgentSetupEndpoint,
|
||||||
CodexAgentTokenListEndpoint,
|
CodexAgentTokenListEndpoint,
|
||||||
CodexAgentTokenRevokeEndpoint,
|
CodexAgentTokenRevokeEndpoint,
|
||||||
|
|
@ -69,4 +70,9 @@ urlpatterns = [
|
||||||
CodexAgentSetupEndpoint.as_view(),
|
CodexAgentSetupEndpoint.as_view(),
|
||||||
name="codex-agent-api-agent-setup",
|
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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,21 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
|
from .ai_workspace import (
|
||||||
|
AIWorkspaceExecutorCheckEndpoint,
|
||||||
|
AIWorkspaceExecutorDetailEndpoint,
|
||||||
|
AIWorkspaceExecutorEventsEndpoint,
|
||||||
|
AIWorkspaceExecutorListEndpoint,
|
||||||
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
|
AIWorkspaceExecutorSetupCommandEndpoint,
|
||||||
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
|
AIWorkspaceSettingsEndpoint,
|
||||||
|
AIWorkspaceThreadDispatchEndpoint,
|
||||||
|
AIWorkspaceThreadDetailEndpoint,
|
||||||
|
AIWorkspaceThreadListEndpoint,
|
||||||
|
AIWorkspaceThreadMessagesEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .codex_agents import (
|
from .codex_agents import (
|
||||||
CodexAgentDetailEndpoint,
|
CodexAgentDetailEndpoint,
|
||||||
CodexAgentGrantListEndpoint,
|
CodexAgentGrantListEndpoint,
|
||||||
|
|
@ -182,6 +197,7 @@ from .codex_agents import (
|
||||||
CodexAgentListEndpoint,
|
CodexAgentListEndpoint,
|
||||||
CodexAgentProjectAccessEndpoint,
|
CodexAgentProjectAccessEndpoint,
|
||||||
CodexAgentRevokeEndpoint,
|
CodexAgentRevokeEndpoint,
|
||||||
|
CodexAgentSetupCodeEndpoint,
|
||||||
CodexAgentSetupEndpoint,
|
CodexAgentSetupEndpoint,
|
||||||
CodexAgentTokenListEndpoint,
|
CodexAgentTokenListEndpoint,
|
||||||
CodexAgentTokenRevokeEndpoint,
|
CodexAgentTokenRevokeEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -563,3 +563,21 @@ class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint):
|
||||||
return entitlement_error
|
return entitlement_error
|
||||||
|
|
||||||
return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup")
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
name = request.data.get("name")
|
name = request.data.get("name")
|
||||||
type = request.data.get("type", False)
|
type = request.data.get("type", False)
|
||||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
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:
|
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
project = Project.objects.get(id=project_id, workspace=workspace)
|
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
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
@ -212,6 +257,28 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||||
def patch(self, request, slug, project_id, issue_id, pk):
|
def patch(self, request, slug, project_id, issue_id, pk):
|
||||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
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)
|
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||||
if not attachment_object_exists(issue_attachment):
|
if not attachment_object_exists(issue_attachment):
|
||||||
return Response(
|
return Response(
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
||||||
ARG VITE_WEB_BASE_URL=""
|
ARG VITE_WEB_BASE_URL=""
|
||||||
ENV VITE_WEB_BASE_URL=$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=""
|
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
|
||||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM node:22-alpine AS base
|
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
|
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||||
ENV PNPM_HOME="/pnpm"
|
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 full directory structure before fetch to ensure all package.json files are available
|
||||||
COPY --from=builder /app/out/full/ .
|
COPY --from=builder /app/out/full/ .
|
||||||
COPY turbo.json turbo.json
|
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
|
# Fetch dependencies to cache store, then install offline with dev deps
|
||||||
RUN pnpm fetch --store-dir=/pnpm/store
|
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=""
|
ARG VITE_WEB_BASE_URL=""
|
||||||
ENV VITE_WEB_BASE_URL=$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=""
|
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
|
||||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$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 apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
|
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
|
RUN chmod -R a+rX /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { observer } from "mobx-react";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
import { AppRailRoot } from "@/components/navigation";
|
import { AppRailRoot } from "@/components/navigation";
|
||||||
import { useAppRailVisibility } from "@/lib/app-rail";
|
import { useAppRailVisibility } from "@/lib/app-rail";
|
||||||
|
import { AIWorkspaceGlobalControl } from "@/components/ai-workspace/global-control";
|
||||||
import { VoiceTaskerGlobalControl } from "@/components/voice-tasker/global-control";
|
import { VoiceTaskerGlobalControl } from "@/components/voice-tasker/global-control";
|
||||||
// local imports
|
// local imports
|
||||||
import { TopNavigationRoot } from "../navigations";
|
import { TopNavigationRoot } from "../navigations";
|
||||||
|
|
@ -40,6 +41,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
{workspaceSlug && <AIWorkspaceGlobalControl workspaceSlug={workspaceSlug} />}
|
||||||
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
|
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -213,10 +213,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) {
|
||||||
createPortal(
|
createPortal(
|
||||||
<Combobox.Options data-prevent-outside-click static>
|
<Combobox.Options data-prevent-outside-click static>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("nodedc-dropdown-surface z-[760] my-1 overflow-hidden", optionsClassName)}
|
||||||
"nodedc-dropdown-surface z-30 my-1 overflow-hidden",
|
|
||||||
optionsClassName
|
|
||||||
)}
|
|
||||||
ref={setPopperElement}
|
ref={setPopperElement}
|
||||||
style={styles.popper}
|
style={styles.popper}
|
||||||
{...attributes.popper}
|
{...attributes.popper}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ import { useCallback, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import type { FileRejection } from "react-dropzone";
|
import type { FileRejection } from "react-dropzone";
|
||||||
import { useDropzone } 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 { useTranslation } from "@plane/i18n";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import type { TIssueServiceType } from "@plane/types";
|
import type { TIssueServiceType } from "@plane/types";
|
||||||
import { EIssueServiceType } from "@plane/types";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
|
import { convertBytesToSize, getFileExtension } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
|
|
@ -25,6 +26,8 @@ import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
|
||||||
// types
|
// types
|
||||||
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
|
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
|
||||||
|
|
||||||
|
type TAttachmentViewMode = "grid" | "list";
|
||||||
|
|
||||||
type TIssueAttachmentItemList = {
|
type TIssueAttachmentItemList = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -46,6 +49,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// states
|
// states
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
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
|
// store hooks
|
||||||
const {
|
const {
|
||||||
attachment: { getAttachmentsByIssueId },
|
attachment: { getAttachmentsByIssueId },
|
||||||
|
|
@ -67,6 +74,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
activeUploads.length > 0
|
activeUploads.length > 0
|
||||||
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
|
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
const isListView = attachmentViewMode === "list";
|
||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
const handleFetchPropertyActivities = useCallback(() => {
|
const handleFetchPropertyActivities = useCallback(() => {
|
||||||
|
|
@ -122,6 +130,14 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
disabled: isUploading || disabled,
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{attachmentDeleteModalId && (
|
{attachmentDeleteModalId && (
|
||||||
|
|
@ -173,26 +189,90 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasAttachmentRows && (
|
{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="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="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
|
||||||
<div className="text-12 text-tertiary">
|
<div className="flex items-center gap-2">
|
||||||
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
{isListView ? (
|
||||||
{uploadStatus?.map((status) => (
|
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
||||||
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
|
<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">
|
||||||
{issueAttachments.map((attachmentId) => (
|
<div>Файл</div>
|
||||||
<IssueAttachmentsListItem
|
<div>Дата</div>
|
||||||
key={attachmentId}
|
<div>Версия</div>
|
||||||
attachmentId={attachmentId}
|
<div>Добавил</div>
|
||||||
disabled={disabled}
|
<div className="text-right">Действия</div>
|
||||||
issueServiceType={issueServiceType}
|
</div>
|
||||||
/>
|
{uploadStatus?.map((status) => (
|
||||||
))}
|
<div
|
||||||
</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} />
|
||||||
|
))}
|
||||||
|
{issueAttachments.map((attachmentId) => (
|
||||||
|
<IssueAttachmentsListItem
|
||||||
|
key={attachmentId}
|
||||||
|
attachmentId={attachmentId}
|
||||||
|
disabled={disabled}
|
||||||
|
issueId={issueId}
|
||||||
|
issueServiceType={issueServiceType}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,16 +6,14 @@
|
||||||
|
|
||||||
import { useRef, type ReactNode } from "react";
|
import { useRef, type ReactNode } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import { Maximize2, Minimize2, MoveRight, X } from "lucide-react";
|
||||||
import { MoveDiagonal, MoveRight } from "lucide-react";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { useTranslation } from "@plane/i18n";
|
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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
import type { TNameDescriptionLoader } from "@plane/types";
|
import type { TNameDescriptionLoader } from "@plane/types";
|
||||||
import { EIssuesStoreType } from "@plane/types";
|
import { EIssuesStoreType } from "@plane/types";
|
||||||
import { SelectionDropdown } from "@/components/common/selection-dropdown";
|
|
||||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
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";
|
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 = {
|
export type PeekOverviewHeaderProps = {
|
||||||
peekMode: TPeekModes;
|
peekMode: TPeekModes;
|
||||||
setPeekMode: (value: TPeekModes) => void;
|
setPeekMode: (value: TPeekModes) => void;
|
||||||
|
|
@ -68,7 +48,6 @@ export type PeekOverviewHeaderProps = {
|
||||||
actionSlot?: ReactNode;
|
actionSlot?: ReactNode;
|
||||||
metaSlot?: ReactNode;
|
metaSlot?: ReactNode;
|
||||||
showCopyLink?: boolean;
|
showCopyLink?: boolean;
|
||||||
showLayoutSwitcher?: boolean;
|
|
||||||
showQuickActions?: boolean;
|
showQuickActions?: boolean;
|
||||||
showSubscription?: boolean;
|
showSubscription?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -82,7 +61,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
issueId,
|
issueId,
|
||||||
isArchived,
|
isArchived,
|
||||||
disabled,
|
disabled,
|
||||||
embedIssue = false,
|
|
||||||
removeRoutePeekId,
|
removeRoutePeekId,
|
||||||
toggleDeleteIssueModal,
|
toggleDeleteIssueModal,
|
||||||
toggleArchiveIssueModal,
|
toggleArchiveIssueModal,
|
||||||
|
|
@ -93,7 +71,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
actionSlot,
|
actionSlot,
|
||||||
metaSlot,
|
metaSlot,
|
||||||
showCopyLink = true,
|
showCopyLink = true,
|
||||||
showLayoutSwitcher = !embedIssue,
|
|
||||||
showQuickActions = true,
|
showQuickActions = true,
|
||||||
showSubscription = true,
|
showSubscription = true,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
@ -113,7 +90,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
const { getProjectIdentifierById } = useProject();
|
const { getProjectIdentifierById } = useProject();
|
||||||
// derived values
|
// derived values
|
||||||
const issueDetails = getIssueById(issueId);
|
const issueDetails = getIssueById(issueId);
|
||||||
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
|
|
||||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||||
const {
|
const {
|
||||||
issues: { removeIssue: removeArchivedIssue },
|
issues: { removeIssue: removeArchivedIssue },
|
||||||
|
|
@ -168,50 +144,29 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className={`relative flex flex-wrap items-start justify-between gap-3 px-6 py-5 ${
|
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">
|
<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}>
|
<Tooltip tooltipContent={t("common.close_peek_view")} isMobile={isMobile}>
|
||||||
<button onClick={removeRoutePeekId}>
|
<button onClick={removeRoutePeekId}>
|
||||||
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
|
{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>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
|
<Tooltip tooltipContent={t("issue.open_in_full_screen")} isMobile={isMobile}>
|
||||||
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
|
<button type="button" onClick={() => setPeekMode(peekMode === "full-screen" ? "side-peek" : "full-screen")}>
|
||||||
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
|
{peekMode === "full-screen" ? (
|
||||||
</Link>
|
<Minimize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</Tooltip>
|
</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>
|
|
||||||
)}
|
|
||||||
{metaSlot}
|
{metaSlot}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
<div className="ml-auto flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,19 @@ import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Download, Maximize2, Minimize2, X } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
import type { EditorRefApi } from "@plane/editor";
|
||||||
import type { TNameDescriptionLoader } from "@plane/types";
|
import type { TNameDescriptionLoader } from "@plane/types";
|
||||||
import { EIssueServiceType } 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
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import useKeypress from "@/hooks/use-keypress";
|
import useKeypress from "@/hooks/use-keypress";
|
||||||
|
|
@ -35,6 +38,7 @@ import { IssuePeekOverviewLoader } from "./loader";
|
||||||
import { PeekOverviewProperties } from "./properties";
|
import { PeekOverviewProperties } from "./properties";
|
||||||
|
|
||||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
|
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
|
||||||
|
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
|
||||||
|
|
||||||
interface IIssueView {
|
interface IIssueView {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -89,6 +93,9 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
|
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
|
||||||
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
|
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
|
||||||
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = 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>(() => {
|
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||||
if (typeof window === "undefined") return 720;
|
if (typeof window === "undefined") return 720;
|
||||||
|
|
||||||
|
|
@ -101,9 +108,12 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
const [isResizingPeek, setIsResizingPeek] = useState(false);
|
const [isResizingPeek, setIsResizingPeek] = useState(false);
|
||||||
// ref
|
// ref
|
||||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const beamPeekViewerRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<EditorRefApi>(null);
|
const editorRef = useRef<EditorRefApi>(null);
|
||||||
const initialPeekWidthRef = useRef<number>(0);
|
const initialPeekWidthRef = useRef<number>(0);
|
||||||
const initialMouseXRef = useRef<number>(0);
|
const initialMouseXRef = useRef<number>(0);
|
||||||
|
const livePeekWidthRef = useRef<number>(sidePeekWidth);
|
||||||
|
const beamCloseTimeoutRef = useRef<number | null>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
setPeekIssue,
|
setPeekIssue,
|
||||||
|
|
@ -114,7 +124,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
const issue = getIssueById(issueId);
|
const issue = getIssueById(issueId);
|
||||||
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
|
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
|
||||||
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
||||||
const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
|
||||||
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
||||||
// remove peek id
|
// remove peek id
|
||||||
const removeRoutePeekId = () => {
|
const removeRoutePeekId = () => {
|
||||||
|
|
@ -129,6 +138,100 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
|
|
||||||
const isAnyLocalModalOpen =
|
const isAnyLocalModalOpen =
|
||||||
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
|
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(() => {
|
const stopPeekResizing = useCallback(() => {
|
||||||
setIsResizingPeek(false);
|
setIsResizingPeek(false);
|
||||||
|
|
@ -138,21 +241,25 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
if (!isResizingPeek) return;
|
if (!isResizingPeek) return;
|
||||||
|
|
||||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
const maxWidth = getMaxSidePeekWidth();
|
||||||
const minWidth = 640;
|
const minWidth = 640;
|
||||||
const deltaX = event.clientX - initialMouseXRef.current;
|
const deltaX = event.clientX - initialMouseXRef.current;
|
||||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
||||||
|
|
||||||
|
livePeekWidthRef.current = nextWidth;
|
||||||
setSidePeekWidth(nextWidth);
|
setSidePeekWidth(nextWidth);
|
||||||
},
|
},
|
||||||
[isResizingPeek]
|
[getMaxSidePeekWidth, isResizingPeek]
|
||||||
);
|
);
|
||||||
|
|
||||||
const startPeekResizing = useCallback(
|
const startPeekResizing = useCallback(
|
||||||
(event: ReactMouseEvent) => {
|
(event: ReactMouseEvent) => {
|
||||||
if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
|
if (!shouldAllowPeekResize || peekMode !== "side-peek") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
setIsResizingPeek(true);
|
setIsResizingPeek(true);
|
||||||
initialPeekWidthRef.current = sidePeekWidth;
|
initialPeekWidthRef.current = sidePeekWidth;
|
||||||
|
livePeekWidthRef.current = sidePeekWidth;
|
||||||
initialMouseXRef.current = event.clientX;
|
initialMouseXRef.current = event.clientX;
|
||||||
},
|
},
|
||||||
[peekMode, shouldAllowPeekResize, sidePeekWidth]
|
[peekMode, shouldAllowPeekResize, sidePeekWidth]
|
||||||
|
|
@ -160,18 +267,18 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWindowResize = () => {
|
const handleWindowResize = () => {
|
||||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
const maxWidth = getMaxSidePeekWidth();
|
||||||
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleWindowResize);
|
window.addEventListener("resize", handleWindowResize);
|
||||||
return () => window.removeEventListener("resize", handleWindowResize);
|
return () => window.removeEventListener("resize", handleWindowResize);
|
||||||
}, []);
|
}, [getMaxSidePeekWidth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
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);
|
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
|
||||||
|
|
||||||
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
|
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) {
|
if (clampedWidth !== sidePeekWidth) {
|
||||||
setSidePeekWidth(clampedWidth);
|
setSidePeekWidth(clampedWidth);
|
||||||
}
|
}
|
||||||
|
}, [getMaxSidePeekWidth, sidePeekWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
livePeekWidthRef.current = sidePeekWidth;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isResizingPeek) return;
|
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 editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
|
||||||
const dropdownElement = document.activeElement?.tagName === "INPUT";
|
const dropdownElement = document.activeElement?.tagName === "INPUT";
|
||||||
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
|
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
|
||||||
|
if (beamPeekViewer) {
|
||||||
|
closeBeamPeekViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
|
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
|
||||||
removeRoutePeekId();
|
removeRoutePeekId();
|
||||||
const issueElement = document.getElementById(`issue-${issueId}`);
|
const issueElement = document.getElementById(`issue-${issueId}`);
|
||||||
|
|
@ -240,11 +379,12 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
: "h-full w-full",
|
: "h-full w-full",
|
||||||
!embedIssue && "absolute z-[80]",
|
!embedIssue && "absolute z-[80]",
|
||||||
!embedIssue && {
|
!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",
|
peekMode === "side-peek",
|
||||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
"nodedc-peek-modal-bound rounded-[28px]": peekMode === "modal",
|
||||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
"nodedc-peek-full-bound rounded-[28px]": peekMode === "full-screen",
|
||||||
},
|
},
|
||||||
|
!embedIssue && isBeamPeekClosing && "nodedc-beam-peek-closing !transition-none !duration-0",
|
||||||
shouldUseInteractiveEmbeddedLayout && {
|
shouldUseInteractiveEmbeddedLayout && {
|
||||||
"relative ml-auto h-full rounded-[28px]": peekMode === "side-peek",
|
"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]":
|
"relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]":
|
||||||
|
|
@ -257,75 +397,115 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
|
|
||||||
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
|
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
|
||||||
|
|
||||||
const content = (
|
const issuePanelStyle: CSSProperties = {
|
||||||
<div className="w-full text-body-sm-regular">
|
width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
|
||||||
{issueId && (
|
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
|
<div
|
||||||
ref={issuePeekOverviewRef}
|
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
||||||
className={peekOverviewIssueClassName}
|
onMouseDown={startPeekResizing}
|
||||||
style={{
|
role="separator"
|
||||||
width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
|
aria-label="Resize issue panel"
|
||||||
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)"
|
{isError ? (
|
||||||
: undefined,
|
<div className="relative h-screen w-full overflow-hidden">
|
||||||
}}
|
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />
|
||||||
>
|
</div>
|
||||||
{shouldAllowPeekResize && peekMode === "side-peek" && (
|
) : (
|
||||||
<div
|
isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />
|
||||||
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
)}
|
||||||
onMouseDown={startPeekResizing}
|
{!isLoading && !isError && issue && (
|
||||||
role="separator"
|
<>
|
||||||
aria-label="Resize issue panel"
|
{renderHeader ? (
|
||||||
|
renderHeader({
|
||||||
|
peekMode,
|
||||||
|
setPeekMode: (value) => setPeekMode(value),
|
||||||
|
removeRoutePeekId,
|
||||||
|
isSubmitting,
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<IssuePeekOverviewHeader
|
||||||
|
peekMode={peekMode}
|
||||||
|
setPeekMode={(value) => setPeekMode(value)}
|
||||||
|
removeRoutePeekId={removeRoutePeekId}
|
||||||
|
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
||||||
|
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
||||||
|
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
||||||
|
toggleEditIssueModal={toggleEditIssueModal}
|
||||||
|
handleRestoreIssue={handleRestore}
|
||||||
|
isArchived={is_archived}
|
||||||
|
issueId={issueId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
disabled={disabled}
|
||||||
|
embedIssue={embedIssue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isError ? (
|
{/* content */}
|
||||||
<div className="relative h-screen w-full overflow-hidden">
|
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
|
||||||
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />
|
{renderContent ? (
|
||||||
</div>
|
renderContent({
|
||||||
) : (
|
peekMode,
|
||||||
isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />
|
isSubmitting,
|
||||||
)}
|
setIsSubmitting: (value) => setIsSubmitting(value),
|
||||||
{!isLoading && !isError && issue && (
|
editorRef,
|
||||||
<>
|
})
|
||||||
{renderHeader ? (
|
) : ["side-peek", "modal"].includes(peekMode) ? (
|
||||||
renderHeader({
|
<div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
|
||||||
peekMode,
|
<PeekOverviewIssueDetails
|
||||||
setPeekMode: (value) => setPeekMode(value),
|
editorRef={editorRef}
|
||||||
removeRoutePeekId,
|
|
||||||
isSubmitting,
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<IssuePeekOverviewHeader
|
|
||||||
peekMode={peekMode}
|
|
||||||
setPeekMode={(value) => setPeekMode(value)}
|
|
||||||
removeRoutePeekId={removeRoutePeekId}
|
|
||||||
toggleDeleteIssueModal={toggleDeleteIssueModal}
|
|
||||||
toggleArchiveIssueModal={toggleArchiveIssueModal}
|
|
||||||
toggleDuplicateIssueModal={toggleDuplicateIssueModal}
|
|
||||||
toggleEditIssueModal={toggleEditIssueModal}
|
|
||||||
handleRestoreIssue={handleRestore}
|
|
||||||
isArchived={is_archived}
|
|
||||||
issueId={issueId}
|
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
isSubmitting={isSubmitting}
|
issueId={issueId}
|
||||||
|
issueOperations={issueOperations}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
embedIssue={embedIssue}
|
isArchived={is_archived}
|
||||||
showLayoutSwitcher={shouldAllowPeekModeToggle}
|
isSubmitting={isSubmitting}
|
||||||
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{/* content */}
|
<div className="py-2">
|
||||||
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
|
<IssueDetailWidgets
|
||||||
{renderContent ? (
|
workspaceSlug={workspaceSlug}
|
||||||
renderContent({
|
projectId={projectId}
|
||||||
peekMode,
|
issueId={issueId}
|
||||||
isSubmitting,
|
disabled={disabled || is_archived}
|
||||||
setIsSubmitting: (value) => setIsSubmitting(value),
|
compactView
|
||||||
editorRef,
|
issueOperations={issueOperations}
|
||||||
})
|
issueServiceType={EIssueServiceType.ISSUES}
|
||||||
) : ["side-peek", "modal"].includes(peekMode) ? (
|
/>
|
||||||
<div className="relative flex flex-col gap-4 space-y-3 px-8 py-6">
|
</div>
|
||||||
|
|
||||||
|
<PeekOverviewProperties
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
issueOperations={issueOperations}
|
||||||
|
disabled={disabled || is_archived}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IssueActivity
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
disabled={is_archived}
|
||||||
|
compactComposer
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
|
||||||
|
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
|
||||||
|
<div className="space-y-3">
|
||||||
<PeekOverviewIssueDetails
|
<PeekOverviewIssueDetails
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
|
|
@ -343,84 +523,114 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
disabled={disabled || is_archived}
|
disabled={disabled}
|
||||||
compactView
|
|
||||||
issueOperations={issueOperations}
|
issueOperations={issueOperations}
|
||||||
issueServiceType={EIssueServiceType.ISSUES}
|
issueServiceType={EIssueServiceType.ISSUES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PeekOverviewProperties
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
disabled={disabled || is_archived}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IssueActivity
|
<IssueActivity
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
disabled={is_archived}
|
disabled={is_archived}
|
||||||
compactComposer
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
|
<div
|
||||||
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
|
className={`vertical-scrollbar scrollbar-sm h-full !w-[400px] flex-shrink-0 overflow-hidden border-l border-subtle p-4 py-5 ${
|
||||||
<div className="space-y-3">
|
is_archived ? "pointer-events-none" : ""
|
||||||
<PeekOverviewIssueDetails
|
}`}
|
||||||
editorRef={editorRef}
|
>
|
||||||
workspaceSlug={workspaceSlug}
|
<PeekOverviewProperties
|
||||||
projectId={projectId}
|
workspaceSlug={workspaceSlug}
|
||||||
issueId={issueId}
|
projectId={projectId}
|
||||||
issueOperations={issueOperations}
|
issueId={issueId}
|
||||||
disabled={disabled}
|
issueOperations={issueOperations}
|
||||||
isArchived={is_archived}
|
disabled={disabled || is_archived}
|
||||||
isSubmitting={isSubmitting}
|
/>
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="py-2">
|
|
||||||
<IssueDetailWidgets
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
disabled={disabled}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
issueServiceType={EIssueServiceType.ISSUES}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IssueActivity
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
disabled={is_archived}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`vertical-scrollbar scrollbar-sm h-full !w-[400px] flex-shrink-0 overflow-hidden border-l border-subtle p-4 py-5 ${
|
|
||||||
is_archived ? "pointer-events-none" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<PeekOverviewProperties
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
disabled={disabled || is_archived}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{issuePanel}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { type ChangeEvent, useMemo, useRef, useState } from "react";
|
import { type ChangeEvent, useMemo, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-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 useSWR from "swr";
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
type TCodexAgent,
|
type TCodexAgent,
|
||||||
type TCodexAgentGrant,
|
type TCodexAgentGrant,
|
||||||
type TCodexAgentGrantableWorkspace,
|
type TCodexAgentGrantableWorkspace,
|
||||||
|
type TCodexAgentSetupCode,
|
||||||
type TCodexAgentSetupPacket,
|
type TCodexAgentSetupPacket,
|
||||||
type TCodexAgentToken,
|
type TCodexAgentToken,
|
||||||
} from "@/services/workspace-codex-agent.service";
|
} from "@/services/workspace-codex-agent.service";
|
||||||
|
|
@ -59,7 +60,9 @@ type TProps = {
|
||||||
type TAgentSetupCard = {
|
type TAgentSetupCard = {
|
||||||
agent: TCodexAgent;
|
agent: TCodexAgent;
|
||||||
grants: TCodexAgentGrant[];
|
grants: TCodexAgentGrant[];
|
||||||
|
installCommand?: string;
|
||||||
setup?: TCodexAgentSetupPacket;
|
setup?: TCodexAgentSetupPacket;
|
||||||
|
setupCode?: TCodexAgentSetupCode;
|
||||||
tokens: TCodexAgentToken[];
|
tokens: TCodexAgentToken[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,6 +87,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
|
const [createdSetupCards, setCreatedSetupCards] = useState<TAgentSetupCard[]>([]);
|
||||||
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
|
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
|
||||||
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
|
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
|
||||||
|
const [creatingSetupCodeAgentIds, setCreatingSetupCodeAgentIds] = useState<Record<string, boolean>>({});
|
||||||
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
|
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
|
||||||
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
|
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
|
||||||
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
|
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
|
||||||
|
|
@ -221,19 +225,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
mode: "voluntary",
|
mode: "voluntary",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, {
|
const [setupResponse, setupCodeResponse] = await Promise.all([
|
||||||
name: `${displayName} local token`,
|
codexAgentService.getSetup(workspaceSlug, createResponse.agent.id),
|
||||||
});
|
codexAgentService.createSetupCode(workspaceSlug, createResponse.agent.id, {
|
||||||
setRevealedTokens((currentTokens) => ({
|
expires_in_seconds: 600,
|
||||||
...currentTokens,
|
token_name: `${displayName} local token`,
|
||||||
[tokenResponse.token_record.id]: tokenResponse.token,
|
}),
|
||||||
}));
|
]);
|
||||||
setCreatedSetupCards((currentCards) =>
|
setCreatedSetupCards((currentCards) =>
|
||||||
upsertSetupCardToken(
|
upsertSetupCardSetupCode(
|
||||||
currentCards,
|
currentCards,
|
||||||
createResponse.agent,
|
createResponse.agent,
|
||||||
tokenResponse.token_record,
|
setupCodeResponse.setup_code_record,
|
||||||
tokenResponse.setup,
|
setupCodeResponse.install.command,
|
||||||
|
setupResponse.setup,
|
||||||
grantsResponse.grants
|
grantsResponse.grants
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -242,7 +247,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Codex agent создан",
|
title: "Codex agent создан",
|
||||||
message: "Полный token показан только в текущем открытии раздела. После перезахода останется masked suffix.",
|
message:
|
||||||
|
"Скопируйте terminal-команду в локальный терминал. Setup-код действует 10 минут и используется один раз.",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setToast({
|
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) => {
|
const handleCreateToken = async (agent: TCodexAgent) => {
|
||||||
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
|
setCreatingTokenAgentIds((current) => ({ ...current, [agent.id]: true }));
|
||||||
try {
|
try {
|
||||||
|
|
@ -435,7 +475,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
<CapabilityCard
|
<CapabilityCard
|
||||||
icon={KeyRound}
|
icon={KeyRound}
|
||||||
title="Локальный Codex"
|
title="Локальный Codex"
|
||||||
description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway."
|
description="Пользовательский Codex подключается по MCP endpoint через одноразовый setup-code или legacy agent token."
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -445,7 +485,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
|
<div className="text-15 font-semibold text-primary">Создать агента workspace</div>
|
||||||
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
||||||
Задайте имя, выберите project grant и выпустите agent token. Аватар меняется кликом по кругу.
|
Задайте имя, выберите project grant и получите terminal-команду для локального Codex. Аватар
|
||||||
|
меняется кликом по кругу.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -514,11 +555,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
activeAgents.map((agent) => {
|
activeAgents.map((agent) => {
|
||||||
const draftName = getAgentDraftName(agentDraftNames, agent);
|
const draftName = getAgentDraftName(agentDraftNames, agent);
|
||||||
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
|
const isUpdatingAgent = updatingAgentIds[agent.id] === true;
|
||||||
|
const isCreatingSetupCode = creatingSetupCodeAgentIds[agent.id] === true;
|
||||||
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
|
const isCreatingToken = creatingTokenAgentIds[agent.id] === true;
|
||||||
const isAgentDirty = draftName.trim() !== agent.display_name;
|
const isAgentDirty = draftName.trim() !== agent.display_name;
|
||||||
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
|
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
|
||||||
const agentTokens = setupCard?.tokens ?? [];
|
const agentTokens = setupCard?.tokens ?? [];
|
||||||
const agentGrants = setupCard?.grants ?? [];
|
const agentGrants = setupCard?.grants ?? [];
|
||||||
|
const installCommand = setupCard?.installCommand;
|
||||||
|
const setupCode = setupCard?.setupCode;
|
||||||
const currentGrantKeys = getGrantedProjectKeys(agentGrants);
|
const currentGrantKeys = getGrantedProjectKeys(agentGrants);
|
||||||
const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
|
const draftGrantKeys = projectGrantDrafts[agent.id] ?? currentGrantKeys;
|
||||||
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
|
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
|
||||||
|
|
@ -584,15 +628,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="nodedc-settings-chip h-12"
|
|
||||||
loading={isCreatingToken}
|
|
||||||
onClick={() => void handleCreateToken(agent)}
|
|
||||||
>
|
|
||||||
Новый токен
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -604,8 +639,18 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AgentTerminalSetupPanel
|
||||||
|
command={installCommand}
|
||||||
|
isCreating={isCreatingSetupCode}
|
||||||
|
setupCode={setupCode}
|
||||||
|
onCopyCommand={() => installCommand && void handleCopy(installCommand, "Terminal-команда")}
|
||||||
|
onCreate={() => void handleCreateSetupCode(agent)}
|
||||||
|
/>
|
||||||
|
|
||||||
{areSetupCardsLoading && agentTokens.length === 0 ? (
|
{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 ? (
|
) : agentTokens.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{agentTokens.map((token) => {
|
{agentTokens.map((token) => {
|
||||||
|
|
@ -617,7 +662,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
|
||||||
return (
|
return (
|
||||||
<div key={token.id} className="nodedc-settings-field p-4">
|
<div key={token.id} className="nodedc-settings-field p-4">
|
||||||
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
|
||||||
Agent token
|
Legacy agent token
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
|
<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">
|
||||||
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -704,12 +760,74 @@ type TCodexConnectionGuideProps = {
|
||||||
onCopyConfig: () => void;
|
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) {
|
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 p-4">
|
<div className="grid gap-4 p-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
|
<div className="text-14 font-semibold text-primary">Legacy manual setup</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
|
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
|
||||||
MCP endpoint · {props.mcpEndpoint}
|
MCP endpoint · {props.mcpEndpoint}
|
||||||
|
|
@ -763,7 +881,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
|
||||||
<p className="mb-3 text-13 leading-5 text-secondary">
|
<p className="mb-3 text-13 leading-5 text-secondary">
|
||||||
Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
|
Добавьте этот блок в конец существующего <code>config.toml</code>. Не заменяйте файл целиком. Если блок{" "}
|
||||||
<code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть — замените только этот блок и его{" "}
|
<code>[mcp_servers.{CODEX_MCP_SERVER_NAME}]</code> уже есть — замените только этот блок и его{" "}
|
||||||
<code>headers</code>.
|
<code>http_headers</code>.
|
||||||
</p>
|
</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">
|
<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}
|
{props.configSnippet}
|
||||||
|
|
@ -790,7 +908,7 @@ required = false
|
||||||
startup_timeout_sec = 20
|
startup_timeout_sec = 20
|
||||||
tool_timeout_sec = 60
|
tool_timeout_sec = 60
|
||||||
|
|
||||||
[mcp_servers.${CODEX_MCP_SERVER_NAME}.headers]
|
[mcp_servers.${CODEX_MCP_SERVER_NAME}.http_headers]
|
||||||
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
|
Authorization = "Bearer ndcag_ВАШ_УНИКАЛЬНЫЙ_ТОКЕН"
|
||||||
Accept = "application/json"
|
Accept = "application/json"
|
||||||
"MCP-Protocol-Version" = "2025-06-18"`;
|
"MCP-Protocol-Version" = "2025-06-18"`;
|
||||||
|
|
@ -994,12 +1112,14 @@ function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgent
|
||||||
cardsByAgentId.set(card.agent.id, {
|
cardsByAgentId.set(card.agent.id, {
|
||||||
agent: persistedCard.agent,
|
agent: persistedCard.agent,
|
||||||
grants: mergeAgentGrants(persistedCard.grants, card.grants),
|
grants: mergeAgentGrants(persistedCard.grants, card.grants),
|
||||||
|
installCommand: card.installCommand ?? persistedCard.installCommand,
|
||||||
setup: persistedCard.setup ?? card.setup,
|
setup: persistedCard.setup ?? card.setup,
|
||||||
|
setupCode: card.setupCode ?? persistedCard.setupCode,
|
||||||
tokens: mergeTokens(persistedCard.tokens, card.tokens),
|
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[] {
|
function mergeTokens(primaryTokens: TCodexAgentToken[], secondaryTokens: TCodexAgentToken[]): TCodexAgentToken[] {
|
||||||
|
|
@ -1029,13 +1149,42 @@ function upsertSetupCardToken(
|
||||||
? {
|
? {
|
||||||
agent,
|
agent,
|
||||||
grants: mergeAgentGrants(card.grants, grants),
|
grants: mergeAgentGrants(card.grants, grants),
|
||||||
|
installCommand: card.installCommand,
|
||||||
setup: setup ?? card.setup,
|
setup: setup ?? card.setup,
|
||||||
|
setupCode: card.setupCode,
|
||||||
tokens: mergeTokens([token], card.tokens),
|
tokens: mergeTokens([token], card.tokens),
|
||||||
}
|
}
|
||||||
: card
|
: 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[] {
|
function flattenProjectAccessOptions(workspaceGroups: TCodexAgentGrantableWorkspace[]): TProjectAccessOption[] {
|
||||||
return workspaceGroups.flatMap((workspaceGroup) =>
|
return workspaceGroups.flatMap((workspaceGroup) =>
|
||||||
workspaceGroup.projects.map((project) => ({
|
workspaceGroup.projects.map((project) => ({
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const StatCard = (props: {
|
||||||
return (
|
return (
|
||||||
<div className="nodedc-settings-card p-5">
|
<div className="nodedc-settings-card p-5">
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
|
"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" />
|
<Icon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
</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 className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||||
</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="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">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
|
<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>
|
</div>
|
||||||
<StorageValue>{formatCount(project.file_count)}</StorageValue>
|
<StorageValue>{formatCount(project.file_count)}</StorageValue>
|
||||||
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
|
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
|
||||||
|
|
@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
|
||||||
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
|
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
|
||||||
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
|
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
|
||||||
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
|
<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>
|
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProjectStorageHeader = () => (
|
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>Файлы</span>
|
<span>Файлы</span>
|
||||||
<span>Blob</span>
|
<span>Blob</span>
|
||||||
|
|
@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
|
||||||
title: "Квота обновлена",
|
title: "Квота обновлена",
|
||||||
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
|
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Квота не сохранена",
|
title: "Квота не сохранена",
|
||||||
|
|
@ -200,34 +202,40 @@ const ProjectQuotaControl = (props: {
|
||||||
<Database className="size-3.5" />
|
<Database className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
{isEnabled ? (
|
||||||
<input
|
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
<input
|
||||||
disabled={isSaving}
|
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||||
inputMode="numeric"
|
disabled={isSaving}
|
||||||
min={1}
|
inputMode="numeric"
|
||||||
onChange={(event) => setQuotaMb(event.target.value)}
|
min={1}
|
||||||
onKeyDown={(event) => {
|
onChange={(event) => setQuotaMb(event.target.value)}
|
||||||
if (event.key === "Enter") {
|
onKeyDown={(event) => {
|
||||||
saveQuota();
|
if (event.key === "Enter") {
|
||||||
}
|
saveQuota();
|
||||||
}}
|
}
|
||||||
type="number"
|
}}
|
||||||
value={quotaMb}
|
type="number"
|
||||||
/>
|
value={quotaMb}
|
||||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
/>
|
||||||
</div>
|
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
) : (
|
||||||
"mt-1 truncate text-[11px] text-tertiary",
|
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
|
||||||
project.quota_warning && "text-yellow-200",
|
Без лимита
|
||||||
project.quota_exceeded && "text-red-200"
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
{project.quota_enabled && (
|
||||||
{project.quota_enabled
|
<div
|
||||||
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
|
className={cn(
|
||||||
: "без лимита"}
|
"mt-1 truncate text-[11px] text-tertiary",
|
||||||
</div>
|
project.quota_warning && "text-yellow-200",
|
||||||
|
project.quota_exceeded && "text-red-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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="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 className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<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="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</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 className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
|
<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)
|
([, 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 maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
|
||||||
|
|
||||||
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
|
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
|
||||||
|
|
@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||||
title: "Хранилище обновлено",
|
title: "Хранилище обновлено",
|
||||||
message: getMaintenanceResultMessage(result.action, result),
|
message: getMaintenanceResultMessage(result.action, result),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Действие не выполнено",
|
title: "Действие не выполнено",
|
||||||
|
|
@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
|
||||||
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
|
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-4 flex items-end justify-between gap-4">
|
<div className="mb-4 flex items-end justify-between gap-4">
|
||||||
<div>
|
<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 className="mt-1 text-13 text-secondary">
|
||||||
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||||
<section className="nodedc-settings-card p-5">
|
<section className="nodedc-settings-card p-5">
|
||||||
<div className="mb-5 flex items-center justify-between gap-4">
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
|
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,36 @@ import type { AxiosRequestConfig } from "axios";
|
||||||
import { API_BASE_URL } from "@plane/constants";
|
import { API_BASE_URL } from "@plane/constants";
|
||||||
// plane types
|
// plane types
|
||||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
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";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
// services
|
// 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 { APIService } from "@/services/api.service";
|
||||||
import { FileUploadService } from "@/services/file-upload.service";
|
import { FileUploadService } from "@/services/file-upload.service";
|
||||||
|
|
||||||
|
type TIssueAttachmentCreateResponse = {
|
||||||
|
asset_id: string;
|
||||||
|
asset_url: string;
|
||||||
|
attachment: TIssueAttachment;
|
||||||
|
};
|
||||||
|
|
||||||
export class IssueAttachmentService extends APIService {
|
export class IssueAttachmentService extends APIService {
|
||||||
private fileUploadService: FileUploadService;
|
private fileUploadService: FileUploadService;
|
||||||
private serviceType: TIssueServiceType;
|
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(
|
async uploadIssueAttachment(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -47,6 +210,22 @@ export class IssueAttachmentService extends APIService {
|
||||||
file: File,
|
file: File,
|
||||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||||
): Promise<TIssueAttachment> {
|
): 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);
|
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||||
return this.post(
|
return this.post(
|
||||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||||
|
|
@ -98,8 +277,16 @@ export class IssueAttachmentService extends APIService {
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
issueId: string,
|
issueId: string,
|
||||||
assetId: string
|
assetId: string,
|
||||||
|
attachment?: TIssueAttachment
|
||||||
): Promise<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(
|
return this.delete(
|
||||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
|
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { APIService } from "@/services/api.service";
|
||||||
export type TCodexAgentStatus = "active" | "disabled" | "revoked";
|
export type TCodexAgentStatus = "active" | "disabled" | "revoked";
|
||||||
export type TCodexAgentGrantMode = "voluntary" | "reporting";
|
export type TCodexAgentGrantMode = "voluntary" | "reporting";
|
||||||
export type TCodexAgentTokenStatus = "active" | "revoked" | "expired";
|
export type TCodexAgentTokenStatus = "active" | "revoked" | "expired";
|
||||||
|
export type TCodexAgentSetupCodeStatus = "active" | "used" | "expired" | "revoked";
|
||||||
|
|
||||||
export type TCodexAgent = {
|
export type TCodexAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -45,6 +46,19 @@ export type TCodexAgentToken = {
|
||||||
created_at: string;
|
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 = {
|
export type TCodexAgentSetupPacket = {
|
||||||
agents_md?: string;
|
agents_md?: string;
|
||||||
codex_config_template?: Record<string, unknown>;
|
codex_config_template?: Record<string, unknown>;
|
||||||
|
|
@ -67,6 +81,25 @@ export type TCodexAgentCreateTokenResponse = {
|
||||||
token_record: TCodexAgentToken;
|
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 = {
|
export type TCodexAgentTokenListResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
tokens: TCodexAgentToken[];
|
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> {
|
async listTokens(workspaceSlug: string, agentId: string): Promise<TCodexAgentTokenListResponse> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
|
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/tokens/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
|
||||||
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
|
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
|
||||||
|
|
||||||
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
||||||
[...attachmentIds].sort((firstId, secondId) => {
|
attachmentIds.toSorted((firstId, secondId) => {
|
||||||
const firstAttachment = attachmentMap[firstId];
|
const firstAttachment = attachmentMap[firstId];
|
||||||
const secondAttachment = attachmentMap[secondId];
|
const secondAttachment = attachmentMap[secondId];
|
||||||
if (!firstAttachment || !secondAttachment) return 0;
|
if (!firstAttachment || !secondAttachment) return 0;
|
||||||
|
|
@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||||
|
|
||||||
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
|
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
|
||||||
const issueAttachmentService = await this.getIssueAttachmentService();
|
const issueAttachmentService = await this.getIssueAttachmentService();
|
||||||
|
const attachment = this.attachmentMap[attachmentId];
|
||||||
const response = await issueAttachmentService.deleteIssueAttachment(
|
const response = await issueAttachmentService.deleteIssueAttachment(
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
projectId,
|
projectId,
|
||||||
issueId,
|
issueId,
|
||||||
attachmentId
|
attachmentId,
|
||||||
|
attachment
|
||||||
);
|
);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -6,11 +6,67 @@
|
||||||
|
|
||||||
import type { TFileSignedURLResponse } from "../file";
|
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 = {
|
export type TIssueAttachment = {
|
||||||
id: string;
|
id: string;
|
||||||
attributes: {
|
attributes: {
|
||||||
|
beamViewer?: TBeamViewerAttachment;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
type?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
asset_url: string;
|
asset_url: string;
|
||||||
issue_id: string;
|
issue_id: string;
|
||||||
|
|
|
||||||
|
|
@ -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_LAUNCHER_URL=${VITE_NODEDC_LAUNCHER_URL:-https://hub.nodedc.ru}
|
||||||
VITE_NODEDC_OIDC_LOGIN_ENABLED=${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}
|
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}
|
RUN_SMOKE=${RUN_SMOKE:-1}
|
||||||
SMOKE_BASE_URL=${SMOKE_BASE_URL:-https://ops.nodedc.ru}
|
SMOKE_BASE_URL=${SMOKE_BASE_URL:-https://ops.nodedc.ru}
|
||||||
|
|
@ -104,6 +106,8 @@ if [ "$BUILD_WEB" = "1" ]; then
|
||||||
$DOCKER_BUILD_NETWORK_ARGS \
|
$DOCKER_BUILD_NETWORK_ARGS \
|
||||||
--build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
|
--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_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" \
|
-t "$PLANE_FRONTEND_IMAGE" \
|
||||||
-f apps/web/Dockerfile.web.nas-legacy .
|
-f apps/web/Dockerfile.web.nas-legacy .
|
||||||
RECREATE_SERVICES="$RECREATE_SERVICES web"
|
RECREATE_SERVICES="$RECREATE_SERVICES web"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue