v0.1.4-p5
parent
95e3fd6c38
commit
ca1f941e4c
|
|
@ -50,6 +50,12 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
|||
# Panel access protection
|
||||
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)
|
||||
UPLOAD_MAX_MB=200
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ REDIS_DEFAULT_TTL=60
|
|||
|
||||
# Optional panel-level access password for all backend API/WS calls.
|
||||
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 / 平台参数:
|
||||
# - page_size
|
||||
# - chat_pull_page_size
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.database import get_session
|
||||
from models.bot import BotInstance
|
||||
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 (
|
||||
authenticate_bot_page_access,
|
||||
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")
|
||||
def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)):
|
||||
return authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password)
|
||||
def login_bot_page(
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -1,73 +1,27 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from sqlmodel import Session
|
||||
|
||||
from core.database import engine, get_session
|
||||
from core.docker_instance import docker_manager
|
||||
from core.settings import BOTS_WORKSPACE_ROOT
|
||||
from core.websocket_manager import manager
|
||||
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, _write_bot_config
|
||||
from services.bot_storage_service import _read_cron_store, _write_cron_store
|
||||
from services.bot_runtime_service import (
|
||||
delete_cron_job as delete_cron_job_service,
|
||||
ensure_monitor_websocket_access,
|
||||
get_bot_logs as get_bot_logs_service,
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
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")
|
||||
def get_bot_logs(
|
||||
bot_id: str,
|
||||
|
|
@ -77,150 +31,72 @@ def get_bot_logs(
|
|||
reverse: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_bot_or_404(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),
|
||||
try:
|
||||
return get_bot_logs_service(
|
||||
session,
|
||||
bot_id=bot_id,
|
||||
tail=tail,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
reverse=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)}
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/bots/{bot_id}/weixin/relogin")
|
||||
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:
|
||||
if state_file.is_file():
|
||||
state_file.unlink()
|
||||
removed = True
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=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,
|
||||
}
|
||||
return await relogin_weixin_service(session, bot_id=bot_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/api/bots/{bot_id}/cron/jobs")
|
||||
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
|
||||
_get_bot_or_404(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}
|
||||
try:
|
||||
return list_cron_jobs_service(session, bot_id=bot_id, include_disabled=include_disabled)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@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)):
|
||||
_get_bot_or_404(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
jobs = store.get("jobs", [])
|
||||
if not isinstance(jobs, list):
|
||||
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}
|
||||
try:
|
||||
return stop_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@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)):
|
||||
_get_bot_or_404(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
jobs = store.get("jobs", [])
|
||||
if not isinstance(jobs, list):
|
||||
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}
|
||||
try:
|
||||
return start_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@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)):
|
||||
_get_bot_or_404(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 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}
|
||||
try:
|
||||
return delete_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.websocket("/ws/monitor/{bot_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
||||
with Session(engine) as session:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
try:
|
||||
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")
|
||||
return
|
||||
|
||||
|
|
@ -240,6 +116,15 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
|||
try:
|
||||
while True:
|
||||
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:
|
||||
pass
|
||||
except RuntimeError as exc:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from services.platform_service import (
|
|||
get_bot_activity_stats,
|
||||
get_platform_settings,
|
||||
list_system_settings,
|
||||
list_login_logs,
|
||||
list_activity_events,
|
||||
list_usage,
|
||||
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)}
|
||||
|
||||
|
||||
@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")
|
||||
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
||||
return {"items": list_system_settings(session, search=search)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlmodel import Session, select
|
||||
|
||||
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 schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
|
||||
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.template_service import (
|
||||
get_agent_md_templates,
|
||||
|
|
@ -21,19 +28,37 @@ router = APIRouter()
|
|||
|
||||
|
||||
@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()
|
||||
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")
|
||||
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()
|
||||
if not configured:
|
||||
clear_panel_token_cookie(response)
|
||||
return {"success": True, "enabled": False}
|
||||
supplied = str(payload.password or "").strip()
|
||||
if supplied != configured:
|
||||
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")
|
||||
def get_system_defaults():
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from api.workspace_router import router as workspace_router
|
|||
from bootstrap.app_runtime import register_app_runtime
|
||||
from core.auth_middleware import PasswordProtectionMiddleware
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -33,9 +33,10 @@ def create_app() -> FastAPI:
|
|||
app.add_middleware(PasswordProtectionMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=list(CORS_ALLOWED_ORIGINS),
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(platform_router)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,125 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from core.settings import PANEL_ACCESS_PASSWORD
|
||||
from services.bot_storage_service import _read_bot_config
|
||||
|
||||
PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password"
|
||||
BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password"
|
||||
BOT_PANEL_ONLY_SUFFIXES = {"/enable", "/disable", "/deactivate"}
|
||||
from bootstrap.auth_access import RouteAccessMode, extract_bot_id, resolve_route_access_mode
|
||||
from core.database import engine
|
||||
from services.platform_auth_service import (
|
||||
resolve_bot_request_auth,
|
||||
resolve_panel_request_auth,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
raw = str(path or "").strip()
|
||||
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
|
||||
def _unauthorized(detail: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=401, content={"detail": detail})
|
||||
|
||||
|
||||
class PasswordProtectionMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
method = request.method.upper()
|
||||
|
||||
if method == "OPTIONS":
|
||||
access_mode = resolve_route_access_mode(path, request.method)
|
||||
if access_mode == RouteAccessMode.PUBLIC:
|
||||
return await call_next(request)
|
||||
|
||||
bot_id = _extract_bot_id_from_api_path(path)
|
||||
if not bot_id:
|
||||
if _is_panel_protected_api_path(path, method):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if panel_error:
|
||||
return JSONResponse(status_code=401, content={"detail": panel_error})
|
||||
return await call_next(request)
|
||||
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 _is_bot_panel_management_api_path(path, method):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if panel_error:
|
||||
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})
|
||||
if access_mode == RouteAccessMode.PANEL_ONLY:
|
||||
return _unauthorized("Panel authentication required")
|
||||
|
||||
return await call_next(request)
|
||||
if not bot_id:
|
||||
return _unauthorized("Bot authentication required")
|
||||
|
||||
bot_principal = resolve_bot_request_auth(session, request, bot_id)
|
||||
if bot_principal.authenticated:
|
||||
request.state.auth_principal = bot_principal
|
||||
return await call_next(request)
|
||||
|
||||
if access_mode == RouteAccessMode.PUBLIC_BOT_OR_PANEL:
|
||||
return _unauthorized("Bot or panel authentication required to access this resource")
|
||||
return _unauthorized("Bot or panel authentication required")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
|
|
@ -10,10 +12,10 @@ except Exception: # pragma: no cover
|
|||
|
||||
|
||||
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.prefix = REDIS_PREFIX
|
||||
self.default_ttl = int(REDIS_DEFAULT_TTL)
|
||||
self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX
|
||||
self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL)
|
||||
self._client: Optional["Redis"] = None
|
||||
if self.enabled:
|
||||
try:
|
||||
|
|
@ -34,11 +36,28 @@ class RedisCache:
|
|||
except Exception:
|
||||
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:
|
||||
if not self.enabled or self._client is None:
|
||||
return None
|
||||
try:
|
||||
raw = self._client.get(self._full_key(key))
|
||||
raw = self.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
|
@ -49,11 +68,46 @@ class RedisCache:
|
|||
if not self.enabled or self._client is None:
|
||||
return
|
||||
try:
|
||||
self._client.setex(
|
||||
self._full_key(key),
|
||||
int(ttl if ttl is not None else self.default_ttl),
|
||||
json.dumps(value, ensure_ascii=False, default=str),
|
||||
)
|
||||
self.set(key, json.dumps(value, ensure_ascii=False, default=str), ttl=ttl)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
|
|
@ -85,4 +139,4 @@ class RedisCache:
|
|||
|
||||
|
||||
cache = RedisCache()
|
||||
|
||||
auth_cache = RedisCache(prefix_override=f"{REDIS_PREFIX}_auth")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from core.settings import (
|
|||
)
|
||||
|
||||
# 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 platform as _platform_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_REQUEST_USAGE_TABLE = "bot_request_usage"
|
||||
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"
|
||||
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
||||
|
||||
|
|
@ -58,6 +61,14 @@ def _release_migration_lock(lock_conn) -> None:
|
|||
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:
|
||||
required_columns = {
|
||||
"current_state": "TEXT DEFAULT 'IDLE'",
|
||||
|
|
@ -133,6 +144,34 @@ def _ensure_bot_request_usage_columns() -> None:
|
|||
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:
|
||||
required_columns = {
|
||||
"topic_topic": {
|
||||
|
|
@ -215,6 +254,7 @@ def align_postgres_sequences() -> None:
|
|||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
sequence_targets = [
|
||||
(SYS_LOGIN_LOG_TABLE, "id"),
|
||||
(BOT_MESSAGE_TABLE, "id"),
|
||||
(BOT_REQUEST_USAGE_TABLE, "id"),
|
||||
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
||||
|
|
@ -247,7 +287,9 @@ def align_postgres_sequences() -> None:
|
|||
def init_database() -> None:
|
||||
lock_conn = _acquire_migration_lock()
|
||||
try:
|
||||
_migrate_auth_login_log_table()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_ensure_auth_login_log_columns()
|
||||
_ensure_sys_setting_columns()
|
||||
_ensure_bot_request_usage_columns()
|
||||
_ensure_botinstance_columns()
|
||||
|
|
|
|||
|
|
@ -76,6 +76,32 @@ def _env_extensions(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|||
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:
|
||||
raw = str(path_value or "").strip()
|
||||
if not raw:
|
||||
|
|
@ -158,6 +184,8 @@ DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
|
|||
DEFAULT_PAGE_SIZE: Final[int] = 10
|
||||
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_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(
|
||||
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") 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_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()
|
||||
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_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -7,6 +7,8 @@ class PlatformSettingsPayload(BaseModel):
|
|||
page_size: int = Field(default=10, ge=1, le=100)
|
||||
chat_pull_page_size: int = Field(default=60, ge=10, le=500)
|
||||
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)
|
||||
allowed_attachment_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
|
||||
|
||||
|
||||
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):
|
||||
id: int
|
||||
bot_id: str
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -32,6 +32,8 @@ def default_platform_settings() -> PlatformSettingsPayload:
|
|||
page_size=int(bootstrap["page_size"]),
|
||||
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
|
||||
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"]),
|
||||
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
|
||||
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
|
||||
|
|
@ -52,6 +54,14 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload:
|
|||
1,
|
||||
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["allowed_attachment_extensions"] = _normalize_extension_list(
|
||||
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))),
|
||||
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))),
|
||||
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,
|
||||
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_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
|
||||
|
||||
|
||||
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]:
|
||||
settings = get_platform_settings_snapshot()
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from services.platform_activity_service import (
|
|||
prune_expired_activity_events,
|
||||
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_settings_service import (
|
||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||
|
|
@ -16,6 +17,8 @@ from services.platform_settings_service import (
|
|||
delete_system_setting,
|
||||
ensure_default_system_settings,
|
||||
get_activity_event_retention_days,
|
||||
get_auth_token_max_active,
|
||||
get_auth_token_ttl_hours,
|
||||
get_allowed_attachment_extensions,
|
||||
get_chat_pull_page_size,
|
||||
get_page_size,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from typing import Any, Dict, List
|
|||
from sqlmodel import Session
|
||||
|
||||
from core.settings import (
|
||||
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
|
||||
DEFAULT_AUTH_TOKEN_TTL_HOURS,
|
||||
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
||||
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
|
|
@ -24,6 +26,8 @@ SETTING_KEYS = (
|
|||
"page_size",
|
||||
"chat_pull_page_size",
|
||||
"command_auto_unlock_seconds",
|
||||
"auth_token_ttl_hours",
|
||||
"auth_token_max_active",
|
||||
"upload_max_mb",
|
||||
"allowed_attachment_extensions",
|
||||
"workspace_download_extensions",
|
||||
|
|
@ -38,6 +42,10 @@ DEPRECATED_SETTING_KEYS = {
|
|||
"speech_audio_preprocess",
|
||||
"speech_audio_filter",
|
||||
"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]] = {
|
||||
"page_size": {
|
||||
|
|
@ -67,6 +75,24 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"is_public": True,
|
||||
"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": {
|
||||
"name": "上传大小限制",
|
||||
"category": "upload",
|
||||
|
|
@ -74,7 +100,7 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"value_type": "integer",
|
||||
"value": DEFAULT_UPLOAD_MAX_MB,
|
||||
"is_public": False,
|
||||
"sort_order": 10,
|
||||
"sort_order": 20,
|
||||
},
|
||||
"allowed_attachment_extensions": {
|
||||
"name": "允许附件后缀",
|
||||
|
|
@ -197,6 +223,18 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]:
|
|||
1,
|
||||
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),
|
||||
"allowed_attachment_extensions": _legacy_env_extensions(
|
||||
"ALLOWED_ATTACHMENT_EXTENSIONS",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from services.platform_runtime_settings_service import (
|
||||
get_auth_token_max_active,
|
||||
get_auth_token_ttl_hours,
|
||||
default_platform_settings,
|
||||
get_allowed_attachment_extensions,
|
||||
get_chat_pull_page_size,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
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:
|
||||
bootstrap_values = _bootstrap_platform_setting_values()
|
||||
legacy_row = session.get(PlatformSetting, "global")
|
||||
|
|
@ -46,15 +55,24 @@ def ensure_default_system_settings(session: Session) -> None:
|
|||
session.delete(legacy_row)
|
||||
session.commit()
|
||||
|
||||
legacy_auth_ttl_hours = None
|
||||
dirty = False
|
||||
for key in DEPRECATED_SETTING_KEYS:
|
||||
legacy_row = session.get(PlatformSetting, key)
|
||||
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)
|
||||
dirty = True
|
||||
|
||||
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
|
||||
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:
|
||||
_upsert_setting_row(
|
||||
session,
|
||||
|
|
@ -63,22 +81,42 @@ def ensure_default_system_settings(session: Session) -> None:
|
|||
category=str(meta["category"]),
|
||||
description=str(meta["description"]),
|
||||
value_type=str(meta["value_type"]),
|
||||
value=bootstrap_values.get(key, meta["value"]),
|
||||
value=default_value,
|
||||
is_public=bool(meta["is_public"]),
|
||||
sort_order=int(meta["sort_order"]),
|
||||
)
|
||||
dirty = True
|
||||
continue
|
||||
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"):
|
||||
value = str(meta[field])
|
||||
if not getattr(row, field):
|
||||
if key in PROTECTED_SETTING_KEYS:
|
||||
if getattr(row, field) != value:
|
||||
setattr(row, field, value)
|
||||
changed = True
|
||||
elif not getattr(row, field):
|
||||
setattr(row, field, value)
|
||||
changed = True
|
||||
if getattr(row, "sort_order", None) is None:
|
||||
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"])
|
||||
changed = True
|
||||
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 getattr(row, "is_public", None) is None:
|
||||
if key not in PROTECTED_SETTING_KEYS and getattr(row, "is_public", None) is None:
|
||||
row.is_public = bool(meta["is_public"])
|
||||
changed = True
|
||||
if changed:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- 真正跨领域的通用工具
|
||||
|
||||
目录分层的目标是稳定边界,不是把每一段逻辑都拆成独立文件:
|
||||
|
||||
- 同一页面域内强关联的视图、状态、交互逻辑,允许在同一模块内靠近放置
|
||||
- 只有当某段逻辑已经被多个页面或多个子流程稳定复用时,才提炼到更高层级
|
||||
- 禁止为了“文件更短”而把一个连续可读的页面流程拆成大量来回跳转的小文件
|
||||
|
||||
### 2.2 页面文件职责
|
||||
|
||||
页面文件如:
|
||||
|
|
@ -83,12 +96,14 @@
|
|||
- 只做页面装配
|
||||
- 只组织已有区块、弹层、控制器 hook
|
||||
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
|
||||
- 如果一个页面本身就是单一业务域,并且逻辑连续可读,可以保留适量页面内状态与事件处理
|
||||
- 不要求为了行数把本来紧密耦合的页面逻辑强拆到多个 hooks / sections / shared 文件中
|
||||
|
||||
页面文件目标体量:
|
||||
|
||||
- 目标:`< 500` 行
|
||||
- 可接受上限:`800` 行
|
||||
- 超过 `800` 行必须优先拆出页面控制器 hook 或区块装配组件
|
||||
- 行数只作为预警,不作为硬性拆分依据
|
||||
- 先判断页面是否仍然属于单一业务域、是否能顺序读懂、依赖是否清晰
|
||||
- 只有在页面同时承担多个子域、多个弹层流程、多个数据源编排时,才优先拆出页面控制器 hook 或区块装配组件
|
||||
|
||||
### 2.3 控制器 hook 规范
|
||||
|
||||
|
|
@ -111,12 +126,15 @@
|
|||
- 一个 hook 只服务一个明确页面或一个明确子流程
|
||||
- hook 不直接产出大量 JSX
|
||||
- hook 内部允许组合更小的子 hook,但不要为了拆分而拆分
|
||||
- 如果页面逻辑并不复杂,不要求必须抽出“页面总 hook”
|
||||
- 只有当副作用编排、状态联动、接口交互已经影响页面可读性时,才值得抽成控制器 hook
|
||||
|
||||
控制器 hook 目标体量:
|
||||
|
||||
- 目标:`< 800` 行
|
||||
- 可接受上限:`1000` 行
|
||||
- 超过 `1000` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api`
|
||||
- 行数只作为风险提示
|
||||
- 优先保证 hook 的流程连续、命名清晰、状态收口明确
|
||||
- 如果继续拆分只会让调用链更深、上下文更难追踪,则不应继续拆
|
||||
- 只有当 hook 明显同时承载多个子流程时,才按主题拆成子 hook 或把稳定复用逻辑提到 `shared`/`api`
|
||||
|
||||
### 2.4 视图组件规范
|
||||
|
||||
|
|
@ -130,6 +148,7 @@
|
|||
- 视图组件默认不直接请求接口
|
||||
- 视图组件只接收已经整理好的 props
|
||||
- 纯视图组件内部不保留与页面强耦合的业务缓存
|
||||
- 不要求把所有小片段都抽成组件;只在存在明确复用、明显视觉区块、或能显著降低页面噪音时再拆组件
|
||||
|
||||
### 2.5 前端复用原则
|
||||
|
||||
|
|
@ -137,6 +156,8 @@
|
|||
- 三处以上重复,优先考虑抽取
|
||||
- 同域复用优先放 `modules/<domain>/shared`
|
||||
- 跨域复用优先放 `src/components` 或 `src/utils`
|
||||
- 如果抽取后的接口比原地实现更难理解,就不应抽取
|
||||
- 不允许创建只有单个页面使用、但又被过度包装的“伪复用层”
|
||||
|
||||
### 2.6 前端禁止事项
|
||||
|
||||
|
|
@ -144,6 +165,8 @@
|
|||
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
|
||||
- 禁止创建无明确职责的超通用组件
|
||||
- 禁止为减少行数而做不可读的过度抽象
|
||||
- 禁止为了满足结构指标,把单一页面域强拆成大量细碎 hooks、sections、shared 文件
|
||||
- 禁止新增纯转发、纯包装、无独立语义价值的组件或 hook
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -211,7 +234,7 @@ Router 文件体量规则:
|
|||
|
||||
### 3.4 Service 规范
|
||||
|
||||
Service 必须按业务主题拆分。
|
||||
Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。
|
||||
|
||||
允许的 service 类型:
|
||||
|
||||
|
|
@ -226,15 +249,18 @@ Service 必须按业务主题拆分。
|
|||
|
||||
Service 文件规则:
|
||||
|
||||
- 一个文件只负责一个主题
|
||||
- 同一文件内允许有私有 helper,但 helper 只能服务当前主题
|
||||
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service
|
||||
- 一个文件只负责一个业务域或一个稳定子主题
|
||||
- 同一文件内允许同时包含该域内的查询、写入、校验、少量派生逻辑
|
||||
- 同一文件内允许有私有 helper,但 helper 只能服务当前域
|
||||
- 只有当一个文件已经明显跨域,或者把 router/core/provider 的职责卷入进来时,才必须继续拆分
|
||||
- 不允许为了“看起来更模块化”而创建纯转发、纯 re-export、纯别名性质的 service 层
|
||||
|
||||
Service 体量规则:
|
||||
|
||||
- 目标:`< 350` 行
|
||||
- 可接受上限:`500` 行
|
||||
- 超过 `500` 行必须继续拆
|
||||
- 行数只作为预警信号,不作为机械拆分依据
|
||||
- 优先判断是否仍然保持单一业务域、可顺序阅读、依赖方向清晰
|
||||
- 如果一个文件虽然较大,但域边界稳定、跳转成本低、上下文连续,可以保留
|
||||
- 如果一个文件即使不大,但已经跨域、跨层、混入无关职责,也必须拆分
|
||||
|
||||
### 3.6 Schema 规范
|
||||
|
||||
|
|
@ -290,6 +316,7 @@ Service 体量规则:
|
|||
- 禁止回到“大文件集中堆功能”的开发方式
|
||||
- 禁止为了图省事把新逻辑加回兼容层
|
||||
- 禁止在没有明确复用收益时过度抽象
|
||||
- 禁止为了满足行数指标而把同一业务域强行拆碎
|
||||
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Backend API entry
|
||||
VITE_API_BASE=http://localhost:8000/api
|
||||
VITE_API_BASE=/api
|
||||
|
||||
# Backend WebSocket entry
|
||||
VITE_WS_BASE=ws://localhost:8000/ws/monitor
|
||||
VITE_WS_BASE=/ws/monitor
|
||||
|
|
|
|||
|
|
@ -1075,3 +1075,23 @@ body {
|
|||
max-height: 84vh;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, SunMedium, X } from 'lucide-react';
|
||||
import { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, ShieldCheck, 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 { APP_ENDPOINTS } from './config/env';
|
||||
import { useBotsSync } from './hooks/useBotsSync';
|
||||
import { appEn } from './i18n/app.en';
|
||||
import { appZhCn } from './i18n/app.zh-cn';
|
||||
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 { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
|
||||
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
|
||||
import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
|
||||
import './components/ui/SharedUi.css';
|
||||
import './App.css';
|
||||
import './App.h5.css';
|
||||
|
||||
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';
|
||||
|
||||
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 { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||
|
|
@ -40,13 +46,6 @@ function AuthenticatedApp() {
|
|||
const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false);
|
||||
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
|
||||
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 : '';
|
||||
useBotsSync(forcedBotId || undefined);
|
||||
|
|
@ -65,9 +64,6 @@ function AuthenticatedApp() {
|
|||
const forcedBotName = String(forcedBot?.name || '').trim();
|
||||
const forcedBotIdLabel = String(forcedBotId || '').trim();
|
||||
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 showNavRail = 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}`;
|
||||
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
setSingleBotUnlocked(false);
|
||||
setSingleBotPassword('');
|
||||
setSingleBotPasswordError('');
|
||||
}, [forcedBotId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBotPanelDrawerEntry) {
|
||||
setBotPanelDrawerOpen(false);
|
||||
|
|
@ -98,52 +88,6 @@ function AuthenticatedApp() {
|
|||
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
|
||||
}, [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 drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
|
||||
const drawerBotId = String(forcedBotId || '').trim() || '-';
|
||||
|
|
@ -171,6 +115,7 @@ function AuthenticatedApp() {
|
|||
label: 'System',
|
||||
items: [
|
||||
{ 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-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 },
|
||||
{ kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes },
|
||||
|
|
@ -188,28 +133,32 @@ function AuthenticatedApp() {
|
|||
const renderRoutePage = () => {
|
||||
switch (route.kind) {
|
||||
case 'admin-dashboard':
|
||||
return <PlatformAdminDashboardPage compactMode={compactMode} />;
|
||||
return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
|
||||
case 'admin-bots':
|
||||
return <PlatformBotManagementPage compactMode={compactMode} />;
|
||||
return <LazyPlatformBotManagementPage compactMode={compactMode} />;
|
||||
case 'system-skills':
|
||||
return <SkillMarketManagerPage isZh={isZh} />;
|
||||
return <LazySkillMarketManagerPage isZh={isZh} />;
|
||||
case 'system-login-logs':
|
||||
return <LazyPlatformLoginLogPage isZh={isZh} />;
|
||||
case 'system-templates':
|
||||
return <TemplateManagerPage isZh={isZh} />;
|
||||
return <LazyTemplateManagerPage isZh={isZh} />;
|
||||
case 'system-settings':
|
||||
return <PlatformSettingsPage isZh={isZh} />;
|
||||
return <LazyPlatformSettingsPage isZh={isZh} />;
|
||||
case 'system-images':
|
||||
return <PlatformImageManagementPage isZh={isZh} />;
|
||||
return <LazyPlatformImageManagementPage isZh={isZh} />;
|
||||
case 'bot':
|
||||
return (
|
||||
<BotHomePage
|
||||
botId={forcedBotId}
|
||||
compactMode={compactMode}
|
||||
compactPanelTab={botCompactPanelTab}
|
||||
onCompactPanelTabChange={setBotCompactPanelTab}
|
||||
/>
|
||||
<BotRouteAccessGate bot={forcedBot} botId={forcedBotId}>
|
||||
<LazyBotHomePage
|
||||
botId={forcedBotId}
|
||||
compactMode={compactMode}
|
||||
compactPanelTab={botCompactPanelTab}
|
||||
onCompactPanelTabChange={setBotCompactPanelTab}
|
||||
/>
|
||||
</BotRouteAccessGate>
|
||||
);
|
||||
default:
|
||||
return <PlatformAdminDashboardPage compactMode={compactMode} />;
|
||||
return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -343,7 +292,9 @@ function AuthenticatedApp() {
|
|||
</header>
|
||||
|
||||
<main className="main-stage">
|
||||
{renderRoutePage()}
|
||||
<Suspense fallback={<RouteLoadingFallback label={isZh ? '页面加载中...' : 'Loading page...'} />}>
|
||||
{renderRoutePage()}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -458,176 +409,15 @@ function AuthenticatedApp() {
|
|||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
const route = useAppRoute();
|
||||
return (
|
||||
<PanelLoginGate>
|
||||
<AuthenticatedApp />
|
||||
<PanelLoginGate bypass={route.kind === 'bot'}>
|
||||
<AppShell />
|
||||
</PanelLoginGate>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ import axios from 'axios';
|
|||
import { useAppStore } from '../store/appStore';
|
||||
import { APP_ENDPOINTS } from '../config/env';
|
||||
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 { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
|
||||
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' {
|
||||
const s = (v || '').toUpperCase();
|
||||
|
|
@ -152,7 +152,12 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
}
|
||||
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -351,13 +356,16 @@ export function useBotsSync(forcedBotId?: string) {
|
|||
addBotLog(bot.id, String(data.text || ''));
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event) => {
|
||||
const hb = heartbeatsRef.current[bot.id];
|
||||
if (hb) {
|
||||
window.clearInterval(hb);
|
||||
delete heartbeatsRef.current[bot.id];
|
||||
}
|
||||
delete socketsRef.current[bot.id];
|
||||
if (event.code === 4401 && forced === bot.id) {
|
||||
notifyBotAuthInvalid(bot.id);
|
||||
}
|
||||
};
|
||||
|
||||
socketsRef.current[bot.id] = ws;
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@ export function BotDashboardModule({
|
|||
botStarting: dashboard.t.botStarting,
|
||||
botStopping: dashboard.t.botStopping,
|
||||
chatDisabled: dashboard.t.chatDisabled,
|
||||
close: dashboard.t.close,
|
||||
controlCommandsHide: dashboard.t.controlCommandsHide,
|
||||
controlCommandsShow: dashboard.t.controlCommandsShow,
|
||||
copyPrompt: dashboard.t.copyPrompt,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,15 +1,26 @@
|
|||
import type { ComponentProps } from 'react';
|
||||
import { Suspense, lazy, type ComponentProps } from 'react';
|
||||
import { MessageCircle, MessageSquareText, X } from 'lucide-react';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { CreateBotWizardModal } from '../../onboarding/CreateBotWizardModal';
|
||||
import { TopicFeedPanel } from '../topic/TopicFeedPanel';
|
||||
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
||||
import { BotListPanel } from './BotListPanel';
|
||||
import { DashboardChatPanel } from './DashboardChatPanel';
|
||||
import { DashboardModalStack } from './DashboardModalStack';
|
||||
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 {
|
||||
compactMode: boolean;
|
||||
hasForcedBot: boolean;
|
||||
|
|
@ -25,12 +36,12 @@ export interface BotDashboardViewProps {
|
|||
runtimeViewMode: RuntimeViewMode;
|
||||
hasTopicUnread: boolean;
|
||||
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
|
||||
topicFeedPanelProps: ComponentProps<typeof TopicFeedPanel>;
|
||||
topicFeedPanelProps: TopicFeedPanelProps;
|
||||
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
|
||||
runtimePanelProps: ComponentProps<typeof RuntimePanel>;
|
||||
onCompactClose: () => void;
|
||||
dashboardModalStackProps: ComponentProps<typeof DashboardModalStack>;
|
||||
createBotModalProps: ComponentProps<typeof CreateBotWizardModal>;
|
||||
dashboardModalStackProps: DashboardModalStackProps;
|
||||
createBotModalProps: CreateBotWizardModalProps;
|
||||
}
|
||||
|
||||
export function BotDashboardView({
|
||||
|
|
@ -54,6 +65,24 @@ export function BotDashboardView({
|
|||
dashboardModalStackProps,
|
||||
createBotModalProps,
|
||||
}: 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 (
|
||||
<>
|
||||
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
|
||||
|
|
@ -92,7 +121,13 @@ export function BotDashboardView({
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -116,8 +151,16 @@ export function BotDashboardView({
|
|||
</LucentIconButton>
|
||||
) : null}
|
||||
|
||||
<DashboardModalStack {...dashboardModalStackProps} />
|
||||
<CreateBotWizardModal {...createBotModalProps} />
|
||||
{hasDashboardOverlay ? (
|
||||
<Suspense fallback={null}>
|
||||
<LazyDashboardModalStack {...dashboardModalStackProps} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{createBotModalProps.open ? (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateBotWizardModal {...createBotModalProps} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { DrawerShell } from '../../../components/DrawerShell';
|
||||
import { PasswordInput } from '../../../components/PasswordInput';
|
||||
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';
|
||||
|
||||
interface PasswordToggleLabels {
|
||||
|
|
@ -593,334 +593,3 @@ export function ChannelConfigModal({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
||||
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
|
||||
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 { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue';
|
||||
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 {
|
||||
conversation: ChatMessage[];
|
||||
isZh: boolean;
|
||||
|
|
@ -330,8 +290,6 @@ export function DashboardChatPanel({
|
|||
submitActionMode,
|
||||
onSubmitAction,
|
||||
}: DashboardChatPanelProps) {
|
||||
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
||||
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
||||
return (
|
||||
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||
<MemoizedChatTranscript
|
||||
|
|
@ -359,321 +317,60 @@ export function DashboardChatPanel({
|
|||
/>
|
||||
|
||||
<div className="ops-chat-dock">
|
||||
{stagedSubmissions.length > 0 ? (
|
||||
<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>
|
||||
) : 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>
|
||||
<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>
|
||||
<DashboardStagedSubmissionQueue
|
||||
labels={labels}
|
||||
stagedSubmissions={stagedSubmissions}
|
||||
onRestoreStagedSubmission={onRestoreStagedSubmission}
|
||||
onRemoveStagedSubmission={onRemoveStagedSubmission}
|
||||
/>
|
||||
<DashboardChatComposer
|
||||
isZh={isZh}
|
||||
labels={labels}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
canChat={canChat}
|
||||
isChatEnabled={isChatEnabled}
|
||||
speechEnabled={speechEnabled}
|
||||
quotedReply={quotedReply}
|
||||
onClearQuotedReply={onClearQuotedReply}
|
||||
pendingAttachments={pendingAttachments}
|
||||
onRemovePendingAttachment={onRemovePendingAttachment}
|
||||
attachmentUploadPercent={attachmentUploadPercent}
|
||||
isUploadingAttachments={isUploadingAttachments}
|
||||
filePickerRef={filePickerRef}
|
||||
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
||||
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>
|
||||
{!canChat ? (
|
||||
<div className="ops-chat-disabled-mask">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@ import { PlugZap, RefreshCw } from 'lucide-react';
|
|||
|
||||
import { DrawerShell } from '../../../components/DrawerShell';
|
||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||
import { PasswordInput } from '../../../components/PasswordInput';
|
||||
import { buildLlmProviderOptions } from '../../../utils/llmProviders';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { DashboardModalCardShell } from './DashboardModalCardShell';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import type { SystemTimezoneOption } from '../../../utils/systemTimezones';
|
||||
import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types';
|
||||
|
|
@ -46,7 +46,7 @@ export function ResourceMonitorModal({
|
|||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<DashboardModalCardShell
|
||||
<ModalCardShell
|
||||
cardClassName="modal-wide"
|
||||
closeLabel={closeLabel}
|
||||
headerActions={(
|
||||
|
|
@ -111,7 +111,7 @@ export function ResourceMonitorModal({
|
|||
) : (
|
||||
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
||||
)}
|
||||
</DashboardModalCardShell>
|
||||
</ModalCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import remarkGfm from 'remark-gfm';
|
|||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
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 { MARKDOWN_SANITIZE_SCHEMA } from '../constants';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../messageParser';
|
||||
import { workspaceFileAction, formatClock, formatConversationDate } from '../utils';
|
||||
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
|
||||
import { formatClock, formatConversationDate } from '../chat/chatUtils';
|
||||
import './DashboardConversationMessages.css';
|
||||
|
||||
interface DashboardConversationLabels {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
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 { EnvParamsModal } from './DashboardEnvParamsModal';
|
||||
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 { WorkspaceHoverCard } from './WorkspaceHoverCard';
|
||||
import { WorkspacePreviewModal } from './WorkspacePreviewModal';
|
||||
|
||||
interface DashboardModalStackProps {
|
||||
agentFilesModal: ComponentProps<typeof AgentFilesModal>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@ import type { RefObject } from 'react';
|
|||
|
||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||
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 { isPreviewableWorkspaceFile } from '../utils';
|
||||
import type { WorkspaceNode } from '../types';
|
||||
import { WorkspaceEntriesList } from './WorkspaceEntriesList';
|
||||
import './DashboardMenus.css';
|
||||
import './RuntimePanel.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import '../../../components/skill-market/SkillMarketShared.css';
|
|||
import type { BotSkillMarketItem } from '../../platform/types';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||
import { fetchPreferredPlatformPageSize } from '../../platform/api/settings';
|
||||
import { readCachedPlatformPageSize } from '../../../utils/platformPageSize';
|
||||
import { DashboardModalCardShell } from './DashboardModalCardShell';
|
||||
|
||||
interface SkillMarketInstallModalProps {
|
||||
isZh: boolean;
|
||||
|
|
@ -69,7 +69,7 @@ export function SkillMarketInstallModal({
|
|||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<DashboardModalCardShell
|
||||
<ModalCardShell
|
||||
cardClassName="modal-wide platform-modal skill-market-browser-shell"
|
||||
closeLabel={isZh ? '关闭' : 'Close'}
|
||||
headerActions={(
|
||||
|
|
@ -185,6 +185,6 @@ export function SkillMarketInstallModal({
|
|||
</LucentIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardModalCardShell>
|
||||
</ModalCardShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../topic/topicPresetUtils';
|
||||
import type { BotTopic, TopicPresetTemplate } from '../types';
|
||||
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../utils';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { createChannelManager } from './config-managers/channelManager';
|
||||
export { createMcpManager } from './config-managers/mcpManager';
|
||||
export { createTopicManager } from './config-managers/topicManager';
|
||||
|
|
@ -1,30 +1,5 @@
|
|||
import { defaultSchema } from 'rehype-sanitize';
|
||||
|
||||
import type { ChannelType } from './types';
|
||||
|
||||
export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email'];
|
||||
export const RUNTIME_STALE_MS = 45000;
|
||||
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'],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
|||
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { pickLocale } from '../../../i18n';
|
||||
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
|
||||
import { channelsEn } from '../../../i18n/channels.en';
|
||||
import { channelsZhCn } from '../../../i18n/channels.zh-cn';
|
||||
import { dashboardEn } from '../../../i18n/dashboard.en';
|
||||
|
|
@ -12,7 +13,7 @@ import { useDashboardBotEditor } from './useDashboardBotEditor';
|
|||
import { useDashboardBotManagement } from './useDashboardBotManagement';
|
||||
import { useDashboardConfigPanels } from './useDashboardConfigPanels';
|
||||
import { useDashboardConversation } from './useDashboardConversation';
|
||||
import { useDashboardDerivedState } from './useDashboardDerivedState';
|
||||
import { useDashboardBaseState, useDashboardInteractionState } from './useDashboardDerivedState';
|
||||
import { useDashboardLifecycle } from './useDashboardLifecycle';
|
||||
import { useDashboardRuntimeControl } from './useDashboardRuntimeControl';
|
||||
import { useDashboardShellState } from './useDashboardShellState';
|
||||
|
|
@ -20,7 +21,6 @@ import { useDashboardSupportData } from './useDashboardSupportData';
|
|||
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
|
||||
import { useDashboardTemplateManager } from './useDashboardTemplateManager';
|
||||
import { useDashboardVoiceInput } from './useDashboardVoiceInput';
|
||||
import { useDashboardWorkspace } from './useDashboardWorkspace';
|
||||
|
||||
export function useBotDashboardModule({
|
||||
forcedBotId,
|
||||
|
|
@ -381,7 +381,7 @@ export function useBotDashboardModule({
|
|||
workspacePreviewSaving,
|
||||
workspaceQuery,
|
||||
workspaceSearchLoading,
|
||||
} = useDashboardWorkspace({
|
||||
} = useBotWorkspace({
|
||||
selectedBotId,
|
||||
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
||||
workspaceDownloadExtensions,
|
||||
|
|
@ -396,13 +396,15 @@ export function useBotDashboardModule({
|
|||
baseImageOptions,
|
||||
canChat,
|
||||
conversation,
|
||||
displayState,
|
||||
hasTopicUnread,
|
||||
isThinking: isBotThinking,
|
||||
runtimeAction,
|
||||
selectedBotControlState,
|
||||
selectedBotEnabled,
|
||||
systemTimezoneOptions,
|
||||
topicPanelState,
|
||||
} = useDashboardDerivedState({
|
||||
} = useDashboardBaseState({
|
||||
availableImages,
|
||||
controlStateByBot,
|
||||
defaultSystemTimezone,
|
||||
|
|
@ -435,9 +437,7 @@ export function useBotDashboardModule({
|
|||
feedbackSavingByMessageId,
|
||||
filePickerRef,
|
||||
interruptExecution,
|
||||
isCommandAutoUnlockWindowActive,
|
||||
isInterrupting,
|
||||
isTaskRunning,
|
||||
isSendingBlocked,
|
||||
jumpConversationToDate,
|
||||
loadInitialChatPage,
|
||||
|
|
@ -448,7 +448,6 @@ export function useBotDashboardModule({
|
|||
restoreStagedSubmission,
|
||||
removeStagedSubmission,
|
||||
scrollConversationToBottom,
|
||||
send,
|
||||
selectedBotStagedSubmissions,
|
||||
sendControlCommand,
|
||||
setChatDatePickerOpen,
|
||||
|
|
@ -501,28 +500,15 @@ export function useBotDashboardModule({
|
|||
});
|
||||
const {
|
||||
canSendControlCommand,
|
||||
displayState,
|
||||
isChatEnabled,
|
||||
isThinking,
|
||||
runtimeAction,
|
||||
showInterruptSubmitAction,
|
||||
} = useDashboardDerivedState({
|
||||
availableImages,
|
||||
controlStateByBot,
|
||||
defaultSystemTimezone,
|
||||
editFormImageTag: editForm.image_tag,
|
||||
editFormSystemTimezone: editForm.system_timezone,
|
||||
events,
|
||||
isCommandAutoUnlockWindowActive,
|
||||
} = useDashboardInteractionState({
|
||||
canChat,
|
||||
isSendingBlocked,
|
||||
isVoiceRecording,
|
||||
isVoiceTranscribing,
|
||||
isZh,
|
||||
messages,
|
||||
selectedBot,
|
||||
topicFeedUnreadCount,
|
||||
topics,
|
||||
});
|
||||
const isThinking = isBotThinking;
|
||||
|
||||
useDashboardLifecycle({
|
||||
activeTopicOptions,
|
||||
|
|
@ -720,9 +706,6 @@ export function useBotDashboardModule({
|
|||
onVoiceInput,
|
||||
triggerPickAttachments,
|
||||
submitActionMode,
|
||||
isTaskRunning,
|
||||
showInterruptSubmitAction,
|
||||
send,
|
||||
handlePrimarySubmitAction,
|
||||
runtimeMenuOpen,
|
||||
runtimeMenuRef,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +1,16 @@
|
|||
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 { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
||||
import type { QuotedReply, StagedSubmissionDraft } from '../types';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
|
||||
import type { QuotedReply } from '../types';
|
||||
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_MAX_HEIGHT_PX = 220;
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
interface NotifyOptions {
|
||||
title?: string;
|
||||
tone?: PromptTone;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface UseDashboardChatComposerOptions {
|
||||
selectedBotId: string;
|
||||
selectedBot?: { id: string } | null;
|
||||
|
|
@ -31,7 +24,7 @@ interface UseDashboardChatComposerOptions {
|
|||
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
||||
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
||||
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
|
|
@ -54,52 +47,67 @@ export function useDashboardChatComposer({
|
|||
const [command, setCommand] = useState('');
|
||||
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
|
||||
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 composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
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 {
|
||||
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
|
||||
? (hasComposerDraft ? 'stage' : 'interrupt')
|
||||
: '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(() => {
|
||||
setComposerDraftHydrated(false);
|
||||
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 () => {
|
||||
if (!selectedBot || !canChat) return;
|
||||
if (isTaskRunning) {
|
||||
|
|
@ -340,7 +193,7 @@ export function useDashboardChatComposer({
|
|||
await interruptExecution();
|
||||
return;
|
||||
}
|
||||
await send();
|
||||
await sendCurrentDraft();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -352,73 +205,8 @@ export function useDashboardChatComposer({
|
|||
return;
|
||||
}
|
||||
stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id;
|
||||
void sendPayload({
|
||||
commandRaw: nextQueuedSubmission.command,
|
||||
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;
|
||||
});
|
||||
}
|
||||
};
|
||||
void sendQueuedSubmission(nextQueuedSubmission);
|
||||
}, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot, sendQueuedSubmission]);
|
||||
|
||||
const copyUserPrompt = async (text: string) => {
|
||||
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
|
||||
|
|
@ -477,17 +265,14 @@ export function useDashboardChatComposer({
|
|||
filePickerRef,
|
||||
handlePrimarySubmitAction,
|
||||
interruptExecution,
|
||||
isCommandAutoUnlockWindowActive,
|
||||
isInterrupting,
|
||||
isSending,
|
||||
isTaskRunning,
|
||||
isSendingBlocked,
|
||||
onComposerKeyDown,
|
||||
quoteAssistantReply,
|
||||
quotedReply,
|
||||
restoreStagedSubmission,
|
||||
removeStagedSubmission,
|
||||
send,
|
||||
sendControlCommand,
|
||||
setCommand,
|
||||
setQuotedReply,
|
||||
|
|
|
|||
|
|
@ -3,29 +3,14 @@ import axios from 'axios';
|
|||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
||||
import type { BotMessagesByDateResponse } from '../types';
|
||||
import {
|
||||
formatConversationDate,
|
||||
formatDateInputValue,
|
||||
mapBotMessageResponseRow,
|
||||
} from '../utils';
|
||||
|
||||
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;
|
||||
}
|
||||
} from '../chat/chatUtils';
|
||||
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||
import { useDashboardChatMessageActions } from './useDashboardChatMessageActions';
|
||||
|
||||
interface UseDashboardChatHistoryOptions {
|
||||
selectedBotId: string;
|
||||
|
|
@ -36,8 +21,8 @@ interface UseDashboardChatHistoryOptions {
|
|||
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||
confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
|
||||
t: any;
|
||||
isZh: boolean;
|
||||
}
|
||||
|
|
@ -65,8 +50,6 @@ export function useDashboardChatHistory({
|
|||
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
|
||||
const [expandedProgressByKey, setExpandedProgressByKey] = 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 chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
|
@ -91,8 +74,6 @@ export function useDashboardChatHistory({
|
|||
useEffect(() => {
|
||||
setExpandedProgressByKey({});
|
||||
setExpandedUserByKey({});
|
||||
setFeedbackSavingByMessageId({});
|
||||
setDeletingMessageIdMap({});
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDatePanelPosition(null);
|
||||
setChatJumpAnchorId(null);
|
||||
|
|
@ -156,6 +137,30 @@ export function useDashboardChatHistory({
|
|||
.slice(-safeLimit);
|
||||
}, [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 (
|
||||
botId: string,
|
||||
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) => {
|
||||
setExpandedProgressByKey((prev) => ({
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { optionalChannelTypes } from '../constants';
|
||||
import { createChannelManager, createMcpManager } from '../configManagers';
|
||||
import { useCallback, useState, type ChangeEvent } from 'react';
|
||||
import { useDashboardSkillsConfig } from './useDashboardSkillsConfig';
|
||||
import { useDashboardChannelConfig } from './useDashboardChannelConfig';
|
||||
import { useDashboardMcpConfig } from './useDashboardMcpConfig';
|
||||
import { useDashboardTopicConfig } from './useDashboardTopicConfig';
|
||||
import type { BotChannel, MCPServerDraft, TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types';
|
||||
import {
|
||||
buildChannelConfigModalProps,
|
||||
buildCronJobsModalProps,
|
||||
buildEnvParamsModalProps,
|
||||
buildMcpConfigModalProps,
|
||||
} from '../shared/configPanelModalProps';
|
||||
import type { TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types';
|
||||
import { formatCronSchedule } from '../utils';
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
interface NotifyOptions {
|
||||
|
|
@ -107,53 +102,10 @@ export function useDashboardConfigPanels({
|
|||
lc,
|
||||
weixinLoginStatus,
|
||||
}: UseDashboardConfigPanelsOptions) {
|
||||
const [showChannelModal, setShowChannelModal] = useState(false);
|
||||
const [showMcpModal, setShowMcpModal] = useState(false);
|
||||
const [showEnvParamsModal, setShowEnvParamsModal] = 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 [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 {
|
||||
loadTopics,
|
||||
|
|
@ -194,125 +146,58 @@ export function useDashboardConfigPanels({
|
|||
selectedBot,
|
||||
});
|
||||
const {
|
||||
channelDraftUiKey,
|
||||
resetNewChannelDraft,
|
||||
isDashboardChannel,
|
||||
openChannelModal,
|
||||
beginChannelCreate,
|
||||
updateChannelLocal,
|
||||
saveChannel,
|
||||
addChannel,
|
||||
removeChannel,
|
||||
updateGlobalDeliveryFlag,
|
||||
saveGlobalDelivery,
|
||||
} = createChannelManager({
|
||||
selectedBotId,
|
||||
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
||||
t,
|
||||
currentGlobalDelivery: globalDelivery,
|
||||
addableChannelTypes,
|
||||
currentNewChannelDraft: newChannelDraft,
|
||||
refresh,
|
||||
notify,
|
||||
channelConfigModalProps,
|
||||
openChannelConfigModal,
|
||||
resetChannelPanels,
|
||||
} = useDashboardChannelConfig({
|
||||
closeRuntimeMenu,
|
||||
confirm,
|
||||
setShowChannelModal,
|
||||
setChannels,
|
||||
setExpandedChannelByKey,
|
||||
setChannelCreateMenuOpen,
|
||||
setNewChannelPanelOpen,
|
||||
setNewChannelDraft,
|
||||
setIsSavingChannel,
|
||||
setGlobalDelivery,
|
||||
setIsSavingGlobalDelivery,
|
||||
isZh,
|
||||
loadWeixinLoginStatus,
|
||||
notify,
|
||||
passwordToggleLabels,
|
||||
refresh,
|
||||
reloginWeixin,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
t,
|
||||
lc,
|
||||
weixinLoginStatus,
|
||||
});
|
||||
const {
|
||||
resetNewMcpDraft,
|
||||
mcpDraftUiKey,
|
||||
openMcpModal,
|
||||
beginMcpCreate,
|
||||
updateMcpServer,
|
||||
canRemoveMcpServer,
|
||||
saveNewMcpServer,
|
||||
saveSingleMcpServer,
|
||||
removeMcpServer,
|
||||
} = createMcpManager({
|
||||
selectedBotId,
|
||||
isZh,
|
||||
t,
|
||||
currentMcpServers: mcpServers,
|
||||
currentPersistedMcpServers: persistedMcpServers,
|
||||
currentNewMcpDraft: newMcpDraft,
|
||||
notify,
|
||||
mcpConfigModalProps,
|
||||
openMcpConfigModal,
|
||||
prepareMcpForBotChange,
|
||||
resetMcpPanels,
|
||||
} = useDashboardMcpConfig({
|
||||
closeRuntimeMenu,
|
||||
confirm,
|
||||
setShowMcpModal,
|
||||
setMcpServers,
|
||||
setPersistedMcpServers,
|
||||
setExpandedMcpByKey,
|
||||
setNewMcpPanelOpen,
|
||||
setNewMcpDraft,
|
||||
setIsSavingMcp,
|
||||
isZh,
|
||||
notify,
|
||||
passwordToggleLabels,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
t,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
setShowChannelModal(false);
|
||||
setShowMcpModal(false);
|
||||
setShowEnvParamsModal(false);
|
||||
setShowCronModal(false);
|
||||
resetSkillsPanels();
|
||||
setChannels([]);
|
||||
setExpandedChannelByKey({});
|
||||
setNewChannelPanelOpen(false);
|
||||
setChannelCreateMenuOpen(false);
|
||||
resetNewChannelDraft();
|
||||
resetChannelPanels();
|
||||
resetTopicPanels();
|
||||
setExpandedMcpByKey({});
|
||||
setMcpServers([]);
|
||||
setPersistedMcpServers([]);
|
||||
setNewMcpPanelOpen(false);
|
||||
resetNewMcpDraft();
|
||||
resetMcpPanels();
|
||||
setEnvDraftKey('');
|
||||
setEnvDraftValue('');
|
||||
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
|
||||
resetSupportState();
|
||||
}, [resetNewChannelDraft, resetNewMcpDraft, resetSkillsPanels, resetSupportState, resetTopicPanels]);
|
||||
}, [resetChannelPanels, resetMcpPanels, resetSkillsPanels, resetSupportState, resetTopicPanels]);
|
||||
|
||||
const prepareForBotChange = useCallback(() => {
|
||||
resetSkillsPanels();
|
||||
prepareTopicForBotChange();
|
||||
setExpandedMcpByKey({});
|
||||
setNewMcpPanelOpen(false);
|
||||
resetNewMcpDraft();
|
||||
prepareMcpForBotChange();
|
||||
resetSupportState();
|
||||
}, [prepareTopicForBotChange, resetNewMcpDraft, resetSkillsPanels, resetSupportState]);
|
||||
}, [prepareMcpForBotChange, prepareTopicForBotChange, resetSkillsPanels, resetSupportState]);
|
||||
|
||||
const loadInitialConfigData = useCallback(async (botId: string) => {
|
||||
await Promise.all([
|
||||
|
|
@ -329,12 +214,6 @@ export function useDashboardConfigPanels({
|
|||
loadTopicFeedStats,
|
||||
loadTopics,
|
||||
]);
|
||||
const openChannelConfigModal = useCallback(() => {
|
||||
closeRuntimeMenu();
|
||||
if (!selectedBot) return;
|
||||
void loadWeixinLoginStatus(selectedBot.id);
|
||||
openChannelModal(selectedBot.id);
|
||||
}, [closeRuntimeMenu, loadWeixinLoginStatus, openChannelModal, selectedBot]);
|
||||
|
||||
const openEnvParamsConfigModal = useCallback(() => {
|
||||
closeRuntimeMenu();
|
||||
|
|
@ -343,141 +222,67 @@ export function useDashboardConfigPanels({
|
|||
setShowEnvParamsModal(true);
|
||||
}, [closeRuntimeMenu, loadBotEnvParams, selectedBot]);
|
||||
|
||||
const openMcpConfigModal = useCallback(() => {
|
||||
closeRuntimeMenu();
|
||||
if (!selectedBot) return;
|
||||
void openMcpModal(selectedBot.id);
|
||||
}, [closeRuntimeMenu, openMcpModal, selectedBot]);
|
||||
|
||||
const openCronJobsModal = useCallback(() => {
|
||||
closeRuntimeMenu();
|
||||
if (selectedBot) void loadCronJobs(selectedBot.id);
|
||||
setShowCronModal(true);
|
||||
}, [closeRuntimeMenu, loadCronJobs, selectedBot]);
|
||||
|
||||
const channelConfigModalProps = buildChannelConfigModalProps({
|
||||
addableChannelTypes,
|
||||
beginChannelCreate,
|
||||
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 envParamsModalProps = {
|
||||
open: showEnvParamsModal,
|
||||
envEntries,
|
||||
envDraftKey,
|
||||
envDraftValue,
|
||||
labels: {
|
||||
addEnvParam: t.addEnvParam,
|
||||
cancel: t.cancel,
|
||||
close: t.close,
|
||||
envDraftPlaceholderKey: t.envKey,
|
||||
envDraftPlaceholderValue: t.envValue,
|
||||
envParams: t.envParams,
|
||||
envParamsDesc: t.envParamsDesc,
|
||||
envParamsHint: t.envParamsHint,
|
||||
envValue: t.envValue,
|
||||
hideEnvValue: t.hideEnvValue,
|
||||
noEnvParams: t.noEnvParams,
|
||||
removeEnvParam: t.removeEnvParam,
|
||||
save: t.save,
|
||||
showEnvValue: t.showEnvValue,
|
||||
},
|
||||
onClose: () => setShowEnvParamsModal(false),
|
||||
onEnvDraftKeyChange: setEnvDraftKey,
|
||||
onEnvDraftValueChange: setEnvDraftValue,
|
||||
onCreateEnvParam: createEnvParam,
|
||||
onDeleteEnvParam: deleteEnvParam,
|
||||
onSaveEnvParam: saveSingleEnvParam,
|
||||
};
|
||||
|
||||
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,
|
||||
envDraftValue,
|
||||
envEntries,
|
||||
labels: {
|
||||
addEnvParam: t.addEnvParam,
|
||||
cancel: t.cancel,
|
||||
close: t.close,
|
||||
envDraftPlaceholderKey: t.envKey,
|
||||
envDraftPlaceholderValue: t.envValue,
|
||||
envParams: t.envParams,
|
||||
envParamsDesc: t.envParamsDesc,
|
||||
envParamsHint: t.envParamsHint,
|
||||
envValue: t.envValue,
|
||||
hideEnvValue: t.hideEnvValue,
|
||||
noEnvParams: t.noEnvParams,
|
||||
removeEnvParam: t.removeEnvParam,
|
||||
save: t.save,
|
||||
showEnvValue: t.showEnvValue,
|
||||
},
|
||||
onClose: () => setShowEnvParamsModal(false),
|
||||
onCreateEnvParam: createEnvParam,
|
||||
onDeleteEnvParam: deleteEnvParam,
|
||||
onSaveEnvParam: saveSingleEnvParam,
|
||||
setEnvDraftKey,
|
||||
setEnvDraftValue,
|
||||
open: showEnvParamsModal,
|
||||
});
|
||||
|
||||
const cronJobsModalProps = buildCronJobsModalProps({
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
deleteCronJob,
|
||||
isZh,
|
||||
labels: {
|
||||
close: t.close,
|
||||
cronDelete: t.cronDelete,
|
||||
cronDisabled: t.cronDisabled,
|
||||
cronEmpty: t.cronEmpty,
|
||||
cronEnabled: t.cronEnabled,
|
||||
cronLoading: t.cronLoading,
|
||||
cronReload: t.cronReload,
|
||||
cronStart: t.cronStart,
|
||||
cronStop: t.cronStop,
|
||||
cronViewer: t.cronViewer,
|
||||
},
|
||||
loadCronJobs,
|
||||
onClose: () => setShowCronModal(false),
|
||||
selectedBot,
|
||||
startCronJob,
|
||||
stopCronJob,
|
||||
open: showCronModal,
|
||||
});
|
||||
const cronJobsModalProps = {
|
||||
open: showCronModal,
|
||||
cronLoading,
|
||||
cronJobs,
|
||||
cronActionJobId: cronActionJobId || '',
|
||||
cronActionType,
|
||||
isZh,
|
||||
labels: {
|
||||
close: t.close,
|
||||
cronDelete: t.cronDelete,
|
||||
cronDisabled: t.cronDisabled,
|
||||
cronEmpty: t.cronEmpty,
|
||||
cronEnabled: t.cronEnabled,
|
||||
cronLoading: t.cronLoading,
|
||||
cronReload: t.cronReload,
|
||||
cronStart: t.cronStart,
|
||||
cronStop: t.cronStop,
|
||||
cronViewer: t.cronViewer,
|
||||
},
|
||||
formatCronSchedule,
|
||||
onClose: () => setShowCronModal(false),
|
||||
onReload: () => (selectedBot ? loadCronJobs(selectedBot.id) : undefined),
|
||||
onStartJob: startCronJob,
|
||||
onStopJob: stopCronJob,
|
||||
onDeleteJob: deleteCronJob,
|
||||
};
|
||||
|
||||
return {
|
||||
channelConfigModalProps,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,10 @@
|
|||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||
import { useDashboardChatComposer } from './useDashboardChatComposer';
|
||||
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 {
|
||||
selectedBotId: string;
|
||||
selectedBot?: { id: string; messages?: ChatMessage[] } | null;
|
||||
|
|
@ -37,8 +22,8 @@ interface UseDashboardConversationOptions {
|
|||
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
||||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||
confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
|
||||
t: any;
|
||||
isZh: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
|
||||
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
||||
import { mergeConversation } from '../chat/chatUtils';
|
||||
import { RUNTIME_STALE_MS } from '../constants';
|
||||
import { normalizeAssistantMessageText } from '../messageParser';
|
||||
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
||||
import type { BaseImageOption, NanobotImage } from '../types';
|
||||
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
||||
import {
|
||||
mergeConversation,
|
||||
normalizeRuntimeState,
|
||||
parseBotTimestamp,
|
||||
} from '../utils';
|
||||
import { normalizeRuntimeState } from '../utils';
|
||||
|
||||
interface UseDashboardDerivedStateOptions {
|
||||
interface UseDashboardBaseStateOptions {
|
||||
availableImages: NanobotImage[];
|
||||
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
||||
defaultSystemTimezone: string;
|
||||
editFormImageTag: string;
|
||||
editFormSystemTimezone: string;
|
||||
events: any[];
|
||||
isCommandAutoUnlockWindowActive?: boolean;
|
||||
isSendingBlocked?: boolean;
|
||||
isVoiceRecording?: boolean;
|
||||
isVoiceTranscribing?: boolean;
|
||||
isZh: boolean;
|
||||
messages: any[];
|
||||
selectedBot?: any;
|
||||
|
|
@ -29,23 +23,27 @@ interface UseDashboardDerivedStateOptions {
|
|||
topics: any[];
|
||||
}
|
||||
|
||||
export function useDashboardDerivedState({
|
||||
interface UseDashboardInteractionStateOptions {
|
||||
canChat: boolean;
|
||||
isSendingBlocked?: boolean;
|
||||
isVoiceRecording?: boolean;
|
||||
isVoiceTranscribing?: boolean;
|
||||
selectedBot?: any;
|
||||
}
|
||||
|
||||
export function useDashboardBaseState({
|
||||
availableImages,
|
||||
controlStateByBot,
|
||||
defaultSystemTimezone,
|
||||
editFormImageTag,
|
||||
editFormSystemTimezone,
|
||||
events,
|
||||
isCommandAutoUnlockWindowActive = false,
|
||||
isSendingBlocked = false,
|
||||
isVoiceRecording = false,
|
||||
isVoiceTranscribing = false,
|
||||
isZh,
|
||||
messages,
|
||||
selectedBot,
|
||||
topicFeedUnreadCount,
|
||||
topics,
|
||||
}: UseDashboardDerivedStateOptions) {
|
||||
}: UseDashboardBaseStateOptions) {
|
||||
const activeTopicOptions = useMemo<TopicFeedOption[]>(
|
||||
() =>
|
||||
topics
|
||||
|
|
@ -102,10 +100,6 @@ export function useDashboardDerivedState({
|
|||
selectedBot.docker_status === 'RUNNING' &&
|
||||
!selectedBotControlState,
|
||||
);
|
||||
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
|
||||
const canSendControlCommand = Boolean(
|
||||
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
|
||||
);
|
||||
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
|
||||
const systemTimezoneOptions = useMemo(
|
||||
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
|
||||
|
|
@ -175,27 +169,38 @@ export function useDashboardDerivedState({
|
|||
if (eventText) return eventText;
|
||||
return '-';
|
||||
}, [latestEvent, selectedBot]);
|
||||
const showInterruptSubmitAction = Boolean(
|
||||
canChat && ((isThinking && isCommandAutoUnlockWindowActive) || isSendingBlocked),
|
||||
);
|
||||
const hasTopicUnread = topicFeedUnreadCount > 0;
|
||||
|
||||
return {
|
||||
activeTopicOptions,
|
||||
baseImageOptions,
|
||||
canChat,
|
||||
canSendControlCommand,
|
||||
conversation,
|
||||
displayState,
|
||||
hasTopicUnread,
|
||||
isChatEnabled,
|
||||
isThinking,
|
||||
latestEvent,
|
||||
runtimeAction,
|
||||
selectedBotControlState,
|
||||
selectedBotEnabled,
|
||||
showInterruptSubmitAction,
|
||||
systemTimezoneOptions,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
||||
import { sortBotsByCreatedAtDesc } from '../utils';
|
||||
|
||||
interface UseDashboardShellStateOptions {
|
||||
activeBots: Record<string, BotState>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { formatBytes } from '../utils';
|
||||
|
||||
interface UseDashboardSkillsConfigOptions {
|
||||
botSkills: WorkspaceSkillOption[];
|
||||
|
|
@ -77,42 +77,52 @@ export function useDashboardSkillsConfig({
|
|||
setSkillAddMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const skillsModalProps = buildSkillsModalProps({
|
||||
botSkills,
|
||||
hasSelectedBot: Boolean(selectedBot),
|
||||
isSkillUploading,
|
||||
isZh,
|
||||
labels,
|
||||
loadBotSkills,
|
||||
loadMarketSkills,
|
||||
onClose: () => {
|
||||
setSkillAddMenuOpen(false);
|
||||
setShowSkillsModal(false);
|
||||
},
|
||||
onOpenSkillMarketDone: () => setShowSkillMarketInstallModal(true),
|
||||
onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void,
|
||||
removeBotSkill,
|
||||
selectedBot,
|
||||
setSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>,
|
||||
skillAddMenuOpen,
|
||||
skillAddMenuRef,
|
||||
skillZipPickerRef,
|
||||
triggerSkillZipUpload,
|
||||
open: showSkillsModal,
|
||||
});
|
||||
const skillsModalProps = {
|
||||
open: showSkillsModal,
|
||||
botSkills,
|
||||
isSkillUploading,
|
||||
isZh,
|
||||
hasSelectedBot: Boolean(selectedBot),
|
||||
labels,
|
||||
skillZipPickerRef,
|
||||
skillAddMenuRef,
|
||||
skillAddMenuOpen,
|
||||
onClose: () => {
|
||||
setSkillAddMenuOpen(false);
|
||||
setShowSkillsModal(false);
|
||||
},
|
||||
onRefreshSkills: () => (selectedBot ? loadBotSkills(selectedBot.id) : undefined),
|
||||
onRemoveSkill: removeBotSkill,
|
||||
onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void,
|
||||
onSetSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>,
|
||||
onTriggerSkillZipUpload: triggerSkillZipUpload,
|
||||
onOpenSkillMarketplace: async () => {
|
||||
if (!selectedBot) return;
|
||||
setSkillAddMenuOpen(false);
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
setShowSkillMarketInstallModal(true);
|
||||
},
|
||||
};
|
||||
|
||||
const skillMarketInstallModalProps = buildSkillMarketInstallModalProps({
|
||||
installMarketSkill,
|
||||
installingId: marketSkillInstallingId,
|
||||
isZh,
|
||||
items: marketSkills,
|
||||
loadBotSkills,
|
||||
loadMarketSkills,
|
||||
loading: isMarketSkillsLoading,
|
||||
onClose: () => setShowSkillMarketInstallModal(false),
|
||||
selectedBot,
|
||||
open: showSkillMarketInstallModal,
|
||||
});
|
||||
const skillMarketInstallModalProps = {
|
||||
isZh,
|
||||
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 {
|
||||
openSkillsConfigModal,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { parseAllowedAttachmentExtensions, parseWorkspaceDownloadExtensions } from '../../../shared/workspace/utils';
|
||||
import { normalizePlatformPageSize } from '../../../utils/platformPageSize';
|
||||
import { fetchDashboardSystemDefaults } from '../api/system';
|
||||
import { parseTopicPresets } from '../topic/topicPresetUtils';
|
||||
import type { SystemDefaultsResponse, TopicPresetTemplate } from '../types';
|
||||
import { parseAllowedAttachmentExtensions, parseTopicPresets, parseWorkspaceDownloadExtensions } from '../utils';
|
||||
|
||||
interface UseDashboardSystemDefaultsOptions {
|
||||
setBotListPageSize: Dispatch<SetStateAction<number>>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchDashboardSystemTemplates, updateDashboardSystemTemplates } from '../api/system';
|
||||
import { parseTopicPresets } from '../topic/topicPresetUtils';
|
||||
import type { TopicPresetTemplate } from '../types';
|
||||
import { parseTopicPresets } from '../utils';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { buildTopicConfigModalProps } from '../shared/configPanelModalProps';
|
||||
import { createTopicManager } from '../config-managers/topicManager';
|
||||
import { resolvePresetText } from '../topic/topicPresetUtils';
|
||||
import type { BotTopic, TopicPresetTemplate } from '../types';
|
||||
import { resolvePresetText } from '../utils';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
|
@ -151,58 +150,66 @@ export function useDashboardTopicConfig({
|
|||
setNewTopicPriority('50');
|
||||
}, []);
|
||||
|
||||
const topicConfigModalProps = buildTopicConfigModalProps({
|
||||
beginTopicCreate,
|
||||
countRoutingTextList: (raw) => normalizeRoutingTextList(raw).length,
|
||||
effectiveTopicPresetTemplates,
|
||||
expandedTopicByKey,
|
||||
getTopicUiKey: topicDraftUiKey,
|
||||
hasSelectedBot: Boolean(selectedBot),
|
||||
isSavingTopic,
|
||||
isZh,
|
||||
labels: { ...t, cancel: t.cancel, close: t.close, delete: t.delete, save: t.save },
|
||||
newTopicAdvancedOpen,
|
||||
newTopicDescription,
|
||||
newTopicExamplesNegative,
|
||||
newTopicExamplesPositive,
|
||||
newTopicExcludeWhen,
|
||||
newTopicIncludeWhen,
|
||||
newTopicKey,
|
||||
newTopicName,
|
||||
newTopicPanelOpen,
|
||||
newTopicPriority,
|
||||
newTopicPurpose,
|
||||
newTopicSourceLabel,
|
||||
normalizeTopicKeyInput,
|
||||
onAddTopic: addTopic,
|
||||
onClose: () => {
|
||||
setShowTopicModal(false);
|
||||
setTopicPresetMenuOpen(false);
|
||||
setNewTopicPanelOpen(false);
|
||||
resetNewTopicDraft();
|
||||
},
|
||||
removeTopic,
|
||||
resetNewTopicDraft,
|
||||
saveTopic,
|
||||
setExpandedTopicByKey: setExpandedTopicByKey as Dispatch<SetStateAction<Record<string, boolean>>>,
|
||||
setNewTopicAdvancedOpen,
|
||||
setNewTopicDescription,
|
||||
setNewTopicExamplesNegative,
|
||||
setNewTopicExamplesPositive,
|
||||
setNewTopicExcludeWhen,
|
||||
setNewTopicIncludeWhen,
|
||||
setNewTopicKey,
|
||||
setNewTopicName,
|
||||
setNewTopicPanelOpen,
|
||||
setNewTopicPriority,
|
||||
setNewTopicPurpose,
|
||||
setTopicPresetMenuOpen,
|
||||
topicPresetMenuOpen,
|
||||
topicPresetMenuRef,
|
||||
topics,
|
||||
updateTopicLocal,
|
||||
open: showTopicModal,
|
||||
});
|
||||
const topicConfigModalProps = {
|
||||
open: showTopicModal,
|
||||
topics,
|
||||
expandedTopicByKey,
|
||||
newTopicPanelOpen,
|
||||
topicPresetMenuOpen,
|
||||
newTopicAdvancedOpen,
|
||||
newTopicSourceLabel,
|
||||
newTopicKey,
|
||||
newTopicName,
|
||||
newTopicDescription,
|
||||
newTopicPurpose,
|
||||
newTopicIncludeWhen,
|
||||
newTopicExcludeWhen,
|
||||
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: () => {
|
||||
setShowTopicModal(false);
|
||||
setTopicPresetMenuOpen(false);
|
||||
setNewTopicPanelOpen(false);
|
||||
resetNewTopicDraft();
|
||||
},
|
||||
getTopicUiKey: topicDraftUiKey,
|
||||
countRoutingTextList: (raw: string) => normalizeRoutingTextList(raw).length,
|
||||
onUpdateTopicLocal: updateTopicLocal,
|
||||
onToggleExpandedTopic: (key: string) => {
|
||||
setExpandedTopicByKey((prev) => {
|
||||
const fallbackExpanded = topics.findIndex((topic, idx) => topicDraftUiKey(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: TopicPresetTemplate) =>
|
||||
resolvePresetText(preset.name, isZh ? 'zh-cn' : 'en') || preset.topic_key || preset.id,
|
||||
onAddTopic: addTopic,
|
||||
};
|
||||
|
||||
return {
|
||||
loadTopics,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type Dispatch, type RefObject, type SetSta
|
|||
import axios from 'axios';
|
||||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { normalizeUserMessageText } from '../messageParser';
|
||||
import { normalizeUserMessageText } from '../../../shared/text/messageText';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@ import rehypeRaw from 'rehype-raw';
|
|||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||
import { DashboardPreviewModalShell } from '../components/DashboardPreviewModalShell';
|
||||
import { PreviewModalShell } from '../../../shared/ui/PreviewModalShell';
|
||||
import {
|
||||
createWorkspaceMarkdownComponents,
|
||||
decorateWorkspacePathsForMarkdown,
|
||||
resolveWorkspaceDocumentPath,
|
||||
} from '../shared/workspaceMarkdown';
|
||||
} from '../../../shared/workspace/workspaceMarkdown';
|
||||
import './TopicFeedPanel.css';
|
||||
|
||||
export interface TopicFeedItem {
|
||||
|
|
@ -357,7 +357,7 @@ export function TopicFeedPanel({
|
|||
</div>
|
||||
{detailState && portalTarget
|
||||
? createPortal(
|
||||
<DashboardPreviewModalShell
|
||||
<PreviewModalShell
|
||||
closeLabel={isZh ? '关闭详情' : 'Close detail'}
|
||||
onClose={closeDetail}
|
||||
subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')}
|
||||
|
|
@ -374,7 +374,7 @@ export function TopicFeedPanel({
|
|||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPreviewModalShell>,
|
||||
</PreviewModalShell>,
|
||||
portalTarget,
|
||||
)
|
||||
: null}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -9,11 +9,9 @@ export interface BotDashboardModuleProps {
|
|||
}
|
||||
|
||||
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 RuntimeViewMode = 'visual' | 'topic';
|
||||
export type CompactPanelTab = 'chat' | 'runtime';
|
||||
export type WorkspacePreviewMode = 'preview' | 'edit';
|
||||
export type QuotedReply = { id?: number; text: string; ts: number };
|
||||
export type StagedSubmissionDraft = {
|
||||
id: string;
|
||||
|
|
@ -24,58 +22,6 @@ export type StagedSubmissionDraft = {
|
|||
};
|
||||
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 {
|
||||
items?: any[];
|
||||
anchor_id?: number | null;
|
||||
|
|
|
|||
|
|
@ -1,145 +1,17 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
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 { normalizeAttachmentPaths } from '../../shared/workspace/utils';
|
||||
import { normalizeDashboardAttachmentPath } from '../../shared/workspace/workspaceMarkdown';
|
||||
import type {
|
||||
BotTopic,
|
||||
CronJob,
|
||||
MCPConfigResponse,
|
||||
MCPServerDraft,
|
||||
TopicPresetTemplate,
|
||||
WorkspaceNode,
|
||||
} from './types';
|
||||
import { normalizeDashboardAttachmentPath } from './shared/workspaceMarkdown';
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const raw = stateLabel(s);
|
||||
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
|
||||
|
|
@ -150,168 +22,6 @@ export function normalizeRuntimeState(s?: string) {
|
|||
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 {
|
||||
command: 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) {
|
||||
if (Number.isNaN(value)) return 0.2;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface PlatformAdminDashboardPageProps {
|
|||
}
|
||||
|
||||
export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboardPageProps) {
|
||||
const dashboard = usePlatformDashboard({ compactMode });
|
||||
const dashboard = usePlatformDashboard({ compactMode, mode: 'admin' });
|
||||
|
||||
return (
|
||||
<section className="panel stack skill-market-page-shell platform-admin-page-shell">
|
||||
|
|
|
|||
|
|
@ -20,9 +20,34 @@ interface PlatformBotManagementPageProps {
|
|||
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
|
||||
|
||||
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
|
||||
const dashboard = usePlatformDashboard({ compactMode });
|
||||
const dashboard = usePlatformDashboard({ compactMode, mode: 'management' });
|
||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -51,24 +76,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
|||
{!compactMode ? (
|
||||
<section className="platform-main">
|
||||
<div className="platform-bot-management-detail-stack">
|
||||
<PlatformBotOverviewSection
|
||||
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}
|
||||
/>
|
||||
{botDetailContent}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
|
@ -82,28 +90,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
|||
isZh={dashboard.isZh}
|
||||
onClose={dashboard.closeCompactBotSheet}
|
||||
>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
{botDetailContent}
|
||||
</PlatformCompactBotSheet>
|
||||
|
||||
<CreateBotWizardModal
|
||||
|
|
|
|||
|
|
@ -31,6 +31,39 @@
|
|||
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 {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
readCachedPlatformPageSize,
|
||||
writeCachedPlatformPageSize,
|
||||
} from '../../../utils/platformPageSize';
|
||||
import type { PlatformSettings, SystemSettingItem } from '../types';
|
||||
import type { PlatformLoginLogResponse, PlatformSettings, SystemSettingItem } from '../types';
|
||||
|
||||
export interface SystemSettingsResponse {
|
||||
items: SystemSettingItem[];
|
||||
|
|
@ -27,6 +27,18 @@ export function fetchPlatformSettings() {
|
|||
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) {
|
||||
const cachedFallback = readCachedPlatformPageSize(fallback);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
|
||||
|
||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { dashboardEn } from '../../../i18n/dashboard.en';
|
||||
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
|
||||
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 { WorkspaceEntriesList } from '../../dashboard/components/WorkspaceEntriesList';
|
||||
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 { usePlatformBotDockerLogs } from '../hooks/usePlatformBotDockerLogs';
|
||||
import '../../dashboard/components/BotListPanel.css';
|
||||
import '../../dashboard/components/RuntimePanel.css';
|
||||
import '../../dashboard/components/DashboardShared.css';
|
||||
import '../../dashboard/components/WorkspaceOverlay.css';
|
||||
import '../../../components/ui/SharedUi.css';
|
||||
|
||||
interface PlatformBotRuntimeSectionProps {
|
||||
|
|
@ -29,41 +28,8 @@ interface PlatformBotRuntimeSectionProps {
|
|||
workspaceDownloadExtensions: string[];
|
||||
}
|
||||
|
||||
const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g;
|
||||
const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40;
|
||||
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({
|
||||
compactSheet = false,
|
||||
|
|
@ -74,11 +40,6 @@ export function PlatformBotRuntimeSection({
|
|||
}: PlatformBotRuntimeSectionProps) {
|
||||
const { notify } = useLucentPrompt();
|
||||
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 [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
|
||||
const workspaceSearchInputName = useMemo(
|
||||
|
|
@ -87,41 +48,18 @@ export function PlatformBotRuntimeSection({
|
|||
);
|
||||
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
|
||||
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(
|
||||
() => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined),
|
||||
[compactSheet, workspaceCardHeightPx],
|
||||
);
|
||||
const refreshWorkspaceAttachmentPolicy = useCallback(
|
||||
async () => ({
|
||||
uploadMaxMb: 0,
|
||||
allowedAttachmentExtensions: [],
|
||||
workspaceDownloadExtensions,
|
||||
}),
|
||||
[workspaceDownloadExtensions],
|
||||
);
|
||||
const {
|
||||
closeWorkspacePreview,
|
||||
copyWorkspacePreviewPath,
|
||||
|
|
@ -145,7 +83,6 @@ export function PlatformBotRuntimeSection({
|
|||
workspaceDownloadExtensionSet,
|
||||
workspaceError,
|
||||
workspaceFileLoading,
|
||||
workspaceFiles,
|
||||
workspaceHoverCard,
|
||||
workspaceLoading,
|
||||
workspaceParentPath,
|
||||
|
|
@ -159,20 +96,43 @@ export function PlatformBotRuntimeSection({
|
|||
workspacePreviewSaving,
|
||||
workspaceQuery,
|
||||
workspaceSearchLoading,
|
||||
} = useDashboardWorkspace({
|
||||
} = useBotWorkspace({
|
||||
selectedBotId: selectedBotInfo?.id || '',
|
||||
selectedBotDockerStatus: selectedBotInfo?.docker_status || '',
|
||||
workspaceDownloadExtensions,
|
||||
refreshAttachmentPolicy: async () => ({
|
||||
uploadMaxMb: 0,
|
||||
allowedAttachmentExtensions: [],
|
||||
workspaceDownloadExtensions,
|
||||
}),
|
||||
refreshAttachmentPolicy: refreshWorkspaceAttachmentPolicy,
|
||||
notify,
|
||||
t: dashboardT,
|
||||
isZh,
|
||||
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(() => {
|
||||
if (!selectedBotInfo?.id) {
|
||||
|
|
@ -186,84 +146,6 @@ export function PlatformBotRuntimeSection({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(() => {
|
||||
if (compactSheet) {
|
||||
setWorkspaceCardHeightPx(null);
|
||||
|
|
@ -364,31 +246,36 @@ export function PlatformBotRuntimeSection({
|
|||
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div>
|
||||
) : workspaceLoading || workspaceSearchLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<WorkspaceEntriesList
|
||||
nodes={filteredWorkspaceEntries}
|
||||
workspaceParentPath={workspaceParentPath}
|
||||
selectedBotId={selectedBotInfo.id}
|
||||
workspaceFileLoading={workspaceFileLoading}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
labels={{
|
||||
download: dashboardT.download,
|
||||
fileNotPreviewable: dashboardT.fileNotPreviewable,
|
||||
folder: dashboardT.folder,
|
||||
goUp: dashboardT.goUp,
|
||||
goUpTitle: dashboardT.goUpTitle,
|
||||
openFolderTitle: dashboardT.openFolderTitle,
|
||||
previewTitle: dashboardT.previewTitle,
|
||||
}}
|
||||
onLoadWorkspaceTree={loadWorkspaceTree}
|
||||
onOpenWorkspaceFilePreview={openWorkspaceFilePreview}
|
||||
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
|
||||
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
|
||||
/>
|
||||
<>
|
||||
{(workspaceParentPath !== null || hasVisibleWorkspaceEntries) ? (
|
||||
<WorkspaceEntriesList
|
||||
nodes={filteredWorkspaceEntries}
|
||||
workspaceParentPath={workspaceParentPath}
|
||||
selectedBotId={selectedBotInfo.id}
|
||||
workspaceFileLoading={workspaceFileLoading}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
labels={{
|
||||
download: dashboardT.download,
|
||||
fileNotPreviewable: dashboardT.fileNotPreviewable,
|
||||
folder: dashboardT.folder,
|
||||
goUp: dashboardT.goUp,
|
||||
goUpTitle: dashboardT.goUpTitle,
|
||||
openFolderTitle: dashboardT.openFolderTitle,
|
||||
previewTitle: dashboardT.previewTitle,
|
||||
}}
|
||||
onLoadWorkspaceTree={loadWorkspaceTree}
|
||||
onOpenWorkspaceFilePreview={openWorkspaceFilePreview}
|
||||
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
|
||||
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
|
||||
/>
|
||||
) : null}
|
||||
{showWorkspaceEmptyState ? (
|
||||
<div className="ops-empty-inline">
|
||||
{normalizedWorkspaceQuery ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-hint">
|
||||
|
|
@ -396,7 +283,7 @@ export function PlatformBotRuntimeSection({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBotInfo && !workspaceFiles.length ? (
|
||||
{showNoPreviewableFilesHint ? (
|
||||
<div className="ops-empty-inline">{dashboardT.noPreviewFile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,17 +6,17 @@ import rehypeSanitize from 'rehype-sanitize';
|
|||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../../dashboard/constants';
|
||||
import { DashboardModalCardShell } from '../../dashboard/components/DashboardModalCardShell';
|
||||
import { repairCollapsedMarkdown } from '../../dashboard/messageParser';
|
||||
import { repairCollapsedMarkdown } from '../../../shared/text/messageText';
|
||||
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
|
||||
import '../../../shared/workspace/WorkspaceOverlay.css';
|
||||
import {
|
||||
createWorkspaceMarkdownComponents,
|
||||
decorateWorkspacePathsForMarkdown,
|
||||
} from '../../dashboard/shared/workspaceMarkdown';
|
||||
} from '../../../shared/workspace/workspaceMarkdown';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import type { PlatformBotResourceSnapshot } from '../types';
|
||||
import { formatPlatformBytes, formatPlatformPercent } from '../utils';
|
||||
import '../../dashboard/components/WorkspaceOverlay.css';
|
||||
|
||||
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ export function PlatformLastActionModal({
|
|||
const closeLabel = isZh ? '关闭' : 'Close';
|
||||
|
||||
return (
|
||||
<DashboardModalCardShell
|
||||
<ModalCardShell
|
||||
cardClassName="platform-last-action-modal"
|
||||
closeLabel={closeLabel}
|
||||
onClose={onClose}
|
||||
|
|
@ -94,7 +94,7 @@ export function PlatformLastActionModal({
|
|||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardModalCardShell>
|
||||
</ModalCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ export function PlatformResourceMonitorModal({
|
|||
const closeLabel = isZh ? '关闭' : 'Close';
|
||||
|
||||
return (
|
||||
<DashboardModalCardShell
|
||||
<ModalCardShell
|
||||
cardClassName="modal-wide"
|
||||
closeLabel={closeLabel}
|
||||
headerActions={(
|
||||
|
|
@ -191,6 +191,6 @@ export function PlatformResourceMonitorModal({
|
|||
) : (
|
||||
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
||||
)}
|
||||
</DashboardModalCardShell>
|
||||
</ModalCardShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import axios from 'axios';
|
|||
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import { sortBotsByCreatedAtDesc } from '../../dashboard/utils';
|
||||
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
|
||||
import { useAppStore } from '../../../store/appStore';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import {
|
||||
|
|
@ -15,7 +15,6 @@ import type {
|
|||
BotActivityStatsItem,
|
||||
PlatformBotResourceSnapshot,
|
||||
PlatformOverviewResponse,
|
||||
PlatformSettings,
|
||||
PlatformUsageAnalyticsSeriesItem,
|
||||
PlatformUsageResponse,
|
||||
} from '../types';
|
||||
|
|
@ -23,18 +22,20 @@ import {
|
|||
buildBotPanelHref,
|
||||
buildPlatformUsageAnalyticsSeries,
|
||||
buildPlatformUsageAnalyticsTicks,
|
||||
clampPlatformPercent,
|
||||
getPlatformChartCeiling,
|
||||
} from '../utils';
|
||||
|
||||
interface UsePlatformDashboardOptions {
|
||||
compactMode: boolean;
|
||||
mode?: 'admin' | 'management';
|
||||
}
|
||||
|
||||
export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOptions) {
|
||||
export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePlatformDashboardOptions) {
|
||||
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
||||
const { notify, confirm } = useLucentPrompt();
|
||||
const isZh = locale === 'zh';
|
||||
const isAdminMode = mode === 'admin';
|
||||
const isManagementMode = mode === 'management';
|
||||
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBotId, setSelectedBotId] = useState('');
|
||||
|
|
@ -52,7 +53,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||
const [botListPage, setBotListPage] = useState(1);
|
||||
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||
|
|
@ -121,7 +121,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
},
|
||||
});
|
||||
setUsageData(res.data);
|
||||
setUsagePage(page);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
|
||||
} finally {
|
||||
|
|
@ -180,26 +179,45 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setUsageData(null);
|
||||
setUsageLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadUsage(1);
|
||||
}, [loadUsage, usagePageSize]);
|
||||
}, [isAdminMode, loadUsage, usagePageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminMode) {
|
||||
setActivityStatsData(null);
|
||||
setActivityLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadActivityStats();
|
||||
}, [loadActivityStats]);
|
||||
}, [isAdminMode, loadActivityStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage(1);
|
||||
}, [search, botListPageSize]);
|
||||
}, [botListPageSize, isManagementMode, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
|
||||
}, [botListPageCount]);
|
||||
}, [botListPageCount, isManagementMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) return;
|
||||
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
|
||||
}, [filteredBots, selectedBotId]);
|
||||
}, [filteredBots, isManagementMode, selectedBotId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
setCompactSheetMounted(false);
|
||||
return;
|
||||
}
|
||||
if (!compactMode) {
|
||||
setShowCompactBotSheet(false);
|
||||
setCompactSheetClosing(false);
|
||||
|
|
@ -208,9 +226,14 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
}
|
||||
if (selectedBotId && showCompactBotSheet) return;
|
||||
if (!selectedBotId) setShowCompactBotSheet(false);
|
||||
}, [compactMode, selectedBotId, showCompactBotSheet]);
|
||||
}, [compactMode, isManagementMode, selectedBotId, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedBotId) {
|
||||
setSelectedBotDetail(null);
|
||||
setSelectedBotUsageSummary(null);
|
||||
|
|
@ -233,7 +256,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [loadSelectedBotUsageSummary, selectedBotId]);
|
||||
}, [isManagementMode, loadSelectedBotUsageSummary, selectedBotId]);
|
||||
|
||||
const resourceBot = useMemo(
|
||||
() => (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 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[]>(
|
||||
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
|
||||
[isZh, usageAnalytics],
|
||||
|
|
@ -296,10 +310,13 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]);
|
||||
|
||||
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));
|
||||
await Promise.allSettled(jobs);
|
||||
}, [loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId, usagePage]);
|
||||
}, [isAdminMode, loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId]);
|
||||
|
||||
const toggleBot = useCallback(async (bot: BotState) => {
|
||||
setOperatingBotId(bot.id);
|
||||
|
|
@ -392,6 +409,15 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
}, [loadResourceSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagementMode) {
|
||||
setCompactSheetMounted(false);
|
||||
setCompactSheetClosing(false);
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
||||
if (compactSheetTimerRef.current) {
|
||||
window.clearTimeout(compactSheetTimerRef.current);
|
||||
|
|
@ -414,7 +440,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
compactSheetTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [compactMode, compactSheetMounted, selectedBotInfo, showCompactBotSheet]);
|
||||
}, [compactMode, compactSheetMounted, isManagementMode, selectedBotInfo, showCompactBotSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResourceModal || !resourceBotId) return;
|
||||
|
|
@ -444,18 +470,9 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
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), []);
|
||||
|
||||
return {
|
||||
botList,
|
||||
botListPage,
|
||||
botListPageCount,
|
||||
botListPageSize,
|
||||
|
|
@ -465,13 +482,11 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
compactSheetClosing,
|
||||
compactSheetMounted,
|
||||
filteredBots,
|
||||
handlePlatformSettingsSaved,
|
||||
handleSelectBot,
|
||||
isZh,
|
||||
lastActionPreview,
|
||||
loadResourceSnapshot,
|
||||
loading,
|
||||
memoryPercent,
|
||||
openBotPanel,
|
||||
openResourceMonitor,
|
||||
operatingBotId,
|
||||
|
|
@ -496,9 +511,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
setSearch,
|
||||
setShowBotLastActionModal,
|
||||
showBotLastActionModal,
|
||||
showCompactBotSheet,
|
||||
showResourceModal,
|
||||
storagePercent,
|
||||
toggleBot,
|
||||
usageAnalytics,
|
||||
activityStats,
|
||||
|
|
@ -506,9 +519,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
usageAnalyticsMax,
|
||||
usageAnalyticsSeries,
|
||||
usageAnalyticsTicks,
|
||||
usageData,
|
||||
usageLoading,
|
||||
usagePage,
|
||||
usageSummary,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,39 @@ export interface PlatformSettings {
|
|||
page_size: number;
|
||||
chat_pull_page_size: number;
|
||||
command_auto_unlock_seconds: number;
|
||||
auth_token_ttl_hours: number;
|
||||
auth_token_max_active: number;
|
||||
upload_max_mb: number;
|
||||
allowed_attachment_extensions: string[];
|
||||
workspace_download_extensions: string[];
|
||||
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 {
|
||||
key: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -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 || ''));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import type { BotEvent, ChatMessage } from '../../types/bot';
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/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 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) {
|
||||
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
||||
if (!text) return '';
|
||||
|
||||
// Keep attachment list out of editable/visible command text.
|
||||
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();
|
||||
|
||||
// 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{3,}/g, '\n\n');
|
||||
|
||||
// Collapse duplicate echoes like "xxx You: xxx" / "xxx xxx".
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|你)\s*[::]\s*\1$/iu);
|
||||
if (prefixedRepeat) return prefixedRepeat[1].trim();
|
||||
|
|
@ -51,15 +33,10 @@ export function normalizeAssistantMessageText(input: string) {
|
|||
.trim();
|
||||
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(/<\/?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(/(?:<p>(?:\s| |<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');
|
||||
return text;
|
||||
}
|
||||
|
|
@ -96,8 +73,8 @@ export function summarizeProgressText(input: string, isZh: boolean) {
|
|||
if (!raw) return isZh ? '处理中...' : 'Processing...';
|
||||
const firstLine = raw
|
||||
.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.find((v) => v.length > 0);
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value.length > 0);
|
||||
const line = (firstLine || raw)
|
||||
.replace(/[`*_>#|\[\]\(\)]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
|
@ -106,43 +83,13 @@ export function summarizeProgressText(input: string, isZh: boolean) {
|
|||
return line.length > 96 ? `${line.slice(0, 96)}...` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心逻辑:日志解析器仅用于提取“状态事件”(用于显示思考气泡)。
|
||||
* 所有的正式对话气泡(用户指令、AI回复)必须由结构化总线消息驱动。
|
||||
*/
|
||||
export function parseLogToArtifacts(
|
||||
raw: string,
|
||||
ts: number = Date.now(),
|
||||
): { message?: ChatMessage; event?: BotEvent } | null {
|
||||
const line = cleanLine(raw);
|
||||
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;
|
||||
export function cleanBotLogLine(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();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue