v0.1.5
parent
852e60435b
commit
ecf223f945
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,44 +558,12 @@ 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:
|
status = self.get_bot_status(bot_id)
|
||||||
lowered = str(line or "").strip().lower()
|
if status != "RUNNING":
|
||||||
return any(marker in lowered for marker in cls._DASHBOARD_READY_LOG_MARKERS)
|
self._last_delivery_error[bot_id] = f"Container status is {status.lower()}"
|
||||||
|
return False
|
||||||
@classmethod
|
return True
|
||||||
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 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."""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: '刷新任务',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
try {
|
||||||
const href = (() => {
|
const res = await axios.post<WorkspacePreviewUrlResponse>(
|
||||||
try {
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/preview-url`,
|
||||||
return new URL(hrefRaw, window.location.origin).href;
|
{ path: normalized },
|
||||||
} catch {
|
);
|
||||||
return hrefRaw;
|
const hrefRaw = String(res.data?.url || '').trim();
|
||||||
}
|
if (!hrefRaw) throw new Error(t.urlCopyFail);
|
||||||
})();
|
await copyTextToClipboard(buildAbsolutePublicHref(hrefRaw), t.urlCopied, t.urlCopyFail);
|
||||||
await copyTextToClipboard(href, t.urlCopied, t.urlCopyFail);
|
} catch (error: unknown) {
|
||||||
}, [copyTextToClipboard, getWorkspacePreviewHref, selectedBotId, t.urlCopied, t.urlCopyFail]);
|
notify(resolveApiErrorMessage(error, t.urlCopyFail), { tone: 'error' });
|
||||||
|
}
|
||||||
|
}, [copyTextToClipboard, notify, 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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue