diff --git a/.env.prod.example b/.env.prod.example index f1f8376..20438b2 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -48,6 +48,8 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai # Panel access protection (deployment secret, not stored in sys_setting) PANEL_ACCESS_PASSWORD=change_me_panel_password +WORKSPACE_PREVIEW_SIGNING_SECRET=change_me_workspace_preview_signing_secret +WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600 # Browser credential requests must use an explicit CORS allowlist (deployment security setting). # If frontend and backend are served under the same origin via nginx `/api` proxy, diff --git a/backend/.env.example b/backend/.env.example index 6c1affe..842c3b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,8 @@ REDIS_DEFAULT_TTL=60 # Optional panel-level access password for all backend API/WS calls. PANEL_ACCESS_PASSWORD= +WORKSPACE_PREVIEW_SIGNING_SECRET= +WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600 # Explicit CORS allowlist for browser credential requests. # For local development, the backend defaults to common Vite dev origins. diff --git a/backend/api/workspace_router.py b/backend/api/workspace_router.py index 7624f71..aa372c0 100644 --- a/backend/api/workspace_router.py +++ b/backend/api/workspace_router.py @@ -5,10 +5,12 @@ from sqlmodel import Session from core.database import get_session from models.bot import BotInstance -from schemas.system import WorkspaceFileUpdateRequest +from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest from services.workspace_service import ( + create_workspace_html_preview_url, get_workspace_tree_data, read_workspace_text_file, + serve_workspace_preview_file, serve_workspace_file, update_workspace_markdown_file, upload_workspace_files_to_workspace, @@ -17,6 +19,19 @@ from services.workspace_service import ( router = APIRouter() +@router.get("/api/preview/workspace/{preview_token}/{path:path}") +def preview_workspace_file( + preview_token: str, + path: str, + request: Request, +): + return serve_workspace_preview_file( + preview_token=preview_token, + path=path, + request=request, + ) + + @router.get("/api/bots/{bot_id}/workspace/tree") def get_workspace_tree( bot_id: str, @@ -53,6 +68,21 @@ def update_workspace_file( raise HTTPException(status_code=404, detail="Bot not found") return update_workspace_markdown_file(bot_id, path=path, content=payload.content) +@router.post("/api/bots/{bot_id}/workspace/preview-url") +def create_workspace_preview_url( + bot_id: str, + payload: WorkspacePreviewUrlRequest, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + return create_workspace_html_preview_url( + bot_id=bot_id, + path=payload.path, + ttl_seconds=payload.ttl_seconds, + ) + @router.get("/api/bots/{bot_id}/workspace/download") def download_workspace_file( bot_id: str, diff --git a/backend/bootstrap/auth_access.py b/backend/bootstrap/auth_access.py index 4d21c7d..8e5a769 100644 --- a/backend/bootstrap/auth_access.py +++ b/backend/bootstrap/auth_access.py @@ -18,6 +18,7 @@ _PUBLIC_EXACT_PATHS = { } _PANEL_AUTH_SEGMENTS = ("api", "panel", "auth") +_WORKSPACE_PREVIEW_SEGMENTS = ("api", "preview", "workspace") _BOT_PUBLIC_SEGMENTS = ("public", "bots") _BOT_API_SEGMENTS = ("api", "bots") _BOT_AUTH_SEGMENT = "auth" @@ -46,6 +47,10 @@ def _is_panel_auth_route(segments: list[str]) -> bool: return tuple(segments[:3]) == _PANEL_AUTH_SEGMENTS +def _is_workspace_preview_route(segments: list[str], method: str) -> bool: + return method == "GET" and tuple(segments[:3]) == _WORKSPACE_PREVIEW_SEGMENTS and len(segments) >= 5 + + def _is_public_bot_route(segments: list[str]) -> bool: return tuple(segments[:2]) == _BOT_PUBLIC_SEGMENTS and len(segments) >= 3 @@ -79,6 +84,9 @@ def resolve_route_access_mode(path: str, method: str) -> RouteAccessMode: if raw_path in _PUBLIC_EXACT_PATHS: return RouteAccessMode.PUBLIC + if _is_workspace_preview_route(segments, verb): + return RouteAccessMode.PUBLIC + if _is_panel_auth_route(segments) or _is_bot_auth_route(segments): return RouteAccessMode.PUBLIC diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index 442b10b..57a2888 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -13,15 +13,6 @@ import docker class BotDockerManager: _RUNTIME_BOOTSTRAP_LABEL_KEY = "dashboard.runtime_bootstrap" _RUNTIME_BOOTSTRAP_LABEL_VALUE = "env-json-v1" - _DASHBOARD_READY_LOG_MARKERS = ( - "nanobot.channels.dashboard:start", - "dashboard channel 代理已上线", - ) - _DASHBOARD_FAILURE_LOG_MARKERS = ( - "failed to start channel dashboard", - "dashboard channel not available", - ) - def __init__( self, host_data_root: str, @@ -567,44 +558,12 @@ class BotDockerManager: def get_last_delivery_error(self, bot_id: str) -> str: return str(self._last_delivery_error.get(bot_id, "") or "").strip() - @classmethod - def _log_indicates_dashboard_ready(cls, line: str) -> bool: - lowered = str(line or "").strip().lower() - return any(marker in lowered for marker in cls._DASHBOARD_READY_LOG_MARKERS) - - @classmethod - def _log_indicates_dashboard_failure(cls, line: str) -> bool: - lowered = str(line or "").strip().lower() - return any(marker in lowered for marker in cls._DASHBOARD_FAILURE_LOG_MARKERS) - - def _wait_for_dashboard_ready( - self, - bot_id: str, - timeout_seconds: float = 15.0, - poll_interval_seconds: float = 0.5, - ) -> bool: - deadline = time.monotonic() + max(1.0, timeout_seconds) - while time.monotonic() < deadline: - status = self.get_bot_status(bot_id) - if status != "RUNNING": - self._last_delivery_error[bot_id] = f"Container status is {status.lower()}" - return False - - logs = self.get_recent_logs(bot_id, tail=200) - for line in logs: - if self._log_indicates_dashboard_failure(line): - detail = str(line or "").strip() - self._last_delivery_error[bot_id] = detail[:300] if detail else "Dashboard channel failed to start" - return False - if self._log_indicates_dashboard_ready(line): - return True - - time.sleep(max(0.1, poll_interval_seconds)) - - self._last_delivery_error[bot_id] = ( - f"Dashboard channel was not ready within {int(max(1.0, timeout_seconds))}s" - ) - return False + def _wait_for_dashboard_ready(self, bot_id: str) -> bool: + status = self.get_bot_status(bot_id) + if status != "RUNNING": + self._last_delivery_error[bot_id] = f"Container status is {status.lower()}" + return False + return True def get_bot_status(self, bot_id: str) -> str: """Return normalized runtime status from Docker: RUNNING or STOPPED.""" diff --git a/backend/core/settings.py b/backend/core/settings.py index c8b5e53..6935592 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -189,6 +189,15 @@ DEFAULT_PAGE_SIZE: Final[int] = 10 DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720) DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20) +WORKSPACE_PREVIEW_SIGNING_SECRET: Final[str] = str( + os.getenv("WORKSPACE_PREVIEW_SIGNING_SECRET") or DATABASE_URL +).strip() +WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: Final[int] = _env_int( + "WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS", + 3600, + 60, + 86400, +) DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str( os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai" ).strip() or "Asia/Shanghai" diff --git a/backend/schemas/system.py b/backend/schemas/system.py index f7e7866..1413713 100644 --- a/backend/schemas/system.py +++ b/backend/schemas/system.py @@ -6,6 +6,11 @@ class WorkspaceFileUpdateRequest(BaseModel): content: str +class WorkspacePreviewUrlRequest(BaseModel): + path: str + ttl_seconds: Optional[int] = None + + class PanelLoginRequest(BaseModel): password: str diff --git a/backend/services/workspace_preview_token_service.py b/backend/services/workspace_preview_token_service.py new file mode 100644 index 0000000..070cb13 --- /dev/null +++ b/backend/services/workspace_preview_token_service.py @@ -0,0 +1,95 @@ +import base64 +import hashlib +import hmac +import json +import time +from typing import Any, Dict, Optional + +from core.settings import WORKSPACE_PREVIEW_SIGNING_SECRET, WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS + +HTML_PREVIEW_EXTENSIONS = {".html", ".htm"} + + +def _b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64url_decode(raw: str) -> bytes: + padding = "=" * (-len(raw) % 4) + return base64.urlsafe_b64decode(f"{raw}{padding}".encode("ascii")) + + +def normalize_workspace_preview_path(path: str) -> str: + return "/".join(part for part in str(path or "").strip().replace("\\", "/").split("/") if part) + + +def is_html_preview_path(path: str) -> bool: + normalized = normalize_workspace_preview_path(path).lower() + return any(normalized.endswith(ext) for ext in HTML_PREVIEW_EXTENSIONS) + + +def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional[int] = None) -> Dict[str, Any]: + normalized_bot_id = str(bot_id or "").strip() + normalized_path = normalize_workspace_preview_path(path) + ttl = max(60, min(int(ttl_seconds or WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS), 86400)) + expires_at = int(time.time()) + ttl + payload = { + "bot_id": normalized_bot_id, + "entry_path": normalized_path, + "exp": expires_at, + "kind": "workspace-preview-session", + } + payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + body = _b64url_encode(payload_json) + signature = hmac.new( + WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"), + body.encode("ascii"), + hashlib.sha256, + ).digest() + return { + "token": f"{body}.{_b64url_encode(signature)}", + "expires_at": expires_at, + "ttl_seconds": ttl, + } + + +def resolve_workspace_preview_token(token: str) -> Optional[Dict[str, Any]]: + raw_token = str(token or "").strip() + if not raw_token or "." not in raw_token: + return None + body, signature_raw = raw_token.split(".", 1) + expected_signature = hmac.new( + WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"), + body.encode("ascii"), + hashlib.sha256, + ).digest() + try: + provided_signature = _b64url_decode(signature_raw) + except Exception: + return None + if not hmac.compare_digest(expected_signature, provided_signature): + return None + + try: + payload = json.loads(_b64url_decode(body).decode("utf-8")) + except Exception: + return None + if not isinstance(payload, dict): + return None + if str(payload.get("kind") or "") != "workspace-preview-session": + return None + bot_id = str(payload.get("bot_id") or "").strip() + entry_path = normalize_workspace_preview_path(str(payload.get("entry_path") or "")) + if not bot_id or not is_html_preview_path(entry_path): + return None + try: + expires_at = int(payload.get("exp") or 0) + except Exception: + return None + if expires_at < int(time.time()): + return None + return { + "bot_id": bot_id, + "entry_path": entry_path, + "expires_at": expires_at, + } diff --git a/backend/services/workspace_service.py b/backend/services/workspace_service.py index 98caa53..0af45f9 100644 --- a/backend/services/workspace_service.py +++ b/backend/services/workspace_service.py @@ -11,6 +11,12 @@ from fastapi.responses import FileResponse, RedirectResponse, Response, Streamin from core.utils import _workspace_stat_ctime_iso from services.bot_storage_service import get_bot_workspace_root from services.platform_settings_service import get_platform_settings_snapshot +from services.workspace_preview_token_service import ( + create_workspace_preview_token, + is_html_preview_path, + normalize_workspace_preview_path, + resolve_workspace_preview_token, +) TEXT_PREVIEW_EXTENSIONS = { "", @@ -216,6 +222,30 @@ def _build_workspace_raw_url(bot_id: str, path: str, public: bool) -> str: return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}" +def create_workspace_html_preview_url( + bot_id: str, + *, + path: str, + ttl_seconds: Optional[int] = None, +) -> Dict[str, Any]: + normalized = normalize_workspace_preview_path(path) + if not normalized: + raise HTTPException(status_code=400, detail="workspace path is required") + if not is_html_preview_path(normalized): + raise HTTPException(status_code=400, detail="signed preview URLs are only supported for html files") + + _root, target = _resolve_workspace_path(bot_id, normalized) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="workspace file not found") + + token_data = create_workspace_preview_token(bot_id, normalized, ttl_seconds=ttl_seconds) + return { + "url": f"/api/preview/workspace/{quote(token_data['token'], safe='')}/{quote(normalized, safe='/')}", + "expires_at": token_data["expires_at"], + "ttl_seconds": token_data["ttl_seconds"], + } + + def _serve_workspace_file( *, bot_id: str, @@ -372,6 +402,28 @@ def serve_workspace_file( ) +def serve_workspace_preview_file( + *, + preview_token: str, + path: str, + request: Request, +) -> Response: + token_data = resolve_workspace_preview_token(preview_token) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid or expired preview token") + normalized = normalize_workspace_preview_path(path) + if not normalized: + raise HTTPException(status_code=400, detail="workspace path is required") + return _serve_workspace_file( + bot_id=str(token_data["bot_id"]), + path=normalized, + download=False, + request=request, + public=True, + redirect_html_to_raw=False, + ) + + def _sanitize_upload_filename(original_name: str) -> str: name = os.path.basename(original_name).replace("\\", "_").replace("/", "_") name = re.sub(r"[^\w.\-()+@ ]+", "_", name) diff --git a/bot-images/dashboard.py b/bot-images/dashboard.py index 522ff52..34c0c6e 100644 --- a/bot-images/dashboard.py +++ b/bot-images/dashboard.py @@ -1,3 +1,4 @@ +import asyncio import json from types import SimpleNamespace from typing import Any @@ -51,6 +52,7 @@ class DashboardChannel(BaseChannel): self.host = host if host is not None else getattr(config_obj, "host", "0.0.0.0") self.port = port if port is not None else getattr(config_obj, "port", 9000) self.runner: web.AppRunner | None = None + self._chat_tasks: set[asyncio.Task[Any]] = set() async def start(self) -> None: """启动 Dashboard HTTP 服务""" @@ -70,6 +72,9 @@ class DashboardChannel(BaseChannel): if self.runner: await self.runner.cleanup() self.runner = None + for task in list(self._chat_tasks): + task.cancel() + self._chat_tasks.clear() self._running = False logger.info("Dashboard Channel 已下线") @@ -110,6 +115,17 @@ class DashboardChannel(BaseChannel): # 使用 JSON 格式输出,方便面板后端精准解析,告别正则 print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True) + async def _dispatch_chat_message(self, user_message: str, media: list[str]) -> None: + try: + await self._handle_message( + sender_id="user", + chat_id="direct", + content=user_message, + media=media, + ) + except Exception as e: + logger.error(f"❌ Dashboard Channel 后台处理指令失败: {e}") + async def _handle_chat(self, request: web.Request) -> web.Response: """处理来自面板的指令入站""" try: @@ -126,13 +142,10 @@ class DashboardChannel(BaseChannel): # 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码 logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...") - # 统一走基类入口,兼容不同核心的会话与权限逻辑。 - await self._handle_message( - sender_id="user", - chat_id="direct", - content=user_message, - media=media, - ) + # 先确认收件,避免面板投递请求被后续 LLM/工具处理链路拖到超时。 + task = asyncio.create_task(self._dispatch_chat_message(user_message, media)) + self._chat_tasks.add(task) + task.add_done_callback(self._chat_tasks.discard) return web.json_response({"status": "ok"}) except Exception as e: diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 0320831..641d118 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -83,6 +83,8 @@ services: REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} + WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-} + WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} STT_ENABLED: ${STT_ENABLED:-true} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 75fc37f..27273f5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,6 +29,8 @@ services: REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} + WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-} + WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} STT_ENABLED: ${STT_ENABLED:-true} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index d308c2f..4e3beee 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -283,9 +283,9 @@ export const dashboardEn = { fileSaved: 'Markdown saved.', fileSaveFail: 'Failed to save markdown.', download: 'Download', - copyAddress: 'Copy URL', - urlCopied: 'URL copied.', - urlCopyFail: 'Failed to copy URL.', + copyAddress: 'Preview URL', + urlCopied: 'Preview URL copied.', + urlCopyFail: 'Failed to get preview URL.', close: 'Close', cronViewer: 'Scheduled Jobs', cronReload: 'Reload jobs', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 0124031..ac051e3 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -283,9 +283,9 @@ export const dashboardZhCn = { fileSaved: 'Markdown 已保存。', fileSaveFail: 'Markdown 保存失败。', download: '下载', - copyAddress: '复制地址', - urlCopied: '地址已复制。', - urlCopyFail: '复制地址失败。', + copyAddress: '预览地址', + urlCopied: '预览地址已复制。', + urlCopyFail: '获取预览地址失败。', close: '关闭', cronViewer: '定时任务', cronReload: '刷新任务', diff --git a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts index a8dc5ec..c8e9452 100644 --- a/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts +++ b/frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts @@ -21,6 +21,8 @@ interface CommandResponse { success?: boolean; } +const COMMAND_DELIVERY_TIMEOUT_MS = 90000; + interface ApiErrorDetail { detail?: string; } @@ -113,7 +115,7 @@ export function useDashboardChatCommandDispatch({ const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: payloadText, attachments }, - { timeout: 12000 }, + { timeout: COMMAND_DELIVERY_TIMEOUT_MS }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); @@ -197,7 +199,7 @@ export function useDashboardChatCommandDispatch({ const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: slashCommand }, - { timeout: 12000 }, + { timeout: COMMAND_DELIVERY_TIMEOUT_MS }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); @@ -240,7 +242,7 @@ export function useDashboardChatCommandDispatch({ const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, { command: '/stop' }, - { timeout: 12000 }, + { timeout: COMMAND_DELIVERY_TIMEOUT_MS }, ); if (!res.data?.success) { throw new Error(t.backendDeliverFail); diff --git a/frontend/src/shared/workspace/useWorkspacePreview.ts b/frontend/src/shared/workspace/useWorkspacePreview.ts index b017262..abbce74 100644 --- a/frontend/src/shared/workspace/useWorkspacePreview.ts +++ b/frontend/src/shared/workspace/useWorkspacePreview.ts @@ -28,6 +28,10 @@ export interface WorkspacePreviewLabels { urlCopyFail: string; } +interface WorkspacePreviewUrlResponse { + url?: string; +} + interface UseWorkspacePreviewOptions { selectedBotId: string; workspaceCurrentPath: string; @@ -53,6 +57,21 @@ function buildMediaPreviewState(path: string, mode: 'image' | 'html' | 'video' | }; } +function buildAbsolutePublicHref(hrefRaw: string): string { + const href = String(hrefRaw || '').trim(); + if (!href) return ''; + try { + const apiOrigin = new URL(APP_ENDPOINTS.apiBase, window.location.origin).origin; + return new URL(href, apiOrigin).href; + } catch { + try { + return new URL(href, window.location.origin).href; + } catch { + return href; + } + } +} + export function useWorkspacePreview({ selectedBotId, workspaceCurrentPath, @@ -233,16 +252,18 @@ export function useWorkspacePreview({ const copyWorkspacePreviewUrl = useCallback(async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!selectedBotId || !normalized) return; - const hrefRaw = getWorkspacePreviewHref(normalized); - const href = (() => { - try { - return new URL(hrefRaw, window.location.origin).href; - } catch { - return hrefRaw; - } - })(); - await copyTextToClipboard(href, t.urlCopied, t.urlCopyFail); - }, [copyTextToClipboard, getWorkspacePreviewHref, selectedBotId, t.urlCopied, t.urlCopyFail]); + try { + const res = await axios.post( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/preview-url`, + { path: normalized }, + ); + const hrefRaw = String(res.data?.url || '').trim(); + if (!hrefRaw) throw new Error(t.urlCopyFail); + await copyTextToClipboard(buildAbsolutePublicHref(hrefRaw), t.urlCopied, t.urlCopyFail); + } catch (error: unknown) { + notify(resolveApiErrorMessage(error, t.urlCopyFail), { tone: 'error' }); + } + }, [copyTextToClipboard, notify, selectedBotId, t.urlCopied, t.urlCopyFail]); const copyWorkspacePreviewPath = useCallback(async (filePath: string) => { const normalized = String(filePath || '').trim();