Compare commits

...

10 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 3806848b07 feat: complete ops ai workspace bridge 2026-06-13 08:46:55 +03:00
DCCONSTRUCTIONS 8b1e356494 fix: connect tasker backend to ai workspace network 2026-06-09 15:48:11 +03:00
DCCONSTRUCTIONS fcd014e26d fix: align ops ai workspace settings ui 2026-06-09 14:49:50 +03:00
DCCONSTRUCTIONS b1d19cf5df fix: open ai workspace console in ops 2026-06-09 14:35:34 +03:00
DCCONSTRUCTIONS 342df60c24 refactor: split ops ai workspace settings modal 2026-06-09 14:06:14 +03:00
DCCONSTRUCTIONS 097012ae31 fix: align ops ai workspace settings UI 2026-06-09 13:51:48 +03:00
DCCONSTRUCTIONS 71979526cf feat: add standalone ops ai workspace setup 2026-06-09 13:23:02 +03:00
DCCONSTRUCTIONS 6c119aae04 feat: add ops ai workspace dock control 2026-06-09 12:48:06 +03:00
DCCONSTRUCTIONS ee4a959a53 feat: proxy ops ai workspace assistant state 2026-06-09 11:52:07 +03:00
DCCONSTRUCTIONS 14caa50088 chore: checkpoint before cross assistant work 2026-06-09 11:33:47 +03:00
24 changed files with 6836 additions and 152 deletions

View File

@ -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.

View File

@ -82,6 +82,9 @@ x-app-env: &app-env
PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-}
PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-}
PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL:-}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TOKEN:-}
PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS: ${PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_TIMEOUT_SECONDS:-8}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}
@ -159,6 +162,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
worker:
image: nodedc/plane-backend:local
@ -179,6 +185,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
beat-worker:
image: nodedc/plane-backend:local
@ -199,6 +208,9 @@ services:
- plane-db
- plane-redis
- plane-mq
networks:
- default
- nodedc-engine
migrator:
image: nodedc/plane-backend:local
@ -217,6 +229,9 @@ services:
depends_on:
- plane-db
- plane-redis
networks:
- default
- nodedc-engine
# Comment this if you already have a database running
plane-db:
@ -304,3 +319,9 @@ volumes:
rabbitmq_data:
proxy_config:
proxy_data:
networks:
default:
nodedc-engine:
external: true
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details.
from .analytic import urlpatterns as analytic_urls
from .ai_workspace import urlpatterns as ai_workspace_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .codex_agents import urlpatterns as codex_agent_urls
@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [
*analytic_urls,
*ai_workspace_urls,
*asset_urls,
*codex_agent_urls,
*cycle_urls,

View File

@ -0,0 +1,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",
),
]

View File

@ -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,

View File

@ -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,
)

View File

@ -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"])

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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>
);
}

View File

@ -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>

View File

@ -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"
@ -322,25 +479,33 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
else setIsPreviewOpen(true);
}}
>
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
{isBeamModel ? (
isBeamConversionFailed ? (
<AlertTriangle className="text-red-400 size-5" />
) : isBeamAwaitingPreview ? (
<Spinner
height="20px"
width="20px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
<Tooltip
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
isMobile={isMobile}
position={isBeamModel ? "left" : "top"}
sideOffset={isBeamModel ? 6 : 10}
className={isBeamModel ? "z-[220]" : ""}
>
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
{isBeamModel ? (
isBeamConversionFailed ? (
<AlertTriangle className="text-red-400 size-5" />
) : isBeamAwaitingPreview ? (
<Spinner
height="20px"
width="20px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
<ImageIcon className="size-5" />
)}
</div>
<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>
);
};

View File

@ -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"

View File

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

View File

@ -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}/`
)

View File

@ -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;
});
}
}

View File

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

View File

@ -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?: {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean;
message?: string;
metadataSrc?: string;
sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
updatedAt?: string;
};
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

View File

@ -6,22 +6,53 @@
import type { TFileSignedURLResponse } from "../file";
export type TBeamViewerAttachment = {
backend: "beam-viewer-local" | "beam-viewer";
conversion?: {
componentTreeRequired: boolean;
message?: string;
sourceFormat: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
};
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 = {