Compare commits
10 Commits
2ad9e7f85c
...
3806848b07
| Author | SHA1 | Date |
|---|---|---|
|
|
3806848b07 | |
|
|
8b1e356494 | |
|
|
fcd014e26d | |
|
|
b1d19cf5df | |
|
|
342df60c24 | |
|
|
097012ae31 | |
|
|
71979526cf | |
|
|
6c119aae04 | |
|
|
ee4a959a53 | |
|
|
14caa50088 |
|
|
@ -32,6 +32,19 @@ Backend integration:
|
|||
- `PLANE_NODEDC_WORKSPACE_POLICY_URL`
|
||||
- `PLANE_NODEDC_ACCESS_CHECK_URL`
|
||||
- `PLANE_NODEDC_ACCESS_TOKEN`
|
||||
- `NODEDC_ENGINE_DOCKER_NETWORK`
|
||||
- `PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL`
|
||||
- `PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN`
|
||||
|
||||
AI Workspace Console uses the shared Platform `ai-workspace-assistant` service. Tasker does not connect to AI Workspace Hub directly. The Tasker backend containers must be attached to the same Docker network as Platform/Engine through `NODEDC_ENGINE_DOCKER_NETWORK`; on NAS this is currently `nodedc-demo_default`, while local development uses `nodedc-platform_default`.
|
||||
|
||||
Use the internal service URL on that shared network:
|
||||
|
||||
```env
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
|
||||
```
|
||||
|
||||
The Assistant generates Codex worker installers with the public Hub endpoint `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`. Do not point Tasker directly at the Hub and do not expose Assistant as a public Nginx/DSM route.
|
||||
|
||||
Production secrets remain in NAS env files or a secret store. They must not be committed.
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ x-app-env: &app-env
|
|||
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL:-}
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN:-}
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS:-8}
|
||||
GUNICORN_WORKERS: 1
|
||||
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
||||
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
||||
|
|
@ -159,6 +162,9 @@ services:
|
|||
- plane-db
|
||||
- plane-redis
|
||||
- plane-mq
|
||||
networks:
|
||||
- default
|
||||
- nodedc-engine
|
||||
|
||||
worker:
|
||||
image: nodedc/plane-backend:local
|
||||
|
|
@ -179,6 +185,9 @@ services:
|
|||
- plane-db
|
||||
- plane-redis
|
||||
- plane-mq
|
||||
networks:
|
||||
- default
|
||||
- nodedc-engine
|
||||
|
||||
beat-worker:
|
||||
image: nodedc/plane-backend:local
|
||||
|
|
@ -199,6 +208,9 @@ services:
|
|||
- plane-db
|
||||
- plane-redis
|
||||
- plane-mq
|
||||
networks:
|
||||
- default
|
||||
- nodedc-engine
|
||||
|
||||
migrator:
|
||||
image: nodedc/plane-backend:local
|
||||
|
|
@ -217,6 +229,9 @@ services:
|
|||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
networks:
|
||||
- default
|
||||
- nodedc-engine
|
||||
|
||||
# Comment this if you already have a database running
|
||||
plane-db:
|
||||
|
|
@ -304,3 +319,9 @@ volumes:
|
|||
rabbitmq_data:
|
||||
proxy_config:
|
||||
proxy_data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
nodedc-engine:
|
||||
external: true
|
||||
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}
|
||||
|
|
|
|||
|
|
@ -120,3 +120,7 @@ PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
|||
PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
||||
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-platform_default
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=dev-ai-workspace-assistant-token
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8
|
||||
|
|
|
|||
|
|
@ -88,3 +88,9 @@ LIVE_SERVER_SECRET_KEY=CHANGE_ME_BEFORE_PRODUCTION
|
|||
DOCKERHUB_USER=makeplane
|
||||
PULL_POLICY=if_not_present
|
||||
CUSTOM_BUILD=false
|
||||
|
||||
# NODE.DC AI Workspace Assistant
|
||||
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-platform_default
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8
|
||||
|
|
|
|||
|
|
@ -97,4 +97,8 @@ PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
|||
PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token
|
||||
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5
|
||||
NODEDC_ENGINE_DOCKER_NETWORK=nodedc-demo_default
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN=replace-with-ai-workspace-assistant-token
|
||||
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS=8
|
||||
PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# See the LICENSE file for details.
|
||||
|
||||
from .analytic import urlpatterns as analytic_urls
|
||||
from .ai_workspace import urlpatterns as ai_workspace_urls
|
||||
from .api import urlpatterns as api_urls
|
||||
from .asset import urlpatterns as asset_urls
|
||||
from .codex_agents import urlpatterns as codex_agent_urls
|
||||
|
|
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
|
|||
|
||||
urlpatterns = [
|
||||
*analytic_urls,
|
||||
*ai_workspace_urls,
|
||||
*asset_urls,
|
||||
*codex_agent_urls,
|
||||
*cycle_urls,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
# 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,6 +174,20 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint
|
|||
|
||||
from .api import ApiTokenEndpoint
|
||||
|
||||
from .ai_workspace import (
|
||||
AIWorkspaceExecutorCheckEndpoint,
|
||||
AIWorkspaceExecutorDetailEndpoint,
|
||||
AIWorkspaceExecutorEventsEndpoint,
|
||||
AIWorkspaceExecutorListEndpoint,
|
||||
AIWorkspaceExecutorSelectEndpoint,
|
||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||
AIWorkspaceSettingsEndpoint,
|
||||
AIWorkspaceThreadDispatchEndpoint,
|
||||
AIWorkspaceThreadDetailEndpoint,
|
||||
AIWorkspaceThreadListEndpoint,
|
||||
AIWorkspaceThreadMessagesEndpoint,
|
||||
)
|
||||
|
||||
from .codex_agents import (
|
||||
CodexAgentDetailEndpoint,
|
||||
CodexAgentGrantListEndpoint,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,708 @@
|
|||
# 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")
|
||||
if not isinstance(existing_beam_viewer, dict):
|
||||
return Response(
|
||||
{"error": "The attachment is not a Beam Viewer reference.", "status": False},
|
||||
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -271,6 +271,9 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
|||
**existing_beam_viewer,
|
||||
**beam_viewer,
|
||||
}
|
||||
for attribute_key in ("name", "size", "type", "version"):
|
||||
if attribute_key in request.data:
|
||||
attributes[attribute_key] = request.data.get(attribute_key)
|
||||
issue_attachment.attributes = attributes
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { observer } from "mobx-react";
|
|||
import { cn } from "@plane/utils";
|
||||
import { AppRailRoot } from "@/components/navigation";
|
||||
import { useAppRailVisibility } from "@/lib/app-rail";
|
||||
import { AIWorkspaceGlobalControl } from "@/components/ai-workspace/global-control";
|
||||
import { VoiceTaskerGlobalControl } from "@/components/voice-tasker/global-control";
|
||||
// local imports
|
||||
import { TopNavigationRoot } from "../navigations";
|
||||
|
|
@ -40,6 +41,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
{workspaceSlug && <AIWorkspaceGlobalControl workspaceSlug={workspaceSlug} />}
|
||||
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// components
|
||||
import { AIWorkspaceProductConsole } from "@/components/ai-workspace/product-console";
|
||||
|
||||
type TAIWorkspaceGlobalControlProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function AIWorkspaceGlobalControl({ workspaceSlug }: TAIWorkspaceGlobalControlProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const updateDockSlot = () => {
|
||||
setDockSlot(
|
||||
document.querySelector("[data-nodedc-voice-task-toolbar-slot]") ??
|
||||
document.querySelector("[data-nodedc-voice-task-dock-slot]")
|
||||
);
|
||||
};
|
||||
|
||||
updateDockSlot();
|
||||
const observer = new MutationObserver(updateDockSlot);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dockSlot
|
||||
? createPortal(
|
||||
<Tooltip tooltipContent="AI Workspace" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-bottom-dock-voice-button nodedc-bottom-dock-ai-workspace-button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="AI Workspace"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
</button>
|
||||
</Tooltip>,
|
||||
dockSlot
|
||||
)
|
||||
: null}
|
||||
|
||||
<AIWorkspaceProductConsole open={isOpen} workspaceSlug={workspaceSlug} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,774 @@
|
|||
/**
|
||||
* 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>
|
||||
{isListView ? (
|
||||
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
||||
<div className="min-w-[760px]">
|
||||
<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 className="min-w-[840px]">
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
||||
<div>Файл</div>
|
||||
<div>Дата</div>
|
||||
<div>Версия</div>
|
||||
|
|
@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
|||
{uploadStatus?.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
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"
|
||||
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-secondary">{status.name}</div>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@
|
|||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||
import { TrashIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
|
|
@ -18,7 +19,7 @@ import { EIssueServiceType } from "@plane/types";
|
|||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { AlertTriangle, Box, Download, Eye, ImageIcon, Play, X } from "lucide-react";
|
||||
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
|
|
@ -27,8 +28,12 @@ import {
|
|||
buildBeamViewerUrl,
|
||||
dispatchBeamViewerOpenEvent,
|
||||
fetchBeamConversionStatus,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamVersionViewerUrl,
|
||||
getBeamViewerAttachment,
|
||||
isBeamModelFile,
|
||||
syncCurrentBeamVersionRecord,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
type TBeamConversionStatus,
|
||||
} from "@/helpers/beam-viewer";
|
||||
|
|
@ -37,6 +42,7 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach
|
|||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { IssueAttachmentService } from "@/services/issue";
|
||||
|
||||
|
|
@ -61,7 +67,7 @@ const appendSearchParam = (url: string | undefined, key: string, value: string):
|
|||
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
||||
};
|
||||
|
||||
const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => {
|
||||
const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
|
|
@ -75,7 +81,10 @@ const getUsableBeamViewerUrl = (url: string | undefined): string | undefined =>
|
|||
}
|
||||
};
|
||||
|
||||
const withBeamViewerSettingsSrc = (url: string | undefined, settingsSrc: string | undefined): string | undefined => {
|
||||
const withBeamViewerSettingsSrc = (
|
||||
url: string | null | undefined,
|
||||
settingsSrc: string | undefined
|
||||
): string | undefined => {
|
||||
if (!url || !settingsSrc) return url;
|
||||
try {
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
|
|
@ -99,9 +108,20 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
|
|||
if (status === "ready") return "Модель готова";
|
||||
if (status === "failed") return "Ошибка дерева";
|
||||
if (status === "processing") return "Собираем дерево";
|
||||
if (status === "conversion_required") return "Ожидает очереди";
|
||||
return "Ожидает дерево";
|
||||
};
|
||||
|
||||
const sanitizeBeamStatusMessage = (message: string | undefined): string | undefined =>
|
||||
message
|
||||
?.replace(/XKT-просмотр/g, "просмотр")
|
||||
.replace(/XKT и дерева/g, "модели и дерева")
|
||||
.replace(/GLB\/XKT/g, "модели")
|
||||
.replace(/XKT/g, "просмотра");
|
||||
|
||||
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
|
||||
version.versionId || `version-${version.version}`;
|
||||
|
||||
const buildSyncedBeamViewerAttachment = (
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
status: TBeamConversionStatus,
|
||||
|
|
@ -112,9 +132,13 @@ const buildSyncedBeamViewerAttachment = (
|
|||
const artifactType = status.artifactType || status.targetFormat || "gltf";
|
||||
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
|
||||
|
||||
return {
|
||||
return syncCurrentBeamVersionRecord({
|
||||
...beamViewer,
|
||||
assetId: beamViewer.assetId || status.assetId,
|
||||
previewAvailable: true,
|
||||
sha256: beamViewer.sha256 || status.sha256,
|
||||
version: beamViewer.version || status.version,
|
||||
versionId: beamViewer.versionId || status.versionId,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: fullFileName,
|
||||
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
|
||||
|
|
@ -122,19 +146,20 @@ const buildSyncedBeamViewerAttachment = (
|
|||
type: artifactType,
|
||||
}),
|
||||
conversion: {
|
||||
...(beamViewer.conversion ?? {}),
|
||||
...beamViewer.conversion,
|
||||
artifactSrc: status.artifactSrc,
|
||||
artifactType,
|
||||
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
|
||||
message: status.message || beamViewer.conversion?.message,
|
||||
metadataSrc: status.metadataSrc,
|
||||
size: status.size ?? beamViewer.conversion?.size,
|
||||
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
|
||||
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
|
||||
status: "ready",
|
||||
targetFormat,
|
||||
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
||||
|
|
@ -151,6 +176,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
} = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
fetchAttachments,
|
||||
|
|
@ -170,7 +196,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
|
||||
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
|
||||
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
|
||||
const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl);
|
||||
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
||||
const storedModelViewerUrl =
|
||||
beamEffectiveStatus === "ready" ? getUsableBeamViewerUrl(beamViewer?.viewerUrl) : undefined;
|
||||
const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
|
||||
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
|
||||
const modelViewerUrl =
|
||||
|
|
@ -185,24 +213,37 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||
const isBeamModel = isBeamModelFile(fullFileName);
|
||||
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
||||
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
|
||||
const beamStatusErrorMessage =
|
||||
beamConversionError ||
|
||||
beamConversionStatus?.error ||
|
||||
(beamConversionStatus?.status === "ready" && !modelViewerUrl
|
||||
? "Beam вернул статус готовности, но не вернул viewer-артефакт."
|
||||
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
|
||||
: null);
|
||||
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
|
||||
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
|
||||
const beamStatusTooltipContent = isBeamConversionFailed
|
||||
const rawBeamStatusTooltipContent = isBeamConversionFailed
|
||||
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
|
||||
: beamConversionStatus?.message ||
|
||||
beamViewer?.conversion?.message ||
|
||||
"Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.";
|
||||
"Оригинальная модель загружена в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов.";
|
||||
const beamStatusTooltipContent = sanitizeBeamStatusMessage(rawBeamStatusTooltipContent) || "";
|
||||
const beamStatusTooltipMessage =
|
||||
beamStatusTooltipContent &&
|
||||
!["ready", "processing", "failed", "conversion_required"].includes(beamStatusTooltipContent)
|
||||
? beamStatusTooltipContent
|
||||
: "";
|
||||
const beamStatusIconTooltipContent = beamConversionStatusLabel
|
||||
? `${beamConversionStatusLabel}${beamStatusTooltipMessage ? `. ${beamStatusTooltipMessage}` : ""}`
|
||||
: beamStatusTooltipContent;
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
||||
const [deletingVersionKey, setDeletingVersionKey] = useState<string | null>(null);
|
||||
const [isVersionUploading, setIsVersionUploading] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
const rawVersion = attachment?.attributes.version;
|
||||
const versionUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const beamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]);
|
||||
const rawVersion = beamViewer?.version ?? attachment?.attributes.version;
|
||||
const versionLabel =
|
||||
typeof rawVersion === "number"
|
||||
? `v${rawVersion}`
|
||||
|
|
@ -211,8 +252,14 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
? rawVersion
|
||||
: `v${rawVersion}`
|
||||
: "v1";
|
||||
const currentVersionId = beamViewer?.versionId;
|
||||
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
|
||||
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
|
||||
const getVersionAuthorName = (version: TBeamModelVersionRecord): string => {
|
||||
const userId = version.uploadedBy || attachment?.created_by;
|
||||
if (!userId) return "—";
|
||||
return getUserDetails(userId)?.display_name ?? "—";
|
||||
};
|
||||
const openModelViewer = () => {
|
||||
if (!modelViewerUrl) return;
|
||||
dispatchBeamViewerOpenEvent({
|
||||
|
|
@ -224,6 +271,88 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
viewerUrl: modelViewerUrl,
|
||||
});
|
||||
};
|
||||
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
|
||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
||||
if (!viewerUrl) return;
|
||||
|
||||
setIsVersionHistoryOpen(false);
|
||||
dispatchBeamViewerOpenEvent({
|
||||
downloadUrl: version.downloadUrl,
|
||||
fileExtension: getFileExtension(version.originalFilename),
|
||||
fileName: version.originalFilename,
|
||||
fileSize: version.size || version.conversion?.size || 0,
|
||||
issueId,
|
||||
viewerUrl,
|
||||
});
|
||||
};
|
||||
const startVersionUpload = () => {
|
||||
versionUploadInputRef.current?.click();
|
||||
};
|
||||
const handleVersionFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || !attachment || !beamViewer) return;
|
||||
|
||||
if (!isBeamModelFile(file.name)) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Формат не поддержан",
|
||||
message: "Для новой версии нужно выбрать файл модели, который поддерживает BIM Viewer.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVersionUploading(true);
|
||||
try {
|
||||
await attachmentService.uploadBeamIssueAttachmentVersion(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment,
|
||||
file,
|
||||
undefined,
|
||||
currentUser?.id
|
||||
);
|
||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Версия добавлена",
|
||||
message: `${file.name} загружен как новая версия модели.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in uploading Beam model version:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Версия не загружена",
|
||||
message: error instanceof Error ? error.message : "Не удалось загрузить новую версию модели.",
|
||||
});
|
||||
} finally {
|
||||
setIsVersionUploading(false);
|
||||
}
|
||||
};
|
||||
const handleDeleteVersion = async (version: TBeamModelVersionRecord) => {
|
||||
if (!attachment || !beamViewer) return;
|
||||
|
||||
const versionKey = getBeamVersionRecordKey(version);
|
||||
setDeletingVersionKey(versionKey);
|
||||
try {
|
||||
await attachmentService.deleteBeamIssueAttachmentVersion(workspaceSlug, projectId, issueId, attachment, version);
|
||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Версия удалена",
|
||||
message: `v${version.version} удалена из истории модели.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Версия не удалена",
|
||||
message: error instanceof Error ? error.message : "Не удалось удалить версию модели.",
|
||||
});
|
||||
} finally {
|
||||
setDeletingVersionKey(null);
|
||||
}
|
||||
};
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
...(modelViewerUrl
|
||||
? [
|
||||
|
|
@ -235,6 +364,23 @@ 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
|
||||
? [
|
||||
{
|
||||
|
|
@ -258,9 +404,19 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
];
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const versionUploadInput = beamViewer ? (
|
||||
<input
|
||||
ref={versionUploadInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={isVersionUploading}
|
||||
onChange={handleVersionFileChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return;
|
||||
if (!beamViewer || !beamViewer.src) return;
|
||||
if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return;
|
||||
|
||||
let isMounted = true;
|
||||
let timeoutId: number | undefined;
|
||||
|
|
@ -283,7 +439,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
}
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус Beam.");
|
||||
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели.");
|
||||
timeoutId = window.setTimeout(pollStatus, 8000);
|
||||
}
|
||||
};
|
||||
|
|
@ -311,7 +467,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
if (viewMode === "list") {
|
||||
return (
|
||||
<>
|
||||
<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]">
|
||||
{versionUploadInput}
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-3 text-left"
|
||||
|
|
@ -321,6 +478,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
if (modelViewerUrl) openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
|
||||
isMobile={isMobile}
|
||||
position={isBeamModel ? "left" : "top"}
|
||||
sideOffset={isBeamModel ? 6 : 10}
|
||||
className={isBeamModel ? "z-[220]" : ""}
|
||||
>
|
||||
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
||||
{isBeamModel ? (
|
||||
|
|
@ -341,6 +505,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<ImageIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
|
||||
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
|
||||
|
|
@ -349,24 +514,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
|
||||
<span className="size-1 rounded-full bg-layer-1" />
|
||||
<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>
|
||||
</button>
|
||||
|
|
@ -374,6 +521,40 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<div className="text-secondary">{versionLabel}</div>
|
||||
<div className="truncate text-tertiary">{uploadedBy}</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{beamViewer && (
|
||||
<Tooltip tooltipContent="История версий" isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsVersionHistoryOpen(true);
|
||||
}}
|
||||
>
|
||||
<History className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{beamViewer && (
|
||||
<Tooltip
|
||||
tooltipContent={isVersionUploading ? "Загружаем версию..." : "Добавить версию"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={isVersionUploading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startVersionUpload();
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(modelViewerUrl || previewURL) && (
|
||||
<Tooltip
|
||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
|
|
@ -446,12 +627,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
size={attachment.attributes.size}
|
||||
/>
|
||||
</ModalCore>
|
||||
<BeamVersionHistoryModal
|
||||
currentVersionId={currentVersionId}
|
||||
deletingVersionKey={deletingVersionKey}
|
||||
fileName={fullFileName}
|
||||
getVersionAuthorName={getVersionAuthorName}
|
||||
isOpen={isVersionHistoryOpen}
|
||||
onClose={() => setIsVersionHistoryOpen(false)}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onOpenVersion={openBeamVersionViewer}
|
||||
versions={beamVersions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{versionUploadInput}
|
||||
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -571,6 +764,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
size={attachment.attributes.size}
|
||||
/>
|
||||
</ModalCore>
|
||||
<BeamVersionHistoryModal
|
||||
currentVersionId={currentVersionId}
|
||||
deletingVersionKey={deletingVersionKey}
|
||||
fileName={fullFileName}
|
||||
getVersionAuthorName={getVersionAuthorName}
|
||||
isOpen={isVersionHistoryOpen}
|
||||
onClose={() => setIsVersionHistoryOpen(false)}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onOpenVersion={openBeamVersionViewer}
|
||||
versions={beamVersions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -674,7 +878,7 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
|||
<div className="mt-2 text-14 text-secondary">
|
||||
{isBeamConversionFailed
|
||||
? beamStatusTooltipContent
|
||||
: "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."}
|
||||
: "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadUrl && (
|
||||
|
|
@ -702,3 +906,150 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
|||
</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
|
||||
}
|
||||
>
|
||||
<section className="nodedc-beam-peek-viewer-card" aria-label="Beam Viewer">
|
||||
<section className="nodedc-beam-peek-viewer-card" aria-label="BIM Viewer">
|
||||
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
|
||||
<div aria-hidden="true" />
|
||||
<div className="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
|
||||
|
|
@ -617,10 +617,11 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
|
||||
<iframe
|
||||
key={beamPeekViewer.viewerUrl}
|
||||
src={beamPeekViewer.viewerUrl}
|
||||
title={`Beam Viewer: ${beamPeekViewer.fileName}`}
|
||||
title={`BIM Viewer: ${beamPeekViewer.fileName}`}
|
||||
tabIndex={-1}
|
||||
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
allow="fullscreen"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const StatCard = (props: {
|
|||
return (
|
||||
<div className="nodedc-settings-card p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
|
||||
|
|
@ -73,7 +73,7 @@ const StatCard = (props: {
|
|||
<Icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="text-3xl tracking-normal font-semibold text-primary">{value}</div>
|
||||
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -92,7 +92,7 @@ const ProjectStorageRow = (props: {
|
|||
<div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
|
||||
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
|
||||
<span className="mt-1 text-11 tracking-[0.16em] text-tertiary uppercase">{project.identifier}</span>
|
||||
</div>
|
||||
<StorageValue>{formatCount(project.file_count)}</StorageValue>
|
||||
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
|
||||
|
|
@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
|
|||
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
|
||||
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
|
||||
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
|
||||
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue>
|
||||
<StorageValue warning={project.stale_unuploaded_count > 0}>
|
||||
{formatCount(project.stale_unuploaded_count)}
|
||||
</StorageValue>
|
||||
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
|
|||
);
|
||||
|
||||
const ProjectStorageHeader = () => (
|
||||
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
|
||||
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||
<span>Проект</span>
|
||||
<span>Файлы</span>
|
||||
<span>Blob</span>
|
||||
|
|
@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
|
|||
title: "Квота обновлена",
|
||||
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Квота не сохранена",
|
||||
|
|
@ -200,6 +202,7 @@ const ProjectQuotaControl = (props: {
|
|||
<Database className="size-3.5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEnabled ? (
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||
|
|
@ -217,6 +220,12 @@ const ProjectQuotaControl = (props: {
|
|||
/>
|
||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
|
||||
Без лимита
|
||||
</div>
|
||||
)}
|
||||
{project.quota_enabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 truncate text-[11px] text-tertiary",
|
||||
|
|
@ -224,10 +233,9 @@ const ProjectQuotaControl = (props: {
|
|||
project.quota_exceeded && "text-red-200"
|
||||
)}
|
||||
>
|
||||
{project.quota_enabled
|
||||
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
|
||||
: "без лимита"}
|
||||
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -269,8 +277,8 @@ const MaintenanceCard = ({
|
|||
<div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
||||
<div className="text-2xl tracking-normal mt-3 font-semibold text-primary">{value}</div>
|
||||
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||
</div>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
|
||||
|
|
@ -300,7 +308,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
|
||||
);
|
||||
|
||||
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
|
||||
const projects = [...(data?.projects ?? [])].toSorted((a, b) => b.logical_size - a.logical_size);
|
||||
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
|
||||
|
||||
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
|
||||
|
|
@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
title: "Хранилище обновлено",
|
||||
message: getMaintenanceResultMessage(result.action, result),
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Действие не выполнено",
|
||||
|
|
@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200">
|
||||
<div className="nodedc-settings-card bg-red-500/10 text-red-200 px-5 py-4 text-14">
|
||||
Не удалось загрузить данные хранилища.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
<section>
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2>
|
||||
<h2 className="text-lg tracking-normal font-semibold text-primary">Обслуживание</h2>
|
||||
<p className="mt-1 text-13 text-secondary">
|
||||
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
||||
</p>
|
||||
|
|
@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
<section className="nodedc-settings-card p-5">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
|
||||
<h2 className="text-lg tracking-normal font-semibold text-primary">Проекты</h2>
|
||||
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
|
||||
</div>
|
||||
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@ import type {
|
|||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import {
|
||||
deleteBeamModelAsset,
|
||||
deleteBeamModelVersion,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamModelMimeType,
|
||||
getBeamViewerAttachment,
|
||||
getBeamViewerVersionNumber,
|
||||
isBeamModelFile,
|
||||
mergeBeamModelVersionRecords,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
} from "@/helpers/beam-viewer";
|
||||
|
|
@ -81,11 +88,12 @@ export class IssueAttachmentService extends APIService {
|
|||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
beamViewer: TBeamViewerAttachment
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
attributes: Record<string, unknown> = {}
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.patch(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
||||
{ beamViewer }
|
||||
{ ...attributes, beamViewer }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
|
@ -93,6 +101,107 @@ 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(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
|
@ -167,8 +276,16 @@ export class IssueAttachmentService extends APIService {
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
assetId: string
|
||||
assetId: string,
|
||||
attachment?: TIssueAttachment
|
||||
): Promise<TIssueAttachment> {
|
||||
const beamViewer = getBeamViewerAttachment(attachment);
|
||||
if (beamViewer) {
|
||||
await deleteBeamModelAsset(beamViewer).catch((error) => {
|
||||
console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.delete(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
/**
|
||||
* 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();
|
||||
|
||||
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
||||
[...attachmentIds].sort((firstId, secondId) => {
|
||||
attachmentIds.toSorted((firstId, secondId) => {
|
||||
const firstAttachment = attachmentMap[firstId];
|
||||
const secondAttachment = attachmentMap[secondId];
|
||||
if (!firstAttachment || !secondAttachment) return 0;
|
||||
|
|
@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
|||
|
||||
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
|
||||
const issueAttachmentService = await this.getIssueAttachmentService();
|
||||
const attachment = this.attachmentMap[attachmentId];
|
||||
const response = await issueAttachmentService.deleteIssueAttachment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachmentId
|
||||
attachmentId,
|
||||
attachment
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,57 @@
|
|||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { TIssueAttachment } from "@plane/types";
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: {
|
||||
export type TBeamViewerConversion = {
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
componentTreeRequired: boolean;
|
||||
message?: string;
|
||||
metadataSrc?: string;
|
||||
size?: number;
|
||||
sourceFormat: string;
|
||||
sourceSrc?: string;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TBeamModelVersionRecord = {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: string;
|
||||
uploadedAt: string;
|
||||
viewerUrl?: string;
|
||||
version: number;
|
||||
versionId?: string;
|
||||
viewerUrl?: string | null;
|
||||
};
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
assetId?: string;
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: string;
|
||||
uploadedAt: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
versions?: TBeamModelVersionRecord[];
|
||||
viewerUrl?: string | null;
|
||||
};
|
||||
|
||||
export type TBeamConversionStatus = {
|
||||
assetId?: string;
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
artifactUrl?: string;
|
||||
|
|
@ -34,11 +61,15 @@ export type TBeamConversionStatus = {
|
|||
message?: string;
|
||||
metadataSrc?: string;
|
||||
metadataUrl?: string;
|
||||
sha256?: string;
|
||||
sourceFormat?: string;
|
||||
sourceSrc?: string;
|
||||
size?: number;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat?: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
|
||||
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
|
||||
|
|
@ -160,13 +191,120 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
|
|||
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> => {
|
||||
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
|
||||
const response = await fetch(statusUrl.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Beam conversion status failed: HTTP ${response.status}`);
|
||||
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as TBeamConversionStatus;
|
||||
|
|
@ -180,16 +318,19 @@ export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachmen
|
|||
export const uploadBeamModelFile = (
|
||||
file: File,
|
||||
options: {
|
||||
assetId?: string;
|
||||
issueId?: string;
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||
projectId?: string;
|
||||
uploadedBy?: string;
|
||||
version?: number;
|
||||
workspaceSlug?: string;
|
||||
} = {}
|
||||
): Promise<TBeamViewerAttachment> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const type = getBeamModelTypeFromName(file.name);
|
||||
if (!type) {
|
||||
reject(new Error("Формат модели не поддерживается Beam Viewer"));
|
||||
reject(new Error("Формат модели не поддерживается BIM Viewer"));
|
||||
return;
|
||||
}
|
||||
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||
|
|
@ -201,64 +342,81 @@ export const uploadBeamModelFile = (
|
|||
"projectId",
|
||||
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
|
||||
);
|
||||
if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
|
||||
if (options.version) uploadUrl.searchParams.set("version", String(options.version));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", uploadUrl.toString());
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.onprogress = (event) => {
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable || !options.onUploadProgress) return;
|
||||
options.onUploadProgress({
|
||||
loaded: event.loaded,
|
||||
progress: event.loaded / event.total,
|
||||
total: event.total,
|
||||
} as AxiosProgressEvent);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
|
||||
xhr.onload = () => {
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`));
|
||||
reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText) as {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerAttachment["conversion"];
|
||||
originalFilename?: string;
|
||||
sha256?: string;
|
||||
src?: string;
|
||||
uploadedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
if (!response.src) {
|
||||
reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
|
||||
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
||||
const baseAttachment: TBeamViewerAttachment = {
|
||||
assetId: response.assetId ?? options.assetId,
|
||||
backend: "beam-viewer-local",
|
||||
downloadUrl: downloadUrl.toString(),
|
||||
originalFilename: file.name,
|
||||
originalFilename: response.originalFilename || file.name,
|
||||
previewAvailable: !!directViewerType,
|
||||
sha256: response.sha256,
|
||||
src: downloadUrl.toString(),
|
||||
type,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: options.uploadedBy,
|
||||
uploadedAt: response.uploadedAt || new Date().toISOString(),
|
||||
version: response.version ?? options.version ?? 1,
|
||||
versionId: response.versionId,
|
||||
};
|
||||
|
||||
if (!directViewerType) {
|
||||
resolve({
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
conversion: {
|
||||
...(response.conversion ?? {}),
|
||||
...response.conversion,
|
||||
componentTreeRequired: true,
|
||||
message:
|
||||
response.conversion?.message ??
|
||||
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
|
||||
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
|
||||
sourceFormat: type,
|
||||
status: response.conversion?.status ?? "conversion_required",
|
||||
targetFormat: response.conversion?.targetFormat ?? "glb",
|
||||
targetFormat: response.conversion?.targetFormat ?? "xkt",
|
||||
},
|
||||
};
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: file.name,
|
||||
|
|
@ -266,10 +424,38 @@ export const uploadBeamModelFile = (
|
|||
src: downloadUrl.toString(),
|
||||
type: directViewerType,
|
||||
}),
|
||||
};
|
||||
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
} catch (_error) {
|
||||
reject(new Error("Beam Viewer вернул некорректный ответ"));
|
||||
reject(new Error("BIM Viewer вернул некорректный ответ"));
|
||||
}
|
||||
};
|
||||
});
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const deleteBeamStorageResource = async (
|
||||
endpoint: "/api/uploads/asset" | "/api/uploads/version",
|
||||
payload: unknown
|
||||
): Promise<void> => {
|
||||
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
|
||||
|
||||
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/version", version);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,22 +6,53 @@
|
|||
|
||||
import type { TFileSignedURLResponse } from "../file";
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: {
|
||||
export type TBeamViewerConversion = {
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
componentTreeRequired: boolean;
|
||||
message?: string;
|
||||
metadataSrc?: string;
|
||||
size?: number;
|
||||
sourceFormat: string;
|
||||
sourceSrc?: string;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TBeamModelVersionRecord = {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: string;
|
||||
uploadedAt: string;
|
||||
viewerUrl?: string;
|
||||
version: number;
|
||||
versionId?: string;
|
||||
viewerUrl?: string | null;
|
||||
};
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
assetId?: string;
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: string;
|
||||
uploadedAt: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
versions?: TBeamModelVersionRecord[];
|
||||
viewerUrl?: string | null;
|
||||
};
|
||||
|
||||
export type TIssueAttachment = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue