main
mula.liu 2026-04-28 16:25:46 +08:00
parent 852e60435b
commit ecf223f945
16 changed files with 276 additions and 74 deletions

View File

@ -48,6 +48,8 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
# Panel access protection (deployment secret, not stored in sys_setting) # Panel access protection (deployment secret, not stored in sys_setting)
PANEL_ACCESS_PASSWORD=change_me_panel_password 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). # 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, # If frontend and backend are served under the same origin via nginx `/api` proxy,

View File

@ -26,6 +26,8 @@ REDIS_DEFAULT_TTL=60
# Optional panel-level access password for all backend API/WS calls. # Optional panel-level access password for all backend API/WS calls.
PANEL_ACCESS_PASSWORD= PANEL_ACCESS_PASSWORD=
WORKSPACE_PREVIEW_SIGNING_SECRET=
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600
# Explicit CORS allowlist for browser credential requests. # Explicit CORS allowlist for browser credential requests.
# For local development, the backend defaults to common Vite dev origins. # For local development, the backend defaults to common Vite dev origins.

View File

@ -5,10 +5,12 @@ from sqlmodel import Session
from core.database import get_session from core.database import get_session
from models.bot import BotInstance from models.bot import BotInstance
from schemas.system import WorkspaceFileUpdateRequest from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest
from services.workspace_service import ( from services.workspace_service import (
create_workspace_html_preview_url,
get_workspace_tree_data, get_workspace_tree_data,
read_workspace_text_file, read_workspace_text_file,
serve_workspace_preview_file,
serve_workspace_file, serve_workspace_file,
update_workspace_markdown_file, update_workspace_markdown_file,
upload_workspace_files_to_workspace, upload_workspace_files_to_workspace,
@ -17,6 +19,19 @@ from services.workspace_service import (
router = APIRouter() 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") @router.get("/api/bots/{bot_id}/workspace/tree")
def get_workspace_tree( def get_workspace_tree(
bot_id: str, bot_id: str,
@ -53,6 +68,21 @@ def update_workspace_file(
raise HTTPException(status_code=404, detail="Bot not found") raise HTTPException(status_code=404, detail="Bot not found")
return update_workspace_markdown_file(bot_id, path=path, content=payload.content) 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") @router.get("/api/bots/{bot_id}/workspace/download")
def download_workspace_file( def download_workspace_file(
bot_id: str, bot_id: str,

View File

@ -18,6 +18,7 @@ _PUBLIC_EXACT_PATHS = {
} }
_PANEL_AUTH_SEGMENTS = ("api", "panel", "auth") _PANEL_AUTH_SEGMENTS = ("api", "panel", "auth")
_WORKSPACE_PREVIEW_SEGMENTS = ("api", "preview", "workspace")
_BOT_PUBLIC_SEGMENTS = ("public", "bots") _BOT_PUBLIC_SEGMENTS = ("public", "bots")
_BOT_API_SEGMENTS = ("api", "bots") _BOT_API_SEGMENTS = ("api", "bots")
_BOT_AUTH_SEGMENT = "auth" _BOT_AUTH_SEGMENT = "auth"
@ -46,6 +47,10 @@ def _is_panel_auth_route(segments: list[str]) -> bool:
return tuple(segments[:3]) == _PANEL_AUTH_SEGMENTS 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: def _is_public_bot_route(segments: list[str]) -> bool:
return tuple(segments[:2]) == _BOT_PUBLIC_SEGMENTS and len(segments) >= 3 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: if raw_path in _PUBLIC_EXACT_PATHS:
return RouteAccessMode.PUBLIC 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): if _is_panel_auth_route(segments) or _is_bot_auth_route(segments):
return RouteAccessMode.PUBLIC return RouteAccessMode.PUBLIC

View File

@ -13,15 +13,6 @@ import docker
class BotDockerManager: class BotDockerManager:
_RUNTIME_BOOTSTRAP_LABEL_KEY = "dashboard.runtime_bootstrap" _RUNTIME_BOOTSTRAP_LABEL_KEY = "dashboard.runtime_bootstrap"
_RUNTIME_BOOTSTRAP_LABEL_VALUE = "env-json-v1" _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__( def __init__(
self, self,
host_data_root: str, host_data_root: str,
@ -567,45 +558,13 @@ class BotDockerManager:
def get_last_delivery_error(self, bot_id: str) -> str: def get_last_delivery_error(self, bot_id: str) -> str:
return str(self._last_delivery_error.get(bot_id, "") or "").strip() return str(self._last_delivery_error.get(bot_id, "") or "").strip()
@classmethod def _wait_for_dashboard_ready(self, bot_id: str) -> bool:
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) status = self.get_bot_status(bot_id)
if status != "RUNNING": if status != "RUNNING":
self._last_delivery_error[bot_id] = f"Container status is {status.lower()}" self._last_delivery_error[bot_id] = f"Container status is {status.lower()}"
return False 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 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 get_bot_status(self, bot_id: str) -> str: def get_bot_status(self, bot_id: str) -> str:
"""Return normalized runtime status from Docker: RUNNING or STOPPED.""" """Return normalized runtime status from Docker: RUNNING or STOPPED."""
if not self.client: if not self.client:

View File

@ -189,6 +189,15 @@ DEFAULT_PAGE_SIZE: Final[int] = 10
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 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_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) 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( DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai" os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
).strip() or "Asia/Shanghai" ).strip() or "Asia/Shanghai"

View File

@ -6,6 +6,11 @@ class WorkspaceFileUpdateRequest(BaseModel):
content: str content: str
class WorkspacePreviewUrlRequest(BaseModel):
path: str
ttl_seconds: Optional[int] = None
class PanelLoginRequest(BaseModel): class PanelLoginRequest(BaseModel):
password: str password: str

View File

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

View File

@ -11,6 +11,12 @@ from fastapi.responses import FileResponse, RedirectResponse, Response, Streamin
from core.utils import _workspace_stat_ctime_iso from core.utils import _workspace_stat_ctime_iso
from services.bot_storage_service import get_bot_workspace_root from services.bot_storage_service import get_bot_workspace_root
from services.platform_settings_service import get_platform_settings_snapshot 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 = { 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='/')}" 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( def _serve_workspace_file(
*, *,
bot_id: str, 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: def _sanitize_upload_filename(original_name: str) -> str:
name = os.path.basename(original_name).replace("\\", "_").replace("/", "_") name = os.path.basename(original_name).replace("\\", "_").replace("/", "_")
name = re.sub(r"[^\w.\-()+@ ]+", "_", name) name = re.sub(r"[^\w.\-()+@ ]+", "_", name)

View File

@ -1,3 +1,4 @@
import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any 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.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.port = port if port is not None else getattr(config_obj, "port", 9000)
self.runner: web.AppRunner | None = None self.runner: web.AppRunner | None = None
self._chat_tasks: set[asyncio.Task[Any]] = set()
async def start(self) -> None: async def start(self) -> None:
"""启动 Dashboard HTTP 服务""" """启动 Dashboard HTTP 服务"""
@ -70,6 +72,9 @@ class DashboardChannel(BaseChannel):
if self.runner: if self.runner:
await self.runner.cleanup() await self.runner.cleanup()
self.runner = None self.runner = None
for task in list(self._chat_tasks):
task.cancel()
self._chat_tasks.clear()
self._running = False self._running = False
logger.info("Dashboard Channel 已下线") logger.info("Dashboard Channel 已下线")
@ -110,6 +115,17 @@ class DashboardChannel(BaseChannel):
# 使用 JSON 格式输出,方便面板后端精准解析,告别正则 # 使用 JSON 格式输出,方便面板后端精准解析,告别正则
print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True) 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: async def _handle_chat(self, request: web.Request) -> web.Response:
"""处理来自面板的指令入站""" """处理来自面板的指令入站"""
try: try:
@ -126,13 +142,10 @@ class DashboardChannel(BaseChannel):
# 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码 # 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码
logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...") logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...")
# 统一走基类入口,兼容不同核心的会话与权限逻辑。 # 先确认收件,避免面板投递请求被后续 LLM/工具处理链路拖到超时。
await self._handle_message( task = asyncio.create_task(self._dispatch_chat_message(user_message, media))
sender_id="user", self._chat_tasks.add(task)
chat_id="direct", task.add_done_callback(self._chat_tasks.discard)
content=user_message,
media=media,
)
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
except Exception as e: except Exception as e:

View File

@ -83,6 +83,8 @@ services:
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} 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:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
STT_ENABLED: ${STT_ENABLED:-true} STT_ENABLED: ${STT_ENABLED:-true}
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}

View File

@ -29,6 +29,8 @@ services:
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} 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:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
STT_ENABLED: ${STT_ENABLED:-true} STT_ENABLED: ${STT_ENABLED:-true}
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}

View File

@ -283,9 +283,9 @@ export const dashboardEn = {
fileSaved: 'Markdown saved.', fileSaved: 'Markdown saved.',
fileSaveFail: 'Failed to save markdown.', fileSaveFail: 'Failed to save markdown.',
download: 'Download', download: 'Download',
copyAddress: 'Copy URL', copyAddress: 'Preview URL',
urlCopied: 'URL copied.', urlCopied: 'Preview URL copied.',
urlCopyFail: 'Failed to copy URL.', urlCopyFail: 'Failed to get preview URL.',
close: 'Close', close: 'Close',
cronViewer: 'Scheduled Jobs', cronViewer: 'Scheduled Jobs',
cronReload: 'Reload jobs', cronReload: 'Reload jobs',

View File

@ -283,9 +283,9 @@ export const dashboardZhCn = {
fileSaved: 'Markdown 已保存。', fileSaved: 'Markdown 已保存。',
fileSaveFail: 'Markdown 保存失败。', fileSaveFail: 'Markdown 保存失败。',
download: '下载', download: '下载',
copyAddress: '复制地址', copyAddress: '预览地址',
urlCopied: '地址已复制。', urlCopied: '预览地址已复制。',
urlCopyFail: '复制地址失败。', urlCopyFail: '获取预览地址失败。',
close: '关闭', close: '关闭',
cronViewer: '定时任务', cronViewer: '定时任务',
cronReload: '刷新任务', cronReload: '刷新任务',

View File

@ -21,6 +21,8 @@ interface CommandResponse {
success?: boolean; success?: boolean;
} }
const COMMAND_DELIVERY_TIMEOUT_MS = 90000;
interface ApiErrorDetail { interface ApiErrorDetail {
detail?: string; detail?: string;
} }
@ -113,7 +115,7 @@ export function useDashboardChatCommandDispatch({
const res = await axios.post<CommandResponse>( const res = await axios.post<CommandResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments }, { command: payloadText, attachments },
{ timeout: 12000 }, { timeout: COMMAND_DELIVERY_TIMEOUT_MS },
); );
if (!res.data?.success) { if (!res.data?.success) {
throw new Error(t.backendDeliverFail); throw new Error(t.backendDeliverFail);
@ -197,7 +199,7 @@ export function useDashboardChatCommandDispatch({
const res = await axios.post<CommandResponse>( const res = await axios.post<CommandResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: slashCommand }, { command: slashCommand },
{ timeout: 12000 }, { timeout: COMMAND_DELIVERY_TIMEOUT_MS },
); );
if (!res.data?.success) { if (!res.data?.success) {
throw new Error(t.backendDeliverFail); throw new Error(t.backendDeliverFail);
@ -240,7 +242,7 @@ export function useDashboardChatCommandDispatch({
const res = await axios.post<CommandResponse>( const res = await axios.post<CommandResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: '/stop' }, { command: '/stop' },
{ timeout: 12000 }, { timeout: COMMAND_DELIVERY_TIMEOUT_MS },
); );
if (!res.data?.success) { if (!res.data?.success) {
throw new Error(t.backendDeliverFail); throw new Error(t.backendDeliverFail);

View File

@ -28,6 +28,10 @@ export interface WorkspacePreviewLabels {
urlCopyFail: string; urlCopyFail: string;
} }
interface WorkspacePreviewUrlResponse {
url?: string;
}
interface UseWorkspacePreviewOptions { interface UseWorkspacePreviewOptions {
selectedBotId: string; selectedBotId: string;
workspaceCurrentPath: 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({ export function useWorkspacePreview({
selectedBotId, selectedBotId,
workspaceCurrentPath, workspaceCurrentPath,
@ -233,16 +252,18 @@ export function useWorkspacePreview({
const copyWorkspacePreviewUrl = useCallback(async (filePath: string) => { const copyWorkspacePreviewUrl = useCallback(async (filePath: string) => {
const normalized = String(filePath || '').trim(); const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return; if (!selectedBotId || !normalized) return;
const hrefRaw = getWorkspacePreviewHref(normalized);
const href = (() => {
try { try {
return new URL(hrefRaw, window.location.origin).href; const res = await axios.post<WorkspacePreviewUrlResponse>(
} catch { `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/preview-url`,
return hrefRaw; { 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]);
await copyTextToClipboard(href, t.urlCopied, t.urlCopyFail);
}, [copyTextToClipboard, getWorkspacePreviewHref, selectedBotId, t.urlCopied, t.urlCopyFail]);
const copyWorkspacePreviewPath = useCallback(async (filePath: string) => { const copyWorkspacePreviewPath = useCallback(async (filePath: string) => {
const normalized = String(filePath || '').trim(); const normalized = String(filePath || '').trim();