Compare commits
No commits in common. "3806848b075db50e77c032cf05b9044615f09b31" and "2ad9e7f85c6d54f8ced808c145529c2be52415ca" have entirely different histories.
3806848b07
...
2ad9e7f85c
|
|
@ -32,19 +32,6 @@ 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,6 @@ 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:-}
|
||||||
|
|
@ -162,9 +159,6 @@ 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
|
||||||
|
|
@ -185,9 +179,6 @@ 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
|
||||||
|
|
@ -208,9 +199,6 @@ 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
|
||||||
|
|
@ -229,9 +217,6 @@ 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:
|
||||||
|
|
@ -319,9 +304,3 @@ 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,7 +120,3 @@ 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,9 +88,3 @@ 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,8 +97,4 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
# 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
|
||||||
|
|
@ -29,7 +28,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# 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,
|
|
||||||
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/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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -174,20 +174,6 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
from .ai_workspace import (
|
|
||||||
AIWorkspaceExecutorCheckEndpoint,
|
|
||||||
AIWorkspaceExecutorDetailEndpoint,
|
|
||||||
AIWorkspaceExecutorEventsEndpoint,
|
|
||||||
AIWorkspaceExecutorListEndpoint,
|
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
|
||||||
AIWorkspaceSettingsEndpoint,
|
|
||||||
AIWorkspaceThreadDispatchEndpoint,
|
|
||||||
AIWorkspaceThreadDetailEndpoint,
|
|
||||||
AIWorkspaceThreadListEndpoint,
|
|
||||||
AIWorkspaceThreadMessagesEndpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .codex_agents import (
|
from .codex_agents import (
|
||||||
CodexAgentDetailEndpoint,
|
CodexAgentDetailEndpoint,
|
||||||
CodexAgentGrantListEndpoint,
|
CodexAgentGrantListEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,708 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
ops_project_id = (request.query_params.get("ops_project_id") or "").strip()
|
|
||||||
if ops_project_id:
|
|
||||||
ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip()
|
|
||||||
active_context = {
|
|
||||||
"surface": "ops",
|
|
||||||
"workspaceSlug": ops_workspace_slug,
|
|
||||||
"opsWorkspaceSlug": ops_workspace_slug,
|
|
||||||
"opsRouteWorkspaceSlug": slug,
|
|
||||||
"opsProjectId": ops_project_id,
|
|
||||||
}
|
|
||||||
sync_payload = ops_settings_payload(
|
|
||||||
{
|
|
||||||
"activeContext": active_context,
|
|
||||||
"enabledToolPacks": ["ops", "engine"],
|
|
||||||
"metadata": {
|
|
||||||
"source": "ops-ai-workspace-installer",
|
|
||||||
"updatedAt": timezone.now().isoformat(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slug,
|
|
||||||
)
|
|
||||||
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
|
|
||||||
if ops_grant_error is not None:
|
|
||||||
return ops_grant_error
|
|
||||||
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
|
|
||||||
if sync_response.status_code >= 400:
|
|
||||||
return sync_response
|
|
||||||
|
|
||||||
try:
|
|
||||||
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 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,
|
|
||||||
)
|
|
||||||
|
|
@ -263,7 +263,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
existing_beam_viewer = attributes.get("beamViewer")
|
existing_beam_viewer = attributes.get("beamViewer")
|
||||||
if not isinstance(existing_beam_viewer, dict):
|
if not isinstance(existing_beam_viewer, dict):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
|
{"error": "The attachment is not a Beam Viewer reference.", "status": False},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -271,9 +271,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
**existing_beam_viewer,
|
**existing_beam_viewer,
|
||||||
**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.attributes = attributes
|
||||||
issue_attachment.is_uploaded = True
|
issue_attachment.is_uploaded = True
|
||||||
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
|
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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";
|
||||||
|
|
@ -41,7 +40,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
@ -1,774 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
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,
|
|
||||||
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 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>
|
|
||||||
<a
|
|
||||||
className="modal-btn ai-settings-download ai-settings-inline-download"
|
|
||||||
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
|
|
||||||
onClick={downloadAgent}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
Скачать агент
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : draft.connectionMode === "hub" ? (
|
|
||||||
<div className="ai-settings-check-note">
|
|
||||||
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -211,8 +211,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
</div>
|
</div>
|
||||||
{isListView ? (
|
{isListView ? (
|
||||||
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
||||||
<div className="min-w-[840px]">
|
<div className="min-w-[760px]">
|
||||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
||||||
<div>Файл</div>
|
<div>Файл</div>
|
||||||
<div>Дата</div>
|
<div>Дата</div>
|
||||||
<div>Версия</div>
|
<div>Версия</div>
|
||||||
|
|
@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
{uploadStatus?.map((status) => (
|
{uploadStatus?.map((status) => (
|
||||||
<div
|
<div
|
||||||
key={status.id}
|
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"
|
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-semibold text-secondary">{status.name}</div>
|
<div className="truncate font-semibold text-secondary">{status.name}</div>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ChangeEvent, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||||
import { TrashIcon } from "@plane/propel/icons";
|
import { TrashIcon } from "@plane/propel/icons";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
import type { TIssueServiceType } from "@plane/types";
|
import type { TIssueServiceType } from "@plane/types";
|
||||||
import { EIssueServiceType } from "@plane/types";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
|
|
@ -19,7 +18,7 @@ import { EIssueServiceType } from "@plane/types";
|
||||||
import type { TContextMenuItem } from "@plane/ui";
|
import type { TContextMenuItem } from "@plane/ui";
|
||||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
||||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||||
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
|
import { AlertTriangle, Box, Download, Eye, ImageIcon, Play, X } from "lucide-react";
|
||||||
// components
|
// components
|
||||||
//
|
//
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
|
|
@ -28,12 +27,8 @@ import {
|
||||||
buildBeamViewerUrl,
|
buildBeamViewerUrl,
|
||||||
dispatchBeamViewerOpenEvent,
|
dispatchBeamViewerOpenEvent,
|
||||||
fetchBeamConversionStatus,
|
fetchBeamConversionStatus,
|
||||||
getBeamModelVersionRecords,
|
|
||||||
getBeamVersionViewerUrl,
|
|
||||||
getBeamViewerAttachment,
|
getBeamViewerAttachment,
|
||||||
isBeamModelFile,
|
isBeamModelFile,
|
||||||
syncCurrentBeamVersionRecord,
|
|
||||||
type TBeamModelVersionRecord,
|
|
||||||
type TBeamViewerAttachment,
|
type TBeamViewerAttachment,
|
||||||
type TBeamConversionStatus,
|
type TBeamConversionStatus,
|
||||||
} from "@/helpers/beam-viewer";
|
} from "@/helpers/beam-viewer";
|
||||||
|
|
@ -42,7 +37,6 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useUser } from "@/hooks/store/user";
|
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
import { IssueAttachmentService } from "@/services/issue";
|
import { IssueAttachmentService } from "@/services/issue";
|
||||||
|
|
||||||
|
|
@ -67,7 +61,7 @@ const appendSearchParam = (url: string | undefined, key: string, value: string):
|
||||||
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefined => {
|
const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url, "http://localhost");
|
const parsedUrl = new URL(url, "http://localhost");
|
||||||
|
|
@ -81,10 +75,7 @@ const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefi
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const withBeamViewerSettingsSrc = (
|
const withBeamViewerSettingsSrc = (url: string | undefined, settingsSrc: string | undefined): string | undefined => {
|
||||||
url: string | null | undefined,
|
|
||||||
settingsSrc: string | undefined
|
|
||||||
): string | undefined => {
|
|
||||||
if (!url || !settingsSrc) return url;
|
if (!url || !settingsSrc) return url;
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url, "http://localhost");
|
const parsedUrl = new URL(url, "http://localhost");
|
||||||
|
|
@ -108,20 +99,9 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
|
||||||
if (status === "ready") return "Модель готова";
|
if (status === "ready") return "Модель готова";
|
||||||
if (status === "failed") return "Ошибка дерева";
|
if (status === "failed") return "Ошибка дерева";
|
||||||
if (status === "processing") return "Собираем дерево";
|
if (status === "processing") return "Собираем дерево";
|
||||||
if (status === "conversion_required") return "Ожидает очереди";
|
|
||||||
return "Ожидает дерево";
|
return "Ожидает дерево";
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeBeamStatusMessage = (message: string | undefined): string | undefined =>
|
|
||||||
message
|
|
||||||
?.replace(/XKT-просмотр/g, "просмотр")
|
|
||||||
.replace(/XKT и дерева/g, "модели и дерева")
|
|
||||||
.replace(/GLB\/XKT/g, "модели")
|
|
||||||
.replace(/XKT/g, "просмотра");
|
|
||||||
|
|
||||||
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
|
|
||||||
version.versionId || `version-${version.version}`;
|
|
||||||
|
|
||||||
const buildSyncedBeamViewerAttachment = (
|
const buildSyncedBeamViewerAttachment = (
|
||||||
beamViewer: TBeamViewerAttachment,
|
beamViewer: TBeamViewerAttachment,
|
||||||
status: TBeamConversionStatus,
|
status: TBeamConversionStatus,
|
||||||
|
|
@ -132,13 +112,9 @@ const buildSyncedBeamViewerAttachment = (
|
||||||
const artifactType = status.artifactType || status.targetFormat || "gltf";
|
const artifactType = status.artifactType || status.targetFormat || "gltf";
|
||||||
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
|
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
|
||||||
|
|
||||||
return syncCurrentBeamVersionRecord({
|
return {
|
||||||
...beamViewer,
|
...beamViewer,
|
||||||
assetId: beamViewer.assetId || status.assetId,
|
|
||||||
previewAvailable: true,
|
previewAvailable: true,
|
||||||
sha256: beamViewer.sha256 || status.sha256,
|
|
||||||
version: beamViewer.version || status.version,
|
|
||||||
versionId: beamViewer.versionId || status.versionId,
|
|
||||||
viewerUrl: buildBeamViewerUrl({
|
viewerUrl: buildBeamViewerUrl({
|
||||||
name: fullFileName,
|
name: fullFileName,
|
||||||
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
|
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
|
||||||
|
|
@ -146,20 +122,19 @@ const buildSyncedBeamViewerAttachment = (
|
||||||
type: artifactType,
|
type: artifactType,
|
||||||
}),
|
}),
|
||||||
conversion: {
|
conversion: {
|
||||||
...beamViewer.conversion,
|
...(beamViewer.conversion ?? {}),
|
||||||
artifactSrc: status.artifactSrc,
|
artifactSrc: status.artifactSrc,
|
||||||
artifactType,
|
artifactType,
|
||||||
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
|
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
|
||||||
message: status.message || beamViewer.conversion?.message,
|
message: status.message || beamViewer.conversion?.message,
|
||||||
metadataSrc: status.metadataSrc,
|
metadataSrc: status.metadataSrc,
|
||||||
size: status.size ?? beamViewer.conversion?.size,
|
|
||||||
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
|
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
|
||||||
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
|
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
targetFormat,
|
targetFormat,
|
||||||
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
|
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
||||||
|
|
@ -176,7 +151,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
} = props;
|
} = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
const {
|
const {
|
||||||
attachment: { getAttachmentById },
|
attachment: { getAttachmentById },
|
||||||
fetchAttachments,
|
fetchAttachments,
|
||||||
|
|
@ -196,9 +170,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
|
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
|
||||||
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
|
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
|
||||||
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
|
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
|
||||||
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl);
|
||||||
const storedModelViewerUrl =
|
|
||||||
beamEffectiveStatus === "ready" ? getUsableBeamViewerUrl(beamViewer?.viewerUrl) : undefined;
|
|
||||||
const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
|
const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
|
||||||
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
|
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
|
||||||
const modelViewerUrl =
|
const modelViewerUrl =
|
||||||
|
|
@ -213,37 +185,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||||
const isBeamModel = isBeamModelFile(fullFileName);
|
const isBeamModel = isBeamModelFile(fullFileName);
|
||||||
|
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
||||||
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
|
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
|
||||||
const beamStatusErrorMessage =
|
const beamStatusErrorMessage =
|
||||||
beamConversionError ||
|
beamConversionError ||
|
||||||
beamConversionStatus?.error ||
|
beamConversionStatus?.error ||
|
||||||
(beamConversionStatus?.status === "ready" && !modelViewerUrl
|
(beamConversionStatus?.status === "ready" && !modelViewerUrl
|
||||||
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
|
? "Beam вернул статус готовности, но не вернул viewer-артефакт."
|
||||||
: null);
|
: null);
|
||||||
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
|
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
|
||||||
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
|
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
|
||||||
const rawBeamStatusTooltipContent = isBeamConversionFailed
|
const beamStatusTooltipContent = isBeamConversionFailed
|
||||||
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
|
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
|
||||||
: beamConversionStatus?.message ||
|
: beamConversionStatus?.message ||
|
||||||
beamViewer?.conversion?.message ||
|
beamViewer?.conversion?.message ||
|
||||||
"Оригинальная модель загружена в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов.";
|
"Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.";
|
||||||
const beamStatusTooltipContent = sanitizeBeamStatusMessage(rawBeamStatusTooltipContent) || "";
|
|
||||||
const beamStatusTooltipMessage =
|
|
||||||
beamStatusTooltipContent &&
|
|
||||||
!["ready", "processing", "failed", "conversion_required"].includes(beamStatusTooltipContent)
|
|
||||||
? beamStatusTooltipContent
|
|
||||||
: "";
|
|
||||||
const beamStatusIconTooltipContent = beamConversionStatusLabel
|
|
||||||
? `${beamConversionStatusLabel}${beamStatusTooltipMessage ? `. ${beamStatusTooltipMessage}` : ""}`
|
|
||||||
: beamStatusTooltipContent;
|
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
|
||||||
const [deletingVersionKey, setDeletingVersionKey] = useState<string | null>(null);
|
|
||||||
const [isVersionUploading, setIsVersionUploading] = useState(false);
|
|
||||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||||
const versionUploadInputRef = useRef<HTMLInputElement>(null);
|
const rawVersion = attachment?.attributes.version;
|
||||||
const beamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]);
|
|
||||||
const rawVersion = beamViewer?.version ?? attachment?.attributes.version;
|
|
||||||
const versionLabel =
|
const versionLabel =
|
||||||
typeof rawVersion === "number"
|
typeof rawVersion === "number"
|
||||||
? `v${rawVersion}`
|
? `v${rawVersion}`
|
||||||
|
|
@ -252,14 +211,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
? rawVersion
|
? rawVersion
|
||||||
: `v${rawVersion}`
|
: `v${rawVersion}`
|
||||||
: "v1";
|
: "v1";
|
||||||
const currentVersionId = beamViewer?.versionId;
|
|
||||||
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
|
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
|
||||||
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
|
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
|
||||||
const getVersionAuthorName = (version: TBeamModelVersionRecord): string => {
|
|
||||||
const userId = version.uploadedBy || attachment?.created_by;
|
|
||||||
if (!userId) return "—";
|
|
||||||
return getUserDetails(userId)?.display_name ?? "—";
|
|
||||||
};
|
|
||||||
const openModelViewer = () => {
|
const openModelViewer = () => {
|
||||||
if (!modelViewerUrl) return;
|
if (!modelViewerUrl) return;
|
||||||
dispatchBeamViewerOpenEvent({
|
dispatchBeamViewerOpenEvent({
|
||||||
|
|
@ -271,88 +224,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
viewerUrl: modelViewerUrl,
|
viewerUrl: modelViewerUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
|
|
||||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
|
||||||
if (!viewerUrl) return;
|
|
||||||
|
|
||||||
setIsVersionHistoryOpen(false);
|
|
||||||
dispatchBeamViewerOpenEvent({
|
|
||||||
downloadUrl: version.downloadUrl,
|
|
||||||
fileExtension: getFileExtension(version.originalFilename),
|
|
||||||
fileName: version.originalFilename,
|
|
||||||
fileSize: version.size || version.conversion?.size || 0,
|
|
||||||
issueId,
|
|
||||||
viewerUrl,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const startVersionUpload = () => {
|
|
||||||
versionUploadInputRef.current?.click();
|
|
||||||
};
|
|
||||||
const handleVersionFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file || !attachment || !beamViewer) return;
|
|
||||||
|
|
||||||
if (!isBeamModelFile(file.name)) {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Формат не поддержан",
|
|
||||||
message: "Для новой версии нужно выбрать файл модели, который поддерживает BIM Viewer.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsVersionUploading(true);
|
|
||||||
try {
|
|
||||||
await attachmentService.uploadBeamIssueAttachmentVersion(
|
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
issueId,
|
|
||||||
attachment,
|
|
||||||
file,
|
|
||||||
undefined,
|
|
||||||
currentUser?.id
|
|
||||||
);
|
|
||||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Версия добавлена",
|
|
||||||
message: `${file.name} загружен как новая версия модели.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in uploading Beam model version:", error);
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Версия не загружена",
|
|
||||||
message: error instanceof Error ? error.message : "Не удалось загрузить новую версию модели.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsVersionUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleDeleteVersion = async (version: TBeamModelVersionRecord) => {
|
|
||||||
if (!attachment || !beamViewer) return;
|
|
||||||
|
|
||||||
const versionKey = getBeamVersionRecordKey(version);
|
|
||||||
setDeletingVersionKey(versionKey);
|
|
||||||
try {
|
|
||||||
await attachmentService.deleteBeamIssueAttachmentVersion(workspaceSlug, projectId, issueId, attachment, version);
|
|
||||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Версия удалена",
|
|
||||||
message: `v${version.version} удалена из истории модели.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Версия не удалена",
|
|
||||||
message: error instanceof Error ? error.message : "Не удалось удалить версию модели.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDeletingVersionKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const menuItems: TContextMenuItem[] = [
|
const menuItems: TContextMenuItem[] = [
|
||||||
...(modelViewerUrl
|
...(modelViewerUrl
|
||||||
? [
|
? [
|
||||||
|
|
@ -364,23 +235,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(beamViewer
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "upload-model-version",
|
|
||||||
action: startVersionUpload,
|
|
||||||
title: isVersionUploading ? "Загружаем версию..." : "Добавить версию",
|
|
||||||
icon: UploadCloud,
|
|
||||||
disabled: isVersionUploading,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "model-version-history",
|
|
||||||
action: () => setIsVersionHistoryOpen(true),
|
|
||||||
title: "История версий",
|
|
||||||
icon: History,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(modelDownloadUrl
|
...(modelDownloadUrl
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -404,19 +258,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
];
|
];
|
||||||
// hooks
|
// hooks
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const versionUploadInput = beamViewer ? (
|
|
||||||
<input
|
|
||||||
ref={versionUploadInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
disabled={isVersionUploading}
|
|
||||||
onChange={handleVersionFileChange}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!beamViewer || !beamViewer.src) return;
|
if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return;
|
||||||
if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return;
|
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
let timeoutId: number | undefined;
|
let timeoutId: number | undefined;
|
||||||
|
|
@ -439,7 +283,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели.");
|
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус Beam.");
|
||||||
timeoutId = window.setTimeout(pollStatus, 8000);
|
timeoutId = window.setTimeout(pollStatus, 8000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -467,8 +311,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
if (viewMode === "list") {
|
if (viewMode === "list") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{versionUploadInput}
|
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
|
||||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 items-center gap-3 text-left"
|
className="flex min-w-0 items-center gap-3 text-left"
|
||||||
|
|
@ -478,13 +321,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
if (modelViewerUrl) openModelViewer();
|
if (modelViewerUrl) openModelViewer();
|
||||||
else setIsPreviewOpen(true);
|
else setIsPreviewOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
|
|
||||||
isMobile={isMobile}
|
|
||||||
position={isBeamModel ? "left" : "top"}
|
|
||||||
sideOffset={isBeamModel ? 6 : 10}
|
|
||||||
className={isBeamModel ? "z-[220]" : ""}
|
|
||||||
>
|
>
|
||||||
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
||||||
{isBeamModel ? (
|
{isBeamModel ? (
|
||||||
|
|
@ -505,7 +341,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
<ImageIcon className="size-5" />
|
<ImageIcon className="size-5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
|
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
|
||||||
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
|
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
|
||||||
|
|
@ -514,6 +349,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
|
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
|
||||||
<span className="size-1 rounded-full bg-layer-1" />
|
<span className="size-1 rounded-full bg-layer-1" />
|
||||||
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
||||||
|
{beamConversionStatusLabel && (
|
||||||
|
<>
|
||||||
|
<span className="size-1 rounded-full bg-layer-1" />
|
||||||
|
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isBeamConversionFailed
|
||||||
|
? "text-red-400"
|
||||||
|
: isBeamAwaitingPreview
|
||||||
|
? "text-[rgb(var(--nodedc-accent-rgb))]"
|
||||||
|
: "text-tertiary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{beamConversionStatusLabel}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -521,40 +374,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
<div className="text-secondary">{versionLabel}</div>
|
<div className="text-secondary">{versionLabel}</div>
|
||||||
<div className="truncate text-tertiary">{uploadedBy}</div>
|
<div className="truncate text-tertiary">{uploadedBy}</div>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{beamViewer && (
|
|
||||||
<Tooltip tooltipContent="История версий" isMobile={isMobile}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsVersionHistoryOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<History className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{beamViewer && (
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={isVersionUploading ? "Загружаем версию..." : "Добавить версию"}
|
|
||||||
isMobile={isMobile}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
disabled={isVersionUploading}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
startVersionUpload();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadCloud className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{(modelViewerUrl || previewURL) && (
|
{(modelViewerUrl || previewURL) && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||||
|
|
@ -627,24 +446,12 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
size={attachment.attributes.size}
|
size={attachment.attributes.size}
|
||||||
/>
|
/>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
<BeamVersionHistoryModal
|
|
||||||
currentVersionId={currentVersionId}
|
|
||||||
deletingVersionKey={deletingVersionKey}
|
|
||||||
fileName={fullFileName}
|
|
||||||
getVersionAuthorName={getVersionAuthorName}
|
|
||||||
isOpen={isVersionHistoryOpen}
|
|
||||||
onClose={() => setIsVersionHistoryOpen(false)}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
|
||||||
onOpenVersion={openBeamVersionViewer}
|
|
||||||
versions={beamVersions}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{versionUploadInput}
|
|
||||||
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
|
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -764,17 +571,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
size={attachment.attributes.size}
|
size={attachment.attributes.size}
|
||||||
/>
|
/>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
<BeamVersionHistoryModal
|
|
||||||
currentVersionId={currentVersionId}
|
|
||||||
deletingVersionKey={deletingVersionKey}
|
|
||||||
fileName={fullFileName}
|
|
||||||
getVersionAuthorName={getVersionAuthorName}
|
|
||||||
isOpen={isVersionHistoryOpen}
|
|
||||||
onClose={() => setIsVersionHistoryOpen(false)}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
|
||||||
onOpenVersion={openBeamVersionViewer}
|
|
||||||
versions={beamVersions}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -878,7 +674,7 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||||
<div className="mt-2 text-14 text-secondary">
|
<div className="mt-2 text-14 text-secondary">
|
||||||
{isBeamConversionFailed
|
{isBeamConversionFailed
|
||||||
? beamStatusTooltipContent
|
? beamStatusTooltipContent
|
||||||
: "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
|
: "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{modelDownloadUrl && (
|
{modelDownloadUrl && (
|
||||||
|
|
@ -906,150 +702,3 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type TBeamVersionHistoryModal = {
|
|
||||||
currentVersionId: string | undefined;
|
|
||||||
deletingVersionKey: string | null;
|
|
||||||
fileName: string;
|
|
||||||
getVersionAuthorName: (version: TBeamModelVersionRecord) => string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onDeleteVersion: (version: TBeamModelVersionRecord) => void;
|
|
||||||
onOpenVersion: (version: TBeamModelVersionRecord) => void;
|
|
||||||
versions: TBeamModelVersionRecord[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const BeamVersionHistoryModal = (props: TBeamVersionHistoryModal) => {
|
|
||||||
const {
|
|
||||||
currentVersionId,
|
|
||||||
deletingVersionKey,
|
|
||||||
fileName,
|
|
||||||
getVersionAuthorName,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onDeleteVersion,
|
|
||||||
onOpenVersion,
|
|
||||||
versions,
|
|
||||||
} = props;
|
|
||||||
const latestVersion = versions.reduce((latest, version) => Math.max(latest, version.version), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalCore
|
|
||||||
isOpen={isOpen}
|
|
||||||
handleClose={onClose}
|
|
||||||
position={EModalPosition.CENTER}
|
|
||||||
width={EModalWidth.VIIXL}
|
|
||||||
className="max-h-[76vh] w-[min(96vw,1280px)] overflow-hidden bg-surface-1"
|
|
||||||
>
|
|
||||||
<div className="flex max-h-[76vh] min-h-0 flex-col bg-surface-1">
|
|
||||||
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-5 py-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-15 truncate font-semibold text-primary">История версий</div>
|
|
||||||
<div className="mt-1 truncate text-12 text-tertiary">{fileName}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hover:bg-surface-3 grid size-9 place-items-center rounded-full bg-surface-2 text-tertiary transition hover:text-primary"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
|
||||||
{versions.length === 0 ? (
|
|
||||||
<div className="rounded-2xl bg-surface-2/60 px-4 py-8 text-center text-13 text-tertiary">
|
|
||||||
История версий пока не создана.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-2xl bg-surface-2/40">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] gap-3 px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
|
||||||
<div>Файл</div>
|
|
||||||
<div>Версия</div>
|
|
||||||
<div>Размер</div>
|
|
||||||
<div>Автор</div>
|
|
||||||
<div>Дата</div>
|
|
||||||
<div>Статус</div>
|
|
||||||
<div className="text-right">Действия</div>
|
|
||||||
</div>
|
|
||||||
{versions
|
|
||||||
.toSorted((first, second) => second.version - first.version)
|
|
||||||
.map((version) => {
|
|
||||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
|
||||||
const isCurrent = currentVersionId
|
|
||||||
? version.versionId === currentVersionId
|
|
||||||
: version.version === latestVersion;
|
|
||||||
const statusLabel = getBeamConversionStatusLabel(version.conversion?.status) ?? "Модель готова";
|
|
||||||
const versionKey = getBeamVersionRecordKey(version);
|
|
||||||
const isDeletingVersion = deletingVersionKey === versionKey;
|
|
||||||
const authorName = getVersionAuthorName(version);
|
|
||||||
const versionSize = version.size || version.conversion?.size || 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={versionKey}
|
|
||||||
className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition last:border-b-0 hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-semibold text-secondary">{version.originalFilename}</div>
|
|
||||||
{version.sha256 && (
|
|
||||||
<div className="mt-1 truncate text-10 text-tertiary">sha256 {version.sha256}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="font-semibold text-secondary">
|
|
||||||
v{version.version}
|
|
||||||
{isCurrent && (
|
|
||||||
<span className="mt-1 block text-10 font-medium text-[rgb(var(--nodedc-accent-rgb))]">
|
|
||||||
текущая
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-tertiary">{convertBytesToSize(versionSize)}</div>
|
|
||||||
<div className="truncate text-tertiary">{authorName}</div>
|
|
||||||
<div className="truncate text-tertiary">{renderFormattedDate(version.uploadedAt)}</div>
|
|
||||||
<div className="truncate text-tertiary">{statusLabel}</div>
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{viewerUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
|
||||||
onClick={() => onOpenVersion(version)}
|
|
||||||
>
|
|
||||||
<Eye className="size-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{version.downloadUrl && (
|
|
||||||
<a
|
|
||||||
href={version.downloadUrl}
|
|
||||||
download={version.originalFilename}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={isCurrent ? "Текущую версию нельзя удалить" : "Удалить версию из истории"}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
disabled={isCurrent || isDeletingVersion}
|
|
||||||
onClick={() => onDeleteVersion(version)}
|
|
||||||
>
|
|
||||||
{isDeletingVersion ? <Spinner className="size-3.5" /> : <TrashIcon className="size-4" />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalCore>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -574,7 +574,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section className="nodedc-beam-peek-viewer-card" aria-label="BIM Viewer">
|
<section className="nodedc-beam-peek-viewer-card" aria-label="Beam 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 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 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="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
|
||||||
|
|
@ -617,11 +617,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
|
|
||||||
<iframe
|
<iframe
|
||||||
key={beamPeekViewer.viewerUrl}
|
key={beamPeekViewer.viewerUrl}
|
||||||
src={beamPeekViewer.viewerUrl}
|
src={beamPeekViewer.viewerUrl}
|
||||||
title={`BIM Viewer: ${beamPeekViewer.fileName}`}
|
title={`Beam Viewer: ${beamPeekViewer.fileName}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
|
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||||
allow="fullscreen"
|
allow="fullscreen"
|
||||||
|
|
|
||||||
|
|
@ -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 tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{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 tracking-normal font-semibold text-primary">{value}</div>
|
<div className="text-3xl font-semibold tracking-normal 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 tracking-[0.16em] text-tertiary uppercase">{project.identifier}</span>
|
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{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,9 +106,7 @@ 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}>
|
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
|
|
@ -127,7 +125,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 tracking-[0.16em] text-tertiary uppercase">
|
<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">
|
||||||
<span>Проект</span>
|
<span>Проект</span>
|
||||||
<span>Файлы</span>
|
<span>Файлы</span>
|
||||||
<span>Blob</span>
|
<span>Blob</span>
|
||||||
|
|
@ -175,7 +173,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: "Квота не сохранена",
|
||||||
|
|
@ -202,7 +200,6 @@ 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">
|
||||||
{isEnabled ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||||
<input
|
<input
|
||||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||||
|
|
@ -220,12 +217,6 @@ const ProjectQuotaControl = (props: {
|
||||||
/>
|
/>
|
||||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
|
|
||||||
Без лимита
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{project.quota_enabled && (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 truncate text-[11px] text-tertiary",
|
"mt-1 truncate text-[11px] text-tertiary",
|
||||||
|
|
@ -233,9 +224,10 @@ const ProjectQuotaControl = (props: {
|
||||||
project.quota_exceeded && "text-red-200"
|
project.quota_exceeded && "text-red-200"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
|
{project.quota_enabled
|
||||||
|
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
|
||||||
|
: "без лимита"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -277,8 +269,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 tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||||
<div className="text-2xl tracking-normal mt-3 font-semibold text-primary">{value}</div>
|
<div className="mt-3 text-2xl font-semibold tracking-normal 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">
|
||||||
|
|
@ -308,7 +300,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||||
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
|
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
|
||||||
);
|
);
|
||||||
|
|
||||||
const projects = [...(data?.projects ?? [])].toSorted((a, b) => b.logical_size - a.logical_size);
|
const projects = [...(data?.projects ?? [])].sort((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) => {
|
||||||
|
|
@ -321,7 +313,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: "Действие не выполнено",
|
||||||
|
|
@ -341,14 +333,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">
|
||||||
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
|
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="nodedc-settings-card bg-red-500/10 text-red-200 px-5 py-4 text-14">
|
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200">
|
||||||
Не удалось загрузить данные хранилища.
|
Не удалось загрузить данные хранилища.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -423,7 +415,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 tracking-normal font-semibold text-primary">Обслуживание</h2>
|
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2>
|
||||||
<p className="mt-1 text-13 text-secondary">
|
<p className="mt-1 text-13 text-secondary">
|
||||||
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -478,7 +470,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 tracking-normal font-semibold text-primary">Проекты</h2>
|
<h2 className="text-lg font-semibold tracking-normal 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">
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,8 @@ import type {
|
||||||
import { EIssueServiceType } from "@plane/types";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import {
|
import {
|
||||||
deleteBeamModelAsset,
|
|
||||||
deleteBeamModelVersion,
|
|
||||||
getBeamModelVersionRecords,
|
|
||||||
getBeamModelMimeType,
|
getBeamModelMimeType,
|
||||||
getBeamViewerAttachment,
|
|
||||||
getBeamViewerVersionNumber,
|
|
||||||
isBeamModelFile,
|
isBeamModelFile,
|
||||||
mergeBeamModelVersionRecords,
|
|
||||||
type TBeamModelVersionRecord,
|
|
||||||
type TBeamViewerAttachment,
|
type TBeamViewerAttachment,
|
||||||
uploadBeamModelFile,
|
uploadBeamModelFile,
|
||||||
} from "@/helpers/beam-viewer";
|
} from "@/helpers/beam-viewer";
|
||||||
|
|
@ -88,12 +81,11 @@ export class IssueAttachmentService extends APIService {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
issueId: string,
|
issueId: string,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
beamViewer: TBeamViewerAttachment,
|
beamViewer: TBeamViewerAttachment
|
||||||
attributes: Record<string, unknown> = {}
|
|
||||||
): Promise<TIssueAttachment> {
|
): Promise<TIssueAttachment> {
|
||||||
return this.patch(
|
return this.patch(
|
||||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
||||||
{ ...attributes, beamViewer }
|
{ beamViewer }
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -101,107 +93,6 @@ export class IssueAttachmentService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
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,
|
||||||
|
|
@ -276,16 +167,8 @@ 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}/`
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,401 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.toSorted((firstId, secondId) => {
|
[...attachmentIds].sort((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,13 +232,11 @@ 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(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,30 @@
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { TIssueAttachment } from "@plane/types";
|
import type { TIssueAttachment } from "@plane/types";
|
||||||
|
|
||||||
export type TBeamViewerConversion = {
|
export type TBeamViewerAttachment = {
|
||||||
|
backend: "beam-viewer-local" | "beam-viewer";
|
||||||
|
conversion?: {
|
||||||
artifactSrc?: string;
|
artifactSrc?: string;
|
||||||
artifactType?: string;
|
artifactType?: string;
|
||||||
componentTreeRequired: boolean;
|
componentTreeRequired: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
metadataSrc?: string;
|
metadataSrc?: string;
|
||||||
size?: number;
|
|
||||||
sourceFormat: string;
|
sourceFormat: string;
|
||||||
sourceSrc?: string;
|
sourceSrc?: string;
|
||||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
targetFormat: "glb" | "xkt";
|
targetFormat: "glb" | "xkt";
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TBeamModelVersionRecord = {
|
|
||||||
assetId?: string;
|
|
||||||
conversion?: TBeamViewerConversion;
|
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
previewAvailable: boolean;
|
previewAvailable: boolean;
|
||||||
sha256?: string;
|
|
||||||
size: number;
|
|
||||||
src: string;
|
src: string;
|
||||||
type: string;
|
type: string;
|
||||||
uploadedBy?: string;
|
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
version: number;
|
viewerUrl?: string;
|
||||||
versionId?: string;
|
|
||||||
viewerUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TBeamViewerAttachment = {
|
|
||||||
assetId?: string;
|
|
||||||
backend: "beam-viewer-local" | "beam-viewer";
|
|
||||||
conversion?: TBeamViewerConversion;
|
|
||||||
downloadUrl: string;
|
|
||||||
originalFilename: string;
|
|
||||||
previewAvailable: boolean;
|
|
||||||
sha256?: string;
|
|
||||||
src: string;
|
|
||||||
type: string;
|
|
||||||
uploadedBy?: string;
|
|
||||||
uploadedAt: string;
|
|
||||||
version?: number;
|
|
||||||
versionId?: string;
|
|
||||||
versions?: TBeamModelVersionRecord[];
|
|
||||||
viewerUrl?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TBeamConversionStatus = {
|
export type TBeamConversionStatus = {
|
||||||
assetId?: string;
|
|
||||||
artifactSrc?: string;
|
artifactSrc?: string;
|
||||||
artifactType?: string;
|
artifactType?: string;
|
||||||
artifactUrl?: string;
|
artifactUrl?: string;
|
||||||
|
|
@ -61,15 +34,11 @@ export type TBeamConversionStatus = {
|
||||||
message?: string;
|
message?: string;
|
||||||
metadataSrc?: string;
|
metadataSrc?: string;
|
||||||
metadataUrl?: string;
|
metadataUrl?: string;
|
||||||
sha256?: string;
|
|
||||||
sourceFormat?: string;
|
sourceFormat?: string;
|
||||||
sourceSrc?: string;
|
sourceSrc?: string;
|
||||||
size?: number;
|
|
||||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
targetFormat?: "glb" | "xkt";
|
targetFormat?: "glb" | "xkt";
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
version?: number;
|
|
||||||
versionId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
|
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
|
||||||
|
|
@ -191,120 +160,13 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
|
||||||
return beamViewer;
|
return beamViewer;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAttachmentVersionNumber = (attachment: TIssueAttachment | undefined): number => {
|
|
||||||
const rawVersion = attachment?.attributes.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,
|
|
||||||
sha256: beamViewer.sha256,
|
|
||||||
size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0),
|
|
||||||
src: beamViewer.src,
|
|
||||||
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))
|
|
||||||
.toSorted((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].toSorted(
|
|
||||||
(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?.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> => {
|
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
|
||||||
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||||
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||||
|
|
||||||
const response = await fetch(statusUrl.toString());
|
const response = await fetch(statusUrl.toString());
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
|
throw new Error(`Beam conversion status failed: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as TBeamConversionStatus;
|
const payload = (await response.json()) as TBeamConversionStatus;
|
||||||
|
|
@ -318,19 +180,16 @@ export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachmen
|
||||||
export const uploadBeamModelFile = (
|
export const uploadBeamModelFile = (
|
||||||
file: File,
|
file: File,
|
||||||
options: {
|
options: {
|
||||||
assetId?: string;
|
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
uploadedBy?: string;
|
|
||||||
version?: number;
|
|
||||||
workspaceSlug?: string;
|
workspaceSlug?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TBeamViewerAttachment> =>
|
): Promise<TBeamViewerAttachment> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const type = getBeamModelTypeFromName(file.name);
|
const type = getBeamModelTypeFromName(file.name);
|
||||||
if (!type) {
|
if (!type) {
|
||||||
reject(new Error("Формат модели не поддерживается BIM Viewer"));
|
reject(new Error("Формат модели не поддерживается Beam Viewer"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||||
|
|
@ -342,81 +201,64 @@ export const uploadBeamModelFile = (
|
||||||
"projectId",
|
"projectId",
|
||||||
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
|
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();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", uploadUrl.toString());
|
xhr.open("POST", uploadUrl.toString());
|
||||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||||
xhr.upload.addEventListener("progress", (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (!event.lengthComputable || !options.onUploadProgress) return;
|
if (!event.lengthComputable || !options.onUploadProgress) return;
|
||||||
options.onUploadProgress({
|
options.onUploadProgress({
|
||||||
loaded: event.loaded,
|
loaded: event.loaded,
|
||||||
progress: event.loaded / event.total,
|
progress: event.loaded / event.total,
|
||||||
total: event.total,
|
total: event.total,
|
||||||
} as AxiosProgressEvent);
|
} as AxiosProgressEvent);
|
||||||
});
|
};
|
||||||
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
|
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
|
||||||
xhr.addEventListener("load", () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status < 200 || xhr.status >= 300) {
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
|
reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText) as {
|
const response = JSON.parse(xhr.responseText) as {
|
||||||
assetId?: string;
|
|
||||||
conversion?: TBeamViewerAttachment["conversion"];
|
conversion?: TBeamViewerAttachment["conversion"];
|
||||||
originalFilename?: string;
|
|
||||||
sha256?: string;
|
|
||||||
src?: string;
|
src?: string;
|
||||||
uploadedAt?: string;
|
|
||||||
version?: number;
|
|
||||||
versionId?: string;
|
|
||||||
};
|
};
|
||||||
if (!response.src) {
|
if (!response.src) {
|
||||||
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
|
reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
||||||
const baseAttachment: TBeamViewerAttachment = {
|
const baseAttachment: TBeamViewerAttachment = {
|
||||||
assetId: response.assetId ?? options.assetId,
|
|
||||||
backend: "beam-viewer-local",
|
backend: "beam-viewer-local",
|
||||||
downloadUrl: downloadUrl.toString(),
|
downloadUrl: downloadUrl.toString(),
|
||||||
originalFilename: response.originalFilename || file.name,
|
originalFilename: file.name,
|
||||||
previewAvailable: !!directViewerType,
|
previewAvailable: !!directViewerType,
|
||||||
sha256: response.sha256,
|
|
||||||
src: downloadUrl.toString(),
|
src: downloadUrl.toString(),
|
||||||
type,
|
type,
|
||||||
uploadedBy: options.uploadedBy,
|
uploadedAt: new Date().toISOString(),
|
||||||
uploadedAt: response.uploadedAt || new Date().toISOString(),
|
|
||||||
version: response.version ?? options.version ?? 1,
|
|
||||||
versionId: response.versionId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!directViewerType) {
|
if (!directViewerType) {
|
||||||
const nextAttachment: TBeamViewerAttachment = {
|
resolve({
|
||||||
...baseAttachment,
|
...baseAttachment,
|
||||||
conversion: {
|
conversion: {
|
||||||
...response.conversion,
|
...(response.conversion ?? {}),
|
||||||
componentTreeRequired: true,
|
componentTreeRequired: true,
|
||||||
message:
|
message:
|
||||||
response.conversion?.message ??
|
response.conversion?.message ??
|
||||||
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
|
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
|
||||||
sourceFormat: type,
|
sourceFormat: type,
|
||||||
status: response.conversion?.status ?? "conversion_required",
|
status: response.conversion?.status ?? "conversion_required",
|
||||||
targetFormat: response.conversion?.targetFormat ?? "xkt",
|
targetFormat: response.conversion?.targetFormat ?? "glb",
|
||||||
},
|
},
|
||||||
};
|
|
||||||
resolve({
|
|
||||||
...nextAttachment,
|
|
||||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAttachment: TBeamViewerAttachment = {
|
resolve({
|
||||||
...baseAttachment,
|
...baseAttachment,
|
||||||
viewerUrl: buildBeamViewerUrl({
|
viewerUrl: buildBeamViewerUrl({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|
@ -424,38 +266,10 @@ export const uploadBeamModelFile = (
|
||||||
src: downloadUrl.toString(),
|
src: downloadUrl.toString(),
|
||||||
type: directViewerType,
|
type: directViewerType,
|
||||||
}),
|
}),
|
||||||
};
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
...nextAttachment,
|
|
||||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
reject(new Error("BIM Viewer вернул некорректный ответ"));
|
reject(new Error("Beam 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",
|
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
xhr.send(file);
|
||||||
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,53 +6,22 @@
|
||||||
|
|
||||||
import type { TFileSignedURLResponse } from "../file";
|
import type { TFileSignedURLResponse } from "../file";
|
||||||
|
|
||||||
export type TBeamViewerConversion = {
|
export type TBeamViewerAttachment = {
|
||||||
artifactSrc?: string;
|
backend: "beam-viewer-local" | "beam-viewer";
|
||||||
artifactType?: string;
|
conversion?: {
|
||||||
componentTreeRequired: boolean;
|
componentTreeRequired: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
metadataSrc?: string;
|
|
||||||
size?: number;
|
|
||||||
sourceFormat: string;
|
sourceFormat: string;
|
||||||
sourceSrc?: string;
|
|
||||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
targetFormat: "glb" | "xkt";
|
targetFormat: "glb" | "xkt";
|
||||||
updatedAt?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TBeamModelVersionRecord = {
|
|
||||||
assetId?: string;
|
|
||||||
conversion?: TBeamViewerConversion;
|
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
previewAvailable: boolean;
|
previewAvailable: boolean;
|
||||||
sha256?: string;
|
|
||||||
size: number;
|
|
||||||
src: string;
|
src: string;
|
||||||
type: string;
|
type: string;
|
||||||
uploadedBy?: string;
|
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
version: number;
|
viewerUrl?: string;
|
||||||
versionId?: string;
|
|
||||||
viewerUrl?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TBeamViewerAttachment = {
|
|
||||||
assetId?: string;
|
|
||||||
backend: "beam-viewer-local" | "beam-viewer";
|
|
||||||
conversion?: TBeamViewerConversion;
|
|
||||||
downloadUrl: string;
|
|
||||||
originalFilename: string;
|
|
||||||
previewAvailable: boolean;
|
|
||||||
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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue