v0.1.4-p5

main
mula.liu 2026-04-03 23:00:08 +08:00
parent 95e3fd6c38
commit ca1f941e4c
106 changed files with 5807 additions and 6043 deletions

0
( 100644
View File

View File

@ -50,6 +50,12 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
# Panel access protection # Panel access protection
PANEL_ACCESS_PASSWORD=change_me_panel_password PANEL_ACCESS_PASSWORD=change_me_panel_password
# Browser credential requests must use an explicit CORS allowlist.
# If frontend and backend are served under the same origin via nginx `/api` proxy,
# this can usually stay unset. Otherwise set the real dashboard origin(s).
# Example:
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
# Max upload size for backend validation (MB) # Max upload size for backend validation (MB)
UPLOAD_MAX_MB=200 UPLOAD_MAX_MB=200

View File

@ -24,6 +24,12 @@ 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=
# Explicit CORS allowlist for browser credential requests.
# For local development, the backend defaults to common Vite dev origins.
# In production, prefer same-origin `/api` reverse proxy, or set your real dashboard origin explicitly.
# Example:
# CORS_ALLOWED_ORIGINS=http://localhost:5173,https://dashboard.example.com
# The following platform-level items are now managed in sys_setting / 平台参数: # The following platform-level items are now managed in sys_setting / 平台参数:
# - page_size # - page_size
# - chat_pull_page_size # - chat_pull_page_size

View File

@ -1,8 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlmodel import Session from sqlmodel import Session
from core.database import get_session from core.database import get_session
from models.bot import BotInstance
from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest
from services.platform_auth_service import (
clear_bot_token_cookie,
create_bot_token,
resolve_bot_request_auth,
revoke_bot_token,
set_bot_token_cookie,
)
from services.bot_management_service import ( from services.bot_management_service import (
authenticate_bot_page_access, authenticate_bot_page_access,
create_bot_record, create_bot_record,
@ -36,8 +44,41 @@ def get_bot_detail(bot_id: str, session: Session = Depends(get_session)):
@router.post("/api/bots/{bot_id}/auth/login") @router.post("/api/bots/{bot_id}/auth/login")
def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)): def login_bot_page(
return authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password) bot_id: str,
payload: BotPageAuthLoginRequest,
request: Request,
response: Response,
session: Session = Depends(get_session),
):
result = authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password)
try:
raw_token = create_bot_token(session, request, bot_id)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
set_bot_token_cookie(response, request, bot_id, raw_token, session)
return result
@router.get("/api/bots/{bot_id}/auth/status")
def get_bot_auth_status(bot_id: str, request: Request, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
return {"enabled": False, "authenticated": False, "auth_source": None, "bot_id": bot_id}
principal = resolve_bot_request_auth(session, request, bot_id)
return {
"enabled": bool(str(bot.access_password or "").strip()),
"authenticated": bool(principal.authenticated),
"auth_source": principal.auth_source if principal.authenticated else None,
"bot_id": bot_id,
}
@router.post("/api/bots/{bot_id}/auth/logout")
def logout_bot_page(bot_id: str, request: Request, response: Response, session: Session = Depends(get_session)):
revoke_bot_token(session, request, bot_id)
clear_bot_token_cookie(response, bot_id)
return {"success": True, "bot_id": bot_id}
@router.put("/api/bots/{bot_id}") @router.put("/api/bots/{bot_id}")

View File

@ -1,73 +1,27 @@
import logging import logging
import time from typing import Optional
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlmodel import Session from sqlmodel import Session
from core.database import engine, get_session from core.database import engine, get_session
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from core.websocket_manager import manager from core.websocket_manager import manager
from models.bot import BotInstance from services.bot_runtime_service import (
from services.bot_channel_service import _get_bot_channels_from_config delete_cron_job as delete_cron_job_service,
from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance ensure_monitor_websocket_access,
from services.bot_storage_service import _read_bot_config, _write_bot_config get_bot_logs as get_bot_logs_service,
from services.bot_storage_service import _read_cron_store, _write_cron_store list_cron_jobs as list_cron_jobs_service,
relogin_weixin as relogin_weixin_service,
start_cron_job as start_cron_job_service,
stop_cron_job as stop_cron_job_service,
)
from services.runtime_service import docker_callback from services.runtime_service import docker_callback
router = APIRouter() router = APIRouter()
logger = logging.getLogger("dashboard.backend") logger = logging.getLogger("dashboard.backend")
def _now_ms() -> int:
return int(time.time() * 1000)
def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]:
current_ms = int(now_ms or _now_ms())
kind = str(schedule.get("kind") or "").strip().lower()
if kind == "at":
at_ms = int(schedule.get("atMs") or 0)
return at_ms if at_ms > current_ms else None
if kind == "every":
every_ms = int(schedule.get("everyMs") or 0)
return current_ms + every_ms if every_ms > 0 else None
if kind == "cron":
expr = str(schedule.get("expr") or "").strip()
if not expr:
return None
try:
from croniter import croniter
tz_name = str(schedule.get("tz") or "").strip()
tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo
base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz)
next_dt = croniter(expr, base_dt).get_next(datetime)
return int(next_dt.timestamp() * 1000)
except Exception:
return None
return None
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def _weixin_state_file_path(bot_id: str) -> Path:
return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json"
@router.get("/api/bots/{bot_id}/logs") @router.get("/api/bots/{bot_id}/logs")
def get_bot_logs( def get_bot_logs(
bot_id: str, bot_id: str,
@ -77,150 +31,72 @@ def get_bot_logs(
reverse: bool = False, reverse: bool = False,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_get_bot_or_404(session, bot_id) try:
if limit is not None: return get_bot_logs_service(
page = docker_manager.get_logs_page( session,
bot_id, bot_id=bot_id,
offset=max(0, int(offset)), tail=tail,
limit=max(1, int(limit)), offset=offset,
reverse=bool(reverse), limit=limit,
reverse=reverse,
) )
return {"bot_id": bot_id, **page} except LookupError as exc:
effective_tail = max(1, int(tail or 300)) raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=effective_tail)}
@router.post("/api/bots/{bot_id}/weixin/relogin") @router.post("/api/bots/{bot_id}/weixin/relogin")
async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)): async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)):
bot = _get_bot_or_404(session, bot_id)
weixin_channel = next(
(
row
for row in _get_bot_channels_from_config(bot)
if str(row.get("channel_type") or "").strip().lower() == "weixin"
),
None,
)
if not weixin_channel:
raise HTTPException(status_code=404, detail="Weixin channel not found")
state_file = _weixin_state_file_path(bot_id)
removed = False
try: try:
if state_file.is_file(): return await relogin_weixin_service(session, bot_id=bot_id)
state_file.unlink() except LookupError as exc:
removed = True raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: except ValueError as exc:
raise HTTPException(status_code=500, detail=f"Failed to remove weixin state: {exc}") from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
config_data = _read_bot_config(bot_id) raise HTTPException(status_code=500, detail=str(exc)) from exc
channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {}
weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
weixin_cfg.pop("token", None)
_write_bot_config(bot_id, config_data)
restarted = False
if str(bot.docker_status or "").upper() == "RUNNING":
stop_bot_instance(session, bot_id)
await start_bot_instance(session, bot_id)
restarted = True
return {
"status": "relogin_started",
"bot_id": bot_id,
"removed_state": removed,
"restarted": restarted,
}
@router.get("/api/bots/{bot_id}/cron/jobs") @router.get("/api/bots/{bot_id}/cron/jobs")
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)): def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id) try:
store = _read_cron_store(bot_id) return list_cron_jobs_service(session, bot_id=bot_id, include_disabled=include_disabled)
rows = [] except LookupError as exc:
for row in store.get("jobs", []): raise HTTPException(status_code=404, detail=str(exc)) from exc
if not isinstance(row, dict):
continue
enabled = bool(row.get("enabled", True))
if not include_disabled and not enabled:
continue
rows.append(row)
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop") @router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)): def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id) try:
store = _read_cron_store(bot_id) return stop_cron_job_service(session, bot_id=bot_id, job_id=job_id)
jobs = store.get("jobs", []) except LookupError as exc:
if not isinstance(jobs, list): raise HTTPException(status_code=404, detail=str(exc)) from exc
jobs = []
found = None
for row in jobs:
if isinstance(row, dict) and str(row.get("id")) == job_id:
found = row
break
if not found:
raise HTTPException(status_code=404, detail="Cron job not found")
found["enabled"] = False
found["updatedAtMs"] = _now_ms()
state = found.get("state")
if not isinstance(state, dict):
state = {}
found["state"] = state
state["nextRunAtMs"] = None
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "stopped", "job_id": job_id}
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start") @router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start")
def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)): def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id) try:
store = _read_cron_store(bot_id) return start_cron_job_service(session, bot_id=bot_id, job_id=job_id)
jobs = store.get("jobs", []) except LookupError as exc:
if not isinstance(jobs, list): raise HTTPException(status_code=404, detail=str(exc)) from exc
jobs = []
found = None
for row in jobs:
if isinstance(row, dict) and str(row.get("id")) == job_id:
found = row
break
if not found:
raise HTTPException(status_code=404, detail="Cron job not found")
found["enabled"] = True
found["updatedAtMs"] = _now_ms()
state = found.get("state")
if not isinstance(state, dict):
state = {}
found["state"] = state
schedule = found.get("schedule")
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {})
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "started", "job_id": job_id}
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}") @router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)): def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id) try:
store = _read_cron_store(bot_id) return delete_cron_job_service(session, bot_id=bot_id, job_id=job_id)
jobs = store.get("jobs", []) except LookupError as exc:
if not isinstance(jobs, list): raise HTTPException(status_code=404, detail=str(exc)) from exc
jobs = []
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
if len(kept) == len(jobs):
raise HTTPException(status_code=404, detail="Cron job not found")
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
return {"status": "deleted", "job_id": job_id}
@router.websocket("/ws/monitor/{bot_id}") @router.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str): async def websocket_endpoint(websocket: WebSocket, bot_id: str):
with Session(engine) as session: with Session(engine) as session:
bot = session.get(BotInstance, bot_id) try:
if not bot: ensure_monitor_websocket_access(session, websocket, bot_id)
except PermissionError:
await websocket.close(code=4401, reason="Bot or panel authentication required")
return
except LookupError:
await websocket.close(code=4404, reason="Bot not found") await websocket.close(code=4404, reason="Bot not found")
return return
@ -240,6 +116,15 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str):
try: try:
while True: while True:
await websocket.receive_text() await websocket.receive_text()
with Session(engine) as session:
try:
ensure_monitor_websocket_access(session, websocket, bot_id)
except PermissionError:
await websocket.close(code=4401, reason="Authentication expired")
return
except LookupError:
await websocket.close(code=4404, reason="Bot not found")
return
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except RuntimeError as exc: except RuntimeError as exc:

View File

@ -13,6 +13,7 @@ from services.platform_service import (
get_bot_activity_stats, get_bot_activity_stats,
get_platform_settings, get_platform_settings,
list_system_settings, list_system_settings,
list_login_logs,
list_activity_events, list_activity_events,
list_usage, list_usage,
save_platform_settings, save_platform_settings,
@ -78,6 +79,25 @@ def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session:
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)} return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
@router.get("/api/platform/login-logs")
def get_platform_login_logs(
search: str = "",
auth_type: str = "",
status: str = "all",
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
):
return list_login_logs(
session,
search=search,
auth_type=auth_type,
status=status,
limit=limit,
offset=offset,
).model_dump()
@router.get("/api/platform/system-settings") @router.get("/api/platform/system-settings")
def get_system_settings(search: str = "", session: Session = Depends(get_session)): def get_system_settings(search: str = "", session: Session = Depends(get_session)):
return {"items": list_system_settings(session, search=search)} return {"items": list_system_settings(session, search=search)}

View File

@ -1,6 +1,6 @@
from typing import Any, Dict from typing import Any, Dict
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlmodel import Session, select from sqlmodel import Session, select
from core.database import engine, get_session from core.database import engine, get_session
@ -9,6 +9,13 @@ from core.utils import _get_default_system_timezone
from models.bot import BotInstance from models.bot import BotInstance
from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
from core.cache import cache from core.cache import cache
from services.platform_auth_service import (
clear_panel_token_cookie,
create_panel_token,
resolve_panel_request_auth,
revoke_panel_token,
set_panel_token_cookie,
)
from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
from services.template_service import ( from services.template_service import (
get_agent_md_templates, get_agent_md_templates,
@ -21,19 +28,37 @@ router = APIRouter()
@router.get("/api/panel/auth/status") @router.get("/api/panel/auth/status")
def get_panel_auth_status(): def get_panel_auth_status(request: Request, session: Session = Depends(get_session)):
configured = str(PANEL_ACCESS_PASSWORD or "").strip() configured = str(PANEL_ACCESS_PASSWORD or "").strip()
return {"enabled": bool(configured)} principal = resolve_panel_request_auth(session, request)
return {
"enabled": bool(configured),
"authenticated": bool(principal.authenticated),
"auth_source": principal.auth_source if principal.authenticated else None,
}
@router.post("/api/panel/auth/login") @router.post("/api/panel/auth/login")
def panel_login(payload: PanelLoginRequest): def panel_login(payload: PanelLoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
configured = str(PANEL_ACCESS_PASSWORD or "").strip() configured = str(PANEL_ACCESS_PASSWORD or "").strip()
if not configured: if not configured:
clear_panel_token_cookie(response)
return {"success": True, "enabled": False} return {"success": True, "enabled": False}
supplied = str(payload.password or "").strip() supplied = str(payload.password or "").strip()
if supplied != configured: if supplied != configured:
raise HTTPException(status_code=401, detail="Invalid panel access password") raise HTTPException(status_code=401, detail="Invalid panel access password")
return {"success": True, "enabled": True} try:
raw_token = create_panel_token(session, request)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
set_panel_token_cookie(response, request, raw_token, session)
return {"success": True, "enabled": True, "authenticated": True}
@router.post("/api/panel/auth/logout")
def panel_logout(request: Request, response: Response, session: Session = Depends(get_session)):
revoke_panel_token(session, request)
clear_panel_token_cookie(response)
return {"success": True}
@router.get("/api/system/defaults") @router.get("/api/system/defaults")
def get_system_defaults(): def get_system_defaults():

View File

@ -19,7 +19,7 @@ from api.workspace_router import router as workspace_router
from bootstrap.app_runtime import register_app_runtime from bootstrap.app_runtime import register_app_runtime
from core.auth_middleware import PasswordProtectionMiddleware from core.auth_middleware import PasswordProtectionMiddleware
from core.docker_instance import docker_manager from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT
from core.speech_service import WhisperSpeechService from core.speech_service import WhisperSpeechService
@ -33,9 +33,10 @@ def create_app() -> FastAPI:
app.add_middleware(PasswordProtectionMiddleware) app.add_middleware(PasswordProtectionMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=list(CORS_ALLOWED_ORIGINS),
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
allow_credentials=True,
) )
app.include_router(platform_router) app.include_router(platform_router)

View File

@ -0,0 +1,60 @@
from __future__ import annotations
import re
from enum import Enum
from typing import Optional
class RouteAccessMode(str, Enum):
PUBLIC = "public"
PANEL_ONLY = "panel_only"
BOT_OR_PANEL = "bot_or_panel"
PUBLIC_BOT_OR_PANEL = "public_bot_or_panel"
_BOT_ID_API_RE = re.compile(r"^/api/bots/([^/]+)(?:/.*)?$")
_BOT_ID_PUBLIC_RE = re.compile(r"^/public/bots/([^/]+)(?:/.*)?$")
_BOT_PANEL_ONLY_ROUTE_METHODS = [
(re.compile(r"^/api/bots/[^/]+$"), {"DELETE"}),
(re.compile(r"^/api/bots/[^/]+/(?:enable|disable|deactivate)$"), {"POST"}),
]
_PUBLIC_PATHS = {
"/api/panel/auth/status",
"/api/panel/auth/login",
"/api/panel/auth/logout",
"/api/health",
"/api/health/cache",
"/api/system/defaults",
}
_BOT_PUBLIC_AUTH_RE = re.compile(r"^/api/bots/[^/]+/auth/(?:login|logout|status)$")
def extract_bot_id(path: str) -> Optional[str]:
raw = str(path or "").strip()
match = _BOT_ID_API_RE.match(raw) or _BOT_ID_PUBLIC_RE.match(raw)
if not match or not match.group(1):
return None
return match.group(1).strip() or None
def resolve_route_access_mode(path: str, method: str) -> RouteAccessMode:
raw_path = str(path or "").strip()
verb = str(method or "GET").strip().upper()
if raw_path in _PUBLIC_PATHS or _BOT_PUBLIC_AUTH_RE.fullmatch(raw_path):
return RouteAccessMode.PUBLIC
if raw_path.startswith("/public/bots/"):
return RouteAccessMode.PUBLIC_BOT_OR_PANEL
if _BOT_ID_API_RE.fullmatch(raw_path):
if any(pattern.fullmatch(raw_path) and verb in methods for pattern, methods in _BOT_PANEL_ONLY_ROUTE_METHODS):
return RouteAccessMode.PANEL_ONLY
return RouteAccessMode.BOT_OR_PANEL
if raw_path.startswith("/api/"):
return RouteAccessMode.PANEL_ONLY
return RouteAccessMode.PUBLIC

View File

@ -1,125 +1,50 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlmodel import Session
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from core.settings import PANEL_ACCESS_PASSWORD from bootstrap.auth_access import RouteAccessMode, extract_bot_id, resolve_route_access_mode
from services.bot_storage_service import _read_bot_config from core.database import engine
from services.platform_auth_service import (
PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" resolve_bot_request_auth,
BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password" resolve_panel_request_auth,
BOT_PANEL_ONLY_SUFFIXES = {"/enable", "/disable", "/deactivate"}
def _extract_bot_id_from_api_path(path: str) -> Optional[str]:
parts = [p for p in path.split("/") if p.strip()]
if len(parts) >= 3 and parts[0] == "api" and parts[1] == "bots":
return parts[2]
return None
def _get_supplied_panel_password_http(request: Request) -> str:
header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip()
if header_value:
return header_value
query_value = str(request.query_params.get("panel_access_password") or "").strip()
return query_value
def _get_supplied_bot_access_password_http(request: Request) -> str:
header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip()
if header_value:
return header_value
query_value = str(request.query_params.get("bot_access_password") or "").strip()
return query_value
def _validate_panel_access_password(supplied: str) -> Optional[str]:
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
if not configured:
return None
candidate = str(supplied or "").strip()
if not candidate:
return "Panel access password required"
if candidate != configured:
return "Invalid panel access password"
return None
def _validate_bot_access_password(bot_id: str, supplied: str) -> Optional[str]:
config = _read_bot_config(bot_id)
configured = str(config.get("access_password") or "").strip()
if not configured:
return None
candidate = str(supplied or "").strip()
if not candidate:
return "Bot access password required"
if candidate != configured:
return "Invalid bot access password"
return None
def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool:
raw = str(path or "").strip()
verb = str(method or "GET").strip().upper()
if not raw.startswith("/api/bots/"):
return False
bot_id = _extract_bot_id_from_api_path(raw)
if not bot_id:
return False
return (
raw.endswith("/start")
or raw.endswith("/stop")
or raw.endswith("/enable")
or raw.endswith("/disable")
or raw.endswith("/deactivate")
or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}")
) )
def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool: def _unauthorized(detail: str) -> JSONResponse:
raw = str(path or "").strip() return JSONResponse(status_code=401, content={"detail": detail})
verb = str(method or "GET").strip().upper()
if not raw.startswith("/api/"):
return False
if raw in {
"/api/panel/auth/status",
"/api/panel/auth/login",
"/api/health",
"/api/health/cache",
}:
return False
if _is_bot_panel_management_api_path(raw, verb):
return True
if _extract_bot_id_from_api_path(raw):
return False
return True
class PasswordProtectionMiddleware(BaseHTTPMiddleware): class PasswordProtectionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
if request.method.upper() == "OPTIONS":
return await call_next(request)
path = request.url.path path = request.url.path
method = request.method.upper() access_mode = resolve_route_access_mode(path, request.method)
if access_mode == RouteAccessMode.PUBLIC:
if method == "OPTIONS":
return await call_next(request) return await call_next(request)
bot_id = _extract_bot_id_from_api_path(path) bot_id = extract_bot_id(path)
with Session(engine) as session:
panel_principal = resolve_panel_request_auth(session, request)
if panel_principal.authenticated:
request.state.auth_principal = panel_principal
return await call_next(request)
if access_mode == RouteAccessMode.PANEL_ONLY:
return _unauthorized("Panel authentication required")
if not bot_id: if not bot_id:
if _is_panel_protected_api_path(path, method): return _unauthorized("Bot authentication required")
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
if panel_error: bot_principal = resolve_bot_request_auth(session, request, bot_id)
return JSONResponse(status_code=401, content={"detail": panel_error}) if bot_principal.authenticated:
request.state.auth_principal = bot_principal
return await call_next(request) return await call_next(request)
if _is_bot_panel_management_api_path(path, method): if access_mode == RouteAccessMode.PUBLIC_BOT_OR_PANEL:
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) return _unauthorized("Bot or panel authentication required to access this resource")
if panel_error: return _unauthorized("Bot or panel authentication required")
bot_error = _validate_bot_access_password(bot_id, _get_supplied_bot_access_password_http(request))
if bot_error:
return JSONResponse(status_code=401, content={"detail": bot_error})
return await call_next(request)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import json import json
from typing import Any, Optional from typing import Any, Optional
@ -10,10 +12,10 @@ except Exception: # pragma: no cover
class RedisCache: class RedisCache:
def __init__(self): def __init__(self, *, prefix_override: Optional[str] = None, default_ttl_override: Optional[int] = None):
self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None) self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None)
self.prefix = REDIS_PREFIX self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX
self.default_ttl = int(REDIS_DEFAULT_TTL) self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL)
self._client: Optional["Redis"] = None self._client: Optional["Redis"] = None
if self.enabled: if self.enabled:
try: try:
@ -34,11 +36,28 @@ class RedisCache:
except Exception: except Exception:
return False return False
def get(self, key: str) -> Optional[str]:
if not self.enabled or self._client is None:
return None
try:
return self._client.get(self._full_key(key))
except Exception:
return None
def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
if not self.enabled or self._client is None:
return
try:
ttl_seconds = int(ttl if ttl is not None else self.default_ttl)
self._client.setex(self._full_key(key), ttl_seconds, str(value))
except Exception:
return
def get_json(self, key: str) -> Any: def get_json(self, key: str) -> Any:
if not self.enabled or self._client is None: if not self.enabled or self._client is None:
return None return None
try: try:
raw = self._client.get(self._full_key(key)) raw = self.get(key)
if not raw: if not raw:
return None return None
return json.loads(raw) return json.loads(raw)
@ -49,11 +68,46 @@ class RedisCache:
if not self.enabled or self._client is None: if not self.enabled or self._client is None:
return return
try: try:
self._client.setex( self.set(key, json.dumps(value, ensure_ascii=False, default=str), ttl=ttl)
self._full_key(key), except Exception:
int(ttl if ttl is not None else self.default_ttl), return
json.dumps(value, ensure_ascii=False, default=str),
) def sadd(self, key: str, *members: str) -> None:
if not self.enabled or self._client is None:
return
normalized = [str(member or "").strip() for member in members if str(member or "").strip()]
if not normalized:
return
try:
self._client.sadd(self._full_key(key), *normalized)
except Exception:
return
def srem(self, key: str, *members: str) -> None:
if not self.enabled or self._client is None:
return
normalized = [str(member or "").strip() for member in members if str(member or "").strip()]
if not normalized:
return
try:
self._client.srem(self._full_key(key), *normalized)
except Exception:
return
def smembers(self, key: str) -> set[str]:
if not self.enabled or self._client is None:
return set()
try:
rows = self._client.smembers(self._full_key(key))
return {str(row or "").strip() for row in rows if str(row or "").strip()}
except Exception:
return set()
def expire(self, key: str, ttl: int) -> None:
if not self.enabled or self._client is None:
return
try:
self._client.expire(self._full_key(key), max(1, int(ttl)))
except Exception: except Exception:
return return
@ -85,4 +139,4 @@ class RedisCache:
cache = RedisCache() cache = RedisCache()
auth_cache = RedisCache(prefix_override=f"{REDIS_PREFIX}_auth")

View File

@ -11,6 +11,7 @@ from core.settings import (
) )
# Ensure table models are registered in SQLModel metadata before create_all. # Ensure table models are registered in SQLModel metadata before create_all.
from models import auth as _auth_models # noqa: F401
from models import bot as _bot_models # noqa: F401 from models import bot as _bot_models # noqa: F401
from models import platform as _platform_models # noqa: F401 from models import platform as _platform_models # noqa: F401
from models import skill as _skill_models # noqa: F401 from models import skill as _skill_models # noqa: F401
@ -32,6 +33,8 @@ BOT_MESSAGE_TABLE = "bot_message"
BOT_IMAGE_TABLE = "bot_image" BOT_IMAGE_TABLE = "bot_image"
BOT_REQUEST_USAGE_TABLE = "bot_request_usage" BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event" BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
SYS_LOGIN_LOG_TABLE = "sys_login_log"
LEGACY_AUTH_LOGIN_LOG_TABLE = "auth_login_log"
SYS_SETTING_TABLE = "sys_setting" SYS_SETTING_TABLE = "sys_setting"
POSTGRES_MIGRATION_LOCK_KEY = 2026031801 POSTGRES_MIGRATION_LOCK_KEY = 2026031801
@ -58,6 +61,14 @@ def _release_migration_lock(lock_conn) -> None:
lock_conn.close() lock_conn.close()
def _rename_table_if_needed(old_name: str, new_name: str) -> None:
inspector = inspect(engine)
if not inspector.has_table(old_name) or inspector.has_table(new_name):
return
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE {_quote_ident(old_name)} RENAME TO {_quote_ident(new_name)}"))
conn.commit()
def _ensure_botinstance_columns() -> None: def _ensure_botinstance_columns() -> None:
required_columns = { required_columns = {
"current_state": "TEXT DEFAULT 'IDLE'", "current_state": "TEXT DEFAULT 'IDLE'",
@ -133,6 +144,34 @@ def _ensure_bot_request_usage_columns() -> None:
conn.commit() conn.commit()
def _migrate_auth_login_log_table() -> None:
_rename_table_if_needed(LEGACY_AUTH_LOGIN_LOG_TABLE, SYS_LOGIN_LOG_TABLE)
def _ensure_auth_login_log_columns() -> None:
required_columns = {
"auth_type": "TEXT NOT NULL DEFAULT 'bot'",
"token_hash": "TEXT",
"auth_source": "TEXT NOT NULL DEFAULT ''",
"revoke_reason": "TEXT",
"device_info": "TEXT",
}
inspector = inspect(engine)
if not inspector.has_table(SYS_LOGIN_LOG_TABLE):
return
with engine.connect() as conn:
existing = {
str(row.get("name"))
for row in inspect(conn).get_columns(SYS_LOGIN_LOG_TABLE)
if row.get("name")
}
for col, ddl in required_columns.items():
if col in existing:
continue
conn.execute(text(f"ALTER TABLE {SYS_LOGIN_LOG_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit()
def _ensure_topic_columns() -> None: def _ensure_topic_columns() -> None:
required_columns = { required_columns = {
"topic_topic": { "topic_topic": {
@ -215,6 +254,7 @@ def align_postgres_sequences() -> None:
if engine.dialect.name != "postgresql": if engine.dialect.name != "postgresql":
return return
sequence_targets = [ sequence_targets = [
(SYS_LOGIN_LOG_TABLE, "id"),
(BOT_MESSAGE_TABLE, "id"), (BOT_MESSAGE_TABLE, "id"),
(BOT_REQUEST_USAGE_TABLE, "id"), (BOT_REQUEST_USAGE_TABLE, "id"),
(BOT_ACTIVITY_EVENT_TABLE, "id"), (BOT_ACTIVITY_EVENT_TABLE, "id"),
@ -247,7 +287,9 @@ def align_postgres_sequences() -> None:
def init_database() -> None: def init_database() -> None:
lock_conn = _acquire_migration_lock() lock_conn = _acquire_migration_lock()
try: try:
_migrate_auth_login_log_table()
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_ensure_auth_login_log_columns()
_ensure_sys_setting_columns() _ensure_sys_setting_columns()
_ensure_bot_request_usage_columns() _ensure_bot_request_usage_columns()
_ensure_botinstance_columns() _ensure_botinstance_columns()

View File

@ -76,6 +76,32 @@ def _env_extensions(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
return tuple(rows) return tuple(rows)
def _normalize_origin(raw: str) -> str:
text = str(raw or "").strip()
if not text:
return ""
try:
parsed = urlsplit(text)
except Exception:
return ""
scheme = str(parsed.scheme or "").strip().lower()
netloc = str(parsed.netloc or "").strip().lower()
if scheme not in {"http", "https"} or not netloc:
return ""
return urlunsplit((scheme, netloc, "", "", ""))
def _env_origins(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
raw = os.getenv(name)
source = list(default) if raw is None else re.split(r"[,;\s]+", str(raw))
rows: list[str] = []
for item in source:
origin = _normalize_origin(item)
if origin and origin not in rows:
rows.append(origin)
return tuple(rows)
def _normalize_dir_path(path_value: str) -> str: def _normalize_dir_path(path_value: str) -> str:
raw = str(path_value or "").strip() raw = str(path_value or "").strip()
if not raw: if not raw:
@ -158,6 +184,8 @@ DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
DEFAULT_PAGE_SIZE: Final[int] = 10 DEFAULT_PAGE_SIZE: Final[int] = 10
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600) DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600)
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_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"
@ -198,6 +226,15 @@ REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot" REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400) REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
CORS_ALLOWED_ORIGINS: Final[tuple[str, ...]] = _env_origins(
"CORS_ALLOWED_ORIGINS",
(
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173",
),
)
APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip() APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip()
APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535) APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)

View File

@ -0,0 +1,23 @@
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class AuthLoginLog(SQLModel, table=True):
__tablename__ = "sys_login_log"
id: Optional[int] = Field(default=None, primary_key=True)
auth_type: str = Field(index=True) # panel | bot
token_hash: str = Field(index=True, unique=True)
subject_id: str = Field(index=True)
bot_id: Optional[str] = Field(default=None, index=True)
auth_source: str = Field(default="", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
expires_at: datetime = Field(index=True)
last_seen_at: datetime = Field(default_factory=datetime.utcnow, index=True)
revoked_at: Optional[datetime] = Field(default=None, index=True)
revoke_reason: Optional[str] = Field(default=None)
client_ip: Optional[str] = Field(default=None)
user_agent: Optional[str] = Field(default=None)
device_info: Optional[str] = Field(default=None)

View File

@ -7,6 +7,8 @@ class PlatformSettingsPayload(BaseModel):
page_size: int = Field(default=10, ge=1, le=100) page_size: int = Field(default=10, ge=1, le=100)
chat_pull_page_size: int = Field(default=60, ge=10, le=500) chat_pull_page_size: int = Field(default=60, ge=10, le=500)
command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600) command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600)
auth_token_ttl_hours: int = Field(default=24, ge=1, le=720)
auth_token_max_active: int = Field(default=2, ge=1, le=20)
upload_max_mb: int = Field(default=100, ge=1, le=2048) upload_max_mb: int = Field(default=100, ge=1, le=2048)
allowed_attachment_extensions: List[str] = Field(default_factory=list) allowed_attachment_extensions: List[str] = Field(default_factory=list)
workspace_download_extensions: List[str] = Field(default_factory=list) workspace_download_extensions: List[str] = Field(default_factory=list)
@ -63,6 +65,31 @@ class PlatformUsageResponse(BaseModel):
analytics: PlatformUsageAnalytics analytics: PlatformUsageAnalytics
class PlatformLoginLogItem(BaseModel):
id: int
auth_type: str
subject_id: str
bot_id: Optional[str] = None
auth_source: str
client_ip: Optional[str] = None
user_agent: Optional[str] = None
device_info: Optional[str] = None
created_at: str
last_seen_at: Optional[str] = None
expires_at: Optional[str] = None
revoked_at: Optional[str] = None
revoke_reason: Optional[str] = None
status: str
class PlatformLoginLogResponse(BaseModel):
items: List[PlatformLoginLogItem]
total: int
limit: int
offset: int
has_more: bool
class PlatformActivityItem(BaseModel): class PlatformActivityItem(BaseModel):
id: int id: int
bot_id: str bot_id: str

View File

@ -0,0 +1,206 @@
from __future__ import annotations
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
from fastapi import WebSocket
from sqlmodel import Session
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance
from services.bot_channel_service import _get_bot_channels_from_config
from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance
from services.bot_storage_service import _read_bot_config, _read_cron_store, _write_bot_config, _write_cron_store
from services.platform_auth_service import resolve_bot_websocket_auth, resolve_panel_websocket_auth
def _now_ms() -> int:
return int(time.time() * 1000)
def _get_bot_or_raise(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise LookupError("Bot not found")
return bot
def _weixin_state_file_path(bot_id: str) -> Path:
return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json"
def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]:
current_ms = int(now_ms or _now_ms())
kind = str(schedule.get("kind") or "").strip().lower()
if kind == "at":
at_ms = int(schedule.get("atMs") or 0)
return at_ms if at_ms > current_ms else None
if kind == "every":
every_ms = int(schedule.get("everyMs") or 0)
return current_ms + every_ms if every_ms > 0 else None
if kind == "cron":
expr = str(schedule.get("expr") or "").strip()
if not expr:
return None
try:
from croniter import croniter
tz_name = str(schedule.get("tz") or "").strip()
tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo
base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz)
next_dt = croniter(expr, base_dt).get_next(datetime)
return int(next_dt.timestamp() * 1000)
except Exception:
return None
return None
def get_bot_logs(
session: Session,
*,
bot_id: str,
tail: Optional[int] = 300,
offset: int = 0,
limit: Optional[int] = None,
reverse: bool = False,
) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id)
if limit is not None:
page = docker_manager.get_logs_page(
bot_id,
offset=max(0, int(offset)),
limit=max(1, int(limit)),
reverse=bool(reverse),
)
return {"bot_id": bot_id, **page}
effective_tail = max(1, int(tail or 300))
return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=effective_tail)}
async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_raise(session, bot_id)
weixin_channel = next(
(
row
for row in _get_bot_channels_from_config(bot)
if str(row.get("channel_type") or "").strip().lower() == "weixin"
),
None,
)
if not weixin_channel:
raise ValueError("Weixin channel not found")
state_file = _weixin_state_file_path(bot_id)
removed = False
try:
if state_file.is_file():
state_file.unlink()
removed = True
except Exception as exc:
raise RuntimeError(f"Failed to remove weixin state: {exc}") from exc
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {}
weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
weixin_cfg.pop("token", None)
_write_bot_config(bot_id, config_data)
restarted = False
if str(bot.docker_status or "").upper() == "RUNNING":
stop_bot_instance(session, bot_id)
await start_bot_instance(session, bot_id)
restarted = True
return {
"status": "relogin_started",
"bot_id": bot_id,
"removed_state": removed,
"restarted": restarted,
}
def list_cron_jobs(session: Session, *, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id)
store = _read_cron_store(bot_id)
rows = []
for row in store.get("jobs", []):
if not isinstance(row, dict):
continue
enabled = bool(row.get("enabled", True))
if not include_disabled and not enabled:
continue
rows.append(row)
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
def stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id)
store = _read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
found = next((row for row in jobs if isinstance(row, dict) and str(row.get("id")) == job_id), None)
if not found:
raise LookupError("Cron job not found")
found["enabled"] = False
found["updatedAtMs"] = _now_ms()
state = found.get("state")
if not isinstance(state, dict):
state = {}
found["state"] = state
state["nextRunAtMs"] = None
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "stopped", "job_id": job_id}
def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id)
store = _read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
found = next((row for row in jobs if isinstance(row, dict) and str(row.get("id")) == job_id), None)
if not found:
raise LookupError("Cron job not found")
found["enabled"] = True
found["updatedAtMs"] = _now_ms()
state = found.get("state")
if not isinstance(state, dict):
state = {}
found["state"] = state
schedule = found.get("schedule")
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {})
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "started", "job_id": job_id}
def delete_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]:
_get_bot_or_raise(session, bot_id)
store = _read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
if len(kept) == len(jobs):
raise LookupError("Cron job not found")
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
return {"status": "deleted", "job_id": job_id}
def ensure_monitor_websocket_access(session: Session, websocket: WebSocket, bot_id: str) -> BotInstance:
principal = resolve_panel_websocket_auth(session, websocket)
if not principal.authenticated:
principal = resolve_bot_websocket_auth(session, websocket, bot_id)
if not principal.authenticated:
raise PermissionError("Bot or panel authentication required")
return _get_bot_or_raise(session, bot_id)

View File

@ -0,0 +1,480 @@
from __future__ import annotations
import hashlib
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Mapping, Optional
from fastapi import Request, Response, WebSocket
from sqlmodel import Session, select
from core.cache import auth_cache
from core.settings import PANEL_ACCESS_PASSWORD
from models.auth import AuthLoginLog
from models.bot import BotInstance
from services.platform_settings_service import get_auth_token_max_active, get_auth_token_ttl_hours
PANEL_TOKEN_COOKIE = "nanobot_panel_token"
BOT_TOKEN_COOKIE_PREFIX = "nanobot_bot_token_"
PANEL_SUBJECT_ID = "panel_admin"
AUTH_STORE_SET_TTL_BUFFER_SECONDS = 300
SESSION_TOUCH_INTERVAL_SECONDS = 300
@dataclass(frozen=True)
class AuthPrincipal:
auth_type: str
subject_id: str
bot_id: Optional[str]
authenticated: bool
auth_source: str
audit_id: Optional[int] = None
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_token(raw: str) -> str:
return str(raw or "").strip()
def _hash_session_token(raw: str) -> str:
return hashlib.sha256(_normalize_token(raw).encode("utf-8")).hexdigest()
def _normalize_bot_cookie_name(bot_id: str) -> str:
safe_bot_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(bot_id or "").strip())
return f"{BOT_TOKEN_COOKIE_PREFIX}{safe_bot_id or 'bot'}"
def _token_key(token_hash: str) -> str:
return f"token:{str(token_hash or '').strip()}"
def _principal_tokens_key(auth_type: str, subject_id: str, bot_id: Optional[str] = None) -> str:
normalized_type = str(auth_type or "").strip().lower() or "unknown"
normalized_subject = re.sub(r"[^a-zA-Z0-9_.:-]+", "_", str(subject_id or "").strip() or "anonymous")
normalized_bot_id = re.sub(r"[^a-zA-Z0-9_.:-]+", "_", str(bot_id or "").strip()) if bot_id else ""
return f"principal:{normalized_type}:{normalized_subject}:{normalized_bot_id or '-'}"
def _auth_token_ttl_seconds(session: Session) -> int:
return max(1, int(get_auth_token_ttl_hours(session))) * 60 * 60
def _auth_token_max_active(session: Session) -> int:
return max(1, int(get_auth_token_max_active(session)))
def _touch_session(session: Session, row: AuthLoginLog) -> None:
now = _utcnow()
last_seen = row.last_seen_at or row.created_at or now
if (now - last_seen).total_seconds() < SESSION_TOUCH_INTERVAL_SECONDS:
return
row.last_seen_at = now
session.add(row)
session.commit()
def _summarize_device(user_agent: str) -> str:
normalized = str(user_agent or "").strip().lower()
if not normalized:
return "Unknown Device"
browser = "Unknown Browser"
if "edg/" in normalized:
browser = "Edge"
elif "chrome/" in normalized and "edg/" not in normalized:
browser = "Chrome"
elif "safari/" in normalized and "chrome/" not in normalized:
browser = "Safari"
elif "firefox/" in normalized:
browser = "Firefox"
platform = "Desktop"
if "iphone" in normalized:
platform = "iPhone"
elif "ipad" in normalized:
platform = "iPad"
elif "android" in normalized:
platform = "Android"
elif "mac os x" in normalized or "macintosh" in normalized:
platform = "macOS"
elif "windows" in normalized:
platform = "Windows"
elif "linux" in normalized:
platform = "Linux"
return f"{platform} / {browser}"
def _extract_client_ip(request: Request) -> str:
forwarded = str(request.headers.get("x-forwarded-for") or "").strip()
if forwarded:
return forwarded.split(",")[0].strip()[:120]
return str(getattr(request.client, "host", "") or "")[:120]
def _get_bearer_token(headers: Mapping[str, Any]) -> str:
authorization = str(headers.get("authorization") or headers.get("Authorization") or "").strip()
if not authorization.lower().startswith("bearer "):
return ""
return _normalize_token(authorization[7:])
def _read_panel_token(request: Request) -> str:
cookie_token = _normalize_token(request.cookies.get(PANEL_TOKEN_COOKIE) or "")
return cookie_token or _get_bearer_token(request.headers)
def _read_bot_token(request: Request, bot_id: str) -> str:
cookie_token = _normalize_token(request.cookies.get(_normalize_bot_cookie_name(bot_id)) or "")
return cookie_token or _get_bearer_token(request.headers)
def _read_panel_token_ws(websocket: WebSocket) -> str:
cookie_token = _normalize_token(websocket.cookies.get(PANEL_TOKEN_COOKIE) or "")
return cookie_token or _get_bearer_token(websocket.headers)
def _read_bot_token_ws(websocket: WebSocket, bot_id: str) -> str:
cookie_token = _normalize_token(websocket.cookies.get(_normalize_bot_cookie_name(bot_id)) or "")
return cookie_token or _get_bearer_token(websocket.headers)
def _is_panel_auth_enabled() -> bool:
return bool(str(PANEL_ACCESS_PASSWORD or "").strip())
def _get_bot_access_password(session: Session, bot_id: str) -> str:
bot = session.get(BotInstance, bot_id)
if not bot:
return ""
return str(bot.access_password or "").strip()
def _is_bot_access_enabled(session: Session, bot_id: str) -> bool:
return bool(_get_bot_access_password(session, bot_id))
def _resolve_bot_auth_source(session: Session, bot_id: str) -> str:
return "bot_password" if _is_bot_access_enabled(session, bot_id) else "bot_public"
def _active_token_payload(token_hash: str) -> Optional[dict[str, Any]]:
payload = auth_cache.get_json(_token_key(token_hash))
return payload if isinstance(payload, dict) else None
def _principal_from_payload(payload: dict[str, Any]) -> tuple[str, str, Optional[str]]:
auth_type = str(payload.get("auth_type") or "").strip().lower()
subject_id = str(payload.get("subject_id") or "").strip()
bot_id = str(payload.get("bot_id") or "").strip() or None
return auth_type, subject_id, bot_id
def _mark_audit_revoked(session: Session, token_hash: str, *, reason: str) -> None:
row = session.exec(
select(AuthLoginLog).where(AuthLoginLog.token_hash == token_hash).limit(1)
).first()
if not row:
return
now = _utcnow()
row.last_seen_at = now
if row.revoked_at is None:
row.revoked_at = now
row.revoke_reason = str(reason or "").strip()[:120] or row.revoke_reason
session.add(row)
session.commit()
def _revoke_token_hash(session: Session, token_hash: str, *, reason: str) -> None:
normalized_hash = str(token_hash or "").strip()
if not normalized_hash:
return
payload = _active_token_payload(normalized_hash)
if payload:
auth_type, subject_id, bot_id = _principal_from_payload(payload)
auth_cache.delete(_token_key(normalized_hash))
auth_cache.srem(_principal_tokens_key(auth_type, subject_id, bot_id), normalized_hash)
_mark_audit_revoked(session, normalized_hash, reason=reason)
def _revoke_raw_token(session: Session, raw_token: str, *, reason: str) -> None:
token = _normalize_token(raw_token)
if not token:
return
_revoke_token_hash(session, _hash_session_token(token), reason=reason)
def _cleanup_principal_set(session: Session, principal_key: str) -> list[tuple[int, str]]:
active_rows: list[tuple[int, str]] = []
stale_hashes: list[str] = []
for token_hash in auth_cache.smembers(principal_key):
payload = _active_token_payload(token_hash)
if not payload:
stale_hashes.append(token_hash)
continue
issued_at_ts = int(payload.get("issued_at_ts") or 0)
active_rows.append((issued_at_ts, token_hash))
if stale_hashes:
auth_cache.srem(principal_key, *stale_hashes)
for stale_hash in stale_hashes:
_mark_audit_revoked(session, stale_hash, reason="expired")
return sorted(active_rows, key=lambda row: (row[0], row[1]))
def _ensure_auth_store_available() -> None:
if auth_cache.enabled:
return
raise RuntimeError("Redis authentication store is unavailable")
def _persist_token_payload(
session: Session,
*,
row: AuthLoginLog,
raw_token: str,
ttl_seconds: int,
) -> None:
token_hash = _hash_session_token(raw_token)
payload = {
"auth_type": row.auth_type,
"subject_id": row.subject_id,
"bot_id": row.bot_id,
"auth_source": row.auth_source,
"issued_at": row.created_at.isoformat() + "Z",
"issued_at_ts": int(row.created_at.timestamp()),
"expires_at": row.expires_at.isoformat() + "Z",
"audit_id": int(row.id or 0),
}
principal_key = _principal_tokens_key(row.auth_type, row.subject_id, row.bot_id)
auth_cache.set_json(_token_key(token_hash), payload, ttl=ttl_seconds)
auth_cache.sadd(principal_key, token_hash)
auth_cache.expire(principal_key, ttl_seconds + AUTH_STORE_SET_TTL_BUFFER_SECONDS)
if not _active_token_payload(token_hash):
row.revoked_at = _utcnow()
row.revoke_reason = "store_write_failed"
session.add(row)
session.commit()
raise RuntimeError("Failed to persist authentication token")
def _enforce_token_limit(
session: Session,
*,
auth_type: str,
subject_id: str,
bot_id: Optional[str],
max_active: int,
) -> None:
principal_key = _principal_tokens_key(auth_type, subject_id, bot_id)
rows = _cleanup_principal_set(session, principal_key)
overflow = max(0, len(rows) - max_active + 1)
if overflow <= 0:
return
for _, token_hash in rows[:overflow]:
_revoke_token_hash(session, token_hash, reason="concurrency_limit")
def _create_audit_row(
session: Session,
*,
request: Request,
auth_type: str,
subject_id: str,
bot_id: Optional[str],
raw_token: str,
expires_at: datetime,
auth_source: str,
) -> AuthLoginLog:
now = _utcnow()
row = AuthLoginLog(
auth_type=auth_type,
token_hash=_hash_session_token(raw_token),
subject_id=subject_id,
bot_id=bot_id,
auth_source=auth_source,
created_at=now,
expires_at=expires_at,
last_seen_at=now,
client_ip=_extract_client_ip(request),
user_agent=str(request.headers.get("user-agent") or "")[:500],
device_info=_summarize_device(str(request.headers.get("user-agent") or ""))[:255],
)
session.add(row)
session.commit()
session.refresh(row)
return row
def _create_auth_token(
session: Session,
*,
request: Request,
auth_type: str,
subject_id: str,
bot_id: Optional[str],
auth_source: str,
) -> str:
_ensure_auth_store_available()
ttl_seconds = _auth_token_ttl_seconds(session)
max_active = _auth_token_max_active(session)
_enforce_token_limit(
session,
auth_type=auth_type,
subject_id=subject_id,
bot_id=bot_id,
max_active=max_active,
)
raw_token = secrets.token_urlsafe(32)
row = _create_audit_row(
session,
request=request,
auth_type=auth_type,
subject_id=subject_id,
bot_id=bot_id,
raw_token=raw_token,
expires_at=_utcnow() + timedelta(seconds=ttl_seconds),
auth_source=auth_source,
)
_persist_token_payload(session, row=row, raw_token=raw_token, ttl_seconds=ttl_seconds)
return raw_token
def create_panel_token(session: Session, request: Request) -> str:
revoke_panel_token(session, request, reason="superseded")
return _create_auth_token(
session,
request=request,
auth_type="panel",
subject_id=PANEL_SUBJECT_ID,
bot_id=None,
auth_source="panel_password",
)
def create_bot_token(session: Session, request: Request, bot_id: str) -> str:
normalized_bot_id = str(bot_id or "").strip()
revoke_bot_token(session, request, normalized_bot_id, reason="superseded")
return _create_auth_token(
session,
request=request,
auth_type="bot",
subject_id=normalized_bot_id,
bot_id=normalized_bot_id,
auth_source=_resolve_bot_auth_source(session, normalized_bot_id),
)
def revoke_panel_token(session: Session, request: Request, reason: str = "logout") -> None:
_revoke_raw_token(session, _read_panel_token(request), reason=reason)
def revoke_bot_token(session: Session, request: Request, bot_id: str, reason: str = "logout") -> None:
_revoke_raw_token(session, _read_bot_token(request, bot_id), reason=reason)
def _set_cookie(response: Response, request: Request, name: str, raw_token: str, max_age: int) -> None:
response.set_cookie(
name,
raw_token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=str(request.url.scheme).lower() == "https",
path="/",
)
def set_panel_token_cookie(response: Response, request: Request, raw_token: str, session: Session) -> None:
_set_cookie(response, request, PANEL_TOKEN_COOKIE, raw_token, _auth_token_ttl_seconds(session))
def set_bot_token_cookie(response: Response, request: Request, bot_id: str, raw_token: str, session: Session) -> None:
_set_cookie(response, request, _normalize_bot_cookie_name(bot_id), raw_token, _auth_token_ttl_seconds(session))
def clear_panel_token_cookie(response: Response) -> None:
response.delete_cookie(PANEL_TOKEN_COOKIE, path="/")
def clear_bot_token_cookie(response: Response, bot_id: str) -> None:
response.delete_cookie(_normalize_bot_cookie_name(bot_id), path="/")
def _resolve_token_auth(
session: Session,
*,
raw_token: str,
expected_type: str,
bot_id: Optional[str] = None,
) -> AuthPrincipal:
token = _normalize_token(raw_token)
normalized_bot_id = str(bot_id or "").strip() or None
if not token:
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
payload = _active_token_payload(_hash_session_token(token))
if not payload:
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
auth_type, subject_id, payload_bot_id = _principal_from_payload(payload)
if auth_type != expected_type:
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
if expected_type == "bot" and payload_bot_id != normalized_bot_id:
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
expires_at_raw = str(payload.get("expires_at") or "").strip()
if expires_at_raw:
try:
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", ""))
if expires_at <= _utcnow():
_revoke_token_hash(session, _hash_session_token(token), reason="expired")
return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing")
except Exception:
pass
row_id = int(payload.get("audit_id") or 0) or None
if row_id is not None:
row = session.get(AuthLoginLog, row_id)
if row is not None:
_touch_session(session, row)
return AuthPrincipal(expected_type, subject_id, payload_bot_id, True, f"{expected_type}_token", row_id)
def resolve_panel_request_auth(session: Session, request: Request) -> AuthPrincipal:
if not _is_panel_auth_enabled():
return AuthPrincipal("panel", PANEL_SUBJECT_ID, None, True, "unprotected")
return _resolve_token_auth(session, raw_token=_read_panel_token(request), expected_type="panel")
def resolve_bot_request_auth(session: Session, request: Request, bot_id: str) -> AuthPrincipal:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return AuthPrincipal("bot", "", None, False, "missing")
return _resolve_token_auth(
session,
raw_token=_read_bot_token(request, normalized_bot_id),
expected_type="bot",
bot_id=normalized_bot_id,
)
def resolve_panel_websocket_auth(session: Session, websocket: WebSocket) -> AuthPrincipal:
if not _is_panel_auth_enabled():
return AuthPrincipal("panel", PANEL_SUBJECT_ID, None, True, "unprotected")
return _resolve_token_auth(session, raw_token=_read_panel_token_ws(websocket), expected_type="panel")
def resolve_bot_websocket_auth(session: Session, websocket: WebSocket, bot_id: str) -> AuthPrincipal:
normalized_bot_id = str(bot_id or "").strip()
if not normalized_bot_id:
return AuthPrincipal("bot", "", None, False, "missing")
return _resolve_token_auth(
session,
raw_token=_read_bot_token_ws(websocket, normalized_bot_id),
expected_type="bot",
bot_id=normalized_bot_id,
)

View File

@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import func, or_
from sqlmodel import Session, select
from models.auth import AuthLoginLog
from schemas.platform import PlatformLoginLogItem, PlatformLoginLogResponse
def _to_iso(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
return value.isoformat() + "Z"
def _normalize_status(value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"active", "revoked"}:
return normalized
return "all"
def list_login_logs(
session: Session,
*,
search: str = "",
auth_type: str = "",
status: str = "all",
limit: int = 50,
offset: int = 0,
) -> PlatformLoginLogResponse:
normalized_search = str(search or "").strip()
normalized_type = str(auth_type or "").strip().lower()
normalized_status = _normalize_status(status)
normalized_limit = max(1, min(200, int(limit or 50)))
normalized_offset = max(0, int(offset or 0))
stmt = select(AuthLoginLog)
count_stmt = select(func.count()).select_from(AuthLoginLog)
if normalized_type in {"panel", "bot"}:
stmt = stmt.where(AuthLoginLog.auth_type == normalized_type)
count_stmt = count_stmt.where(AuthLoginLog.auth_type == normalized_type)
if normalized_status == "active":
stmt = stmt.where(AuthLoginLog.revoked_at == None) # noqa: E711
count_stmt = count_stmt.where(AuthLoginLog.revoked_at == None) # noqa: E711
elif normalized_status == "revoked":
stmt = stmt.where(AuthLoginLog.revoked_at != None) # noqa: E711
count_stmt = count_stmt.where(AuthLoginLog.revoked_at != None) # noqa: E711
if normalized_search:
like_value = f"%{normalized_search}%"
search_filter = or_(
AuthLoginLog.subject_id.ilike(like_value),
AuthLoginLog.bot_id.ilike(like_value),
AuthLoginLog.client_ip.ilike(like_value),
AuthLoginLog.device_info.ilike(like_value),
AuthLoginLog.user_agent.ilike(like_value),
AuthLoginLog.auth_source.ilike(like_value),
AuthLoginLog.revoke_reason.ilike(like_value),
)
stmt = stmt.where(search_filter)
count_stmt = count_stmt.where(search_filter)
total = int(session.exec(count_stmt).one() or 0)
rows = session.exec(
stmt.order_by(AuthLoginLog.created_at.desc(), AuthLoginLog.id.desc()).offset(normalized_offset).limit(normalized_limit)
).all()
items = [
PlatformLoginLogItem(
id=int(row.id or 0),
auth_type=row.auth_type,
subject_id=row.subject_id,
bot_id=row.bot_id,
auth_source=str(row.auth_source or ""),
client_ip=row.client_ip,
user_agent=row.user_agent,
device_info=row.device_info,
created_at=_to_iso(row.created_at) or "",
last_seen_at=_to_iso(row.last_seen_at),
expires_at=_to_iso(row.expires_at),
revoked_at=_to_iso(row.revoked_at),
revoke_reason=row.revoke_reason,
status="revoked" if row.revoked_at else "active",
)
for row in rows
]
return PlatformLoginLogResponse(
items=items,
total=total,
limit=normalized_limit,
offset=normalized_offset,
has_more=normalized_offset + len(items) < total,
)

View File

@ -32,6 +32,8 @@ def default_platform_settings() -> PlatformSettingsPayload:
page_size=int(bootstrap["page_size"]), page_size=int(bootstrap["page_size"]),
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]), chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]), command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]),
auth_token_max_active=int(bootstrap["auth_token_max_active"]),
upload_max_mb=int(bootstrap["upload_max_mb"]), upload_max_mb=int(bootstrap["upload_max_mb"]),
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
@ -52,6 +54,14 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload:
1, 1,
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])), min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
) )
merged["auth_token_ttl_hours"] = max(
1,
min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])),
)
merged["auth_token_max_active"] = max(
1,
min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])),
)
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"]) merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
merged["allowed_attachment_extensions"] = _normalize_extension_list( merged["allowed_attachment_extensions"] = _normalize_extension_list(
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]) data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
@ -68,6 +78,8 @@ def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -
page_size=max(1, min(100, int(payload.page_size))), page_size=max(1, min(100, int(payload.page_size))),
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))), chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))), command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
auth_token_ttl_hours=max(1, min(720, int(payload.auth_token_ttl_hours))),
auth_token_max_active=max(1, min(20, int(payload.auth_token_max_active))),
upload_max_mb=payload.upload_max_mb, upload_max_mb=payload.upload_max_mb,
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions), allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions),
workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions), workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions),
@ -116,6 +128,14 @@ def get_chat_pull_page_size() -> int:
return get_platform_settings_snapshot().chat_pull_page_size return get_platform_settings_snapshot().chat_pull_page_size
def get_auth_token_ttl_hours(session: Session) -> int:
return get_platform_settings(session).auth_token_ttl_hours
def get_auth_token_max_active(session: Session) -> int:
return get_platform_settings(session).auth_token_max_active
def get_speech_runtime_settings() -> Dict[str, Any]: def get_speech_runtime_settings() -> Dict[str, Any]:
settings = get_platform_settings_snapshot() settings = get_platform_settings_snapshot()
return { return {

View File

@ -4,6 +4,7 @@ from services.platform_activity_service import (
prune_expired_activity_events, prune_expired_activity_events,
record_activity_event, record_activity_event,
) )
from services.platform_login_log_service import list_login_logs
from services.platform_overview_service import build_platform_overview from services.platform_overview_service import build_platform_overview
from services.platform_settings_service import ( from services.platform_settings_service import (
ACTIVITY_EVENT_RETENTION_SETTING_KEY, ACTIVITY_EVENT_RETENTION_SETTING_KEY,
@ -16,6 +17,8 @@ from services.platform_settings_service import (
delete_system_setting, delete_system_setting,
ensure_default_system_settings, ensure_default_system_settings,
get_activity_event_retention_days, get_activity_event_retention_days,
get_auth_token_max_active,
get_auth_token_ttl_hours,
get_allowed_attachment_extensions, get_allowed_attachment_extensions,
get_chat_pull_page_size, get_chat_pull_page_size,
get_page_size, get_page_size,

View File

@ -7,6 +7,8 @@ from typing import Any, Dict, List
from sqlmodel import Session from sqlmodel import Session
from core.settings import ( from core.settings import (
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
DEFAULT_AUTH_TOKEN_TTL_HOURS,
DEFAULT_CHAT_PULL_PAGE_SIZE, DEFAULT_CHAT_PULL_PAGE_SIZE,
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@ -24,6 +26,8 @@ SETTING_KEYS = (
"page_size", "page_size",
"chat_pull_page_size", "chat_pull_page_size",
"command_auto_unlock_seconds", "command_auto_unlock_seconds",
"auth_token_ttl_hours",
"auth_token_max_active",
"upload_max_mb", "upload_max_mb",
"allowed_attachment_extensions", "allowed_attachment_extensions",
"workspace_download_extensions", "workspace_download_extensions",
@ -38,6 +42,10 @@ DEPRECATED_SETTING_KEYS = {
"speech_audio_preprocess", "speech_audio_preprocess",
"speech_audio_filter", "speech_audio_filter",
"speech_initial_prompt", "speech_initial_prompt",
"sys_auth_token_ttl_days",
"auth_token_ttl_days",
"panel_session_ttl_days",
"bot_session_ttl_days",
} }
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"page_size": { "page_size": {
@ -67,6 +75,24 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"is_public": True, "is_public": True,
"sort_order": 9, "sort_order": 9,
}, },
"auth_token_ttl_hours": {
"name": "认证 Token 过期小时数",
"category": "auth",
"description": "Panel 与 Bot 登录 Token 的统一有效时长,单位小时。",
"value_type": "integer",
"value": DEFAULT_AUTH_TOKEN_TTL_HOURS,
"is_public": False,
"sort_order": 10,
},
"auth_token_max_active": {
"name": "认证 Token 最大并发数",
"category": "auth",
"description": "同一主体允许同时活跃的 Token 数量,超过时自动撤销最旧 Token。",
"value_type": "integer",
"value": DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
"is_public": False,
"sort_order": 11,
},
"upload_max_mb": { "upload_max_mb": {
"name": "上传大小限制", "name": "上传大小限制",
"category": "upload", "category": "upload",
@ -74,7 +100,7 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"value_type": "integer", "value_type": "integer",
"value": DEFAULT_UPLOAD_MAX_MB, "value": DEFAULT_UPLOAD_MAX_MB,
"is_public": False, "is_public": False,
"sort_order": 10, "sort_order": 20,
}, },
"allowed_attachment_extensions": { "allowed_attachment_extensions": {
"name": "允许附件后缀", "name": "允许附件后缀",
@ -197,6 +223,18 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]:
1, 1,
600, 600,
), ),
"auth_token_ttl_hours": _legacy_env_int(
"AUTH_TOKEN_TTL_HOURS",
DEFAULT_AUTH_TOKEN_TTL_HOURS,
1,
720,
),
"auth_token_max_active": _legacy_env_int(
"AUTH_TOKEN_MAX_ACTIVE",
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
1,
20,
),
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
"allowed_attachment_extensions": _legacy_env_extensions( "allowed_attachment_extensions": _legacy_env_extensions(
"ALLOWED_ATTACHMENT_EXTENSIONS", "ALLOWED_ATTACHMENT_EXTENSIONS",

View File

@ -1,4 +1,6 @@
from services.platform_runtime_settings_service import ( from services.platform_runtime_settings_service import (
get_auth_token_max_active,
get_auth_token_ttl_hours,
default_platform_settings, default_platform_settings,
get_allowed_attachment_extensions, get_allowed_attachment_extensions,
get_chat_pull_page_size, get_chat_pull_page_size,

View File

@ -1,3 +1,4 @@
import json
from typing import Any, Dict, List from typing import Any, Dict, List
from sqlmodel import Session, select from sqlmodel import Session, select
@ -21,6 +22,14 @@ from services.platform_settings_core import (
) )
def _coerce_auth_ttl_hours_from_legacy(value: Any) -> int:
try:
normalized = int(value)
except Exception:
normalized = 0
return max(1, min(720, normalized * 24))
def ensure_default_system_settings(session: Session) -> None: def ensure_default_system_settings(session: Session) -> None:
bootstrap_values = _bootstrap_platform_setting_values() bootstrap_values = _bootstrap_platform_setting_values()
legacy_row = session.get(PlatformSetting, "global") legacy_row = session.get(PlatformSetting, "global")
@ -46,15 +55,24 @@ def ensure_default_system_settings(session: Session) -> None:
session.delete(legacy_row) session.delete(legacy_row)
session.commit() session.commit()
legacy_auth_ttl_hours = None
dirty = False dirty = False
for key in DEPRECATED_SETTING_KEYS: for key in DEPRECATED_SETTING_KEYS:
legacy_row = session.get(PlatformSetting, key) legacy_row = session.get(PlatformSetting, key)
if legacy_row is not None: if legacy_row is not None:
if key in {"sys_auth_token_ttl_days", "auth_token_ttl_days"} and legacy_auth_ttl_hours is None:
try:
legacy_auth_ttl_hours = _coerce_auth_ttl_hours_from_legacy(_read_setting_value(legacy_row))
except Exception:
legacy_auth_ttl_hours = None
session.delete(legacy_row) session.delete(legacy_row)
dirty = True dirty = True
for key, meta in SYSTEM_SETTING_DEFINITIONS.items(): for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
row = session.get(PlatformSetting, key) row = session.get(PlatformSetting, key)
default_value = bootstrap_values.get(key, meta["value"])
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
default_value = legacy_auth_ttl_hours
if row is None: if row is None:
_upsert_setting_row( _upsert_setting_row(
session, session,
@ -63,22 +81,42 @@ def ensure_default_system_settings(session: Session) -> None:
category=str(meta["category"]), category=str(meta["category"]),
description=str(meta["description"]), description=str(meta["description"]),
value_type=str(meta["value_type"]), value_type=str(meta["value_type"]),
value=bootstrap_values.get(key, meta["value"]), value=default_value,
is_public=bool(meta["is_public"]), is_public=bool(meta["is_public"]),
sort_order=int(meta["sort_order"]), sort_order=int(meta["sort_order"]),
) )
dirty = True dirty = True
continue continue
changed = False changed = False
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
try:
current_value = int(_read_setting_value(row))
except Exception:
current_value = int(meta["value"])
if current_value == int(meta["value"]) and legacy_auth_ttl_hours != current_value:
row.value_type = str(meta["value_type"])
row.value_json = json.dumps(legacy_auth_ttl_hours, ensure_ascii=False)
changed = True
for field in ("name", "category", "description", "value_type"): for field in ("name", "category", "description", "value_type"):
value = str(meta[field]) value = str(meta[field])
if not getattr(row, field): if key in PROTECTED_SETTING_KEYS:
if getattr(row, field) != value:
setattr(row, field, value) setattr(row, field, value)
changed = True changed = True
if getattr(row, "sort_order", None) is None: elif not getattr(row, field):
setattr(row, field, value)
changed = True
if key in PROTECTED_SETTING_KEYS:
if int(getattr(row, "sort_order", 100) or 100) != int(meta["sort_order"]):
row.sort_order = int(meta["sort_order"]) row.sort_order = int(meta["sort_order"])
changed = True changed = True
if getattr(row, "is_public", None) is None: if bool(getattr(row, "is_public", False)) != bool(meta["is_public"]):
row.is_public = bool(meta["is_public"])
changed = True
elif getattr(row, "sort_order", None) is None:
row.sort_order = int(meta["sort_order"])
changed = True
if key not in PROTECTED_SETTING_KEYS and getattr(row, "is_public", None) is None:
row.is_public = bool(meta["is_public"]) row.is_public = bool(meta["is_public"])
changed = True changed = True
if changed: if changed:

View File

@ -21,7 +21,14 @@
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。 - 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。 - 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
### 1.2 低风险重构优先 ### 1.2 领域内聚优先于机械拆分
- 代码拆分的第一判断标准是“是否仍属于同一业务域”,不是“是否还能再拆小”。
- 同一业务域内的读、写、校验、少量编排、少量派生逻辑,可以保留在同一个模块中。
- 如果拆分只会制造多层跳转、隐藏真实依赖、降低可读性,则不应继续拆。
- 真正需要拆分的场景是跨域、跨层、跨边界,而不是单纯文件偏长。
### 1.3 低风险重构优先
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。 - 结构重构优先做“搬运与收口”,不顺手修改业务行为。
- 同一轮改动里,默认**不要**同时做: - 同一轮改动里,默认**不要**同时做:
@ -30,13 +37,13 @@
- 行为修复 - 行为修复
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。 - 如果确实需要行为修复,只允许修复拆分直接引入的问题。
### 1.3 装配层必须薄 ### 1.4 装配层必须薄
- 页面层、路由层、应用启动层都只负责装配。 - 页面层、路由层、应用启动层都只负责装配。
- 装配层可以做依赖注入、状态接线、事件转发。 - 装配层可以做依赖注入、状态接线、事件转发。
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。 - 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
### 1.4 新文件必须按主题命名 ### 1.5 新文件必须按主题命名
- 文件名必须直接表达职责。 - 文件名必须直接表达职责。
- 禁止模糊命名,例如: - 禁止模糊命名,例如:
@ -70,6 +77,12 @@
- `frontend/src/utils` - `frontend/src/utils`
- 真正跨领域的通用工具 - 真正跨领域的通用工具
目录分层的目标是稳定边界,不是把每一段逻辑都拆成独立文件:
- 同一页面域内强关联的视图、状态、交互逻辑,允许在同一模块内靠近放置
- 只有当某段逻辑已经被多个页面或多个子流程稳定复用时,才提炼到更高层级
- 禁止为了“文件更短”而把一个连续可读的页面流程拆成大量来回跳转的小文件
### 2.2 页面文件职责 ### 2.2 页面文件职责
页面文件如: 页面文件如:
@ -83,12 +96,14 @@
- 只做页面装配 - 只做页面装配
- 只组织已有区块、弹层、控制器 hook - 只组织已有区块、弹层、控制器 hook
- 不直接承载长段 API 请求、副作用、数据清洗逻辑 - 不直接承载长段 API 请求、副作用、数据清洗逻辑
- 如果一个页面本身就是单一业务域,并且逻辑连续可读,可以保留适量页面内状态与事件处理
- 不要求为了行数把本来紧密耦合的页面逻辑强拆到多个 hooks / sections / shared 文件中
页面文件目标体量: 页面文件目标体量:
- 目标:`< 500` - 行数只作为预警,不作为硬性拆分依据
- 可接受上限:`800` 行 - 先判断页面是否仍然属于单一业务域、是否能顺序读懂、依赖是否清晰
- 超过 `800` 行必须优先拆出页面控制器 hook 或区块装配组件 - 只有在页面同时承担多个子域、多个弹层流程、多个数据源编排时,才优先拆出页面控制器 hook 或区块装配组件
### 2.3 控制器 hook 规范 ### 2.3 控制器 hook 规范
@ -111,12 +126,15 @@
- 一个 hook 只服务一个明确页面或一个明确子流程 - 一个 hook 只服务一个明确页面或一个明确子流程
- hook 不直接产出大量 JSX - hook 不直接产出大量 JSX
- hook 内部允许组合更小的子 hook但不要为了拆分而拆分 - hook 内部允许组合更小的子 hook但不要为了拆分而拆分
- 如果页面逻辑并不复杂,不要求必须抽出“页面总 hook”
- 只有当副作用编排、状态联动、接口交互已经影响页面可读性时,才值得抽成控制器 hook
控制器 hook 目标体量: 控制器 hook 目标体量:
- 目标:`< 800` - 行数只作为风险提示
- 可接受上限:`1000` 行 - 优先保证 hook 的流程连续、命名清晰、状态收口明确
- 超过 `1000` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api` - 如果继续拆分只会让调用链更深、上下文更难追踪,则不应继续拆
- 只有当 hook 明显同时承载多个子流程时,才按主题拆成子 hook 或把稳定复用逻辑提到 `shared`/`api`
### 2.4 视图组件规范 ### 2.4 视图组件规范
@ -130,6 +148,7 @@
- 视图组件默认不直接请求接口 - 视图组件默认不直接请求接口
- 视图组件只接收已经整理好的 props - 视图组件只接收已经整理好的 props
- 纯视图组件内部不保留与页面强耦合的业务缓存 - 纯视图组件内部不保留与页面强耦合的业务缓存
- 不要求把所有小片段都抽成组件;只在存在明确复用、明显视觉区块、或能显著降低页面噪音时再拆组件
### 2.5 前端复用原则 ### 2.5 前端复用原则
@ -137,6 +156,8 @@
- 三处以上重复,优先考虑抽取 - 三处以上重复,优先考虑抽取
- 同域复用优先放 `modules/<domain>/shared` - 同域复用优先放 `modules/<domain>/shared`
- 跨域复用优先放 `src/components``src/utils` - 跨域复用优先放 `src/components``src/utils`
- 如果抽取后的接口比原地实现更难理解,就不应抽取
- 不允许创建只有单个页面使用、但又被过度包装的“伪复用层”
### 2.6 前端禁止事项 ### 2.6 前端禁止事项
@ -144,6 +165,8 @@
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件 - 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
- 禁止创建无明确职责的超通用组件 - 禁止创建无明确职责的超通用组件
- 禁止为减少行数而做不可读的过度抽象 - 禁止为减少行数而做不可读的过度抽象
- 禁止为了满足结构指标,把单一页面域强拆成大量细碎 hooks、sections、shared 文件
- 禁止新增纯转发、纯包装、无独立语义价值的组件或 hook
--- ---
@ -211,7 +234,7 @@ Router 文件体量规则:
### 3.4 Service 规范 ### 3.4 Service 规范
Service 必须按业务主题拆分 Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎
允许的 service 类型: 允许的 service 类型:
@ -226,15 +249,18 @@ Service 必须按业务主题拆分。
Service 文件规则: Service 文件规则:
- 一个文件只负责一个主题 - 一个文件只负责一个业务域或一个稳定子主题
- 同一文件内允许有私有 helper但 helper 只能服务当前主题 - 同一文件内允许同时包含该域内的查询、写入、校验、少量派生逻辑
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service - 同一文件内允许有私有 helper但 helper 只能服务当前域
- 只有当一个文件已经明显跨域,或者把 router/core/provider 的职责卷入进来时,才必须继续拆分
- 不允许为了“看起来更模块化”而创建纯转发、纯 re-export、纯别名性质的 service 层
Service 体量规则: Service 体量规则:
- 目标:`< 350` - 行数只作为预警信号,不作为机械拆分依据
- 可接受上限:`500` 行 - 优先判断是否仍然保持单一业务域、可顺序阅读、依赖方向清晰
- 超过 `500` 行必须继续拆 - 如果一个文件虽然较大,但域边界稳定、跳转成本低、上下文连续,可以保留
- 如果一个文件即使不大,但已经跨域、跨层、混入无关职责,也必须拆分
### 3.6 Schema 规范 ### 3.6 Schema 规范
@ -290,6 +316,7 @@ Service 体量规则:
- 禁止回到“大文件集中堆功能”的开发方式 - 禁止回到“大文件集中堆功能”的开发方式
- 禁止为了图省事把新逻辑加回兼容层 - 禁止为了图省事把新逻辑加回兼容层
- 禁止在没有明确复用收益时过度抽象 - 禁止在没有明确复用收益时过度抽象
- 禁止为了满足行数指标而把同一业务域强行拆碎
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议 - 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
--- ---

View File

@ -1,5 +1,5 @@
# Backend API entry # Backend API entry
VITE_API_BASE=http://localhost:8000/api VITE_API_BASE=/api
# Backend WebSocket entry # Backend WebSocket entry
VITE_WS_BASE=ws://localhost:8000/ws/monitor VITE_WS_BASE=/ws/monitor

View File

@ -1075,3 +1075,23 @@ body {
max-height: 84vh; max-height: 84vh;
overflow-y: auto; overflow-y: auto;
} }
.route-loading-shell {
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.route-loading-card {
min-width: min(320px, calc(100vw - 48px));
padding: 18px 20px;
border: 1px solid color-mix(in oklab, var(--line) 80%, transparent);
border-radius: 16px;
background: color-mix(in oklab, var(--panel) 88%, transparent);
color: var(--subtitle);
box-shadow: var(--shadow);
text-align: center;
font-weight: 600;
letter-spacing: 0.01em;
}

View File

@ -1,34 +1,40 @@
import { useEffect, useState, type ReactElement } from 'react'; import { Suspense, lazy, useEffect, useState } from 'react';
import axios from 'axios'; import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, ShieldCheck, SunMedium, X } from 'lucide-react';
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, SunMedium, X } from 'lucide-react';
import { PasswordInput } from './components/PasswordInput'; import { BotRouteAccessGate } from './app/BotRouteAccessGate';
import { PanelLoginGate } from './app/PanelLoginGate';
import { LucentTooltip } from './components/lucent/LucentTooltip'; import { LucentTooltip } from './components/lucent/LucentTooltip';
import { APP_ENDPOINTS } from './config/env';
import { useBotsSync } from './hooks/useBotsSync'; import { useBotsSync } from './hooks/useBotsSync';
import { appEn } from './i18n/app.en'; import { appEn } from './i18n/app.en';
import { appZhCn } from './i18n/app.zh-cn'; import { appZhCn } from './i18n/app.zh-cn';
import { pickLocale } from './i18n'; import { pickLocale } from './i18n';
import { BotHomePage } from './modules/bot-home/BotHomePage';
import { PlatformAdminDashboardPage } from './modules/platform/PlatformAdminDashboardPage';
import { PlatformBotManagementPage } from './modules/platform/PlatformBotManagementPage';
import { PlatformImageManagementPage } from './modules/platform/PlatformImageManagementPage';
import { PlatformSettingsPage } from './modules/platform/components/PlatformSettingsModal';
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { TemplateManagerPage } from './modules/platform/components/TemplateManagerModal';
import { useAppStore } from './store/appStore'; import { useAppStore } from './store/appStore';
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute'; import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
import './components/ui/SharedUi.css'; import './components/ui/SharedUi.css';
import './App.css'; import './App.css';
import './App.h5.css'; import './App.h5.css';
const defaultLoadingTitle = 'Dashboard Nanobot'; const defaultLoadingTitle = 'Dashboard Nanobot';
const LazyBotHomePage = lazy(() => import('./modules/bot-home/BotHomePage').then((module) => ({ default: module.BotHomePage })));
const LazyPlatformAdminDashboardPage = lazy(() => import('./modules/platform/PlatformAdminDashboardPage').then((module) => ({ default: module.PlatformAdminDashboardPage })));
const LazyPlatformBotManagementPage = lazy(() => import('./modules/platform/PlatformBotManagementPage').then((module) => ({ default: module.PlatformBotManagementPage })));
const LazyPlatformImageManagementPage = lazy(() => import('./modules/platform/PlatformImageManagementPage').then((module) => ({ default: module.PlatformImageManagementPage })));
const LazyPlatformLoginLogPage = lazy(() => import('./modules/platform/PlatformLoginLogPage').then((module) => ({ default: module.PlatformLoginLogPage })));
const LazyPlatformSettingsPage = lazy(() => import('./modules/platform/components/PlatformSettingsPage').then((module) => ({ default: module.PlatformSettingsPage })));
const LazySkillMarketManagerPage = lazy(() => import('./modules/platform/components/SkillMarketManagerPage').then((module) => ({ default: module.SkillMarketManagerPage })));
const LazyTemplateManagerPage = lazy(() => import('./modules/platform/components/TemplateManagerPage').then((module) => ({ default: module.TemplateManagerPage })));
type CompactBotPanelTab = 'chat' | 'runtime'; type CompactBotPanelTab = 'chat' | 'runtime';
function AuthenticatedApp() { function RouteLoadingFallback({ label }: { label: string }) {
return (
<div className="route-loading-shell">
<div className="route-loading-card">{label}</div>
</div>
);
}
function AppShell() {
const route = useAppRoute(); const route = useAppRoute();
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
@ -40,13 +46,6 @@ function AuthenticatedApp() {
const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false); const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false);
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false); const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat'); const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
const [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const forcedBotId = route.kind === 'bot' ? route.botId : ''; const forcedBotId = route.kind === 'bot' ? route.botId : '';
useBotsSync(forcedBotId || undefined); useBotsSync(forcedBotId || undefined);
@ -65,9 +64,6 @@ function AuthenticatedApp() {
const forcedBotName = String(forcedBot?.name || '').trim(); const forcedBotName = String(forcedBot?.name || '').trim();
const forcedBotIdLabel = String(forcedBotId || '').trim(); const forcedBotIdLabel = String(forcedBotId || '').trim();
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle; const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle;
const shouldPromptSingleBotPassword = Boolean(
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
);
const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined }); const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined });
const showNavRail = route.kind !== 'bot' && !compactMode; const showNavRail = route.kind !== 'bot' && !compactMode;
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode; const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
@ -81,12 +77,6 @@ function AuthenticatedApp() {
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`; document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]); }, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
useEffect(() => {
setSingleBotUnlocked(false);
setSingleBotPassword('');
setSingleBotPasswordError('');
}, [forcedBotId]);
useEffect(() => { useEffect(() => {
if (!showBotPanelDrawerEntry) { if (!showBotPanelDrawerEntry) {
setBotPanelDrawerOpen(false); setBotPanelDrawerOpen(false);
@ -98,52 +88,6 @@ function AuthenticatedApp() {
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false); if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
}, [route.kind, showAppNavDrawerEntry]); }, [route.kind, showAppNavDrawerEntry]);
useEffect(() => {
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
const stored = getBotAccessPassword(forcedBotId);
if (!stored) return;
let alive = true;
const boot = async () => {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
if (!alive) return;
setBotAccessPassword(forcedBotId, stored);
setSingleBotUnlocked(true);
setSingleBotPassword('');
setSingleBotPasswordError('');
} catch {
clearBotAccessPassword(forcedBotId);
if (!alive) return;
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
}
};
void boot();
return () => {
alive = false;
};
}, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
const unlockSingleBot = async () => {
const entered = String(singleBotPassword || '').trim();
if (!entered || route.kind !== 'bot' || !forcedBotId) {
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
return;
}
setSingleBotSubmitting(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
setBotAccessPassword(forcedBotId, entered);
setSingleBotPasswordError('');
setSingleBotUnlocked(true);
setSingleBotPassword('');
} catch {
clearBotAccessPassword(forcedBotId);
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
} finally {
setSingleBotSubmitting(false);
}
};
const botPanelLabels = t.botPanels; const botPanelLabels = t.botPanels;
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle; const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
const drawerBotId = String(forcedBotId || '').trim() || '-'; const drawerBotId = String(forcedBotId || '').trim() || '-';
@ -171,6 +115,7 @@ function AuthenticatedApp() {
label: 'System', label: 'System',
items: [ items: [
{ kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer }, { kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer },
{ kind: 'system-login-logs', label: isZh ? '登录日志' : 'Login Logs', icon: ShieldCheck },
{ kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText }, { kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText },
{ kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 }, { kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 },
{ kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes }, { kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes },
@ -188,28 +133,32 @@ function AuthenticatedApp() {
const renderRoutePage = () => { const renderRoutePage = () => {
switch (route.kind) { switch (route.kind) {
case 'admin-dashboard': case 'admin-dashboard':
return <PlatformAdminDashboardPage compactMode={compactMode} />; return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
case 'admin-bots': case 'admin-bots':
return <PlatformBotManagementPage compactMode={compactMode} />; return <LazyPlatformBotManagementPage compactMode={compactMode} />;
case 'system-skills': case 'system-skills':
return <SkillMarketManagerPage isZh={isZh} />; return <LazySkillMarketManagerPage isZh={isZh} />;
case 'system-login-logs':
return <LazyPlatformLoginLogPage isZh={isZh} />;
case 'system-templates': case 'system-templates':
return <TemplateManagerPage isZh={isZh} />; return <LazyTemplateManagerPage isZh={isZh} />;
case 'system-settings': case 'system-settings':
return <PlatformSettingsPage isZh={isZh} />; return <LazyPlatformSettingsPage isZh={isZh} />;
case 'system-images': case 'system-images':
return <PlatformImageManagementPage isZh={isZh} />; return <LazyPlatformImageManagementPage isZh={isZh} />;
case 'bot': case 'bot':
return ( return (
<BotHomePage <BotRouteAccessGate bot={forcedBot} botId={forcedBotId}>
<LazyBotHomePage
botId={forcedBotId} botId={forcedBotId}
compactMode={compactMode} compactMode={compactMode}
compactPanelTab={botCompactPanelTab} compactPanelTab={botCompactPanelTab}
onCompactPanelTabChange={setBotCompactPanelTab} onCompactPanelTabChange={setBotCompactPanelTab}
/> />
</BotRouteAccessGate>
); );
default: default:
return <PlatformAdminDashboardPage compactMode={compactMode} />; return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
} }
}; };
@ -343,7 +292,9 @@ function AuthenticatedApp() {
</header> </header>
<main className="main-stage"> <main className="main-stage">
<Suspense fallback={<RouteLoadingFallback label={isZh ? '页面加载中...' : 'Loading page...'} />}>
{renderRoutePage()} {renderRoutePage()}
</Suspense>
</main> </main>
</div> </div>
</div> </div>
@ -458,176 +409,15 @@ function AuthenticatedApp() {
</div> </div>
) : null} ) : null}
{shouldPromptSingleBotPassword ? (
<div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{forcedBot?.name || forcedBotId}</h1>
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={singleBotPassword}
onChange={(event) => {
setSingleBotPassword(event.target.value);
if (singleBotPasswordError) setSingleBotPasswordError('');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') void unlockSingleBot();
}}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus
toggleLabels={passwordToggleLabels}
/>
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
function PanelLoginGate({ children }: { children: ReactElement }) {
const route = useAppRoute();
const { theme, locale } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const bypassPanelGate = route.kind === 'bot';
useEffect(() => {
if (bypassPanelGate) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
let alive = true;
const boot = async () => {
try {
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
if (!alive) return;
const enabled = Boolean(status.data?.enabled);
if (!enabled) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
setRequired(true);
const stored = getPanelAccessPassword();
if (!stored) {
setChecking(false);
return;
}
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
if (!alive) return;
setAuthenticated(true);
} catch {
clearPanelAccessPassword();
if (!alive) return;
setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
} finally {
if (alive) setChecking(false);
}
} catch {
if (!alive) return;
clearPanelAccessPassword();
setRequired(true);
setAuthenticated(false);
setError(locale === 'zh' ? '无法校验面板访问状态,请检查后端连接后重试。' : 'Unable to verify panel access. Check the backend connection and try again.');
setChecking(false);
}
};
void boot();
return () => {
alive = false;
};
}, [bypassPanelGate, locale]);
const onSubmit = async () => {
const next = String(password || '').trim();
if (!next) {
setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.');
return;
}
setSubmitting(true);
setError('');
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
setPanelAccessPassword(next);
setAuthenticated(true);
} catch {
clearPanelAccessPassword();
setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.');
} finally {
setSubmitting(false);
}
};
if (checking) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
</div>
</div>
</div>
);
}
if (required && !authenticated) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit();
}}
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
toggleLabels={passwordToggleLabels}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
</button>
</div>
</div>
</div>
</div>
);
}
return children;
}
function App() { function App() {
const route = useAppRoute();
return ( return (
<PanelLoginGate> <PanelLoginGate bypass={route.kind === 'bot'}>
<AuthenticatedApp /> <AppShell />
</PanelLoginGate> </PanelLoginGate>
); );
} }

View File

@ -0,0 +1,222 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import axios from 'axios';
import { PasswordInput } from '../components/PasswordInput';
import { APP_ENDPOINTS } from '../config/env';
import { useAppStore } from '../store/appStore';
import type { BotState } from '../types/bot';
import {
BOT_AUTH_INVALID_EVENT,
clearBotAccessPassword,
getBotAccessPassword,
setBotAccessPassword,
} from '../utils/botAccess';
interface BotRouteAccessGateProps {
bot?: BotState;
botId: string;
children: ReactNode;
}
const BOT_PASSWORD_LABELS = {
zh: {
checking: '校验中...',
continue: '进入',
errorEmpty: '请输入 Bot 密码。',
errorExpired: '登录已失效,请重新输入 Bot 密码。',
errorInvalid: 'Bot 密码错误,请重试。',
prompt: '请输入该 Bot 的访问密码后继续。',
placeholder: 'Bot 密码',
},
en: {
checking: 'Checking...',
continue: 'Continue',
errorEmpty: 'Enter the bot password.',
errorExpired: 'Your login expired. Enter the bot password again.',
errorInvalid: 'Invalid bot password. Please try again.',
prompt: 'Enter the bot password to continue.',
placeholder: 'Bot password',
},
} as const;
export function BotRouteAccessGate({
bot,
botId,
children,
}: BotRouteAccessGateProps) {
const { locale, setBots } = useAppStore();
const isZh = locale === 'zh';
const copy = isZh ? BOT_PASSWORD_LABELS.zh : BOT_PASSWORD_LABELS.en;
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const normalizedBotId = String(botId || '').trim();
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordEnabled, setPasswordEnabled] = useState(false);
const [authChecking, setAuthChecking] = useState(Boolean(normalizedBotId));
const [unlocked, setUnlocked] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authRefreshNonce, setAuthRefreshNonce] = useState(0);
const refreshBotDetail = useCallback(async () => {
if (!normalizedBotId) return;
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}`);
setBots(res.data ? [res.data] : []);
}, [normalizedBotId, setBots]);
useEffect(() => {
setPasswordEnabled(false);
setAuthChecking(Boolean(normalizedBotId));
setUnlocked(false);
setPassword('');
setPasswordError('');
}, [normalizedBotId]);
useEffect(() => {
if (!normalizedBotId) {
setAuthChecking(false);
return;
}
let alive = true;
const bootstrapBotAuth = async () => {
setAuthChecking(true);
try {
const status = await axios.get<{
enabled?: boolean;
authenticated?: boolean;
}>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/status`);
if (!alive) return;
const enabled = Boolean(status.data?.enabled);
const authenticated = Boolean(status.data?.authenticated);
setPasswordEnabled(enabled);
if (!enabled && !authenticated) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: '' });
if (!alive) return;
setUnlocked(true);
setPassword('');
setPasswordError('');
await refreshBotDetail();
return;
}
setUnlocked(authenticated);
if (authenticated) {
await refreshBotDetail();
}
} catch {
if (!alive) return;
setPasswordEnabled(false);
setUnlocked(false);
} finally {
if (alive) setAuthChecking(false);
}
};
void bootstrapBotAuth();
return () => {
alive = false;
};
}, [authRefreshNonce, normalizedBotId, refreshBotDetail]);
useEffect(() => {
if (typeof window === 'undefined' || !normalizedBotId) return undefined;
const handleBotAuthInvalid = (event: Event) => {
const customEvent = event as CustomEvent<{ botId?: string }>;
const invalidBotId = String(customEvent.detail?.botId || '').trim();
if (!invalidBotId || invalidBotId !== normalizedBotId) return;
setUnlocked(false);
setAuthRefreshNonce((value) => value + 1);
setPassword('');
setPasswordError(passwordEnabled ? copy.errorExpired : '');
setAuthChecking(true);
};
window.addEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener);
return () => window.removeEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener);
}, [copy.errorExpired, normalizedBotId, passwordEnabled]);
useEffect(() => {
if (!normalizedBotId || !passwordEnabled || unlocked) return;
const stored = getBotAccessPassword(normalizedBotId);
if (!stored) return;
let alive = true;
const boot = async () => {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: stored });
if (!alive) return;
setBotAccessPassword(normalizedBotId, stored);
setUnlocked(true);
setPassword('');
setPasswordError('');
await refreshBotDetail();
} catch {
clearBotAccessPassword(normalizedBotId);
if (!alive) return;
setPasswordError(copy.errorInvalid);
}
};
void boot();
return () => {
alive = false;
};
}, [copy.errorInvalid, normalizedBotId, passwordEnabled, refreshBotDetail, unlocked]);
const unlockBot = async () => {
const entered = String(password || '').trim();
if (!entered || !normalizedBotId) {
setPasswordError(copy.errorEmpty);
return;
}
setSubmitting(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: entered });
setBotAccessPassword(normalizedBotId, entered);
setPasswordError('');
setUnlocked(true);
setPassword('');
await refreshBotDetail();
} catch {
clearBotAccessPassword(normalizedBotId);
setPasswordError(copy.errorInvalid);
} finally {
setSubmitting(false);
}
};
const shouldPromptPassword = Boolean(
normalizedBotId && passwordEnabled && !authChecking && !unlocked,
);
return (
<>
{children}
{shouldPromptPassword ? (
<div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{bot?.name || normalizedBotId}</h1>
<p>{copy.prompt}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={password}
onChange={(event) => {
setPassword(event.target.value);
if (passwordError) setPasswordError('');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') void unlockBot();
}}
placeholder={copy.placeholder}
autoFocus
toggleLabels={passwordToggleLabels}
/>
{passwordError ? <div className="app-login-error">{passwordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockBot()} disabled={submitting}>
{submitting ? copy.checking : copy.continue}
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -0,0 +1,179 @@
import { useEffect, useState, type ReactElement } from 'react';
import axios from 'axios';
import { PasswordInput } from '../components/PasswordInput';
import { APP_ENDPOINTS } from '../config/env';
import { appEn } from '../i18n/app.en';
import { appZhCn } from '../i18n/app.zh-cn';
import { pickLocale } from '../i18n';
import { useAppStore } from '../store/appStore';
import {
PANEL_AUTH_INVALID_EVENT,
clearPanelAccessPassword,
getPanelAccessPassword,
setPanelAccessPassword,
} from '../utils/panelAccess';
interface PanelLoginGateProps {
bypass?: boolean;
children: ReactElement;
}
export function PanelLoginGate({
bypass = false,
children,
}: PanelLoginGateProps) {
const { theme, locale } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isZh = locale === 'zh';
const [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
useEffect(() => {
if (bypass) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
let alive = true;
const boot = async () => {
try {
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
if (!alive) return;
const enabled = Boolean(status.data?.enabled);
if (!enabled) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
setRequired(true);
const stored = getPanelAccessPassword();
if (!stored) {
setChecking(false);
return;
}
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
if (!alive) return;
setAuthenticated(true);
} catch {
clearPanelAccessPassword();
if (!alive) return;
setError(isZh ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
} finally {
if (alive) setChecking(false);
}
} catch {
if (!alive) return;
clearPanelAccessPassword();
setRequired(true);
setAuthenticated(false);
setError(
isZh
? '无法校验面板访问状态,请检查后端连接后重试。'
: 'Unable to verify panel access. Check the backend connection and try again.',
);
setChecking(false);
}
};
void boot();
return () => {
alive = false;
};
}, [bypass, isZh]);
useEffect(() => {
if (typeof window === 'undefined' || bypass) return undefined;
const handlePanelAuthInvalid = () => {
setRequired(true);
setAuthenticated(false);
setChecking(false);
setPassword('');
setError(
isZh
? '登录已失效,请重新输入面板访问密码。'
: 'Your login expired. Enter the panel access password again.',
);
};
window.addEventListener(PANEL_AUTH_INVALID_EVENT, handlePanelAuthInvalid);
return () => window.removeEventListener(PANEL_AUTH_INVALID_EVENT, handlePanelAuthInvalid);
}, [bypass, isZh]);
const onSubmit = async () => {
const next = String(password || '').trim();
if (!next) {
setError(isZh ? '请输入面板访问密码。' : 'Enter the panel access password.');
return;
}
setSubmitting(true);
setError('');
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
setPanelAccessPassword(next);
setAuthenticated(true);
} catch (error: any) {
clearPanelAccessPassword();
setError(
error?.response?.data?.detail
|| (isZh ? '面板访问密码错误。' : 'Invalid panel access password.'),
);
} finally {
setSubmitting(false);
}
};
if (checking) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{isZh ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
</div>
</div>
</div>
);
}
if (required && !authenticated) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{isZh ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit();
}}
placeholder={isZh ? '面板访问密码' : 'Panel access password'}
toggleLabels={passwordToggleLabels}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? (isZh ? '登录中...' : 'Signing in...') : (isZh ? '登录' : 'Sign In')}
</button>
</div>
</div>
</div>
</div>
);
}
return children;
}

View File

@ -3,11 +3,11 @@ import axios from 'axios';
import { useAppStore } from '../store/appStore'; import { useAppStore } from '../store/appStore';
import { APP_ENDPOINTS } from '../config/env'; import { APP_ENDPOINTS } from '../config/env';
import type { BotState, ChatMessage } from '../types/bot'; import type { BotState, ChatMessage } from '../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../modules/dashboard/messageParser'; import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../shared/text/messageText';
import { pickLocale } from '../i18n'; import { pickLocale } from '../i18n';
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en'; import { botsSyncEn } from '../i18n/bots-sync.en';
import { buildMonitorWsUrl } from '../utils/botAccess'; import { buildMonitorWsUrl, notifyBotAuthInvalid } from '../utils/botAccess';
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase(); const s = (v || '').toUpperCase();
@ -152,7 +152,12 @@ export function useBotsSync(forcedBotId?: string) {
} }
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`); const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data); setBots(res.data);
} catch (error) { } catch (error: any) {
const status = Number(error?.response?.status || 0);
if (forced && status === 401) {
setBots([]);
return;
}
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error); console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
} }
}; };
@ -351,13 +356,16 @@ export function useBotsSync(forcedBotId?: string) {
addBotLog(bot.id, String(data.text || '')); addBotLog(bot.id, String(data.text || ''));
} }
}; };
ws.onclose = () => { ws.onclose = (event) => {
const hb = heartbeatsRef.current[bot.id]; const hb = heartbeatsRef.current[bot.id];
if (hb) { if (hb) {
window.clearInterval(hb); window.clearInterval(hb);
delete heartbeatsRef.current[bot.id]; delete heartbeatsRef.current[bot.id];
} }
delete socketsRef.current[bot.id]; delete socketsRef.current[bot.id];
if (event.code === 4401 && forced === bot.id) {
notifyBotAuthInvalid(bot.id);
}
}; };
socketsRef.current[bot.id] = ws; socketsRef.current[bot.id] = ws;

View File

@ -117,7 +117,6 @@ export function BotDashboardModule({
botStarting: dashboard.t.botStarting, botStarting: dashboard.t.botStarting,
botStopping: dashboard.t.botStopping, botStopping: dashboard.t.botStopping,
chatDisabled: dashboard.t.chatDisabled, chatDisabled: dashboard.t.chatDisabled,
close: dashboard.t.close,
controlCommandsHide: dashboard.t.controlCommandsHide, controlCommandsHide: dashboard.t.controlCommandsHide,
controlCommandsShow: dashboard.t.controlCommandsShow, controlCommandsShow: dashboard.t.controlCommandsShow,
copyPrompt: dashboard.t.copyPrompt, copyPrompt: dashboard.t.copyPrompt,

View File

@ -0,0 +1,97 @@
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
import { normalizeAttachmentPaths } from '../../../shared/workspace/utils';
import { normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
export function formatClock(ts: number) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export function formatConversationDate(ts: number, isZh: boolean) {
const d = new Date(ts);
try {
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
});
} catch {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
export function formatDateInputValue(ts: number): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function mapBotMessageResponseRow(row: any): ChatMessage {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
}
export function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
const quoted = normalizeAssistantMessageText(match?.[1] || '');
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
return { quoted, body };
}
export function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
const userQuoted = parsedUser.quoted;
const userBody = parsedUser.body;
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0 && !userQuoted) return;
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
const lastKind = last.kind || 'final';
const currentKind = msg.kind || 'final';
const sameAttachmentSet =
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return;
}
}
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
});
return merged.slice(-120);
}

View File

@ -1,15 +1,26 @@
import type { ComponentProps } from 'react'; import { Suspense, lazy, type ComponentProps } from 'react';
import { MessageCircle, MessageSquareText, X } from 'lucide-react'; import { MessageCircle, MessageSquareText, X } from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { CreateBotWizardModal } from '../../onboarding/CreateBotWizardModal';
import { TopicFeedPanel } from '../topic/TopicFeedPanel';
import type { CompactPanelTab, RuntimeViewMode } from '../types'; import type { CompactPanelTab, RuntimeViewMode } from '../types';
import { BotListPanel } from './BotListPanel'; import { BotListPanel } from './BotListPanel';
import { DashboardChatPanel } from './DashboardChatPanel'; import { DashboardChatPanel } from './DashboardChatPanel';
import { DashboardModalStack } from './DashboardModalStack';
import { RuntimePanel } from './RuntimePanel'; import { RuntimePanel } from './RuntimePanel';
const LazyCreateBotWizardModal = lazy(() =>
import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })),
);
const LazyTopicFeedPanel = lazy(() =>
import('../topic/TopicFeedPanel').then((module) => ({ default: module.TopicFeedPanel })),
);
const LazyDashboardModalStack = lazy(() =>
import('./DashboardModalStack').then((module) => ({ default: module.DashboardModalStack })),
);
type TopicFeedPanelProps = Parameters<typeof import('../topic/TopicFeedPanel').TopicFeedPanel>[0];
type DashboardModalStackProps = Parameters<typeof import('./DashboardModalStack').DashboardModalStack>[0];
type CreateBotWizardModalProps = Parameters<typeof import('../../onboarding/CreateBotWizardModal').CreateBotWizardModal>[0];
export interface BotDashboardViewProps { export interface BotDashboardViewProps {
compactMode: boolean; compactMode: boolean;
hasForcedBot: boolean; hasForcedBot: boolean;
@ -25,12 +36,12 @@ export interface BotDashboardViewProps {
runtimeViewMode: RuntimeViewMode; runtimeViewMode: RuntimeViewMode;
hasTopicUnread: boolean; hasTopicUnread: boolean;
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void; onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
topicFeedPanelProps: ComponentProps<typeof TopicFeedPanel>; topicFeedPanelProps: TopicFeedPanelProps;
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>; dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
runtimePanelProps: ComponentProps<typeof RuntimePanel>; runtimePanelProps: ComponentProps<typeof RuntimePanel>;
onCompactClose: () => void; onCompactClose: () => void;
dashboardModalStackProps: ComponentProps<typeof DashboardModalStack>; dashboardModalStackProps: DashboardModalStackProps;
createBotModalProps: ComponentProps<typeof CreateBotWizardModal>; createBotModalProps: CreateBotWizardModalProps;
} }
export function BotDashboardView({ export function BotDashboardView({
@ -54,6 +65,24 @@ export function BotDashboardView({
dashboardModalStackProps, dashboardModalStackProps,
createBotModalProps, createBotModalProps,
}: BotDashboardViewProps) { }: BotDashboardViewProps) {
const hasDashboardOverlay = Boolean(
dashboardModalStackProps.resourceMonitorModal.open ||
dashboardModalStackProps.baseConfigModal.open ||
dashboardModalStackProps.paramConfigModal.open ||
dashboardModalStackProps.channelConfigModal.open ||
dashboardModalStackProps.topicConfigModal.open ||
dashboardModalStackProps.skillsModal.open ||
dashboardModalStackProps.skillMarketInstallModal.open ||
dashboardModalStackProps.mcpConfigModal.open ||
dashboardModalStackProps.envParamsModal.open ||
dashboardModalStackProps.cronJobsModal.open ||
dashboardModalStackProps.templateManagerModal.open ||
dashboardModalStackProps.agentFilesModal.open ||
dashboardModalStackProps.runtimeActionModal.open ||
dashboardModalStackProps.workspacePreviewModal.preview ||
dashboardModalStackProps.workspaceHoverCard.state,
);
return ( return (
<> <>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}> <div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
@ -92,7 +121,13 @@ export function BotDashboardView({
</div> </div>
</div> </div>
<div className="ops-main-content-body"> <div className="ops-main-content-body">
{runtimeViewMode === 'topic' ? <TopicFeedPanel {...topicFeedPanelProps} /> : <DashboardChatPanel {...dashboardChatPanelProps} />} {runtimeViewMode === 'topic' ? (
<Suspense fallback={<div className="ops-empty-inline">{isZh ? '读取主题消息中...' : 'Loading topic feed...'}</div>}>
<LazyTopicFeedPanel {...topicFeedPanelProps} />
</Suspense>
) : (
<DashboardChatPanel {...dashboardChatPanelProps} />
)}
</div> </div>
</div> </div>
</div> </div>
@ -116,8 +151,16 @@ export function BotDashboardView({
</LucentIconButton> </LucentIconButton>
) : null} ) : null}
<DashboardModalStack {...dashboardModalStackProps} /> {hasDashboardOverlay ? (
<CreateBotWizardModal {...createBotModalProps} /> <Suspense fallback={null}>
<LazyDashboardModalStack {...dashboardModalStackProps} />
</Suspense>
) : null}
{createBotModalProps.open ? (
<Suspense fallback={null}>
<LazyCreateBotWizardModal {...createBotModalProps} />
</Suspense>
) : null}
</> </>
); );
} }

View File

@ -0,0 +1,76 @@
import { DrawerShell } from '../../../components/DrawerShell';
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
import type { AgentTab } from '../types';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface AgentFilesModalLabels {
agentFiles: string;
cancel: string;
close: string;
saveFiles: string;
}
const AGENT_FILE_TABS: AgentTab[] = ['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'];
interface AgentFilesModalProps {
open: boolean;
agentTab: AgentTab;
tabValue: string;
isSaving: boolean;
labels: AgentFilesModalLabels;
onClose: () => void;
onAgentTabChange: (tab: AgentTab) => void;
onTabValueChange: (value: string) => void;
onSave: () => Promise<void> | void;
}
export function AgentFilesModal({
open,
agentTab,
tabValue,
isSaving,
labels,
onClose,
onAgentTabChange,
onTabValueChange,
onSave,
}: AgentFilesModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.agentFiles}
subtitle="AGENTS.md, SOUL.md, USER.md, TOOLS.md, IDENTITY.md"
size="extend"
bodyClassName="ops-form-drawer-body"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{`${agentTab}.md`}</div>
<div className="ops-inline-actions ops-inline-actions-wrap">
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void onSave()}>{labels.saveFiles}</button>
</div>
</div>
)}
>
<div className="ops-form-modal">
<div className="wizard-agent-layout ops-agent-files-layout">
<div className="agent-tabs-vertical">
{AGENT_FILE_TABS.map((tab) => (
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => onAgentTabChange(tab)}>{tab}.md</button>
))}
</div>
<MarkdownLiteEditor
value={tabValue}
onChange={onTabValueChange}
fullHeight
/>
</div>
</div>
</DrawerShell>
);
}

View File

@ -1,10 +1,10 @@
import { ChevronDown, ChevronUp, ExternalLink, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react'; import { ChevronDown, ChevronUp, ExternalLink, Plus, Save, Trash2, X } from 'lucide-react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { DrawerShell } from '../../../components/DrawerShell'; import { DrawerShell } from '../../../components/DrawerShell';
import { PasswordInput } from '../../../components/PasswordInput'; import { PasswordInput } from '../../../components/PasswordInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotChannel, BotTopic, ChannelType, TopicPresetTemplate, WeixinLoginStatus } from '../types'; import type { BotChannel, ChannelType, WeixinLoginStatus } from '../types';
import './DashboardManagementModals.css'; import './DashboardManagementModals.css';
interface PasswordToggleLabels { interface PasswordToggleLabels {
@ -593,334 +593,3 @@ export function ChannelConfigModal({
</DrawerShell> </DrawerShell>
); );
} }
interface TopicConfigModalProps {
open: boolean;
topics: BotTopic[];
expandedTopicByKey: Record<string, boolean>;
newTopicPanelOpen: boolean;
topicPresetMenuOpen: boolean;
newTopicAdvancedOpen: boolean;
newTopicSourceLabel: string;
newTopicKey: string;
newTopicName: string;
newTopicDescription: string;
newTopicPurpose: string;
newTopicIncludeWhen: string;
newTopicExcludeWhen: string;
newTopicExamplesPositive: string;
newTopicExamplesNegative: string;
newTopicPriority: string;
effectiveTopicPresetTemplates: TopicPresetTemplate[];
topicPresetMenuRef: RefObject<HTMLDivElement | null>;
isSavingTopic: boolean;
hasSelectedBot: boolean;
isZh: boolean;
labels: Record<string, any>;
onClose: () => void;
getTopicUiKey: (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => string;
countRoutingTextList: (raw: string) => number;
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
onToggleExpandedTopic: (key: string) => void;
onRemoveTopic: (topic: BotTopic) => Promise<void> | void;
onSaveTopic: (topic: BotTopic) => Promise<void> | void;
onSetNewTopicPanelOpen: (value: boolean) => void;
onSetTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onSetNewTopicAdvancedOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onResetNewTopicDraft: () => void;
onNormalizeTopicKeyInput: (value: string) => string;
onSetNewTopicKey: (value: string) => void;
onSetNewTopicName: (value: string) => void;
onSetNewTopicDescription: (value: string) => void;
onSetNewTopicPurpose: (value: string) => void;
onSetNewTopicIncludeWhen: (value: string) => void;
onSetNewTopicExcludeWhen: (value: string) => void;
onSetNewTopicExamplesPositive: (value: string) => void;
onSetNewTopicExamplesNegative: (value: string) => void;
onSetNewTopicPriority: (value: string) => void;
onBeginTopicCreate: (presetId: string) => void;
onResolvePresetLabel: (preset: TopicPresetTemplate) => string;
onAddTopic: () => Promise<void> | void;
}
export function TopicConfigModal({
open,
topics,
expandedTopicByKey,
newTopicPanelOpen,
topicPresetMenuOpen,
newTopicAdvancedOpen,
newTopicSourceLabel,
newTopicKey,
newTopicName,
newTopicDescription,
newTopicPurpose,
newTopicIncludeWhen,
newTopicExcludeWhen,
newTopicExamplesPositive,
newTopicExamplesNegative,
newTopicPriority,
effectiveTopicPresetTemplates,
topicPresetMenuRef,
isSavingTopic,
hasSelectedBot,
isZh,
labels,
onClose,
getTopicUiKey,
countRoutingTextList,
onUpdateTopicLocal,
onToggleExpandedTopic,
onRemoveTopic,
onSaveTopic,
onSetNewTopicPanelOpen,
onSetTopicPresetMenuOpen,
onSetNewTopicAdvancedOpen,
onResetNewTopicDraft,
onNormalizeTopicKeyInput,
onSetNewTopicKey,
onSetNewTopicName,
onSetNewTopicDescription,
onSetNewTopicPurpose,
onSetNewTopicIncludeWhen,
onSetNewTopicExcludeWhen,
onSetNewTopicExamplesPositive,
onSetNewTopicExamplesNegative,
onSetNewTopicPriority,
onBeginTopicCreate,
onResolvePresetLabel,
onAddTopic,
}: TopicConfigModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.topicPanel}
size="extend"
closeLabel={labels.close}
bodyClassName="ops-config-drawer-body"
footer={(
!newTopicPanelOpen ? (
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{labels.topicAddHint}</div>
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
<button className="btn btn-primary" disabled={isSavingTopic || !hasSelectedBot} onClick={() => onSetTopicPresetMenuOpen((prev) => !prev)}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.topicAdd}</span>
</button>
{topicPresetMenuOpen ? (
<div className="ops-topic-create-menu">
{effectiveTopicPresetTemplates.map((preset) => (
<button key={preset.id} className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate(preset.id)}>
{onResolvePresetLabel(preset)}
</button>
))}
<button className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate('blank')}>{labels.topicPresetBlank}</button>
</div>
) : null}
</div>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="wizard-channel-list ops-config-list-scroll">
{topics.length === 0 ? (
<div className="ops-empty-inline">{labels.topicEmpty}</div>
) : (
topics.map((topic, idx) => {
const uiKey = getTopicUiKey(topic, idx);
const expanded = expandedTopicByKey[uiKey] ?? idx === 0;
const includeCount = countRoutingTextList(String(topic.routing_include_when || ''));
const excludeCount = countRoutingTextList(String(topic.routing_exclude_when || ''));
return (
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{topic.topic_key}</strong>
<div className="field-label">{topic.name || topic.topic_key}</div>
{!expanded ? (
<div className="ops-config-collapsed-meta">
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`}
</div>
) : null}
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={Boolean(topic.is_active)}
onChange={(e) => onUpdateTopicLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.topicActive}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic}
onClick={() => void onRemoveTopic(topic)}
tooltip={labels.delete}
aria-label={labels.delete}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleExpandedTopic(uiKey)}
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input className="input" value={topic.name || ''} onChange={(e) => onUpdateTopicLocal(idx, { name: e.target.value })} placeholder={labels.topicName} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input className="input mono" type="number" min={0} max={100} step={1} value={topic.routing_priority || '50'} onChange={(e) => onUpdateTopicLocal(idx, { routing_priority: e.target.value })} />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea className="input" rows={3} value={topic.description || ''} onChange={(e) => onUpdateTopicLocal(idx, { description: e.target.value })} placeholder={labels.topicDescription} />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea className="input" rows={3} value={topic.routing_purpose || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_purpose: e.target.value })} placeholder={labels.topicPurpose} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea className="input mono" rows={4} value={topic.routing_include_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_include_when: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea className="input mono" rows={4} value={topic.routing_exclude_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_exclude_when: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea className="input mono" rows={4} value={topic.routing_examples_positive || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_positive: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea className="input mono" rows={4} value={topic.routing_examples_negative || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_negative: e.target.value })} placeholder={labels.topicListHint} />
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.topicAddHint}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void onSaveTopic(topic)}>
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newTopicPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.topicAdd}</strong>
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onSetNewTopicAdvancedOpen((prev) => !prev)}
tooltip={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
aria-label={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
>
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
onSetNewTopicPanelOpen(false);
onSetTopicPresetMenuOpen(false);
onResetNewTopicDraft();
}}
tooltip={labels.cancel}
aria-label={labels.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicKey}</label>
<input className="input mono" value={newTopicKey} onChange={(e) => onSetNewTopicKey(onNormalizeTopicKeyInput(e.target.value))} placeholder={labels.topicKeyPlaceholder} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input className="input" value={newTopicName} onChange={(e) => onSetNewTopicName(e.target.value)} placeholder={labels.topicName} autoComplete="off" />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea className="input" rows={3} value={newTopicDescription} onChange={(e) => onSetNewTopicDescription(e.target.value)} placeholder={labels.topicDescription} />
</div>
{newTopicAdvancedOpen ? (
<>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea className="input" rows={3} value={newTopicPurpose} onChange={(e) => onSetNewTopicPurpose(e.target.value)} placeholder={labels.topicPurpose} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea className="input mono" rows={4} value={newTopicIncludeWhen} onChange={(e) => onSetNewTopicIncludeWhen(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea className="input mono" rows={4} value={newTopicExcludeWhen} onChange={(e) => onSetNewTopicExcludeWhen(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea className="input mono" rows={4} value={newTopicExamplesPositive} onChange={(e) => onSetNewTopicExamplesPositive(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea className="input mono" rows={4} value={newTopicExamplesNegative} onChange={(e) => onSetNewTopicExamplesNegative(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input className="input mono" type="number" min={0} max={100} step={1} value={newTopicPriority} onChange={(e) => onSetNewTopicPriority(e.target.value)} />
</div>
</>
) : null}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.topicAddHint}</span>
<div className="ops-inline-actions ops-inline-actions-wrap ops-inline-actions-end">
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic}
onClick={() => {
onSetNewTopicPanelOpen(false);
onSetTopicPresetMenuOpen(false);
onResetNewTopicDraft();
}}
>
{labels.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic || !hasSelectedBot} onClick={() => void onAddTopic()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}

View File

@ -0,0 +1,403 @@
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square, X } from 'lucide-react';
import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
import { normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
import { workspaceFileAction } from '../../../shared/workspace/utils';
import { formatDateInputValue } from '../chat/chatUtils';
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
interface DashboardChatComposerProps {
isZh: boolean;
labels: DashboardChatPanelLabels;
workspaceDownloadExtensionSet: ReadonlySet<string>;
canChat: boolean;
isChatEnabled: boolean;
speechEnabled: boolean;
quotedReply: { text: string } | null;
onClearQuotedReply: () => void;
pendingAttachments: string[];
onRemovePendingAttachment: (path: string) => void;
attachmentUploadPercent: number | null;
isUploadingAttachments: boolean;
filePickerRef: RefObject<HTMLInputElement | null>;
allowedAttachmentExtensions: string[];
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
controlCommandPanelOpen: boolean;
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
onToggleControlCommandPanel: () => void;
activeControlCommand: string;
canSendControlCommand: boolean;
isInterrupting: boolean;
onSendControlCommand: (command: '/restart' | '/new') => Promise<void> | void;
onInterruptExecution: () => Promise<void> | void;
chatDateTriggerRef: RefObject<HTMLButtonElement | null>;
hasSelectedBot: boolean;
chatDateJumping: boolean;
onToggleChatDatePicker: () => void;
chatDatePickerOpen: boolean;
chatDatePanelPosition: { bottom: number; right: number } | null;
chatDateValue: string;
onChatDateValueChange: (value: string) => void;
onCloseChatDatePicker: () => void;
onJumpConversationToDate: () => Promise<void> | void;
command: string;
onCommandChange: (value: string) => void;
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
onComposerKeyDown: KeyboardEventHandler<HTMLTextAreaElement>;
isVoiceRecording: boolean;
isVoiceTranscribing: boolean;
isCompactMobile: boolean;
voiceCountdown: number;
onVoiceInput: () => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onTriggerPickAttachments: () => Promise<void> | void;
submitActionMode: 'interrupt' | 'send' | 'stage';
onSubmitAction: () => Promise<void> | void;
}
function PendingAttachmentChip({
path,
workspaceDownloadExtensionSet,
onOpenWorkspacePath,
onRemovePendingAttachment,
}: {
path: string;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onRemovePendingAttachment: (path: string) => void;
}) {
const filePath = normalizeDashboardAttachmentPath(path);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<span className="ops-pending-chip mono">
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onOpenWorkspacePath(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemovePendingAttachment(path);
}}
>
<X size={12} />
</button>
</span>
);
}
export function DashboardChatComposer({
isZh,
labels,
workspaceDownloadExtensionSet,
canChat,
isChatEnabled,
speechEnabled,
quotedReply,
onClearQuotedReply,
pendingAttachments,
onRemovePendingAttachment,
attachmentUploadPercent,
isUploadingAttachments,
filePickerRef,
allowedAttachmentExtensions,
onPickAttachments,
controlCommandPanelOpen,
controlCommandPanelRef,
onToggleControlCommandPanel,
activeControlCommand,
canSendControlCommand,
isInterrupting,
onSendControlCommand,
onInterruptExecution,
chatDateTriggerRef,
hasSelectedBot,
chatDateJumping,
onToggleChatDatePicker,
chatDatePickerOpen,
chatDatePanelPosition,
chatDateValue,
onChatDateValueChange,
onCloseChatDatePicker,
onJumpConversationToDate,
command,
onCommandChange,
composerTextareaRef,
onComposerKeyDown,
isVoiceRecording,
isVoiceTranscribing,
isCompactMobile,
voiceCountdown,
onVoiceInput,
onOpenWorkspacePath,
onTriggerPickAttachments,
submitActionMode,
onSubmitAction,
}: DashboardChatComposerProps) {
const showInterruptSubmitAction = submitActionMode === 'interrupt';
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
return (
<>
{(quotedReply || pendingAttachments.length > 0) ? (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{labels.quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={onClearQuotedReply}
>
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((path) => (
<PendingAttachmentChip
key={path}
path={path}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onOpenWorkspacePath={onOpenWorkspacePath}
onRemovePendingAttachment={onRemovePendingAttachment}
/>
))}
</div>
) : null}
</div>
) : null}
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/>
</div>
<span className="ops-upload-progress-text mono">
{attachmentUploadPercent === null
? labels.uploadingFile
: `${labels.uploadingFile} ${attachmentUploadPercent}%`}
</span>
</div>
) : null}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments}
className="ops-hidden-file-input"
/>
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!hasSelectedBot || !canChat || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onInterruptExecution()}
aria-label="/stop"
title="/stop"
>
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!hasSelectedBot || chatDateJumping}
onClick={onToggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={formatDateInputValue(Date.now())}
onChange={(event) => onChatDateValueChange(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCloseChatDatePicker}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={() => void onJumpConversationToDate()}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span className={chatDateJumping ? 'ops-control-date-submit-label' : undefined}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={onToggleControlCommandPanel}
aria-label={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
title={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
rows={3}
value={command}
onChange={(event) => onCommandChange(event.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
placeholder={canChat ? labels.inputPlaceholder : labels.disabledPlaceholder}
/>
<div className="ops-composer-tools-right">
{(isVoiceRecording || isVoiceTranscribing) ? (
<div className="ops-voice-inline" aria-live="polite">
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
return (
<i
key={`vw-inline-${segmentIdx}-${idx}`}
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
/>
);
})}
</div>
))}
</div>
<div className="ops-voice-countdown mono">
{isVoiceRecording ? `${voiceCountdown}s` : labels.voiceTranscribing}
</div>
</div>
) : null}
<button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
onClick={() => void onVoiceInput()}
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
>
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
</button>
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={() => void onTriggerPickAttachments()}
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
>
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
disabled={
submitActionMode === 'interrupt'
? isInterrupting
: (
submitActionMode === 'stage'
? (
isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
)
}
onClick={() => void onSubmitAction()}
aria-label={showInterruptSubmitAction ? labels.interrupt : labels.send}
title={showInterruptSubmitAction ? labels.interrupt : labels.send}
>
{showInterruptSubmitAction ? <Square size={15} /> : <ArrowUp size={18} />}
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -1,55 +1,15 @@
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react'; import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png'; import nanobotLogo from '../../../assets/nanobot-logo.png';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
import type { StagedSubmissionDraft } from '../types'; import type { StagedSubmissionDraft } from '../types';
import { formatDateInputValue, workspaceFileAction } from '../utils'; import { DashboardChatComposer } from './DashboardChatComposer';
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
import { DashboardConversationMessages } from './DashboardConversationMessages'; import { DashboardConversationMessages } from './DashboardConversationMessages';
import { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue';
import './DashboardChatPanel.css'; import './DashboardChatPanel.css';
interface DashboardChatPanelLabels {
badReply: string;
botDisabledHint: string;
botStarting: string;
botStopping: string;
chatDisabled: string;
close: string;
controlCommandsHide: string;
controlCommandsShow: string;
copyPrompt: string;
copyReply: string;
deleteMessage: string;
disabledPlaceholder: string;
download: string;
editPrompt: string;
fileNotPreviewable: string;
goodReply: string;
inputPlaceholder: string;
interrupt: string;
noConversation: string;
previewTitle: string;
stagedSubmissionAttachmentCount: (count: number) => string;
stagedSubmissionEmpty: string;
stagedSubmissionRestore: string;
stagedSubmissionRemove: string;
quoteReply: string;
quotedReplyLabel: string;
send: string;
thinking: string;
uploadFile: string;
uploadingFile: string;
user: string;
voiceStart: string;
voiceStop: string;
voiceTranscribing: string;
you: string;
}
interface DashboardChatPanelProps { interface DashboardChatPanelProps {
conversation: ChatMessage[]; conversation: ChatMessage[];
isZh: boolean; isZh: boolean;
@ -330,8 +290,6 @@ export function DashboardChatPanel({
submitActionMode, submitActionMode,
onSubmitAction, onSubmitAction,
}: DashboardChatPanelProps) { }: DashboardChatPanelProps) {
const showInterruptSubmitAction = submitActionMode === 'interrupt';
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
return ( return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}> <div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<MemoizedChatTranscript <MemoizedChatTranscript
@ -359,321 +317,60 @@ export function DashboardChatPanel({
/> />
<div className="ops-chat-dock"> <div className="ops-chat-dock">
{stagedSubmissions.length > 0 ? ( <DashboardStagedSubmissionQueue
<div className="ops-staged-submission-queue" aria-live="polite"> labels={labels}
{stagedSubmissions.map((stagedSubmission, index) => ( stagedSubmissions={stagedSubmissions}
<div key={stagedSubmission.id} className="ops-staged-submission-item"> onRestoreStagedSubmission={onRestoreStagedSubmission}
<span className="ops-staged-submission-index mono">{index + 1}</span> onRemoveStagedSubmission={onRemoveStagedSubmission}
<div className="ops-staged-submission-body">
<div className="ops-staged-submission-text">
{normalizeUserMessageText(stagedSubmission.command) || labels.stagedSubmissionEmpty}
</div>
{(stagedSubmission.quotedReply || stagedSubmission.attachments.length > 0) ? (
<div className="ops-staged-submission-meta">
{stagedSubmission.quotedReply ? (
<span className="ops-staged-submission-pill">{labels.quotedReplyLabel}</span>
) : null}
{stagedSubmission.attachments.length > 0 ? (
<span className="ops-staged-submission-pill">
{labels.stagedSubmissionAttachmentCount(stagedSubmission.attachments.length)}
</span>
) : null}
</div>
) : null}
</div>
<div className="ops-staged-submission-actions">
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRestoreStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRestore}
title={labels.stagedSubmissionRestore}
>
<Pencil size={14} />
</button>
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRemoveStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRemove}
title={labels.stagedSubmissionRemove}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
) : null}
{(quotedReply || pendingAttachments.length > 0) ? (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{labels.quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={onClearQuotedReply}
>
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((path) => (
<span key={path} className="ops-pending-chip mono">
{(() => {
const filePath = normalizeDashboardAttachmentPath(path);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onOpenWorkspacePath(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})()}
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemovePendingAttachment(path);
}}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
</div>
) : null}
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/> />
</div> <DashboardChatComposer
<span className="ops-upload-progress-text mono"> isZh={isZh}
{attachmentUploadPercent === null labels={labels}
? labels.uploadingFile workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
: `${labels.uploadingFile} ${attachmentUploadPercent}%`} canChat={canChat}
</span> isChatEnabled={isChatEnabled}
</div> speechEnabled={speechEnabled}
) : null} quotedReply={quotedReply}
<div className="ops-composer"> onClearQuotedReply={onClearQuotedReply}
<input pendingAttachments={pendingAttachments}
ref={filePickerRef} onRemovePendingAttachment={onRemovePendingAttachment}
type="file" attachmentUploadPercent={attachmentUploadPercent}
multiple isUploadingAttachments={isUploadingAttachments}
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined} filePickerRef={filePickerRef}
onChange={onPickAttachments} allowedAttachmentExtensions={allowedAttachmentExtensions}
className="ops-hidden-file-input" onPickAttachments={onPickAttachments}
controlCommandPanelOpen={controlCommandPanelOpen}
controlCommandPanelRef={controlCommandPanelRef}
onToggleControlCommandPanel={onToggleControlCommandPanel}
activeControlCommand={activeControlCommand}
canSendControlCommand={canSendControlCommand}
isInterrupting={isInterrupting}
onSendControlCommand={onSendControlCommand}
onInterruptExecution={onInterruptExecution}
chatDateTriggerRef={chatDateTriggerRef}
hasSelectedBot={hasSelectedBot}
chatDateJumping={chatDateJumping}
onToggleChatDatePicker={onToggleChatDatePicker}
chatDatePickerOpen={chatDatePickerOpen}
chatDatePanelPosition={chatDatePanelPosition}
chatDateValue={chatDateValue}
onChatDateValueChange={onChatDateValueChange}
onCloseChatDatePicker={onCloseChatDatePicker}
onJumpConversationToDate={onJumpConversationToDate}
command={command}
onCommandChange={onCommandChange}
composerTextareaRef={composerTextareaRef}
onComposerKeyDown={onComposerKeyDown}
isVoiceRecording={isVoiceRecording}
isVoiceTranscribing={isVoiceTranscribing}
isCompactMobile={isCompactMobile}
voiceCountdown={voiceCountdown}
onVoiceInput={onVoiceInput}
onOpenWorkspacePath={onOpenWorkspacePath}
onTriggerPickAttachments={onTriggerPickAttachments}
submitActionMode={submitActionMode}
onSubmitAction={onSubmitAction}
/> />
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!hasSelectedBot || !canChat || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onInterruptExecution()}
aria-label="/stop"
title="/stop"
>
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!hasSelectedBot || chatDateJumping}
onClick={onToggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={formatDateInputValue(Date.now())}
onChange={(event) => onChatDateValueChange(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCloseChatDatePicker}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={() => void onJumpConversationToDate()}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span className={chatDateJumping ? 'ops-control-date-submit-label' : undefined}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={onToggleControlCommandPanel}
aria-label={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
title={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
rows={3}
value={command}
onChange={(event) => onCommandChange(event.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
placeholder={canChat ? labels.inputPlaceholder : labels.disabledPlaceholder}
/>
<div className="ops-composer-tools-right">
{(isVoiceRecording || isVoiceTranscribing) ? (
<div className="ops-voice-inline" aria-live="polite">
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
return (
<i
key={`vw-inline-${segmentIdx}-${idx}`}
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
/>
);
})}
</div>
))}
</div>
<div className="ops-voice-countdown mono">
{isVoiceRecording ? `${voiceCountdown}s` : labels.voiceTranscribing}
</div>
</div>
) : null}
<button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
onClick={() => void onVoiceInput()}
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
>
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
</button>
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={() => void onTriggerPickAttachments()}
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
>
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
disabled={
submitActionMode === 'interrupt'
? isInterrupting
: (
submitActionMode === 'stage'
? (
isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
)
}
onClick={() => void onSubmitAction()}
aria-label={showInterruptSubmitAction ? labels.interrupt : labels.send}
title={showInterruptSubmitAction ? labels.interrupt : labels.send}
>
{showInterruptSubmitAction ? <Square size={15} /> : <ArrowUp size={18} />}
</button>
</div>
</div>
</div>
</div> </div>
{!canChat ? ( {!canChat ? (
<div className="ops-chat-disabled-mask"> <div className="ops-chat-disabled-mask">

View File

@ -0,0 +1,36 @@
export interface DashboardChatPanelLabels {
badReply: string;
botDisabledHint: string;
botStarting: string;
botStopping: string;
chatDisabled: string;
controlCommandsHide: string;
controlCommandsShow: string;
copyPrompt: string;
copyReply: string;
deleteMessage: string;
disabledPlaceholder: string;
download: string;
editPrompt: string;
fileNotPreviewable: string;
goodReply: string;
inputPlaceholder: string;
interrupt: string;
noConversation: string;
previewTitle: string;
stagedSubmissionAttachmentCount: (count: number) => string;
stagedSubmissionEmpty: string;
stagedSubmissionRestore: string;
stagedSubmissionRemove: string;
quoteReply: string;
quotedReplyLabel: string;
send: string;
thinking: string;
uploadFile: string;
uploadingFile: string;
user: string;
voiceStart: string;
voiceStop: string;
voiceTranscribing: string;
you: string;
}

View File

@ -2,10 +2,10 @@ import { PlugZap, RefreshCw } from 'lucide-react';
import { DrawerShell } from '../../../components/DrawerShell'; import { DrawerShell } from '../../../components/DrawerShell';
import { LucentSelect } from '../../../components/lucent/LucentSelect'; import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
import { PasswordInput } from '../../../components/PasswordInput'; import { PasswordInput } from '../../../components/PasswordInput';
import { buildLlmProviderOptions } from '../../../utils/llmProviders'; import { buildLlmProviderOptions } from '../../../utils/llmProviders';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { DashboardModalCardShell } from './DashboardModalCardShell';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import type { SystemTimezoneOption } from '../../../utils/systemTimezones'; import type { SystemTimezoneOption } from '../../../utils/systemTimezones';
import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types'; import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types';
@ -46,7 +46,7 @@ export function ResourceMonitorModal({
if (!open) return null; if (!open) return null;
return ( return (
<DashboardModalCardShell <ModalCardShell
cardClassName="modal-wide" cardClassName="modal-wide"
closeLabel={closeLabel} closeLabel={closeLabel}
headerActions={( headerActions={(
@ -111,7 +111,7 @@ export function ResourceMonitorModal({
) : ( ) : (
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div> <div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
)} )}
</DashboardModalCardShell> </ModalCardShell>
); );
} }

View File

@ -7,11 +7,12 @@ import remarkGfm from 'remark-gfm';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png'; import nanobotLogo from '../../../assets/nanobot-logo.png';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText';
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
import { workspaceFileAction } from '../../../shared/workspace/utils';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { MARKDOWN_SANITIZE_SCHEMA } from '../constants'; import { formatClock, formatConversationDate } from '../chat/chatUtils';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../messageParser';
import { workspaceFileAction, formatClock, formatConversationDate } from '../utils';
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
import './DashboardConversationMessages.css'; import './DashboardConversationMessages.css';
interface DashboardConversationLabels { interface DashboardConversationLabels {

View File

@ -0,0 +1,135 @@
import { Clock3, Power, RefreshCw, Square, Trash2 } from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
import type { CronJob } from '../types';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface CronModalLabels {
close: string;
cronDelete: string;
cronDisabled: string;
cronEmpty: string;
cronEnabled: string;
cronLoading: string;
cronReload: string;
cronStart: string;
cronStop: string;
cronViewer: string;
}
interface CronJobsModalProps {
open: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronActionJobId: string;
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
isZh: boolean;
labels: CronModalLabels;
formatCronSchedule: (job: CronJob, isZh: boolean) => string;
onClose: () => void;
onReload: () => Promise<void> | void;
onStartJob: (jobId: string) => Promise<void> | void;
onStopJob: (jobId: string) => Promise<void> | void;
onDeleteJob: (jobId: string) => Promise<void> | void;
}
export function CronJobsModal({
open,
cronLoading,
cronJobs,
cronActionJobId,
cronActionType,
isZh,
labels,
formatCronSchedule,
onClose,
onReload,
onStartJob,
onStopJob,
onDeleteJob,
}: CronJobsModalProps) {
if (!open) return null;
return (
<ModalCardShell
cardClassName="modal-wide"
closeLabel={labels.close}
headerActions={(
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onReload()}
tooltip={labels.cronReload}
aria-label={labels.cronReload}
disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
</LucentIconButton>
)}
onClose={onClose}
title={labels.cronViewer}
>
{cronLoading ? (
<div className="ops-empty-inline">{labels.cronLoading}</div>
) : cronJobs.length === 0 ? (
<div className="ops-empty-inline">{labels.cronEmpty}</div>
) : (
<div className="ops-cron-list ops-cron-list-scroll">
{cronJobs.map((job) => {
const acting = cronActionJobId === job.id && Boolean(cronActionType);
const enabled = job.enabled !== false;
const channel = String(job.payload?.channel || '').trim();
const to = String(job.payload?.to || '').trim();
const target = channel && to ? `${channel}:${to}` : channel || to || '-';
return (
<div key={job.id} className="ops-cron-item">
<div className="ops-cron-main">
<div className="ops-cron-name">
<Clock3 size={13} />
<span>{job.name || job.id}</span>
</div>
<div className="ops-cron-meta mono">{formatCronSchedule(job, isZh)}</div>
<div className="ops-cron-meta mono">
{job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toLocaleString() : '-'}
</div>
<div className="ops-cron-meta mono">{target}</div>
<div className="ops-cron-meta">{job.enabled === false ? labels.cronDisabled : labels.cronEnabled}</div>
</div>
<div className="ops-cron-actions">
<LucentIconButton
className={`btn btn-sm icon-btn ${enabled ? 'ops-cron-action-stop' : 'ops-cron-action-start'}`}
onClick={() => void (enabled ? onStopJob(job.id) : onStartJob(job.id))}
tooltip={enabled ? labels.cronStop : labels.cronStart}
aria-label={enabled ? labels.cronStop : labels.cronStart}
disabled={acting}
>
{acting ? (
<span className="ops-cron-control-pending">
<span className="ops-cron-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : enabled ? <Square size={13} /> : <Power size={13} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void onDeleteJob(job.id)}
tooltip={labels.cronDelete}
aria-label={labels.cronDelete}
disabled={acting}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
</div>
);
})}
</div>
)}
</ModalCardShell>
);
}

View File

@ -0,0 +1,262 @@
import { useEffect, useState } from 'react';
import { Plus, Save, Trash2, X } from 'lucide-react';
import { DrawerShell } from '../../../components/DrawerShell';
import { PasswordInput } from '../../../components/PasswordInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface CommonModalLabels {
cancel: string;
close: string;
save: string;
}
interface EnvParamsModalLabels extends CommonModalLabels {
addEnvParam: string;
envDraftPlaceholderKey: string;
envDraftPlaceholderValue: string;
envParams: string;
envParamsDesc: string;
envParamsHint: string;
envValue: string;
hideEnvValue: string;
noEnvParams: string;
removeEnvParam: string;
showEnvValue: string;
}
interface EnvParamsModalProps {
open: boolean;
envEntries: Array<[string, string]>;
envDraftKey: string;
envDraftValue: string;
labels: EnvParamsModalLabels;
onClose: () => void;
onCreateEnvParam: (key: string, value: string) => Promise<boolean> | boolean;
onDeleteEnvParam: (key: string) => Promise<boolean> | boolean;
onEnvDraftKeyChange: (value: string) => void;
onEnvDraftValueChange: (value: string) => void;
onSaveEnvParam: (originalKey: string, nextKey: string, nextValue: string) => Promise<boolean> | boolean;
}
export function EnvParamsModal({
open,
envEntries,
envDraftKey,
envDraftValue,
labels,
onClose,
onCreateEnvParam,
onDeleteEnvParam,
onEnvDraftKeyChange,
onEnvDraftValueChange,
onSaveEnvParam,
}: EnvParamsModalProps) {
const [createPanelOpen, setCreatePanelOpen] = useState(false);
const [envEditDrafts, setEnvEditDrafts] = useState<Record<string, { key: string; value: string }>>({});
useEffect(() => {
if (open) return;
setCreatePanelOpen(false);
}, [open]);
useEffect(() => {
if (!open) return;
const nextDrafts: Record<string, { key: string; value: string }> = {};
envEntries.forEach(([key, value]) => {
nextDrafts[key] = { key, value };
});
setEnvEditDrafts(nextDrafts);
}, [envEntries, open]);
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.envParams}
size="standard"
bodyClassName="ops-config-drawer-body"
closeLabel={labels.close}
footer={(
!createPanelOpen ? (
<div className="drawer-shell-footer-content">
<span className="drawer-shell-footer-main field-label">{labels.envParamsHint}</span>
<button className="btn btn-primary" onClick={() => setCreatePanelOpen(true)}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.addEnvParam}</span>
</button>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="wizard-channel-list ops-config-list-scroll">
{envEntries.length === 0 ? (
<div className="ops-empty-inline">{labels.noEnvParams}</div>
) : (
envEntries.map(([key, value]) => {
const draft = envEditDrafts[key] || { key, value };
return (
<div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{draft.key || key}</strong>
<div className="ops-config-collapsed-meta">{labels.envValue}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void onDeleteEnvParam(key)}
tooltip={labels.removeEnvParam}
aria-label={labels.removeEnvParam}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderKey}</label>
<input
className="input mono"
value={draft.key}
onChange={(e) => {
const nextKey = e.target.value.toUpperCase();
setEnvEditDrafts((prev) => ({
...prev,
[key]: {
...(prev[key] || { key, value }),
key: nextKey,
},
}));
}}
placeholder={labels.envDraftPlaceholderKey}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.envValue}</label>
<PasswordInput
className="input"
value={draft.value}
onChange={(e) => {
const nextValue = e.target.value;
setEnvEditDrafts((prev) => ({
...prev,
[key]: {
...(prev[key] || { key, value }),
value: nextValue,
},
}));
}}
placeholder={labels.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: labels.showEnvValue,
hide: labels.hideEnvValue,
}}
/>
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.envParamsHint}</span>
<button className="btn btn-primary btn-sm" onClick={() => void onSaveEnvParam(key, draft.key, draft.value)}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
);
})
)}
</div>
{createPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.addEnvParam}</strong>
<div className="ops-config-collapsed-meta">{labels.envParamsHint}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
setCreatePanelOpen(false);
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
}}
tooltip={labels.cancel}
aria-label={labels.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderKey}</label>
<input
className="input mono"
value={envDraftKey}
onChange={(e) => onEnvDraftKeyChange(e.target.value.toUpperCase())}
placeholder={labels.envDraftPlaceholderKey}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderValue}</label>
<PasswordInput
className="input"
value={envDraftValue}
onChange={(e) => onEnvDraftValueChange(e.target.value)}
placeholder={labels.envDraftPlaceholderValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: labels.showEnvValue,
hide: labels.hideEnvValue,
}}
/>
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.envParamsHint}</span>
<div className="ops-inline-actions ops-inline-actions-wrap ops-inline-actions-end">
<button
className="btn btn-secondary btn-sm"
onClick={() => {
setCreatePanelOpen(false);
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
}}
>
{labels.cancel}
</button>
<button
className="btn btn-primary btn-sm"
onClick={async () => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
const saved = await onCreateEnvParam(key, envDraftValue);
if (!saved) return;
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
setCreatePanelOpen(false);
}}
>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}

View File

@ -1,12 +1,17 @@
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { ChannelConfigModal, TopicConfigModal } from './DashboardChannelTopicModals'; import { WorkspaceHoverCard } from '../../../shared/workspace/WorkspaceHoverCard';
import { WorkspacePreviewModal } from '../../../shared/workspace/WorkspacePreviewModal';
import { ChannelConfigModal } from './DashboardChannelConfigModal';
import { AgentFilesModal } from './DashboardAgentFilesModal';
import { CronJobsModal } from './DashboardCronJobsModal';
import { BaseConfigModal, ParamConfigModal, ResourceMonitorModal } from './DashboardConfigModals'; import { BaseConfigModal, ParamConfigModal, ResourceMonitorModal } from './DashboardConfigModals';
import { EnvParamsModal } from './DashboardEnvParamsModal';
import { McpConfigModal, SkillsModal } from './DashboardSkillsMcpModals'; import { McpConfigModal, SkillsModal } from './DashboardSkillsMcpModals';
import { AgentFilesModal, CronJobsModal, EnvParamsModal, RuntimeActionModal, TemplateManagerModal } from './DashboardSupportModals'; import { RuntimeActionModal } from './DashboardRuntimeActionModal';
import { TemplateManagerModal } from './DashboardTemplateManagerModal';
import { TopicConfigModal } from './DashboardTopicConfigModal';
import { SkillMarketInstallModal } from './SkillMarketInstallModal'; import { SkillMarketInstallModal } from './SkillMarketInstallModal';
import { WorkspaceHoverCard } from './WorkspaceHoverCard';
import { WorkspacePreviewModal } from './WorkspacePreviewModal';
interface DashboardModalStackProps { interface DashboardModalStackProps {
agentFilesModal: ComponentProps<typeof AgentFilesModal>; agentFilesModal: ComponentProps<typeof AgentFilesModal>;

View File

@ -0,0 +1,37 @@
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface RuntimeActionModalLabels {
close: string;
lastAction: string;
}
interface RuntimeActionModalProps {
open: boolean;
runtimeAction: string;
labels: RuntimeActionModalLabels;
onClose: () => void;
}
export function RuntimeActionModal({
open,
runtimeAction,
labels,
onClose,
}: RuntimeActionModalProps) {
if (!open) return null;
return (
<ModalCardShell
cardClassName="modal-preview"
closeLabel={labels.close}
onClose={onClose}
title={labels.lastAction}
>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</ModalCardShell>
);
}

View File

@ -0,0 +1,70 @@
import { Pencil, Trash2 } from 'lucide-react';
import { normalizeUserMessageText } from '../../../shared/text/messageText';
import type { StagedSubmissionDraft } from '../types';
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
interface DashboardStagedSubmissionQueueProps {
labels: DashboardChatPanelLabels;
stagedSubmissions: StagedSubmissionDraft[];
onRestoreStagedSubmission: (stagedSubmissionId: string) => void;
onRemoveStagedSubmission: (stagedSubmissionId: string) => void;
}
export function DashboardStagedSubmissionQueue({
labels,
stagedSubmissions,
onRestoreStagedSubmission,
onRemoveStagedSubmission,
}: DashboardStagedSubmissionQueueProps) {
if (stagedSubmissions.length === 0) {
return null;
}
return (
<div className="ops-staged-submission-queue" aria-live="polite">
{stagedSubmissions.map((stagedSubmission, index) => (
<div key={stagedSubmission.id} className="ops-staged-submission-item">
<span className="ops-staged-submission-index mono">{index + 1}</span>
<div className="ops-staged-submission-body">
<div className="ops-staged-submission-text">
{normalizeUserMessageText(stagedSubmission.command) || labels.stagedSubmissionEmpty}
</div>
{(stagedSubmission.quotedReply || stagedSubmission.attachments.length > 0) ? (
<div className="ops-staged-submission-meta">
{stagedSubmission.quotedReply ? (
<span className="ops-staged-submission-pill">{labels.quotedReplyLabel}</span>
) : null}
{stagedSubmission.attachments.length > 0 ? (
<span className="ops-staged-submission-pill">
{labels.stagedSubmissionAttachmentCount(stagedSubmission.attachments.length)}
</span>
) : null}
</div>
) : null}
</div>
<div className="ops-staged-submission-actions">
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRestoreStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRestore}
title={labels.stagedSubmissionRestore}
>
<Pencil size={14} />
</button>
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRemoveStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRemove}
title={labels.stagedSubmissionRemove}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
);
}

View File

@ -1,601 +0,0 @@
import { useEffect, useState } from 'react';
import { Clock3, Plus, Power, RefreshCw, Save, Square, Trash2, X } from 'lucide-react';
import { DrawerShell } from '../../../components/DrawerShell';
import { PasswordInput } from '../../../components/PasswordInput';
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { DashboardModalCardShell } from './DashboardModalCardShell';
import type { AgentTab, CronJob } from '../types';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface CommonModalLabels {
cancel: string;
close: string;
save: string;
}
interface EnvParamsModalLabels extends CommonModalLabels {
addEnvParam: string;
envDraftPlaceholderKey: string;
envDraftPlaceholderValue: string;
envParams: string;
envParamsDesc: string;
envParamsHint: string;
envValue: string;
hideEnvValue: string;
noEnvParams: string;
removeEnvParam: string;
showEnvValue: string;
}
interface CronModalLabels {
close: string;
cronDelete: string;
cronDisabled: string;
cronEmpty: string;
cronEnabled: string;
cronLoading: string;
cronReload: string;
cronStart: string;
cronStop: string;
cronViewer: string;
}
interface TemplateManagerLabels extends CommonModalLabels {
processing: string;
templateManagerTitle: string;
templateTabAgent: string;
templateTabTopic: string;
}
interface AgentFilesModalLabels {
agentFiles: string;
cancel: string;
close: string;
saveFiles: string;
}
interface RuntimeActionModalLabels {
close: string;
lastAction: string;
}
const AGENT_FILE_TABS: AgentTab[] = ['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'];
interface EnvParamsModalProps {
open: boolean;
envEntries: Array<[string, string]>;
envDraftKey: string;
envDraftValue: string;
labels: EnvParamsModalLabels;
onClose: () => void;
onCreateEnvParam: (key: string, value: string) => Promise<boolean> | boolean;
onDeleteEnvParam: (key: string) => Promise<boolean> | boolean;
onEnvDraftKeyChange: (value: string) => void;
onEnvDraftValueChange: (value: string) => void;
onSaveEnvParam: (originalKey: string, nextKey: string, nextValue: string) => Promise<boolean> | boolean;
}
export function EnvParamsModal({
open,
envEntries,
envDraftKey,
envDraftValue,
labels,
onClose,
onCreateEnvParam,
onDeleteEnvParam,
onEnvDraftKeyChange,
onEnvDraftValueChange,
onSaveEnvParam,
}: EnvParamsModalProps) {
const [createPanelOpen, setCreatePanelOpen] = useState(false);
const [envEditDrafts, setEnvEditDrafts] = useState<Record<string, { key: string; value: string }>>({});
useEffect(() => {
if (open) return;
setCreatePanelOpen(false);
}, [open]);
useEffect(() => {
if (!open) return;
const nextDrafts: Record<string, { key: string; value: string }> = {};
envEntries.forEach(([key, value]) => {
nextDrafts[key] = { key, value };
});
setEnvEditDrafts(nextDrafts);
}, [envEntries, open]);
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.envParams}
size="standard"
bodyClassName="ops-config-drawer-body"
closeLabel={labels.close}
footer={(
!createPanelOpen ? (
<div className="drawer-shell-footer-content">
<span className="drawer-shell-footer-main field-label">{labels.envParamsHint}</span>
<button className="btn btn-primary" onClick={() => setCreatePanelOpen(true)}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.addEnvParam}</span>
</button>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="wizard-channel-list ops-config-list-scroll">
{envEntries.length === 0 ? (
<div className="ops-empty-inline">{labels.noEnvParams}</div>
) : (
envEntries.map(([key, value]) => {
const draft = envEditDrafts[key] || { key, value };
return (
<div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{draft.key || key}</strong>
<div className="ops-config-collapsed-meta">{labels.envValue}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void onDeleteEnvParam(key)}
tooltip={labels.removeEnvParam}
aria-label={labels.removeEnvParam}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderKey}</label>
<input
className="input mono"
value={draft.key}
onChange={(e) => {
const nextKey = e.target.value.toUpperCase();
setEnvEditDrafts((prev) => ({
...prev,
[key]: {
...(prev[key] || { key, value }),
key: nextKey,
},
}));
}}
placeholder={labels.envDraftPlaceholderKey}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.envValue}</label>
<PasswordInput
className="input"
value={draft.value}
onChange={(e) => {
const nextValue = e.target.value;
setEnvEditDrafts((prev) => ({
...prev,
[key]: {
...(prev[key] || { key, value }),
value: nextValue,
},
}));
}}
placeholder={labels.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: labels.showEnvValue,
hide: labels.hideEnvValue,
}}
/>
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.envParamsHint}</span>
<button className="btn btn-primary btn-sm" onClick={() => void onSaveEnvParam(key, draft.key, draft.value)}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
)})
)}
</div>
{createPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.addEnvParam}</strong>
<div className="ops-config-collapsed-meta">{labels.envParamsHint}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
setCreatePanelOpen(false);
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
}}
tooltip={labels.cancel}
aria-label={labels.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderKey}</label>
<input
className="input mono"
value={envDraftKey}
onChange={(e) => onEnvDraftKeyChange(e.target.value.toUpperCase())}
placeholder={labels.envDraftPlaceholderKey}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.envDraftPlaceholderValue}</label>
<PasswordInput
className="input"
value={envDraftValue}
onChange={(e) => onEnvDraftValueChange(e.target.value)}
placeholder={labels.envDraftPlaceholderValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: labels.showEnvValue,
hide: labels.hideEnvValue,
}}
/>
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.envParamsHint}</span>
<div className="ops-inline-actions ops-inline-actions-wrap ops-inline-actions-end">
<button
className="btn btn-secondary btn-sm"
onClick={() => {
setCreatePanelOpen(false);
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
}}
>
{labels.cancel}
</button>
<button
className="btn btn-primary btn-sm"
onClick={async () => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
const saved = await onCreateEnvParam(key, envDraftValue);
if (!saved) return;
onEnvDraftKeyChange('');
onEnvDraftValueChange('');
setCreatePanelOpen(false);
}}
>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}
interface CronJobsModalProps {
open: boolean;
cronLoading: boolean;
cronJobs: CronJob[];
cronActionJobId: string;
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
isZh: boolean;
labels: CronModalLabels;
formatCronSchedule: (job: CronJob, isZh: boolean) => string;
onClose: () => void;
onReload: () => Promise<void> | void;
onStartJob: (jobId: string) => Promise<void> | void;
onStopJob: (jobId: string) => Promise<void> | void;
onDeleteJob: (jobId: string) => Promise<void> | void;
}
export function CronJobsModal({
open,
cronLoading,
cronJobs,
cronActionJobId,
cronActionType,
isZh,
labels,
formatCronSchedule,
onClose,
onReload,
onStartJob,
onStopJob,
onDeleteJob,
}: CronJobsModalProps) {
if (!open) return null;
return (
<DashboardModalCardShell
cardClassName="modal-wide"
closeLabel={labels.close}
headerActions={(
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onReload()}
tooltip={labels.cronReload}
aria-label={labels.cronReload}
disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
</LucentIconButton>
)}
onClose={onClose}
title={labels.cronViewer}
>
{cronLoading ? (
<div className="ops-empty-inline">{labels.cronLoading}</div>
) : cronJobs.length === 0 ? (
<div className="ops-empty-inline">{labels.cronEmpty}</div>
) : (
<div className="ops-cron-list ops-cron-list-scroll">
{cronJobs.map((job) => {
const acting = cronActionJobId === job.id && Boolean(cronActionType);
const enabled = job.enabled !== false;
const channel = String(job.payload?.channel || '').trim();
const to = String(job.payload?.to || '').trim();
const target = channel && to ? `${channel}:${to}` : channel || to || '-';
return (
<div key={job.id} className="ops-cron-item">
<div className="ops-cron-main">
<div className="ops-cron-name">
<Clock3 size={13} />
<span>{job.name || job.id}</span>
</div>
<div className="ops-cron-meta mono">{formatCronSchedule(job, isZh)}</div>
<div className="ops-cron-meta mono">
{job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toLocaleString() : '-'}
</div>
<div className="ops-cron-meta mono">{target}</div>
<div className="ops-cron-meta">{job.enabled === false ? labels.cronDisabled : labels.cronEnabled}</div>
</div>
<div className="ops-cron-actions">
<LucentIconButton
className={`btn btn-sm icon-btn ${enabled ? 'ops-cron-action-stop' : 'ops-cron-action-start'}`}
onClick={() => void (enabled ? onStopJob(job.id) : onStartJob(job.id))}
tooltip={enabled ? labels.cronStop : labels.cronStart}
aria-label={enabled ? labels.cronStop : labels.cronStart}
disabled={acting}
>
{acting ? (
<span className="ops-cron-control-pending">
<span className="ops-cron-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : enabled ? <Square size={13} /> : <Power size={13} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void onDeleteJob(job.id)}
tooltip={labels.cronDelete}
aria-label={labels.cronDelete}
disabled={acting}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
</div>
);
})}
</div>
)}
</DashboardModalCardShell>
);
}
interface TemplateManagerModalProps {
open: boolean;
templateTab: 'agent' | 'topic';
templateAgentCount: number;
templateTopicCount: number;
templateAgentText: string;
templateTopicText: string;
isSavingTemplates: boolean;
labels: TemplateManagerLabels;
onClose: () => void;
onTemplateTabChange: (tab: 'agent' | 'topic') => void;
onTemplateAgentTextChange: (value: string) => void;
onTemplateTopicTextChange: (value: string) => void;
onSave: (tab: 'agent' | 'topic') => Promise<void> | void;
}
export function TemplateManagerModal({
open,
templateTab,
templateAgentCount,
templateTopicCount,
templateAgentText,
templateTopicText,
isSavingTemplates,
labels,
onClose,
onTemplateTabChange,
onTemplateAgentTextChange,
onTemplateTopicTextChange,
onSave,
}: TemplateManagerModalProps) {
if (!open) return null;
const activeTemplateCount = templateTab === 'agent' ? templateAgentCount : templateTopicCount;
const activeTemplateLabel = templateTab === 'agent' ? labels.templateTabAgent : labels.templateTabTopic;
const activeTemplateText = templateTab === 'agent' ? templateAgentText : templateTopicText;
const activeTemplatePlaceholder = templateTab === 'agent' ? '{"agents_md":"..."}' : '{"presets":[...]}';
const handleTemplateTextChange = templateTab === 'agent' ? onTemplateAgentTextChange : onTemplateTopicTextChange;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.templateManagerTitle}
subtitle={`${activeTemplateLabel} (${activeTemplateCount})`}
size="extend"
bodyClassName="ops-form-drawer-body"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">
{`${activeTemplateLabel} (${activeTemplateCount})`}
</div>
<div className="ops-inline-actions ops-inline-actions-wrap">
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSavingTemplates} onClick={() => void onSave(templateTab)}>
{isSavingTemplates ? labels.processing : labels.save}
</button>
</div>
</div>
)}
>
<div className="ops-form-modal">
<div className="ops-template-tabs" role="tablist" aria-label={labels.templateManagerTitle}>
<button
className={`ops-template-tab ${templateTab === 'agent' ? 'is-active' : ''}`}
onClick={() => onTemplateTabChange('agent')}
role="tab"
aria-selected={templateTab === 'agent'}
>
<span className="ops-template-tab-label">{`${labels.templateTabAgent} (${templateAgentCount})`}</span>
</button>
<button
className={`ops-template-tab ${templateTab === 'topic' ? 'is-active' : ''}`}
onClick={() => onTemplateTabChange('topic')}
role="tab"
aria-selected={templateTab === 'topic'}
>
<span className="ops-template-tab-label">{`${labels.templateTabTopic} (${templateTopicCount})`}</span>
</button>
</div>
<div className="ops-form-scroll">
<div className="ops-config-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={activeTemplateText}
onChange={(e) => handleTemplateTextChange(e.target.value)}
placeholder={activeTemplatePlaceholder}
/>
</div>
</div>
</div>
</div>
</DrawerShell>
);
}
interface AgentFilesModalProps {
open: boolean;
agentTab: AgentTab;
tabValue: string;
isSaving: boolean;
labels: AgentFilesModalLabels;
onClose: () => void;
onAgentTabChange: (tab: AgentTab) => void;
onTabValueChange: (value: string) => void;
onSave: () => Promise<void> | void;
}
export function AgentFilesModal({
open,
agentTab,
tabValue,
isSaving,
labels,
onClose,
onAgentTabChange,
onTabValueChange,
onSave,
}: AgentFilesModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.agentFiles}
subtitle="AGENTS.md, SOUL.md, USER.md, TOOLS.md, IDENTITY.md"
size="extend"
bodyClassName="ops-form-drawer-body"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{`${agentTab}.md`}</div>
<div className="ops-inline-actions ops-inline-actions-wrap">
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void onSave()}>{labels.saveFiles}</button>
</div>
</div>
)}
>
<div className="ops-form-modal">
<div className="wizard-agent-layout ops-agent-files-layout">
<div className="agent-tabs-vertical">
{AGENT_FILE_TABS.map((tab) => (
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => onAgentTabChange(tab)}>{tab}.md</button>
))}
</div>
<MarkdownLiteEditor
value={tabValue}
onChange={onTabValueChange}
fullHeight
/>
</div>
</div>
</DrawerShell>
);
}
interface RuntimeActionModalProps {
open: boolean;
runtimeAction: string;
labels: RuntimeActionModalLabels;
onClose: () => void;
}
export function RuntimeActionModal({
open,
runtimeAction,
labels,
onClose,
}: RuntimeActionModalProps) {
if (!open) return null;
return (
<DashboardModalCardShell
cardClassName="modal-preview"
closeLabel={labels.close}
onClose={onClose}
title={labels.lastAction}
>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</DashboardModalCardShell>
);
}

View File

@ -0,0 +1,116 @@
import { DrawerShell } from '../../../components/DrawerShell';
import './DashboardManagementModals.css';
import './DashboardSupportModals.css';
interface CommonModalLabels {
cancel: string;
close: string;
save: string;
}
interface TemplateManagerLabels extends CommonModalLabels {
processing: string;
templateManagerTitle: string;
templateTabAgent: string;
templateTabTopic: string;
}
interface TemplateManagerModalProps {
open: boolean;
templateTab: 'agent' | 'topic';
templateAgentCount: number;
templateTopicCount: number;
templateAgentText: string;
templateTopicText: string;
isSavingTemplates: boolean;
labels: TemplateManagerLabels;
onClose: () => void;
onTemplateTabChange: (tab: 'agent' | 'topic') => void;
onTemplateAgentTextChange: (value: string) => void;
onTemplateTopicTextChange: (value: string) => void;
onSave: (tab: 'agent' | 'topic') => Promise<void> | void;
}
export function TemplateManagerModal({
open,
templateTab,
templateAgentCount,
templateTopicCount,
templateAgentText,
templateTopicText,
isSavingTemplates,
labels,
onClose,
onTemplateTabChange,
onTemplateAgentTextChange,
onTemplateTopicTextChange,
onSave,
}: TemplateManagerModalProps) {
if (!open) return null;
const activeTemplateCount = templateTab === 'agent' ? templateAgentCount : templateTopicCount;
const activeTemplateLabel = templateTab === 'agent' ? labels.templateTabAgent : labels.templateTabTopic;
const activeTemplateText = templateTab === 'agent' ? templateAgentText : templateTopicText;
const activeTemplatePlaceholder = templateTab === 'agent' ? '{"agents_md":"..."}' : '{"presets":[...]}';
const handleTemplateTextChange = templateTab === 'agent' ? onTemplateAgentTextChange : onTemplateTopicTextChange;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.templateManagerTitle}
subtitle={`${activeTemplateLabel} (${activeTemplateCount})`}
size="extend"
bodyClassName="ops-form-drawer-body"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">
{`${activeTemplateLabel} (${activeTemplateCount})`}
</div>
<div className="ops-inline-actions ops-inline-actions-wrap">
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSavingTemplates} onClick={() => void onSave(templateTab)}>
{isSavingTemplates ? labels.processing : labels.save}
</button>
</div>
</div>
)}
>
<div className="ops-form-modal">
<div className="ops-template-tabs" role="tablist" aria-label={labels.templateManagerTitle}>
<button
className={`ops-template-tab ${templateTab === 'agent' ? 'is-active' : ''}`}
onClick={() => onTemplateTabChange('agent')}
role="tab"
aria-selected={templateTab === 'agent'}
>
<span className="ops-template-tab-label">{`${labels.templateTabAgent} (${templateAgentCount})`}</span>
</button>
<button
className={`ops-template-tab ${templateTab === 'topic' ? 'is-active' : ''}`}
onClick={() => onTemplateTabChange('topic')}
role="tab"
aria-selected={templateTab === 'topic'}
>
<span className="ops-template-tab-label">{`${labels.templateTabTopic} (${templateTopicCount})`}</span>
</button>
</div>
<div className="ops-form-scroll">
<div className="ops-config-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={activeTemplateText}
onChange={(e) => handleTemplateTextChange(e.target.value)}
placeholder={activeTemplatePlaceholder}
/>
</div>
</div>
</div>
</div>
</DrawerShell>
);
}

View File

@ -0,0 +1,338 @@
import { ChevronDown, ChevronUp, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react';
import type { RefObject } from 'react';
import { DrawerShell } from '../../../components/DrawerShell';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotTopic, TopicPresetTemplate } from '../types';
import './DashboardManagementModals.css';
interface TopicConfigModalProps {
open: boolean;
topics: BotTopic[];
expandedTopicByKey: Record<string, boolean>;
newTopicPanelOpen: boolean;
topicPresetMenuOpen: boolean;
newTopicAdvancedOpen: boolean;
newTopicSourceLabel: string;
newTopicKey: string;
newTopicName: string;
newTopicDescription: string;
newTopicPurpose: string;
newTopicIncludeWhen: string;
newTopicExcludeWhen: string;
newTopicExamplesPositive: string;
newTopicExamplesNegative: string;
newTopicPriority: string;
effectiveTopicPresetTemplates: TopicPresetTemplate[];
topicPresetMenuRef: RefObject<HTMLDivElement | null>;
isSavingTopic: boolean;
hasSelectedBot: boolean;
isZh: boolean;
labels: Record<string, any>;
onClose: () => void;
getTopicUiKey: (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => string;
countRoutingTextList: (raw: string) => number;
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
onToggleExpandedTopic: (key: string) => void;
onRemoveTopic: (topic: BotTopic) => Promise<void> | void;
onSaveTopic: (topic: BotTopic) => Promise<void> | void;
onSetNewTopicPanelOpen: (value: boolean) => void;
onSetTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onSetNewTopicAdvancedOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onResetNewTopicDraft: () => void;
onNormalizeTopicKeyInput: (value: string) => string;
onSetNewTopicKey: (value: string) => void;
onSetNewTopicName: (value: string) => void;
onSetNewTopicDescription: (value: string) => void;
onSetNewTopicPurpose: (value: string) => void;
onSetNewTopicIncludeWhen: (value: string) => void;
onSetNewTopicExcludeWhen: (value: string) => void;
onSetNewTopicExamplesPositive: (value: string) => void;
onSetNewTopicExamplesNegative: (value: string) => void;
onSetNewTopicPriority: (value: string) => void;
onBeginTopicCreate: (presetId: string) => void;
onResolvePresetLabel: (preset: TopicPresetTemplate) => string;
onAddTopic: () => Promise<void> | void;
}
export function TopicConfigModal({
open,
topics,
expandedTopicByKey,
newTopicPanelOpen,
topicPresetMenuOpen,
newTopicAdvancedOpen,
newTopicSourceLabel,
newTopicKey,
newTopicName,
newTopicDescription,
newTopicPurpose,
newTopicIncludeWhen,
newTopicExcludeWhen,
newTopicExamplesPositive,
newTopicExamplesNegative,
newTopicPriority,
effectiveTopicPresetTemplates,
topicPresetMenuRef,
isSavingTopic,
hasSelectedBot,
isZh,
labels,
onClose,
getTopicUiKey,
countRoutingTextList,
onUpdateTopicLocal,
onToggleExpandedTopic,
onRemoveTopic,
onSaveTopic,
onSetNewTopicPanelOpen,
onSetTopicPresetMenuOpen,
onSetNewTopicAdvancedOpen,
onResetNewTopicDraft,
onNormalizeTopicKeyInput,
onSetNewTopicKey,
onSetNewTopicName,
onSetNewTopicDescription,
onSetNewTopicPurpose,
onSetNewTopicIncludeWhen,
onSetNewTopicExcludeWhen,
onSetNewTopicExamplesPositive,
onSetNewTopicExamplesNegative,
onSetNewTopicPriority,
onBeginTopicCreate,
onResolvePresetLabel,
onAddTopic,
}: TopicConfigModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.topicPanel}
size="extend"
closeLabel={labels.close}
bodyClassName="ops-config-drawer-body"
footer={(
!newTopicPanelOpen ? (
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{labels.topicAddHint}</div>
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
<button className="btn btn-primary" disabled={isSavingTopic || !hasSelectedBot} onClick={() => onSetTopicPresetMenuOpen((prev) => !prev)}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.topicAdd}</span>
</button>
{topicPresetMenuOpen ? (
<div className="ops-topic-create-menu">
{effectiveTopicPresetTemplates.map((preset) => (
<button key={preset.id} className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate(preset.id)}>
{onResolvePresetLabel(preset)}
</button>
))}
<button className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate('blank')}>{labels.topicPresetBlank}</button>
</div>
) : null}
</div>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="wizard-channel-list ops-config-list-scroll">
{topics.length === 0 ? (
<div className="ops-empty-inline">{labels.topicEmpty}</div>
) : (
topics.map((topic, idx) => {
const uiKey = getTopicUiKey(topic, idx);
const expanded = expandedTopicByKey[uiKey] ?? idx === 0;
const includeCount = countRoutingTextList(String(topic.routing_include_when || ''));
const excludeCount = countRoutingTextList(String(topic.routing_exclude_when || ''));
return (
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{topic.topic_key}</strong>
<div className="field-label">{topic.name || topic.topic_key}</div>
{!expanded ? (
<div className="ops-config-collapsed-meta">
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`}
</div>
) : null}
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={Boolean(topic.is_active)}
onChange={(e) => onUpdateTopicLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.topicActive}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic}
onClick={() => void onRemoveTopic(topic)}
tooltip={labels.delete}
aria-label={labels.delete}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleExpandedTopic(uiKey)}
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input className="input" value={topic.name || ''} onChange={(e) => onUpdateTopicLocal(idx, { name: e.target.value })} placeholder={labels.topicName} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input className="input mono" type="number" min={0} max={100} step={1} value={topic.routing_priority || '50'} onChange={(e) => onUpdateTopicLocal(idx, { routing_priority: e.target.value })} />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea className="input" rows={3} value={topic.description || ''} onChange={(e) => onUpdateTopicLocal(idx, { description: e.target.value })} placeholder={labels.topicDescription} />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea className="input" rows={3} value={topic.routing_purpose || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_purpose: e.target.value })} placeholder={labels.topicPurpose} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea className="input mono" rows={4} value={topic.routing_include_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_include_when: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea className="input mono" rows={4} value={topic.routing_exclude_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_exclude_when: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea className="input mono" rows={4} value={topic.routing_examples_positive || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_positive: e.target.value })} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea className="input mono" rows={4} value={topic.routing_examples_negative || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_negative: e.target.value })} placeholder={labels.topicListHint} />
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.topicAddHint}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void onSaveTopic(topic)}>
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newTopicPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.topicAdd}</strong>
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onSetNewTopicAdvancedOpen((prev) => !prev)}
tooltip={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
aria-label={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
>
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
onSetNewTopicPanelOpen(false);
onSetTopicPresetMenuOpen(false);
onResetNewTopicDraft();
}}
tooltip={labels.cancel}
aria-label={labels.cancel}
>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicKey}</label>
<input className="input mono" value={newTopicKey} onChange={(e) => onSetNewTopicKey(onNormalizeTopicKeyInput(e.target.value))} placeholder={labels.topicKeyPlaceholder} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input className="input" value={newTopicName} onChange={(e) => onSetNewTopicName(e.target.value)} placeholder={labels.topicName} autoComplete="off" />
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea className="input" rows={3} value={newTopicDescription} onChange={(e) => onSetNewTopicDescription(e.target.value)} placeholder={labels.topicDescription} />
</div>
{newTopicAdvancedOpen ? (
<>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea className="input" rows={3} value={newTopicPurpose} onChange={(e) => onSetNewTopicPurpose(e.target.value)} placeholder={labels.topicPurpose} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea className="input mono" rows={4} value={newTopicIncludeWhen} onChange={(e) => onSetNewTopicIncludeWhen(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea className="input mono" rows={4} value={newTopicExcludeWhen} onChange={(e) => onSetNewTopicExcludeWhen(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea className="input mono" rows={4} value={newTopicExamplesPositive} onChange={(e) => onSetNewTopicExamplesPositive(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea className="input mono" rows={4} value={newTopicExamplesNegative} onChange={(e) => onSetNewTopicExamplesNegative(e.target.value)} placeholder={labels.topicListHint} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input className="input mono" type="number" min={0} max={100} step={1} value={newTopicPriority} onChange={(e) => onSetNewTopicPriority(e.target.value)} />
</div>
</>
) : null}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.topicAddHint}</span>
<div className="ops-inline-actions ops-inline-actions-wrap ops-inline-actions-end">
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic}
onClick={() => {
onSetNewTopicPanelOpen(false);
onSetTopicPresetMenuOpen(false);
onResetNewTopicDraft();
}}
>
{labels.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic || !hasSelectedBot} onClick={() => void onAddTopic()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}

View File

@ -3,10 +3,10 @@ import type { RefObject } from 'react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput'; import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { WorkspaceEntriesList } from '../../../shared/workspace/WorkspaceEntriesList';
import type { WorkspaceNode } from '../../../shared/workspace/types';
import { isPreviewableWorkspaceFile } from '../../../shared/workspace/utils';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import { isPreviewableWorkspaceFile } from '../utils';
import type { WorkspaceNode } from '../types';
import { WorkspaceEntriesList } from './WorkspaceEntriesList';
import './DashboardMenus.css'; import './DashboardMenus.css';
import './RuntimePanel.css'; import './RuntimePanel.css';

View File

@ -4,9 +4,9 @@ import '../../../components/skill-market/SkillMarketShared.css';
import type { BotSkillMarketItem } from '../../platform/types'; import type { BotSkillMarketItem } from '../../platform/types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput'; import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
import { fetchPreferredPlatformPageSize } from '../../platform/api/settings'; import { fetchPreferredPlatformPageSize } from '../../platform/api/settings';
import { readCachedPlatformPageSize } from '../../../utils/platformPageSize'; import { readCachedPlatformPageSize } from '../../../utils/platformPageSize';
import { DashboardModalCardShell } from './DashboardModalCardShell';
interface SkillMarketInstallModalProps { interface SkillMarketInstallModalProps {
isZh: boolean; isZh: boolean;
@ -69,7 +69,7 @@ export function SkillMarketInstallModal({
if (!open) return null; if (!open) return null;
return ( return (
<DashboardModalCardShell <ModalCardShell
cardClassName="modal-wide platform-modal skill-market-browser-shell" cardClassName="modal-wide platform-modal skill-market-browser-shell"
closeLabel={isZh ? '关闭' : 'Close'} closeLabel={isZh ? '关闭' : 'Close'}
headerActions={( headerActions={(
@ -185,6 +185,6 @@ export function SkillMarketInstallModal({
</LucentIconButton> </LucentIconButton>
</div> </div>
</div> </div>
</DashboardModalCardShell> </ModalCardShell>
); );
} }

View File

@ -1,100 +0,0 @@
import { FileText, FolderOpen } from 'lucide-react';
import type { WorkspaceNode } from '../types';
import { isPreviewableWorkspaceFile, workspaceFileAction } from '../utils';
interface WorkspaceEntriesLabels {
download: string;
fileNotPreviewable: string;
folder: string;
goUp: string;
goUpTitle: string;
openFolderTitle: string;
previewTitle: string;
}
interface WorkspaceEntriesListProps {
nodes: WorkspaceNode[];
workspaceParentPath: string | null;
selectedBotId: string;
workspaceFileLoading: boolean;
workspaceDownloadExtensionSet: ReadonlySet<string>;
labels: WorkspaceEntriesLabels;
onLoadWorkspaceTree: (botId: string, path?: string) => Promise<void> | void;
onOpenWorkspaceFilePreview: (path: string) => Promise<void> | void;
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
onHideWorkspaceHoverCard: () => void;
}
export function WorkspaceEntriesList({
nodes,
workspaceParentPath,
selectedBotId,
workspaceFileLoading,
workspaceDownloadExtensionSet,
labels,
onLoadWorkspaceTree,
onOpenWorkspaceFilePreview,
onShowWorkspaceHoverCard,
onHideWorkspaceHoverCard,
}: WorkspaceEntriesListProps) {
return (
<>
{workspaceParentPath !== null ? (
<button
key="dir:.."
className="workspace-entry dir nav-up"
onClick={() => void onLoadWorkspaceTree(selectedBotId, workspaceParentPath || '')}
title={labels.goUpTitle}
>
<FolderOpen size={14} />
<span className="workspace-entry-name">..</span>
<span className="workspace-entry-meta">{labels.goUp}</span>
</button>
) : null}
{nodes.map((node) => {
const key = `${node.type}:${node.path}`;
if (node.type === 'dir') {
return (
<button
key={key}
className="workspace-entry dir"
onClick={() => void onLoadWorkspaceTree(selectedBotId, node.path)}
title={labels.openFolderTitle}
>
<FolderOpen size={14} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta">{labels.folder}</span>
</button>
);
}
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
return (
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={workspaceFileLoading}
aria-disabled={!previewable || workspaceFileLoading}
onClick={() => {
if (workspaceFileLoading || !previewable) return;
void onOpenWorkspaceFilePreview(node.path);
}}
onMouseEnter={(event) => onShowWorkspaceHoverCard(node, event.currentTarget)}
onMouseLeave={onHideWorkspaceHoverCard}
onFocus={(event) => onShowWorkspaceHoverCard(node, event.currentTarget)}
onBlur={onHideWorkspaceHoverCard}
title={previewable ? (downloadOnlyFile ? labels.download : labels.previewTitle) : labels.fileNotPreviewable}
>
<FileText size={14} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
</button>
);
})}
</>
);
}

View File

@ -1,56 +0,0 @@
import { renderWorkspacePathSegments } from '../utils';
import type { WorkspaceHoverCardState } from '../types';
import './WorkspaceOverlay.css';
interface WorkspaceHoverCardProps {
state: WorkspaceHoverCardState | null;
isZh: boolean;
formatWorkspaceTime: (raw: string | undefined, isZh: boolean) => string;
formatBytes: (value: number) => string;
}
export function WorkspaceHoverCard({
state,
isZh,
formatWorkspaceTime,
formatBytes,
}: WorkspaceHoverCardProps) {
if (!state) return null;
return (
<div
className={`workspace-hover-panel ${state.above ? 'is-above' : ''}`}
style={{ top: state.top, left: state.left }}
role="tooltip"
>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
<span className="workspace-entry-info-value mono">{state.node.name || '-'}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
<span
className="workspace-entry-info-value workspace-entry-info-path mono"
title={`/root/.nanobot/workspace/${String(state.node.path || '').replace(/^\/+/, '')}`}
>
{renderWorkspacePathSegments(
`/root/.nanobot/workspace/${String(state.node.path || '').replace(/^\/+/, '')}`,
'hover-path',
)}
</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(state.node.ctime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(state.node.mtime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
<span className="workspace-entry-info-value mono">{Number.isFinite(Number(state.node.size)) ? formatBytes(Number(state.node.size)) : '-'}</span>
</div>
</div>
);
}

View File

@ -1,382 +0,0 @@
.workspace-hover-panel {
position: fixed;
z-index: 140;
width: min(420px, calc(100vw - 16px));
border: 1px solid color-mix(in oklab, var(--line) 70%, var(--brand) 30%);
border-radius: 10px;
background: color-mix(in oklab, var(--panel) 90%, #000 10%);
box-shadow: 0 12px 26px rgba(7, 13, 26, 0.28);
padding: 8px 10px;
display: grid;
gap: 5px;
pointer-events: none;
}
.workspace-hover-panel.is-above {
transform: translateY(-100%);
}
.workspace-entry-info-row {
display: grid;
grid-template-columns: 60px 1fr;
gap: 8px;
align-items: start;
}
.workspace-entry-info-label {
color: var(--subtitle);
font-size: 11px;
font-weight: 700;
}
.workspace-entry-info-value {
color: var(--text);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
word-break: break-all;
}
.workspace-entry-info-path {
min-width: 0;
word-break: normal;
}
.workspace-entry-info-path .workspace-path-segments {
display: flex;
width: 100%;
overflow: visible;
}
.modal-preview {
width: min(1080px, 95vw);
height: min(860px, 92vh);
max-height: 92vh;
}
.modal-preview-fullscreen {
width: 100vw;
max-width: 100vw;
height: 100vh;
max-height: 100vh;
margin: 0;
border-radius: 0;
}
.modal-preview-fullscreen .workspace-preview-body {
min-height: calc(100vh - 170px);
max-height: calc(100vh - 170px);
}
.modal-title-row.workspace-preview-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
position: relative;
padding-right: 72px;
min-height: 28px;
}
.workspace-preview-header-text {
min-width: 0;
display: grid;
gap: 4px;
}
.workspace-preview-path-row {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.workspace-preview-path-row > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
}
.workspace-path-segments {
min-width: 0;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
row-gap: 2px;
column-gap: 0;
overflow: hidden;
}
.workspace-path-segment {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.workspace-path-separator {
opacity: 0.6;
flex: 0 0 auto;
}
.workspace-preview-copy-name {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
box-shadow: none;
}
.workspace-preview-copy-name:hover {
color: var(--text);
background: transparent;
}
.workspace-preview-copy-name:focus-visible {
outline: 2px solid color-mix(in oklab, var(--brand) 40%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
.workspace-preview-header-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
position: absolute;
top: 0;
right: 0;
}
.workspace-preview-footer-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.workspace-preview-body {
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
height: 100%;
overflow: auto;
}
.workspace-preview-body.is-editing {
display: flex;
overflow: hidden;
}
.workspace-preview-body.media {
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
}
.workspace-preview-media {
width: 100%;
max-height: min(72vh, 720px);
border-radius: 16px;
background: #000;
}
.workspace-preview-audio {
width: min(100%, 760px);
align-self: center;
margin: 0 auto;
}
.workspace-preview-embed {
width: 100%;
min-height: 68vh;
border: 0;
background: #fff;
}
.workspace-preview-body pre {
margin: 0;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.56;
color: var(--text);
font-size: 13px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.workspace-preview-body.markdown pre {
margin: 0;
}
.workspace-preview-editor {
display: block;
width: 100%;
height: 100%;
min-height: 100%;
max-height: none;
padding: 14px 16px;
border: 0;
border-radius: 10px;
resize: vertical;
background: transparent;
color: var(--text);
line-height: 1.68;
box-sizing: border-box;
overflow: auto;
}
.workspace-preview-editor:focus {
outline: none;
}
.workspace-preview-editor-shell {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
box-sizing: border-box;
}
.workspace-preview-image {
display: block;
max-width: 100%;
max-height: 70vh;
width: auto;
height: auto;
object-fit: contain;
margin: 0 auto;
}
.workspace-markdown {
padding: 12px 14px;
color: var(--text);
line-height: 1.72;
font-size: 14px;
font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.workspace-markdown > *:first-child {
margin-top: 0;
}
.workspace-markdown > *:last-child {
margin-bottom: 0;
}
.workspace-markdown h1,
.workspace-markdown h2,
.workspace-markdown h3,
.workspace-markdown h4 {
margin: 16px 0 8px;
color: var(--text);
font-weight: 800;
line-height: 1.3;
}
.workspace-markdown h1 {
font-size: 24px;
}
.workspace-markdown h2 {
font-size: 20px;
}
.workspace-markdown h3 {
font-size: 17px;
}
.workspace-markdown p {
margin: 8px 0;
}
.workspace-markdown ul,
.workspace-markdown ol {
margin: 8px 0 8px 20px;
padding: 0;
}
.workspace-markdown li {
margin: 4px 0;
}
.workspace-markdown blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 3px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
border-radius: 8px;
}
.workspace-markdown hr {
border: 0;
border-top: 1px solid var(--line);
margin: 14px 0;
}
.workspace-markdown code {
font-family: 'SF Mono', Menlo, Consolas, monospace;
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 6px;
font-size: 12px;
}
.workspace-markdown pre {
margin: 10px 0;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
background: color-mix(in oklab, var(--panel-soft) 70%, var(--panel) 30%);
border: 1px solid var(--line);
border-radius: 8px;
}
.workspace-markdown pre code {
border: none;
background: transparent;
padding: 0;
font-size: 12px;
}
.workspace-markdown table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 13px;
}
.workspace-markdown th,
.workspace-markdown td {
border: 1px solid var(--line);
padding: 6px 8px;
text-align: left;
}
.workspace-markdown th {
background: color-mix(in oklab, var(--panel-soft) 74%, var(--panel) 26%);
}
.workspace-markdown a {
color: color-mix(in oklab, var(--brand) 80%, #5fa6ff 20%);
text-decoration: underline;
}
.workspace-preview-meta {
color: var(--muted);
font-size: 11px;
}

View File

@ -1,219 +0,0 @@
import { Copy, Maximize2, Minimize2, RefreshCw, Save } from 'lucide-react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { DashboardPreviewModalShell } from './DashboardPreviewModalShell';
import { MARKDOWN_SANITIZE_SCHEMA } from '../constants';
import type { WorkspacePreviewState } from '../types';
import { renderWorkspacePathSegments } from '../utils';
import { decorateWorkspacePathsForMarkdown } from '../shared/workspaceMarkdown';
import './WorkspaceOverlay.css';
interface WorkspacePreviewLabels {
cancel: string;
close: string;
copyAddress: string;
download: string;
editFile: string;
filePreview: string;
fileTruncated: string;
save: string;
}
interface WorkspacePreviewModalProps {
isZh: boolean;
labels: WorkspacePreviewLabels;
preview: WorkspacePreviewState | null;
previewFullscreen: boolean;
previewEditorEnabled: boolean;
previewCanEdit: boolean;
previewDraft: string;
previewSaving: boolean;
markdownComponents: Components;
onClose: () => void;
onToggleFullscreen: () => void;
onCopyPreviewPath: (path: string) => Promise<void> | void;
onCopyPreviewUrl: (path: string) => Promise<void> | void;
onPreviewDraftChange: (value: string) => void;
onSavePreviewMarkdown: () => Promise<void> | void;
onEnterEditMode: () => void;
onExitEditMode: () => void;
getWorkspaceDownloadHref: (filePath: string, forceDownload?: boolean) => string;
getWorkspaceRawHref: (filePath: string, forceDownload?: boolean) => string;
}
export function WorkspacePreviewModal({
isZh,
labels,
preview,
previewFullscreen,
previewEditorEnabled,
previewCanEdit,
previewDraft,
previewSaving,
markdownComponents,
onClose,
onToggleFullscreen,
onCopyPreviewPath,
onCopyPreviewUrl,
onPreviewDraftChange,
onSavePreviewMarkdown,
onEnterEditMode,
onExitEditMode,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
}: WorkspacePreviewModalProps) {
if (!preview) return null;
const fullscreenLabel = previewFullscreen
? (isZh ? '退出全屏' : 'Exit full screen')
: previewEditorEnabled
? (isZh ? '全屏编辑' : 'Full screen editor')
: (isZh ? '全屏预览' : 'Full screen');
return (
<DashboardPreviewModalShell
cardClassName={previewFullscreen ? 'modal-preview-fullscreen' : undefined}
closeLabel={labels.close}
headerActions={(
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleFullscreen}
tooltip={fullscreenLabel}
aria-label={fullscreenLabel}
>
{previewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
)}
onClose={onClose}
subtitle={(
<span className="mono workspace-preview-path-row">
<span className="workspace-path-segments" title={preview.path}>
{renderWorkspacePathSegments(preview.path, 'preview-path')}
</span>
<LucentIconButton
className="workspace-preview-copy-name"
onClick={() => void onCopyPreviewPath(preview.path)}
tooltip={isZh ? '复制路径' : 'Copy path'}
aria-label={isZh ? '复制路径' : 'Copy path'}
>
<Copy size={12} />
</LucentIconButton>
</span>
)}
title={previewEditorEnabled ? labels.editFile : labels.filePreview}
>
<div
className={`workspace-preview-body ${preview.isMarkdown ? 'markdown' : ''} ${previewEditorEnabled ? 'is-editing' : ''} ${preview.isImage || preview.isVideo || preview.isAudio ? 'media' : ''}`}
>
{preview.isImage ? (
<img
className="workspace-preview-image"
src={getWorkspaceDownloadHref(preview.path, false)}
alt={preview.path.split('/').pop() || 'workspace-image'}
/>
) : preview.isVideo ? (
<video
className="workspace-preview-media"
src={getWorkspaceDownloadHref(preview.path, false)}
controls
preload="metadata"
/>
) : preview.isAudio ? (
<audio
className="workspace-preview-audio"
src={getWorkspaceDownloadHref(preview.path, false)}
controls
preload="metadata"
/>
) : preview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={getWorkspaceRawHref(preview.path, false)}
title={preview.path}
/>
) : previewEditorEnabled ? (
<MarkdownLiteEditor
className="workspace-preview-editor-shell"
textareaClassName="workspace-preview-editor"
value={previewDraft}
onChange={onPreviewDraftChange}
spellCheck={false}
fullHeight
onSaveShortcut={() => {
void onSavePreviewMarkdown();
}}
/>
) : preview.isMarkdown ? (
<div className="workspace-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(preview.content || '')}
</ReactMarkdown>
</div>
) : (
<pre>{preview.content}</pre>
)}
</div>
{preview.truncated ? (
<div className="ops-empty-inline">{labels.fileTruncated}</div>
) : null}
<div className="row-between">
<span className="workspace-preview-meta mono">{preview.ext || '-'}</span>
<div className="workspace-preview-footer-actions">
{previewEditorEnabled ? (
<>
<button
className="btn btn-secondary"
onClick={onExitEditMode}
disabled={previewSaving}
>
{labels.cancel}
</button>
<button
className="btn btn-primary"
onClick={() => void onSavePreviewMarkdown()}
disabled={previewSaving}
>
{previewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</>
) : previewCanEdit ? (
<button
className="btn btn-secondary"
onClick={onEnterEditMode}
>
{labels.editFile}
</button>
) : null}
{preview.isHtml ? (
<button
className="btn btn-secondary"
onClick={() => void onCopyPreviewUrl(preview.path)}
>
{labels.copyAddress}
</button>
) : (
<a
className="btn btn-secondary"
href={getWorkspaceDownloadHref(preview.path, true)}
target="_blank"
rel="noopener noreferrer"
download={preview.path.split('/').pop() || 'workspace-file'}
>
{labels.download}
</a>
)}
</div>
</div>
</DashboardPreviewModalShell>
);
}

View File

@ -1,8 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env'; import { APP_ENDPOINTS } from '../../../config/env';
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../topic/topicPresetUtils';
import type { BotTopic, TopicPresetTemplate } from '../types'; import type { BotTopic, TopicPresetTemplate } from '../types';
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';

View File

@ -1,3 +0,0 @@
export { createChannelManager } from './config-managers/channelManager';
export { createMcpManager } from './config-managers/mcpManager';
export { createTopicManager } from './config-managers/topicManager';

View File

@ -1,30 +1,5 @@
import { defaultSchema } from 'rehype-sanitize';
import type { ChannelType } from './types'; import type { ChannelType } from './types';
export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email']; export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email'];
export const RUNTIME_STALE_MS = 45000; export const RUNTIME_STALE_MS = 45000;
export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']); export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
export const TEXT_PREVIEW_EXTENSIONS = new Set(['.md', '.json', '.log', '.txt', '.csv']);
export const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']);
export const IMAGE_PREVIEW_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
export const AUDIO_PREVIEW_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma']);
export const VIDEO_PREVIEW_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts']);
export const MEDIA_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
]);
export const MARKDOWN_SANITIZE_SCHEMA = {
...defaultSchema,
tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])],
attributes: {
...defaultSchema.attributes,
audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'],
video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'],
},
};

View File

@ -0,0 +1,15 @@
type PromptTone = 'info' | 'success' | 'warning' | 'error';
export interface DashboardChatNotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
export interface DashboardChatConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}

View File

@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { pickLocale } from '../../../i18n'; import { pickLocale } from '../../../i18n';
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
import { channelsEn } from '../../../i18n/channels.en'; import { channelsEn } from '../../../i18n/channels.en';
import { channelsZhCn } from '../../../i18n/channels.zh-cn'; import { channelsZhCn } from '../../../i18n/channels.zh-cn';
import { dashboardEn } from '../../../i18n/dashboard.en'; import { dashboardEn } from '../../../i18n/dashboard.en';
@ -12,7 +13,7 @@ import { useDashboardBotEditor } from './useDashboardBotEditor';
import { useDashboardBotManagement } from './useDashboardBotManagement'; import { useDashboardBotManagement } from './useDashboardBotManagement';
import { useDashboardConfigPanels } from './useDashboardConfigPanels'; import { useDashboardConfigPanels } from './useDashboardConfigPanels';
import { useDashboardConversation } from './useDashboardConversation'; import { useDashboardConversation } from './useDashboardConversation';
import { useDashboardDerivedState } from './useDashboardDerivedState'; import { useDashboardBaseState, useDashboardInteractionState } from './useDashboardDerivedState';
import { useDashboardLifecycle } from './useDashboardLifecycle'; import { useDashboardLifecycle } from './useDashboardLifecycle';
import { useDashboardRuntimeControl } from './useDashboardRuntimeControl'; import { useDashboardRuntimeControl } from './useDashboardRuntimeControl';
import { useDashboardShellState } from './useDashboardShellState'; import { useDashboardShellState } from './useDashboardShellState';
@ -20,7 +21,6 @@ import { useDashboardSupportData } from './useDashboardSupportData';
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults'; import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
import { useDashboardTemplateManager } from './useDashboardTemplateManager'; import { useDashboardTemplateManager } from './useDashboardTemplateManager';
import { useDashboardVoiceInput } from './useDashboardVoiceInput'; import { useDashboardVoiceInput } from './useDashboardVoiceInput';
import { useDashboardWorkspace } from './useDashboardWorkspace';
export function useBotDashboardModule({ export function useBotDashboardModule({
forcedBotId, forcedBotId,
@ -381,7 +381,7 @@ export function useBotDashboardModule({
workspacePreviewSaving, workspacePreviewSaving,
workspaceQuery, workspaceQuery,
workspaceSearchLoading, workspaceSearchLoading,
} = useDashboardWorkspace({ } = useBotWorkspace({
selectedBotId, selectedBotId,
selectedBotDockerStatus: selectedBot?.docker_status || '', selectedBotDockerStatus: selectedBot?.docker_status || '',
workspaceDownloadExtensions, workspaceDownloadExtensions,
@ -396,13 +396,15 @@ export function useBotDashboardModule({
baseImageOptions, baseImageOptions,
canChat, canChat,
conversation, conversation,
displayState,
hasTopicUnread, hasTopicUnread,
isThinking: isBotThinking, isThinking: isBotThinking,
runtimeAction,
selectedBotControlState, selectedBotControlState,
selectedBotEnabled, selectedBotEnabled,
systemTimezoneOptions, systemTimezoneOptions,
topicPanelState, topicPanelState,
} = useDashboardDerivedState({ } = useDashboardBaseState({
availableImages, availableImages,
controlStateByBot, controlStateByBot,
defaultSystemTimezone, defaultSystemTimezone,
@ -435,9 +437,7 @@ export function useBotDashboardModule({
feedbackSavingByMessageId, feedbackSavingByMessageId,
filePickerRef, filePickerRef,
interruptExecution, interruptExecution,
isCommandAutoUnlockWindowActive,
isInterrupting, isInterrupting,
isTaskRunning,
isSendingBlocked, isSendingBlocked,
jumpConversationToDate, jumpConversationToDate,
loadInitialChatPage, loadInitialChatPage,
@ -448,7 +448,6 @@ export function useBotDashboardModule({
restoreStagedSubmission, restoreStagedSubmission,
removeStagedSubmission, removeStagedSubmission,
scrollConversationToBottom, scrollConversationToBottom,
send,
selectedBotStagedSubmissions, selectedBotStagedSubmissions,
sendControlCommand, sendControlCommand,
setChatDatePickerOpen, setChatDatePickerOpen,
@ -501,28 +500,15 @@ export function useBotDashboardModule({
}); });
const { const {
canSendControlCommand, canSendControlCommand,
displayState,
isChatEnabled, isChatEnabled,
isThinking, } = useDashboardInteractionState({
runtimeAction, canChat,
showInterruptSubmitAction,
} = useDashboardDerivedState({
availableImages,
controlStateByBot,
defaultSystemTimezone,
editFormImageTag: editForm.image_tag,
editFormSystemTimezone: editForm.system_timezone,
events,
isCommandAutoUnlockWindowActive,
isSendingBlocked, isSendingBlocked,
isVoiceRecording, isVoiceRecording,
isVoiceTranscribing, isVoiceTranscribing,
isZh,
messages,
selectedBot, selectedBot,
topicFeedUnreadCount,
topics,
}); });
const isThinking = isBotThinking;
useDashboardLifecycle({ useDashboardLifecycle({
activeTopicOptions, activeTopicOptions,
@ -720,9 +706,6 @@ export function useBotDashboardModule({
onVoiceInput, onVoiceInput,
triggerPickAttachments, triggerPickAttachments,
submitActionMode, submitActionMode,
isTaskRunning,
showInterruptSubmitAction,
send,
handlePrimarySubmitAction, handlePrimarySubmitAction,
runtimeMenuOpen, runtimeMenuOpen,
runtimeMenuRef, runtimeMenuRef,

View File

@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { optionalChannelTypes } from '../constants';
import { createChannelManager } from '../config-managers/channelManager';
import type { BotChannel, WeixinLoginStatus } from '../types';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface ConfirmOptions {
title?: string;
message: string;
tone?: PromptTone;
confirmText?: string;
cancelText?: string;
}
interface UseDashboardChannelConfigOptions {
closeRuntimeMenu: () => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
isZh: boolean;
loadWeixinLoginStatus: (botId: string, silent?: boolean) => Promise<void>;
notify: (message: string, options?: NotifyOptions) => void;
passwordToggleLabels: { show: string; hide: string };
refresh: () => Promise<void>;
reloginWeixin: () => Promise<void>;
selectedBot?: any;
selectedBotId: string;
t: any;
lc: any;
weixinLoginStatus: WeixinLoginStatus | null;
}
export function useDashboardChannelConfig({
closeRuntimeMenu,
confirm,
isZh,
loadWeixinLoginStatus,
notify,
passwordToggleLabels,
refresh,
reloginWeixin,
selectedBot,
selectedBotId,
t,
lc,
weixinLoginStatus,
}: UseDashboardChannelConfigOptions) {
const [showChannelModal, setShowChannelModal] = useState(false);
const [channels, setChannels] = useState<BotChannel[]>([]);
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
id: 'draft-channel',
bot_id: '',
channel_type: 'feishu',
external_app_id: '',
app_secret: '',
internal_port: 8080,
is_active: true,
extra_config: {},
});
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
const addableChannelTypes = useMemo(() => {
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
return optionalChannelTypes.filter((type) => !exists.has(type));
}, [channels]);
const {
resetNewChannelDraft,
channelDraftUiKey,
isDashboardChannel,
openChannelModal,
beginChannelCreate,
updateChannelLocal,
saveChannel,
addChannel,
removeChannel,
updateGlobalDeliveryFlag,
saveGlobalDelivery,
} = createChannelManager({
selectedBotId,
selectedBotDockerStatus: selectedBot?.docker_status || '',
t,
currentGlobalDelivery: globalDelivery,
addableChannelTypes,
currentNewChannelDraft: newChannelDraft,
refresh,
notify,
confirm,
setShowChannelModal,
setChannels,
setExpandedChannelByKey,
setChannelCreateMenuOpen,
setNewChannelPanelOpen,
setNewChannelDraft,
setIsSavingChannel,
setGlobalDelivery,
setIsSavingGlobalDelivery,
});
useEffect(() => {
if (!selectedBotId || !selectedBot) {
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
return;
}
setGlobalDelivery({
sendProgress: Boolean(selectedBot.send_progress),
sendToolHints: Boolean(selectedBot.send_tool_hints),
});
}, [selectedBot, selectedBotId]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
setChannelCreateMenuOpen(false);
}
};
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return;
setChannelCreateMenuOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const openChannelConfigModal = useCallback(() => {
closeRuntimeMenu();
if (!selectedBot) return;
void loadWeixinLoginStatus(selectedBot.id);
openChannelModal(selectedBot.id);
}, [closeRuntimeMenu, loadWeixinLoginStatus, openChannelModal, selectedBot]);
const resetChannelPanels = useCallback(() => {
setShowChannelModal(false);
setChannels([]);
setExpandedChannelByKey({});
setNewChannelPanelOpen(false);
setChannelCreateMenuOpen(false);
resetNewChannelDraft();
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
}, [resetNewChannelDraft]);
const channelConfigModalProps = {
open: showChannelModal,
channels,
globalDelivery,
expandedChannelByKey,
newChannelDraft,
addableChannelTypes,
newChannelPanelOpen,
channelCreateMenuOpen,
channelCreateMenuRef,
isSavingGlobalDelivery,
isSavingChannel,
weixinLoginStatus,
hasSelectedBot: Boolean(selectedBot),
isZh,
labels: { ...lc, cancel: t.cancel, close: t.close },
passwordToggleLabels,
onClose: () => {
setShowChannelModal(false);
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
},
onUpdateGlobalDeliveryFlag: updateGlobalDeliveryFlag,
onSaveGlobalDelivery: saveGlobalDelivery,
getChannelUiKey: channelDraftUiKey,
isDashboardChannel,
onUpdateChannelLocal: updateChannelLocal,
onToggleExpandedChannel: (key: string) => {
setExpandedChannelByKey((prev) => {
const fallbackExpanded = channels.findIndex((channel, idx) => channelDraftUiKey(channel, idx) === key) === 0;
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
return { ...prev, [key]: !current };
});
},
onRemoveChannel: removeChannel,
onSaveChannel: saveChannel,
onReloginWeixin: reloginWeixin,
onSetNewChannelPanelOpen: setNewChannelPanelOpen,
onSetChannelCreateMenuOpen: setChannelCreateMenuOpen,
onResetNewChannelDraft: resetNewChannelDraft,
onUpdateNewChannelDraft: (patch: Partial<BotChannel>) => setNewChannelDraft((prev) => ({ ...prev, ...patch })),
onBeginChannelCreate: beginChannelCreate,
onAddChannel: addChannel,
};
return {
channelConfigModalProps,
openChannelConfigModal,
resetChannelPanels,
};
}

View File

@ -0,0 +1,280 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
import type { QuotedReply, StagedSubmissionDraft } from '../types';
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
interface UseDashboardChatCommandDispatchOptions {
selectedBot?: { id: string } | null;
canChat: boolean;
isTaskRunningExternally: boolean;
commandAutoUnlockSeconds: number;
command: string;
pendingAttachments: string[];
quotedReply: QuotedReply | null;
setCommand: Dispatch<SetStateAction<string>>;
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
setQuotedReply: Dispatch<SetStateAction<QuotedReply | null>>;
setChatDatePickerOpen: Dispatch<SetStateAction<boolean>>;
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
completeLeadingStagedSubmission: (stagedSubmissionId: string) => void;
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
t: any;
}
export function useDashboardChatCommandDispatch({
selectedBot,
canChat,
isTaskRunningExternally,
commandAutoUnlockSeconds,
command,
pendingAttachments,
quotedReply,
setCommand,
setPendingAttachments,
setQuotedReply,
setChatDatePickerOpen,
setControlCommandPanelOpen,
addBotMessage,
scrollConversationToBottom,
completeLeadingStagedSubmission,
notify,
t,
}: UseDashboardChatCommandDispatchOptions) {
const [sendingByBot, setSendingByBot] = useState<Record<string, number>>({});
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0;
const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0;
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
const isSending = selectedBotSendingCount > 0;
const isTaskRunning = Boolean(selectedBot && (isSending || isTaskRunningExternally));
const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now();
const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive;
const isInterrupting = Boolean(selectedBot && interruptingByBot[selectedBot.id]);
useEffect(() => {
if (!selectedBot?.id || selectedBotAutoUnlockDeadline <= 0) return;
const remaining = selectedBotAutoUnlockDeadline - Date.now();
if (remaining <= 0) {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
return;
}
const timer = window.setTimeout(() => {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}, remaining + 20);
return () => window.clearTimeout(timer);
}, [selectedBot?.id, selectedBotAutoUnlockDeadline]);
const submitChatPayload = useCallback(async ({
commandRaw,
attachmentsRaw,
quotedReplyRaw,
clearComposerOnSuccess,
clearStagedSubmissionId,
}: {
commandRaw: string;
attachmentsRaw: string[];
quotedReplyRaw: QuotedReply | null;
clearComposerOnSuccess: boolean;
clearStagedSubmissionId?: string;
}) => {
if (!selectedBot || !canChat) return false;
const attachments = [...attachmentsRaw];
const text = normalizeUserMessageText(commandRaw);
const quoteText = normalizeAssistantMessageText(quotedReplyRaw?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (attachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && attachments.length === 0) return false;
try {
requestAnimationFrame(() => scrollConversationToBottom('auto'));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: Number(prev[selectedBot.id] || 0) + 1 }));
setCommandAutoUnlockDeadlineByBot((prev) => ({
...prev,
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
}));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments,
ts: Date.now(),
kind: 'final',
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
if (clearComposerOnSuccess) {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
if (clearStagedSubmissionId) {
completeLeadingStagedSubmission(clearStagedSubmissionId);
}
return true;
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
ts: Date.now(),
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
notify(msg, { tone: 'error' });
return false;
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
const remaining = Number(next[selectedBot.id] || 0) - 1;
if (remaining > 0) {
next[selectedBot.id] = remaining;
} else {
delete next[selectedBot.id];
}
return next;
});
}
}, [
addBotMessage,
canChat,
commandAutoUnlockSeconds,
completeLeadingStagedSubmission,
notify,
scrollConversationToBottom,
selectedBot,
setCommand,
setPendingAttachments,
setQuotedReply,
t,
]);
const sendCurrentDraft = useCallback(async () => {
if (!selectedBot || !canChat || isTaskRunning) return false;
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
if (!hasComposerDraft) return false;
return submitChatPayload({
commandRaw: command,
attachmentsRaw: pendingAttachments,
quotedReplyRaw: quotedReply,
clearComposerOnSuccess: true,
});
}, [canChat, command, isTaskRunning, pendingAttachments, quotedReply, selectedBot, submitChatPayload]);
const sendQueuedSubmission = useCallback(async (submission: StagedSubmissionDraft) => submitChatPayload({
commandRaw: submission.command,
attachmentsRaw: submission.attachments,
quotedReplyRaw: submission.quotedReply,
clearComposerOnSuccess: false,
clearStagedSubmissionId: submission.id,
}), [submitChatPayload]);
const sendControlCommand = useCallback(async (slashCommand: '/new' | '/restart') => {
if (!selectedBot || !canChat || activeControlCommand) return;
try {
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: slashCommand },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
if (slashCommand === '/new') {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setControlCommandByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
}, [
activeControlCommand,
canChat,
notify,
selectedBot,
setChatDatePickerOpen,
setCommand,
setControlCommandPanelOpen,
setPendingAttachments,
setQuotedReply,
t,
]);
const interruptExecution = useCallback(async () => {
if (!selectedBot || !canChat || isInterrupting) return;
try {
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: '/stop' },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setInterruptingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
}, [canChat, isInterrupting, notify, selectedBot, setChatDatePickerOpen, setControlCommandPanelOpen, t]);
return {
activeControlCommand,
interruptExecution,
isInterrupting,
isSending,
isSendingBlocked,
isTaskRunning,
sendControlCommand,
sendCurrentDraft,
sendQueuedSubmission,
};
}

View File

@ -1,23 +1,16 @@
import { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react'; import { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser'; import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
import type { QuotedReply, StagedSubmissionDraft } from '../types'; import type { QuotedReply } from '../types';
import { loadComposerDraft, persistComposerDraft } from '../utils'; import { loadComposerDraft, persistComposerDraft } from '../utils';
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
import { useDashboardChatCommandDispatch } from './useDashboardChatCommandDispatch';
import { useDashboardChatStaging } from './useDashboardChatStaging';
const COMPOSER_MIN_ROWS = 3; const COMPOSER_MIN_ROWS = 3;
const COMPOSER_MAX_HEIGHT_PX = 220; const COMPOSER_MAX_HEIGHT_PX = 220;
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface UseDashboardChatComposerOptions { interface UseDashboardChatComposerOptions {
selectedBotId: string; selectedBotId: string;
selectedBot?: { id: string } | null; selectedBot?: { id: string } | null;
@ -31,7 +24,7 @@ interface UseDashboardChatComposerOptions {
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>; setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void; addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
scrollConversationToBottom: (behavior?: ScrollBehavior) => void; scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
notify: (message: string, options?: NotifyOptions) => void; notify: (message: string, options?: DashboardChatNotifyOptions) => void;
t: any; t: any;
} }
@ -54,52 +47,67 @@ export function useDashboardChatComposer({
const [command, setCommand] = useState(''); const [command, setCommand] = useState('');
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false); const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null); const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
const [sendingByBot, setSendingByBot] = useState<Record<string, number>>({});
const [stagedSubmissionQueueByBot, setStagedSubmissionQueueByBot] = useState<Record<string, StagedSubmissionDraft[]>>({});
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
const filePickerRef = useRef<HTMLInputElement | null>(null); const filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null); const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({}); const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({});
const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0;
const selectedBotStagedSubmissions = selectedBot ? stagedSubmissionQueueByBot[selectedBot.id] || [] : [];
const nextQueuedSubmission = selectedBotStagedSubmissions[0] || null;
const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0;
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
const isSending = selectedBotSendingCount > 0;
const isTaskRunning = Boolean(selectedBot && (isSending || isTaskRunningExternally));
const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now();
const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive;
const isInterrupting = Boolean(selectedBot && interruptingByBot[selectedBot.id]);
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
const {
completeLeadingStagedSubmission,
nextQueuedSubmission,
removeStagedSubmission,
restoreStagedSubmission,
selectedBotStagedSubmissions,
stageCurrentSubmission,
} = useDashboardChatStaging({
selectedBot,
command,
pendingAttachments,
quotedReply,
setCommand,
setPendingAttachments,
setQuotedReply,
composerTextareaRef,
notify,
t,
});
const {
activeControlCommand,
interruptExecution,
isInterrupting,
isSending,
isSendingBlocked,
isTaskRunning,
sendControlCommand,
sendCurrentDraft,
sendQueuedSubmission,
} = useDashboardChatCommandDispatch({
selectedBot,
canChat,
isTaskRunningExternally,
commandAutoUnlockSeconds,
command,
pendingAttachments,
quotedReply,
setCommand,
setPendingAttachments,
setQuotedReply,
setChatDatePickerOpen,
setControlCommandPanelOpen,
addBotMessage,
scrollConversationToBottom,
completeLeadingStagedSubmission,
notify,
t,
});
const submitActionMode: 'interrupt' | 'send' | 'stage' = isTaskRunning const submitActionMode: 'interrupt' | 'send' | 'stage' = isTaskRunning
? (hasComposerDraft ? 'stage' : 'interrupt') ? (hasComposerDraft ? 'stage' : 'interrupt')
: 'send'; : 'send';
useEffect(() => {
if (!selectedBot?.id || selectedBotAutoUnlockDeadline <= 0) return;
const remaining = selectedBotAutoUnlockDeadline - Date.now();
if (remaining <= 0) {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
return;
}
const timer = window.setTimeout(() => {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}, remaining + 20);
return () => window.clearTimeout(timer);
}, [selectedBot?.id, selectedBotAutoUnlockDeadline]);
useEffect(() => { useEffect(() => {
setComposerDraftHydrated(false); setComposerDraftHydrated(false);
if (!selectedBotId) { if (!selectedBotId) {
@ -175,161 +183,6 @@ export function useDashboardChatComposer({
} }
}; };
const sendPayload = async ({
commandRaw,
attachmentsRaw,
quotedReplyRaw,
clearComposerOnSuccess,
clearStagedSubmissionId,
}: {
commandRaw: string;
attachmentsRaw: string[];
quotedReplyRaw: QuotedReply | null;
clearComposerOnSuccess: boolean;
clearStagedSubmissionId?: string;
}) => {
if (!selectedBot || !canChat) return false;
const attachments = [...attachmentsRaw];
const text = normalizeUserMessageText(commandRaw);
const quoteText = normalizeAssistantMessageText(quotedReplyRaw?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (attachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && attachments.length === 0) return false;
try {
requestAnimationFrame(() => scrollConversationToBottom('auto'));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: Number(prev[selectedBot.id] || 0) + 1 }));
setCommandAutoUnlockDeadlineByBot((prev) => ({
...prev,
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
}));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments,
ts: Date.now(),
kind: 'final',
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
if (clearComposerOnSuccess) {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
if (clearStagedSubmissionId) {
setStagedSubmissionQueueByBot((prev) => {
const currentQueue = prev[selectedBot.id] || [];
const current = currentQueue[0];
if (!current || current.id !== clearStagedSubmissionId) {
return prev;
}
const remainingQueue = currentQueue.slice(1);
const next = { ...prev };
if (remainingQueue.length > 0) {
next[selectedBot.id] = remainingQueue;
} else {
delete next[selectedBot.id];
}
return next;
});
}
return true;
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
ts: Date.now(),
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
notify(msg, { tone: 'error' });
return false;
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
const remaining = Number(next[selectedBot.id] || 0) - 1;
if (remaining > 0) {
next[selectedBot.id] = remaining;
} else {
delete next[selectedBot.id];
}
return next;
});
}
};
const send = async () => {
if (!selectedBot || !canChat || isTaskRunning) return false;
if (!hasComposerDraft) return false;
return sendPayload({
commandRaw: command,
attachmentsRaw: pendingAttachments,
quotedReplyRaw: quotedReply,
clearComposerOnSuccess: true,
});
};
const removeStagedSubmission = (stagedSubmissionId: string) => {
if (!selectedBot) return;
setStagedSubmissionQueueByBot((prev) => {
const currentQueue = prev[selectedBot.id] || [];
const nextQueue = currentQueue.filter((item) => item.id !== stagedSubmissionId);
if (nextQueue.length === currentQueue.length) return prev;
const next = { ...prev };
if (nextQueue.length > 0) {
next[selectedBot.id] = nextQueue;
} else {
delete next[selectedBot.id];
}
return next;
});
};
const restoreStagedSubmission = (stagedSubmissionId: string) => {
if (!selectedBot) return;
const targetSubmission = selectedBotStagedSubmissions.find((item) => item.id === stagedSubmissionId);
if (!targetSubmission) return;
setCommand(targetSubmission.command);
setPendingAttachments(targetSubmission.attachments);
setQuotedReply(targetSubmission.quotedReply);
removeStagedSubmission(stagedSubmissionId);
composerTextareaRef.current?.focus();
notify(t.stagedSubmissionRestored, { tone: 'success' });
};
const stageCurrentSubmission = () => {
if (!selectedBot || !hasComposerDraft) return;
const nextStagedSubmission: StagedSubmissionDraft = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
command,
attachments: [...pendingAttachments],
quotedReply,
updated_at_ms: Date.now(),
};
setStagedSubmissionQueueByBot((prev) => ({
...prev,
[selectedBot.id]: [...(prev[selectedBot.id] || []), nextStagedSubmission],
}));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
notify(t.stagedSubmissionQueued, { tone: 'success' });
};
const handlePrimarySubmitAction = async () => { const handlePrimarySubmitAction = async () => {
if (!selectedBot || !canChat) return; if (!selectedBot || !canChat) return;
if (isTaskRunning) { if (isTaskRunning) {
@ -340,7 +193,7 @@ export function useDashboardChatComposer({
await interruptExecution(); await interruptExecution();
return; return;
} }
await send(); await sendCurrentDraft();
}; };
useEffect(() => { useEffect(() => {
@ -352,73 +205,8 @@ export function useDashboardChatComposer({
return; return;
} }
stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id; stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id;
void sendPayload({ void sendQueuedSubmission(nextQueuedSubmission);
commandRaw: nextQueuedSubmission.command, }, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot, sendQueuedSubmission]);
attachmentsRaw: nextQueuedSubmission.attachments,
quotedReplyRaw: nextQueuedSubmission.quotedReply,
clearComposerOnSuccess: false,
clearStagedSubmissionId: nextQueuedSubmission.id,
});
}, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot]);
const sendControlCommand = async (slashCommand: '/new' | '/restart') => {
if (!selectedBot || !canChat || activeControlCommand) return;
try {
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: slashCommand },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
if (slashCommand === '/new') {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setControlCommandByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const interruptExecution = async () => {
if (!selectedBot || !canChat || isInterrupting) return;
try {
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: '/stop' },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setInterruptingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const copyUserPrompt = async (text: string) => { const copyUserPrompt = async (text: string) => {
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
@ -477,17 +265,14 @@ export function useDashboardChatComposer({
filePickerRef, filePickerRef,
handlePrimarySubmitAction, handlePrimarySubmitAction,
interruptExecution, interruptExecution,
isCommandAutoUnlockWindowActive,
isInterrupting, isInterrupting,
isSending, isSending,
isTaskRunning,
isSendingBlocked, isSendingBlocked,
onComposerKeyDown, onComposerKeyDown,
quoteAssistantReply, quoteAssistantReply,
quotedReply, quotedReply,
restoreStagedSubmission, restoreStagedSubmission,
removeStagedSubmission, removeStagedSubmission,
send,
sendControlCommand, sendControlCommand,
setCommand, setCommand,
setQuotedReply, setQuotedReply,

View File

@ -3,29 +3,14 @@ import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env'; import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import type { BotMessagesByDateResponse } from '../types'; import type { BotMessagesByDateResponse } from '../types';
import { import {
formatConversationDate, formatConversationDate,
formatDateInputValue, formatDateInputValue,
mapBotMessageResponseRow, mapBotMessageResponseRow,
} from '../utils'; } from '../chat/chatUtils';
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; import { useDashboardChatMessageActions } from './useDashboardChatMessageActions';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface ConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}
interface UseDashboardChatHistoryOptions { interface UseDashboardChatHistoryOptions {
selectedBotId: string; selectedBotId: string;
@ -36,8 +21,8 @@ interface UseDashboardChatHistoryOptions {
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>; setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
setBotMessages: (botId: string, messages: ChatMessage[]) => void; setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void; setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: NotifyOptions) => void; notify: (message: string, options?: DashboardChatNotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>; confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
t: any; t: any;
isZh: boolean; isZh: boolean;
} }
@ -65,8 +50,6 @@ export function useDashboardChatHistory({
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null); const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({}); const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({}); const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
const chatScrollRef = useRef<HTMLDivElement | null>(null); const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null); const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
@ -91,8 +74,6 @@ export function useDashboardChatHistory({
useEffect(() => { useEffect(() => {
setExpandedProgressByKey({}); setExpandedProgressByKey({});
setExpandedUserByKey({}); setExpandedUserByKey({});
setFeedbackSavingByMessageId({});
setDeletingMessageIdMap({});
setChatDatePickerOpen(false); setChatDatePickerOpen(false);
setChatDatePanelPosition(null); setChatDatePanelPosition(null);
setChatJumpAnchorId(null); setChatJumpAnchorId(null);
@ -156,6 +137,30 @@ export function useDashboardChatHistory({
.slice(-safeLimit); .slice(-safeLimit);
}, [chatPullPageSize]); }, [chatPullPageSize]);
const hydrateLatestMessages = useCallback(async (botId: string) => {
const latest = await fetchBotMessages(botId);
setBotMessages(botId, latest);
return latest;
}, [fetchBotMessages, setBotMessages]);
const {
deleteConversationMessage,
deletingMessageIdMap,
feedbackSavingByMessageId,
submitAssistantFeedback,
} = useDashboardChatMessageActions({
selectedBotId,
messages,
chatScrollRef,
chatAutoFollowRef,
hydrateLatestMessages,
setBotMessages,
setBotMessageFeedback,
notify,
confirm,
t,
});
const fetchBotMessagesPage = useCallback(async ( const fetchBotMessagesPage = useCallback(async (
botId: string, botId: string,
options?: { beforeId?: number | null; limit?: number }, options?: { beforeId?: number | null; limit?: number },
@ -368,203 +373,6 @@ export function useDashboardChatHistory({
} }
}; };
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = message.id;
if (!targetMessageId) {
try {
const latest = await fetchBotMessages(selectedBotId);
setBotMessages(selectedBotId, latest);
const normalizedTarget = normalizeAssistantMessageText(message.text);
const matched = latest
.filter((row) => row.role === 'assistant' && row.id)
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
.filter(({ message: row, diff }) => normalizeAssistantMessageText(row.text) === normalizedTarget && diff <= 10 * 60 * 1000)
.sort((a, b) => a.diff - b.diff)[0]?.message;
if (matched?.id) {
targetMessageId = matched.id;
}
} catch {
// ignore and fallback to warning below
}
}
if (!targetMessageId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
if (feedbackSavingByMessageId[targetMessageId]) return;
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
if (nextFeedback === null) {
notify(t.feedbackCleared, { tone: 'success' });
} else {
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
notify(msg, { tone: 'error' });
} finally {
setFeedbackSavingByMessageId((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
};
const resolveMessageIdFromLatest = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) return null;
const latest = await fetchBotMessages(selectedBotId);
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const matched = latest
.filter((row) => row.role === message.role && row.id)
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
.filter(({ message: row, diff }) => {
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& diff <= 10 * 60 * 1000;
})
.sort((a, b) => a.diff - b.diff)[0]?.message;
return matched?.id || null;
}, [fetchBotMessages, selectedBotId]);
const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => {
if (!selectedBotId) return;
const originalMessageId = Number(message.id);
const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0;
const idsToRemove = new Set<number>([deletedMessageId]);
if (hasOriginalId) {
idsToRemove.add(originalMessageId);
}
const scrollBox = chatScrollRef.current;
const prevTop = scrollBox?.scrollTop ?? null;
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const nextMessages = messages.filter((row) => {
const rowId = Number(row.id);
if (Number.isFinite(rowId) && rowId > 0) {
return !idsToRemove.has(rowId);
}
if (hasOriginalId || row.role !== message.role) {
return true;
}
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return !(
normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000
);
});
setBotMessages(selectedBotId, nextMessages);
if (prevTop === null || chatAutoFollowRef.current) return;
requestAnimationFrame(() => {
const box = chatScrollRef.current;
if (!box) return;
const maxTop = Math.max(0, box.scrollHeight - box.clientHeight);
box.scrollTop = Math.min(prevTop, maxTop);
});
}, [messages, selectedBotId, setBotMessages]);
const deleteConversationMessageOnServer = useCallback(async (messageId: number) => {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`);
}, [selectedBotId]);
const deleteConversationMessage = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = Number(message.id);
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
targetMessageId = Number(await resolveMessageIdFromLatest(message));
}
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
if (deletingMessageIdMap[targetMessageId]) return;
const roleLabel = message.role === 'user' ? t.you : 'Nanobot';
const ok = await confirm({
title: t.deleteMessage,
message: t.deleteMessageConfirm(roleLabel),
tone: 'warning',
confirmLabel: t.delete,
cancelLabel: t.cancel,
});
if (!ok) return;
setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await deleteConversationMessageOnServer(targetMessageId);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
} catch (error: any) {
if (error?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/delete`);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch {
// continue to secondary re-match fallback below
}
}
if (error?.response?.status === 404) {
const refreshedMessageId = Number(await resolveMessageIdFromLatest(message));
if (Number.isFinite(refreshedMessageId) && refreshedMessageId > 0 && refreshedMessageId !== targetMessageId) {
try {
await deleteConversationMessageOnServer(refreshedMessageId);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (retryError: any) {
if (retryError?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${refreshedMessageId}/delete`);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (postRetryError: any) {
notify(postRetryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
notify(retryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
}
notify(error?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
} finally {
setDeletingMessageIdMap((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
}, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]);
const toggleProgressExpanded = (key: string) => { const toggleProgressExpanded = (key: string) => {
setExpandedProgressByKey((prev) => ({ setExpandedProgressByKey((prev) => ({
...prev, ...prev,

View File

@ -0,0 +1,244 @@
import { useCallback, useEffect, useState, type MutableRefObject, type RefObject } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
interface UseDashboardChatMessageActionsOptions {
selectedBotId: string;
messages: ChatMessage[];
chatScrollRef: RefObject<HTMLDivElement | null>;
chatAutoFollowRef: MutableRefObject<boolean>;
hydrateLatestMessages: (botId: string) => Promise<ChatMessage[]>;
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
t: any;
}
export function useDashboardChatMessageActions({
selectedBotId,
messages,
chatScrollRef,
chatAutoFollowRef,
hydrateLatestMessages,
setBotMessages,
setBotMessageFeedback,
notify,
confirm,
t,
}: UseDashboardChatMessageActionsOptions) {
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
useEffect(() => {
setFeedbackSavingByMessageId({});
setDeletingMessageIdMap({});
}, [selectedBotId]);
const submitAssistantFeedback = useCallback(async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = message.id;
if (!targetMessageId) {
try {
const latest = await hydrateLatestMessages(selectedBotId);
const normalizedTarget = normalizeAssistantMessageText(message.text);
const matched = latest
.filter((row) => row.role === 'assistant' && row.id)
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
.filter(({ message: row, diff }) => normalizeAssistantMessageText(row.text) === normalizedTarget && diff <= 10 * 60 * 1000)
.sort((a, b) => a.diff - b.diff)[0]?.message;
if (matched?.id) {
targetMessageId = matched.id;
}
} catch {
// ignore and fallback to warning below
}
}
if (!targetMessageId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
return;
}
if (feedbackSavingByMessageId[targetMessageId]) return;
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
if (nextFeedback === null) {
notify(t.feedbackCleared, { tone: 'success' });
} else {
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
notify(msg, { tone: 'error' });
} finally {
setFeedbackSavingByMessageId((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
}, [feedbackSavingByMessageId, hydrateLatestMessages, notify, selectedBotId, setBotMessageFeedback, t]);
const resolveMessageIdFromLatest = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) return null;
const latest = await hydrateLatestMessages(selectedBotId);
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const matched = latest
.filter((row) => row.role === message.role && row.id)
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
.filter(({ message: row, diff }) => {
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& diff <= 10 * 60 * 1000;
})
.sort((a, b) => a.diff - b.diff)[0]?.message;
return matched?.id || null;
}, [hydrateLatestMessages, selectedBotId]);
const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => {
if (!selectedBotId) return;
const originalMessageId = Number(message.id);
const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0;
const idsToRemove = new Set<number>([deletedMessageId]);
if (hasOriginalId) {
idsToRemove.add(originalMessageId);
}
const scrollBox = chatScrollRef.current;
const prevTop = scrollBox?.scrollTop ?? null;
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const nextMessages = messages.filter((row) => {
const rowId = Number(row.id);
if (Number.isFinite(rowId) && rowId > 0) {
return !idsToRemove.has(rowId);
}
if (hasOriginalId || row.role !== message.role) {
return true;
}
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return !(
normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000
);
});
setBotMessages(selectedBotId, nextMessages);
if (prevTop === null || chatAutoFollowRef.current) return;
requestAnimationFrame(() => {
const box = chatScrollRef.current;
if (!box) return;
const maxTop = Math.max(0, box.scrollHeight - box.clientHeight);
box.scrollTop = Math.min(prevTop, maxTop);
});
}, [chatAutoFollowRef, chatScrollRef, messages, selectedBotId, setBotMessages]);
const deleteConversationMessageOnServer = useCallback(async (messageId: number) => {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`);
}, [selectedBotId]);
const deleteConversationMessage = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = Number(message.id);
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
targetMessageId = Number(await resolveMessageIdFromLatest(message));
}
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
if (deletingMessageIdMap[targetMessageId]) return;
const roleLabel = message.role === 'user' ? t.you : 'Nanobot';
const ok = await confirm({
title: t.deleteMessage,
message: t.deleteMessageConfirm(roleLabel),
tone: 'warning',
confirmLabel: t.delete,
cancelLabel: t.cancel,
});
if (!ok) return;
setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await deleteConversationMessageOnServer(targetMessageId);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
} catch (error: any) {
if (error?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/delete`);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch {
// continue to secondary re-match fallback below
}
}
if (error?.response?.status === 404) {
const refreshedMessageId = Number(await resolveMessageIdFromLatest(message));
if (Number.isFinite(refreshedMessageId) && refreshedMessageId > 0 && refreshedMessageId !== targetMessageId) {
try {
await deleteConversationMessageOnServer(refreshedMessageId);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (retryError: any) {
if (retryError?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${refreshedMessageId}/delete`);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (postRetryError: any) {
notify(postRetryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
notify(retryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
}
notify(error?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
} finally {
setDeletingMessageIdMap((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
}, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]);
return {
deleteConversationMessage,
deletingMessageIdMap,
feedbackSavingByMessageId,
submitAssistantFeedback,
};
}

View File

@ -0,0 +1,117 @@
import { useMemo, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
import type { QuotedReply, StagedSubmissionDraft } from '../types';
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
interface UseDashboardChatStagingOptions {
selectedBot?: { id: string } | null;
command: string;
pendingAttachments: string[];
quotedReply: QuotedReply | null;
setCommand: Dispatch<SetStateAction<string>>;
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
setQuotedReply: Dispatch<SetStateAction<QuotedReply | null>>;
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
t: any;
}
export function useDashboardChatStaging({
selectedBot,
command,
pendingAttachments,
quotedReply,
setCommand,
setPendingAttachments,
setQuotedReply,
composerTextareaRef,
notify,
t,
}: UseDashboardChatStagingOptions) {
const [stagedSubmissionQueueByBot, setStagedSubmissionQueueByBot] = useState<Record<string, StagedSubmissionDraft[]>>({});
const selectedBotStagedSubmissions = useMemo(
() => (selectedBot ? stagedSubmissionQueueByBot[selectedBot.id] || [] : []),
[selectedBot, stagedSubmissionQueueByBot],
);
const nextQueuedSubmission = selectedBotStagedSubmissions[0] || null;
const completeLeadingStagedSubmission = (stagedSubmissionId: string) => {
if (!selectedBot) return;
setStagedSubmissionQueueByBot((prev) => {
const currentQueue = prev[selectedBot.id] || [];
const current = currentQueue[0];
if (!current || current.id !== stagedSubmissionId) {
return prev;
}
const remainingQueue = currentQueue.slice(1);
const next = { ...prev };
if (remainingQueue.length > 0) {
next[selectedBot.id] = remainingQueue;
} else {
delete next[selectedBot.id];
}
return next;
});
};
const removeStagedSubmission = (stagedSubmissionId: string) => {
if (!selectedBot) return;
setStagedSubmissionQueueByBot((prev) => {
const currentQueue = prev[selectedBot.id] || [];
const nextQueue = currentQueue.filter((item) => item.id !== stagedSubmissionId);
if (nextQueue.length === currentQueue.length) return prev;
const next = { ...prev };
if (nextQueue.length > 0) {
next[selectedBot.id] = nextQueue;
} else {
delete next[selectedBot.id];
}
return next;
});
};
const restoreStagedSubmission = (stagedSubmissionId: string) => {
if (!selectedBot) return;
const targetSubmission = selectedBotStagedSubmissions.find((item) => item.id === stagedSubmissionId);
if (!targetSubmission) return;
setCommand(targetSubmission.command);
setPendingAttachments(targetSubmission.attachments);
setQuotedReply(targetSubmission.quotedReply);
removeStagedSubmission(stagedSubmissionId);
composerTextareaRef.current?.focus();
notify(t.stagedSubmissionRestored, { tone: 'success' });
};
const stageCurrentSubmission = () => {
if (!selectedBot) return false;
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
if (!hasComposerDraft) return false;
const nextStagedSubmission: StagedSubmissionDraft = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
command,
attachments: [...pendingAttachments],
quotedReply,
updated_at_ms: Date.now(),
};
setStagedSubmissionQueueByBot((prev) => ({
...prev,
[selectedBot.id]: [...(prev[selectedBot.id] || []), nextStagedSubmission],
}));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
notify(t.stagedSubmissionQueued, { tone: 'success' });
return true;
};
return {
completeLeadingStagedSubmission,
nextQueuedSubmission,
removeStagedSubmission,
restoreStagedSubmission,
selectedBotStagedSubmissions,
stageCurrentSubmission,
};
}

View File

@ -1,15 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'; import { useCallback, useState, type ChangeEvent } from 'react';
import { optionalChannelTypes } from '../constants';
import { createChannelManager, createMcpManager } from '../configManagers';
import { useDashboardSkillsConfig } from './useDashboardSkillsConfig'; import { useDashboardSkillsConfig } from './useDashboardSkillsConfig';
import { useDashboardChannelConfig } from './useDashboardChannelConfig';
import { useDashboardMcpConfig } from './useDashboardMcpConfig';
import { useDashboardTopicConfig } from './useDashboardTopicConfig'; import { useDashboardTopicConfig } from './useDashboardTopicConfig';
import type { BotChannel, MCPServerDraft, TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types'; import type { TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types';
import { import { formatCronSchedule } from '../utils';
buildChannelConfigModalProps,
buildCronJobsModalProps,
buildEnvParamsModalProps,
buildMcpConfigModalProps,
} from '../shared/configPanelModalProps';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions { interface NotifyOptions {
@ -107,53 +102,10 @@ export function useDashboardConfigPanels({
lc, lc,
weixinLoginStatus, weixinLoginStatus,
}: UseDashboardConfigPanelsOptions) { }: UseDashboardConfigPanelsOptions) {
const [showChannelModal, setShowChannelModal] = useState(false);
const [showMcpModal, setShowMcpModal] = useState(false);
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
const [showCronModal, setShowCronModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false);
const [channels, setChannels] = useState<BotChannel[]>([]);
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
id: 'draft-channel',
bot_id: '',
channel_type: 'feishu',
external_app_id: '',
app_secret: '',
internal_port: 8080,
is_active: true,
extra_config: {},
});
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
const [newMcpPanelOpen, setNewMcpPanelOpen] = useState(false);
const [newMcpDraft, setNewMcpDraft] = useState<MCPServerDraft>({
name: '',
type: 'streamableHttp',
url: '',
botId: '',
botSecret: '',
toolTimeout: '60',
headers: {},
locked: false,
originName: '',
});
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftValue, setEnvDraftValue] = useState('');
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingMcp, setIsSavingMcp] = useState(false);
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
});
const addableChannelTypes = useMemo(() => {
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
return optionalChannelTypes.filter((type) => !exists.has(type));
}, [channels]);
const { const {
loadTopics, loadTopics,
@ -194,125 +146,58 @@ export function useDashboardConfigPanels({
selectedBot, selectedBot,
}); });
const { const {
channelDraftUiKey, channelConfigModalProps,
resetNewChannelDraft, openChannelConfigModal,
isDashboardChannel, resetChannelPanels,
openChannelModal, } = useDashboardChannelConfig({
beginChannelCreate, closeRuntimeMenu,
updateChannelLocal,
saveChannel,
addChannel,
removeChannel,
updateGlobalDeliveryFlag,
saveGlobalDelivery,
} = createChannelManager({
selectedBotId,
selectedBotDockerStatus: selectedBot?.docker_status || '',
t,
currentGlobalDelivery: globalDelivery,
addableChannelTypes,
currentNewChannelDraft: newChannelDraft,
refresh,
notify,
confirm, confirm,
setShowChannelModal, isZh,
setChannels, loadWeixinLoginStatus,
setExpandedChannelByKey, notify,
setChannelCreateMenuOpen, passwordToggleLabels,
setNewChannelPanelOpen, refresh,
setNewChannelDraft, reloginWeixin,
setIsSavingChannel, selectedBot,
setGlobalDelivery, selectedBotId,
setIsSavingGlobalDelivery, t,
lc,
weixinLoginStatus,
}); });
const { const {
resetNewMcpDraft, mcpConfigModalProps,
mcpDraftUiKey, openMcpConfigModal,
openMcpModal, prepareMcpForBotChange,
beginMcpCreate, resetMcpPanels,
updateMcpServer, } = useDashboardMcpConfig({
canRemoveMcpServer, closeRuntimeMenu,
saveNewMcpServer,
saveSingleMcpServer,
removeMcpServer,
} = createMcpManager({
selectedBotId,
isZh,
t,
currentMcpServers: mcpServers,
currentPersistedMcpServers: persistedMcpServers,
currentNewMcpDraft: newMcpDraft,
notify,
confirm, confirm,
setShowMcpModal, isZh,
setMcpServers, notify,
setPersistedMcpServers, passwordToggleLabels,
setExpandedMcpByKey, selectedBot,
setNewMcpPanelOpen, selectedBotId,
setNewMcpDraft, t,
setIsSavingMcp,
}); });
useEffect(() => {
if (!selectedBotId || !selectedBot) {
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
return;
}
setGlobalDelivery({
sendProgress: Boolean(selectedBot.send_progress),
sendToolHints: Boolean(selectedBot.send_tool_hints),
});
}, [selectedBot, selectedBotId]);
useEffect(() => {
const onPointerDown = (event: MouseEvent) => {
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
setChannelCreateMenuOpen(false);
}
};
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return;
setChannelCreateMenuOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const resetAllConfigPanels = useCallback(() => { const resetAllConfigPanels = useCallback(() => {
setShowChannelModal(false);
setShowMcpModal(false);
setShowEnvParamsModal(false); setShowEnvParamsModal(false);
setShowCronModal(false); setShowCronModal(false);
resetSkillsPanels(); resetSkillsPanels();
setChannels([]); resetChannelPanels();
setExpandedChannelByKey({});
setNewChannelPanelOpen(false);
setChannelCreateMenuOpen(false);
resetNewChannelDraft();
resetTopicPanels(); resetTopicPanels();
setExpandedMcpByKey({}); resetMcpPanels();
setMcpServers([]);
setPersistedMcpServers([]);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setEnvDraftKey(''); setEnvDraftKey('');
setEnvDraftValue(''); setEnvDraftValue('');
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
resetSupportState(); resetSupportState();
}, [resetNewChannelDraft, resetNewMcpDraft, resetSkillsPanels, resetSupportState, resetTopicPanels]); }, [resetChannelPanels, resetMcpPanels, resetSkillsPanels, resetSupportState, resetTopicPanels]);
const prepareForBotChange = useCallback(() => { const prepareForBotChange = useCallback(() => {
resetSkillsPanels(); resetSkillsPanels();
prepareTopicForBotChange(); prepareTopicForBotChange();
setExpandedMcpByKey({}); prepareMcpForBotChange();
setNewMcpPanelOpen(false);
resetNewMcpDraft();
resetSupportState(); resetSupportState();
}, [prepareTopicForBotChange, resetNewMcpDraft, resetSkillsPanels, resetSupportState]); }, [prepareMcpForBotChange, prepareTopicForBotChange, resetSkillsPanels, resetSupportState]);
const loadInitialConfigData = useCallback(async (botId: string) => { const loadInitialConfigData = useCallback(async (botId: string) => {
await Promise.all([ await Promise.all([
@ -329,12 +214,6 @@ export function useDashboardConfigPanels({
loadTopicFeedStats, loadTopicFeedStats,
loadTopics, loadTopics,
]); ]);
const openChannelConfigModal = useCallback(() => {
closeRuntimeMenu();
if (!selectedBot) return;
void loadWeixinLoginStatus(selectedBot.id);
openChannelModal(selectedBot.id);
}, [closeRuntimeMenu, loadWeixinLoginStatus, openChannelModal, selectedBot]);
const openEnvParamsConfigModal = useCallback(() => { const openEnvParamsConfigModal = useCallback(() => {
closeRuntimeMenu(); closeRuntimeMenu();
@ -343,90 +222,17 @@ export function useDashboardConfigPanels({
setShowEnvParamsModal(true); setShowEnvParamsModal(true);
}, [closeRuntimeMenu, loadBotEnvParams, selectedBot]); }, [closeRuntimeMenu, loadBotEnvParams, selectedBot]);
const openMcpConfigModal = useCallback(() => {
closeRuntimeMenu();
if (!selectedBot) return;
void openMcpModal(selectedBot.id);
}, [closeRuntimeMenu, openMcpModal, selectedBot]);
const openCronJobsModal = useCallback(() => { const openCronJobsModal = useCallback(() => {
closeRuntimeMenu(); closeRuntimeMenu();
if (selectedBot) void loadCronJobs(selectedBot.id); if (selectedBot) void loadCronJobs(selectedBot.id);
setShowCronModal(true); setShowCronModal(true);
}, [closeRuntimeMenu, loadCronJobs, selectedBot]); }, [closeRuntimeMenu, loadCronJobs, selectedBot]);
const channelConfigModalProps = buildChannelConfigModalProps({ const envParamsModalProps = {
addableChannelTypes, open: showEnvParamsModal,
beginChannelCreate, envEntries,
channelCreateMenuOpen,
channelCreateMenuRef,
channelDraftUiKey,
channels,
expandedChannelByKey,
globalDelivery,
hasSelectedBot: Boolean(selectedBot),
isDashboardChannel,
isSavingChannel,
isSavingGlobalDelivery,
isZh,
labels: { ...lc, cancel: t.cancel, close: t.close },
newChannelDraft,
newChannelPanelOpen,
onAddChannel: addChannel,
onClose: () => {
setShowChannelModal(false);
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
},
onReloginWeixin: reloginWeixin,
passwordToggleLabels,
removeChannel,
resetNewChannelDraft,
saveChannel,
saveGlobalDelivery,
setChannelCreateMenuOpen,
setExpandedChannelByKey,
setNewChannelDraft,
setNewChannelPanelOpen,
updateChannelLocal,
updateGlobalDeliveryFlag,
weixinLoginStatus,
open: showChannelModal,
});
const mcpConfigModalProps = buildMcpConfigModalProps({
beginMcpCreate,
canRemoveMcpServer,
expandedMcpByKey,
getMcpUiKey: mcpDraftUiKey,
isSavingMcp,
isZh,
labels: { ...t, cancel: t.cancel, close: t.close, save: t.save },
mcpServers,
newMcpDraft,
newMcpPanelOpen,
onClose: () => {
setShowMcpModal(false);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
},
passwordToggleLabels,
removeMcpServer,
resetNewMcpDraft,
saveNewMcpServer,
saveSingleMcpServer,
setExpandedMcpByKey,
setNewMcpDraft,
setNewMcpPanelOpen,
updateMcpServer,
open: showMcpModal,
});
const envParamsModalProps = buildEnvParamsModalProps({
envDraftKey, envDraftKey,
envDraftValue, envDraftValue,
envEntries,
labels: { labels: {
addEnvParam: t.addEnvParam, addEnvParam: t.addEnvParam,
cancel: t.cancel, cancel: t.cancel,
@ -444,20 +250,19 @@ export function useDashboardConfigPanels({
showEnvValue: t.showEnvValue, showEnvValue: t.showEnvValue,
}, },
onClose: () => setShowEnvParamsModal(false), onClose: () => setShowEnvParamsModal(false),
onEnvDraftKeyChange: setEnvDraftKey,
onEnvDraftValueChange: setEnvDraftValue,
onCreateEnvParam: createEnvParam, onCreateEnvParam: createEnvParam,
onDeleteEnvParam: deleteEnvParam, onDeleteEnvParam: deleteEnvParam,
onSaveEnvParam: saveSingleEnvParam, onSaveEnvParam: saveSingleEnvParam,
setEnvDraftKey, };
setEnvDraftValue,
open: showEnvParamsModal,
});
const cronJobsModalProps = buildCronJobsModalProps({ const cronJobsModalProps = {
cronActionJobId, open: showCronModal,
cronActionType,
cronJobs,
cronLoading, cronLoading,
deleteCronJob, cronJobs,
cronActionJobId: cronActionJobId || '',
cronActionType,
isZh, isZh,
labels: { labels: {
close: t.close, close: t.close,
@ -471,13 +276,13 @@ export function useDashboardConfigPanels({
cronStop: t.cronStop, cronStop: t.cronStop,
cronViewer: t.cronViewer, cronViewer: t.cronViewer,
}, },
loadCronJobs, formatCronSchedule,
onClose: () => setShowCronModal(false), onClose: () => setShowCronModal(false),
selectedBot, onReload: () => (selectedBot ? loadCronJobs(selectedBot.id) : undefined),
startCronJob, onStartJob: startCronJob,
stopCronJob, onStopJob: stopCronJob,
open: showCronModal, onDeleteJob: deleteCronJob,
}); };
return { return {
channelConfigModalProps, channelConfigModalProps,

View File

@ -1,25 +1,10 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
import { useDashboardChatComposer } from './useDashboardChatComposer'; import { useDashboardChatComposer } from './useDashboardChatComposer';
import { useDashboardChatHistory } from './useDashboardChatHistory'; import { useDashboardChatHistory } from './useDashboardChatHistory';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface ConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}
interface UseDashboardConversationOptions { interface UseDashboardConversationOptions {
selectedBotId: string; selectedBotId: string;
selectedBot?: { id: string; messages?: ChatMessage[] } | null; selectedBot?: { id: string; messages?: ChatMessage[] } | null;
@ -37,8 +22,8 @@ interface UseDashboardConversationOptions {
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void; addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
setBotMessages: (botId: string, messages: ChatMessage[]) => void; setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void; setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: NotifyOptions) => void; notify: (message: string, options?: DashboardChatNotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>; confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
t: any; t: any;
isZh: boolean; isZh: boolean;
} }

View File

@ -1,27 +1,21 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones'; import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
import { mergeConversation } from '../chat/chatUtils';
import { RUNTIME_STALE_MS } from '../constants'; import { RUNTIME_STALE_MS } from '../constants';
import { normalizeAssistantMessageText } from '../messageParser'; import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
import type { BaseImageOption, NanobotImage } from '../types'; import type { BaseImageOption, NanobotImage } from '../types';
import type { TopicFeedOption } from '../topic/TopicFeedPanel'; import type { TopicFeedOption } from '../topic/TopicFeedPanel';
import { import { normalizeRuntimeState } from '../utils';
mergeConversation,
normalizeRuntimeState,
parseBotTimestamp,
} from '../utils';
interface UseDashboardDerivedStateOptions { interface UseDashboardBaseStateOptions {
availableImages: NanobotImage[]; availableImages: NanobotImage[];
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>; controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
defaultSystemTimezone: string; defaultSystemTimezone: string;
editFormImageTag: string; editFormImageTag: string;
editFormSystemTimezone: string; editFormSystemTimezone: string;
events: any[]; events: any[];
isCommandAutoUnlockWindowActive?: boolean;
isSendingBlocked?: boolean;
isVoiceRecording?: boolean;
isVoiceTranscribing?: boolean;
isZh: boolean; isZh: boolean;
messages: any[]; messages: any[];
selectedBot?: any; selectedBot?: any;
@ -29,23 +23,27 @@ interface UseDashboardDerivedStateOptions {
topics: any[]; topics: any[];
} }
export function useDashboardDerivedState({ interface UseDashboardInteractionStateOptions {
canChat: boolean;
isSendingBlocked?: boolean;
isVoiceRecording?: boolean;
isVoiceTranscribing?: boolean;
selectedBot?: any;
}
export function useDashboardBaseState({
availableImages, availableImages,
controlStateByBot, controlStateByBot,
defaultSystemTimezone, defaultSystemTimezone,
editFormImageTag, editFormImageTag,
editFormSystemTimezone, editFormSystemTimezone,
events, events,
isCommandAutoUnlockWindowActive = false,
isSendingBlocked = false,
isVoiceRecording = false,
isVoiceTranscribing = false,
isZh, isZh,
messages, messages,
selectedBot, selectedBot,
topicFeedUnreadCount, topicFeedUnreadCount,
topics, topics,
}: UseDashboardDerivedStateOptions) { }: UseDashboardBaseStateOptions) {
const activeTopicOptions = useMemo<TopicFeedOption[]>( const activeTopicOptions = useMemo<TopicFeedOption[]>(
() => () =>
topics topics
@ -102,10 +100,6 @@ export function useDashboardDerivedState({
selectedBot.docker_status === 'RUNNING' && selectedBot.docker_status === 'RUNNING' &&
!selectedBotControlState, !selectedBotControlState,
); );
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
const canSendControlCommand = Boolean(
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]); const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
const systemTimezoneOptions = useMemo( const systemTimezoneOptions = useMemo(
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone), () => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
@ -175,27 +169,38 @@ export function useDashboardDerivedState({
if (eventText) return eventText; if (eventText) return eventText;
return '-'; return '-';
}, [latestEvent, selectedBot]); }, [latestEvent, selectedBot]);
const showInterruptSubmitAction = Boolean(
canChat && ((isThinking && isCommandAutoUnlockWindowActive) || isSendingBlocked),
);
const hasTopicUnread = topicFeedUnreadCount > 0; const hasTopicUnread = topicFeedUnreadCount > 0;
return { return {
activeTopicOptions, activeTopicOptions,
baseImageOptions, baseImageOptions,
canChat, canChat,
canSendControlCommand,
conversation, conversation,
displayState, displayState,
hasTopicUnread, hasTopicUnread,
isChatEnabled,
isThinking, isThinking,
latestEvent,
runtimeAction, runtimeAction,
selectedBotControlState, selectedBotControlState,
selectedBotEnabled, selectedBotEnabled,
showInterruptSubmitAction,
systemTimezoneOptions, systemTimezoneOptions,
topicPanelState, topicPanelState,
}; };
} }
export function useDashboardInteractionState({
canChat,
isSendingBlocked = false,
isVoiceRecording = false,
isVoiceTranscribing = false,
selectedBot,
}: UseDashboardInteractionStateOptions) {
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
const canSendControlCommand = Boolean(
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
);
return {
canSendControlCommand,
isChatEnabled,
};
}

View File

@ -0,0 +1,150 @@
import { useCallback, useState } from 'react';
import { createMcpManager } from '../config-managers/mcpManager';
import type { MCPServerDraft } from '../types';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface ConfirmOptions {
title?: string;
message: string;
tone?: PromptTone;
confirmText?: string;
cancelText?: string;
}
interface UseDashboardMcpConfigOptions {
closeRuntimeMenu: () => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
isZh: boolean;
notify: (message: string, options?: NotifyOptions) => void;
passwordToggleLabels: { show: string; hide: string };
selectedBot?: any;
selectedBotId: string;
t: any;
}
export function useDashboardMcpConfig({
closeRuntimeMenu,
confirm,
isZh,
notify,
passwordToggleLabels,
selectedBot,
selectedBotId,
t,
}: UseDashboardMcpConfigOptions) {
const [showMcpModal, setShowMcpModal] = useState(false);
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
const [newMcpPanelOpen, setNewMcpPanelOpen] = useState(false);
const [newMcpDraft, setNewMcpDraft] = useState<MCPServerDraft>({
name: '',
type: 'streamableHttp',
url: '',
botId: '',
botSecret: '',
toolTimeout: '60',
headers: {},
locked: false,
originName: '',
});
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
const [isSavingMcp, setIsSavingMcp] = useState(false);
const {
resetNewMcpDraft,
mcpDraftUiKey,
openMcpModal,
beginMcpCreate,
updateMcpServer,
canRemoveMcpServer,
saveNewMcpServer,
saveSingleMcpServer,
removeMcpServer,
} = createMcpManager({
selectedBotId,
isZh,
t,
currentMcpServers: mcpServers,
currentPersistedMcpServers: persistedMcpServers,
currentNewMcpDraft: newMcpDraft,
notify,
confirm,
setShowMcpModal,
setMcpServers,
setPersistedMcpServers,
setExpandedMcpByKey,
setNewMcpPanelOpen,
setNewMcpDraft,
setIsSavingMcp,
});
const openMcpConfigModal = useCallback(() => {
closeRuntimeMenu();
if (!selectedBot) return;
void openMcpModal(selectedBot.id);
}, [closeRuntimeMenu, openMcpModal, selectedBot]);
const resetMcpPanels = useCallback(() => {
setExpandedMcpByKey({});
setMcpServers([]);
setPersistedMcpServers([]);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
setShowMcpModal(false);
}, [resetNewMcpDraft]);
const prepareMcpForBotChange = useCallback(() => {
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}, [resetNewMcpDraft]);
const mcpConfigModalProps = {
open: showMcpModal,
mcpServers,
expandedMcpByKey,
newMcpDraft,
newMcpPanelOpen,
isSavingMcp,
isZh,
labels: { ...t, cancel: t.cancel, close: t.close, save: t.save },
passwordToggleLabels,
onClose: () => {
setShowMcpModal(false);
setNewMcpPanelOpen(false);
resetNewMcpDraft();
},
getMcpUiKey: mcpDraftUiKey,
canRemoveMcpServer,
onRemoveMcpServer: removeMcpServer,
onToggleExpandedMcp: (key: string) => {
setExpandedMcpByKey((prev) => {
const fallbackExpanded = mcpServers.findIndex((row, idx) => mcpDraftUiKey(row, idx) === key) === 0;
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
return { ...prev, [key]: !current };
});
},
onUpdateMcpServer: updateMcpServer,
onSaveSingleMcpServer: saveSingleMcpServer,
onSetNewMcpPanelOpen: setNewMcpPanelOpen,
onUpdateNewMcpDraft: (patch: Partial<MCPServerDraft>) => setNewMcpDraft((prev) => ({ ...prev, ...patch })),
onResetNewMcpDraft: resetNewMcpDraft,
onSaveNewMcpServer: saveNewMcpServer,
onBeginMcpCreate: beginMcpCreate,
};
return {
mcpConfigModalProps,
openMcpConfigModal,
prepareMcpForBotChange,
resetMcpPanels,
};
}

View File

@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import type { CompactPanelTab, RuntimeViewMode } from '../types'; import type { CompactPanelTab, RuntimeViewMode } from '../types';
import { sortBotsByCreatedAtDesc } from '../utils';
interface UseDashboardShellStateOptions { interface UseDashboardShellStateOptions {
activeBots: Record<string, BotState>; activeBots: Record<string, BotState>;

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type SetStateAction } from 'react'; import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type SetStateAction } from 'react';
import { buildSkillMarketInstallModalProps, buildSkillsModalProps } from '../shared/configPanelModalProps';
import type { WorkspaceSkillOption } from '../types'; import type { WorkspaceSkillOption } from '../types';
import { formatBytes } from '../utils';
interface UseDashboardSkillsConfigOptions { interface UseDashboardSkillsConfigOptions {
botSkills: WorkspaceSkillOption[]; botSkills: WorkspaceSkillOption[];
@ -77,42 +77,52 @@ export function useDashboardSkillsConfig({
setSkillAddMenuOpen(false); setSkillAddMenuOpen(false);
}, []); }, []);
const skillsModalProps = buildSkillsModalProps({ const skillsModalProps = {
open: showSkillsModal,
botSkills, botSkills,
hasSelectedBot: Boolean(selectedBot),
isSkillUploading, isSkillUploading,
isZh, isZh,
hasSelectedBot: Boolean(selectedBot),
labels, labels,
loadBotSkills, skillZipPickerRef,
loadMarketSkills, skillAddMenuRef,
skillAddMenuOpen,
onClose: () => { onClose: () => {
setSkillAddMenuOpen(false); setSkillAddMenuOpen(false);
setShowSkillsModal(false); setShowSkillsModal(false);
}, },
onOpenSkillMarketDone: () => setShowSkillMarketInstallModal(true), onRefreshSkills: () => (selectedBot ? loadBotSkills(selectedBot.id) : undefined),
onRemoveSkill: removeBotSkill,
onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void, onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void,
removeBotSkill, onSetSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>,
selectedBot, onTriggerSkillZipUpload: triggerSkillZipUpload,
setSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>, onOpenSkillMarketplace: async () => {
skillAddMenuOpen, if (!selectedBot) return;
skillAddMenuRef, setSkillAddMenuOpen(false);
skillZipPickerRef, await loadMarketSkills(selectedBot.id);
triggerSkillZipUpload, setShowSkillMarketInstallModal(true);
open: showSkillsModal, },
}); };
const skillMarketInstallModalProps = buildSkillMarketInstallModalProps({ const skillMarketInstallModalProps = {
installMarketSkill,
installingId: marketSkillInstallingId,
isZh, isZh,
items: marketSkills,
loadBotSkills,
loadMarketSkills,
loading: isMarketSkillsLoading,
onClose: () => setShowSkillMarketInstallModal(false),
selectedBot,
open: showSkillMarketInstallModal, open: showSkillMarketInstallModal,
}); items: marketSkills,
loading: isMarketSkillsLoading,
installingId: typeof marketSkillInstallingId === 'number' ? marketSkillInstallingId : null,
onClose: () => setShowSkillMarketInstallModal(false),
onRefresh: async () => {
if (!selectedBot) return;
await loadMarketSkills(selectedBot.id);
},
onInstall: async (skill: any) => {
await installMarketSkill(skill);
if (selectedBot) {
await loadBotSkills(selectedBot.id);
}
},
formatBytes,
};
return { return {
openSkillsConfigModal, openSkillsConfigModal,

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { parseAllowedAttachmentExtensions, parseWorkspaceDownloadExtensions } from '../../../shared/workspace/utils';
import { normalizePlatformPageSize } from '../../../utils/platformPageSize'; import { normalizePlatformPageSize } from '../../../utils/platformPageSize';
import { fetchDashboardSystemDefaults } from '../api/system'; import { fetchDashboardSystemDefaults } from '../api/system';
import { parseTopicPresets } from '../topic/topicPresetUtils';
import type { SystemDefaultsResponse, TopicPresetTemplate } from '../types'; import type { SystemDefaultsResponse, TopicPresetTemplate } from '../types';
import { parseAllowedAttachmentExtensions, parseTopicPresets, parseWorkspaceDownloadExtensions } from '../utils';
interface UseDashboardSystemDefaultsOptions { interface UseDashboardSystemDefaultsOptions {
setBotListPageSize: Dispatch<SetStateAction<number>>; setBotListPageSize: Dispatch<SetStateAction<number>>;

View File

@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { fetchDashboardSystemTemplates, updateDashboardSystemTemplates } from '../api/system'; import { fetchDashboardSystemTemplates, updateDashboardSystemTemplates } from '../api/system';
import { parseTopicPresets } from '../topic/topicPresetUtils';
import type { TopicPresetTemplate } from '../types'; import type { TopicPresetTemplate } from '../types';
import { parseTopicPresets } from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';

View File

@ -1,9 +1,8 @@
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { createTopicManager } from '../configManagers'; import { createTopicManager } from '../config-managers/topicManager';
import { buildTopicConfigModalProps } from '../shared/configPanelModalProps'; import { resolvePresetText } from '../topic/topicPresetUtils';
import type { BotTopic, TopicPresetTemplate } from '../types'; import type { BotTopic, TopicPresetTemplate } from '../types';
import { resolvePresetText } from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';
@ -151,58 +150,66 @@ export function useDashboardTopicConfig({
setNewTopicPriority('50'); setNewTopicPriority('50');
}, []); }, []);
const topicConfigModalProps = buildTopicConfigModalProps({ const topicConfigModalProps = {
beginTopicCreate, open: showTopicModal,
countRoutingTextList: (raw) => normalizeRoutingTextList(raw).length, topics,
effectiveTopicPresetTemplates,
expandedTopicByKey, expandedTopicByKey,
getTopicUiKey: topicDraftUiKey, newTopicPanelOpen,
hasSelectedBot: Boolean(selectedBot), topicPresetMenuOpen,
isSavingTopic,
isZh,
labels: { ...t, cancel: t.cancel, close: t.close, delete: t.delete, save: t.save },
newTopicAdvancedOpen, newTopicAdvancedOpen,
newTopicDescription, newTopicSourceLabel,
newTopicExamplesNegative,
newTopicExamplesPositive,
newTopicExcludeWhen,
newTopicIncludeWhen,
newTopicKey, newTopicKey,
newTopicName, newTopicName,
newTopicPanelOpen, newTopicDescription,
newTopicPriority,
newTopicPurpose, newTopicPurpose,
newTopicSourceLabel, newTopicIncludeWhen,
normalizeTopicKeyInput, newTopicExcludeWhen,
onAddTopic: addTopic, newTopicExamplesPositive,
newTopicExamplesNegative,
newTopicPriority,
effectiveTopicPresetTemplates,
topicPresetMenuRef,
isSavingTopic,
hasSelectedBot: Boolean(selectedBot),
isZh,
labels: { ...t, cancel: t.cancel, close: t.close, delete: t.delete, save: t.save },
onClose: () => { onClose: () => {
setShowTopicModal(false); setShowTopicModal(false);
setTopicPresetMenuOpen(false); setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(false); setNewTopicPanelOpen(false);
resetNewTopicDraft(); resetNewTopicDraft();
}, },
removeTopic, getTopicUiKey: topicDraftUiKey,
resetNewTopicDraft, countRoutingTextList: (raw: string) => normalizeRoutingTextList(raw).length,
saveTopic, onUpdateTopicLocal: updateTopicLocal,
setExpandedTopicByKey: setExpandedTopicByKey as Dispatch<SetStateAction<Record<string, boolean>>>, onToggleExpandedTopic: (key: string) => {
setNewTopicAdvancedOpen, setExpandedTopicByKey((prev) => {
setNewTopicDescription, const fallbackExpanded = topics.findIndex((topic, idx) => topicDraftUiKey(topic, idx) === key) === 0;
setNewTopicExamplesNegative, const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
setNewTopicExamplesPositive, return { ...prev, [key]: !current };
setNewTopicExcludeWhen,
setNewTopicIncludeWhen,
setNewTopicKey,
setNewTopicName,
setNewTopicPanelOpen,
setNewTopicPriority,
setNewTopicPurpose,
setTopicPresetMenuOpen,
topicPresetMenuOpen,
topicPresetMenuRef,
topics,
updateTopicLocal,
open: showTopicModal,
}); });
},
onRemoveTopic: removeTopic,
onSaveTopic: saveTopic,
onSetNewTopicPanelOpen: setNewTopicPanelOpen,
onSetTopicPresetMenuOpen: setTopicPresetMenuOpen,
onSetNewTopicAdvancedOpen: setNewTopicAdvancedOpen,
onResetNewTopicDraft: resetNewTopicDraft,
onNormalizeTopicKeyInput: normalizeTopicKeyInput,
onSetNewTopicKey: setNewTopicKey,
onSetNewTopicName: setNewTopicName,
onSetNewTopicDescription: setNewTopicDescription,
onSetNewTopicPurpose: setNewTopicPurpose,
onSetNewTopicIncludeWhen: setNewTopicIncludeWhen,
onSetNewTopicExcludeWhen: setNewTopicExcludeWhen,
onSetNewTopicExamplesPositive: setNewTopicExamplesPositive,
onSetNewTopicExamplesNegative: setNewTopicExamplesNegative,
onSetNewTopicPriority: setNewTopicPriority,
onBeginTopicCreate: beginTopicCreate,
onResolvePresetLabel: (preset: TopicPresetTemplate) =>
resolvePresetText(preset.name, isZh ? 'zh-cn' : 'en') || preset.topic_key || preset.id,
onAddTopic: addTopic,
};
return { return {
loadTopics, loadTopics,

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type Dispatch, type RefObject, type SetSta
import axios from 'axios'; import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env'; import { APP_ENDPOINTS } from '../../../config/env';
import { normalizeUserMessageText } from '../messageParser'; import { normalizeUserMessageText } from '../../../shared/text/messageText';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';

View File

@ -1,727 +0,0 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type {
WorkspaceFileResponse,
WorkspaceHoverCardState,
WorkspaceNode,
WorkspacePreviewMode,
WorkspacePreviewState,
WorkspaceTreeResponse,
WorkspaceUploadResponse,
} from '../types';
import {
buildWorkspaceDownloadHref,
buildWorkspacePreviewHref,
buildWorkspaceRawHref,
createWorkspaceMarkdownComponents,
normalizeDashboardAttachmentPath,
resolveWorkspaceDocumentPath,
} from '../shared/workspaceMarkdown';
import {
isAudioPath,
isHtmlPath,
isImagePath,
isMediaUploadFile,
isPreviewableWorkspaceFile,
isPreviewableWorkspacePath,
isVideoPath,
normalizeAttachmentPaths,
parseWorkspaceDownloadExtensions,
workspaceFileAction,
} from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface AttachmentPolicySnapshot {
uploadMaxMb: number;
allowedAttachmentExtensions: string[];
workspaceDownloadExtensions?: string[];
}
interface UseDashboardWorkspaceOptions {
selectedBotId: string;
selectedBotDockerStatus?: string;
workspaceDownloadExtensions: string[];
refreshAttachmentPolicy: () => Promise<AttachmentPolicySnapshot>;
notify: (message: string, options?: NotifyOptions) => void;
t: any;
isZh: boolean;
fileNotPreviewableLabel: string;
}
export function useDashboardWorkspace({
selectedBotId,
selectedBotDockerStatus,
workspaceDownloadExtensions,
refreshAttachmentPolicy,
notify,
t,
isZh,
fileNotPreviewableLabel,
}: UseDashboardWorkspaceOptions) {
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
const [workspaceLoading, setWorkspaceLoading] = useState(false);
const [workspaceError, setWorkspaceError] = useState('');
const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState('');
const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewMode, setWorkspacePreviewMode] = useState<WorkspacePreviewMode>('preview');
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
const [workspaceQuery, setWorkspaceQuery] = useState('');
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
const [workspaceDownloadExtensionList, setWorkspaceDownloadExtensionList] = useState<string[]>(
() => parseWorkspaceDownloadExtensions(workspaceDownloadExtensions),
);
useEffect(() => {
const nextList = parseWorkspaceDownloadExtensions(workspaceDownloadExtensions);
setWorkspaceDownloadExtensionList((current) => {
if (
current.length === nextList.length &&
current.every((item, index) => item === nextList[index])
) {
return current;
}
return nextList;
});
}, [workspaceDownloadExtensions]);
const workspaceDownloadExtensionSet = useMemo(
() => new Set(workspaceDownloadExtensionList),
[workspaceDownloadExtensionList],
);
const workspaceFiles = useMemo(
() => workspaceEntries.filter((entry) => entry.type === 'file' && isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet)),
[workspaceDownloadExtensionSet, workspaceEntries],
);
const workspacePathDisplay = workspaceCurrentPath
? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}`
: '/';
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
const filteredWorkspaceEntries = useMemo(() => {
const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries;
if (!normalizedWorkspaceQuery) return sourceEntries;
return sourceEntries.filter((entry) => {
const name = String(entry.name || '').toLowerCase();
const path = String(entry.path || '').toLowerCase();
return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery);
});
}, [normalizedWorkspaceQuery, workspaceEntries, workspaceSearchEntries]);
const workspacePreviewCanEdit = Boolean(workspacePreview?.isMarkdown && !workspacePreview?.truncated);
const workspacePreviewEditorEnabled = workspacePreviewCanEdit && workspacePreviewMode === 'edit';
const getWorkspaceDownloadHref = useCallback((filePath: string, forceDownload: boolean = true) =>
buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload),
[selectedBotId]);
const getWorkspaceRawHref = useCallback((filePath: string, forceDownload: boolean = false) =>
buildWorkspaceRawHref(selectedBotId, filePath, forceDownload),
[selectedBotId]);
const getWorkspacePreviewHref = useCallback((filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return '';
return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) });
}, [selectedBotId]);
const closeWorkspacePreview = useCallback(() => {
setWorkspacePreview(null);
setWorkspacePreviewMode('preview');
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
}, []);
const copyTextToClipboard = useCallback(async (textRaw: string, successMsg: string, failMsg: string) => {
const text = String(textRaw || '');
if (!text.trim()) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(successMsg, { tone: 'success' });
} catch {
notify(failMsg, { tone: 'error' });
}
}, [notify]);
const loadWorkspaceTree = useCallback(async (botId: string, path: string = '') => {
if (!botId) return;
setWorkspaceLoading(true);
setWorkspaceError('');
try {
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
params: { path },
});
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
setWorkspaceEntries(entries);
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath(res.data?.cwd || '');
setWorkspaceParentPath(res.data?.parent ?? null);
} catch (error: any) {
setWorkspaceEntries([]);
setWorkspaceSearchEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
} finally {
setWorkspaceLoading(false);
}
}, [t.workspaceLoadFail]);
const openWorkspaceFilePreview = useCallback(async (path: string) => {
const normalizedPath = String(path || '').trim();
if (!selectedBotId || !normalizedPath) return;
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
window.open(getWorkspaceDownloadHref(normalizedPath, true), '_blank', 'noopener,noreferrer');
return;
}
if (isImagePath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: true,
isHtml: false,
isVideo: false,
isAudio: false,
});
return;
}
if (isHtmlPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: true,
isVideo: false,
isAudio: false,
});
return;
}
if (isVideoPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: false,
isVideo: true,
isAudio: false,
});
return;
}
if (isAudioPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: false,
isVideo: false,
isAudio: true,
});
return;
}
setWorkspaceFileLoading(true);
try {
const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
params: { path, max_bytes: 400000 },
});
const filePath = res.data.path || path;
const textExt = (filePath.split('.').pop() || '').toLowerCase();
let content = res.data.content || '';
if (textExt === 'json') {
try {
content = JSON.stringify(JSON.parse(content), null, 2);
} catch {
// Keep original content when JSON is not strictly parseable.
}
}
setWorkspacePreview({
path: filePath,
content,
truncated: Boolean(res.data.truncated),
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
isImage: false,
isHtml: false,
isVideo: false,
isAudio: false,
});
} catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail;
notify(msg, { tone: 'error' });
} finally {
setWorkspaceFileLoading(false);
}
}, [getWorkspaceDownloadHref, notify, selectedBotId, t.fileReadFail, workspaceDownloadExtensionSet]);
const saveWorkspacePreviewMarkdown = useCallback(async () => {
if (!selectedBotId || !workspacePreview?.isMarkdown) return;
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewSaving(true);
try {
const res = await axios.put<WorkspaceFileResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`,
{ content: workspacePreviewDraft },
{ params: { path: workspacePreview.path } },
);
const filePath = res.data.path || workspacePreview.path;
const textExt = (filePath.split('.').pop() || '').toLowerCase();
const content = res.data.content || workspacePreviewDraft;
setWorkspacePreview({
...workspacePreview,
path: filePath,
content,
truncated: false,
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown),
});
notify(t.fileSaved, { tone: 'success' });
void loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
} catch (error: any) {
notify(error?.response?.data?.detail || t.fileSaveFail, { tone: 'error' });
} finally {
setWorkspacePreviewSaving(false);
}
}, [
loadWorkspaceTree,
notify,
selectedBotId,
t.fileEditDisabled,
t.fileSaveFail,
t.fileSaved,
workspaceCurrentPath,
workspacePreview,
workspacePreviewDraft,
]);
const triggerWorkspaceFileDownload = useCallback((filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = getWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}, [getWorkspaceDownloadHref, selectedBotId]);
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]);
const copyWorkspacePreviewPath = useCallback(async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return;
await copyTextToClipboard(
normalized,
isZh ? '文件路径已复制' : 'File path copied',
isZh ? '文件路径复制失败' : 'Failed to copy file path',
);
}, [copyTextToClipboard, isZh]);
const loadWorkspaceSearchEntries = useCallback(async (botId: string, path: string = '') => {
if (!botId) return;
const q = String(workspaceQuery || '').trim();
if (!q) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
setWorkspaceSearchLoading(true);
try {
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
params: { path, recursive: true },
});
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
setWorkspaceSearchEntries(entries);
} catch {
setWorkspaceSearchEntries([]);
} finally {
setWorkspaceSearchLoading(false);
}
}, [workspaceQuery]);
const openWorkspacePathFromChat = useCallback(async (path: string) => {
const normalized = String(path || '').trim();
if (!normalized) return;
const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet);
if (action === 'download') {
triggerWorkspaceFileDownload(normalized);
return;
}
if (action === 'preview') {
void openWorkspaceFilePreview(normalized);
return;
}
try {
await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
params: { path: normalized },
});
await loadWorkspaceTree(selectedBotId, normalized);
return;
} catch {
if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') {
notify(fileNotPreviewableLabel, { tone: 'warning' });
}
}
}, [
fileNotPreviewableLabel,
loadWorkspaceTree,
notify,
openWorkspaceFilePreview,
selectedBotId,
triggerWorkspaceFileDownload,
workspaceDownloadExtensionSet,
]);
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
const src = String(srcRaw || '').trim();
if (!src || !selectedBotId) return src;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
if (resolvedWorkspacePath) {
return getWorkspacePreviewHref(resolvedWorkspacePath);
}
const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
return src;
}
return src;
}, [getWorkspacePreviewHref, selectedBotId]);
const markdownComponents = useMemo(
() => createWorkspaceMarkdownComponents(
(path) => {
void openWorkspacePathFromChat(path);
},
{ resolveMediaSrc: resolveWorkspaceMediaSrc },
),
[openWorkspacePathFromChat, resolveWorkspaceMediaSrc],
);
const workspacePreviewMarkdownComponents = useMemo(
() => createWorkspaceMarkdownComponents(
(path) => {
void openWorkspacePathFromChat(path);
},
{
baseFilePath: workspacePreview?.path,
resolveMediaSrc: resolveWorkspaceMediaSrc,
},
),
[openWorkspacePathFromChat, resolveWorkspaceMediaSrc, workspacePreview?.path],
);
const showWorkspaceHoverCard = useCallback((node: WorkspaceNode, anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
const panelHeight = 160;
const panelWidth = 420;
const gap = 8;
const viewportPadding = 8;
const belowSpace = window.innerHeight - rect.bottom;
const aboveSpace = rect.top;
const above = belowSpace < panelHeight && aboveSpace > panelHeight;
const leftRaw = rect.left + 8;
const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding));
const top = above ? rect.top - gap : rect.bottom + gap;
setWorkspaceHoverCard({ node, top, left, above });
}, []);
const hideWorkspaceHoverCard = useCallback(() => setWorkspaceHoverCard(null), []);
const resetWorkspaceState = useCallback(() => {
setWorkspaceEntries([]);
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
setWorkspaceLoading(false);
setWorkspaceError('');
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
setWorkspaceFileLoading(false);
setWorkspacePreview(null);
setWorkspacePreviewMode('preview');
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
setWorkspaceAutoRefresh(false);
setWorkspaceQuery('');
setPendingAttachments([]);
setIsUploadingAttachments(false);
setAttachmentUploadPercent(null);
setWorkspaceHoverCard(null);
}, []);
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBotId || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files);
let effectiveUploadMaxMb = 0;
let effectiveAllowedAttachmentExtensions: string[] = [];
const latestAttachmentPolicy = await refreshAttachmentPolicy();
effectiveUploadMaxMb = latestAttachmentPolicy.uploadMaxMb;
effectiveAllowedAttachmentExtensions = [...latestAttachmentPolicy.allowedAttachmentExtensions];
const effectiveAllowedAttachmentExtensionSet = new Set(effectiveAllowedAttachmentExtensions);
if (effectiveAllowedAttachmentExtensionSet.size > 0) {
const disallowed = files.filter((file) => {
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
const ext = dot >= 0 ? name.slice(dot) : '';
return !ext || !effectiveAllowedAttachmentExtensionSet.has(ext);
});
if (disallowed.length > 0) {
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
event.target.value = '';
return;
}
}
if (effectiveUploadMaxMb > 0) {
const maxBytes = effectiveUploadMaxMb * 1024 * 1024;
const tooLarge = files.filter((file) => Number(file.size) > maxBytes);
if (tooLarge.length > 0) {
const names = tooLarge.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
event.target.value = '';
return;
}
}
const mediaFiles: File[] = [];
const normalFiles: File[] = [];
files.forEach((file) => {
if (isMediaUploadFile(file)) {
mediaFiles.push(file);
} else {
normalFiles.push(file);
}
});
const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
let uploadedBytes = 0;
const uploadedPaths: string[] = [];
const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
if (batchFiles.length === 0) return;
const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
const formData = new FormData();
batchFiles.forEach((file) => formData.append('files', file));
const res = await axios.post<WorkspaceUploadResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/upload`,
formData,
{
params: { path },
onUploadProgress: (progressEvent) => {
const loaded = Number(progressEvent.loaded || 0);
if (!Number.isFinite(loaded) || loaded < 0) {
setAttachmentUploadPercent(null);
return;
}
if (totalBytes <= 0) {
setAttachmentUploadPercent(null);
return;
}
const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded));
const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
},
},
);
const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((file) => file.path));
uploadedPaths.push(...uploaded);
uploadedBytes += batchBytes;
if (totalBytes > 0) {
const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)));
setAttachmentUploadPercent(pct);
}
};
setIsUploadingAttachments(true);
setAttachmentUploadPercent(0);
try {
await uploadBatch(mediaFiles, 'media');
await uploadBatch(normalFiles, 'uploads');
if (uploadedPaths.length > 0) {
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
}
} catch (error: any) {
const msg = error?.response?.data?.detail || t.uploadFail;
notify(msg, { tone: 'error' });
} finally {
setIsUploadingAttachments(false);
setAttachmentUploadPercent(null);
event.target.value = '';
}
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, t, workspaceCurrentPath]);
useEffect(() => {
if (!workspacePreview) {
setWorkspacePreviewMode('preview');
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
return;
}
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft(workspacePreview.content || '');
}, [workspacePreview?.content, workspacePreview?.path]);
useEffect(() => {
if (!workspaceHoverCard) return;
const close = () => setWorkspaceHoverCard(null);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [workspaceHoverCard]);
useEffect(() => {
if (!selectedBotId) {
resetWorkspaceState();
}
}, [resetWorkspaceState, selectedBotId]);
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBotDockerStatus !== 'RUNNING') return;
let stopped = false;
const tick = async () => {
if (stopped) return;
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, 2000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [loadWorkspaceTree, selectedBotDockerStatus, selectedBotId, workspaceAutoRefresh, workspaceCurrentPath]);
useEffect(() => {
setWorkspaceQuery('');
}, [selectedBotId, workspaceCurrentPath]);
useEffect(() => {
if (!selectedBotId) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
if (!workspaceQuery.trim()) {
setWorkspaceSearchEntries([]);
setWorkspaceSearchLoading(false);
return;
}
void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
}, [loadWorkspaceSearchEntries, selectedBotId, workspaceCurrentPath, workspaceQuery]);
return {
attachmentUploadPercent,
closeWorkspacePreview,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
filteredWorkspaceEntries,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
hideWorkspaceHoverCard,
isUploadingAttachments,
loadWorkspaceTree,
markdownComponents,
normalizePendingAttachmentPath: normalizeDashboardAttachmentPath,
onPickAttachments,
openWorkspaceFilePreview,
openWorkspacePathFromChat,
pendingAttachments,
resetWorkspaceState,
resolveWorkspaceMediaSrc,
saveWorkspacePreviewMarkdown,
setPendingAttachments,
setWorkspaceAutoRefresh,
setWorkspacePreviewDraft,
setWorkspacePreviewFullscreen,
setWorkspacePreviewMode,
setWorkspaceQuery,
showWorkspaceHoverCard,
workspaceAutoRefresh,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
workspaceEntries,
workspaceError,
workspaceFileLoading,
workspaceFiles,
workspaceHoverCard,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspacePreview,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewEditorEnabled,
workspacePreviewFullscreen,
workspacePreviewMarkdownComponents,
workspacePreviewSaving,
workspaceQuery,
workspaceSearchEntries,
workspaceSearchLoading,
};
}

View File

@ -1,267 +0,0 @@
import type { ComponentProps, Dispatch, RefObject, SetStateAction } from 'react';
import { ChannelConfigModal, TopicConfigModal } from '../../components/DashboardChannelTopicModals';
import type { BotChannel, BotTopic, TopicPresetTemplate, WeixinLoginStatus } from '../../types';
import { resolvePresetText } from '../../utils';
export function buildChannelConfigModalProps(options: {
addableChannelTypes: any[];
beginChannelCreate: (channelType: any) => void;
channelCreateMenuOpen: boolean;
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
channelDraftUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
channels: BotChannel[];
expandedChannelByKey: Record<string, boolean>;
globalDelivery: { sendProgress: boolean; sendToolHints: boolean };
hasSelectedBot: boolean;
isDashboardChannel: (channel: BotChannel) => boolean;
isSavingChannel: boolean;
isSavingGlobalDelivery: boolean;
isZh: boolean;
labels: Record<string, any>;
newChannelDraft: BotChannel;
newChannelPanelOpen: boolean;
onAddChannel: () => Promise<void> | void;
onClose: () => void;
onReloginWeixin: () => Promise<void> | void;
passwordToggleLabels: { show: string; hide: string };
removeChannel: (channel: BotChannel) => Promise<void> | void;
resetNewChannelDraft: (channelType?: any) => void;
saveChannel: (channel: BotChannel) => Promise<void> | void;
saveGlobalDelivery: () => Promise<void> | void;
setChannelCreateMenuOpen: Dispatch<SetStateAction<boolean>>;
setExpandedChannelByKey: Dispatch<SetStateAction<Record<string, boolean>>>;
setNewChannelDraft: Dispatch<SetStateAction<BotChannel>>;
setNewChannelPanelOpen: Dispatch<SetStateAction<boolean>>;
updateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
updateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
weixinLoginStatus: WeixinLoginStatus | null;
open: boolean;
}): ComponentProps<typeof ChannelConfigModal> {
const {
addableChannelTypes,
beginChannelCreate,
channelCreateMenuOpen,
channelCreateMenuRef,
channelDraftUiKey,
channels,
expandedChannelByKey,
globalDelivery,
hasSelectedBot,
isDashboardChannel,
isSavingChannel,
isSavingGlobalDelivery,
isZh,
labels,
newChannelDraft,
newChannelPanelOpen,
onAddChannel,
onClose,
onReloginWeixin,
passwordToggleLabels,
removeChannel,
resetNewChannelDraft,
saveChannel,
saveGlobalDelivery,
setChannelCreateMenuOpen,
setExpandedChannelByKey,
setNewChannelDraft,
setNewChannelPanelOpen,
updateChannelLocal,
updateGlobalDeliveryFlag,
weixinLoginStatus,
open,
} = options;
return {
open,
channels,
globalDelivery,
expandedChannelByKey,
newChannelDraft,
addableChannelTypes,
newChannelPanelOpen,
channelCreateMenuOpen,
channelCreateMenuRef,
isSavingGlobalDelivery,
isSavingChannel,
weixinLoginStatus,
hasSelectedBot,
isZh,
labels,
passwordToggleLabels,
onClose,
onUpdateGlobalDeliveryFlag: updateGlobalDeliveryFlag,
onSaveGlobalDelivery: saveGlobalDelivery,
getChannelUiKey: channelDraftUiKey,
isDashboardChannel,
onUpdateChannelLocal: updateChannelLocal,
onToggleExpandedChannel: (key) => {
setExpandedChannelByKey((prev) => {
const fallbackExpanded = channels.findIndex((channel, idx) => channelDraftUiKey(channel, idx) === key) === 0;
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
return { ...prev, [key]: !current };
});
},
onRemoveChannel: removeChannel,
onSaveChannel: saveChannel,
onReloginWeixin,
onSetNewChannelPanelOpen: setNewChannelPanelOpen,
onSetChannelCreateMenuOpen: setChannelCreateMenuOpen,
onResetNewChannelDraft: resetNewChannelDraft,
onUpdateNewChannelDraft: (patch) => setNewChannelDraft((prev) => ({ ...prev, ...patch })),
onBeginChannelCreate: beginChannelCreate,
onAddChannel,
};
}
export function buildTopicConfigModalProps(options: {
beginTopicCreate: (presetId: string) => void;
countRoutingTextList: (raw: string) => number;
effectiveTopicPresetTemplates: TopicPresetTemplate[];
expandedTopicByKey: Record<string, boolean>;
getTopicUiKey: (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => string;
hasSelectedBot: boolean;
isSavingTopic: boolean;
isZh: boolean;
labels: Record<string, any>;
newTopicAdvancedOpen: boolean;
newTopicDescription: string;
newTopicExamplesNegative: string;
newTopicExamplesPositive: string;
newTopicExcludeWhen: string;
newTopicIncludeWhen: string;
newTopicKey: string;
newTopicName: string;
newTopicPanelOpen: boolean;
newTopicPriority: string;
newTopicPurpose: string;
newTopicSourceLabel: string;
normalizeTopicKeyInput: (raw: string) => string;
onAddTopic: () => Promise<void> | void;
onClose: () => void;
removeTopic: (topic: BotTopic) => Promise<void> | void;
resetNewTopicDraft: () => void;
saveTopic: (topic: BotTopic) => Promise<void> | void;
setExpandedTopicByKey: Dispatch<SetStateAction<Record<string, boolean>>>;
setNewTopicAdvancedOpen: Dispatch<SetStateAction<boolean>>;
setNewTopicDescription: Dispatch<SetStateAction<string>>;
setNewTopicExamplesNegative: Dispatch<SetStateAction<string>>;
setNewTopicExamplesPositive: Dispatch<SetStateAction<string>>;
setNewTopicExcludeWhen: Dispatch<SetStateAction<string>>;
setNewTopicIncludeWhen: Dispatch<SetStateAction<string>>;
setNewTopicKey: Dispatch<SetStateAction<string>>;
setNewTopicName: Dispatch<SetStateAction<string>>;
setNewTopicPanelOpen: Dispatch<SetStateAction<boolean>>;
setNewTopicPriority: Dispatch<SetStateAction<string>>;
setNewTopicPurpose: Dispatch<SetStateAction<string>>;
setTopicPresetMenuOpen: Dispatch<SetStateAction<boolean>>;
topicPresetMenuOpen: boolean;
topicPresetMenuRef: RefObject<HTMLDivElement | null>;
topics: BotTopic[];
updateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
open: boolean;
}): ComponentProps<typeof TopicConfigModal> {
const {
beginTopicCreate,
countRoutingTextList,
effectiveTopicPresetTemplates,
expandedTopicByKey,
getTopicUiKey,
hasSelectedBot,
isSavingTopic,
isZh,
labels,
newTopicAdvancedOpen,
newTopicDescription,
newTopicExamplesNegative,
newTopicExamplesPositive,
newTopicExcludeWhen,
newTopicIncludeWhen,
newTopicKey,
newTopicName,
newTopicPanelOpen,
newTopicPriority,
newTopicPurpose,
newTopicSourceLabel,
normalizeTopicKeyInput,
onAddTopic,
onClose,
removeTopic,
resetNewTopicDraft,
saveTopic,
setExpandedTopicByKey,
setNewTopicAdvancedOpen,
setNewTopicDescription,
setNewTopicExamplesNegative,
setNewTopicExamplesPositive,
setNewTopicExcludeWhen,
setNewTopicIncludeWhen,
setNewTopicKey,
setNewTopicName,
setNewTopicPanelOpen,
setNewTopicPriority,
setNewTopicPurpose,
setTopicPresetMenuOpen,
topicPresetMenuOpen,
topicPresetMenuRef,
topics,
updateTopicLocal,
open,
} = options;
return {
open,
topics,
expandedTopicByKey,
newTopicPanelOpen,
topicPresetMenuOpen,
newTopicAdvancedOpen,
newTopicSourceLabel,
newTopicKey,
newTopicName,
newTopicDescription,
newTopicPurpose,
newTopicIncludeWhen,
newTopicExcludeWhen,
newTopicExamplesPositive,
newTopicExamplesNegative,
newTopicPriority,
effectiveTopicPresetTemplates,
topicPresetMenuRef,
isSavingTopic,
hasSelectedBot,
isZh,
labels,
onClose,
getTopicUiKey,
countRoutingTextList,
onUpdateTopicLocal: updateTopicLocal,
onToggleExpandedTopic: (key) => {
setExpandedTopicByKey((prev) => {
const fallbackExpanded = topics.findIndex((topic, idx) => getTopicUiKey(topic, idx) === key) === 0;
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
return { ...prev, [key]: !current };
});
},
onRemoveTopic: removeTopic,
onSaveTopic: saveTopic,
onSetNewTopicPanelOpen: setNewTopicPanelOpen,
onSetTopicPresetMenuOpen: setTopicPresetMenuOpen,
onSetNewTopicAdvancedOpen: setNewTopicAdvancedOpen,
onResetNewTopicDraft: resetNewTopicDraft,
onNormalizeTopicKeyInput: normalizeTopicKeyInput,
onSetNewTopicKey: setNewTopicKey,
onSetNewTopicName: setNewTopicName,
onSetNewTopicDescription: setNewTopicDescription,
onSetNewTopicPurpose: setNewTopicPurpose,
onSetNewTopicIncludeWhen: setNewTopicIncludeWhen,
onSetNewTopicExcludeWhen: setNewTopicExcludeWhen,
onSetNewTopicExamplesPositive: setNewTopicExamplesPositive,
onSetNewTopicExamplesNegative: setNewTopicExamplesNegative,
onSetNewTopicPriority: setNewTopicPriority,
onBeginTopicCreate: beginTopicCreate,
onResolvePresetLabel: (preset) => resolvePresetText(preset.name, isZh ? 'zh-cn' : 'en') || preset.topic_key || preset.id,
onAddTopic,
};
}

View File

@ -1,196 +0,0 @@
import type { ChangeEvent, ComponentProps, Dispatch, RefObject, SetStateAction } from 'react';
import { McpConfigModal, SkillsModal } from '../../components/DashboardSkillsMcpModals';
import { SkillMarketInstallModal } from '../../components/SkillMarketInstallModal';
import type { MCPServerDraft, WorkspaceSkillOption } from '../../types';
import { formatBytes } from '../../utils';
export function buildSkillsModalProps(options: {
botSkills: WorkspaceSkillOption[];
hasSelectedBot: boolean;
isSkillUploading: boolean;
isZh: boolean;
labels: Record<string, any>;
loadBotSkills: (botId: string) => Promise<void>;
loadMarketSkills: (botId: string) => Promise<void>;
onClose: () => void;
onOpenSkillMarketDone: () => void;
onPickSkillZip: (event: ChangeEvent<HTMLInputElement>) => void;
removeBotSkill: (skill: WorkspaceSkillOption) => Promise<void>;
selectedBot?: any;
setSkillAddMenuOpen: Dispatch<SetStateAction<boolean>>;
skillAddMenuOpen: boolean;
skillAddMenuRef: RefObject<HTMLDivElement | null>;
skillZipPickerRef: RefObject<HTMLInputElement | null>;
triggerSkillZipUpload: () => void;
open: boolean;
}): ComponentProps<typeof SkillsModal> {
const {
botSkills,
hasSelectedBot,
isSkillUploading,
isZh,
labels,
loadBotSkills,
loadMarketSkills,
onClose,
onOpenSkillMarketDone,
onPickSkillZip,
removeBotSkill,
selectedBot,
setSkillAddMenuOpen,
skillAddMenuOpen,
skillAddMenuRef,
skillZipPickerRef,
triggerSkillZipUpload,
open,
} = options;
return {
open,
botSkills,
isSkillUploading,
isZh,
hasSelectedBot,
labels,
skillZipPickerRef,
skillAddMenuRef,
skillAddMenuOpen,
onClose,
onRefreshSkills: () => selectedBot && loadBotSkills(selectedBot.id),
onRemoveSkill: removeBotSkill,
onPickSkillZip,
onSetSkillAddMenuOpen: setSkillAddMenuOpen,
onTriggerSkillZipUpload: triggerSkillZipUpload,
onOpenSkillMarketplace: async () => {
if (!selectedBot) return;
setSkillAddMenuOpen(false);
await loadMarketSkills(selectedBot.id);
onOpenSkillMarketDone();
},
};
}
export function buildSkillMarketInstallModalProps(options: {
installMarketSkill: (skill: any) => Promise<void>;
installingId: string | number | null;
isZh: boolean;
items: any[];
loadBotSkills: (botId: string) => Promise<void>;
loadMarketSkills: (botId: string) => Promise<void>;
loading: boolean;
onClose: () => void;
selectedBot?: any;
open: boolean;
}): ComponentProps<typeof SkillMarketInstallModal> {
const {
installMarketSkill,
installingId,
isZh,
items,
loadBotSkills,
loadMarketSkills,
loading,
onClose,
selectedBot,
open,
} = options;
return {
isZh,
open,
items,
loading,
installingId: typeof installingId === 'number' ? installingId : null,
onClose,
onRefresh: async () => {
if (!selectedBot) return;
await loadMarketSkills(selectedBot.id);
},
onInstall: async (skill) => {
await installMarketSkill(skill);
if (selectedBot) {
await loadBotSkills(selectedBot.id);
}
},
formatBytes,
};
}
export function buildMcpConfigModalProps(options: {
beginMcpCreate: () => void;
canRemoveMcpServer: (row?: MCPServerDraft | null) => boolean;
expandedMcpByKey: Record<string, boolean>;
getMcpUiKey: (_row: Pick<MCPServerDraft, 'name' | 'url'>, fallbackIndex: number) => string;
isSavingMcp: boolean;
isZh: boolean;
labels: Record<string, any>;
mcpServers: MCPServerDraft[];
newMcpDraft: MCPServerDraft;
newMcpPanelOpen: boolean;
onClose: () => void;
passwordToggleLabels: { show: string; hide: string };
removeMcpServer: (index: number) => Promise<void> | void;
resetNewMcpDraft: () => void;
saveNewMcpServer: () => Promise<void> | void;
saveSingleMcpServer: (index: number) => Promise<void> | void;
setExpandedMcpByKey: Dispatch<SetStateAction<Record<string, boolean>>>;
setNewMcpDraft: Dispatch<SetStateAction<MCPServerDraft>>;
setNewMcpPanelOpen: Dispatch<SetStateAction<boolean>>;
updateMcpServer: (index: number, patch: Partial<MCPServerDraft>) => void;
open: boolean;
}): ComponentProps<typeof McpConfigModal> {
const {
beginMcpCreate,
canRemoveMcpServer,
expandedMcpByKey,
getMcpUiKey,
isSavingMcp,
isZh,
labels,
mcpServers,
newMcpDraft,
newMcpPanelOpen,
onClose,
passwordToggleLabels,
removeMcpServer,
resetNewMcpDraft,
saveNewMcpServer,
saveSingleMcpServer,
setExpandedMcpByKey,
setNewMcpDraft,
setNewMcpPanelOpen,
updateMcpServer,
open,
} = options;
return {
open,
mcpServers,
expandedMcpByKey,
newMcpDraft,
newMcpPanelOpen,
isSavingMcp,
isZh,
labels,
passwordToggleLabels,
onClose,
getMcpUiKey,
canRemoveMcpServer,
onRemoveMcpServer: removeMcpServer,
onToggleExpandedMcp: (key) => {
setExpandedMcpByKey((prev) => {
const fallbackExpanded = mcpServers.findIndex((row, idx) => getMcpUiKey(row, idx) === key) === 0;
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
return { ...prev, [key]: !current };
});
},
onUpdateMcpServer: updateMcpServer,
onSaveSingleMcpServer: saveSingleMcpServer,
onSetNewMcpPanelOpen: setNewMcpPanelOpen,
onUpdateNewMcpDraft: (patch) => setNewMcpDraft((prev) => ({ ...prev, ...patch })),
onResetNewMcpDraft: resetNewMcpDraft,
onSaveNewMcpServer: saveNewMcpServer,
onBeginMcpCreate: beginMcpCreate,
};
}

View File

@ -1,94 +0,0 @@
import type { ComponentProps, Dispatch, SetStateAction } from 'react';
import { CronJobsModal, EnvParamsModal } from '../../components/DashboardSupportModals';
import { formatCronSchedule } from '../../utils';
export function buildEnvParamsModalProps(options: {
envDraftKey: string;
envDraftValue: string;
envEntries: [string, string][];
labels: ComponentProps<typeof EnvParamsModal>['labels'];
onClose: () => void;
onCreateEnvParam: (key: string, value: string) => Promise<boolean> | boolean;
onDeleteEnvParam: (key: string) => Promise<boolean> | boolean;
onSaveEnvParam: (originalKey: string, nextKey: string, nextValue: string) => Promise<boolean> | boolean;
setEnvDraftKey: Dispatch<SetStateAction<string>>;
setEnvDraftValue: Dispatch<SetStateAction<string>>;
open: boolean;
}): ComponentProps<typeof EnvParamsModal> {
const {
envDraftKey,
envDraftValue,
envEntries,
labels,
onClose,
onCreateEnvParam,
onDeleteEnvParam,
onSaveEnvParam,
setEnvDraftKey,
setEnvDraftValue,
open,
} = options;
return {
open,
envEntries,
envDraftKey,
envDraftValue,
labels,
onClose,
onEnvDraftKeyChange: setEnvDraftKey,
onEnvDraftValueChange: setEnvDraftValue,
onCreateEnvParam,
onDeleteEnvParam,
onSaveEnvParam,
};
}
export function buildCronJobsModalProps(options: {
cronActionJobId: string | null;
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
cronJobs: any[];
cronLoading: boolean;
deleteCronJob: (jobId: string) => Promise<void>;
isZh: boolean;
labels: ComponentProps<typeof CronJobsModal>['labels'];
loadCronJobs: (botId: string) => Promise<void>;
onClose: () => void;
selectedBot?: any;
startCronJob: (jobId: string) => Promise<void>;
stopCronJob: (jobId: string) => Promise<void>;
open: boolean;
}): ComponentProps<typeof CronJobsModal> {
const {
cronActionJobId,
cronActionType,
cronJobs,
cronLoading,
deleteCronJob,
isZh,
labels,
loadCronJobs,
onClose,
selectedBot,
startCronJob,
stopCronJob,
open,
} = options;
return {
open,
cronLoading,
cronJobs,
cronActionJobId: cronActionJobId || '',
cronActionType: cronActionType || '',
isZh,
labels,
formatCronSchedule,
onClose,
onReload: () => selectedBot && loadCronJobs(selectedBot.id),
onStartJob: startCronJob,
onStopJob: stopCronJob,
onDeleteJob: deleteCronJob,
};
}

View File

@ -1,3 +0,0 @@
export { buildChannelConfigModalProps, buildTopicConfigModalProps } from './config-panel-modal-props/channelTopicModalProps';
export { buildMcpConfigModalProps, buildSkillMarketInstallModalProps, buildSkillsModalProps } from './config-panel-modal-props/skillsMcpModalProps';
export { buildCronJobsModalProps, buildEnvParamsModalProps } from './config-panel-modal-props/supportModalProps';

View File

@ -1,302 +0,0 @@
import type { AnchorHTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react';
export const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
const WORKSPACE_ABS_PATH_PATTERN =
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg|mp3|wav|m4a|flac|ogg|opus|aac|amr|wma|mp4|mov|avi|mkv|webm|m4v|3gp|mpeg|mpg|ts|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi;
const WORKSPACE_RELATIVE_PATH_PATTERN =
/(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg|mp3|wav|m4a|flac|ogg|opus|aac|amr|wma|mp4|mov|avi|mkv|webm|m4v|3gp|mpeg|mpg|ts|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))(?![A-Za-z0-9_./-])/gim;
const WORKSPACE_RENDER_PATTERN =
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
export function normalizeDashboardAttachmentPath(path: string): string {
const v = String(path || '')
.trim()
.replace(/\\/g, '/')
.replace(/^['"`([<{]+/, '')
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
if (!v) return '';
const prefix = '/root/.nanobot/workspace/';
if (v.startsWith(prefix)) return v.slice(prefix.length);
return v.replace(/^\/+/, '');
}
export function buildWorkspaceLink(path: string) {
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
}
export function buildWorkspaceDownloadHref(botIdRaw: string, filePath: string, forceDownload: boolean = true) {
const botId = String(botIdRaw || '').trim();
const normalizedPath = String(filePath || '').trim();
const query = [`path=${encodeURIComponent(normalizedPath)}`];
if (forceDownload) query.push('download=1');
return `/public/bots/${encodeURIComponent(botId)}/workspace/download?${query.join('&')}`;
}
export function buildWorkspaceRawHref(botIdRaw: string, filePath: string, forceDownload: boolean = false) {
const botId = String(botIdRaw || '').trim();
const normalized = String(filePath || '')
.trim()
.split('/')
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join('/');
if (!normalized) return '';
const base = `/public/bots/${encodeURIComponent(botId)}/workspace/raw/${normalized}`;
return forceDownload ? `${base}?download=1` : base;
}
export function buildWorkspacePreviewHref(
botIdRaw: string,
filePath: string,
options: { preferRaw?: boolean } = {},
) {
const normalized = String(filePath || '').trim();
if (!normalized) return '';
return options.preferRaw
? buildWorkspaceRawHref(botIdRaw, normalized, false)
: buildWorkspaceDownloadHref(botIdRaw, normalized, false);
}
export function parseWorkspaceLink(href: string): string | null {
const link = String(href || '').trim();
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
try {
const decoded = decodeURIComponent(encoded || '').trim();
return decoded || null;
} catch {
return null;
}
}
function isExternalHttpLink(href: string): boolean {
return /^https?:\/\//i.test(String(href || '').trim());
}
export function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null {
const target = String(targetRaw || '').trim();
if (!target || target.startsWith('#')) return null;
const linkedPath = parseWorkspaceLink(target);
if (linkedPath) return linkedPath;
if (target.startsWith('/root/.nanobot/workspace/')) {
return normalizeDashboardAttachmentPath(target);
}
const lower = target.toLowerCase();
if (
lower.startsWith('blob:') ||
lower.startsWith('data:') ||
lower.startsWith('http://') ||
lower.startsWith('https://') ||
lower.startsWith('javascript:') ||
lower.startsWith('mailto:') ||
lower.startsWith('tel:') ||
target.startsWith('//')
) {
return null;
}
const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || '');
if (!normalizedBase) return null;
try {
const baseUrl = new URL(`https://workspace.local/${normalizedBase}`);
const resolvedUrl = new URL(target, baseUrl);
if (resolvedUrl.origin !== 'https://workspace.local') return null;
try {
return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname));
} catch {
return normalizeDashboardAttachmentPath(resolvedUrl.pathname);
}
} catch {
return null;
}
}
function decorateWorkspacePathsInPlainChunk(source: string): string {
if (!source) return source;
const protectedLinks: string[] = [];
const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath;
const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`;
protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`);
return token;
});
const withRelativeLinks = withProtectedAbsoluteLinks.replace(
WORKSPACE_RELATIVE_PATH_PATTERN,
(full, prefix: string, rawPath: string) => {
const normalized = normalizeDashboardAttachmentPath(rawPath);
if (!normalized) return full;
return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`;
},
);
return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => {
const idx = Number(idxRaw);
if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || '');
return protectedLinks[idx];
});
}
export function decorateWorkspacePathsForMarkdown(text: string) {
const source = String(text || '');
if (!source) return source;
const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g;
let result = '';
let last = 0;
let match = markdownLinkPattern.exec(source);
while (match) {
const idx = Number(match.index || 0);
if (idx > last) {
result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx));
}
result += match[0];
last = idx + match[0].length;
match = markdownLinkPattern.exec(source);
}
if (last < source.length) {
result += decorateWorkspacePathsInPlainChunk(source.slice(last));
}
return result;
}
function renderWorkspaceAwareText(
text: string,
keyPrefix: string,
openWorkspacePath: (path: string) => void,
): ReactNode[] {
const source = String(text || '');
if (!source) return [source];
const nodes: ReactNode[] = [];
let lastIndex = 0;
let matchIndex = 0;
let match = WORKSPACE_RENDER_PATTERN.exec(source);
while (match) {
if (match.index > lastIndex) {
nodes.push(source.slice(lastIndex, match.index));
}
const raw = match[0];
const markdownPath = match[1] ? String(match[1]) : '';
const markdownHref = match[2] ? String(match[2]) : '';
let normalizedPath = '';
let displayText = raw;
if (markdownPath && markdownHref) {
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
displayText = markdownPath;
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
normalizedPath = normalizeDashboardAttachmentPath(raw);
displayText = raw;
}
if (normalizedPath) {
nodes.push(
<a
key={`${keyPrefix}-ws-${matchIndex}`}
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openWorkspacePath(normalizedPath);
}}
>
{displayText}
</a>,
);
} else {
nodes.push(raw);
}
lastIndex = match.index + raw.length;
matchIndex += 1;
match = WORKSPACE_RENDER_PATTERN.exec(source);
}
if (lastIndex < source.length) {
nodes.push(source.slice(lastIndex));
}
return nodes;
}
function renderWorkspaceAwareChildren(
children: ReactNode,
keyPrefix: string,
openWorkspacePath: (path: string) => void,
): ReactNode {
const list = Array.isArray(children) ? children : [children];
const mapped = list.flatMap((child, idx) => {
if (typeof child === 'string') {
return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`, openWorkspacePath);
}
return [child];
});
return mapped;
}
interface WorkspaceMarkdownOptions {
baseFilePath?: string;
resolveMediaSrc?: (src: string, baseFilePath?: string) => string;
}
export function createWorkspaceMarkdownComponents(
openWorkspacePath: (path: string) => void,
options: WorkspaceMarkdownOptions = {},
) {
const { baseFilePath, resolveMediaSrc } = options;
return {
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim();
const workspacePath = parseWorkspaceLink(link) || resolveWorkspaceDocumentPath(link, baseFilePath);
if (workspacePath) {
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
openWorkspacePath(workspacePath);
}}
{...props}
>
{children}
</a>
);
}
if (isExternalHttpLink(link)) {
return (
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
return (
<a href={link || '#'} {...props}>
{children}
</a>
);
},
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
const link = String(src || '').trim();
const resolvedSrc = resolveMediaSrc ? resolveMediaSrc(link, baseFilePath) : link;
return (
<img
src={resolvedSrc}
alt={String(alt || '')}
loading="lazy"
{...props}
/>
);
},
p: ({ children, ...props }: { children?: ReactNode }) => (
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p', openWorkspacePath)}</p>
),
li: ({ children, ...props }: { children?: ReactNode }) => (
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li', openWorkspacePath)}</li>
),
code: ({ children, ...props }: { children?: ReactNode }) => (
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code', openWorkspacePath)}</code>
),
};
}

View File

@ -7,12 +7,12 @@ import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../../components/lucent/LucentSelect'; import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { DashboardPreviewModalShell } from '../components/DashboardPreviewModalShell'; import { PreviewModalShell } from '../../../shared/ui/PreviewModalShell';
import { import {
createWorkspaceMarkdownComponents, createWorkspaceMarkdownComponents,
decorateWorkspacePathsForMarkdown, decorateWorkspacePathsForMarkdown,
resolveWorkspaceDocumentPath, resolveWorkspaceDocumentPath,
} from '../shared/workspaceMarkdown'; } from '../../../shared/workspace/workspaceMarkdown';
import './TopicFeedPanel.css'; import './TopicFeedPanel.css';
export interface TopicFeedItem { export interface TopicFeedItem {
@ -357,7 +357,7 @@ export function TopicFeedPanel({
</div> </div>
{detailState && portalTarget {detailState && portalTarget
? createPortal( ? createPortal(
<DashboardPreviewModalShell <PreviewModalShell
closeLabel={isZh ? '关闭详情' : 'Close detail'} closeLabel={isZh ? '关闭详情' : 'Close detail'}
onClose={closeDetail} onClose={closeDetail}
subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')} subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')}
@ -374,7 +374,7 @@ export function TopicFeedPanel({
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
</DashboardPreviewModalShell>, </PreviewModalShell>,
portalTarget, portalTarget,
) )
: null} : null}

View File

@ -0,0 +1,62 @@
import { SYSTEM_FALLBACK_TOPIC_KEYS } from '../constants';
import type { BotTopic, TopicPresetTemplate } from '../types';
export function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
if (typeof raw === 'string') return raw.trim();
if (!raw || typeof raw !== 'object') return '';
const bag = raw as Record<string, unknown>;
const byLocale = String(bag[locale] || '').trim();
if (byLocale) return byLocale;
return String(bag['zh-cn'] || bag.en || '').trim();
}
export function normalizePresetTextList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const rows: string[] = [];
raw.forEach((item) => {
const text = String(item || '').trim();
if (text) rows.push(text);
});
return rows;
}
export function parseTopicPresets(raw: unknown): TopicPresetTemplate[] {
if (!Array.isArray(raw)) return [];
const rows: TopicPresetTemplate[] = [];
raw.forEach((item) => {
if (!item || typeof item !== 'object') return;
const record = item as Record<string, unknown>;
const id = String(record.id || '').trim().toLowerCase();
const topicKey = String(record.topic_key || '').trim().toLowerCase();
if (!id || !topicKey) return;
const priority = Number(record.routing_priority);
rows.push({
id,
topic_key: topicKey,
name: record.name,
description: record.description,
routing_purpose: record.routing_purpose,
routing_include_when: record.routing_include_when,
routing_exclude_when: record.routing_exclude_when,
routing_examples_positive: record.routing_examples_positive,
routing_examples_negative: record.routing_examples_negative,
routing_priority: Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : undefined,
});
});
return rows;
}
export function isSystemFallbackTopic(topic: Pick<BotTopic, 'topic_key' | 'name' | 'description' | 'routing'>): boolean {
const key = String(topic.topic_key || '').trim().toLowerCase();
if (!SYSTEM_FALLBACK_TOPIC_KEYS.has(key)) return false;
const routing = topic.routing && typeof topic.routing === 'object' ? topic.routing : {};
const purpose = String((routing as Record<string, unknown>).purpose || '').trim().toLowerCase();
const desc = String(topic.description || '').trim().toLowerCase();
const name = String(topic.name || '').trim().toLowerCase();
const priority = Number((routing as Record<string, unknown>).priority);
if (purpose.includes('fallback')) return true;
if (desc.includes('default topic')) return true;
if (name === 'inbox') return true;
if (Number.isFinite(priority) && priority <= 1) return true;
return false;
}

View File

@ -9,11 +9,9 @@ export interface BotDashboardModuleProps {
} }
export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
export type WorkspaceNodeType = 'dir' | 'file';
export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack' | 'email'; export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack' | 'email';
export type RuntimeViewMode = 'visual' | 'topic'; export type RuntimeViewMode = 'visual' | 'topic';
export type CompactPanelTab = 'chat' | 'runtime'; export type CompactPanelTab = 'chat' | 'runtime';
export type WorkspacePreviewMode = 'preview' | 'edit';
export type QuotedReply = { id?: number; text: string; ts: number }; export type QuotedReply = { id?: number; text: string; ts: number };
export type StagedSubmissionDraft = { export type StagedSubmissionDraft = {
id: string; id: string;
@ -24,58 +22,6 @@ export type StagedSubmissionDraft = {
}; };
export type BotEnvParams = Record<string, string>; export type BotEnvParams = Record<string, string>;
export interface WorkspaceNode {
name: string;
path: string;
type: WorkspaceNodeType;
size?: number;
ext?: string;
ctime?: string;
mtime?: string;
children?: WorkspaceNode[];
}
export interface WorkspaceHoverCardState {
node: WorkspaceNode;
top: number;
left: number;
above: boolean;
}
export interface WorkspaceTreeResponse {
bot_id: string;
root: string;
cwd: string;
parent: string | null;
entries: WorkspaceNode[];
}
export interface WorkspaceFileResponse {
bot_id: string;
path: string;
size: number;
is_markdown: boolean;
truncated: boolean;
content: string;
}
export interface WorkspacePreviewState {
path: string;
content: string;
truncated: boolean;
ext: string;
isMarkdown: boolean;
isImage: boolean;
isHtml: boolean;
isVideo: boolean;
isAudio: boolean;
}
export interface WorkspaceUploadResponse {
bot_id: string;
files: Array<{ name: string; path: string; size: number }>;
}
export interface BotMessagesByDateResponse { export interface BotMessagesByDateResponse {
items?: any[]; items?: any[];
anchor_id?: number | null; anchor_id?: number | null;

View File

@ -1,145 +1,17 @@
import type { ReactNode } from 'react'; import { normalizeAttachmentPaths } from '../../shared/workspace/utils';
import { normalizeDashboardAttachmentPath } from '../../shared/workspace/workspaceMarkdown';
import type { ChatMessage } from '../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from './messageParser';
import {
AUDIO_PREVIEW_EXTENSIONS,
HTML_PREVIEW_EXTENSIONS,
IMAGE_PREVIEW_EXTENSIONS,
MEDIA_UPLOAD_EXTENSIONS,
SYSTEM_FALLBACK_TOPIC_KEYS,
TEXT_PREVIEW_EXTENSIONS,
VIDEO_PREVIEW_EXTENSIONS,
} from './constants';
import type { import type {
BotTopic,
CronJob, CronJob,
MCPConfigResponse, MCPConfigResponse,
MCPServerDraft, MCPServerDraft,
TopicPresetTemplate,
WorkspaceNode,
} from './types'; } from './types';
import { normalizeDashboardAttachmentPath } from './shared/workspaceMarkdown';
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:'; const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: readonly string[] = [];
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET: ReadonlySet<string> = new Set<string>();
export function formatClock(ts: number) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export function formatConversationDate(ts: number, isZh: boolean) {
const d = new Date(ts);
try {
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
});
} catch {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
export function formatDateInputValue(ts: number): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function mapBotMessageResponseRow(row: any): ChatMessage {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
}
export function stateLabel(s?: string) { export function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase(); return (s || 'IDLE').toUpperCase();
} }
export function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
if (typeof raw === 'string') return raw.trim();
if (!raw || typeof raw !== 'object') return '';
const bag = raw as Record<string, unknown>;
const byLocale = String(bag[locale] || '').trim();
if (byLocale) return byLocale;
return String(bag['zh-cn'] || bag.en || '').trim();
}
export function normalizePresetTextList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const rows: string[] = [];
raw.forEach((item) => {
const text = String(item || '').trim();
if (text) rows.push(text);
});
return rows;
}
export function parseTopicPresets(raw: unknown): TopicPresetTemplate[] {
if (!Array.isArray(raw)) return [];
const rows: TopicPresetTemplate[] = [];
raw.forEach((item) => {
if (!item || typeof item !== 'object') return;
const record = item as Record<string, unknown>;
const id = String(record.id || '').trim().toLowerCase();
const topicKey = String(record.topic_key || '').trim().toLowerCase();
if (!id || !topicKey) return;
const priority = Number(record.routing_priority);
rows.push({
id,
topic_key: topicKey,
name: record.name,
description: record.description,
routing_purpose: record.routing_purpose,
routing_include_when: record.routing_include_when,
routing_exclude_when: record.routing_exclude_when,
routing_examples_positive: record.routing_examples_positive,
routing_examples_negative: record.routing_examples_negative,
routing_priority: Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : undefined,
});
});
return rows;
}
export function isSystemFallbackTopic(topic: Pick<BotTopic, 'topic_key' | 'name' | 'description' | 'routing'>): boolean {
const key = String(topic.topic_key || '').trim().toLowerCase();
if (!SYSTEM_FALLBACK_TOPIC_KEYS.has(key)) return false;
const routing = topic.routing && typeof topic.routing === 'object' ? topic.routing : {};
const purpose = String((routing as Record<string, unknown>).purpose || '').trim().toLowerCase();
const desc = String(topic.description || '').trim().toLowerCase();
const name = String(topic.name || '').trim().toLowerCase();
const priority = Number((routing as Record<string, unknown>).priority);
if (purpose.includes('fallback')) return true;
if (desc.includes('default topic')) return true;
if (name === 'inbox') return true;
if (Number.isFinite(priority) && priority <= 1) return true;
return false;
}
export function normalizeRuntimeState(s?: string) { export function normalizeRuntimeState(s?: string) {
const raw = stateLabel(s); const raw = stateLabel(s);
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR'; if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
@ -150,168 +22,6 @@ export function normalizeRuntimeState(s?: string) {
return raw; return raw;
} }
export function parseBotTimestamp(raw?: string | number | null) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
export function sortBotsByCreatedAtDesc<T extends { id?: string | number | null; created_at?: string | number | null }>(
bots: readonly T[],
): T[] {
return [...bots].sort((left, right) => {
const leftCreated = parseBotTimestamp(left.created_at);
const rightCreated = parseBotTimestamp(right.created_at);
if (leftCreated !== rightCreated) return rightCreated - leftCreated;
return String(left.id || '').localeCompare(String(right.id || ''));
});
}
export function normalizeWorkspaceExtension(raw: unknown): string {
const value = String(raw ?? '').trim().toLowerCase();
if (!value) return '';
const stripped = value.replace(/^\*\./, '');
const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`;
return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : '';
}
export function parseWorkspaceDownloadExtensions(
raw: unknown,
fallback: readonly string[] = EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS,
): string[] {
if (raw === null || raw === undefined) return [...fallback];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
export function parseAllowedAttachmentExtensions(raw: unknown): string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
export function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
const normalized = String(path || '').trim().toLowerCase();
if (!normalized) return false;
for (const ext of extensions) {
if (normalized.endsWith(ext)) return true;
}
return false;
}
export function isDownloadOnlyPath(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
return pathHasExtension(path, downloadExtensions);
}
export function isPreviewableWorkspaceFile(
node: WorkspaceNode,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (node.type !== 'file') return false;
return isPreviewableWorkspacePath(node.path, downloadExtensions);
}
export function isImagePath(path: string) {
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
}
export function isVideoPath(path: string) {
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
}
export function isAudioPath(path: string) {
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
}
export function isMediaUploadFile(file: File): boolean {
const mime = String(file.type || '').toLowerCase();
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
return true;
}
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
if (dot < 0) return false;
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
}
export function isHtmlPath(path: string) {
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
}
export function isPreviewableWorkspacePath(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (isDownloadOnlyPath(path, downloadExtensions)) return true;
return (
pathHasExtension(path, TEXT_PREVIEW_EXTENSIONS) ||
isHtmlPath(path) ||
isImagePath(path) ||
isAudioPath(path) ||
isVideoPath(path)
);
}
export function workspaceFileAction(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
): 'preview' | 'download' | 'unsupported' {
const normalized = String(path || '').trim();
if (!normalized) return 'unsupported';
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
return 'unsupported';
}
export function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
const path = String(pathRaw || '');
if (!path) return ['-'];
const normalized = path.replace(/\\/g, '/');
const hasLeadingSlash = normalized.startsWith('/');
const parts = normalized.split('/').filter((part) => part.length > 0);
const nodes: ReactNode[] = [];
if (hasLeadingSlash) {
nodes.push(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
}
parts.forEach((part, index) => {
if (index > 0) {
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
}
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
});
return nodes.length > 0 ? nodes : ['-'];
}
export function normalizeAttachmentPaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
.filter((v) => v.length > 0);
}
export interface ComposerDraftStorage { export interface ComposerDraftStorage {
command: string; command: string;
attachments: string[]; attachments: string[];
@ -367,47 +77,6 @@ export function persistComposerDraft(botId: string, commandRaw: string, attachme
} }
} }
export function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
const quoted = normalizeAssistantMessageText(match?.[1] || '');
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
return { quoted, body };
}
export function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
const userQuoted = parsedUser.quoted;
const userBody = parsedUser.body;
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0 && !userQuoted) return;
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
const lastKind = last.kind || 'final';
const currentKind = msg.kind || 'final';
const sameAttachmentSet =
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return;
}
}
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
});
return merged.slice(-120);
}
export function clampTemperature(value: number) { export function clampTemperature(value: number) {
if (Number.isNaN(value)) return 0.2; if (Number.isNaN(value)) return 0.2;

View File

@ -10,7 +10,7 @@ interface PlatformAdminDashboardPageProps {
} }
export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboardPageProps) { export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboardPageProps) {
const dashboard = usePlatformDashboard({ compactMode }); const dashboard = usePlatformDashboard({ compactMode, mode: 'admin' });
return ( return (
<section className="panel stack skill-market-page-shell platform-admin-page-shell"> <section className="panel stack skill-market-page-shell platform-admin-page-shell">

View File

@ -20,9 +20,34 @@ interface PlatformBotManagementPageProps {
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = []; const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) { export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
const dashboard = usePlatformDashboard({ compactMode }); const dashboard = usePlatformDashboard({ compactMode, mode: 'management' });
const [showCreateBotModal, setShowCreateBotModal] = useState(false); const [showCreateBotModal, setShowCreateBotModal] = useState(false);
const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS; const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS;
const runtimePageSize = dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10;
const botDetailContent = (
<>
<PlatformBotOverviewSection
compactSheet={compactMode}
isZh={dashboard.isZh}
lastActionPreview={dashboard.lastActionPreview}
operatingBotId={dashboard.operatingBotId}
selectedBotInfo={dashboard.selectedBotInfo}
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
onOpenBotPanel={dashboard.openBotPanel}
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
onOpenResourceMonitor={dashboard.openResourceMonitor}
onRemoveBot={dashboard.removeBot}
/>
<PlatformBotRuntimeSection
compactSheet={compactMode}
isZh={dashboard.isZh}
pageSize={runtimePageSize}
selectedBotInfo={dashboard.selectedBotInfo}
workspaceDownloadExtensions={workspaceDownloadExtensions}
/>
</>
);
return ( return (
<> <>
@ -51,24 +76,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
{!compactMode ? ( {!compactMode ? (
<section className="platform-main"> <section className="platform-main">
<div className="platform-bot-management-detail-stack"> <div className="platform-bot-management-detail-stack">
<PlatformBotOverviewSection {botDetailContent}
isZh={dashboard.isZh}
lastActionPreview={dashboard.lastActionPreview}
operatingBotId={dashboard.operatingBotId}
selectedBotInfo={dashboard.selectedBotInfo}
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
onOpenBotPanel={dashboard.openBotPanel}
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
onOpenResourceMonitor={dashboard.openResourceMonitor}
onRemoveBot={dashboard.removeBot}
/>
<PlatformBotRuntimeSection
isZh={dashboard.isZh}
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
selectedBotInfo={dashboard.selectedBotInfo}
workspaceDownloadExtensions={workspaceDownloadExtensions}
/>
</div> </div>
</section> </section>
) : null} ) : null}
@ -82,28 +90,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
isZh={dashboard.isZh} isZh={dashboard.isZh}
onClose={dashboard.closeCompactBotSheet} onClose={dashboard.closeCompactBotSheet}
> >
<> {botDetailContent}
<PlatformBotOverviewSection
compactSheet
isZh={dashboard.isZh}
lastActionPreview={dashboard.lastActionPreview}
operatingBotId={dashboard.operatingBotId}
selectedBotInfo={dashboard.selectedBotInfo}
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
onOpenBotPanel={dashboard.openBotPanel}
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
onOpenResourceMonitor={dashboard.openResourceMonitor}
onRemoveBot={dashboard.removeBot}
/>
<PlatformBotRuntimeSection
compactSheet
isZh={dashboard.isZh}
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
selectedBotInfo={dashboard.selectedBotInfo}
workspaceDownloadExtensions={workspaceDownloadExtensions}
/>
</>
</PlatformCompactBotSheet> </PlatformCompactBotSheet>
<CreateBotWizardModal <CreateBotWizardModal

View File

@ -31,6 +31,39 @@
margin: 0; margin: 0;
} }
.platform-auth-audit-workspace {
min-height: 0;
}
.platform-auth-audit-table td {
vertical-align: top;
}
.platform-auth-audit-status {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.platform-auth-audit-status.is-active {
background: color-mix(in oklab, var(--ok) 18%, var(--panel-soft));
color: color-mix(in oklab, var(--ok) 72%, var(--title));
}
.platform-auth-audit-status.is-revoked {
background: color-mix(in oklab, var(--err) 14%, var(--panel-soft));
color: color-mix(in oklab, var(--err) 76%, var(--title));
}
.platform-auth-audit-reason {
margin-top: 6px;
font-size: 12px;
color: var(--muted);
}
.platform-bot-list-panel { .platform-bot-list-panel {
min-height: 0; min-height: 0;
display: grid; display: grid;

View File

@ -0,0 +1,234 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw, ShieldCheck } from 'lucide-react';
import '../../components/skill-market/SkillMarketShared.css';
import { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { fetchPlatformLoginLogs, fetchPreferredPlatformPageSize } from './api/settings';
import type { PlatformLoginLogItem } from './types';
import './PlatformDashboardPage.css';
function formatAuditTime(raw: string | null | undefined, isZh: boolean): string {
const text = String(raw || '').trim();
if (!text) return '-';
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return text;
try {
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
return dt.toLocaleString();
}
}
function describePrincipal(row: PlatformLoginLogItem, isZh: boolean): string {
if (row.auth_type === 'panel') {
return isZh ? '平台管理端' : 'Panel';
}
const botId = String(row.bot_id || row.subject_id || '').trim();
return botId || (isZh ? 'Bot' : 'Bot');
}
interface PlatformLoginLogPageProps {
isZh: boolean;
}
export function PlatformLoginLogPage({ isZh }: PlatformLoginLogPageProps) {
const { notify } = useLucentPrompt();
const [items, setItems] = useState<PlatformLoginLogItem[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [authType, setAuthType] = useState<'all' | 'panel' | 'bot'>('all');
const [status, setStatus] = useState<'all' | 'active' | 'revoked'>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
useEffect(() => {
void (async () => {
setPageSize(await fetchPreferredPlatformPageSize(10));
})();
}, []);
useEffect(() => {
setPage(1);
}, [search, authType, status, pageSize]);
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const loadRows = async () => {
setLoading(true);
try {
const data = await fetchPlatformLoginLogs({
search: search.trim(),
auth_type: authType === 'all' ? '' : authType,
status,
limit: pageSize,
offset: Math.max(0, page - 1) * pageSize,
});
setItems(Array.isArray(data?.items) ? data.items : []);
setTotal(Number(data?.total || 0));
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取登录日志失败。' : 'Failed to load login logs.'), {
tone: 'error',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, search, authType, status]);
const summaryText = useMemo(() => {
if (isZh) {
return `${page} / ${pageCount} 页,共 ${total}`;
}
return `Page ${page} / ${pageCount}, ${total} rows`;
}, [isZh, page, pageCount, total]);
return (
<section className="panel stack skill-market-page-shell platform-settings-page-shell">
<div className="platform-settings-info-card skill-market-page-info-card">
<div className="skill-market-page-info-main">
<div className="platform-settings-info-icon">
<ShieldCheck size={18} />
</div>
<div className="skill-market-page-info-copy">
<strong>{isZh ? '登录日志' : 'Login Logs'}</strong>
<div>
{isZh
? '查看 Panel 与 Bot 登录令牌的签发、访问与失效记录。数据库仅保留审计日志,活跃令牌实际存放于 Redis。'
: 'Review issuance, access, and revocation records for panel and bot login tokens. The database keeps audit logs while active tokens live in Redis.'}
</div>
</div>
</div>
</div>
<div className="platform-settings-toolbar">
<ProtectedSearchInput
className="platform-searchbar platform-settings-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
debounceMs={120}
placeholder={isZh ? '搜索 Bot、IP、设备、来源...' : 'Search bot, IP, device, source...'}
ariaLabel={isZh ? '搜索登录日志' : 'Search login logs'}
clearTitle={isZh ? '清除搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
<LucentSelect value={authType} onChange={(event) => setAuthType(event.target.value as 'all' | 'panel' | 'bot')}>
<option value="all">{isZh ? '全部类型' : 'All Types'}</option>
<option value="panel">{isZh ? 'Panel' : 'Panel'}</option>
<option value="bot">{isZh ? 'Bot' : 'Bot'}</option>
</LucentSelect>
<LucentSelect value={status} onChange={(event) => setStatus(event.target.value as 'all' | 'active' | 'revoked')}>
<option value="all">{isZh ? '全部状态' : 'All Statuses'}</option>
<option value="active">{isZh ? '活跃中' : 'Active'}</option>
<option value="revoked">{isZh ? '已失效' : 'Revoked'}</option>
</LucentSelect>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void loadRows()}
disabled={loading}
tooltip={isZh ? '刷新' : 'Refresh'}
aria-label={isZh ? '刷新' : 'Refresh'}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</LucentIconButton>
</div>
<div className="skill-market-page-workspace platform-auth-audit-workspace">
<div className="platform-settings-table-wrap">
<table className="table platform-settings-table platform-auth-audit-table">
<thead>
<tr>
<th>{isZh ? '主体' : 'Principal'}</th>
<th>{isZh ? '类型' : 'Type'}</th>
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '设备 / IP' : 'Device / IP'}</th>
<th>{isZh ? '登录时间' : 'Issued At'}</th>
<th>{isZh ? '最近访问' : 'Last Seen'}</th>
<th>{isZh ? '状态' : 'Status'}</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const principalText = describePrincipal(item, isZh);
const statusLabel = item.status === 'active'
? (isZh ? '活跃中' : 'Active')
: (isZh ? '已失效' : 'Revoked');
const revokeReason = String(item.revoke_reason || '').trim();
return (
<tr key={item.id}>
<td>
<div><strong>{principalText}</strong></div>
<div className="mono">{item.auth_type === 'panel' ? item.subject_id : (item.bot_id || item.subject_id)}</div>
</td>
<td>{item.auth_type}</td>
<td>
<div>{item.auth_source || '-'}</div>
<div className="mono">#{item.id}</div>
</td>
<td>
<div>{item.device_info || (isZh ? '未知设备' : 'Unknown device')}</div>
<div className="mono">{item.client_ip || '-'}</div>
</td>
<td>{formatAuditTime(item.created_at, isZh)}</td>
<td>{formatAuditTime(item.last_seen_at, isZh)}</td>
<td>
<div className={`platform-auth-audit-status ${item.status === 'active' ? 'is-active' : 'is-revoked'}`}>
{statusLabel}
</div>
{revokeReason ? <div className="platform-auth-audit-reason">{revokeReason}</div> : null}
</td>
</tr>
);
})}
</tbody>
</table>
{!loading && items.length === 0 ? (
<div className="ops-empty-inline">{isZh ? '暂无登录日志记录。' : 'No login log records.'}</div>
) : null}
</div>
</div>
<div className="platform-settings-pager">
<span className="pager-status">{summaryText}</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</section>
);
}

View File

@ -6,7 +6,7 @@ import {
readCachedPlatformPageSize, readCachedPlatformPageSize,
writeCachedPlatformPageSize, writeCachedPlatformPageSize,
} from '../../../utils/platformPageSize'; } from '../../../utils/platformPageSize';
import type { PlatformSettings, SystemSettingItem } from '../types'; import type { PlatformLoginLogResponse, PlatformSettings, SystemSettingItem } from '../types';
export interface SystemSettingsResponse { export interface SystemSettingsResponse {
items: SystemSettingItem[]; items: SystemSettingItem[];
@ -27,6 +27,18 @@ export function fetchPlatformSettings() {
return axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`).then((res) => res.data); return axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`).then((res) => res.data);
} }
export function fetchPlatformLoginLogs(params: {
search?: string;
auth_type?: string;
status?: string;
limit?: number;
offset?: number;
}) {
return axios
.get<PlatformLoginLogResponse>(`${APP_ENDPOINTS.apiBase}/platform/login-logs`, { params })
.then((res) => res.data);
}
export async function fetchPreferredPlatformPageSize(fallback = 10) { export async function fetchPreferredPlatformPageSize(fallback = 10) {
const cachedFallback = readCachedPlatformPageSize(fallback); const cachedFallback = readCachedPlatformPageSize(fallback);
try { try {

View File

@ -1,24 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react'; import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput'; import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { APP_ENDPOINTS } from '../../../config/env';
import { dashboardEn } from '../../../i18n/dashboard.en'; import { dashboardEn } from '../../../i18n/dashboard.en';
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn'; import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
import { pickLocale } from '../../../i18n'; import { pickLocale } from '../../../i18n';
import { WorkspaceEntriesList } from '../../../shared/workspace/WorkspaceEntriesList';
import { WorkspaceHoverCard } from '../../../shared/workspace/WorkspaceHoverCard';
import { WorkspacePreviewModal } from '../../../shared/workspace/WorkspacePreviewModal';
import '../../../shared/workspace/WorkspaceOverlay.css';
import { formatBytes, formatWorkspaceTime, isPreviewableWorkspaceFile } from '../../../shared/workspace/utils';
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import { WorkspaceEntriesList } from '../../dashboard/components/WorkspaceEntriesList'; import { usePlatformBotDockerLogs } from '../hooks/usePlatformBotDockerLogs';
import { WorkspaceHoverCard } from '../../dashboard/components/WorkspaceHoverCard';
import { WorkspacePreviewModal } from '../../dashboard/components/WorkspacePreviewModal';
import { useDashboardWorkspace } from '../../dashboard/hooks/useDashboardWorkspace';
import { formatBytes, formatWorkspaceTime } from '../../dashboard/utils';
import '../../dashboard/components/BotListPanel.css'; import '../../dashboard/components/BotListPanel.css';
import '../../dashboard/components/RuntimePanel.css'; import '../../dashboard/components/RuntimePanel.css';
import '../../dashboard/components/DashboardShared.css'; import '../../dashboard/components/DashboardShared.css';
import '../../dashboard/components/WorkspaceOverlay.css';
import '../../../components/ui/SharedUi.css'; import '../../../components/ui/SharedUi.css';
interface PlatformBotRuntimeSectionProps { interface PlatformBotRuntimeSectionProps {
@ -29,41 +28,8 @@ interface PlatformBotRuntimeSectionProps {
workspaceDownloadExtensions: string[]; workspaceDownloadExtensions: string[];
} }
const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g;
const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40; const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40;
const DOCKER_LOG_TABLE_ROW_HEIGHT = 56; const DOCKER_LOG_TABLE_ROW_HEIGHT = 56;
const EMPTY_DOCKER_LOG_ENTRY = {
key: '',
index: '',
level: '',
text: '',
tone: 'plain',
} as const;
function stripAnsi(textRaw: string) {
return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim();
}
function parseDockerLogEntry(textRaw: string) {
const text = stripAnsi(textRaw);
const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i);
const levelRaw = String(levelMatch?.[1] || '').toUpperCase();
const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-');
return {
level,
text,
tone:
level === 'ERROR' || level === 'FATAL' || level === 'CRITICAL'
? 'err'
: level === 'WARN'
? 'warn'
: level === 'INFO'
? 'info'
: level === 'DEBUG' || level === 'TRACE'
? 'debug'
: 'plain',
} as const;
}
export function PlatformBotRuntimeSection({ export function PlatformBotRuntimeSection({
compactSheet = false, compactSheet = false,
@ -74,11 +40,6 @@ export function PlatformBotRuntimeSection({
}: PlatformBotRuntimeSectionProps) { }: PlatformBotRuntimeSectionProps) {
const { notify } = useLucentPrompt(); const { notify } = useLucentPrompt();
const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn }); const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn });
const [dockerLogs, setDockerLogs] = useState<string[]>([]);
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
const [dockerLogsError, setDockerLogsError] = useState('');
const [dockerLogsPage, setDockerLogsPage] = useState(1);
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
const dockerLogsCardRef = useRef<HTMLDivElement | null>(null); const dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null); const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
const workspaceSearchInputName = useMemo( const workspaceSearchInputName = useMemo(
@ -87,41 +48,18 @@ export function PlatformBotRuntimeSection({
); );
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10)); const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT; const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT;
const recentLogEntries = useMemo(() => {
const logs = (dockerLogs || [])
.map((line) => String(line || '').trim())
.filter(Boolean)
.map((line, index) => ({
key: `log-${dockerLogsPage}-${index}`,
index: String((dockerLogsPage - 1) * effectivePageSize + index + 1).padStart(3, '0'),
...parseDockerLogEntry(line),
}));
if (logs.length > 0) return logs;
const events = (selectedBotInfo?.events || [])
.filter((event) => String(event?.text || '').trim().length > 0)
.slice(0, effectivePageSize)
.map((event, index) => ({
key: `event-${event.ts}-${index}`,
index: String(index + 1).padStart(3, '0'),
...parseDockerLogEntry(`[${String(event.state || 'INFO').toUpperCase()}] ${String(event.text || '').trim()}`),
}));
return events;
}, [dockerLogs, dockerLogsPage, effectivePageSize, selectedBotInfo?.events]);
const dockerLogTableRows = useMemo(
() => [
...recentLogEntries,
...Array.from({ length: Math.max(0, effectivePageSize - recentLogEntries.length) }, (_, index) => ({
...EMPTY_DOCKER_LOG_ENTRY,
key: `docker-log-empty-${dockerLogsPage}-${index}`,
})),
],
[dockerLogsPage, effectivePageSize, recentLogEntries],
);
const workspaceCardStyle = useMemo( const workspaceCardStyle = useMemo(
() => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined), () => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined),
[compactSheet, workspaceCardHeightPx], [compactSheet, workspaceCardHeightPx],
); );
const refreshWorkspaceAttachmentPolicy = useCallback(
async () => ({
uploadMaxMb: 0,
allowedAttachmentExtensions: [],
workspaceDownloadExtensions,
}),
[workspaceDownloadExtensions],
);
const { const {
closeWorkspacePreview, closeWorkspacePreview,
copyWorkspacePreviewPath, copyWorkspacePreviewPath,
@ -145,7 +83,6 @@ export function PlatformBotRuntimeSection({
workspaceDownloadExtensionSet, workspaceDownloadExtensionSet,
workspaceError, workspaceError,
workspaceFileLoading, workspaceFileLoading,
workspaceFiles,
workspaceHoverCard, workspaceHoverCard,
workspaceLoading, workspaceLoading,
workspaceParentPath, workspaceParentPath,
@ -159,20 +96,43 @@ export function PlatformBotRuntimeSection({
workspacePreviewSaving, workspacePreviewSaving,
workspaceQuery, workspaceQuery,
workspaceSearchLoading, workspaceSearchLoading,
} = useDashboardWorkspace({ } = useBotWorkspace({
selectedBotId: selectedBotInfo?.id || '', selectedBotId: selectedBotInfo?.id || '',
selectedBotDockerStatus: selectedBotInfo?.docker_status || '', selectedBotDockerStatus: selectedBotInfo?.docker_status || '',
workspaceDownloadExtensions, workspaceDownloadExtensions,
refreshAttachmentPolicy: async () => ({ refreshAttachmentPolicy: refreshWorkspaceAttachmentPolicy,
uploadMaxMb: 0,
allowedAttachmentExtensions: [],
workspaceDownloadExtensions,
}),
notify, notify,
t: dashboardT, t: dashboardT,
isZh, isZh,
fileNotPreviewableLabel: dashboardT.fileNotPreviewable, fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
}); });
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
const hasVisibleWorkspaceEntries = filteredWorkspaceEntries.length > 0;
const visibleWorkspaceFiles = filteredWorkspaceEntries.filter((entry) => entry.type === 'file');
const hasVisiblePreviewableFiles = visibleWorkspaceFiles.some((entry) =>
isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet),
);
const showWorkspaceEmptyState = !workspaceLoading && !workspaceSearchLoading && !workspaceError && !hasVisibleWorkspaceEntries;
const showNoPreviewableFilesHint = Boolean(
selectedBotInfo &&
!workspaceError &&
!normalizedWorkspaceQuery &&
visibleWorkspaceFiles.length > 0 &&
!hasVisiblePreviewableFiles,
);
const {
dockerLogsError,
dockerLogsHasMore,
dockerLogsLoading,
dockerLogsPage,
dockerLogTableRows,
fetchDockerLogsPage,
recentLogEntries,
} = usePlatformBotDockerLogs({
effectivePageSize,
isZh,
selectedBotInfo,
});
useEffect(() => { useEffect(() => {
if (!selectedBotInfo?.id) { if (!selectedBotInfo?.id) {
@ -186,84 +146,6 @@ export function PlatformBotRuntimeSection({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetWorkspaceState, selectedBotInfo?.id]); }, [resetWorkspaceState, selectedBotInfo?.id]);
useEffect(() => {
setDockerLogsPage(1);
}, [selectedBotInfo?.id, effectivePageSize]);
const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => {
if (!selectedBotInfo?.id) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError('');
setDockerLogsLoading(false);
return;
}
const safePage = Math.max(1, page);
if (!silent) setDockerLogsLoading(true);
setDockerLogsError('');
try {
const res = await axios.get<{
bot_id: string;
logs?: string[];
total?: number | null;
offset?: number;
limit?: number;
has_more?: boolean;
reverse?: boolean;
}>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, {
params: {
offset: (safePage - 1) * effectivePageSize,
limit: effectivePageSize,
reverse: true,
},
});
const lines = Array.isArray(res.data?.logs)
? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean)
: [];
setDockerLogs(lines);
setDockerLogsHasMore(Boolean(res.data?.has_more));
setDockerLogsPage(safePage);
} catch (error: any) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.'));
} finally {
if (!silent) {
setDockerLogsLoading(false);
}
}
}, [effectivePageSize, isZh, selectedBotInfo?.id]);
useEffect(() => {
if (!selectedBotInfo?.id) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError('');
setDockerLogsLoading(false);
return;
}
let stopped = false;
void fetchDockerLogsPage(dockerLogsPage, false);
if (dockerLogsPage !== 1 || String(selectedBotInfo.docker_status || '').toUpperCase() !== 'RUNNING') {
return () => {
stopped = true;
};
}
const timer = window.setInterval(() => {
if (!stopped) {
void fetchDockerLogsPage(1, true);
}
}, 3000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]);
useEffect(() => { useEffect(() => {
if (compactSheet) { if (compactSheet) {
setWorkspaceCardHeightPx(null); setWorkspaceCardHeightPx(null);
@ -364,11 +246,9 @@ export function PlatformBotRuntimeSection({
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div> <div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div>
) : workspaceLoading || workspaceSearchLoading ? ( ) : workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{dashboardT.loadingDir}</div> <div className="ops-empty-inline">{dashboardT.loadingDir}</div>
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
<div className="ops-empty-inline">
{workspaceQuery.trim() ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
</div>
) : ( ) : (
<>
{(workspaceParentPath !== null || hasVisibleWorkspaceEntries) ? (
<WorkspaceEntriesList <WorkspaceEntriesList
nodes={filteredWorkspaceEntries} nodes={filteredWorkspaceEntries}
workspaceParentPath={workspaceParentPath} workspaceParentPath={workspaceParentPath}
@ -389,6 +269,13 @@ export function PlatformBotRuntimeSection({
onShowWorkspaceHoverCard={showWorkspaceHoverCard} onShowWorkspaceHoverCard={showWorkspaceHoverCard}
onHideWorkspaceHoverCard={hideWorkspaceHoverCard} onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
/> />
) : null}
{showWorkspaceEmptyState ? (
<div className="ops-empty-inline">
{normalizedWorkspaceQuery ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
</div>
) : null}
</>
)} )}
</div> </div>
<div className="workspace-hint"> <div className="workspace-hint">
@ -396,7 +283,7 @@ export function PlatformBotRuntimeSection({
</div> </div>
</div> </div>
{selectedBotInfo && !workspaceFiles.length ? ( {showNoPreviewableFilesHint ? (
<div className="ops-empty-inline">{dashboardT.noPreviewFile}</div> <div className="ops-empty-inline">{dashboardT.noPreviewFile}</div>
) : null} ) : null}
</div> </div>

View File

@ -6,17 +6,17 @@ import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotState } from '../../../types/bot'; import { repairCollapsedMarkdown } from '../../../shared/text/messageText';
import { MARKDOWN_SANITIZE_SCHEMA } from '../../dashboard/constants'; import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
import { DashboardModalCardShell } from '../../dashboard/components/DashboardModalCardShell'; import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
import { repairCollapsedMarkdown } from '../../dashboard/messageParser'; import '../../../shared/workspace/WorkspaceOverlay.css';
import { import {
createWorkspaceMarkdownComponents, createWorkspaceMarkdownComponents,
decorateWorkspacePathsForMarkdown, decorateWorkspacePathsForMarkdown,
} from '../../dashboard/shared/workspaceMarkdown'; } from '../../../shared/workspace/workspaceMarkdown';
import type { BotState } from '../../../types/bot';
import type { PlatformBotResourceSnapshot } from '../types'; import type { PlatformBotResourceSnapshot } from '../types';
import { formatPlatformBytes, formatPlatformPercent } from '../utils'; import { formatPlatformBytes, formatPlatformPercent } from '../utils';
import '../../dashboard/components/WorkspaceOverlay.css';
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {}); const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
@ -76,7 +76,7 @@ export function PlatformLastActionModal({
const closeLabel = isZh ? '关闭' : 'Close'; const closeLabel = isZh ? '关闭' : 'Close';
return ( return (
<DashboardModalCardShell <ModalCardShell
cardClassName="platform-last-action-modal" cardClassName="platform-last-action-modal"
closeLabel={closeLabel} closeLabel={closeLabel}
onClose={onClose} onClose={onClose}
@ -94,7 +94,7 @@ export function PlatformLastActionModal({
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
</DashboardModalCardShell> </ModalCardShell>
); );
} }
@ -126,7 +126,7 @@ export function PlatformResourceMonitorModal({
const closeLabel = isZh ? '关闭' : 'Close'; const closeLabel = isZh ? '关闭' : 'Close';
return ( return (
<DashboardModalCardShell <ModalCardShell
cardClassName="modal-wide" cardClassName="modal-wide"
closeLabel={closeLabel} closeLabel={closeLabel}
headerActions={( headerActions={(
@ -191,6 +191,6 @@ export function PlatformResourceMonitorModal({
) : ( ) : (
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div> <div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
)} )}
</DashboardModalCardShell> </ModalCardShell>
); );
} }

View File

@ -0,0 +1,178 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotState } from '../../../types/bot';
const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g;
const EMPTY_DOCKER_LOG_ENTRY = {
key: '',
index: '',
level: '',
text: '',
tone: 'plain',
} as const;
function stripAnsi(textRaw: string) {
return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim();
}
function parseDockerLogEntry(textRaw: string) {
const text = stripAnsi(textRaw);
const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i);
const levelRaw = String(levelMatch?.[1] || '').toUpperCase();
const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-');
return {
level,
text,
tone:
level === 'ERROR' || level === 'FATAL' || level === 'CRITICAL'
? 'err'
: level === 'WARN'
? 'warn'
: level === 'INFO'
? 'info'
: level === 'DEBUG' || level === 'TRACE'
? 'debug'
: 'plain',
} as const;
}
interface UsePlatformBotDockerLogsOptions {
effectivePageSize: number;
isZh: boolean;
selectedBotInfo?: BotState;
}
export function usePlatformBotDockerLogs({
effectivePageSize,
isZh,
selectedBotInfo,
}: UsePlatformBotDockerLogsOptions) {
const [dockerLogs, setDockerLogs] = useState<string[]>([]);
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
const [dockerLogsError, setDockerLogsError] = useState('');
const [dockerLogsPage, setDockerLogsPage] = useState(1);
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
const recentLogEntries = useMemo(() => {
const logs = (dockerLogs || [])
.map((line) => String(line || '').trim())
.filter(Boolean)
.map((line, index) => ({
key: `log-${dockerLogsPage}-${index}`,
index: String((dockerLogsPage - 1) * effectivePageSize + index + 1).padStart(3, '0'),
...parseDockerLogEntry(line),
}));
if (logs.length > 0) return logs;
const events = (selectedBotInfo?.events || [])
.filter((event) => String(event?.text || '').trim().length > 0)
.slice(0, effectivePageSize)
.map((event, index) => ({
key: `event-${event.ts}-${index}`,
index: String(index + 1).padStart(3, '0'),
...parseDockerLogEntry(`[${String(event.state || 'INFO').toUpperCase()}] ${String(event.text || '').trim()}`),
}));
return events;
}, [dockerLogs, dockerLogsPage, effectivePageSize, selectedBotInfo?.events]);
const dockerLogTableRows = useMemo(
() => [
...recentLogEntries,
...Array.from({ length: Math.max(0, effectivePageSize - recentLogEntries.length) }, (_, index) => ({
...EMPTY_DOCKER_LOG_ENTRY,
key: `docker-log-empty-${dockerLogsPage}-${index}`,
})),
],
[dockerLogsPage, effectivePageSize, recentLogEntries],
);
const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => {
if (!selectedBotInfo?.id) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError('');
setDockerLogsLoading(false);
return;
}
const safePage = Math.max(1, page);
if (!silent) setDockerLogsLoading(true);
setDockerLogsError('');
try {
const res = await axios.get<{
bot_id: string;
logs?: string[];
total?: number | null;
offset?: number;
limit?: number;
has_more?: boolean;
reverse?: boolean;
}>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, {
params: {
offset: (safePage - 1) * effectivePageSize,
limit: effectivePageSize,
reverse: true,
},
});
const lines = Array.isArray(res.data?.logs)
? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean)
: [];
setDockerLogs(lines);
setDockerLogsHasMore(Boolean(res.data?.has_more));
setDockerLogsPage(safePage);
} catch (error: any) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.'));
} finally {
if (!silent) {
setDockerLogsLoading(false);
}
}
}, [effectivePageSize, isZh, selectedBotInfo?.id]);
useEffect(() => {
setDockerLogsPage(1);
}, [selectedBotInfo?.id, effectivePageSize]);
useEffect(() => {
if (!selectedBotInfo?.id) {
setDockerLogs([]);
setDockerLogsHasMore(false);
setDockerLogsError('');
setDockerLogsLoading(false);
return;
}
let stopped = false;
void fetchDockerLogsPage(dockerLogsPage, false);
if (dockerLogsPage !== 1 || String(selectedBotInfo.docker_status || '').toUpperCase() !== 'RUNNING') {
return () => {
stopped = true;
};
}
const timer = window.setInterval(() => {
if (!stopped) {
void fetchDockerLogsPage(1, true);
}
}, 3000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]);
return {
dockerLogsError,
dockerLogsHasMore,
dockerLogsLoading,
dockerLogsPage,
dockerLogTableRows,
fetchDockerLogsPage,
recentLogEntries,
};
}

View File

@ -3,7 +3,7 @@ import axios from 'axios';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { APP_ENDPOINTS } from '../../../config/env'; import { APP_ENDPOINTS } from '../../../config/env';
import { sortBotsByCreatedAtDesc } from '../../dashboard/utils'; import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
import { useAppStore } from '../../../store/appStore'; import { useAppStore } from '../../../store/appStore';
import type { BotState } from '../../../types/bot'; import type { BotState } from '../../../types/bot';
import { import {
@ -15,7 +15,6 @@ import type {
BotActivityStatsItem, BotActivityStatsItem,
PlatformBotResourceSnapshot, PlatformBotResourceSnapshot,
PlatformOverviewResponse, PlatformOverviewResponse,
PlatformSettings,
PlatformUsageAnalyticsSeriesItem, PlatformUsageAnalyticsSeriesItem,
PlatformUsageResponse, PlatformUsageResponse,
} from '../types'; } from '../types';
@ -23,18 +22,20 @@ import {
buildBotPanelHref, buildBotPanelHref,
buildPlatformUsageAnalyticsSeries, buildPlatformUsageAnalyticsSeries,
buildPlatformUsageAnalyticsTicks, buildPlatformUsageAnalyticsTicks,
clampPlatformPercent,
getPlatformChartCeiling, getPlatformChartCeiling,
} from '../utils'; } from '../utils';
interface UsePlatformDashboardOptions { interface UsePlatformDashboardOptions {
compactMode: boolean; compactMode: boolean;
mode?: 'admin' | 'management';
} }
export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOptions) { export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePlatformDashboardOptions) {
const { activeBots, setBots, updateBotStatus, locale } = useAppStore(); const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
const { notify, confirm } = useLucentPrompt(); const { notify, confirm } = useLucentPrompt();
const isZh = locale === 'zh'; const isZh = locale === 'zh';
const isAdminMode = mode === 'admin';
const isManagementMode = mode === 'management';
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null); const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedBotId, setSelectedBotId] = useState(''); const [selectedBotId, setSelectedBotId] = useState('');
@ -52,7 +53,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
const [usageLoading, setUsageLoading] = useState(false); const [usageLoading, setUsageLoading] = useState(false);
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null); const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
const [activityLoading, setActivityLoading] = useState(false); const [activityLoading, setActivityLoading] = useState(false);
const [usagePage, setUsagePage] = useState(1);
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10)); const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
const [botListPage, setBotListPage] = useState(1); const [botListPage, setBotListPage] = useState(1);
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10)); const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
@ -121,7 +121,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
}, },
}); });
setUsageData(res.data); setUsageData(res.data);
setUsagePage(page);
} catch (error: any) { } catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' }); notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
} finally { } finally {
@ -180,26 +179,45 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
}, [loadOverview]); }, [loadOverview]);
useEffect(() => { useEffect(() => {
if (!isAdminMode) {
setUsageData(null);
setUsageLoading(false);
return;
}
void loadUsage(1); void loadUsage(1);
}, [loadUsage, usagePageSize]); }, [isAdminMode, loadUsage, usagePageSize]);
useEffect(() => { useEffect(() => {
if (!isAdminMode) {
setActivityStatsData(null);
setActivityLoading(false);
return;
}
void loadActivityStats(); void loadActivityStats();
}, [loadActivityStats]); }, [isAdminMode, loadActivityStats]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) return;
setBotListPage(1); setBotListPage(1);
}, [search, botListPageSize]); }, [botListPageSize, isManagementMode, search]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) return;
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount)); setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
}, [botListPageCount]); }, [botListPageCount, isManagementMode]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) return;
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id); if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
}, [filteredBots, selectedBotId]); }, [filteredBots, isManagementMode, selectedBotId]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) {
setShowCompactBotSheet(false);
setCompactSheetClosing(false);
setCompactSheetMounted(false);
return;
}
if (!compactMode) { if (!compactMode) {
setShowCompactBotSheet(false); setShowCompactBotSheet(false);
setCompactSheetClosing(false); setCompactSheetClosing(false);
@ -208,9 +226,14 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
} }
if (selectedBotId && showCompactBotSheet) return; if (selectedBotId && showCompactBotSheet) return;
if (!selectedBotId) setShowCompactBotSheet(false); if (!selectedBotId) setShowCompactBotSheet(false);
}, [compactMode, selectedBotId, showCompactBotSheet]); }, [compactMode, isManagementMode, selectedBotId, showCompactBotSheet]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) {
setSelectedBotDetail(null);
setSelectedBotUsageSummary(null);
return;
}
if (!selectedBotId) { if (!selectedBotId) {
setSelectedBotDetail(null); setSelectedBotDetail(null);
setSelectedBotUsageSummary(null); setSelectedBotUsageSummary(null);
@ -233,7 +256,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
return () => { return () => {
alive = false; alive = false;
}; };
}, [loadSelectedBotUsageSummary, selectedBotId]); }, [isManagementMode, loadSelectedBotUsageSummary, selectedBotId]);
const resourceBot = useMemo( const resourceBot = useMemo(
() => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined), () => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined),
@ -271,15 +294,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
const usageSummary = usageData?.summary || overview?.usage.summary; const usageSummary = usageData?.summary || overview?.usage.summary;
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null; const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
const memoryPercent =
overviewResources && overviewResources.live_memory_limit_bytes > 0
? clampPlatformPercent((overviewResources.live_memory_used_bytes / overviewResources.live_memory_limit_bytes) * 100)
: 0;
const storagePercent =
overviewResources && overviewResources.workspace_limit_bytes > 0
? clampPlatformPercent((overviewResources.workspace_used_bytes / overviewResources.workspace_limit_bytes) * 100)
: 0;
const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>( const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>(
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh), () => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
[isZh, usageAnalytics], [isZh, usageAnalytics],
@ -296,10 +310,13 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]); const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]);
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
const jobs: Promise<unknown>[] = [loadOverview(), loadBots(), loadUsage(usagePage), loadActivityStats()]; const jobs: Promise<unknown>[] = [loadOverview(), loadBots()];
if (isAdminMode) {
jobs.push(loadUsage(), loadActivityStats());
}
if (selectedBotId) jobs.push(loadSelectedBotUsageSummary(selectedBotId)); if (selectedBotId) jobs.push(loadSelectedBotUsageSummary(selectedBotId));
await Promise.allSettled(jobs); await Promise.allSettled(jobs);
}, [loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId, usagePage]); }, [isAdminMode, loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId]);
const toggleBot = useCallback(async (bot: BotState) => { const toggleBot = useCallback(async (bot: BotState) => {
setOperatingBotId(bot.id); setOperatingBotId(bot.id);
@ -392,6 +409,15 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
}, [loadResourceSnapshot]); }, [loadResourceSnapshot]);
useEffect(() => { useEffect(() => {
if (!isManagementMode) {
setCompactSheetMounted(false);
setCompactSheetClosing(false);
if (compactSheetTimerRef.current) {
window.clearTimeout(compactSheetTimerRef.current);
compactSheetTimerRef.current = null;
}
return;
}
if (compactMode && showCompactBotSheet && selectedBotInfo) { if (compactMode && showCompactBotSheet && selectedBotInfo) {
if (compactSheetTimerRef.current) { if (compactSheetTimerRef.current) {
window.clearTimeout(compactSheetTimerRef.current); window.clearTimeout(compactSheetTimerRef.current);
@ -414,7 +440,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
compactSheetTimerRef.current = null; compactSheetTimerRef.current = null;
} }
}; };
}, [compactMode, compactSheetMounted, selectedBotInfo, showCompactBotSheet]); }, [compactMode, compactSheetMounted, isManagementMode, selectedBotInfo, showCompactBotSheet]);
useEffect(() => { useEffect(() => {
if (!showResourceModal || !resourceBotId) return; if (!showResourceModal || !resourceBotId) return;
@ -444,18 +470,9 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer'); window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer');
}, []); }, []);
const handlePlatformSettingsSaved = useCallback((settings: PlatformSettings) => {
setOverview((prev) => (prev ? { ...prev, settings } : prev));
const normalizedPageSize = normalizePlatformPageSize(settings.page_size, 10);
writeCachedPlatformPageSize(normalizedPageSize);
setUsagePageSize(normalizedPageSize);
setBotListPageSize(normalizedPageSize);
}, []);
const closeResourceModal = useCallback(() => setShowResourceModal(false), []); const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
return { return {
botList,
botListPage, botListPage,
botListPageCount, botListPageCount,
botListPageSize, botListPageSize,
@ -465,13 +482,11 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
compactSheetClosing, compactSheetClosing,
compactSheetMounted, compactSheetMounted,
filteredBots, filteredBots,
handlePlatformSettingsSaved,
handleSelectBot, handleSelectBot,
isZh, isZh,
lastActionPreview, lastActionPreview,
loadResourceSnapshot, loadResourceSnapshot,
loading, loading,
memoryPercent,
openBotPanel, openBotPanel,
openResourceMonitor, openResourceMonitor,
operatingBotId, operatingBotId,
@ -496,9 +511,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
setSearch, setSearch,
setShowBotLastActionModal, setShowBotLastActionModal,
showBotLastActionModal, showBotLastActionModal,
showCompactBotSheet,
showResourceModal, showResourceModal,
storagePercent,
toggleBot, toggleBot,
usageAnalytics, usageAnalytics,
activityStats, activityStats,
@ -506,9 +519,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
usageAnalyticsMax, usageAnalyticsMax,
usageAnalyticsSeries, usageAnalyticsSeries,
usageAnalyticsTicks, usageAnalyticsTicks,
usageData,
usageLoading, usageLoading,
usagePage,
usageSummary, usageSummary,
}; };
} }

View File

@ -2,12 +2,39 @@ export interface PlatformSettings {
page_size: number; page_size: number;
chat_pull_page_size: number; chat_pull_page_size: number;
command_auto_unlock_seconds: number; command_auto_unlock_seconds: number;
auth_token_ttl_hours: number;
auth_token_max_active: number;
upload_max_mb: number; upload_max_mb: number;
allowed_attachment_extensions: string[]; allowed_attachment_extensions: string[];
workspace_download_extensions: string[]; workspace_download_extensions: string[];
speech_enabled: boolean; speech_enabled: boolean;
} }
export interface PlatformLoginLogItem {
id: number;
auth_type: string;
subject_id: string;
bot_id?: string | null;
auth_source: string;
client_ip?: string | null;
user_agent?: string | null;
device_info?: string | null;
created_at: string;
last_seen_at?: string | null;
expires_at?: string | null;
revoked_at?: string | null;
revoke_reason?: string | null;
status: string;
}
export interface PlatformLoginLogResponse {
items: PlatformLoginLogItem[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface SystemSettingItem { export interface SystemSettingItem {
key: string; key: string;
name: string; name: string;

View File

@ -0,0 +1,18 @@
export function parseBotTimestamp(raw?: string | number | null) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
export function sortBotsByCreatedAtDesc<T extends { id?: string | number | null; created_at?: string | number | null }>(
bots: readonly T[],
): T[] {
return [...bots].sort((left, right) => {
const leftCreated = parseBotTimestamp(left.created_at);
const rightCreated = parseBotTimestamp(right.created_at);
if (leftCreated !== rightCreated) return rightCreated - leftCreated;
return String(left.id || '').localeCompare(String(right.id || ''));
});
}

View File

@ -1,5 +1,3 @@
import type { BotEvent, ChatMessage } from '../../types/bot';
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g; const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。、“”《》【】—…·\-_./:\\,%+*='"`|<>]/g; const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。、“”《》【】—…·\-_./:\\,%+*='"`|<>]/g;
@ -7,31 +5,15 @@ const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi; const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi; const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi;
function cleanLine(line: string) {
return line
.replace(OSC_RE, '')
.replace(ANSI_RE, '')
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
.replace(/\[(\d+)?K/g, '')
.replace(NON_TEXT_RE, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function normalizeUserMessageText(input: string) { export function normalizeUserMessageText(input: string) {
let text = (input || '').replace(/\r\n/g, '\n').trim(); let text = (input || '').replace(/\r\n/g, '\n').trim();
if (!text) return ''; if (!text) return '';
// Keep attachment list out of editable/visible command text.
text = text.replace(ATTACHMENT_BLOCK_RE, '').trim(); text = text.replace(ATTACHMENT_BLOCK_RE, '').trim();
// Keep quoted assistant context hidden in chat bubbles, but still allow backend delivery.
text = text.replace(QUOTED_REPLY_BLOCK_RE, '').trim(); text = text.replace(QUOTED_REPLY_BLOCK_RE, '').trim();
// Strip role prefixes injected by some gateways, e.g. "You: ...".
text = text.replace(/(^|\n)\s*(you|user|你)\s*[:]\s*/gi, '$1').trim(); text = text.replace(/(^|\n)\s*(you|user|你)\s*[:]\s*/gi, '$1').trim();
text = text.replace(/\n{3,}/g, '\n\n'); text = text.replace(/\n{3,}/g, '\n\n');
// Collapse duplicate echoes like "xxx You: xxx" / "xxx xxx".
const flat = text.replace(/\s+/g, ' ').trim(); const flat = text.replace(/\s+/g, ' ').trim();
const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|)\s*[:]\s*\1$/iu); const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|)\s*[:]\s*\1$/iu);
if (prefixedRepeat) return prefixedRepeat[1].trim(); if (prefixedRepeat) return prefixedRepeat[1].trim();
@ -51,15 +33,10 @@ export function normalizeAssistantMessageText(input: string) {
.trim(); .trim();
if (!text) return ''; if (!text) return '';
// Remove dashboard wrapper if channel accidentally outputs raw marker line.
text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim(); text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim();
text = text.replace(/<\/?tool_call>/gi, '').trim(); text = text.replace(/<\/?tool_call>/gi, '').trim();
// Keep HTML-enabled markdown readable: cap excessive HTML line breaks/empty paragraphs.
text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>'); text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>');
text = text.replace(/(?:<p>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>\s*){3,}/gi, '<p><br></p>'); text = text.replace(/(?:<p>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>\s*){3,}/gi, '<p><br></p>');
// Reduce excessive blank lines while keeping markdown readability.
text = text.replace(/\n{4,}/g, '\n\n\n'); text = text.replace(/\n{4,}/g, '\n\n\n');
return text; return text;
} }
@ -96,8 +73,8 @@ export function summarizeProgressText(input: string, isZh: boolean) {
if (!raw) return isZh ? '处理中...' : 'Processing...'; if (!raw) return isZh ? '处理中...' : 'Processing...';
const firstLine = raw const firstLine = raw
.split('\n') .split('\n')
.map((v) => v.trim()) .map((value) => value.trim())
.find((v) => v.length > 0); .find((value) => value.length > 0);
const line = (firstLine || raw) const line = (firstLine || raw)
.replace(/[`*_>#|\[\]\(\)]/g, ' ') .replace(/[`*_>#|\[\]\(\)]/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@ -106,43 +83,13 @@ export function summarizeProgressText(input: string, isZh: boolean) {
return line.length > 96 ? `${line.slice(0, 96)}...` : line; return line.length > 96 ? `${line.slice(0, 96)}...` : line;
} }
/** export function cleanBotLogLine(line: string) {
* return line
* AI线 .replace(OSC_RE, '')
*/ .replace(ANSI_RE, '')
export function parseLogToArtifacts( .replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
raw: string, .replace(/\[(\d+)?K/g, '')
ts: number = Date.now(), .replace(NON_TEXT_RE, ' ')
): { message?: ChatMessage; event?: BotEvent } | null { .replace(/\s+/g, ' ')
const line = cleanLine(raw); .trim();
if (!line || line.length < 3) return null;
const lower = line.toLowerCase();
// 1. 忽略结构化标签、系统日志和心跳干扰
if (
lower.includes('__dashboard_data') ||
lower.includes('litellm') ||
lower.includes('heartbeat') ||
lower.includes('starting nanobot gateway')
) {
return null;
}
// 2. 仅提取思考/工具执行状态
if (lower.includes('nanobot is thinking')) {
return { event: { state: 'THINKING', text: 'Thinking', ts } };
}
const toolMatch = line.match(/execut(?:e|ing) tool[:\s]+([\w\-./]+)/i);
if (toolMatch) {
return { event: { state: 'TOOL_CALL', text: `Executing Tool: ${toolMatch[1]}`, ts } };
}
// 3. 错误状态提取
if (lower.includes('traceback') || (lower.includes('error') && !lower.includes('no error'))) {
return { event: { state: 'ERROR', text: 'Execution Error', ts } };
}
// 绝对不返回 message 对象
return null;
} }

Some files were not shown because too many files have changed in this diff Show More