v0.1.4-p5
parent
95e3fd6c38
commit
ca1f941e4c
|
|
@ -50,6 +50,12 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
||||||
# Panel access protection
|
# Panel access protection
|
||||||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||||
|
|
||||||
|
# Browser credential requests must use an explicit CORS allowlist.
|
||||||
|
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
||||||
|
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
||||||
|
# Example:
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
|
||||||
|
|
||||||
# Max upload size for backend validation (MB)
|
# Max upload size for backend validation (MB)
|
||||||
UPLOAD_MAX_MB=200
|
UPLOAD_MAX_MB=200
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ REDIS_DEFAULT_TTL=60
|
||||||
|
|
||||||
# Optional panel-level access password for all backend API/WS calls.
|
# Optional panel-level access password for all backend API/WS calls.
|
||||||
PANEL_ACCESS_PASSWORD=
|
PANEL_ACCESS_PASSWORD=
|
||||||
|
|
||||||
|
# Explicit CORS allowlist for browser credential requests.
|
||||||
|
# For local development, the backend defaults to common Vite dev origins.
|
||||||
|
# In production, prefer same-origin `/api` reverse proxy, or set your real dashboard origin explicitly.
|
||||||
|
# Example:
|
||||||
|
# CORS_ALLOWED_ORIGINS=http://localhost:5173,https://dashboard.example.com
|
||||||
# The following platform-level items are now managed in sys_setting / 平台参数:
|
# The following platform-level items are now managed in sys_setting / 平台参数:
|
||||||
# - page_size
|
# - page_size
|
||||||
# - chat_pull_page_size
|
# - chat_pull_page_size
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from core.database import get_session
|
from core.database import get_session
|
||||||
|
from models.bot import BotInstance
|
||||||
from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest
|
from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest
|
||||||
|
from services.platform_auth_service import (
|
||||||
|
clear_bot_token_cookie,
|
||||||
|
create_bot_token,
|
||||||
|
resolve_bot_request_auth,
|
||||||
|
revoke_bot_token,
|
||||||
|
set_bot_token_cookie,
|
||||||
|
)
|
||||||
from services.bot_management_service import (
|
from services.bot_management_service import (
|
||||||
authenticate_bot_page_access,
|
authenticate_bot_page_access,
|
||||||
create_bot_record,
|
create_bot_record,
|
||||||
|
|
@ -36,8 +44,41 @@ def get_bot_detail(bot_id: str, session: Session = Depends(get_session)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/bots/{bot_id}/auth/login")
|
@router.post("/api/bots/{bot_id}/auth/login")
|
||||||
def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)):
|
def login_bot_page(
|
||||||
return authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password)
|
bot_id: str,
|
||||||
|
payload: BotPageAuthLoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
result = authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password)
|
||||||
|
try:
|
||||||
|
raw_token = create_bot_token(session, request, bot_id)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
set_bot_token_cookie(response, request, bot_id, raw_token, session)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/bots/{bot_id}/auth/status")
|
||||||
|
def get_bot_auth_status(bot_id: str, request: Request, session: Session = Depends(get_session)):
|
||||||
|
bot = session.get(BotInstance, bot_id)
|
||||||
|
if not bot:
|
||||||
|
return {"enabled": False, "authenticated": False, "auth_source": None, "bot_id": bot_id}
|
||||||
|
principal = resolve_bot_request_auth(session, request, bot_id)
|
||||||
|
return {
|
||||||
|
"enabled": bool(str(bot.access_password or "").strip()),
|
||||||
|
"authenticated": bool(principal.authenticated),
|
||||||
|
"auth_source": principal.auth_source if principal.authenticated else None,
|
||||||
|
"bot_id": bot_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/bots/{bot_id}/auth/logout")
|
||||||
|
def logout_bot_page(bot_id: str, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||||
|
revoke_bot_token(session, request, bot_id)
|
||||||
|
clear_bot_token_cookie(response, bot_id)
|
||||||
|
return {"success": True, "bot_id": bot_id}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/bots/{bot_id}")
|
@router.put("/api/bots/{bot_id}")
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,27 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from core.database import engine, get_session
|
from core.database import engine, get_session
|
||||||
from core.docker_instance import docker_manager
|
from core.docker_instance import docker_manager
|
||||||
from core.settings import BOTS_WORKSPACE_ROOT
|
|
||||||
from core.websocket_manager import manager
|
from core.websocket_manager import manager
|
||||||
from models.bot import BotInstance
|
from services.bot_runtime_service import (
|
||||||
from services.bot_channel_service import _get_bot_channels_from_config
|
delete_cron_job as delete_cron_job_service,
|
||||||
from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance
|
ensure_monitor_websocket_access,
|
||||||
from services.bot_storage_service import _read_bot_config, _write_bot_config
|
get_bot_logs as get_bot_logs_service,
|
||||||
from services.bot_storage_service import _read_cron_store, _write_cron_store
|
list_cron_jobs as list_cron_jobs_service,
|
||||||
|
relogin_weixin as relogin_weixin_service,
|
||||||
|
start_cron_job as start_cron_job_service,
|
||||||
|
stop_cron_job as stop_cron_job_service,
|
||||||
|
)
|
||||||
from services.runtime_service import docker_callback
|
from services.runtime_service import docker_callback
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("dashboard.backend")
|
logger = logging.getLogger("dashboard.backend")
|
||||||
|
|
||||||
|
|
||||||
def _now_ms() -> int:
|
|
||||||
return int(time.time() * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]:
|
|
||||||
current_ms = int(now_ms or _now_ms())
|
|
||||||
kind = str(schedule.get("kind") or "").strip().lower()
|
|
||||||
|
|
||||||
if kind == "at":
|
|
||||||
at_ms = int(schedule.get("atMs") or 0)
|
|
||||||
return at_ms if at_ms > current_ms else None
|
|
||||||
|
|
||||||
if kind == "every":
|
|
||||||
every_ms = int(schedule.get("everyMs") or 0)
|
|
||||||
return current_ms + every_ms if every_ms > 0 else None
|
|
||||||
|
|
||||||
if kind == "cron":
|
|
||||||
expr = str(schedule.get("expr") or "").strip()
|
|
||||||
if not expr:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from croniter import croniter
|
|
||||||
|
|
||||||
tz_name = str(schedule.get("tz") or "").strip()
|
|
||||||
tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo
|
|
||||||
base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz)
|
|
||||||
next_dt = croniter(expr, base_dt).get_next(datetime)
|
|
||||||
return int(next_dt.timestamp() * 1000)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
|
|
||||||
bot = session.get(BotInstance, bot_id)
|
|
||||||
if not bot:
|
|
||||||
raise HTTPException(status_code=404, detail="Bot not found")
|
|
||||||
return bot
|
|
||||||
|
|
||||||
|
|
||||||
def _weixin_state_file_path(bot_id: str) -> Path:
|
|
||||||
return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/bots/{bot_id}/logs")
|
@router.get("/api/bots/{bot_id}/logs")
|
||||||
def get_bot_logs(
|
def get_bot_logs(
|
||||||
bot_id: str,
|
bot_id: str,
|
||||||
|
|
@ -77,150 +31,72 @@ def get_bot_logs(
|
||||||
reverse: bool = False,
|
reverse: bool = False,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_get_bot_or_404(session, bot_id)
|
try:
|
||||||
if limit is not None:
|
return get_bot_logs_service(
|
||||||
page = docker_manager.get_logs_page(
|
session,
|
||||||
bot_id,
|
bot_id=bot_id,
|
||||||
offset=max(0, int(offset)),
|
tail=tail,
|
||||||
limit=max(1, int(limit)),
|
offset=offset,
|
||||||
reverse=bool(reverse),
|
limit=limit,
|
||||||
|
reverse=reverse,
|
||||||
)
|
)
|
||||||
return {"bot_id": bot_id, **page}
|
except LookupError as exc:
|
||||||
effective_tail = max(1, int(tail or 300))
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=effective_tail)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/bots/{bot_id}/weixin/relogin")
|
@router.post("/api/bots/{bot_id}/weixin/relogin")
|
||||||
async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)):
|
async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)):
|
||||||
bot = _get_bot_or_404(session, bot_id)
|
|
||||||
weixin_channel = next(
|
|
||||||
(
|
|
||||||
row
|
|
||||||
for row in _get_bot_channels_from_config(bot)
|
|
||||||
if str(row.get("channel_type") or "").strip().lower() == "weixin"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not weixin_channel:
|
|
||||||
raise HTTPException(status_code=404, detail="Weixin channel not found")
|
|
||||||
|
|
||||||
state_file = _weixin_state_file_path(bot_id)
|
|
||||||
removed = False
|
|
||||||
try:
|
try:
|
||||||
if state_file.is_file():
|
return await relogin_weixin_service(session, bot_id=bot_id)
|
||||||
state_file.unlink()
|
except LookupError as exc:
|
||||||
removed = True
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to remove weixin state: {exc}") from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
config_data = _read_bot_config(bot_id)
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {}
|
|
||||||
weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
|
|
||||||
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
|
|
||||||
weixin_cfg.pop("token", None)
|
|
||||||
_write_bot_config(bot_id, config_data)
|
|
||||||
|
|
||||||
restarted = False
|
|
||||||
if str(bot.docker_status or "").upper() == "RUNNING":
|
|
||||||
stop_bot_instance(session, bot_id)
|
|
||||||
await start_bot_instance(session, bot_id)
|
|
||||||
restarted = True
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "relogin_started",
|
|
||||||
"bot_id": bot_id,
|
|
||||||
"removed_state": removed,
|
|
||||||
"restarted": restarted,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/bots/{bot_id}/cron/jobs")
|
@router.get("/api/bots/{bot_id}/cron/jobs")
|
||||||
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
|
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
|
||||||
_get_bot_or_404(session, bot_id)
|
try:
|
||||||
store = _read_cron_store(bot_id)
|
return list_cron_jobs_service(session, bot_id=bot_id, include_disabled=include_disabled)
|
||||||
rows = []
|
except LookupError as exc:
|
||||||
for row in store.get("jobs", []):
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
if not isinstance(row, dict):
|
|
||||||
continue
|
|
||||||
enabled = bool(row.get("enabled", True))
|
|
||||||
if not include_disabled and not enabled:
|
|
||||||
continue
|
|
||||||
rows.append(row)
|
|
||||||
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
|
|
||||||
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
|
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
|
||||||
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||||
_get_bot_or_404(session, bot_id)
|
try:
|
||||||
store = _read_cron_store(bot_id)
|
return stop_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||||
jobs = store.get("jobs", [])
|
except LookupError as exc:
|
||||||
if not isinstance(jobs, list):
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
jobs = []
|
|
||||||
found = None
|
|
||||||
for row in jobs:
|
|
||||||
if isinstance(row, dict) and str(row.get("id")) == job_id:
|
|
||||||
found = row
|
|
||||||
break
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
|
||||||
found["enabled"] = False
|
|
||||||
found["updatedAtMs"] = _now_ms()
|
|
||||||
state = found.get("state")
|
|
||||||
if not isinstance(state, dict):
|
|
||||||
state = {}
|
|
||||||
found["state"] = state
|
|
||||||
state["nextRunAtMs"] = None
|
|
||||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
|
||||||
return {"status": "stopped", "job_id": job_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start")
|
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start")
|
||||||
def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||||
_get_bot_or_404(session, bot_id)
|
try:
|
||||||
store = _read_cron_store(bot_id)
|
return start_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||||
jobs = store.get("jobs", [])
|
except LookupError as exc:
|
||||||
if not isinstance(jobs, list):
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
jobs = []
|
|
||||||
found = None
|
|
||||||
for row in jobs:
|
|
||||||
if isinstance(row, dict) and str(row.get("id")) == job_id:
|
|
||||||
found = row
|
|
||||||
break
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
|
||||||
|
|
||||||
found["enabled"] = True
|
|
||||||
found["updatedAtMs"] = _now_ms()
|
|
||||||
state = found.get("state")
|
|
||||||
if not isinstance(state, dict):
|
|
||||||
state = {}
|
|
||||||
found["state"] = state
|
|
||||||
schedule = found.get("schedule")
|
|
||||||
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {})
|
|
||||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
|
||||||
return {"status": "started", "job_id": job_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
|
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
|
||||||
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||||
_get_bot_or_404(session, bot_id)
|
try:
|
||||||
store = _read_cron_store(bot_id)
|
return delete_cron_job_service(session, bot_id=bot_id, job_id=job_id)
|
||||||
jobs = store.get("jobs", [])
|
except LookupError as exc:
|
||||||
if not isinstance(jobs, list):
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
jobs = []
|
|
||||||
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
|
|
||||||
if len(kept) == len(jobs):
|
|
||||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
|
||||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
|
|
||||||
return {"status": "deleted", "job_id": job_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/monitor/{bot_id}")
|
@router.websocket("/ws/monitor/{bot_id}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
bot = session.get(BotInstance, bot_id)
|
try:
|
||||||
if not bot:
|
ensure_monitor_websocket_access(session, websocket, bot_id)
|
||||||
|
except PermissionError:
|
||||||
|
await websocket.close(code=4401, reason="Bot or panel authentication required")
|
||||||
|
return
|
||||||
|
except LookupError:
|
||||||
await websocket.close(code=4404, reason="Bot not found")
|
await websocket.close(code=4404, reason="Bot not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -240,6 +116,15 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str):
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
|
with Session(engine) as session:
|
||||||
|
try:
|
||||||
|
ensure_monitor_websocket_access(session, websocket, bot_id)
|
||||||
|
except PermissionError:
|
||||||
|
await websocket.close(code=4401, reason="Authentication expired")
|
||||||
|
return
|
||||||
|
except LookupError:
|
||||||
|
await websocket.close(code=4404, reason="Bot not found")
|
||||||
|
return
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from services.platform_service import (
|
||||||
get_bot_activity_stats,
|
get_bot_activity_stats,
|
||||||
get_platform_settings,
|
get_platform_settings,
|
||||||
list_system_settings,
|
list_system_settings,
|
||||||
|
list_login_logs,
|
||||||
list_activity_events,
|
list_activity_events,
|
||||||
list_usage,
|
list_usage,
|
||||||
save_platform_settings,
|
save_platform_settings,
|
||||||
|
|
@ -78,6 +79,25 @@ def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session:
|
||||||
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
|
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/platform/login-logs")
|
||||||
|
def get_platform_login_logs(
|
||||||
|
search: str = "",
|
||||||
|
auth_type: str = "",
|
||||||
|
status: str = "all",
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return list_login_logs(
|
||||||
|
session,
|
||||||
|
search=search,
|
||||||
|
auth_type=auth_type,
|
||||||
|
status=status,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/platform/system-settings")
|
@router.get("/api/platform/system-settings")
|
||||||
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
|
||||||
return {"items": list_system_settings(session, search=search)}
|
return {"items": list_system_settings(session, search=search)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from core.database import engine, get_session
|
from core.database import engine, get_session
|
||||||
|
|
@ -9,6 +9,13 @@ from core.utils import _get_default_system_timezone
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
|
from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
|
||||||
from core.cache import cache
|
from core.cache import cache
|
||||||
|
from services.platform_auth_service import (
|
||||||
|
clear_panel_token_cookie,
|
||||||
|
create_panel_token,
|
||||||
|
resolve_panel_request_auth,
|
||||||
|
revoke_panel_token,
|
||||||
|
set_panel_token_cookie,
|
||||||
|
)
|
||||||
from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
|
from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
|
||||||
from services.template_service import (
|
from services.template_service import (
|
||||||
get_agent_md_templates,
|
get_agent_md_templates,
|
||||||
|
|
@ -21,19 +28,37 @@ router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/panel/auth/status")
|
@router.get("/api/panel/auth/status")
|
||||||
def get_panel_auth_status():
|
def get_panel_auth_status(request: Request, session: Session = Depends(get_session)):
|
||||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||||
return {"enabled": bool(configured)}
|
principal = resolve_panel_request_auth(session, request)
|
||||||
|
return {
|
||||||
|
"enabled": bool(configured),
|
||||||
|
"authenticated": bool(principal.authenticated),
|
||||||
|
"auth_source": principal.auth_source if principal.authenticated else None,
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/api/panel/auth/login")
|
@router.post("/api/panel/auth/login")
|
||||||
def panel_login(payload: PanelLoginRequest):
|
def panel_login(payload: PanelLoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||||
if not configured:
|
if not configured:
|
||||||
|
clear_panel_token_cookie(response)
|
||||||
return {"success": True, "enabled": False}
|
return {"success": True, "enabled": False}
|
||||||
supplied = str(payload.password or "").strip()
|
supplied = str(payload.password or "").strip()
|
||||||
if supplied != configured:
|
if supplied != configured:
|
||||||
raise HTTPException(status_code=401, detail="Invalid panel access password")
|
raise HTTPException(status_code=401, detail="Invalid panel access password")
|
||||||
return {"success": True, "enabled": True}
|
try:
|
||||||
|
raw_token = create_panel_token(session, request)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
set_panel_token_cookie(response, request, raw_token, session)
|
||||||
|
return {"success": True, "enabled": True, "authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/panel/auth/logout")
|
||||||
|
def panel_logout(request: Request, response: Response, session: Session = Depends(get_session)):
|
||||||
|
revoke_panel_token(session, request)
|
||||||
|
clear_panel_token_cookie(response)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
@router.get("/api/system/defaults")
|
@router.get("/api/system/defaults")
|
||||||
def get_system_defaults():
|
def get_system_defaults():
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from api.workspace_router import router as workspace_router
|
||||||
from bootstrap.app_runtime import register_app_runtime
|
from bootstrap.app_runtime import register_app_runtime
|
||||||
from core.auth_middleware import PasswordProtectionMiddleware
|
from core.auth_middleware import PasswordProtectionMiddleware
|
||||||
from core.docker_instance import docker_manager
|
from core.docker_instance import docker_manager
|
||||||
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
|
from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT
|
||||||
from core.speech_service import WhisperSpeechService
|
from core.speech_service import WhisperSpeechService
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,9 +33,10 @@ def create_app() -> FastAPI:
|
||||||
app.add_middleware(PasswordProtectionMiddleware)
|
app.add_middleware(PasswordProtectionMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=list(CORS_ALLOWED_ORIGINS),
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(platform_router)
|
app.include_router(platform_router)
|
||||||
|
|
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlmodel import Session
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from core.settings import PANEL_ACCESS_PASSWORD
|
from bootstrap.auth_access import RouteAccessMode, extract_bot_id, resolve_route_access_mode
|
||||||
from services.bot_storage_service import _read_bot_config
|
from core.database import engine
|
||||||
|
from services.platform_auth_service import (
|
||||||
PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password"
|
resolve_bot_request_auth,
|
||||||
BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password"
|
resolve_panel_request_auth,
|
||||||
BOT_PANEL_ONLY_SUFFIXES = {"/enable", "/disable", "/deactivate"}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_bot_id_from_api_path(path: str) -> Optional[str]:
|
|
||||||
parts = [p for p in path.split("/") if p.strip()]
|
|
||||||
if len(parts) >= 3 and parts[0] == "api" and parts[1] == "bots":
|
|
||||||
return parts[2]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_supplied_panel_password_http(request: Request) -> str:
|
|
||||||
header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip()
|
|
||||||
if header_value:
|
|
||||||
return header_value
|
|
||||||
query_value = str(request.query_params.get("panel_access_password") or "").strip()
|
|
||||||
return query_value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_supplied_bot_access_password_http(request: Request) -> str:
|
|
||||||
header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip()
|
|
||||||
if header_value:
|
|
||||||
return header_value
|
|
||||||
query_value = str(request.query_params.get("bot_access_password") or "").strip()
|
|
||||||
return query_value
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_panel_access_password(supplied: str) -> Optional[str]:
|
|
||||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
|
||||||
if not configured:
|
|
||||||
return None
|
|
||||||
candidate = str(supplied or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return "Panel access password required"
|
|
||||||
if candidate != configured:
|
|
||||||
return "Invalid panel access password"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_bot_access_password(bot_id: str, supplied: str) -> Optional[str]:
|
|
||||||
config = _read_bot_config(bot_id)
|
|
||||||
configured = str(config.get("access_password") or "").strip()
|
|
||||||
if not configured:
|
|
||||||
return None
|
|
||||||
candidate = str(supplied or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return "Bot access password required"
|
|
||||||
if candidate != configured:
|
|
||||||
return "Invalid bot access password"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool:
|
|
||||||
raw = str(path or "").strip()
|
|
||||||
verb = str(method or "GET").strip().upper()
|
|
||||||
if not raw.startswith("/api/bots/"):
|
|
||||||
return False
|
|
||||||
bot_id = _extract_bot_id_from_api_path(raw)
|
|
||||||
if not bot_id:
|
|
||||||
return False
|
|
||||||
return (
|
|
||||||
raw.endswith("/start")
|
|
||||||
or raw.endswith("/stop")
|
|
||||||
or raw.endswith("/enable")
|
|
||||||
or raw.endswith("/disable")
|
|
||||||
or raw.endswith("/deactivate")
|
|
||||||
or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool:
|
def _unauthorized(detail: str) -> JSONResponse:
|
||||||
raw = str(path or "").strip()
|
return JSONResponse(status_code=401, content={"detail": detail})
|
||||||
verb = str(method or "GET").strip().upper()
|
|
||||||
if not raw.startswith("/api/"):
|
|
||||||
return False
|
|
||||||
if raw in {
|
|
||||||
"/api/panel/auth/status",
|
|
||||||
"/api/panel/auth/login",
|
|
||||||
"/api/health",
|
|
||||||
"/api/health/cache",
|
|
||||||
}:
|
|
||||||
return False
|
|
||||||
if _is_bot_panel_management_api_path(raw, verb):
|
|
||||||
return True
|
|
||||||
if _extract_bot_id_from_api_path(raw):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordProtectionMiddleware(BaseHTTPMiddleware):
|
class PasswordProtectionMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method.upper() == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
method = request.method.upper()
|
access_mode = resolve_route_access_mode(path, request.method)
|
||||||
|
if access_mode == RouteAccessMode.PUBLIC:
|
||||||
if method == "OPTIONS":
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
bot_id = _extract_bot_id_from_api_path(path)
|
bot_id = extract_bot_id(path)
|
||||||
|
with Session(engine) as session:
|
||||||
|
panel_principal = resolve_panel_request_auth(session, request)
|
||||||
|
if panel_principal.authenticated:
|
||||||
|
request.state.auth_principal = panel_principal
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if access_mode == RouteAccessMode.PANEL_ONLY:
|
||||||
|
return _unauthorized("Panel authentication required")
|
||||||
|
|
||||||
if not bot_id:
|
if not bot_id:
|
||||||
if _is_panel_protected_api_path(path, method):
|
return _unauthorized("Bot authentication required")
|
||||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
|
||||||
if panel_error:
|
bot_principal = resolve_bot_request_auth(session, request, bot_id)
|
||||||
return JSONResponse(status_code=401, content={"detail": panel_error})
|
if bot_principal.authenticated:
|
||||||
|
request.state.auth_principal = bot_principal
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
if _is_bot_panel_management_api_path(path, method):
|
if access_mode == RouteAccessMode.PUBLIC_BOT_OR_PANEL:
|
||||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
return _unauthorized("Bot or panel authentication required to access this resource")
|
||||||
if panel_error:
|
return _unauthorized("Bot or panel authentication required")
|
||||||
bot_error = _validate_bot_access_password(bot_id, _get_supplied_bot_access_password_http(request))
|
|
||||||
if bot_error:
|
|
||||||
return JSONResponse(status_code=401, content={"detail": bot_error})
|
|
||||||
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
@ -10,10 +12,10 @@ except Exception: # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class RedisCache:
|
class RedisCache:
|
||||||
def __init__(self):
|
def __init__(self, *, prefix_override: Optional[str] = None, default_ttl_override: Optional[int] = None):
|
||||||
self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None)
|
self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None)
|
||||||
self.prefix = REDIS_PREFIX
|
self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX
|
||||||
self.default_ttl = int(REDIS_DEFAULT_TTL)
|
self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL)
|
||||||
self._client: Optional["Redis"] = None
|
self._client: Optional["Redis"] = None
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
try:
|
try:
|
||||||
|
|
@ -34,11 +36,28 @@ class RedisCache:
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[str]:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._client.get(self._full_key(key))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ttl_seconds = int(ttl if ttl is not None else self.default_ttl)
|
||||||
|
self._client.setex(self._full_key(key), ttl_seconds, str(value))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
def get_json(self, key: str) -> Any:
|
def get_json(self, key: str) -> Any:
|
||||||
if not self.enabled or self._client is None:
|
if not self.enabled or self._client is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
raw = self._client.get(self._full_key(key))
|
raw = self.get(key)
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
|
|
@ -49,11 +68,46 @@ class RedisCache:
|
||||||
if not self.enabled or self._client is None:
|
if not self.enabled or self._client is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._client.setex(
|
self.set(key, json.dumps(value, ensure_ascii=False, default=str), ttl=ttl)
|
||||||
self._full_key(key),
|
except Exception:
|
||||||
int(ttl if ttl is not None else self.default_ttl),
|
return
|
||||||
json.dumps(value, ensure_ascii=False, default=str),
|
|
||||||
)
|
def sadd(self, key: str, *members: str) -> None:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return
|
||||||
|
normalized = [str(member or "").strip() for member in members if str(member or "").strip()]
|
||||||
|
if not normalized:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.sadd(self._full_key(key), *normalized)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def srem(self, key: str, *members: str) -> None:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return
|
||||||
|
normalized = [str(member or "").strip() for member in members if str(member or "").strip()]
|
||||||
|
if not normalized:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.srem(self._full_key(key), *normalized)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def smembers(self, key: str) -> set[str]:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
rows = self._client.smembers(self._full_key(key))
|
||||||
|
return {str(row or "").strip() for row in rows if str(row or "").strip()}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def expire(self, key: str, ttl: int) -> None:
|
||||||
|
if not self.enabled or self._client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.expire(self._full_key(key), max(1, int(ttl)))
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -85,4 +139,4 @@ class RedisCache:
|
||||||
|
|
||||||
|
|
||||||
cache = RedisCache()
|
cache = RedisCache()
|
||||||
|
auth_cache = RedisCache(prefix_override=f"{REDIS_PREFIX}_auth")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from core.settings import (
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure table models are registered in SQLModel metadata before create_all.
|
# Ensure table models are registered in SQLModel metadata before create_all.
|
||||||
|
from models import auth as _auth_models # noqa: F401
|
||||||
from models import bot as _bot_models # noqa: F401
|
from models import bot as _bot_models # noqa: F401
|
||||||
from models import platform as _platform_models # noqa: F401
|
from models import platform as _platform_models # noqa: F401
|
||||||
from models import skill as _skill_models # noqa: F401
|
from models import skill as _skill_models # noqa: F401
|
||||||
|
|
@ -32,6 +33,8 @@ BOT_MESSAGE_TABLE = "bot_message"
|
||||||
BOT_IMAGE_TABLE = "bot_image"
|
BOT_IMAGE_TABLE = "bot_image"
|
||||||
BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
||||||
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
||||||
|
SYS_LOGIN_LOG_TABLE = "sys_login_log"
|
||||||
|
LEGACY_AUTH_LOGIN_LOG_TABLE = "auth_login_log"
|
||||||
SYS_SETTING_TABLE = "sys_setting"
|
SYS_SETTING_TABLE = "sys_setting"
|
||||||
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
||||||
|
|
||||||
|
|
@ -58,6 +61,14 @@ def _release_migration_lock(lock_conn) -> None:
|
||||||
lock_conn.close()
|
lock_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_table_if_needed(old_name: str, new_name: str) -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if not inspector.has_table(old_name) or inspector.has_table(new_name):
|
||||||
|
return
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(f"ALTER TABLE {_quote_ident(old_name)} RENAME TO {_quote_ident(new_name)}"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def _ensure_botinstance_columns() -> None:
|
def _ensure_botinstance_columns() -> None:
|
||||||
required_columns = {
|
required_columns = {
|
||||||
"current_state": "TEXT DEFAULT 'IDLE'",
|
"current_state": "TEXT DEFAULT 'IDLE'",
|
||||||
|
|
@ -133,6 +144,34 @@ def _ensure_bot_request_usage_columns() -> None:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_auth_login_log_table() -> None:
|
||||||
|
_rename_table_if_needed(LEGACY_AUTH_LOGIN_LOG_TABLE, SYS_LOGIN_LOG_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_auth_login_log_columns() -> None:
|
||||||
|
required_columns = {
|
||||||
|
"auth_type": "TEXT NOT NULL DEFAULT 'bot'",
|
||||||
|
"token_hash": "TEXT",
|
||||||
|
"auth_source": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"revoke_reason": "TEXT",
|
||||||
|
"device_info": "TEXT",
|
||||||
|
}
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if not inspector.has_table(SYS_LOGIN_LOG_TABLE):
|
||||||
|
return
|
||||||
|
with engine.connect() as conn:
|
||||||
|
existing = {
|
||||||
|
str(row.get("name"))
|
||||||
|
for row in inspect(conn).get_columns(SYS_LOGIN_LOG_TABLE)
|
||||||
|
if row.get("name")
|
||||||
|
}
|
||||||
|
for col, ddl in required_columns.items():
|
||||||
|
if col in existing:
|
||||||
|
continue
|
||||||
|
conn.execute(text(f"ALTER TABLE {SYS_LOGIN_LOG_TABLE} ADD COLUMN {col} {ddl}"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_topic_columns() -> None:
|
def _ensure_topic_columns() -> None:
|
||||||
required_columns = {
|
required_columns = {
|
||||||
"topic_topic": {
|
"topic_topic": {
|
||||||
|
|
@ -215,6 +254,7 @@ def align_postgres_sequences() -> None:
|
||||||
if engine.dialect.name != "postgresql":
|
if engine.dialect.name != "postgresql":
|
||||||
return
|
return
|
||||||
sequence_targets = [
|
sequence_targets = [
|
||||||
|
(SYS_LOGIN_LOG_TABLE, "id"),
|
||||||
(BOT_MESSAGE_TABLE, "id"),
|
(BOT_MESSAGE_TABLE, "id"),
|
||||||
(BOT_REQUEST_USAGE_TABLE, "id"),
|
(BOT_REQUEST_USAGE_TABLE, "id"),
|
||||||
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
||||||
|
|
@ -247,7 +287,9 @@ def align_postgres_sequences() -> None:
|
||||||
def init_database() -> None:
|
def init_database() -> None:
|
||||||
lock_conn = _acquire_migration_lock()
|
lock_conn = _acquire_migration_lock()
|
||||||
try:
|
try:
|
||||||
|
_migrate_auth_login_log_table()
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
_ensure_auth_login_log_columns()
|
||||||
_ensure_sys_setting_columns()
|
_ensure_sys_setting_columns()
|
||||||
_ensure_bot_request_usage_columns()
|
_ensure_bot_request_usage_columns()
|
||||||
_ensure_botinstance_columns()
|
_ensure_botinstance_columns()
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,32 @@ def _env_extensions(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
return tuple(rows)
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_origin(raw: str) -> str:
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(text)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
scheme = str(parsed.scheme or "").strip().lower()
|
||||||
|
netloc = str(parsed.netloc or "").strip().lower()
|
||||||
|
if scheme not in {"http", "https"} or not netloc:
|
||||||
|
return ""
|
||||||
|
return urlunsplit((scheme, netloc, "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _env_origins(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
source = list(default) if raw is None else re.split(r"[,;\s]+", str(raw))
|
||||||
|
rows: list[str] = []
|
||||||
|
for item in source:
|
||||||
|
origin = _normalize_origin(item)
|
||||||
|
if origin and origin not in rows:
|
||||||
|
rows.append(origin)
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_dir_path(path_value: str) -> str:
|
def _normalize_dir_path(path_value: str) -> str:
|
||||||
raw = str(path_value or "").strip()
|
raw = str(path_value or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
@ -158,6 +184,8 @@ DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
|
||||||
DEFAULT_PAGE_SIZE: Final[int] = 10
|
DEFAULT_PAGE_SIZE: Final[int] = 10
|
||||||
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
||||||
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600)
|
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600)
|
||||||
|
DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720)
|
||||||
|
DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
||||||
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
|
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
|
||||||
).strip() or "Asia/Shanghai"
|
).strip() or "Asia/Shanghai"
|
||||||
|
|
@ -198,6 +226,15 @@ REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
|
||||||
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
|
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
|
||||||
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
||||||
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
|
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
|
||||||
|
CORS_ALLOWED_ORIGINS: Final[tuple[str, ...]] = _env_origins(
|
||||||
|
"CORS_ALLOWED_ORIGINS",
|
||||||
|
(
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:4173",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip()
|
APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip()
|
||||||
APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
|
APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
page_size: int = Field(default=10, ge=1, le=100)
|
||||||
chat_pull_page_size: int = Field(default=60, ge=10, le=500)
|
chat_pull_page_size: int = Field(default=60, ge=10, le=500)
|
||||||
command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600)
|
command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600)
|
||||||
|
auth_token_ttl_hours: int = Field(default=24, ge=1, le=720)
|
||||||
|
auth_token_max_active: int = Field(default=2, ge=1, le=20)
|
||||||
upload_max_mb: int = Field(default=100, ge=1, le=2048)
|
upload_max_mb: int = Field(default=100, ge=1, le=2048)
|
||||||
allowed_attachment_extensions: List[str] = Field(default_factory=list)
|
allowed_attachment_extensions: List[str] = Field(default_factory=list)
|
||||||
workspace_download_extensions: List[str] = Field(default_factory=list)
|
workspace_download_extensions: List[str] = Field(default_factory=list)
|
||||||
|
|
@ -63,6 +65,31 @@ class PlatformUsageResponse(BaseModel):
|
||||||
analytics: PlatformUsageAnalytics
|
analytics: PlatformUsageAnalytics
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformLoginLogItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
auth_type: str
|
||||||
|
subject_id: str
|
||||||
|
bot_id: Optional[str] = None
|
||||||
|
auth_source: str
|
||||||
|
client_ip: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
device_info: Optional[str] = None
|
||||||
|
created_at: str
|
||||||
|
last_seen_at: Optional[str] = None
|
||||||
|
expires_at: Optional[str] = None
|
||||||
|
revoked_at: Optional[str] = None
|
||||||
|
revoke_reason: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformLoginLogResponse(BaseModel):
|
||||||
|
items: List[PlatformLoginLogItem]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
class PlatformActivityItem(BaseModel):
|
class PlatformActivityItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
bot_id: str
|
bot_id: str
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
page_size=int(bootstrap["page_size"]),
|
||||||
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
|
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
|
||||||
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
|
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
|
||||||
|
auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]),
|
||||||
|
auth_token_max_active=int(bootstrap["auth_token_max_active"]),
|
||||||
upload_max_mb=int(bootstrap["upload_max_mb"]),
|
upload_max_mb=int(bootstrap["upload_max_mb"]),
|
||||||
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
|
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
|
||||||
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
|
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
|
||||||
|
|
@ -52,6 +54,14 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload:
|
||||||
1,
|
1,
|
||||||
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
|
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
|
||||||
)
|
)
|
||||||
|
merged["auth_token_ttl_hours"] = max(
|
||||||
|
1,
|
||||||
|
min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])),
|
||||||
|
)
|
||||||
|
merged["auth_token_max_active"] = max(
|
||||||
|
1,
|
||||||
|
min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])),
|
||||||
|
)
|
||||||
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
|
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
|
||||||
merged["allowed_attachment_extensions"] = _normalize_extension_list(
|
merged["allowed_attachment_extensions"] = _normalize_extension_list(
|
||||||
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
|
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
|
||||||
|
|
@ -68,6 +78,8 @@ def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -
|
||||||
page_size=max(1, min(100, int(payload.page_size))),
|
page_size=max(1, min(100, int(payload.page_size))),
|
||||||
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
|
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
|
||||||
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
|
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
|
||||||
|
auth_token_ttl_hours=max(1, min(720, int(payload.auth_token_ttl_hours))),
|
||||||
|
auth_token_max_active=max(1, min(20, int(payload.auth_token_max_active))),
|
||||||
upload_max_mb=payload.upload_max_mb,
|
upload_max_mb=payload.upload_max_mb,
|
||||||
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions),
|
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions),
|
||||||
workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions),
|
workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions),
|
||||||
|
|
@ -116,6 +128,14 @@ def get_chat_pull_page_size() -> int:
|
||||||
return get_platform_settings_snapshot().chat_pull_page_size
|
return get_platform_settings_snapshot().chat_pull_page_size
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_token_ttl_hours(session: Session) -> int:
|
||||||
|
return get_platform_settings(session).auth_token_ttl_hours
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_token_max_active(session: Session) -> int:
|
||||||
|
return get_platform_settings(session).auth_token_max_active
|
||||||
|
|
||||||
|
|
||||||
def get_speech_runtime_settings() -> Dict[str, Any]:
|
def get_speech_runtime_settings() -> Dict[str, Any]:
|
||||||
settings = get_platform_settings_snapshot()
|
settings = get_platform_settings_snapshot()
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from services.platform_activity_service import (
|
||||||
prune_expired_activity_events,
|
prune_expired_activity_events,
|
||||||
record_activity_event,
|
record_activity_event,
|
||||||
)
|
)
|
||||||
|
from services.platform_login_log_service import list_login_logs
|
||||||
from services.platform_overview_service import build_platform_overview
|
from services.platform_overview_service import build_platform_overview
|
||||||
from services.platform_settings_service import (
|
from services.platform_settings_service import (
|
||||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||||
|
|
@ -16,6 +17,8 @@ from services.platform_settings_service import (
|
||||||
delete_system_setting,
|
delete_system_setting,
|
||||||
ensure_default_system_settings,
|
ensure_default_system_settings,
|
||||||
get_activity_event_retention_days,
|
get_activity_event_retention_days,
|
||||||
|
get_auth_token_max_active,
|
||||||
|
get_auth_token_ttl_hours,
|
||||||
get_allowed_attachment_extensions,
|
get_allowed_attachment_extensions,
|
||||||
get_chat_pull_page_size,
|
get_chat_pull_page_size,
|
||||||
get_page_size,
|
get_page_size,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Any, Dict, List
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from core.settings import (
|
from core.settings import (
|
||||||
|
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
|
||||||
|
DEFAULT_AUTH_TOKEN_TTL_HOURS,
|
||||||
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
||||||
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
|
|
@ -24,6 +26,8 @@ SETTING_KEYS = (
|
||||||
"page_size",
|
"page_size",
|
||||||
"chat_pull_page_size",
|
"chat_pull_page_size",
|
||||||
"command_auto_unlock_seconds",
|
"command_auto_unlock_seconds",
|
||||||
|
"auth_token_ttl_hours",
|
||||||
|
"auth_token_max_active",
|
||||||
"upload_max_mb",
|
"upload_max_mb",
|
||||||
"allowed_attachment_extensions",
|
"allowed_attachment_extensions",
|
||||||
"workspace_download_extensions",
|
"workspace_download_extensions",
|
||||||
|
|
@ -38,6 +42,10 @@ DEPRECATED_SETTING_KEYS = {
|
||||||
"speech_audio_preprocess",
|
"speech_audio_preprocess",
|
||||||
"speech_audio_filter",
|
"speech_audio_filter",
|
||||||
"speech_initial_prompt",
|
"speech_initial_prompt",
|
||||||
|
"sys_auth_token_ttl_days",
|
||||||
|
"auth_token_ttl_days",
|
||||||
|
"panel_session_ttl_days",
|
||||||
|
"bot_session_ttl_days",
|
||||||
}
|
}
|
||||||
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"page_size": {
|
"page_size": {
|
||||||
|
|
@ -67,6 +75,24 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"is_public": True,
|
"is_public": True,
|
||||||
"sort_order": 9,
|
"sort_order": 9,
|
||||||
},
|
},
|
||||||
|
"auth_token_ttl_hours": {
|
||||||
|
"name": "认证 Token 过期小时数",
|
||||||
|
"category": "auth",
|
||||||
|
"description": "Panel 与 Bot 登录 Token 的统一有效时长,单位小时。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_AUTH_TOKEN_TTL_HOURS,
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
"auth_token_max_active": {
|
||||||
|
"name": "认证 Token 最大并发数",
|
||||||
|
"category": "auth",
|
||||||
|
"description": "同一主体允许同时活跃的 Token 数量,超过时自动撤销最旧 Token。",
|
||||||
|
"value_type": "integer",
|
||||||
|
"value": DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
|
||||||
|
"is_public": False,
|
||||||
|
"sort_order": 11,
|
||||||
|
},
|
||||||
"upload_max_mb": {
|
"upload_max_mb": {
|
||||||
"name": "上传大小限制",
|
"name": "上传大小限制",
|
||||||
"category": "upload",
|
"category": "upload",
|
||||||
|
|
@ -74,7 +100,7 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"value": DEFAULT_UPLOAD_MAX_MB,
|
"value": DEFAULT_UPLOAD_MAX_MB,
|
||||||
"is_public": False,
|
"is_public": False,
|
||||||
"sort_order": 10,
|
"sort_order": 20,
|
||||||
},
|
},
|
||||||
"allowed_attachment_extensions": {
|
"allowed_attachment_extensions": {
|
||||||
"name": "允许附件后缀",
|
"name": "允许附件后缀",
|
||||||
|
|
@ -197,6 +223,18 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]:
|
||||||
1,
|
1,
|
||||||
600,
|
600,
|
||||||
),
|
),
|
||||||
|
"auth_token_ttl_hours": _legacy_env_int(
|
||||||
|
"AUTH_TOKEN_TTL_HOURS",
|
||||||
|
DEFAULT_AUTH_TOKEN_TTL_HOURS,
|
||||||
|
1,
|
||||||
|
720,
|
||||||
|
),
|
||||||
|
"auth_token_max_active": _legacy_env_int(
|
||||||
|
"AUTH_TOKEN_MAX_ACTIVE",
|
||||||
|
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
),
|
||||||
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
|
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
|
||||||
"allowed_attachment_extensions": _legacy_env_extensions(
|
"allowed_attachment_extensions": _legacy_env_extensions(
|
||||||
"ALLOWED_ATTACHMENT_EXTENSIONS",
|
"ALLOWED_ATTACHMENT_EXTENSIONS",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from services.platform_runtime_settings_service import (
|
from services.platform_runtime_settings_service import (
|
||||||
|
get_auth_token_max_active,
|
||||||
|
get_auth_token_ttl_hours,
|
||||||
default_platform_settings,
|
default_platform_settings,
|
||||||
get_allowed_attachment_extensions,
|
get_allowed_attachment_extensions,
|
||||||
get_chat_pull_page_size,
|
get_chat_pull_page_size,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
@ -21,6 +22,14 @@ from services.platform_settings_core import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_auth_ttl_hours_from_legacy(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
normalized = int(value)
|
||||||
|
except Exception:
|
||||||
|
normalized = 0
|
||||||
|
return max(1, min(720, normalized * 24))
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_system_settings(session: Session) -> None:
|
def ensure_default_system_settings(session: Session) -> None:
|
||||||
bootstrap_values = _bootstrap_platform_setting_values()
|
bootstrap_values = _bootstrap_platform_setting_values()
|
||||||
legacy_row = session.get(PlatformSetting, "global")
|
legacy_row = session.get(PlatformSetting, "global")
|
||||||
|
|
@ -46,15 +55,24 @@ def ensure_default_system_settings(session: Session) -> None:
|
||||||
session.delete(legacy_row)
|
session.delete(legacy_row)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
legacy_auth_ttl_hours = None
|
||||||
dirty = False
|
dirty = False
|
||||||
for key in DEPRECATED_SETTING_KEYS:
|
for key in DEPRECATED_SETTING_KEYS:
|
||||||
legacy_row = session.get(PlatformSetting, key)
|
legacy_row = session.get(PlatformSetting, key)
|
||||||
if legacy_row is not None:
|
if legacy_row is not None:
|
||||||
|
if key in {"sys_auth_token_ttl_days", "auth_token_ttl_days"} and legacy_auth_ttl_hours is None:
|
||||||
|
try:
|
||||||
|
legacy_auth_ttl_hours = _coerce_auth_ttl_hours_from_legacy(_read_setting_value(legacy_row))
|
||||||
|
except Exception:
|
||||||
|
legacy_auth_ttl_hours = None
|
||||||
session.delete(legacy_row)
|
session.delete(legacy_row)
|
||||||
dirty = True
|
dirty = True
|
||||||
|
|
||||||
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
|
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
|
||||||
row = session.get(PlatformSetting, key)
|
row = session.get(PlatformSetting, key)
|
||||||
|
default_value = bootstrap_values.get(key, meta["value"])
|
||||||
|
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
|
||||||
|
default_value = legacy_auth_ttl_hours
|
||||||
if row is None:
|
if row is None:
|
||||||
_upsert_setting_row(
|
_upsert_setting_row(
|
||||||
session,
|
session,
|
||||||
|
|
@ -63,22 +81,42 @@ def ensure_default_system_settings(session: Session) -> None:
|
||||||
category=str(meta["category"]),
|
category=str(meta["category"]),
|
||||||
description=str(meta["description"]),
|
description=str(meta["description"]),
|
||||||
value_type=str(meta["value_type"]),
|
value_type=str(meta["value_type"]),
|
||||||
value=bootstrap_values.get(key, meta["value"]),
|
value=default_value,
|
||||||
is_public=bool(meta["is_public"]),
|
is_public=bool(meta["is_public"]),
|
||||||
sort_order=int(meta["sort_order"]),
|
sort_order=int(meta["sort_order"]),
|
||||||
)
|
)
|
||||||
dirty = True
|
dirty = True
|
||||||
continue
|
continue
|
||||||
changed = False
|
changed = False
|
||||||
|
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
|
||||||
|
try:
|
||||||
|
current_value = int(_read_setting_value(row))
|
||||||
|
except Exception:
|
||||||
|
current_value = int(meta["value"])
|
||||||
|
if current_value == int(meta["value"]) and legacy_auth_ttl_hours != current_value:
|
||||||
|
row.value_type = str(meta["value_type"])
|
||||||
|
row.value_json = json.dumps(legacy_auth_ttl_hours, ensure_ascii=False)
|
||||||
|
changed = True
|
||||||
for field in ("name", "category", "description", "value_type"):
|
for field in ("name", "category", "description", "value_type"):
|
||||||
value = str(meta[field])
|
value = str(meta[field])
|
||||||
if not getattr(row, field):
|
if key in PROTECTED_SETTING_KEYS:
|
||||||
|
if getattr(row, field) != value:
|
||||||
setattr(row, field, value)
|
setattr(row, field, value)
|
||||||
changed = True
|
changed = True
|
||||||
if getattr(row, "sort_order", None) is None:
|
elif not getattr(row, field):
|
||||||
|
setattr(row, field, value)
|
||||||
|
changed = True
|
||||||
|
if key in PROTECTED_SETTING_KEYS:
|
||||||
|
if int(getattr(row, "sort_order", 100) or 100) != int(meta["sort_order"]):
|
||||||
row.sort_order = int(meta["sort_order"])
|
row.sort_order = int(meta["sort_order"])
|
||||||
changed = True
|
changed = True
|
||||||
if getattr(row, "is_public", None) is None:
|
if bool(getattr(row, "is_public", False)) != bool(meta["is_public"]):
|
||||||
|
row.is_public = bool(meta["is_public"])
|
||||||
|
changed = True
|
||||||
|
elif getattr(row, "sort_order", None) is None:
|
||||||
|
row.sort_order = int(meta["sort_order"])
|
||||||
|
changed = True
|
||||||
|
if key not in PROTECTED_SETTING_KEYS and getattr(row, "is_public", None) is None:
|
||||||
row.is_public = bool(meta["is_public"])
|
row.is_public = bool(meta["is_public"])
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,14 @@
|
||||||
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
|
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
|
||||||
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
|
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
|
||||||
|
|
||||||
### 1.2 低风险重构优先
|
### 1.2 领域内聚优先于机械拆分
|
||||||
|
|
||||||
|
- 代码拆分的第一判断标准是“是否仍属于同一业务域”,不是“是否还能再拆小”。
|
||||||
|
- 同一业务域内的读、写、校验、少量编排、少量派生逻辑,可以保留在同一个模块中。
|
||||||
|
- 如果拆分只会制造多层跳转、隐藏真实依赖、降低可读性,则不应继续拆。
|
||||||
|
- 真正需要拆分的场景是跨域、跨层、跨边界,而不是单纯文件偏长。
|
||||||
|
|
||||||
|
### 1.3 低风险重构优先
|
||||||
|
|
||||||
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。
|
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。
|
||||||
- 同一轮改动里,默认**不要**同时做:
|
- 同一轮改动里,默认**不要**同时做:
|
||||||
|
|
@ -30,13 +37,13 @@
|
||||||
- 行为修复
|
- 行为修复
|
||||||
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。
|
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。
|
||||||
|
|
||||||
### 1.3 装配层必须薄
|
### 1.4 装配层必须薄
|
||||||
|
|
||||||
- 页面层、路由层、应用启动层都只负责装配。
|
- 页面层、路由层、应用启动层都只负责装配。
|
||||||
- 装配层可以做依赖注入、状态接线、事件转发。
|
- 装配层可以做依赖注入、状态接线、事件转发。
|
||||||
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
|
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
|
||||||
|
|
||||||
### 1.4 新文件必须按主题命名
|
### 1.5 新文件必须按主题命名
|
||||||
|
|
||||||
- 文件名必须直接表达职责。
|
- 文件名必须直接表达职责。
|
||||||
- 禁止模糊命名,例如:
|
- 禁止模糊命名,例如:
|
||||||
|
|
@ -70,6 +77,12 @@
|
||||||
- `frontend/src/utils`
|
- `frontend/src/utils`
|
||||||
- 真正跨领域的通用工具
|
- 真正跨领域的通用工具
|
||||||
|
|
||||||
|
目录分层的目标是稳定边界,不是把每一段逻辑都拆成独立文件:
|
||||||
|
|
||||||
|
- 同一页面域内强关联的视图、状态、交互逻辑,允许在同一模块内靠近放置
|
||||||
|
- 只有当某段逻辑已经被多个页面或多个子流程稳定复用时,才提炼到更高层级
|
||||||
|
- 禁止为了“文件更短”而把一个连续可读的页面流程拆成大量来回跳转的小文件
|
||||||
|
|
||||||
### 2.2 页面文件职责
|
### 2.2 页面文件职责
|
||||||
|
|
||||||
页面文件如:
|
页面文件如:
|
||||||
|
|
@ -83,12 +96,14 @@
|
||||||
- 只做页面装配
|
- 只做页面装配
|
||||||
- 只组织已有区块、弹层、控制器 hook
|
- 只组织已有区块、弹层、控制器 hook
|
||||||
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
|
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
|
||||||
|
- 如果一个页面本身就是单一业务域,并且逻辑连续可读,可以保留适量页面内状态与事件处理
|
||||||
|
- 不要求为了行数把本来紧密耦合的页面逻辑强拆到多个 hooks / sections / shared 文件中
|
||||||
|
|
||||||
页面文件目标体量:
|
页面文件目标体量:
|
||||||
|
|
||||||
- 目标:`< 500` 行
|
- 行数只作为预警,不作为硬性拆分依据
|
||||||
- 可接受上限:`800` 行
|
- 先判断页面是否仍然属于单一业务域、是否能顺序读懂、依赖是否清晰
|
||||||
- 超过 `800` 行必须优先拆出页面控制器 hook 或区块装配组件
|
- 只有在页面同时承担多个子域、多个弹层流程、多个数据源编排时,才优先拆出页面控制器 hook 或区块装配组件
|
||||||
|
|
||||||
### 2.3 控制器 hook 规范
|
### 2.3 控制器 hook 规范
|
||||||
|
|
||||||
|
|
@ -111,12 +126,15 @@
|
||||||
- 一个 hook 只服务一个明确页面或一个明确子流程
|
- 一个 hook 只服务一个明确页面或一个明确子流程
|
||||||
- hook 不直接产出大量 JSX
|
- hook 不直接产出大量 JSX
|
||||||
- hook 内部允许组合更小的子 hook,但不要为了拆分而拆分
|
- hook 内部允许组合更小的子 hook,但不要为了拆分而拆分
|
||||||
|
- 如果页面逻辑并不复杂,不要求必须抽出“页面总 hook”
|
||||||
|
- 只有当副作用编排、状态联动、接口交互已经影响页面可读性时,才值得抽成控制器 hook
|
||||||
|
|
||||||
控制器 hook 目标体量:
|
控制器 hook 目标体量:
|
||||||
|
|
||||||
- 目标:`< 800` 行
|
- 行数只作为风险提示
|
||||||
- 可接受上限:`1000` 行
|
- 优先保证 hook 的流程连续、命名清晰、状态收口明确
|
||||||
- 超过 `1000` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api`
|
- 如果继续拆分只会让调用链更深、上下文更难追踪,则不应继续拆
|
||||||
|
- 只有当 hook 明显同时承载多个子流程时,才按主题拆成子 hook 或把稳定复用逻辑提到 `shared`/`api`
|
||||||
|
|
||||||
### 2.4 视图组件规范
|
### 2.4 视图组件规范
|
||||||
|
|
||||||
|
|
@ -130,6 +148,7 @@
|
||||||
- 视图组件默认不直接请求接口
|
- 视图组件默认不直接请求接口
|
||||||
- 视图组件只接收已经整理好的 props
|
- 视图组件只接收已经整理好的 props
|
||||||
- 纯视图组件内部不保留与页面强耦合的业务缓存
|
- 纯视图组件内部不保留与页面强耦合的业务缓存
|
||||||
|
- 不要求把所有小片段都抽成组件;只在存在明确复用、明显视觉区块、或能显著降低页面噪音时再拆组件
|
||||||
|
|
||||||
### 2.5 前端复用原则
|
### 2.5 前端复用原则
|
||||||
|
|
||||||
|
|
@ -137,6 +156,8 @@
|
||||||
- 三处以上重复,优先考虑抽取
|
- 三处以上重复,优先考虑抽取
|
||||||
- 同域复用优先放 `modules/<domain>/shared`
|
- 同域复用优先放 `modules/<domain>/shared`
|
||||||
- 跨域复用优先放 `src/components` 或 `src/utils`
|
- 跨域复用优先放 `src/components` 或 `src/utils`
|
||||||
|
- 如果抽取后的接口比原地实现更难理解,就不应抽取
|
||||||
|
- 不允许创建只有单个页面使用、但又被过度包装的“伪复用层”
|
||||||
|
|
||||||
### 2.6 前端禁止事项
|
### 2.6 前端禁止事项
|
||||||
|
|
||||||
|
|
@ -144,6 +165,8 @@
|
||||||
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
|
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
|
||||||
- 禁止创建无明确职责的超通用组件
|
- 禁止创建无明确职责的超通用组件
|
||||||
- 禁止为减少行数而做不可读的过度抽象
|
- 禁止为减少行数而做不可读的过度抽象
|
||||||
|
- 禁止为了满足结构指标,把单一页面域强拆成大量细碎 hooks、sections、shared 文件
|
||||||
|
- 禁止新增纯转发、纯包装、无独立语义价值的组件或 hook
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -211,7 +234,7 @@ Router 文件体量规则:
|
||||||
|
|
||||||
### 3.4 Service 规范
|
### 3.4 Service 规范
|
||||||
|
|
||||||
Service 必须按业务主题拆分。
|
Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。
|
||||||
|
|
||||||
允许的 service 类型:
|
允许的 service 类型:
|
||||||
|
|
||||||
|
|
@ -226,15 +249,18 @@ Service 必须按业务主题拆分。
|
||||||
|
|
||||||
Service 文件规则:
|
Service 文件规则:
|
||||||
|
|
||||||
- 一个文件只负责一个主题
|
- 一个文件只负责一个业务域或一个稳定子主题
|
||||||
- 同一文件内允许有私有 helper,但 helper 只能服务当前主题
|
- 同一文件内允许同时包含该域内的查询、写入、校验、少量派生逻辑
|
||||||
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service
|
- 同一文件内允许有私有 helper,但 helper 只能服务当前域
|
||||||
|
- 只有当一个文件已经明显跨域,或者把 router/core/provider 的职责卷入进来时,才必须继续拆分
|
||||||
|
- 不允许为了“看起来更模块化”而创建纯转发、纯 re-export、纯别名性质的 service 层
|
||||||
|
|
||||||
Service 体量规则:
|
Service 体量规则:
|
||||||
|
|
||||||
- 目标:`< 350` 行
|
- 行数只作为预警信号,不作为机械拆分依据
|
||||||
- 可接受上限:`500` 行
|
- 优先判断是否仍然保持单一业务域、可顺序阅读、依赖方向清晰
|
||||||
- 超过 `500` 行必须继续拆
|
- 如果一个文件虽然较大,但域边界稳定、跳转成本低、上下文连续,可以保留
|
||||||
|
- 如果一个文件即使不大,但已经跨域、跨层、混入无关职责,也必须拆分
|
||||||
|
|
||||||
### 3.6 Schema 规范
|
### 3.6 Schema 规范
|
||||||
|
|
||||||
|
|
@ -290,6 +316,7 @@ Service 体量规则:
|
||||||
- 禁止回到“大文件集中堆功能”的开发方式
|
- 禁止回到“大文件集中堆功能”的开发方式
|
||||||
- 禁止为了图省事把新逻辑加回兼容层
|
- 禁止为了图省事把新逻辑加回兼容层
|
||||||
- 禁止在没有明确复用收益时过度抽象
|
- 禁止在没有明确复用收益时过度抽象
|
||||||
|
- 禁止为了满足行数指标而把同一业务域强行拆碎
|
||||||
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
|
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Backend API entry
|
# Backend API entry
|
||||||
VITE_API_BASE=http://localhost:8000/api
|
VITE_API_BASE=/api
|
||||||
|
|
||||||
# Backend WebSocket entry
|
# Backend WebSocket entry
|
||||||
VITE_WS_BASE=ws://localhost:8000/ws/monitor
|
VITE_WS_BASE=/ws/monitor
|
||||||
|
|
|
||||||
|
|
@ -1075,3 +1075,23 @@ body {
|
||||||
max-height: 84vh;
|
max-height: 84vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-loading-shell {
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-loading-card {
|
||||||
|
min-width: min(320px, calc(100vw - 48px));
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 80%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 88%, transparent);
|
||||||
|
color: var(--subtitle);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,40 @@
|
||||||
import { useEffect, useState, type ReactElement } from 'react';
|
import { Suspense, lazy, useEffect, useState } from 'react';
|
||||||
import axios from 'axios';
|
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, ShieldCheck, SunMedium, X } from 'lucide-react';
|
||||||
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, SunMedium, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { PasswordInput } from './components/PasswordInput';
|
import { BotRouteAccessGate } from './app/BotRouteAccessGate';
|
||||||
|
import { PanelLoginGate } from './app/PanelLoginGate';
|
||||||
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
||||||
import { APP_ENDPOINTS } from './config/env';
|
|
||||||
import { useBotsSync } from './hooks/useBotsSync';
|
import { useBotsSync } from './hooks/useBotsSync';
|
||||||
import { appEn } from './i18n/app.en';
|
import { appEn } from './i18n/app.en';
|
||||||
import { appZhCn } from './i18n/app.zh-cn';
|
import { appZhCn } from './i18n/app.zh-cn';
|
||||||
import { pickLocale } from './i18n';
|
import { pickLocale } from './i18n';
|
||||||
import { BotHomePage } from './modules/bot-home/BotHomePage';
|
|
||||||
import { PlatformAdminDashboardPage } from './modules/platform/PlatformAdminDashboardPage';
|
|
||||||
import { PlatformBotManagementPage } from './modules/platform/PlatformBotManagementPage';
|
|
||||||
import { PlatformImageManagementPage } from './modules/platform/PlatformImageManagementPage';
|
|
||||||
import { PlatformSettingsPage } from './modules/platform/components/PlatformSettingsModal';
|
|
||||||
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
|
|
||||||
import { TemplateManagerPage } from './modules/platform/components/TemplateManagerModal';
|
|
||||||
import { useAppStore } from './store/appStore';
|
import { useAppStore } from './store/appStore';
|
||||||
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
|
|
||||||
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
|
|
||||||
import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
|
import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
|
||||||
import './components/ui/SharedUi.css';
|
import './components/ui/SharedUi.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import './App.h5.css';
|
import './App.h5.css';
|
||||||
|
|
||||||
const defaultLoadingTitle = 'Dashboard Nanobot';
|
const defaultLoadingTitle = 'Dashboard Nanobot';
|
||||||
|
const LazyBotHomePage = lazy(() => import('./modules/bot-home/BotHomePage').then((module) => ({ default: module.BotHomePage })));
|
||||||
|
const LazyPlatformAdminDashboardPage = lazy(() => import('./modules/platform/PlatformAdminDashboardPage').then((module) => ({ default: module.PlatformAdminDashboardPage })));
|
||||||
|
const LazyPlatformBotManagementPage = lazy(() => import('./modules/platform/PlatformBotManagementPage').then((module) => ({ default: module.PlatformBotManagementPage })));
|
||||||
|
const LazyPlatformImageManagementPage = lazy(() => import('./modules/platform/PlatformImageManagementPage').then((module) => ({ default: module.PlatformImageManagementPage })));
|
||||||
|
const LazyPlatformLoginLogPage = lazy(() => import('./modules/platform/PlatformLoginLogPage').then((module) => ({ default: module.PlatformLoginLogPage })));
|
||||||
|
const LazyPlatformSettingsPage = lazy(() => import('./modules/platform/components/PlatformSettingsPage').then((module) => ({ default: module.PlatformSettingsPage })));
|
||||||
|
const LazySkillMarketManagerPage = lazy(() => import('./modules/platform/components/SkillMarketManagerPage').then((module) => ({ default: module.SkillMarketManagerPage })));
|
||||||
|
const LazyTemplateManagerPage = lazy(() => import('./modules/platform/components/TemplateManagerPage').then((module) => ({ default: module.TemplateManagerPage })));
|
||||||
|
|
||||||
type CompactBotPanelTab = 'chat' | 'runtime';
|
type CompactBotPanelTab = 'chat' | 'runtime';
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
function RouteLoadingFallback({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="route-loading-shell">
|
||||||
|
<div className="route-loading-card">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell() {
|
||||||
const route = useAppRoute();
|
const route = useAppRoute();
|
||||||
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
||||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||||
|
|
@ -40,13 +46,6 @@ function AuthenticatedApp() {
|
||||||
const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false);
|
const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false);
|
||||||
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
|
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
|
||||||
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
|
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
|
||||||
const [singleBotPassword, setSingleBotPassword] = useState('');
|
|
||||||
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
|
||||||
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
|
||||||
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
|
|
||||||
const passwordToggleLabels = locale === 'zh'
|
|
||||||
? { show: '显示密码', hide: '隐藏密码' }
|
|
||||||
: { show: 'Show password', hide: 'Hide password' };
|
|
||||||
|
|
||||||
const forcedBotId = route.kind === 'bot' ? route.botId : '';
|
const forcedBotId = route.kind === 'bot' ? route.botId : '';
|
||||||
useBotsSync(forcedBotId || undefined);
|
useBotsSync(forcedBotId || undefined);
|
||||||
|
|
@ -65,9 +64,6 @@ function AuthenticatedApp() {
|
||||||
const forcedBotName = String(forcedBot?.name || '').trim();
|
const forcedBotName = String(forcedBot?.name || '').trim();
|
||||||
const forcedBotIdLabel = String(forcedBotId || '').trim();
|
const forcedBotIdLabel = String(forcedBotId || '').trim();
|
||||||
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle;
|
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle;
|
||||||
const shouldPromptSingleBotPassword = Boolean(
|
|
||||||
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
|
|
||||||
);
|
|
||||||
const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined });
|
const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined });
|
||||||
const showNavRail = route.kind !== 'bot' && !compactMode;
|
const showNavRail = route.kind !== 'bot' && !compactMode;
|
||||||
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
|
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
|
||||||
|
|
@ -81,12 +77,6 @@ function AuthenticatedApp() {
|
||||||
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
|
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
|
||||||
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
|
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSingleBotUnlocked(false);
|
|
||||||
setSingleBotPassword('');
|
|
||||||
setSingleBotPasswordError('');
|
|
||||||
}, [forcedBotId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showBotPanelDrawerEntry) {
|
if (!showBotPanelDrawerEntry) {
|
||||||
setBotPanelDrawerOpen(false);
|
setBotPanelDrawerOpen(false);
|
||||||
|
|
@ -98,52 +88,6 @@ function AuthenticatedApp() {
|
||||||
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
|
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
|
||||||
}, [route.kind, showAppNavDrawerEntry]);
|
}, [route.kind, showAppNavDrawerEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
|
||||||
const stored = getBotAccessPassword(forcedBotId);
|
|
||||||
if (!stored) return;
|
|
||||||
let alive = true;
|
|
||||||
const boot = async () => {
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
|
|
||||||
if (!alive) return;
|
|
||||||
setBotAccessPassword(forcedBotId, stored);
|
|
||||||
setSingleBotUnlocked(true);
|
|
||||||
setSingleBotPassword('');
|
|
||||||
setSingleBotPasswordError('');
|
|
||||||
} catch {
|
|
||||||
clearBotAccessPassword(forcedBotId);
|
|
||||||
if (!alive) return;
|
|
||||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void boot();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
|
|
||||||
|
|
||||||
const unlockSingleBot = async () => {
|
|
||||||
const entered = String(singleBotPassword || '').trim();
|
|
||||||
if (!entered || route.kind !== 'bot' || !forcedBotId) {
|
|
||||||
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSingleBotSubmitting(true);
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
|
|
||||||
setBotAccessPassword(forcedBotId, entered);
|
|
||||||
setSingleBotPasswordError('');
|
|
||||||
setSingleBotUnlocked(true);
|
|
||||||
setSingleBotPassword('');
|
|
||||||
} catch {
|
|
||||||
clearBotAccessPassword(forcedBotId);
|
|
||||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSingleBotSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const botPanelLabels = t.botPanels;
|
const botPanelLabels = t.botPanels;
|
||||||
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
|
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
|
||||||
const drawerBotId = String(forcedBotId || '').trim() || '-';
|
const drawerBotId = String(forcedBotId || '').trim() || '-';
|
||||||
|
|
@ -171,6 +115,7 @@ function AuthenticatedApp() {
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer },
|
{ kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer },
|
||||||
|
{ kind: 'system-login-logs', label: isZh ? '登录日志' : 'Login Logs', icon: ShieldCheck },
|
||||||
{ kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText },
|
{ kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText },
|
||||||
{ kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 },
|
{ kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 },
|
||||||
{ kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes },
|
{ kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes },
|
||||||
|
|
@ -188,28 +133,32 @@ function AuthenticatedApp() {
|
||||||
const renderRoutePage = () => {
|
const renderRoutePage = () => {
|
||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
case 'admin-dashboard':
|
case 'admin-dashboard':
|
||||||
return <PlatformAdminDashboardPage compactMode={compactMode} />;
|
return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
|
||||||
case 'admin-bots':
|
case 'admin-bots':
|
||||||
return <PlatformBotManagementPage compactMode={compactMode} />;
|
return <LazyPlatformBotManagementPage compactMode={compactMode} />;
|
||||||
case 'system-skills':
|
case 'system-skills':
|
||||||
return <SkillMarketManagerPage isZh={isZh} />;
|
return <LazySkillMarketManagerPage isZh={isZh} />;
|
||||||
|
case 'system-login-logs':
|
||||||
|
return <LazyPlatformLoginLogPage isZh={isZh} />;
|
||||||
case 'system-templates':
|
case 'system-templates':
|
||||||
return <TemplateManagerPage isZh={isZh} />;
|
return <LazyTemplateManagerPage isZh={isZh} />;
|
||||||
case 'system-settings':
|
case 'system-settings':
|
||||||
return <PlatformSettingsPage isZh={isZh} />;
|
return <LazyPlatformSettingsPage isZh={isZh} />;
|
||||||
case 'system-images':
|
case 'system-images':
|
||||||
return <PlatformImageManagementPage isZh={isZh} />;
|
return <LazyPlatformImageManagementPage isZh={isZh} />;
|
||||||
case 'bot':
|
case 'bot':
|
||||||
return (
|
return (
|
||||||
<BotHomePage
|
<BotRouteAccessGate bot={forcedBot} botId={forcedBotId}>
|
||||||
|
<LazyBotHomePage
|
||||||
botId={forcedBotId}
|
botId={forcedBotId}
|
||||||
compactMode={compactMode}
|
compactMode={compactMode}
|
||||||
compactPanelTab={botCompactPanelTab}
|
compactPanelTab={botCompactPanelTab}
|
||||||
onCompactPanelTabChange={setBotCompactPanelTab}
|
onCompactPanelTabChange={setBotCompactPanelTab}
|
||||||
/>
|
/>
|
||||||
|
</BotRouteAccessGate>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <PlatformAdminDashboardPage compactMode={compactMode} />;
|
return <LazyPlatformAdminDashboardPage compactMode={compactMode} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -343,7 +292,9 @@ function AuthenticatedApp() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="main-stage">
|
<main className="main-stage">
|
||||||
|
<Suspense fallback={<RouteLoadingFallback label={isZh ? '页面加载中...' : 'Loading page...'} />}>
|
||||||
{renderRoutePage()}
|
{renderRoutePage()}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -458,176 +409,15 @@ function AuthenticatedApp() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{shouldPromptSingleBotPassword ? (
|
|
||||||
<div className="modal-mask app-modal-mask">
|
|
||||||
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
||||||
<h1>{forcedBot?.name || forcedBotId}</h1>
|
|
||||||
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
|
|
||||||
<div className="app-login-form">
|
|
||||||
<PasswordInput
|
|
||||||
className="input"
|
|
||||||
value={singleBotPassword}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSingleBotPassword(event.target.value);
|
|
||||||
if (singleBotPasswordError) setSingleBotPasswordError('');
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') void unlockSingleBot();
|
|
||||||
}}
|
|
||||||
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
|
|
||||||
autoFocus
|
|
||||||
toggleLabels={passwordToggleLabels}
|
|
||||||
/>
|
|
||||||
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
|
|
||||||
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
|
|
||||||
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelLoginGate({ children }: { children: ReactElement }) {
|
|
||||||
const route = useAppRoute();
|
|
||||||
const { theme, locale } = useAppStore();
|
|
||||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
|
||||||
const [checking, setChecking] = useState(true);
|
|
||||||
const [required, setRequired] = useState(false);
|
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const passwordToggleLabels = locale === 'zh'
|
|
||||||
? { show: '显示密码', hide: '隐藏密码' }
|
|
||||||
: { show: 'Show password', hide: 'Hide password' };
|
|
||||||
const bypassPanelGate = route.kind === 'bot';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (bypassPanelGate) {
|
|
||||||
setRequired(false);
|
|
||||||
setAuthenticated(true);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let alive = true;
|
|
||||||
const boot = async () => {
|
|
||||||
try {
|
|
||||||
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
|
|
||||||
if (!alive) return;
|
|
||||||
const enabled = Boolean(status.data?.enabled);
|
|
||||||
if (!enabled) {
|
|
||||||
setRequired(false);
|
|
||||||
setAuthenticated(true);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequired(true);
|
|
||||||
const stored = getPanelAccessPassword();
|
|
||||||
if (!stored) {
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
|
|
||||||
if (!alive) return;
|
|
||||||
setAuthenticated(true);
|
|
||||||
} catch {
|
|
||||||
clearPanelAccessPassword();
|
|
||||||
if (!alive) return;
|
|
||||||
setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
|
|
||||||
} finally {
|
|
||||||
if (alive) setChecking(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!alive) return;
|
|
||||||
clearPanelAccessPassword();
|
|
||||||
setRequired(true);
|
|
||||||
setAuthenticated(false);
|
|
||||||
setError(locale === 'zh' ? '无法校验面板访问状态,请检查后端连接后重试。' : 'Unable to verify panel access. Check the backend connection and try again.');
|
|
||||||
setChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void boot();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [bypassPanelGate, locale]);
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
const next = String(password || '').trim();
|
|
||||||
if (!next) {
|
|
||||||
setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
|
|
||||||
setPanelAccessPassword(next);
|
|
||||||
setAuthenticated(true);
|
|
||||||
} catch {
|
|
||||||
clearPanelAccessPassword();
|
|
||||||
setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (checking) {
|
|
||||||
return (
|
|
||||||
<div className="app-shell" data-theme={theme}>
|
|
||||||
<div className="app-login-shell">
|
|
||||||
<div className="app-login-card">
|
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
||||||
<h1>{t.title}</h1>
|
|
||||||
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (required && !authenticated) {
|
|
||||||
return (
|
|
||||||
<div className="app-shell" data-theme={theme}>
|
|
||||||
<div className="app-login-shell">
|
|
||||||
<div className="app-login-card">
|
|
||||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
|
|
||||||
<h1>{t.title}</h1>
|
|
||||||
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
|
|
||||||
<div className="app-login-form">
|
|
||||||
<PasswordInput
|
|
||||||
className="input"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') void onSubmit();
|
|
||||||
}}
|
|
||||||
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
|
|
||||||
toggleLabels={passwordToggleLabels}
|
|
||||||
/>
|
|
||||||
{error ? <div className="app-login-error">{error}</div> : null}
|
|
||||||
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
|
|
||||||
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const route = useAppRoute();
|
||||||
return (
|
return (
|
||||||
<PanelLoginGate>
|
<PanelLoginGate bypass={route.kind === 'bot'}>
|
||||||
<AuthenticatedApp />
|
<AppShell />
|
||||||
</PanelLoginGate>
|
</PanelLoginGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useAppStore } from '../store/appStore';
|
||||||
import { APP_ENDPOINTS } from '../config/env';
|
import { APP_ENDPOINTS } from '../config/env';
|
||||||
import type { BotState, ChatMessage } from '../types/bot';
|
import type { BotState, ChatMessage } from '../types/bot';
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../modules/dashboard/messageParser';
|
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../shared/text/messageText';
|
||||||
import { pickLocale } from '../i18n';
|
import { pickLocale } from '../i18n';
|
||||||
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
|
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
|
||||||
import { botsSyncEn } from '../i18n/bots-sync.en';
|
import { botsSyncEn } from '../i18n/bots-sync.en';
|
||||||
import { buildMonitorWsUrl } from '../utils/botAccess';
|
import { buildMonitorWsUrl, notifyBotAuthInvalid } from '../utils/botAccess';
|
||||||
|
|
||||||
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
|
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
|
||||||
const s = (v || '').toUpperCase();
|
const s = (v || '').toUpperCase();
|
||||||
|
|
@ -152,7 +152,12 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
}
|
}
|
||||||
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||||
setBots(res.data);
|
setBots(res.data);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
const status = Number(error?.response?.status || 0);
|
||||||
|
if (forced && status === 401) {
|
||||||
|
setBots([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
|
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -351,13 +356,16 @@ export function useBotsSync(forcedBotId?: string) {
|
||||||
addBotLog(bot.id, String(data.text || ''));
|
addBotLog(bot.id, String(data.text || ''));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
const hb = heartbeatsRef.current[bot.id];
|
const hb = heartbeatsRef.current[bot.id];
|
||||||
if (hb) {
|
if (hb) {
|
||||||
window.clearInterval(hb);
|
window.clearInterval(hb);
|
||||||
delete heartbeatsRef.current[bot.id];
|
delete heartbeatsRef.current[bot.id];
|
||||||
}
|
}
|
||||||
delete socketsRef.current[bot.id];
|
delete socketsRef.current[bot.id];
|
||||||
|
if (event.code === 4401 && forced === bot.id) {
|
||||||
|
notifyBotAuthInvalid(bot.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socketsRef.current[bot.id] = ws;
|
socketsRef.current[bot.id] = ws;
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ export function BotDashboardModule({
|
||||||
botStarting: dashboard.t.botStarting,
|
botStarting: dashboard.t.botStarting,
|
||||||
botStopping: dashboard.t.botStopping,
|
botStopping: dashboard.t.botStopping,
|
||||||
chatDisabled: dashboard.t.chatDisabled,
|
chatDisabled: dashboard.t.chatDisabled,
|
||||||
close: dashboard.t.close,
|
|
||||||
controlCommandsHide: dashboard.t.controlCommandsHide,
|
controlCommandsHide: dashboard.t.controlCommandsHide,
|
||||||
controlCommandsShow: dashboard.t.controlCommandsShow,
|
controlCommandsShow: dashboard.t.controlCommandsShow,
|
||||||
copyPrompt: dashboard.t.copyPrompt,
|
copyPrompt: dashboard.t.copyPrompt,
|
||||||
|
|
|
||||||
|
|
@ -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 { MessageCircle, MessageSquareText, X } from 'lucide-react';
|
||||||
|
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { CreateBotWizardModal } from '../../onboarding/CreateBotWizardModal';
|
|
||||||
import { TopicFeedPanel } from '../topic/TopicFeedPanel';
|
|
||||||
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
||||||
import { BotListPanel } from './BotListPanel';
|
import { BotListPanel } from './BotListPanel';
|
||||||
import { DashboardChatPanel } from './DashboardChatPanel';
|
import { DashboardChatPanel } from './DashboardChatPanel';
|
||||||
import { DashboardModalStack } from './DashboardModalStack';
|
|
||||||
import { RuntimePanel } from './RuntimePanel';
|
import { RuntimePanel } from './RuntimePanel';
|
||||||
|
|
||||||
|
const LazyCreateBotWizardModal = lazy(() =>
|
||||||
|
import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })),
|
||||||
|
);
|
||||||
|
const LazyTopicFeedPanel = lazy(() =>
|
||||||
|
import('../topic/TopicFeedPanel').then((module) => ({ default: module.TopicFeedPanel })),
|
||||||
|
);
|
||||||
|
const LazyDashboardModalStack = lazy(() =>
|
||||||
|
import('./DashboardModalStack').then((module) => ({ default: module.DashboardModalStack })),
|
||||||
|
);
|
||||||
|
|
||||||
|
type TopicFeedPanelProps = Parameters<typeof import('../topic/TopicFeedPanel').TopicFeedPanel>[0];
|
||||||
|
type DashboardModalStackProps = Parameters<typeof import('./DashboardModalStack').DashboardModalStack>[0];
|
||||||
|
type CreateBotWizardModalProps = Parameters<typeof import('../../onboarding/CreateBotWizardModal').CreateBotWizardModal>[0];
|
||||||
|
|
||||||
export interface BotDashboardViewProps {
|
export interface BotDashboardViewProps {
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
hasForcedBot: boolean;
|
hasForcedBot: boolean;
|
||||||
|
|
@ -25,12 +36,12 @@ export interface BotDashboardViewProps {
|
||||||
runtimeViewMode: RuntimeViewMode;
|
runtimeViewMode: RuntimeViewMode;
|
||||||
hasTopicUnread: boolean;
|
hasTopicUnread: boolean;
|
||||||
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
|
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
|
||||||
topicFeedPanelProps: ComponentProps<typeof TopicFeedPanel>;
|
topicFeedPanelProps: TopicFeedPanelProps;
|
||||||
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
|
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
|
||||||
runtimePanelProps: ComponentProps<typeof RuntimePanel>;
|
runtimePanelProps: ComponentProps<typeof RuntimePanel>;
|
||||||
onCompactClose: () => void;
|
onCompactClose: () => void;
|
||||||
dashboardModalStackProps: ComponentProps<typeof DashboardModalStack>;
|
dashboardModalStackProps: DashboardModalStackProps;
|
||||||
createBotModalProps: ComponentProps<typeof CreateBotWizardModal>;
|
createBotModalProps: CreateBotWizardModalProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BotDashboardView({
|
export function BotDashboardView({
|
||||||
|
|
@ -54,6 +65,24 @@ export function BotDashboardView({
|
||||||
dashboardModalStackProps,
|
dashboardModalStackProps,
|
||||||
createBotModalProps,
|
createBotModalProps,
|
||||||
}: BotDashboardViewProps) {
|
}: BotDashboardViewProps) {
|
||||||
|
const hasDashboardOverlay = Boolean(
|
||||||
|
dashboardModalStackProps.resourceMonitorModal.open ||
|
||||||
|
dashboardModalStackProps.baseConfigModal.open ||
|
||||||
|
dashboardModalStackProps.paramConfigModal.open ||
|
||||||
|
dashboardModalStackProps.channelConfigModal.open ||
|
||||||
|
dashboardModalStackProps.topicConfigModal.open ||
|
||||||
|
dashboardModalStackProps.skillsModal.open ||
|
||||||
|
dashboardModalStackProps.skillMarketInstallModal.open ||
|
||||||
|
dashboardModalStackProps.mcpConfigModal.open ||
|
||||||
|
dashboardModalStackProps.envParamsModal.open ||
|
||||||
|
dashboardModalStackProps.cronJobsModal.open ||
|
||||||
|
dashboardModalStackProps.templateManagerModal.open ||
|
||||||
|
dashboardModalStackProps.agentFilesModal.open ||
|
||||||
|
dashboardModalStackProps.runtimeActionModal.open ||
|
||||||
|
dashboardModalStackProps.workspacePreviewModal.preview ||
|
||||||
|
dashboardModalStackProps.workspaceHoverCard.state,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
|
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
|
||||||
|
|
@ -92,7 +121,13 @@ export function BotDashboardView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-main-content-body">
|
<div className="ops-main-content-body">
|
||||||
{runtimeViewMode === 'topic' ? <TopicFeedPanel {...topicFeedPanelProps} /> : <DashboardChatPanel {...dashboardChatPanelProps} />}
|
{runtimeViewMode === 'topic' ? (
|
||||||
|
<Suspense fallback={<div className="ops-empty-inline">{isZh ? '读取主题消息中...' : 'Loading topic feed...'}</div>}>
|
||||||
|
<LazyTopicFeedPanel {...topicFeedPanelProps} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<DashboardChatPanel {...dashboardChatPanelProps} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,8 +151,16 @@ export function BotDashboardView({
|
||||||
</LucentIconButton>
|
</LucentIconButton>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DashboardModalStack {...dashboardModalStackProps} />
|
{hasDashboardOverlay ? (
|
||||||
<CreateBotWizardModal {...createBotModalProps} />
|
<Suspense fallback={null}>
|
||||||
|
<LazyDashboardModalStack {...dashboardModalStackProps} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
{createBotModalProps.open ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LazyCreateBotWizardModal {...createBotModalProps} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 type { RefObject } from 'react';
|
||||||
|
|
||||||
import { DrawerShell } from '../../../components/DrawerShell';
|
import { DrawerShell } from '../../../components/DrawerShell';
|
||||||
import { PasswordInput } from '../../../components/PasswordInput';
|
import { PasswordInput } from '../../../components/PasswordInput';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import type { BotChannel, BotTopic, ChannelType, TopicPresetTemplate, WeixinLoginStatus } from '../types';
|
import type { BotChannel, ChannelType, WeixinLoginStatus } from '../types';
|
||||||
import './DashboardManagementModals.css';
|
import './DashboardManagementModals.css';
|
||||||
|
|
||||||
interface PasswordToggleLabels {
|
interface PasswordToggleLabels {
|
||||||
|
|
@ -593,334 +593,3 @@ export function ChannelConfigModal({
|
||||||
</DrawerShell>
|
</DrawerShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopicConfigModalProps {
|
|
||||||
open: boolean;
|
|
||||||
topics: BotTopic[];
|
|
||||||
expandedTopicByKey: Record<string, boolean>;
|
|
||||||
newTopicPanelOpen: boolean;
|
|
||||||
topicPresetMenuOpen: boolean;
|
|
||||||
newTopicAdvancedOpen: boolean;
|
|
||||||
newTopicSourceLabel: string;
|
|
||||||
newTopicKey: string;
|
|
||||||
newTopicName: string;
|
|
||||||
newTopicDescription: string;
|
|
||||||
newTopicPurpose: string;
|
|
||||||
newTopicIncludeWhen: string;
|
|
||||||
newTopicExcludeWhen: string;
|
|
||||||
newTopicExamplesPositive: string;
|
|
||||||
newTopicExamplesNegative: string;
|
|
||||||
newTopicPriority: string;
|
|
||||||
effectiveTopicPresetTemplates: TopicPresetTemplate[];
|
|
||||||
topicPresetMenuRef: RefObject<HTMLDivElement | null>;
|
|
||||||
isSavingTopic: boolean;
|
|
||||||
hasSelectedBot: boolean;
|
|
||||||
isZh: boolean;
|
|
||||||
labels: Record<string, any>;
|
|
||||||
onClose: () => void;
|
|
||||||
getTopicUiKey: (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => string;
|
|
||||||
countRoutingTextList: (raw: string) => number;
|
|
||||||
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
|
|
||||||
onToggleExpandedTopic: (key: string) => void;
|
|
||||||
onRemoveTopic: (topic: BotTopic) => Promise<void> | void;
|
|
||||||
onSaveTopic: (topic: BotTopic) => Promise<void> | void;
|
|
||||||
onSetNewTopicPanelOpen: (value: boolean) => void;
|
|
||||||
onSetTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
||||||
onSetNewTopicAdvancedOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
||||||
onResetNewTopicDraft: () => void;
|
|
||||||
onNormalizeTopicKeyInput: (value: string) => string;
|
|
||||||
onSetNewTopicKey: (value: string) => void;
|
|
||||||
onSetNewTopicName: (value: string) => void;
|
|
||||||
onSetNewTopicDescription: (value: string) => void;
|
|
||||||
onSetNewTopicPurpose: (value: string) => void;
|
|
||||||
onSetNewTopicIncludeWhen: (value: string) => void;
|
|
||||||
onSetNewTopicExcludeWhen: (value: string) => void;
|
|
||||||
onSetNewTopicExamplesPositive: (value: string) => void;
|
|
||||||
onSetNewTopicExamplesNegative: (value: string) => void;
|
|
||||||
onSetNewTopicPriority: (value: string) => void;
|
|
||||||
onBeginTopicCreate: (presetId: string) => void;
|
|
||||||
onResolvePresetLabel: (preset: TopicPresetTemplate) => string;
|
|
||||||
onAddTopic: () => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopicConfigModal({
|
|
||||||
open,
|
|
||||||
topics,
|
|
||||||
expandedTopicByKey,
|
|
||||||
newTopicPanelOpen,
|
|
||||||
topicPresetMenuOpen,
|
|
||||||
newTopicAdvancedOpen,
|
|
||||||
newTopicSourceLabel,
|
|
||||||
newTopicKey,
|
|
||||||
newTopicName,
|
|
||||||
newTopicDescription,
|
|
||||||
newTopicPurpose,
|
|
||||||
newTopicIncludeWhen,
|
|
||||||
newTopicExcludeWhen,
|
|
||||||
newTopicExamplesPositive,
|
|
||||||
newTopicExamplesNegative,
|
|
||||||
newTopicPriority,
|
|
||||||
effectiveTopicPresetTemplates,
|
|
||||||
topicPresetMenuRef,
|
|
||||||
isSavingTopic,
|
|
||||||
hasSelectedBot,
|
|
||||||
isZh,
|
|
||||||
labels,
|
|
||||||
onClose,
|
|
||||||
getTopicUiKey,
|
|
||||||
countRoutingTextList,
|
|
||||||
onUpdateTopicLocal,
|
|
||||||
onToggleExpandedTopic,
|
|
||||||
onRemoveTopic,
|
|
||||||
onSaveTopic,
|
|
||||||
onSetNewTopicPanelOpen,
|
|
||||||
onSetTopicPresetMenuOpen,
|
|
||||||
onSetNewTopicAdvancedOpen,
|
|
||||||
onResetNewTopicDraft,
|
|
||||||
onNormalizeTopicKeyInput,
|
|
||||||
onSetNewTopicKey,
|
|
||||||
onSetNewTopicName,
|
|
||||||
onSetNewTopicDescription,
|
|
||||||
onSetNewTopicPurpose,
|
|
||||||
onSetNewTopicIncludeWhen,
|
|
||||||
onSetNewTopicExcludeWhen,
|
|
||||||
onSetNewTopicExamplesPositive,
|
|
||||||
onSetNewTopicExamplesNegative,
|
|
||||||
onSetNewTopicPriority,
|
|
||||||
onBeginTopicCreate,
|
|
||||||
onResolvePresetLabel,
|
|
||||||
onAddTopic,
|
|
||||||
}: TopicConfigModalProps) {
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DrawerShell
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title={labels.topicPanel}
|
|
||||||
size="extend"
|
|
||||||
closeLabel={labels.close}
|
|
||||||
bodyClassName="ops-config-drawer-body"
|
|
||||||
footer={(
|
|
||||||
!newTopicPanelOpen ? (
|
|
||||||
<div className="drawer-shell-footer-content">
|
|
||||||
<div className="drawer-shell-footer-main field-label">{labels.topicAddHint}</div>
|
|
||||||
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
|
|
||||||
<button className="btn btn-primary" disabled={isSavingTopic || !hasSelectedBot} onClick={() => onSetTopicPresetMenuOpen((prev) => !prev)}>
|
|
||||||
<Plus size={14} />
|
|
||||||
<span style={{ marginLeft: 6 }}>{labels.topicAdd}</span>
|
|
||||||
</button>
|
|
||||||
{topicPresetMenuOpen ? (
|
|
||||||
<div className="ops-topic-create-menu">
|
|
||||||
{effectiveTopicPresetTemplates.map((preset) => (
|
|
||||||
<button key={preset.id} className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate(preset.id)}>
|
|
||||||
{onResolvePresetLabel(preset)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate('blank')}>{labels.topicPresetBlank}</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="ops-config-modal">
|
|
||||||
<div className="wizard-channel-list ops-config-list-scroll">
|
|
||||||
{topics.length === 0 ? (
|
|
||||||
<div className="ops-empty-inline">{labels.topicEmpty}</div>
|
|
||||||
) : (
|
|
||||||
topics.map((topic, idx) => {
|
|
||||||
const uiKey = getTopicUiKey(topic, idx);
|
|
||||||
const expanded = expandedTopicByKey[uiKey] ?? idx === 0;
|
|
||||||
const includeCount = countRoutingTextList(String(topic.routing_include_when || ''));
|
|
||||||
const excludeCount = countRoutingTextList(String(topic.routing_exclude_when || ''));
|
|
||||||
return (
|
|
||||||
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
|
|
||||||
<div className="ops-config-card-header">
|
|
||||||
<div className="ops-config-card-main">
|
|
||||||
<strong className="mono">{topic.topic_key}</strong>
|
|
||||||
<div className="field-label">{topic.name || topic.topic_key}</div>
|
|
||||||
{!expanded ? (
|
|
||||||
<div className="ops-config-collapsed-meta">
|
|
||||||
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-card-actions">
|
|
||||||
<label className="field-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(topic.is_active)}
|
|
||||||
onChange={(e) => onUpdateTopicLocal(idx, { is_active: e.target.checked })}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
{labels.topicActive}
|
|
||||||
</label>
|
|
||||||
<LucentIconButton
|
|
||||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
|
||||||
disabled={isSavingTopic}
|
|
||||||
onClick={() => void onRemoveTopic(topic)}
|
|
||||||
tooltip={labels.delete}
|
|
||||||
aria-label={labels.delete}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</LucentIconButton>
|
|
||||||
<LucentIconButton
|
|
||||||
className="ops-plain-icon-btn"
|
|
||||||
onClick={() => onToggleExpandedTopic(uiKey)}
|
|
||||||
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
||||||
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
||||||
</LucentIconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expanded ? (
|
|
||||||
<>
|
|
||||||
<div className="ops-topic-grid">
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicName}</label>
|
|
||||||
<input className="input" value={topic.name || ''} onChange={(e) => onUpdateTopicLocal(idx, { name: e.target.value })} placeholder={labels.topicName} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicPriority}</label>
|
|
||||||
<input className="input mono" type="number" min={0} max={100} step={1} value={topic.routing_priority || '50'} onChange={(e) => onUpdateTopicLocal(idx, { routing_priority: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field ops-config-field-full">
|
|
||||||
<label className="field-label">{labels.topicDescription}</label>
|
|
||||||
<textarea className="input" rows={3} value={topic.description || ''} onChange={(e) => onUpdateTopicLocal(idx, { description: e.target.value })} placeholder={labels.topicDescription} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field ops-config-field-full">
|
|
||||||
<label className="field-label">{labels.topicPurpose}</label>
|
|
||||||
<textarea className="input" rows={3} value={topic.routing_purpose || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_purpose: e.target.value })} placeholder={labels.topicPurpose} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicIncludeWhen}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={topic.routing_include_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_include_when: e.target.value })} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExcludeWhen}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={topic.routing_exclude_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_exclude_when: e.target.value })} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExamplesPositive}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={topic.routing_examples_positive || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_positive: e.target.value })} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExamplesNegative}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={topic.routing_examples_negative || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_negative: e.target.value })} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row-between ops-config-footer">
|
|
||||||
<span className="field-label">{labels.topicAddHint}</span>
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void onSaveTopic(topic)}>
|
|
||||||
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
|
||||||
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{newTopicPanelOpen ? (
|
|
||||||
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
|
|
||||||
<div className="ops-config-card-header">
|
|
||||||
<div className="ops-config-card-main">
|
|
||||||
<strong>{labels.topicAdd}</strong>
|
|
||||||
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-card-actions">
|
|
||||||
<LucentIconButton
|
|
||||||
className="ops-plain-icon-btn"
|
|
||||||
onClick={() => onSetNewTopicAdvancedOpen((prev) => !prev)}
|
|
||||||
tooltip={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
|
|
||||||
aria-label={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
|
|
||||||
>
|
|
||||||
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
|
||||||
</LucentIconButton>
|
|
||||||
<LucentIconButton
|
|
||||||
className="ops-plain-icon-btn"
|
|
||||||
onClick={() => {
|
|
||||||
onSetNewTopicPanelOpen(false);
|
|
||||||
onSetTopicPresetMenuOpen(false);
|
|
||||||
onResetNewTopicDraft();
|
|
||||||
}}
|
|
||||||
tooltip={labels.cancel}
|
|
||||||
aria-label={labels.cancel}
|
|
||||||
>
|
|
||||||
<X size={15} />
|
|
||||||
</LucentIconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ops-topic-grid">
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicKey}</label>
|
|
||||||
<input className="input mono" value={newTopicKey} onChange={(e) => onSetNewTopicKey(onNormalizeTopicKeyInput(e.target.value))} placeholder={labels.topicKeyPlaceholder} autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicName}</label>
|
|
||||||
<input className="input" value={newTopicName} onChange={(e) => onSetNewTopicName(e.target.value)} placeholder={labels.topicName} autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field ops-config-field-full">
|
|
||||||
<label className="field-label">{labels.topicDescription}</label>
|
|
||||||
<textarea className="input" rows={3} value={newTopicDescription} onChange={(e) => onSetNewTopicDescription(e.target.value)} placeholder={labels.topicDescription} />
|
|
||||||
</div>
|
|
||||||
{newTopicAdvancedOpen ? (
|
|
||||||
<>
|
|
||||||
<div className="ops-config-field ops-config-field-full">
|
|
||||||
<label className="field-label">{labels.topicPurpose}</label>
|
|
||||||
<textarea className="input" rows={3} value={newTopicPurpose} onChange={(e) => onSetNewTopicPurpose(e.target.value)} placeholder={labels.topicPurpose} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicIncludeWhen}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={newTopicIncludeWhen} onChange={(e) => onSetNewTopicIncludeWhen(e.target.value)} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExcludeWhen}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={newTopicExcludeWhen} onChange={(e) => onSetNewTopicExcludeWhen(e.target.value)} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExamplesPositive}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={newTopicExamplesPositive} onChange={(e) => onSetNewTopicExamplesPositive(e.target.value)} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicExamplesNegative}</label>
|
|
||||||
<textarea className="input mono" rows={4} value={newTopicExamplesNegative} onChange={(e) => onSetNewTopicExamplesNegative(e.target.value)} placeholder={labels.topicListHint} />
|
|
||||||
</div>
|
|
||||||
<div className="ops-config-field">
|
|
||||||
<label className="field-label">{labels.topicPriority}</label>
|
|
||||||
<input className="input mono" type="number" min={0} max={100} step={1} value={newTopicPriority} onChange={(e) => onSetNewTopicPriority(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="row-between ops-config-footer">
|
|
||||||
<span className="field-label">{labels.topicAddHint}</span>
|
|
||||||
<div className="ops-inline-actions ops-inline-actions-wrap ops-inline-actions-end">
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={isSavingTopic}
|
|
||||||
onClick={() => {
|
|
||||||
onSetNewTopicPanelOpen(false);
|
|
||||||
onSetTopicPresetMenuOpen(false);
|
|
||||||
onResetNewTopicDraft();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels.cancel}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={isSavingTopic || !hasSelectedBot} onClick={() => void onAddTopic()}>
|
|
||||||
<Save size={14} />
|
|
||||||
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DrawerShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 type { Components } from 'react-markdown';
|
||||||
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||||
|
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
|
||||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||||
import type { ChatMessage } from '../../../types/bot';
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
|
||||||
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
|
|
||||||
import type { StagedSubmissionDraft } from '../types';
|
import type { StagedSubmissionDraft } from '../types';
|
||||||
import { formatDateInputValue, workspaceFileAction } from '../utils';
|
import { DashboardChatComposer } from './DashboardChatComposer';
|
||||||
|
import type { DashboardChatPanelLabels } from './DashboardChatPanel.types';
|
||||||
import { DashboardConversationMessages } from './DashboardConversationMessages';
|
import { DashboardConversationMessages } from './DashboardConversationMessages';
|
||||||
|
import { DashboardStagedSubmissionQueue } from './DashboardStagedSubmissionQueue';
|
||||||
import './DashboardChatPanel.css';
|
import './DashboardChatPanel.css';
|
||||||
|
|
||||||
interface DashboardChatPanelLabels {
|
|
||||||
badReply: string;
|
|
||||||
botDisabledHint: string;
|
|
||||||
botStarting: string;
|
|
||||||
botStopping: string;
|
|
||||||
chatDisabled: string;
|
|
||||||
close: string;
|
|
||||||
controlCommandsHide: string;
|
|
||||||
controlCommandsShow: string;
|
|
||||||
copyPrompt: string;
|
|
||||||
copyReply: string;
|
|
||||||
deleteMessage: string;
|
|
||||||
disabledPlaceholder: string;
|
|
||||||
download: string;
|
|
||||||
editPrompt: string;
|
|
||||||
fileNotPreviewable: string;
|
|
||||||
goodReply: string;
|
|
||||||
inputPlaceholder: string;
|
|
||||||
interrupt: string;
|
|
||||||
noConversation: string;
|
|
||||||
previewTitle: string;
|
|
||||||
stagedSubmissionAttachmentCount: (count: number) => string;
|
|
||||||
stagedSubmissionEmpty: string;
|
|
||||||
stagedSubmissionRestore: string;
|
|
||||||
stagedSubmissionRemove: string;
|
|
||||||
quoteReply: string;
|
|
||||||
quotedReplyLabel: string;
|
|
||||||
send: string;
|
|
||||||
thinking: string;
|
|
||||||
uploadFile: string;
|
|
||||||
uploadingFile: string;
|
|
||||||
user: string;
|
|
||||||
voiceStart: string;
|
|
||||||
voiceStop: string;
|
|
||||||
voiceTranscribing: string;
|
|
||||||
you: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardChatPanelProps {
|
interface DashboardChatPanelProps {
|
||||||
conversation: ChatMessage[];
|
conversation: ChatMessage[];
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
|
|
@ -330,8 +290,6 @@ export function DashboardChatPanel({
|
||||||
submitActionMode,
|
submitActionMode,
|
||||||
onSubmitAction,
|
onSubmitAction,
|
||||||
}: DashboardChatPanelProps) {
|
}: DashboardChatPanelProps) {
|
||||||
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
|
||||||
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
|
||||||
return (
|
return (
|
||||||
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
||||||
<MemoizedChatTranscript
|
<MemoizedChatTranscript
|
||||||
|
|
@ -359,321 +317,60 @@ export function DashboardChatPanel({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ops-chat-dock">
|
<div className="ops-chat-dock">
|
||||||
{stagedSubmissions.length > 0 ? (
|
<DashboardStagedSubmissionQueue
|
||||||
<div className="ops-staged-submission-queue" aria-live="polite">
|
labels={labels}
|
||||||
{stagedSubmissions.map((stagedSubmission, index) => (
|
stagedSubmissions={stagedSubmissions}
|
||||||
<div key={stagedSubmission.id} className="ops-staged-submission-item">
|
onRestoreStagedSubmission={onRestoreStagedSubmission}
|
||||||
<span className="ops-staged-submission-index mono">{index + 1}</span>
|
onRemoveStagedSubmission={onRemoveStagedSubmission}
|
||||||
<div className="ops-staged-submission-body">
|
|
||||||
<div className="ops-staged-submission-text">
|
|
||||||
{normalizeUserMessageText(stagedSubmission.command) || labels.stagedSubmissionEmpty}
|
|
||||||
</div>
|
|
||||||
{(stagedSubmission.quotedReply || stagedSubmission.attachments.length > 0) ? (
|
|
||||||
<div className="ops-staged-submission-meta">
|
|
||||||
{stagedSubmission.quotedReply ? (
|
|
||||||
<span className="ops-staged-submission-pill">{labels.quotedReplyLabel}</span>
|
|
||||||
) : null}
|
|
||||||
{stagedSubmission.attachments.length > 0 ? (
|
|
||||||
<span className="ops-staged-submission-pill">
|
|
||||||
{labels.stagedSubmissionAttachmentCount(stagedSubmission.attachments.length)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="ops-staged-submission-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-staged-submission-icon-btn"
|
|
||||||
onClick={() => onRestoreStagedSubmission(stagedSubmission.id)}
|
|
||||||
aria-label={labels.stagedSubmissionRestore}
|
|
||||||
title={labels.stagedSubmissionRestore}
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-staged-submission-icon-btn"
|
|
||||||
onClick={() => onRemoveStagedSubmission(stagedSubmission.id)}
|
|
||||||
aria-label={labels.stagedSubmissionRemove}
|
|
||||||
title={labels.stagedSubmissionRemove}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{(quotedReply || pendingAttachments.length > 0) ? (
|
|
||||||
<div className="ops-chat-top-context">
|
|
||||||
{quotedReply ? (
|
|
||||||
<div className="ops-composer-quote" aria-live="polite">
|
|
||||||
<div className="ops-composer-quote-head">
|
|
||||||
<span>{labels.quotedReplyLabel}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
|
||||||
onClick={onClearQuotedReply}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{pendingAttachments.length > 0 ? (
|
|
||||||
<div className="ops-pending-files">
|
|
||||||
{pendingAttachments.map((path) => (
|
|
||||||
<span key={path} className="ops-pending-chip mono">
|
|
||||||
{(() => {
|
|
||||||
const filePath = normalizeDashboardAttachmentPath(path);
|
|
||||||
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
|
||||||
const filename = filePath.split('/').pop() || filePath;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="ops-attach-link mono ops-pending-open"
|
|
||||||
href="#"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void onOpenWorkspacePath(filePath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fileAction === 'download' ? (
|
|
||||||
<Download size={12} className="ops-attach-link-icon" />
|
|
||||||
) : fileAction === 'preview' ? (
|
|
||||||
<Eye size={12} className="ops-attach-link-icon" />
|
|
||||||
) : (
|
|
||||||
<FileText size={12} className="ops-attach-link-icon" />
|
|
||||||
)}
|
|
||||||
<span className="ops-attach-link-name">{filename}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onRemovePendingAttachment(path);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isUploadingAttachments ? (
|
|
||||||
<div className="ops-upload-progress" aria-live="polite">
|
|
||||||
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
|
|
||||||
<div
|
|
||||||
className="ops-upload-progress-fill"
|
|
||||||
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<DashboardChatComposer
|
||||||
<span className="ops-upload-progress-text mono">
|
isZh={isZh}
|
||||||
{attachmentUploadPercent === null
|
labels={labels}
|
||||||
? labels.uploadingFile
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||||
: `${labels.uploadingFile} ${attachmentUploadPercent}%`}
|
canChat={canChat}
|
||||||
</span>
|
isChatEnabled={isChatEnabled}
|
||||||
</div>
|
speechEnabled={speechEnabled}
|
||||||
) : null}
|
quotedReply={quotedReply}
|
||||||
<div className="ops-composer">
|
onClearQuotedReply={onClearQuotedReply}
|
||||||
<input
|
pendingAttachments={pendingAttachments}
|
||||||
ref={filePickerRef}
|
onRemovePendingAttachment={onRemovePendingAttachment}
|
||||||
type="file"
|
attachmentUploadPercent={attachmentUploadPercent}
|
||||||
multiple
|
isUploadingAttachments={isUploadingAttachments}
|
||||||
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
|
filePickerRef={filePickerRef}
|
||||||
onChange={onPickAttachments}
|
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
||||||
className="ops-hidden-file-input"
|
onPickAttachments={onPickAttachments}
|
||||||
|
controlCommandPanelOpen={controlCommandPanelOpen}
|
||||||
|
controlCommandPanelRef={controlCommandPanelRef}
|
||||||
|
onToggleControlCommandPanel={onToggleControlCommandPanel}
|
||||||
|
activeControlCommand={activeControlCommand}
|
||||||
|
canSendControlCommand={canSendControlCommand}
|
||||||
|
isInterrupting={isInterrupting}
|
||||||
|
onSendControlCommand={onSendControlCommand}
|
||||||
|
onInterruptExecution={onInterruptExecution}
|
||||||
|
chatDateTriggerRef={chatDateTriggerRef}
|
||||||
|
hasSelectedBot={hasSelectedBot}
|
||||||
|
chatDateJumping={chatDateJumping}
|
||||||
|
onToggleChatDatePicker={onToggleChatDatePicker}
|
||||||
|
chatDatePickerOpen={chatDatePickerOpen}
|
||||||
|
chatDatePanelPosition={chatDatePanelPosition}
|
||||||
|
chatDateValue={chatDateValue}
|
||||||
|
onChatDateValueChange={onChatDateValueChange}
|
||||||
|
onCloseChatDatePicker={onCloseChatDatePicker}
|
||||||
|
onJumpConversationToDate={onJumpConversationToDate}
|
||||||
|
command={command}
|
||||||
|
onCommandChange={onCommandChange}
|
||||||
|
composerTextareaRef={composerTextareaRef}
|
||||||
|
onComposerKeyDown={onComposerKeyDown}
|
||||||
|
isVoiceRecording={isVoiceRecording}
|
||||||
|
isVoiceTranscribing={isVoiceTranscribing}
|
||||||
|
isCompactMobile={isCompactMobile}
|
||||||
|
voiceCountdown={voiceCountdown}
|
||||||
|
onVoiceInput={onVoiceInput}
|
||||||
|
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||||
|
onTriggerPickAttachments={onTriggerPickAttachments}
|
||||||
|
submitActionMode={submitActionMode}
|
||||||
|
onSubmitAction={onSubmitAction}
|
||||||
/>
|
/>
|
||||||
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
|
||||||
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
|
||||||
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-control-command-chip"
|
|
||||||
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
|
||||||
onClick={() => void onSendControlCommand('/restart')}
|
|
||||||
aria-label="/restart"
|
|
||||||
title="/restart"
|
|
||||||
>
|
|
||||||
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
|
|
||||||
<span className="mono">/restart</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-control-command-chip"
|
|
||||||
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
|
||||||
onClick={() => void onSendControlCommand('/new')}
|
|
||||||
aria-label="/new"
|
|
||||||
title="/new"
|
|
||||||
>
|
|
||||||
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
|
|
||||||
<span className="mono">/new</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-control-command-chip"
|
|
||||||
disabled={!hasSelectedBot || !canChat || Boolean(activeControlCommand) || isInterrupting}
|
|
||||||
onClick={() => void onInterruptExecution()}
|
|
||||||
aria-label="/stop"
|
|
||||||
title="/stop"
|
|
||||||
>
|
|
||||||
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
|
|
||||||
<span className="mono">/stop</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ops-control-command-chip"
|
|
||||||
ref={chatDateTriggerRef}
|
|
||||||
disabled={!hasSelectedBot || chatDateJumping}
|
|
||||||
onClick={onToggleChatDatePicker}
|
|
||||||
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
|
|
||||||
title={isZh ? '按日期定位对话' : 'Jump to date'}
|
|
||||||
>
|
|
||||||
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
|
|
||||||
<span className="mono">/time</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{chatDatePickerOpen ? (
|
|
||||||
<div
|
|
||||||
className="ops-control-date-panel"
|
|
||||||
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
|
|
||||||
>
|
|
||||||
<label className="ops-control-date-label">
|
|
||||||
<span>{isZh ? '选择日期' : 'Select date'}</span>
|
|
||||||
<input
|
|
||||||
className="input ops-control-date-input"
|
|
||||||
type="date"
|
|
||||||
value={chatDateValue}
|
|
||||||
max={formatDateInputValue(Date.now())}
|
|
||||||
onChange={(event) => onChatDateValueChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="ops-control-date-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={onCloseChatDatePicker}
|
|
||||||
>
|
|
||||||
{isZh ? '取消' : 'Cancel'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
disabled={chatDateJumping || !chatDateValue}
|
|
||||||
onClick={() => void onJumpConversationToDate()}
|
|
||||||
>
|
|
||||||
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
|
|
||||||
<span className={chatDateJumping ? 'ops-control-date-submit-label' : undefined}>
|
|
||||||
{isZh ? '跳转' : 'Jump'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
|
|
||||||
onClick={onToggleControlCommandPanel}
|
|
||||||
aria-label={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
|
|
||||||
title={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
|
|
||||||
>
|
|
||||||
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref={composerTextareaRef}
|
|
||||||
className="input ops-composer-input"
|
|
||||||
rows={3}
|
|
||||||
value={command}
|
|
||||||
onChange={(event) => onCommandChange(event.target.value)}
|
|
||||||
onKeyDown={onComposerKeyDown}
|
|
||||||
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
|
|
||||||
placeholder={canChat ? labels.inputPlaceholder : labels.disabledPlaceholder}
|
|
||||||
/>
|
|
||||||
<div className="ops-composer-tools-right">
|
|
||||||
{(isVoiceRecording || isVoiceTranscribing) ? (
|
|
||||||
<div className="ops-voice-inline" aria-live="polite">
|
|
||||||
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
|
|
||||||
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
|
|
||||||
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
|
|
||||||
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
|
|
||||||
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
|
|
||||||
return (
|
|
||||||
<i
|
|
||||||
key={`vw-inline-${segmentIdx}-${idx}`}
|
|
||||||
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="ops-voice-countdown mono">
|
|
||||||
{isVoiceRecording ? `${voiceCountdown}s` : labels.voiceTranscribing}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
|
|
||||||
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
|
|
||||||
onClick={() => void onVoiceInput()}
|
|
||||||
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
|
||||||
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
|
||||||
>
|
|
||||||
{isVoiceTranscribing ? (
|
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
|
||||||
) : isVoiceRecording ? (
|
|
||||||
<Square size={16} />
|
|
||||||
) : (
|
|
||||||
<Mic size={16} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<LucentIconButton
|
|
||||||
className="ops-composer-inline-btn"
|
|
||||||
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
|
|
||||||
onClick={() => void onTriggerPickAttachments()}
|
|
||||||
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
|
||||||
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
|
||||||
>
|
|
||||||
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
|
||||||
</LucentIconButton>
|
|
||||||
<button
|
|
||||||
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
|
|
||||||
disabled={
|
|
||||||
submitActionMode === 'interrupt'
|
|
||||||
? isInterrupting
|
|
||||||
: (
|
|
||||||
submitActionMode === 'stage'
|
|
||||||
? (
|
|
||||||
isVoiceRecording
|
|
||||||
|| isVoiceTranscribing
|
|
||||||
|| !hasComposerDraft
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
!isChatEnabled
|
|
||||||
|| isVoiceRecording
|
|
||||||
|| isVoiceTranscribing
|
|
||||||
|| !hasComposerDraft
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => void onSubmitAction()}
|
|
||||||
aria-label={showInterruptSubmitAction ? labels.interrupt : labels.send}
|
|
||||||
title={showInterruptSubmitAction ? labels.interrupt : labels.send}
|
|
||||||
>
|
|
||||||
{showInterruptSubmitAction ? <Square size={15} /> : <ArrowUp size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!canChat ? (
|
{!canChat ? (
|
||||||
<div className="ops-chat-disabled-mask">
|
<div className="ops-chat-disabled-mask">
|
||||||
|
|
|
||||||
|
|
@ -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 { DrawerShell } from '../../../components/DrawerShell';
|
||||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||||
|
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||||
import { PasswordInput } from '../../../components/PasswordInput';
|
import { PasswordInput } from '../../../components/PasswordInput';
|
||||||
import { buildLlmProviderOptions } from '../../../utils/llmProviders';
|
import { buildLlmProviderOptions } from '../../../utils/llmProviders';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { DashboardModalCardShell } from './DashboardModalCardShell';
|
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import type { SystemTimezoneOption } from '../../../utils/systemTimezones';
|
import type { SystemTimezoneOption } from '../../../utils/systemTimezones';
|
||||||
import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types';
|
import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types';
|
||||||
|
|
@ -46,7 +46,7 @@ export function ResourceMonitorModal({
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardModalCardShell
|
<ModalCardShell
|
||||||
cardClassName="modal-wide"
|
cardClassName="modal-wide"
|
||||||
closeLabel={closeLabel}
|
closeLabel={closeLabel}
|
||||||
headerActions={(
|
headerActions={(
|
||||||
|
|
@ -111,7 +111,7 @@ export function ResourceMonitorModal({
|
||||||
) : (
|
) : (
|
||||||
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
||||||
)}
|
)}
|
||||||
</DashboardModalCardShell>
|
</ModalCardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
||||||
|
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText';
|
||||||
|
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
|
||||||
|
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
|
||||||
|
import { workspaceFileAction } from '../../../shared/workspace/utils';
|
||||||
import type { ChatMessage } from '../../../types/bot';
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../constants';
|
import { formatClock, formatConversationDate } from '../chat/chatUtils';
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../messageParser';
|
|
||||||
import { workspaceFileAction, formatClock, formatConversationDate } from '../utils';
|
|
||||||
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
|
|
||||||
import './DashboardConversationMessages.css';
|
import './DashboardConversationMessages.css';
|
||||||
|
|
||||||
interface DashboardConversationLabels {
|
interface DashboardConversationLabels {
|
||||||
|
|
|
||||||
|
|
@ -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 type { ComponentProps } from 'react';
|
||||||
|
|
||||||
import { ChannelConfigModal, TopicConfigModal } from './DashboardChannelTopicModals';
|
import { WorkspaceHoverCard } from '../../../shared/workspace/WorkspaceHoverCard';
|
||||||
|
import { WorkspacePreviewModal } from '../../../shared/workspace/WorkspacePreviewModal';
|
||||||
|
import { ChannelConfigModal } from './DashboardChannelConfigModal';
|
||||||
|
import { AgentFilesModal } from './DashboardAgentFilesModal';
|
||||||
|
import { CronJobsModal } from './DashboardCronJobsModal';
|
||||||
import { BaseConfigModal, ParamConfigModal, ResourceMonitorModal } from './DashboardConfigModals';
|
import { BaseConfigModal, ParamConfigModal, ResourceMonitorModal } from './DashboardConfigModals';
|
||||||
|
import { EnvParamsModal } from './DashboardEnvParamsModal';
|
||||||
import { McpConfigModal, SkillsModal } from './DashboardSkillsMcpModals';
|
import { McpConfigModal, SkillsModal } from './DashboardSkillsMcpModals';
|
||||||
import { AgentFilesModal, CronJobsModal, EnvParamsModal, RuntimeActionModal, TemplateManagerModal } from './DashboardSupportModals';
|
import { RuntimeActionModal } from './DashboardRuntimeActionModal';
|
||||||
|
import { TemplateManagerModal } from './DashboardTemplateManagerModal';
|
||||||
|
import { TopicConfigModal } from './DashboardTopicConfigModal';
|
||||||
import { SkillMarketInstallModal } from './SkillMarketInstallModal';
|
import { SkillMarketInstallModal } from './SkillMarketInstallModal';
|
||||||
import { WorkspaceHoverCard } from './WorkspaceHoverCard';
|
|
||||||
import { WorkspacePreviewModal } from './WorkspacePreviewModal';
|
|
||||||
|
|
||||||
interface DashboardModalStackProps {
|
interface DashboardModalStackProps {
|
||||||
agentFilesModal: ComponentProps<typeof AgentFilesModal>;
|
agentFilesModal: ComponentProps<typeof AgentFilesModal>;
|
||||||
|
|
|
||||||
|
|
@ -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 { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
|
import { WorkspaceEntriesList } from '../../../shared/workspace/WorkspaceEntriesList';
|
||||||
|
import type { WorkspaceNode } from '../../../shared/workspace/types';
|
||||||
|
import { isPreviewableWorkspaceFile } from '../../../shared/workspace/utils';
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import { isPreviewableWorkspaceFile } from '../utils';
|
|
||||||
import type { WorkspaceNode } from '../types';
|
|
||||||
import { WorkspaceEntriesList } from './WorkspaceEntriesList';
|
|
||||||
import './DashboardMenus.css';
|
import './DashboardMenus.css';
|
||||||
import './RuntimePanel.css';
|
import './RuntimePanel.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import '../../../components/skill-market/SkillMarketShared.css';
|
||||||
import type { BotSkillMarketItem } from '../../platform/types';
|
import type { BotSkillMarketItem } from '../../platform/types';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
|
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||||
import { fetchPreferredPlatformPageSize } from '../../platform/api/settings';
|
import { fetchPreferredPlatformPageSize } from '../../platform/api/settings';
|
||||||
import { readCachedPlatformPageSize } from '../../../utils/platformPageSize';
|
import { readCachedPlatformPageSize } from '../../../utils/platformPageSize';
|
||||||
import { DashboardModalCardShell } from './DashboardModalCardShell';
|
|
||||||
|
|
||||||
interface SkillMarketInstallModalProps {
|
interface SkillMarketInstallModalProps {
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
|
|
@ -69,7 +69,7 @@ export function SkillMarketInstallModal({
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardModalCardShell
|
<ModalCardShell
|
||||||
cardClassName="modal-wide platform-modal skill-market-browser-shell"
|
cardClassName="modal-wide platform-modal skill-market-browser-shell"
|
||||||
closeLabel={isZh ? '关闭' : 'Close'}
|
closeLabel={isZh ? '关闭' : 'Close'}
|
||||||
headerActions={(
|
headerActions={(
|
||||||
|
|
@ -185,6 +185,6 @@ export function SkillMarketInstallModal({
|
||||||
</LucentIconButton>
|
</LucentIconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardModalCardShell>
|
</ModalCardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 axios from 'axios';
|
||||||
|
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
|
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../topic/topicPresetUtils';
|
||||||
import type { BotTopic, TopicPresetTemplate } from '../types';
|
import type { BotTopic, TopicPresetTemplate } from '../types';
|
||||||
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../utils';
|
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import type { ChannelType } from './types';
|
||||||
|
|
||||||
export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email'];
|
export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email'];
|
||||||
export const RUNTIME_STALE_MS = 45000;
|
export const RUNTIME_STALE_MS = 45000;
|
||||||
export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
|
export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
|
||||||
|
|
||||||
export const TEXT_PREVIEW_EXTENSIONS = new Set(['.md', '.json', '.log', '.txt', '.csv']);
|
|
||||||
export const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']);
|
|
||||||
export const IMAGE_PREVIEW_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
||||||
export const AUDIO_PREVIEW_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma']);
|
|
||||||
export const VIDEO_PREVIEW_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts']);
|
|
||||||
|
|
||||||
export const MEDIA_UPLOAD_EXTENSIONS = new Set([
|
|
||||||
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
|
|
||||||
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
|
|
||||||
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const MARKDOWN_SANITIZE_SCHEMA = {
|
|
||||||
...defaultSchema,
|
|
||||||
tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])],
|
|
||||||
attributes: {
|
|
||||||
...defaultSchema.attributes,
|
|
||||||
audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
|
|
||||||
source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'],
|
|
||||||
video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
import { pickLocale } from '../../../i18n';
|
import { pickLocale } from '../../../i18n';
|
||||||
|
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
|
||||||
import { channelsEn } from '../../../i18n/channels.en';
|
import { channelsEn } from '../../../i18n/channels.en';
|
||||||
import { channelsZhCn } from '../../../i18n/channels.zh-cn';
|
import { channelsZhCn } from '../../../i18n/channels.zh-cn';
|
||||||
import { dashboardEn } from '../../../i18n/dashboard.en';
|
import { dashboardEn } from '../../../i18n/dashboard.en';
|
||||||
|
|
@ -12,7 +13,7 @@ import { useDashboardBotEditor } from './useDashboardBotEditor';
|
||||||
import { useDashboardBotManagement } from './useDashboardBotManagement';
|
import { useDashboardBotManagement } from './useDashboardBotManagement';
|
||||||
import { useDashboardConfigPanels } from './useDashboardConfigPanels';
|
import { useDashboardConfigPanels } from './useDashboardConfigPanels';
|
||||||
import { useDashboardConversation } from './useDashboardConversation';
|
import { useDashboardConversation } from './useDashboardConversation';
|
||||||
import { useDashboardDerivedState } from './useDashboardDerivedState';
|
import { useDashboardBaseState, useDashboardInteractionState } from './useDashboardDerivedState';
|
||||||
import { useDashboardLifecycle } from './useDashboardLifecycle';
|
import { useDashboardLifecycle } from './useDashboardLifecycle';
|
||||||
import { useDashboardRuntimeControl } from './useDashboardRuntimeControl';
|
import { useDashboardRuntimeControl } from './useDashboardRuntimeControl';
|
||||||
import { useDashboardShellState } from './useDashboardShellState';
|
import { useDashboardShellState } from './useDashboardShellState';
|
||||||
|
|
@ -20,7 +21,6 @@ import { useDashboardSupportData } from './useDashboardSupportData';
|
||||||
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
|
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
|
||||||
import { useDashboardTemplateManager } from './useDashboardTemplateManager';
|
import { useDashboardTemplateManager } from './useDashboardTemplateManager';
|
||||||
import { useDashboardVoiceInput } from './useDashboardVoiceInput';
|
import { useDashboardVoiceInput } from './useDashboardVoiceInput';
|
||||||
import { useDashboardWorkspace } from './useDashboardWorkspace';
|
|
||||||
|
|
||||||
export function useBotDashboardModule({
|
export function useBotDashboardModule({
|
||||||
forcedBotId,
|
forcedBotId,
|
||||||
|
|
@ -381,7 +381,7 @@ export function useBotDashboardModule({
|
||||||
workspacePreviewSaving,
|
workspacePreviewSaving,
|
||||||
workspaceQuery,
|
workspaceQuery,
|
||||||
workspaceSearchLoading,
|
workspaceSearchLoading,
|
||||||
} = useDashboardWorkspace({
|
} = useBotWorkspace({
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
||||||
workspaceDownloadExtensions,
|
workspaceDownloadExtensions,
|
||||||
|
|
@ -396,13 +396,15 @@ export function useBotDashboardModule({
|
||||||
baseImageOptions,
|
baseImageOptions,
|
||||||
canChat,
|
canChat,
|
||||||
conversation,
|
conversation,
|
||||||
|
displayState,
|
||||||
hasTopicUnread,
|
hasTopicUnread,
|
||||||
isThinking: isBotThinking,
|
isThinking: isBotThinking,
|
||||||
|
runtimeAction,
|
||||||
selectedBotControlState,
|
selectedBotControlState,
|
||||||
selectedBotEnabled,
|
selectedBotEnabled,
|
||||||
systemTimezoneOptions,
|
systemTimezoneOptions,
|
||||||
topicPanelState,
|
topicPanelState,
|
||||||
} = useDashboardDerivedState({
|
} = useDashboardBaseState({
|
||||||
availableImages,
|
availableImages,
|
||||||
controlStateByBot,
|
controlStateByBot,
|
||||||
defaultSystemTimezone,
|
defaultSystemTimezone,
|
||||||
|
|
@ -435,9 +437,7 @@ export function useBotDashboardModule({
|
||||||
feedbackSavingByMessageId,
|
feedbackSavingByMessageId,
|
||||||
filePickerRef,
|
filePickerRef,
|
||||||
interruptExecution,
|
interruptExecution,
|
||||||
isCommandAutoUnlockWindowActive,
|
|
||||||
isInterrupting,
|
isInterrupting,
|
||||||
isTaskRunning,
|
|
||||||
isSendingBlocked,
|
isSendingBlocked,
|
||||||
jumpConversationToDate,
|
jumpConversationToDate,
|
||||||
loadInitialChatPage,
|
loadInitialChatPage,
|
||||||
|
|
@ -448,7 +448,6 @@ export function useBotDashboardModule({
|
||||||
restoreStagedSubmission,
|
restoreStagedSubmission,
|
||||||
removeStagedSubmission,
|
removeStagedSubmission,
|
||||||
scrollConversationToBottom,
|
scrollConversationToBottom,
|
||||||
send,
|
|
||||||
selectedBotStagedSubmissions,
|
selectedBotStagedSubmissions,
|
||||||
sendControlCommand,
|
sendControlCommand,
|
||||||
setChatDatePickerOpen,
|
setChatDatePickerOpen,
|
||||||
|
|
@ -501,28 +500,15 @@ export function useBotDashboardModule({
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
canSendControlCommand,
|
canSendControlCommand,
|
||||||
displayState,
|
|
||||||
isChatEnabled,
|
isChatEnabled,
|
||||||
isThinking,
|
} = useDashboardInteractionState({
|
||||||
runtimeAction,
|
canChat,
|
||||||
showInterruptSubmitAction,
|
|
||||||
} = useDashboardDerivedState({
|
|
||||||
availableImages,
|
|
||||||
controlStateByBot,
|
|
||||||
defaultSystemTimezone,
|
|
||||||
editFormImageTag: editForm.image_tag,
|
|
||||||
editFormSystemTimezone: editForm.system_timezone,
|
|
||||||
events,
|
|
||||||
isCommandAutoUnlockWindowActive,
|
|
||||||
isSendingBlocked,
|
isSendingBlocked,
|
||||||
isVoiceRecording,
|
isVoiceRecording,
|
||||||
isVoiceTranscribing,
|
isVoiceTranscribing,
|
||||||
isZh,
|
|
||||||
messages,
|
|
||||||
selectedBot,
|
selectedBot,
|
||||||
topicFeedUnreadCount,
|
|
||||||
topics,
|
|
||||||
});
|
});
|
||||||
|
const isThinking = isBotThinking;
|
||||||
|
|
||||||
useDashboardLifecycle({
|
useDashboardLifecycle({
|
||||||
activeTopicOptions,
|
activeTopicOptions,
|
||||||
|
|
@ -720,9 +706,6 @@ export function useBotDashboardModule({
|
||||||
onVoiceInput,
|
onVoiceInput,
|
||||||
triggerPickAttachments,
|
triggerPickAttachments,
|
||||||
submitActionMode,
|
submitActionMode,
|
||||||
isTaskRunning,
|
|
||||||
showInterruptSubmitAction,
|
|
||||||
send,
|
|
||||||
handlePrimarySubmitAction,
|
handlePrimarySubmitAction,
|
||||||
runtimeMenuOpen,
|
runtimeMenuOpen,
|
||||||
runtimeMenuRef,
|
runtimeMenuRef,
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
|
||||||
import type { ChatMessage } from '../../../types/bot';
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText';
|
||||||
import type { QuotedReply, StagedSubmissionDraft } from '../types';
|
import type { QuotedReply } from '../types';
|
||||||
import { loadComposerDraft, persistComposerDraft } from '../utils';
|
import { loadComposerDraft, persistComposerDraft } from '../utils';
|
||||||
|
import type { DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||||
|
import { useDashboardChatCommandDispatch } from './useDashboardChatCommandDispatch';
|
||||||
|
import { useDashboardChatStaging } from './useDashboardChatStaging';
|
||||||
|
|
||||||
const COMPOSER_MIN_ROWS = 3;
|
const COMPOSER_MIN_ROWS = 3;
|
||||||
const COMPOSER_MAX_HEIGHT_PX = 220;
|
const COMPOSER_MAX_HEIGHT_PX = 220;
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
interface NotifyOptions {
|
|
||||||
title?: string;
|
|
||||||
tone?: PromptTone;
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDashboardChatComposerOptions {
|
interface UseDashboardChatComposerOptions {
|
||||||
selectedBotId: string;
|
selectedBotId: string;
|
||||||
selectedBot?: { id: string } | null;
|
selectedBot?: { id: string } | null;
|
||||||
|
|
@ -31,7 +24,7 @@ interface UseDashboardChatComposerOptions {
|
||||||
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
||||||
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
|
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
|
||||||
notify: (message: string, options?: NotifyOptions) => void;
|
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||||
t: any;
|
t: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,52 +47,67 @@ export function useDashboardChatComposer({
|
||||||
const [command, setCommand] = useState('');
|
const [command, setCommand] = useState('');
|
||||||
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
|
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
|
||||||
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
|
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
|
||||||
const [sendingByBot, setSendingByBot] = useState<Record<string, number>>({});
|
|
||||||
const [stagedSubmissionQueueByBot, setStagedSubmissionQueueByBot] = useState<Record<string, StagedSubmissionDraft[]>>({});
|
|
||||||
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
|
|
||||||
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
|
|
||||||
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const filePickerRef = useRef<HTMLInputElement | null>(null);
|
const filePickerRef = useRef<HTMLInputElement | null>(null);
|
||||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({});
|
const stagedAutoSubmitAttemptByBotRef = useRef<Record<string, string>>({});
|
||||||
|
|
||||||
const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0;
|
|
||||||
const selectedBotStagedSubmissions = selectedBot ? stagedSubmissionQueueByBot[selectedBot.id] || [] : [];
|
|
||||||
const nextQueuedSubmission = selectedBotStagedSubmissions[0] || null;
|
|
||||||
const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0;
|
|
||||||
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
|
|
||||||
const isSending = selectedBotSendingCount > 0;
|
|
||||||
const isTaskRunning = Boolean(selectedBot && (isSending || isTaskRunningExternally));
|
|
||||||
const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now();
|
|
||||||
const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive;
|
|
||||||
const isInterrupting = Boolean(selectedBot && interruptingByBot[selectedBot.id]);
|
|
||||||
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
||||||
|
|
||||||
|
const {
|
||||||
|
completeLeadingStagedSubmission,
|
||||||
|
nextQueuedSubmission,
|
||||||
|
removeStagedSubmission,
|
||||||
|
restoreStagedSubmission,
|
||||||
|
selectedBotStagedSubmissions,
|
||||||
|
stageCurrentSubmission,
|
||||||
|
} = useDashboardChatStaging({
|
||||||
|
selectedBot,
|
||||||
|
command,
|
||||||
|
pendingAttachments,
|
||||||
|
quotedReply,
|
||||||
|
setCommand,
|
||||||
|
setPendingAttachments,
|
||||||
|
setQuotedReply,
|
||||||
|
composerTextareaRef,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeControlCommand,
|
||||||
|
interruptExecution,
|
||||||
|
isInterrupting,
|
||||||
|
isSending,
|
||||||
|
isSendingBlocked,
|
||||||
|
isTaskRunning,
|
||||||
|
sendControlCommand,
|
||||||
|
sendCurrentDraft,
|
||||||
|
sendQueuedSubmission,
|
||||||
|
} = useDashboardChatCommandDispatch({
|
||||||
|
selectedBot,
|
||||||
|
canChat,
|
||||||
|
isTaskRunningExternally,
|
||||||
|
commandAutoUnlockSeconds,
|
||||||
|
command,
|
||||||
|
pendingAttachments,
|
||||||
|
quotedReply,
|
||||||
|
setCommand,
|
||||||
|
setPendingAttachments,
|
||||||
|
setQuotedReply,
|
||||||
|
setChatDatePickerOpen,
|
||||||
|
setControlCommandPanelOpen,
|
||||||
|
addBotMessage,
|
||||||
|
scrollConversationToBottom,
|
||||||
|
completeLeadingStagedSubmission,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
const submitActionMode: 'interrupt' | 'send' | 'stage' = isTaskRunning
|
const submitActionMode: 'interrupt' | 'send' | 'stage' = isTaskRunning
|
||||||
? (hasComposerDraft ? 'stage' : 'interrupt')
|
? (hasComposerDraft ? 'stage' : 'interrupt')
|
||||||
: 'send';
|
: 'send';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedBot?.id || selectedBotAutoUnlockDeadline <= 0) return;
|
|
||||||
const remaining = selectedBotAutoUnlockDeadline - Date.now();
|
|
||||||
if (remaining <= 0) {
|
|
||||||
setCommandAutoUnlockDeadlineByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setCommandAutoUnlockDeadlineByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, remaining + 20);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [selectedBot?.id, selectedBotAutoUnlockDeadline]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposerDraftHydrated(false);
|
setComposerDraftHydrated(false);
|
||||||
if (!selectedBotId) {
|
if (!selectedBotId) {
|
||||||
|
|
@ -175,161 +183,6 @@ export function useDashboardChatComposer({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendPayload = async ({
|
|
||||||
commandRaw,
|
|
||||||
attachmentsRaw,
|
|
||||||
quotedReplyRaw,
|
|
||||||
clearComposerOnSuccess,
|
|
||||||
clearStagedSubmissionId,
|
|
||||||
}: {
|
|
||||||
commandRaw: string;
|
|
||||||
attachmentsRaw: string[];
|
|
||||||
quotedReplyRaw: QuotedReply | null;
|
|
||||||
clearComposerOnSuccess: boolean;
|
|
||||||
clearStagedSubmissionId?: string;
|
|
||||||
}) => {
|
|
||||||
if (!selectedBot || !canChat) return false;
|
|
||||||
const attachments = [...attachmentsRaw];
|
|
||||||
const text = normalizeUserMessageText(commandRaw);
|
|
||||||
const quoteText = normalizeAssistantMessageText(quotedReplyRaw?.text || '');
|
|
||||||
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
|
|
||||||
const payloadCore = text || (attachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
|
|
||||||
const payloadText = `${quoteBlock}${payloadCore}`.trim();
|
|
||||||
if (!payloadText && attachments.length === 0) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
requestAnimationFrame(() => scrollConversationToBottom('auto'));
|
|
||||||
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: Number(prev[selectedBot.id] || 0) + 1 }));
|
|
||||||
setCommandAutoUnlockDeadlineByBot((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
|
|
||||||
}));
|
|
||||||
const res = await axios.post(
|
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
|
||||||
{ command: payloadText, attachments },
|
|
||||||
{ timeout: 12000 },
|
|
||||||
);
|
|
||||||
if (!res.data?.success) {
|
|
||||||
throw new Error(t.backendDeliverFail);
|
|
||||||
}
|
|
||||||
addBotMessage(selectedBot.id, {
|
|
||||||
role: 'user',
|
|
||||||
text: payloadText,
|
|
||||||
attachments,
|
|
||||||
ts: Date.now(),
|
|
||||||
kind: 'final',
|
|
||||||
});
|
|
||||||
requestAnimationFrame(() => scrollConversationToBottom('auto'));
|
|
||||||
if (clearComposerOnSuccess) {
|
|
||||||
setCommand('');
|
|
||||||
setPendingAttachments([]);
|
|
||||||
setQuotedReply(null);
|
|
||||||
}
|
|
||||||
if (clearStagedSubmissionId) {
|
|
||||||
setStagedSubmissionQueueByBot((prev) => {
|
|
||||||
const currentQueue = prev[selectedBot.id] || [];
|
|
||||||
const current = currentQueue[0];
|
|
||||||
if (!current || current.id !== clearStagedSubmissionId) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const remainingQueue = currentQueue.slice(1);
|
|
||||||
const next = { ...prev };
|
|
||||||
if (remainingQueue.length > 0) {
|
|
||||||
next[selectedBot.id] = remainingQueue;
|
|
||||||
} else {
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
|
||||||
setCommandAutoUnlockDeadlineByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
addBotMessage(selectedBot.id, {
|
|
||||||
role: 'assistant',
|
|
||||||
text: t.sendFailMsg(msg),
|
|
||||||
ts: Date.now(),
|
|
||||||
});
|
|
||||||
requestAnimationFrame(() => scrollConversationToBottom('auto'));
|
|
||||||
notify(msg, { tone: 'error' });
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setSendingByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
const remaining = Number(next[selectedBot.id] || 0) - 1;
|
|
||||||
if (remaining > 0) {
|
|
||||||
next[selectedBot.id] = remaining;
|
|
||||||
} else {
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
if (!selectedBot || !canChat || isTaskRunning) return false;
|
|
||||||
if (!hasComposerDraft) return false;
|
|
||||||
return sendPayload({
|
|
||||||
commandRaw: command,
|
|
||||||
attachmentsRaw: pendingAttachments,
|
|
||||||
quotedReplyRaw: quotedReply,
|
|
||||||
clearComposerOnSuccess: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStagedSubmission = (stagedSubmissionId: string) => {
|
|
||||||
if (!selectedBot) return;
|
|
||||||
setStagedSubmissionQueueByBot((prev) => {
|
|
||||||
const currentQueue = prev[selectedBot.id] || [];
|
|
||||||
const nextQueue = currentQueue.filter((item) => item.id !== stagedSubmissionId);
|
|
||||||
if (nextQueue.length === currentQueue.length) return prev;
|
|
||||||
const next = { ...prev };
|
|
||||||
if (nextQueue.length > 0) {
|
|
||||||
next[selectedBot.id] = nextQueue;
|
|
||||||
} else {
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreStagedSubmission = (stagedSubmissionId: string) => {
|
|
||||||
if (!selectedBot) return;
|
|
||||||
const targetSubmission = selectedBotStagedSubmissions.find((item) => item.id === stagedSubmissionId);
|
|
||||||
if (!targetSubmission) return;
|
|
||||||
setCommand(targetSubmission.command);
|
|
||||||
setPendingAttachments(targetSubmission.attachments);
|
|
||||||
setQuotedReply(targetSubmission.quotedReply);
|
|
||||||
removeStagedSubmission(stagedSubmissionId);
|
|
||||||
composerTextareaRef.current?.focus();
|
|
||||||
notify(t.stagedSubmissionRestored, { tone: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const stageCurrentSubmission = () => {
|
|
||||||
if (!selectedBot || !hasComposerDraft) return;
|
|
||||||
const nextStagedSubmission: StagedSubmissionDraft = {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
command,
|
|
||||||
attachments: [...pendingAttachments],
|
|
||||||
quotedReply,
|
|
||||||
updated_at_ms: Date.now(),
|
|
||||||
};
|
|
||||||
setStagedSubmissionQueueByBot((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[selectedBot.id]: [...(prev[selectedBot.id] || []), nextStagedSubmission],
|
|
||||||
}));
|
|
||||||
setCommand('');
|
|
||||||
setPendingAttachments([]);
|
|
||||||
setQuotedReply(null);
|
|
||||||
notify(t.stagedSubmissionQueued, { tone: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrimarySubmitAction = async () => {
|
const handlePrimarySubmitAction = async () => {
|
||||||
if (!selectedBot || !canChat) return;
|
if (!selectedBot || !canChat) return;
|
||||||
if (isTaskRunning) {
|
if (isTaskRunning) {
|
||||||
|
|
@ -340,7 +193,7 @@ export function useDashboardChatComposer({
|
||||||
await interruptExecution();
|
await interruptExecution();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await send();
|
await sendCurrentDraft();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -352,73 +205,8 @@ export function useDashboardChatComposer({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id;
|
stagedAutoSubmitAttemptByBotRef.current[selectedBot.id] = nextQueuedSubmission.id;
|
||||||
void sendPayload({
|
void sendQueuedSubmission(nextQueuedSubmission);
|
||||||
commandRaw: nextQueuedSubmission.command,
|
}, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot, sendQueuedSubmission]);
|
||||||
attachmentsRaw: nextQueuedSubmission.attachments,
|
|
||||||
quotedReplyRaw: nextQueuedSubmission.quotedReply,
|
|
||||||
clearComposerOnSuccess: false,
|
|
||||||
clearStagedSubmissionId: nextQueuedSubmission.id,
|
|
||||||
});
|
|
||||||
}, [canChat, isTaskRunning, isUploadingAttachments, nextQueuedSubmission, selectedBot]);
|
|
||||||
|
|
||||||
const sendControlCommand = async (slashCommand: '/new' | '/restart') => {
|
|
||||||
if (!selectedBot || !canChat || activeControlCommand) return;
|
|
||||||
try {
|
|
||||||
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
|
|
||||||
const res = await axios.post(
|
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
|
||||||
{ command: slashCommand },
|
|
||||||
{ timeout: 12000 },
|
|
||||||
);
|
|
||||||
if (!res.data?.success) {
|
|
||||||
throw new Error(t.backendDeliverFail);
|
|
||||||
}
|
|
||||||
if (slashCommand === '/new') {
|
|
||||||
setCommand('');
|
|
||||||
setPendingAttachments([]);
|
|
||||||
setQuotedReply(null);
|
|
||||||
}
|
|
||||||
setChatDatePickerOpen(false);
|
|
||||||
setControlCommandPanelOpen(false);
|
|
||||||
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
|
|
||||||
} catch (error: any) {
|
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
|
||||||
notify(msg, { tone: 'error' });
|
|
||||||
} finally {
|
|
||||||
setControlCommandByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const interruptExecution = async () => {
|
|
||||||
if (!selectedBot || !canChat || isInterrupting) return;
|
|
||||||
try {
|
|
||||||
setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
|
|
||||||
const res = await axios.post(
|
|
||||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
|
|
||||||
{ command: '/stop' },
|
|
||||||
{ timeout: 12000 },
|
|
||||||
);
|
|
||||||
if (!res.data?.success) {
|
|
||||||
throw new Error(t.backendDeliverFail);
|
|
||||||
}
|
|
||||||
setChatDatePickerOpen(false);
|
|
||||||
setControlCommandPanelOpen(false);
|
|
||||||
notify(t.interruptSent, { tone: 'success' });
|
|
||||||
} catch (error: any) {
|
|
||||||
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
|
|
||||||
notify(msg, { tone: 'error' });
|
|
||||||
} finally {
|
|
||||||
setInterruptingByBot((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[selectedBot.id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyUserPrompt = async (text: string) => {
|
const copyUserPrompt = async (text: string) => {
|
||||||
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
|
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
|
||||||
|
|
@ -477,17 +265,14 @@ export function useDashboardChatComposer({
|
||||||
filePickerRef,
|
filePickerRef,
|
||||||
handlePrimarySubmitAction,
|
handlePrimarySubmitAction,
|
||||||
interruptExecution,
|
interruptExecution,
|
||||||
isCommandAutoUnlockWindowActive,
|
|
||||||
isInterrupting,
|
isInterrupting,
|
||||||
isSending,
|
isSending,
|
||||||
isTaskRunning,
|
|
||||||
isSendingBlocked,
|
isSendingBlocked,
|
||||||
onComposerKeyDown,
|
onComposerKeyDown,
|
||||||
quoteAssistantReply,
|
quoteAssistantReply,
|
||||||
quotedReply,
|
quotedReply,
|
||||||
restoreStagedSubmission,
|
restoreStagedSubmission,
|
||||||
removeStagedSubmission,
|
removeStagedSubmission,
|
||||||
send,
|
|
||||||
sendControlCommand,
|
sendControlCommand,
|
||||||
setCommand,
|
setCommand,
|
||||||
setQuotedReply,
|
setQuotedReply,
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,14 @@ import axios from 'axios';
|
||||||
|
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
import type { ChatMessage } from '../../../types/bot';
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
|
||||||
import type { BotMessagesByDateResponse } from '../types';
|
import type { BotMessagesByDateResponse } from '../types';
|
||||||
import {
|
import {
|
||||||
formatConversationDate,
|
formatConversationDate,
|
||||||
formatDateInputValue,
|
formatDateInputValue,
|
||||||
mapBotMessageResponseRow,
|
mapBotMessageResponseRow,
|
||||||
} from '../utils';
|
} from '../chat/chatUtils';
|
||||||
|
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
import { useDashboardChatMessageActions } from './useDashboardChatMessageActions';
|
||||||
|
|
||||||
interface NotifyOptions {
|
|
||||||
title?: string;
|
|
||||||
tone?: PromptTone;
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmOptions {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
tone?: PromptTone;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDashboardChatHistoryOptions {
|
interface UseDashboardChatHistoryOptions {
|
||||||
selectedBotId: string;
|
selectedBotId: string;
|
||||||
|
|
@ -36,8 +21,8 @@ interface UseDashboardChatHistoryOptions {
|
||||||
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||||
notify: (message: string, options?: NotifyOptions) => void;
|
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
|
||||||
t: any;
|
t: any;
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -65,8 +50,6 @@ export function useDashboardChatHistory({
|
||||||
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
|
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
|
||||||
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
||||||
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
|
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
|
||||||
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
|
|
||||||
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
|
|
||||||
|
|
||||||
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
@ -91,8 +74,6 @@ export function useDashboardChatHistory({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedProgressByKey({});
|
setExpandedProgressByKey({});
|
||||||
setExpandedUserByKey({});
|
setExpandedUserByKey({});
|
||||||
setFeedbackSavingByMessageId({});
|
|
||||||
setDeletingMessageIdMap({});
|
|
||||||
setChatDatePickerOpen(false);
|
setChatDatePickerOpen(false);
|
||||||
setChatDatePanelPosition(null);
|
setChatDatePanelPosition(null);
|
||||||
setChatJumpAnchorId(null);
|
setChatJumpAnchorId(null);
|
||||||
|
|
@ -156,6 +137,30 @@ export function useDashboardChatHistory({
|
||||||
.slice(-safeLimit);
|
.slice(-safeLimit);
|
||||||
}, [chatPullPageSize]);
|
}, [chatPullPageSize]);
|
||||||
|
|
||||||
|
const hydrateLatestMessages = useCallback(async (botId: string) => {
|
||||||
|
const latest = await fetchBotMessages(botId);
|
||||||
|
setBotMessages(botId, latest);
|
||||||
|
return latest;
|
||||||
|
}, [fetchBotMessages, setBotMessages]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
deleteConversationMessage,
|
||||||
|
deletingMessageIdMap,
|
||||||
|
feedbackSavingByMessageId,
|
||||||
|
submitAssistantFeedback,
|
||||||
|
} = useDashboardChatMessageActions({
|
||||||
|
selectedBotId,
|
||||||
|
messages,
|
||||||
|
chatScrollRef,
|
||||||
|
chatAutoFollowRef,
|
||||||
|
hydrateLatestMessages,
|
||||||
|
setBotMessages,
|
||||||
|
setBotMessageFeedback,
|
||||||
|
notify,
|
||||||
|
confirm,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
const fetchBotMessagesPage = useCallback(async (
|
const fetchBotMessagesPage = useCallback(async (
|
||||||
botId: string,
|
botId: string,
|
||||||
options?: { beforeId?: number | null; limit?: number },
|
options?: { beforeId?: number | null; limit?: number },
|
||||||
|
|
@ -368,203 +373,6 @@ export function useDashboardChatHistory({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
|
|
||||||
if (!selectedBotId) {
|
|
||||||
notify(t.feedbackMessagePending, { tone: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let targetMessageId = message.id;
|
|
||||||
if (!targetMessageId) {
|
|
||||||
try {
|
|
||||||
const latest = await fetchBotMessages(selectedBotId);
|
|
||||||
setBotMessages(selectedBotId, latest);
|
|
||||||
const normalizedTarget = normalizeAssistantMessageText(message.text);
|
|
||||||
const matched = latest
|
|
||||||
.filter((row) => row.role === 'assistant' && row.id)
|
|
||||||
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
|
|
||||||
.filter(({ message: row, diff }) => normalizeAssistantMessageText(row.text) === normalizedTarget && diff <= 10 * 60 * 1000)
|
|
||||||
.sort((a, b) => a.diff - b.diff)[0]?.message;
|
|
||||||
if (matched?.id) {
|
|
||||||
targetMessageId = matched.id;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore and fallback to warning below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!targetMessageId) {
|
|
||||||
notify(t.feedbackMessagePending, { tone: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (feedbackSavingByMessageId[targetMessageId]) return;
|
|
||||||
const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
|
|
||||||
setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
|
|
||||||
try {
|
|
||||||
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
|
|
||||||
setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
|
|
||||||
if (nextFeedback === null) {
|
|
||||||
notify(t.feedbackCleared, { tone: 'success' });
|
|
||||||
} else {
|
|
||||||
notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
const msg = error?.response?.data?.detail || t.feedbackSaveFail;
|
|
||||||
notify(msg, { tone: 'error' });
|
|
||||||
} finally {
|
|
||||||
setFeedbackSavingByMessageId((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[targetMessageId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveMessageIdFromLatest = useCallback(async (message: ChatMessage) => {
|
|
||||||
if (!selectedBotId) return null;
|
|
||||||
const latest = await fetchBotMessages(selectedBotId);
|
|
||||||
const normalizedTargetText = message.role === 'user'
|
|
||||||
? normalizeUserMessageText(message.text)
|
|
||||||
: normalizeAssistantMessageText(message.text);
|
|
||||||
const targetAttachments = JSON.stringify(message.attachments || []);
|
|
||||||
const matched = latest
|
|
||||||
.filter((row) => row.role === message.role && row.id)
|
|
||||||
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
|
|
||||||
.filter(({ message: row, diff }) => {
|
|
||||||
const normalizedRowText = row.role === 'user'
|
|
||||||
? normalizeUserMessageText(row.text)
|
|
||||||
: normalizeAssistantMessageText(row.text);
|
|
||||||
return normalizedRowText === normalizedTargetText
|
|
||||||
&& JSON.stringify(row.attachments || []) === targetAttachments
|
|
||||||
&& diff <= 10 * 60 * 1000;
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.diff - b.diff)[0]?.message;
|
|
||||||
return matched?.id || null;
|
|
||||||
}, [fetchBotMessages, selectedBotId]);
|
|
||||||
|
|
||||||
const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => {
|
|
||||||
if (!selectedBotId) return;
|
|
||||||
const originalMessageId = Number(message.id);
|
|
||||||
const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0;
|
|
||||||
const idsToRemove = new Set<number>([deletedMessageId]);
|
|
||||||
if (hasOriginalId) {
|
|
||||||
idsToRemove.add(originalMessageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollBox = chatScrollRef.current;
|
|
||||||
const prevTop = scrollBox?.scrollTop ?? null;
|
|
||||||
const normalizedTargetText = message.role === 'user'
|
|
||||||
? normalizeUserMessageText(message.text)
|
|
||||||
: normalizeAssistantMessageText(message.text);
|
|
||||||
const targetAttachments = JSON.stringify(message.attachments || []);
|
|
||||||
|
|
||||||
const nextMessages = messages.filter((row) => {
|
|
||||||
const rowId = Number(row.id);
|
|
||||||
if (Number.isFinite(rowId) && rowId > 0) {
|
|
||||||
return !idsToRemove.has(rowId);
|
|
||||||
}
|
|
||||||
if (hasOriginalId || row.role !== message.role) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const normalizedRowText = row.role === 'user'
|
|
||||||
? normalizeUserMessageText(row.text)
|
|
||||||
: normalizeAssistantMessageText(row.text);
|
|
||||||
return !(
|
|
||||||
normalizedRowText === normalizedTargetText
|
|
||||||
&& JSON.stringify(row.attachments || []) === targetAttachments
|
|
||||||
&& Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setBotMessages(selectedBotId, nextMessages);
|
|
||||||
|
|
||||||
if (prevTop === null || chatAutoFollowRef.current) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const box = chatScrollRef.current;
|
|
||||||
if (!box) return;
|
|
||||||
const maxTop = Math.max(0, box.scrollHeight - box.clientHeight);
|
|
||||||
box.scrollTop = Math.min(prevTop, maxTop);
|
|
||||||
});
|
|
||||||
}, [messages, selectedBotId, setBotMessages]);
|
|
||||||
|
|
||||||
const deleteConversationMessageOnServer = useCallback(async (messageId: number) => {
|
|
||||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`);
|
|
||||||
}, [selectedBotId]);
|
|
||||||
|
|
||||||
const deleteConversationMessage = useCallback(async (message: ChatMessage) => {
|
|
||||||
if (!selectedBotId) {
|
|
||||||
notify(t.deleteMessagePending, { tone: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let targetMessageId = Number(message.id);
|
|
||||||
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
|
|
||||||
targetMessageId = Number(await resolveMessageIdFromLatest(message));
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
|
|
||||||
notify(t.deleteMessagePending, { tone: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (deletingMessageIdMap[targetMessageId]) return;
|
|
||||||
|
|
||||||
const roleLabel = message.role === 'user' ? t.you : 'Nanobot';
|
|
||||||
const ok = await confirm({
|
|
||||||
title: t.deleteMessage,
|
|
||||||
message: t.deleteMessageConfirm(roleLabel),
|
|
||||||
tone: 'warning',
|
|
||||||
confirmLabel: t.delete,
|
|
||||||
cancelLabel: t.cancel,
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true }));
|
|
||||||
try {
|
|
||||||
await deleteConversationMessageOnServer(targetMessageId);
|
|
||||||
removeConversationMessageLocally(message, targetMessageId);
|
|
||||||
notify(t.deleteMessageDone, { tone: 'success' });
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.response?.status === 404) {
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/delete`);
|
|
||||||
removeConversationMessageLocally(message, targetMessageId);
|
|
||||||
notify(t.deleteMessageDone, { tone: 'success' });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// continue to secondary re-match fallback below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (error?.response?.status === 404) {
|
|
||||||
const refreshedMessageId = Number(await resolveMessageIdFromLatest(message));
|
|
||||||
if (Number.isFinite(refreshedMessageId) && refreshedMessageId > 0 && refreshedMessageId !== targetMessageId) {
|
|
||||||
try {
|
|
||||||
await deleteConversationMessageOnServer(refreshedMessageId);
|
|
||||||
removeConversationMessageLocally(message, refreshedMessageId);
|
|
||||||
notify(t.deleteMessageDone, { tone: 'success' });
|
|
||||||
return;
|
|
||||||
} catch (retryError: any) {
|
|
||||||
if (retryError?.response?.status === 404) {
|
|
||||||
try {
|
|
||||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${refreshedMessageId}/delete`);
|
|
||||||
removeConversationMessageLocally(message, refreshedMessageId);
|
|
||||||
notify(t.deleteMessageDone, { tone: 'success' });
|
|
||||||
return;
|
|
||||||
} catch (postRetryError: any) {
|
|
||||||
notify(postRetryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notify(retryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notify(error?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
|
||||||
} finally {
|
|
||||||
setDeletingMessageIdMap((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[targetMessageId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]);
|
|
||||||
|
|
||||||
const toggleProgressExpanded = (key: string) => {
|
const toggleProgressExpanded = (key: string) => {
|
||||||
setExpandedProgressByKey((prev) => ({
|
setExpandedProgressByKey((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
|
||||||
|
|
@ -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 { useCallback, useState, type ChangeEvent } from 'react';
|
||||||
import { optionalChannelTypes } from '../constants';
|
|
||||||
import { createChannelManager, createMcpManager } from '../configManagers';
|
|
||||||
import { useDashboardSkillsConfig } from './useDashboardSkillsConfig';
|
import { useDashboardSkillsConfig } from './useDashboardSkillsConfig';
|
||||||
|
import { useDashboardChannelConfig } from './useDashboardChannelConfig';
|
||||||
|
import { useDashboardMcpConfig } from './useDashboardMcpConfig';
|
||||||
import { useDashboardTopicConfig } from './useDashboardTopicConfig';
|
import { useDashboardTopicConfig } from './useDashboardTopicConfig';
|
||||||
import type { BotChannel, MCPServerDraft, TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types';
|
import type { TopicPresetTemplate, WeixinLoginStatus, WorkspaceSkillOption } from '../types';
|
||||||
import {
|
import { formatCronSchedule } from '../utils';
|
||||||
buildChannelConfigModalProps,
|
|
||||||
buildCronJobsModalProps,
|
|
||||||
buildEnvParamsModalProps,
|
|
||||||
buildMcpConfigModalProps,
|
|
||||||
} from '../shared/configPanelModalProps';
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
interface NotifyOptions {
|
interface NotifyOptions {
|
||||||
|
|
@ -107,53 +102,10 @@ export function useDashboardConfigPanels({
|
||||||
lc,
|
lc,
|
||||||
weixinLoginStatus,
|
weixinLoginStatus,
|
||||||
}: UseDashboardConfigPanelsOptions) {
|
}: UseDashboardConfigPanelsOptions) {
|
||||||
const [showChannelModal, setShowChannelModal] = useState(false);
|
|
||||||
const [showMcpModal, setShowMcpModal] = useState(false);
|
|
||||||
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
||||||
const [showCronModal, setShowCronModal] = useState(false);
|
const [showCronModal, setShowCronModal] = useState(false);
|
||||||
const [channels, setChannels] = useState<BotChannel[]>([]);
|
|
||||||
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
|
|
||||||
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
|
|
||||||
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
|
|
||||||
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
|
|
||||||
id: 'draft-channel',
|
|
||||||
bot_id: '',
|
|
||||||
channel_type: 'feishu',
|
|
||||||
external_app_id: '',
|
|
||||||
app_secret: '',
|
|
||||||
internal_port: 8080,
|
|
||||||
is_active: true,
|
|
||||||
extra_config: {},
|
|
||||||
});
|
|
||||||
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
|
|
||||||
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
|
|
||||||
const [newMcpPanelOpen, setNewMcpPanelOpen] = useState(false);
|
|
||||||
const [newMcpDraft, setNewMcpDraft] = useState<MCPServerDraft>({
|
|
||||||
name: '',
|
|
||||||
type: 'streamableHttp',
|
|
||||||
url: '',
|
|
||||||
botId: '',
|
|
||||||
botSecret: '',
|
|
||||||
toolTimeout: '60',
|
|
||||||
headers: {},
|
|
||||||
locked: false,
|
|
||||||
originName: '',
|
|
||||||
});
|
|
||||||
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
|
|
||||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
|
||||||
const [isSavingMcp, setIsSavingMcp] = useState(false);
|
|
||||||
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
|
||||||
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
|
|
||||||
sendProgress: false,
|
|
||||||
sendToolHints: false,
|
|
||||||
});
|
|
||||||
const addableChannelTypes = useMemo(() => {
|
|
||||||
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
|
|
||||||
return optionalChannelTypes.filter((type) => !exists.has(type));
|
|
||||||
}, [channels]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loadTopics,
|
loadTopics,
|
||||||
|
|
@ -194,125 +146,58 @@ export function useDashboardConfigPanels({
|
||||||
selectedBot,
|
selectedBot,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
channelDraftUiKey,
|
channelConfigModalProps,
|
||||||
resetNewChannelDraft,
|
openChannelConfigModal,
|
||||||
isDashboardChannel,
|
resetChannelPanels,
|
||||||
openChannelModal,
|
} = useDashboardChannelConfig({
|
||||||
beginChannelCreate,
|
closeRuntimeMenu,
|
||||||
updateChannelLocal,
|
|
||||||
saveChannel,
|
|
||||||
addChannel,
|
|
||||||
removeChannel,
|
|
||||||
updateGlobalDeliveryFlag,
|
|
||||||
saveGlobalDelivery,
|
|
||||||
} = createChannelManager({
|
|
||||||
selectedBotId,
|
|
||||||
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
|
||||||
t,
|
|
||||||
currentGlobalDelivery: globalDelivery,
|
|
||||||
addableChannelTypes,
|
|
||||||
currentNewChannelDraft: newChannelDraft,
|
|
||||||
refresh,
|
|
||||||
notify,
|
|
||||||
confirm,
|
confirm,
|
||||||
setShowChannelModal,
|
isZh,
|
||||||
setChannels,
|
loadWeixinLoginStatus,
|
||||||
setExpandedChannelByKey,
|
notify,
|
||||||
setChannelCreateMenuOpen,
|
passwordToggleLabels,
|
||||||
setNewChannelPanelOpen,
|
refresh,
|
||||||
setNewChannelDraft,
|
reloginWeixin,
|
||||||
setIsSavingChannel,
|
selectedBot,
|
||||||
setGlobalDelivery,
|
selectedBotId,
|
||||||
setIsSavingGlobalDelivery,
|
t,
|
||||||
|
lc,
|
||||||
|
weixinLoginStatus,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
resetNewMcpDraft,
|
mcpConfigModalProps,
|
||||||
mcpDraftUiKey,
|
openMcpConfigModal,
|
||||||
openMcpModal,
|
prepareMcpForBotChange,
|
||||||
beginMcpCreate,
|
resetMcpPanels,
|
||||||
updateMcpServer,
|
} = useDashboardMcpConfig({
|
||||||
canRemoveMcpServer,
|
closeRuntimeMenu,
|
||||||
saveNewMcpServer,
|
|
||||||
saveSingleMcpServer,
|
|
||||||
removeMcpServer,
|
|
||||||
} = createMcpManager({
|
|
||||||
selectedBotId,
|
|
||||||
isZh,
|
|
||||||
t,
|
|
||||||
currentMcpServers: mcpServers,
|
|
||||||
currentPersistedMcpServers: persistedMcpServers,
|
|
||||||
currentNewMcpDraft: newMcpDraft,
|
|
||||||
notify,
|
|
||||||
confirm,
|
confirm,
|
||||||
setShowMcpModal,
|
isZh,
|
||||||
setMcpServers,
|
notify,
|
||||||
setPersistedMcpServers,
|
passwordToggleLabels,
|
||||||
setExpandedMcpByKey,
|
selectedBot,
|
||||||
setNewMcpPanelOpen,
|
selectedBotId,
|
||||||
setNewMcpDraft,
|
t,
|
||||||
setIsSavingMcp,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedBotId || !selectedBot) {
|
|
||||||
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGlobalDelivery({
|
|
||||||
sendProgress: Boolean(selectedBot.send_progress),
|
|
||||||
sendToolHints: Boolean(selectedBot.send_tool_hints),
|
|
||||||
});
|
|
||||||
}, [selectedBot, selectedBotId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onPointerDown = (event: MouseEvent) => {
|
|
||||||
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
|
|
||||||
setChannelCreateMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
||||||
if (event.key !== 'Escape') return;
|
|
||||||
setChannelCreateMenuOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', onPointerDown);
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', onPointerDown);
|
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetAllConfigPanels = useCallback(() => {
|
const resetAllConfigPanels = useCallback(() => {
|
||||||
setShowChannelModal(false);
|
|
||||||
setShowMcpModal(false);
|
|
||||||
setShowEnvParamsModal(false);
|
setShowEnvParamsModal(false);
|
||||||
setShowCronModal(false);
|
setShowCronModal(false);
|
||||||
resetSkillsPanels();
|
resetSkillsPanels();
|
||||||
setChannels([]);
|
resetChannelPanels();
|
||||||
setExpandedChannelByKey({});
|
|
||||||
setNewChannelPanelOpen(false);
|
|
||||||
setChannelCreateMenuOpen(false);
|
|
||||||
resetNewChannelDraft();
|
|
||||||
resetTopicPanels();
|
resetTopicPanels();
|
||||||
setExpandedMcpByKey({});
|
resetMcpPanels();
|
||||||
setMcpServers([]);
|
|
||||||
setPersistedMcpServers([]);
|
|
||||||
setNewMcpPanelOpen(false);
|
|
||||||
resetNewMcpDraft();
|
|
||||||
setEnvDraftKey('');
|
setEnvDraftKey('');
|
||||||
setEnvDraftValue('');
|
setEnvDraftValue('');
|
||||||
setGlobalDelivery({ sendProgress: false, sendToolHints: false });
|
|
||||||
resetSupportState();
|
resetSupportState();
|
||||||
}, [resetNewChannelDraft, resetNewMcpDraft, resetSkillsPanels, resetSupportState, resetTopicPanels]);
|
}, [resetChannelPanels, resetMcpPanels, resetSkillsPanels, resetSupportState, resetTopicPanels]);
|
||||||
|
|
||||||
const prepareForBotChange = useCallback(() => {
|
const prepareForBotChange = useCallback(() => {
|
||||||
resetSkillsPanels();
|
resetSkillsPanels();
|
||||||
prepareTopicForBotChange();
|
prepareTopicForBotChange();
|
||||||
setExpandedMcpByKey({});
|
prepareMcpForBotChange();
|
||||||
setNewMcpPanelOpen(false);
|
|
||||||
resetNewMcpDraft();
|
|
||||||
resetSupportState();
|
resetSupportState();
|
||||||
}, [prepareTopicForBotChange, resetNewMcpDraft, resetSkillsPanels, resetSupportState]);
|
}, [prepareMcpForBotChange, prepareTopicForBotChange, resetSkillsPanels, resetSupportState]);
|
||||||
|
|
||||||
const loadInitialConfigData = useCallback(async (botId: string) => {
|
const loadInitialConfigData = useCallback(async (botId: string) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -329,12 +214,6 @@ export function useDashboardConfigPanels({
|
||||||
loadTopicFeedStats,
|
loadTopicFeedStats,
|
||||||
loadTopics,
|
loadTopics,
|
||||||
]);
|
]);
|
||||||
const openChannelConfigModal = useCallback(() => {
|
|
||||||
closeRuntimeMenu();
|
|
||||||
if (!selectedBot) return;
|
|
||||||
void loadWeixinLoginStatus(selectedBot.id);
|
|
||||||
openChannelModal(selectedBot.id);
|
|
||||||
}, [closeRuntimeMenu, loadWeixinLoginStatus, openChannelModal, selectedBot]);
|
|
||||||
|
|
||||||
const openEnvParamsConfigModal = useCallback(() => {
|
const openEnvParamsConfigModal = useCallback(() => {
|
||||||
closeRuntimeMenu();
|
closeRuntimeMenu();
|
||||||
|
|
@ -343,90 +222,17 @@ export function useDashboardConfigPanels({
|
||||||
setShowEnvParamsModal(true);
|
setShowEnvParamsModal(true);
|
||||||
}, [closeRuntimeMenu, loadBotEnvParams, selectedBot]);
|
}, [closeRuntimeMenu, loadBotEnvParams, selectedBot]);
|
||||||
|
|
||||||
const openMcpConfigModal = useCallback(() => {
|
|
||||||
closeRuntimeMenu();
|
|
||||||
if (!selectedBot) return;
|
|
||||||
void openMcpModal(selectedBot.id);
|
|
||||||
}, [closeRuntimeMenu, openMcpModal, selectedBot]);
|
|
||||||
|
|
||||||
const openCronJobsModal = useCallback(() => {
|
const openCronJobsModal = useCallback(() => {
|
||||||
closeRuntimeMenu();
|
closeRuntimeMenu();
|
||||||
if (selectedBot) void loadCronJobs(selectedBot.id);
|
if (selectedBot) void loadCronJobs(selectedBot.id);
|
||||||
setShowCronModal(true);
|
setShowCronModal(true);
|
||||||
}, [closeRuntimeMenu, loadCronJobs, selectedBot]);
|
}, [closeRuntimeMenu, loadCronJobs, selectedBot]);
|
||||||
|
|
||||||
const channelConfigModalProps = buildChannelConfigModalProps({
|
const envParamsModalProps = {
|
||||||
addableChannelTypes,
|
open: showEnvParamsModal,
|
||||||
beginChannelCreate,
|
envEntries,
|
||||||
channelCreateMenuOpen,
|
|
||||||
channelCreateMenuRef,
|
|
||||||
channelDraftUiKey,
|
|
||||||
channels,
|
|
||||||
expandedChannelByKey,
|
|
||||||
globalDelivery,
|
|
||||||
hasSelectedBot: Boolean(selectedBot),
|
|
||||||
isDashboardChannel,
|
|
||||||
isSavingChannel,
|
|
||||||
isSavingGlobalDelivery,
|
|
||||||
isZh,
|
|
||||||
labels: { ...lc, cancel: t.cancel, close: t.close },
|
|
||||||
newChannelDraft,
|
|
||||||
newChannelPanelOpen,
|
|
||||||
onAddChannel: addChannel,
|
|
||||||
onClose: () => {
|
|
||||||
setShowChannelModal(false);
|
|
||||||
setChannelCreateMenuOpen(false);
|
|
||||||
setNewChannelPanelOpen(false);
|
|
||||||
resetNewChannelDraft();
|
|
||||||
},
|
|
||||||
onReloginWeixin: reloginWeixin,
|
|
||||||
passwordToggleLabels,
|
|
||||||
removeChannel,
|
|
||||||
resetNewChannelDraft,
|
|
||||||
saveChannel,
|
|
||||||
saveGlobalDelivery,
|
|
||||||
setChannelCreateMenuOpen,
|
|
||||||
setExpandedChannelByKey,
|
|
||||||
setNewChannelDraft,
|
|
||||||
setNewChannelPanelOpen,
|
|
||||||
updateChannelLocal,
|
|
||||||
updateGlobalDeliveryFlag,
|
|
||||||
weixinLoginStatus,
|
|
||||||
open: showChannelModal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mcpConfigModalProps = buildMcpConfigModalProps({
|
|
||||||
beginMcpCreate,
|
|
||||||
canRemoveMcpServer,
|
|
||||||
expandedMcpByKey,
|
|
||||||
getMcpUiKey: mcpDraftUiKey,
|
|
||||||
isSavingMcp,
|
|
||||||
isZh,
|
|
||||||
labels: { ...t, cancel: t.cancel, close: t.close, save: t.save },
|
|
||||||
mcpServers,
|
|
||||||
newMcpDraft,
|
|
||||||
newMcpPanelOpen,
|
|
||||||
onClose: () => {
|
|
||||||
setShowMcpModal(false);
|
|
||||||
setNewMcpPanelOpen(false);
|
|
||||||
resetNewMcpDraft();
|
|
||||||
},
|
|
||||||
passwordToggleLabels,
|
|
||||||
removeMcpServer,
|
|
||||||
resetNewMcpDraft,
|
|
||||||
saveNewMcpServer,
|
|
||||||
saveSingleMcpServer,
|
|
||||||
setExpandedMcpByKey,
|
|
||||||
setNewMcpDraft,
|
|
||||||
setNewMcpPanelOpen,
|
|
||||||
updateMcpServer,
|
|
||||||
open: showMcpModal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const envParamsModalProps = buildEnvParamsModalProps({
|
|
||||||
envDraftKey,
|
envDraftKey,
|
||||||
envDraftValue,
|
envDraftValue,
|
||||||
envEntries,
|
|
||||||
labels: {
|
labels: {
|
||||||
addEnvParam: t.addEnvParam,
|
addEnvParam: t.addEnvParam,
|
||||||
cancel: t.cancel,
|
cancel: t.cancel,
|
||||||
|
|
@ -444,20 +250,19 @@ export function useDashboardConfigPanels({
|
||||||
showEnvValue: t.showEnvValue,
|
showEnvValue: t.showEnvValue,
|
||||||
},
|
},
|
||||||
onClose: () => setShowEnvParamsModal(false),
|
onClose: () => setShowEnvParamsModal(false),
|
||||||
|
onEnvDraftKeyChange: setEnvDraftKey,
|
||||||
|
onEnvDraftValueChange: setEnvDraftValue,
|
||||||
onCreateEnvParam: createEnvParam,
|
onCreateEnvParam: createEnvParam,
|
||||||
onDeleteEnvParam: deleteEnvParam,
|
onDeleteEnvParam: deleteEnvParam,
|
||||||
onSaveEnvParam: saveSingleEnvParam,
|
onSaveEnvParam: saveSingleEnvParam,
|
||||||
setEnvDraftKey,
|
};
|
||||||
setEnvDraftValue,
|
|
||||||
open: showEnvParamsModal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cronJobsModalProps = buildCronJobsModalProps({
|
const cronJobsModalProps = {
|
||||||
cronActionJobId,
|
open: showCronModal,
|
||||||
cronActionType,
|
|
||||||
cronJobs,
|
|
||||||
cronLoading,
|
cronLoading,
|
||||||
deleteCronJob,
|
cronJobs,
|
||||||
|
cronActionJobId: cronActionJobId || '',
|
||||||
|
cronActionType,
|
||||||
isZh,
|
isZh,
|
||||||
labels: {
|
labels: {
|
||||||
close: t.close,
|
close: t.close,
|
||||||
|
|
@ -471,13 +276,13 @@ export function useDashboardConfigPanels({
|
||||||
cronStop: t.cronStop,
|
cronStop: t.cronStop,
|
||||||
cronViewer: t.cronViewer,
|
cronViewer: t.cronViewer,
|
||||||
},
|
},
|
||||||
loadCronJobs,
|
formatCronSchedule,
|
||||||
onClose: () => setShowCronModal(false),
|
onClose: () => setShowCronModal(false),
|
||||||
selectedBot,
|
onReload: () => (selectedBot ? loadCronJobs(selectedBot.id) : undefined),
|
||||||
startCronJob,
|
onStartJob: startCronJob,
|
||||||
stopCronJob,
|
onStopJob: stopCronJob,
|
||||||
open: showCronModal,
|
onDeleteJob: deleteCronJob,
|
||||||
});
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelConfigModalProps,
|
channelConfigModalProps,
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { ChatMessage } from '../../../types/bot';
|
import type { ChatMessage } from '../../../types/bot';
|
||||||
|
import type { DashboardChatConfirmOptions, DashboardChatNotifyOptions } from './dashboardChatShared';
|
||||||
import { useDashboardChatComposer } from './useDashboardChatComposer';
|
import { useDashboardChatComposer } from './useDashboardChatComposer';
|
||||||
import { useDashboardChatHistory } from './useDashboardChatHistory';
|
import { useDashboardChatHistory } from './useDashboardChatHistory';
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
interface NotifyOptions {
|
|
||||||
title?: string;
|
|
||||||
tone?: PromptTone;
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmOptions {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
tone?: PromptTone;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseDashboardConversationOptions {
|
interface UseDashboardConversationOptions {
|
||||||
selectedBotId: string;
|
selectedBotId: string;
|
||||||
selectedBot?: { id: string; messages?: ChatMessage[] } | null;
|
selectedBot?: { id: string; messages?: ChatMessage[] } | null;
|
||||||
|
|
@ -37,8 +22,8 @@ interface UseDashboardConversationOptions {
|
||||||
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
|
||||||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||||
notify: (message: string, options?: NotifyOptions) => void;
|
notify: (message: string, options?: DashboardChatNotifyOptions) => void;
|
||||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
confirm: (options: DashboardChatConfirmOptions) => Promise<boolean>;
|
||||||
t: any;
|
t: any;
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,21 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
|
||||||
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
||||||
|
import { mergeConversation } from '../chat/chatUtils';
|
||||||
import { RUNTIME_STALE_MS } from '../constants';
|
import { RUNTIME_STALE_MS } from '../constants';
|
||||||
import { normalizeAssistantMessageText } from '../messageParser';
|
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
||||||
import type { BaseImageOption, NanobotImage } from '../types';
|
import type { BaseImageOption, NanobotImage } from '../types';
|
||||||
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
||||||
import {
|
import { normalizeRuntimeState } from '../utils';
|
||||||
mergeConversation,
|
|
||||||
normalizeRuntimeState,
|
|
||||||
parseBotTimestamp,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
interface UseDashboardDerivedStateOptions {
|
interface UseDashboardBaseStateOptions {
|
||||||
availableImages: NanobotImage[];
|
availableImages: NanobotImage[];
|
||||||
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
||||||
defaultSystemTimezone: string;
|
defaultSystemTimezone: string;
|
||||||
editFormImageTag: string;
|
editFormImageTag: string;
|
||||||
editFormSystemTimezone: string;
|
editFormSystemTimezone: string;
|
||||||
events: any[];
|
events: any[];
|
||||||
isCommandAutoUnlockWindowActive?: boolean;
|
|
||||||
isSendingBlocked?: boolean;
|
|
||||||
isVoiceRecording?: boolean;
|
|
||||||
isVoiceTranscribing?: boolean;
|
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
messages: any[];
|
messages: any[];
|
||||||
selectedBot?: any;
|
selectedBot?: any;
|
||||||
|
|
@ -29,23 +23,27 @@ interface UseDashboardDerivedStateOptions {
|
||||||
topics: any[];
|
topics: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDashboardDerivedState({
|
interface UseDashboardInteractionStateOptions {
|
||||||
|
canChat: boolean;
|
||||||
|
isSendingBlocked?: boolean;
|
||||||
|
isVoiceRecording?: boolean;
|
||||||
|
isVoiceTranscribing?: boolean;
|
||||||
|
selectedBot?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardBaseState({
|
||||||
availableImages,
|
availableImages,
|
||||||
controlStateByBot,
|
controlStateByBot,
|
||||||
defaultSystemTimezone,
|
defaultSystemTimezone,
|
||||||
editFormImageTag,
|
editFormImageTag,
|
||||||
editFormSystemTimezone,
|
editFormSystemTimezone,
|
||||||
events,
|
events,
|
||||||
isCommandAutoUnlockWindowActive = false,
|
|
||||||
isSendingBlocked = false,
|
|
||||||
isVoiceRecording = false,
|
|
||||||
isVoiceTranscribing = false,
|
|
||||||
isZh,
|
isZh,
|
||||||
messages,
|
messages,
|
||||||
selectedBot,
|
selectedBot,
|
||||||
topicFeedUnreadCount,
|
topicFeedUnreadCount,
|
||||||
topics,
|
topics,
|
||||||
}: UseDashboardDerivedStateOptions) {
|
}: UseDashboardBaseStateOptions) {
|
||||||
const activeTopicOptions = useMemo<TopicFeedOption[]>(
|
const activeTopicOptions = useMemo<TopicFeedOption[]>(
|
||||||
() =>
|
() =>
|
||||||
topics
|
topics
|
||||||
|
|
@ -102,10 +100,6 @@ export function useDashboardDerivedState({
|
||||||
selectedBot.docker_status === 'RUNNING' &&
|
selectedBot.docker_status === 'RUNNING' &&
|
||||||
!selectedBotControlState,
|
!selectedBotControlState,
|
||||||
);
|
);
|
||||||
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
|
|
||||||
const canSendControlCommand = Boolean(
|
|
||||||
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
|
|
||||||
);
|
|
||||||
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
|
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
|
||||||
const systemTimezoneOptions = useMemo(
|
const systemTimezoneOptions = useMemo(
|
||||||
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
|
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
|
||||||
|
|
@ -175,27 +169,38 @@ export function useDashboardDerivedState({
|
||||||
if (eventText) return eventText;
|
if (eventText) return eventText;
|
||||||
return '-';
|
return '-';
|
||||||
}, [latestEvent, selectedBot]);
|
}, [latestEvent, selectedBot]);
|
||||||
const showInterruptSubmitAction = Boolean(
|
|
||||||
canChat && ((isThinking && isCommandAutoUnlockWindowActive) || isSendingBlocked),
|
|
||||||
);
|
|
||||||
const hasTopicUnread = topicFeedUnreadCount > 0;
|
const hasTopicUnread = topicFeedUnreadCount > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTopicOptions,
|
activeTopicOptions,
|
||||||
baseImageOptions,
|
baseImageOptions,
|
||||||
canChat,
|
canChat,
|
||||||
canSendControlCommand,
|
|
||||||
conversation,
|
conversation,
|
||||||
displayState,
|
displayState,
|
||||||
hasTopicUnread,
|
hasTopicUnread,
|
||||||
isChatEnabled,
|
|
||||||
isThinking,
|
isThinking,
|
||||||
latestEvent,
|
|
||||||
runtimeAction,
|
runtimeAction,
|
||||||
selectedBotControlState,
|
selectedBotControlState,
|
||||||
selectedBotEnabled,
|
selectedBotEnabled,
|
||||||
showInterruptSubmitAction,
|
|
||||||
systemTimezoneOptions,
|
systemTimezoneOptions,
|
||||||
topicPanelState,
|
topicPanelState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDashboardInteractionState({
|
||||||
|
canChat,
|
||||||
|
isSendingBlocked = false,
|
||||||
|
isVoiceRecording = false,
|
||||||
|
isVoiceTranscribing = false,
|
||||||
|
selectedBot,
|
||||||
|
}: UseDashboardInteractionStateOptions) {
|
||||||
|
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
|
||||||
|
const canSendControlCommand = Boolean(
|
||||||
|
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSendControlCommand,
|
||||||
|
isChatEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
import type { CompactPanelTab, RuntimeViewMode } from '../types';
|
||||||
import { sortBotsByCreatedAtDesc } from '../utils';
|
|
||||||
|
|
||||||
interface UseDashboardShellStateOptions {
|
interface UseDashboardShellStateOptions {
|
||||||
activeBots: Record<string, BotState>;
|
activeBots: Record<string, BotState>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type SetStateAction } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
|
||||||
import { buildSkillMarketInstallModalProps, buildSkillsModalProps } from '../shared/configPanelModalProps';
|
|
||||||
import type { WorkspaceSkillOption } from '../types';
|
import type { WorkspaceSkillOption } from '../types';
|
||||||
|
import { formatBytes } from '../utils';
|
||||||
|
|
||||||
interface UseDashboardSkillsConfigOptions {
|
interface UseDashboardSkillsConfigOptions {
|
||||||
botSkills: WorkspaceSkillOption[];
|
botSkills: WorkspaceSkillOption[];
|
||||||
|
|
@ -77,42 +77,52 @@ export function useDashboardSkillsConfig({
|
||||||
setSkillAddMenuOpen(false);
|
setSkillAddMenuOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const skillsModalProps = buildSkillsModalProps({
|
const skillsModalProps = {
|
||||||
|
open: showSkillsModal,
|
||||||
botSkills,
|
botSkills,
|
||||||
hasSelectedBot: Boolean(selectedBot),
|
|
||||||
isSkillUploading,
|
isSkillUploading,
|
||||||
isZh,
|
isZh,
|
||||||
|
hasSelectedBot: Boolean(selectedBot),
|
||||||
labels,
|
labels,
|
||||||
loadBotSkills,
|
skillZipPickerRef,
|
||||||
loadMarketSkills,
|
skillAddMenuRef,
|
||||||
|
skillAddMenuOpen,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
setSkillAddMenuOpen(false);
|
setSkillAddMenuOpen(false);
|
||||||
setShowSkillsModal(false);
|
setShowSkillsModal(false);
|
||||||
},
|
},
|
||||||
onOpenSkillMarketDone: () => setShowSkillMarketInstallModal(true),
|
onRefreshSkills: () => (selectedBot ? loadBotSkills(selectedBot.id) : undefined),
|
||||||
|
onRemoveSkill: removeBotSkill,
|
||||||
onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void,
|
onPickSkillZip: onPickSkillZip as (event: ChangeEvent<HTMLInputElement>) => void,
|
||||||
removeBotSkill,
|
onSetSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>,
|
||||||
selectedBot,
|
onTriggerSkillZipUpload: triggerSkillZipUpload,
|
||||||
setSkillAddMenuOpen: setSkillAddMenuOpen as Dispatch<SetStateAction<boolean>>,
|
onOpenSkillMarketplace: async () => {
|
||||||
skillAddMenuOpen,
|
if (!selectedBot) return;
|
||||||
skillAddMenuRef,
|
setSkillAddMenuOpen(false);
|
||||||
skillZipPickerRef,
|
await loadMarketSkills(selectedBot.id);
|
||||||
triggerSkillZipUpload,
|
setShowSkillMarketInstallModal(true);
|
||||||
open: showSkillsModal,
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
const skillMarketInstallModalProps = buildSkillMarketInstallModalProps({
|
const skillMarketInstallModalProps = {
|
||||||
installMarketSkill,
|
|
||||||
installingId: marketSkillInstallingId,
|
|
||||||
isZh,
|
isZh,
|
||||||
items: marketSkills,
|
|
||||||
loadBotSkills,
|
|
||||||
loadMarketSkills,
|
|
||||||
loading: isMarketSkillsLoading,
|
|
||||||
onClose: () => setShowSkillMarketInstallModal(false),
|
|
||||||
selectedBot,
|
|
||||||
open: showSkillMarketInstallModal,
|
open: showSkillMarketInstallModal,
|
||||||
});
|
items: marketSkills,
|
||||||
|
loading: isMarketSkillsLoading,
|
||||||
|
installingId: typeof marketSkillInstallingId === 'number' ? marketSkillInstallingId : null,
|
||||||
|
onClose: () => setShowSkillMarketInstallModal(false),
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!selectedBot) return;
|
||||||
|
await loadMarketSkills(selectedBot.id);
|
||||||
|
},
|
||||||
|
onInstall: async (skill: any) => {
|
||||||
|
await installMarketSkill(skill);
|
||||||
|
if (selectedBot) {
|
||||||
|
await loadBotSkills(selectedBot.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatBytes,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openSkillsConfigModal,
|
openSkillsConfigModal,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
|
||||||
|
import { parseAllowedAttachmentExtensions, parseWorkspaceDownloadExtensions } from '../../../shared/workspace/utils';
|
||||||
import { normalizePlatformPageSize } from '../../../utils/platformPageSize';
|
import { normalizePlatformPageSize } from '../../../utils/platformPageSize';
|
||||||
import { fetchDashboardSystemDefaults } from '../api/system';
|
import { fetchDashboardSystemDefaults } from '../api/system';
|
||||||
|
import { parseTopicPresets } from '../topic/topicPresetUtils';
|
||||||
import type { SystemDefaultsResponse, TopicPresetTemplate } from '../types';
|
import type { SystemDefaultsResponse, TopicPresetTemplate } from '../types';
|
||||||
import { parseAllowedAttachmentExtensions, parseTopicPresets, parseWorkspaceDownloadExtensions } from '../utils';
|
|
||||||
|
|
||||||
interface UseDashboardSystemDefaultsOptions {
|
interface UseDashboardSystemDefaultsOptions {
|
||||||
setBotListPageSize: Dispatch<SetStateAction<number>>;
|
setBotListPageSize: Dispatch<SetStateAction<number>>;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { fetchDashboardSystemTemplates, updateDashboardSystemTemplates } from '../api/system';
|
import { fetchDashboardSystemTemplates, updateDashboardSystemTemplates } from '../api/system';
|
||||||
|
import { parseTopicPresets } from '../topic/topicPresetUtils';
|
||||||
import type { TopicPresetTemplate } from '../types';
|
import type { TopicPresetTemplate } from '../types';
|
||||||
import { parseTopicPresets } from '../utils';
|
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { createTopicManager } from '../configManagers';
|
import { createTopicManager } from '../config-managers/topicManager';
|
||||||
import { buildTopicConfigModalProps } from '../shared/configPanelModalProps';
|
import { resolvePresetText } from '../topic/topicPresetUtils';
|
||||||
import type { BotTopic, TopicPresetTemplate } from '../types';
|
import type { BotTopic, TopicPresetTemplate } from '../types';
|
||||||
import { resolvePresetText } from '../utils';
|
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
|
@ -151,58 +150,66 @@ export function useDashboardTopicConfig({
|
||||||
setNewTopicPriority('50');
|
setNewTopicPriority('50');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const topicConfigModalProps = buildTopicConfigModalProps({
|
const topicConfigModalProps = {
|
||||||
beginTopicCreate,
|
open: showTopicModal,
|
||||||
countRoutingTextList: (raw) => normalizeRoutingTextList(raw).length,
|
topics,
|
||||||
effectiveTopicPresetTemplates,
|
|
||||||
expandedTopicByKey,
|
expandedTopicByKey,
|
||||||
getTopicUiKey: topicDraftUiKey,
|
newTopicPanelOpen,
|
||||||
hasSelectedBot: Boolean(selectedBot),
|
topicPresetMenuOpen,
|
||||||
isSavingTopic,
|
|
||||||
isZh,
|
|
||||||
labels: { ...t, cancel: t.cancel, close: t.close, delete: t.delete, save: t.save },
|
|
||||||
newTopicAdvancedOpen,
|
newTopicAdvancedOpen,
|
||||||
newTopicDescription,
|
newTopicSourceLabel,
|
||||||
newTopicExamplesNegative,
|
|
||||||
newTopicExamplesPositive,
|
|
||||||
newTopicExcludeWhen,
|
|
||||||
newTopicIncludeWhen,
|
|
||||||
newTopicKey,
|
newTopicKey,
|
||||||
newTopicName,
|
newTopicName,
|
||||||
newTopicPanelOpen,
|
newTopicDescription,
|
||||||
newTopicPriority,
|
|
||||||
newTopicPurpose,
|
newTopicPurpose,
|
||||||
newTopicSourceLabel,
|
newTopicIncludeWhen,
|
||||||
normalizeTopicKeyInput,
|
newTopicExcludeWhen,
|
||||||
onAddTopic: addTopic,
|
newTopicExamplesPositive,
|
||||||
|
newTopicExamplesNegative,
|
||||||
|
newTopicPriority,
|
||||||
|
effectiveTopicPresetTemplates,
|
||||||
|
topicPresetMenuRef,
|
||||||
|
isSavingTopic,
|
||||||
|
hasSelectedBot: Boolean(selectedBot),
|
||||||
|
isZh,
|
||||||
|
labels: { ...t, cancel: t.cancel, close: t.close, delete: t.delete, save: t.save },
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
setShowTopicModal(false);
|
setShowTopicModal(false);
|
||||||
setTopicPresetMenuOpen(false);
|
setTopicPresetMenuOpen(false);
|
||||||
setNewTopicPanelOpen(false);
|
setNewTopicPanelOpen(false);
|
||||||
resetNewTopicDraft();
|
resetNewTopicDraft();
|
||||||
},
|
},
|
||||||
removeTopic,
|
getTopicUiKey: topicDraftUiKey,
|
||||||
resetNewTopicDraft,
|
countRoutingTextList: (raw: string) => normalizeRoutingTextList(raw).length,
|
||||||
saveTopic,
|
onUpdateTopicLocal: updateTopicLocal,
|
||||||
setExpandedTopicByKey: setExpandedTopicByKey as Dispatch<SetStateAction<Record<string, boolean>>>,
|
onToggleExpandedTopic: (key: string) => {
|
||||||
setNewTopicAdvancedOpen,
|
setExpandedTopicByKey((prev) => {
|
||||||
setNewTopicDescription,
|
const fallbackExpanded = topics.findIndex((topic, idx) => topicDraftUiKey(topic, idx) === key) === 0;
|
||||||
setNewTopicExamplesNegative,
|
const current = typeof prev[key] === 'boolean' ? prev[key] : fallbackExpanded;
|
||||||
setNewTopicExamplesPositive,
|
return { ...prev, [key]: !current };
|
||||||
setNewTopicExcludeWhen,
|
|
||||||
setNewTopicIncludeWhen,
|
|
||||||
setNewTopicKey,
|
|
||||||
setNewTopicName,
|
|
||||||
setNewTopicPanelOpen,
|
|
||||||
setNewTopicPriority,
|
|
||||||
setNewTopicPurpose,
|
|
||||||
setTopicPresetMenuOpen,
|
|
||||||
topicPresetMenuOpen,
|
|
||||||
topicPresetMenuRef,
|
|
||||||
topics,
|
|
||||||
updateTopicLocal,
|
|
||||||
open: showTopicModal,
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
onRemoveTopic: removeTopic,
|
||||||
|
onSaveTopic: saveTopic,
|
||||||
|
onSetNewTopicPanelOpen: setNewTopicPanelOpen,
|
||||||
|
onSetTopicPresetMenuOpen: setTopicPresetMenuOpen,
|
||||||
|
onSetNewTopicAdvancedOpen: setNewTopicAdvancedOpen,
|
||||||
|
onResetNewTopicDraft: resetNewTopicDraft,
|
||||||
|
onNormalizeTopicKeyInput: normalizeTopicKeyInput,
|
||||||
|
onSetNewTopicKey: setNewTopicKey,
|
||||||
|
onSetNewTopicName: setNewTopicName,
|
||||||
|
onSetNewTopicDescription: setNewTopicDescription,
|
||||||
|
onSetNewTopicPurpose: setNewTopicPurpose,
|
||||||
|
onSetNewTopicIncludeWhen: setNewTopicIncludeWhen,
|
||||||
|
onSetNewTopicExcludeWhen: setNewTopicExcludeWhen,
|
||||||
|
onSetNewTopicExamplesPositive: setNewTopicExamplesPositive,
|
||||||
|
onSetNewTopicExamplesNegative: setNewTopicExamplesNegative,
|
||||||
|
onSetNewTopicPriority: setNewTopicPriority,
|
||||||
|
onBeginTopicCreate: beginTopicCreate,
|
||||||
|
onResolvePresetLabel: (preset: TopicPresetTemplate) =>
|
||||||
|
resolvePresetText(preset.name, isZh ? 'zh-cn' : 'en') || preset.topic_key || preset.id,
|
||||||
|
onAddTopic: addTopic,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadTopics,
|
loadTopics,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type Dispatch, type RefObject, type SetSta
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
import { normalizeUserMessageText } from '../messageParser';
|
import { normalizeUserMessageText } from '../../../shared/text/messageText';
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 rehypeSanitize from 'rehype-sanitize';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||||
import { DashboardPreviewModalShell } from '../components/DashboardPreviewModalShell';
|
import { PreviewModalShell } from '../../../shared/ui/PreviewModalShell';
|
||||||
import {
|
import {
|
||||||
createWorkspaceMarkdownComponents,
|
createWorkspaceMarkdownComponents,
|
||||||
decorateWorkspacePathsForMarkdown,
|
decorateWorkspacePathsForMarkdown,
|
||||||
resolveWorkspaceDocumentPath,
|
resolveWorkspaceDocumentPath,
|
||||||
} from '../shared/workspaceMarkdown';
|
} from '../../../shared/workspace/workspaceMarkdown';
|
||||||
import './TopicFeedPanel.css';
|
import './TopicFeedPanel.css';
|
||||||
|
|
||||||
export interface TopicFeedItem {
|
export interface TopicFeedItem {
|
||||||
|
|
@ -357,7 +357,7 @@ export function TopicFeedPanel({
|
||||||
</div>
|
</div>
|
||||||
{detailState && portalTarget
|
{detailState && portalTarget
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<DashboardPreviewModalShell
|
<PreviewModalShell
|
||||||
closeLabel={isZh ? '关闭详情' : 'Close detail'}
|
closeLabel={isZh ? '关闭详情' : 'Close detail'}
|
||||||
onClose={closeDetail}
|
onClose={closeDetail}
|
||||||
subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')}
|
subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')}
|
||||||
|
|
@ -374,7 +374,7 @@ export function TopicFeedPanel({
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPreviewModalShell>,
|
</PreviewModalShell>,
|
||||||
portalTarget,
|
portalTarget,
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
|
||||||
|
|
@ -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 AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||||
export type WorkspaceNodeType = 'dir' | 'file';
|
|
||||||
export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack' | 'email';
|
export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack' | 'email';
|
||||||
export type RuntimeViewMode = 'visual' | 'topic';
|
export type RuntimeViewMode = 'visual' | 'topic';
|
||||||
export type CompactPanelTab = 'chat' | 'runtime';
|
export type CompactPanelTab = 'chat' | 'runtime';
|
||||||
export type WorkspacePreviewMode = 'preview' | 'edit';
|
|
||||||
export type QuotedReply = { id?: number; text: string; ts: number };
|
export type QuotedReply = { id?: number; text: string; ts: number };
|
||||||
export type StagedSubmissionDraft = {
|
export type StagedSubmissionDraft = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,58 +22,6 @@ export type StagedSubmissionDraft = {
|
||||||
};
|
};
|
||||||
export type BotEnvParams = Record<string, string>;
|
export type BotEnvParams = Record<string, string>;
|
||||||
|
|
||||||
export interface WorkspaceNode {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type: WorkspaceNodeType;
|
|
||||||
size?: number;
|
|
||||||
ext?: string;
|
|
||||||
ctime?: string;
|
|
||||||
mtime?: string;
|
|
||||||
children?: WorkspaceNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceHoverCardState {
|
|
||||||
node: WorkspaceNode;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
above: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceTreeResponse {
|
|
||||||
bot_id: string;
|
|
||||||
root: string;
|
|
||||||
cwd: string;
|
|
||||||
parent: string | null;
|
|
||||||
entries: WorkspaceNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceFileResponse {
|
|
||||||
bot_id: string;
|
|
||||||
path: string;
|
|
||||||
size: number;
|
|
||||||
is_markdown: boolean;
|
|
||||||
truncated: boolean;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspacePreviewState {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
truncated: boolean;
|
|
||||||
ext: string;
|
|
||||||
isMarkdown: boolean;
|
|
||||||
isImage: boolean;
|
|
||||||
isHtml: boolean;
|
|
||||||
isVideo: boolean;
|
|
||||||
isAudio: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspaceUploadResponse {
|
|
||||||
bot_id: string;
|
|
||||||
files: Array<{ name: string; path: string; size: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotMessagesByDateResponse {
|
export interface BotMessagesByDateResponse {
|
||||||
items?: any[];
|
items?: any[];
|
||||||
anchor_id?: number | null;
|
anchor_id?: number | null;
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,17 @@
|
||||||
import type { ReactNode } from 'react';
|
import { normalizeAttachmentPaths } from '../../shared/workspace/utils';
|
||||||
|
import { normalizeDashboardAttachmentPath } from '../../shared/workspace/workspaceMarkdown';
|
||||||
import type { ChatMessage } from '../../types/bot';
|
|
||||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from './messageParser';
|
|
||||||
import {
|
|
||||||
AUDIO_PREVIEW_EXTENSIONS,
|
|
||||||
HTML_PREVIEW_EXTENSIONS,
|
|
||||||
IMAGE_PREVIEW_EXTENSIONS,
|
|
||||||
MEDIA_UPLOAD_EXTENSIONS,
|
|
||||||
SYSTEM_FALLBACK_TOPIC_KEYS,
|
|
||||||
TEXT_PREVIEW_EXTENSIONS,
|
|
||||||
VIDEO_PREVIEW_EXTENSIONS,
|
|
||||||
} from './constants';
|
|
||||||
import type {
|
import type {
|
||||||
BotTopic,
|
|
||||||
CronJob,
|
CronJob,
|
||||||
MCPConfigResponse,
|
MCPConfigResponse,
|
||||||
MCPServerDraft,
|
MCPServerDraft,
|
||||||
TopicPresetTemplate,
|
|
||||||
WorkspaceNode,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { normalizeDashboardAttachmentPath } from './shared/workspaceMarkdown';
|
|
||||||
|
|
||||||
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
|
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
|
||||||
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: readonly string[] = [];
|
|
||||||
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET: ReadonlySet<string> = new Set<string>();
|
|
||||||
|
|
||||||
export function formatClock(ts: number) {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const hh = String(d.getHours()).padStart(2, '0');
|
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
||||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
||||||
return `${hh}:${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatConversationDate(ts: number, isZh: boolean) {
|
|
||||||
const d = new Date(ts);
|
|
||||||
try {
|
|
||||||
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
weekday: 'short',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateInputValue(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
const year = d.getFullYear();
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapBotMessageResponseRow(row: any): ChatMessage {
|
|
||||||
const roleRaw = String(row?.role || '').toLowerCase();
|
|
||||||
const role: ChatMessage['role'] =
|
|
||||||
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
|
||||||
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
|
||||||
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
|
||||||
return {
|
|
||||||
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
|
||||||
role,
|
|
||||||
text: String(row?.text || ''),
|
|
||||||
attachments: normalizeAttachmentPaths(row?.media),
|
|
||||||
ts: Number(row?.ts || Date.now()),
|
|
||||||
feedback,
|
|
||||||
kind: 'final',
|
|
||||||
} as ChatMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stateLabel(s?: string) {
|
export function stateLabel(s?: string) {
|
||||||
return (s || 'IDLE').toUpperCase();
|
return (s || 'IDLE').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
|
|
||||||
if (typeof raw === 'string') return raw.trim();
|
|
||||||
if (!raw || typeof raw !== 'object') return '';
|
|
||||||
const bag = raw as Record<string, unknown>;
|
|
||||||
const byLocale = String(bag[locale] || '').trim();
|
|
||||||
if (byLocale) return byLocale;
|
|
||||||
return String(bag['zh-cn'] || bag.en || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePresetTextList(raw: unknown): string[] {
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
const rows: string[] = [];
|
|
||||||
raw.forEach((item) => {
|
|
||||||
const text = String(item || '').trim();
|
|
||||||
if (text) rows.push(text);
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTopicPresets(raw: unknown): TopicPresetTemplate[] {
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
const rows: TopicPresetTemplate[] = [];
|
|
||||||
raw.forEach((item) => {
|
|
||||||
if (!item || typeof item !== 'object') return;
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
const id = String(record.id || '').trim().toLowerCase();
|
|
||||||
const topicKey = String(record.topic_key || '').trim().toLowerCase();
|
|
||||||
if (!id || !topicKey) return;
|
|
||||||
const priority = Number(record.routing_priority);
|
|
||||||
rows.push({
|
|
||||||
id,
|
|
||||||
topic_key: topicKey,
|
|
||||||
name: record.name,
|
|
||||||
description: record.description,
|
|
||||||
routing_purpose: record.routing_purpose,
|
|
||||||
routing_include_when: record.routing_include_when,
|
|
||||||
routing_exclude_when: record.routing_exclude_when,
|
|
||||||
routing_examples_positive: record.routing_examples_positive,
|
|
||||||
routing_examples_negative: record.routing_examples_negative,
|
|
||||||
routing_priority: Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSystemFallbackTopic(topic: Pick<BotTopic, 'topic_key' | 'name' | 'description' | 'routing'>): boolean {
|
|
||||||
const key = String(topic.topic_key || '').trim().toLowerCase();
|
|
||||||
if (!SYSTEM_FALLBACK_TOPIC_KEYS.has(key)) return false;
|
|
||||||
const routing = topic.routing && typeof topic.routing === 'object' ? topic.routing : {};
|
|
||||||
const purpose = String((routing as Record<string, unknown>).purpose || '').trim().toLowerCase();
|
|
||||||
const desc = String(topic.description || '').trim().toLowerCase();
|
|
||||||
const name = String(topic.name || '').trim().toLowerCase();
|
|
||||||
const priority = Number((routing as Record<string, unknown>).priority);
|
|
||||||
if (purpose.includes('fallback')) return true;
|
|
||||||
if (desc.includes('default topic')) return true;
|
|
||||||
if (name === 'inbox') return true;
|
|
||||||
if (Number.isFinite(priority) && priority <= 1) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRuntimeState(s?: string) {
|
export function normalizeRuntimeState(s?: string) {
|
||||||
const raw = stateLabel(s);
|
const raw = stateLabel(s);
|
||||||
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
|
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
|
||||||
|
|
@ -150,168 +22,6 @@ export function normalizeRuntimeState(s?: string) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBotTimestamp(raw?: string | number | null) {
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
|
||||||
const text = String(raw || '').trim();
|
|
||||||
if (!text) return 0;
|
|
||||||
const ms = Date.parse(text);
|
|
||||||
return Number.isFinite(ms) ? ms : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortBotsByCreatedAtDesc<T extends { id?: string | number | null; created_at?: string | number | null }>(
|
|
||||||
bots: readonly T[],
|
|
||||||
): T[] {
|
|
||||||
return [...bots].sort((left, right) => {
|
|
||||||
const leftCreated = parseBotTimestamp(left.created_at);
|
|
||||||
const rightCreated = parseBotTimestamp(right.created_at);
|
|
||||||
if (leftCreated !== rightCreated) return rightCreated - leftCreated;
|
|
||||||
return String(left.id || '').localeCompare(String(right.id || ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeWorkspaceExtension(raw: unknown): string {
|
|
||||||
const value = String(raw ?? '').trim().toLowerCase();
|
|
||||||
if (!value) return '';
|
|
||||||
const stripped = value.replace(/^\*\./, '');
|
|
||||||
const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`;
|
|
||||||
return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkspaceDownloadExtensions(
|
|
||||||
raw: unknown,
|
|
||||||
fallback: readonly string[] = EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
|
||||||
): string[] {
|
|
||||||
if (raw === null || raw === undefined) return [...fallback];
|
|
||||||
if (Array.isArray(raw) && raw.length === 0) return [];
|
|
||||||
if (typeof raw === 'string' && raw.trim() === '') return [];
|
|
||||||
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
|
|
||||||
const rows: string[] = [];
|
|
||||||
source.forEach((item) => {
|
|
||||||
const ext = normalizeWorkspaceExtension(item);
|
|
||||||
if (ext && !rows.includes(ext)) rows.push(ext);
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseAllowedAttachmentExtensions(raw: unknown): string[] {
|
|
||||||
if (raw === null || raw === undefined) return [];
|
|
||||||
if (Array.isArray(raw) && raw.length === 0) return [];
|
|
||||||
if (typeof raw === 'string' && raw.trim() === '') return [];
|
|
||||||
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
|
|
||||||
const rows: string[] = [];
|
|
||||||
source.forEach((item) => {
|
|
||||||
const ext = normalizeWorkspaceExtension(item);
|
|
||||||
if (ext && !rows.includes(ext)) rows.push(ext);
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
|
|
||||||
const normalized = String(path || '').trim().toLowerCase();
|
|
||||||
if (!normalized) return false;
|
|
||||||
for (const ext of extensions) {
|
|
||||||
if (normalized.endsWith(ext)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDownloadOnlyPath(
|
|
||||||
path: string,
|
|
||||||
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
|
||||||
) {
|
|
||||||
return pathHasExtension(path, downloadExtensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPreviewableWorkspaceFile(
|
|
||||||
node: WorkspaceNode,
|
|
||||||
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
|
||||||
) {
|
|
||||||
if (node.type !== 'file') return false;
|
|
||||||
return isPreviewableWorkspacePath(node.path, downloadExtensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isImagePath(path: string) {
|
|
||||||
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isVideoPath(path: string) {
|
|
||||||
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAudioPath(path: string) {
|
|
||||||
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMediaUploadFile(file: File): boolean {
|
|
||||||
const mime = String(file.type || '').toLowerCase();
|
|
||||||
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const name = String(file.name || '').trim().toLowerCase();
|
|
||||||
const dot = name.lastIndexOf('.');
|
|
||||||
if (dot < 0) return false;
|
|
||||||
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isHtmlPath(path: string) {
|
|
||||||
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPreviewableWorkspacePath(
|
|
||||||
path: string,
|
|
||||||
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
|
||||||
) {
|
|
||||||
if (isDownloadOnlyPath(path, downloadExtensions)) return true;
|
|
||||||
return (
|
|
||||||
pathHasExtension(path, TEXT_PREVIEW_EXTENSIONS) ||
|
|
||||||
isHtmlPath(path) ||
|
|
||||||
isImagePath(path) ||
|
|
||||||
isAudioPath(path) ||
|
|
||||||
isVideoPath(path)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workspaceFileAction(
|
|
||||||
path: string,
|
|
||||||
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
|
||||||
): 'preview' | 'download' | 'unsupported' {
|
|
||||||
const normalized = String(path || '').trim();
|
|
||||||
if (!normalized) return 'unsupported';
|
|
||||||
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
|
|
||||||
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
|
|
||||||
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
|
|
||||||
return 'unsupported';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
|
|
||||||
const path = String(pathRaw || '');
|
|
||||||
if (!path) return ['-'];
|
|
||||||
const normalized = path.replace(/\\/g, '/');
|
|
||||||
const hasLeadingSlash = normalized.startsWith('/');
|
|
||||||
const parts = normalized.split('/').filter((part) => part.length > 0);
|
|
||||||
const nodes: ReactNode[] = [];
|
|
||||||
|
|
||||||
if (hasLeadingSlash) {
|
|
||||||
nodes.push(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.forEach((part, index) => {
|
|
||||||
if (index > 0) {
|
|
||||||
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
|
|
||||||
}
|
|
||||||
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
|
|
||||||
});
|
|
||||||
|
|
||||||
return nodes.length > 0 ? nodes : ['-'];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAttachmentPaths(raw: unknown): string[] {
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
return raw
|
|
||||||
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
|
|
||||||
.filter((v) => v.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComposerDraftStorage {
|
export interface ComposerDraftStorage {
|
||||||
command: string;
|
command: string;
|
||||||
attachments: string[];
|
attachments: string[];
|
||||||
|
|
@ -367,47 +77,6 @@ export function persistComposerDraft(botId: string, commandRaw: string, attachme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
|
|
||||||
const source = String(input || '');
|
|
||||||
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
|
|
||||||
const quoted = normalizeAssistantMessageText(match?.[1] || '');
|
|
||||||
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
|
|
||||||
return { quoted, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeConversation(messages: ChatMessage[]) {
|
|
||||||
const merged: ChatMessage[] = [];
|
|
||||||
messages
|
|
||||||
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
|
|
||||||
.forEach((msg) => {
|
|
||||||
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
|
|
||||||
const userQuoted = parsedUser.quoted;
|
|
||||||
const userBody = parsedUser.body;
|
|
||||||
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
|
|
||||||
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
|
|
||||||
if (!cleanText && attachments.length === 0 && !userQuoted) return;
|
|
||||||
const last = merged[merged.length - 1];
|
|
||||||
if (last && last.role === msg.role) {
|
|
||||||
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
|
|
||||||
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
|
|
||||||
const lastKind = last.kind || 'final';
|
|
||||||
const currentKind = msg.kind || 'final';
|
|
||||||
const sameAttachmentSet =
|
|
||||||
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
|
|
||||||
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
|
|
||||||
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
|
|
||||||
last.ts = msg.ts;
|
|
||||||
last.id = msg.id || last.id;
|
|
||||||
if (typeof msg.feedback !== 'undefined') {
|
|
||||||
last.feedback = msg.feedback;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
|
|
||||||
});
|
|
||||||
return merged.slice(-120);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampTemperature(value: number) {
|
export function clampTemperature(value: number) {
|
||||||
if (Number.isNaN(value)) return 0.2;
|
if (Number.isNaN(value)) return 0.2;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ interface PlatformAdminDashboardPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboardPageProps) {
|
export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboardPageProps) {
|
||||||
const dashboard = usePlatformDashboard({ compactMode });
|
const dashboard = usePlatformDashboard({ compactMode, mode: 'admin' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel stack skill-market-page-shell platform-admin-page-shell">
|
<section className="panel stack skill-market-page-shell platform-admin-page-shell">
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,34 @@ interface PlatformBotManagementPageProps {
|
||||||
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
|
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
|
||||||
|
|
||||||
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
|
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
|
||||||
const dashboard = usePlatformDashboard({ compactMode });
|
const dashboard = usePlatformDashboard({ compactMode, mode: 'management' });
|
||||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||||
const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS;
|
const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS;
|
||||||
|
const runtimePageSize = dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10;
|
||||||
|
const botDetailContent = (
|
||||||
|
<>
|
||||||
|
<PlatformBotOverviewSection
|
||||||
|
compactSheet={compactMode}
|
||||||
|
isZh={dashboard.isZh}
|
||||||
|
lastActionPreview={dashboard.lastActionPreview}
|
||||||
|
operatingBotId={dashboard.operatingBotId}
|
||||||
|
selectedBotInfo={dashboard.selectedBotInfo}
|
||||||
|
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
|
||||||
|
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
|
||||||
|
onOpenBotPanel={dashboard.openBotPanel}
|
||||||
|
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
|
||||||
|
onOpenResourceMonitor={dashboard.openResourceMonitor}
|
||||||
|
onRemoveBot={dashboard.removeBot}
|
||||||
|
/>
|
||||||
|
<PlatformBotRuntimeSection
|
||||||
|
compactSheet={compactMode}
|
||||||
|
isZh={dashboard.isZh}
|
||||||
|
pageSize={runtimePageSize}
|
||||||
|
selectedBotInfo={dashboard.selectedBotInfo}
|
||||||
|
workspaceDownloadExtensions={workspaceDownloadExtensions}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -51,24 +76,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
||||||
{!compactMode ? (
|
{!compactMode ? (
|
||||||
<section className="platform-main">
|
<section className="platform-main">
|
||||||
<div className="platform-bot-management-detail-stack">
|
<div className="platform-bot-management-detail-stack">
|
||||||
<PlatformBotOverviewSection
|
{botDetailContent}
|
||||||
isZh={dashboard.isZh}
|
|
||||||
lastActionPreview={dashboard.lastActionPreview}
|
|
||||||
operatingBotId={dashboard.operatingBotId}
|
|
||||||
selectedBotInfo={dashboard.selectedBotInfo}
|
|
||||||
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
|
|
||||||
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
|
|
||||||
onOpenBotPanel={dashboard.openBotPanel}
|
|
||||||
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
|
|
||||||
onOpenResourceMonitor={dashboard.openResourceMonitor}
|
|
||||||
onRemoveBot={dashboard.removeBot}
|
|
||||||
/>
|
|
||||||
<PlatformBotRuntimeSection
|
|
||||||
isZh={dashboard.isZh}
|
|
||||||
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
|
|
||||||
selectedBotInfo={dashboard.selectedBotInfo}
|
|
||||||
workspaceDownloadExtensions={workspaceDownloadExtensions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -82,28 +90,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
||||||
isZh={dashboard.isZh}
|
isZh={dashboard.isZh}
|
||||||
onClose={dashboard.closeCompactBotSheet}
|
onClose={dashboard.closeCompactBotSheet}
|
||||||
>
|
>
|
||||||
<>
|
{botDetailContent}
|
||||||
<PlatformBotOverviewSection
|
|
||||||
compactSheet
|
|
||||||
isZh={dashboard.isZh}
|
|
||||||
lastActionPreview={dashboard.lastActionPreview}
|
|
||||||
operatingBotId={dashboard.operatingBotId}
|
|
||||||
selectedBotInfo={dashboard.selectedBotInfo}
|
|
||||||
selectedBotUsageSummary={dashboard.selectedBotUsageSummary}
|
|
||||||
onClearDashboardDirectSession={dashboard.clearDashboardDirectSession}
|
|
||||||
onOpenBotPanel={dashboard.openBotPanel}
|
|
||||||
onOpenLastAction={() => dashboard.setShowBotLastActionModal(true)}
|
|
||||||
onOpenResourceMonitor={dashboard.openResourceMonitor}
|
|
||||||
onRemoveBot={dashboard.removeBot}
|
|
||||||
/>
|
|
||||||
<PlatformBotRuntimeSection
|
|
||||||
compactSheet
|
|
||||||
isZh={dashboard.isZh}
|
|
||||||
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
|
|
||||||
selectedBotInfo={dashboard.selectedBotInfo}
|
|
||||||
workspaceDownloadExtensions={workspaceDownloadExtensions}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</PlatformCompactBotSheet>
|
</PlatformCompactBotSheet>
|
||||||
|
|
||||||
<CreateBotWizardModal
|
<CreateBotWizardModal
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,39 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-workspace {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-status.is-active {
|
||||||
|
background: color-mix(in oklab, var(--ok) 18%, var(--panel-soft));
|
||||||
|
color: color-mix(in oklab, var(--ok) 72%, var(--title));
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-status.is-revoked {
|
||||||
|
background: color-mix(in oklab, var(--err) 14%, var(--panel-soft));
|
||||||
|
color: color-mix(in oklab, var(--err) 76%, var(--title));
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-auth-audit-reason {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.platform-bot-list-panel {
|
.platform-bot-list-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
readCachedPlatformPageSize,
|
||||||
writeCachedPlatformPageSize,
|
writeCachedPlatformPageSize,
|
||||||
} from '../../../utils/platformPageSize';
|
} from '../../../utils/platformPageSize';
|
||||||
import type { PlatformSettings, SystemSettingItem } from '../types';
|
import type { PlatformLoginLogResponse, PlatformSettings, SystemSettingItem } from '../types';
|
||||||
|
|
||||||
export interface SystemSettingsResponse {
|
export interface SystemSettingsResponse {
|
||||||
items: SystemSettingItem[];
|
items: SystemSettingItem[];
|
||||||
|
|
@ -27,6 +27,18 @@ export function fetchPlatformSettings() {
|
||||||
return axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`).then((res) => res.data);
|
return axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchPlatformLoginLogs(params: {
|
||||||
|
search?: string;
|
||||||
|
auth_type?: string;
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) {
|
||||||
|
return axios
|
||||||
|
.get<PlatformLoginLogResponse>(`${APP_ENDPOINTS.apiBase}/platform/login-logs`, { params })
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPreferredPlatformPageSize(fallback = 10) {
|
export async function fetchPreferredPlatformPageSize(fallback = 10) {
|
||||||
const cachedFallback = readCachedPlatformPageSize(fallback);
|
const cachedFallback = readCachedPlatformPageSize(fallback);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
|
||||||
import { dashboardEn } from '../../../i18n/dashboard.en';
|
import { dashboardEn } from '../../../i18n/dashboard.en';
|
||||||
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
|
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
|
||||||
import { pickLocale } from '../../../i18n';
|
import { pickLocale } from '../../../i18n';
|
||||||
|
import { WorkspaceEntriesList } from '../../../shared/workspace/WorkspaceEntriesList';
|
||||||
|
import { WorkspaceHoverCard } from '../../../shared/workspace/WorkspaceHoverCard';
|
||||||
|
import { WorkspacePreviewModal } from '../../../shared/workspace/WorkspacePreviewModal';
|
||||||
|
import '../../../shared/workspace/WorkspaceOverlay.css';
|
||||||
|
import { formatBytes, formatWorkspaceTime, isPreviewableWorkspaceFile } from '../../../shared/workspace/utils';
|
||||||
|
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import { WorkspaceEntriesList } from '../../dashboard/components/WorkspaceEntriesList';
|
import { usePlatformBotDockerLogs } from '../hooks/usePlatformBotDockerLogs';
|
||||||
import { WorkspaceHoverCard } from '../../dashboard/components/WorkspaceHoverCard';
|
|
||||||
import { WorkspacePreviewModal } from '../../dashboard/components/WorkspacePreviewModal';
|
|
||||||
import { useDashboardWorkspace } from '../../dashboard/hooks/useDashboardWorkspace';
|
|
||||||
import { formatBytes, formatWorkspaceTime } from '../../dashboard/utils';
|
|
||||||
import '../../dashboard/components/BotListPanel.css';
|
import '../../dashboard/components/BotListPanel.css';
|
||||||
import '../../dashboard/components/RuntimePanel.css';
|
import '../../dashboard/components/RuntimePanel.css';
|
||||||
import '../../dashboard/components/DashboardShared.css';
|
import '../../dashboard/components/DashboardShared.css';
|
||||||
import '../../dashboard/components/WorkspaceOverlay.css';
|
|
||||||
import '../../../components/ui/SharedUi.css';
|
import '../../../components/ui/SharedUi.css';
|
||||||
|
|
||||||
interface PlatformBotRuntimeSectionProps {
|
interface PlatformBotRuntimeSectionProps {
|
||||||
|
|
@ -29,41 +28,8 @@ interface PlatformBotRuntimeSectionProps {
|
||||||
workspaceDownloadExtensions: string[];
|
workspaceDownloadExtensions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g;
|
|
||||||
const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40;
|
const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40;
|
||||||
const DOCKER_LOG_TABLE_ROW_HEIGHT = 56;
|
const DOCKER_LOG_TABLE_ROW_HEIGHT = 56;
|
||||||
const EMPTY_DOCKER_LOG_ENTRY = {
|
|
||||||
key: '',
|
|
||||||
index: '',
|
|
||||||
level: '',
|
|
||||||
text: '',
|
|
||||||
tone: 'plain',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function stripAnsi(textRaw: string) {
|
|
||||||
return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDockerLogEntry(textRaw: string) {
|
|
||||||
const text = stripAnsi(textRaw);
|
|
||||||
const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i);
|
|
||||||
const levelRaw = String(levelMatch?.[1] || '').toUpperCase();
|
|
||||||
const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-');
|
|
||||||
return {
|
|
||||||
level,
|
|
||||||
text,
|
|
||||||
tone:
|
|
||||||
level === 'ERROR' || level === 'FATAL' || level === 'CRITICAL'
|
|
||||||
? 'err'
|
|
||||||
: level === 'WARN'
|
|
||||||
? 'warn'
|
|
||||||
: level === 'INFO'
|
|
||||||
? 'info'
|
|
||||||
: level === 'DEBUG' || level === 'TRACE'
|
|
||||||
? 'debug'
|
|
||||||
: 'plain',
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlatformBotRuntimeSection({
|
export function PlatformBotRuntimeSection({
|
||||||
compactSheet = false,
|
compactSheet = false,
|
||||||
|
|
@ -74,11 +40,6 @@ export function PlatformBotRuntimeSection({
|
||||||
}: PlatformBotRuntimeSectionProps) {
|
}: PlatformBotRuntimeSectionProps) {
|
||||||
const { notify } = useLucentPrompt();
|
const { notify } = useLucentPrompt();
|
||||||
const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn });
|
const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn });
|
||||||
const [dockerLogs, setDockerLogs] = useState<string[]>([]);
|
|
||||||
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
|
|
||||||
const [dockerLogsError, setDockerLogsError] = useState('');
|
|
||||||
const [dockerLogsPage, setDockerLogsPage] = useState(1);
|
|
||||||
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
|
|
||||||
const dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
|
const dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
|
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
|
||||||
const workspaceSearchInputName = useMemo(
|
const workspaceSearchInputName = useMemo(
|
||||||
|
|
@ -87,41 +48,18 @@ export function PlatformBotRuntimeSection({
|
||||||
);
|
);
|
||||||
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
|
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
|
||||||
const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT;
|
const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT;
|
||||||
const recentLogEntries = useMemo(() => {
|
|
||||||
const logs = (dockerLogs || [])
|
|
||||||
.map((line) => String(line || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line, index) => ({
|
|
||||||
key: `log-${dockerLogsPage}-${index}`,
|
|
||||||
index: String((dockerLogsPage - 1) * effectivePageSize + index + 1).padStart(3, '0'),
|
|
||||||
...parseDockerLogEntry(line),
|
|
||||||
}));
|
|
||||||
if (logs.length > 0) return logs;
|
|
||||||
|
|
||||||
const events = (selectedBotInfo?.events || [])
|
|
||||||
.filter((event) => String(event?.text || '').trim().length > 0)
|
|
||||||
.slice(0, effectivePageSize)
|
|
||||||
.map((event, index) => ({
|
|
||||||
key: `event-${event.ts}-${index}`,
|
|
||||||
index: String(index + 1).padStart(3, '0'),
|
|
||||||
...parseDockerLogEntry(`[${String(event.state || 'INFO').toUpperCase()}] ${String(event.text || '').trim()}`),
|
|
||||||
}));
|
|
||||||
return events;
|
|
||||||
}, [dockerLogs, dockerLogsPage, effectivePageSize, selectedBotInfo?.events]);
|
|
||||||
const dockerLogTableRows = useMemo(
|
|
||||||
() => [
|
|
||||||
...recentLogEntries,
|
|
||||||
...Array.from({ length: Math.max(0, effectivePageSize - recentLogEntries.length) }, (_, index) => ({
|
|
||||||
...EMPTY_DOCKER_LOG_ENTRY,
|
|
||||||
key: `docker-log-empty-${dockerLogsPage}-${index}`,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
[dockerLogsPage, effectivePageSize, recentLogEntries],
|
|
||||||
);
|
|
||||||
const workspaceCardStyle = useMemo(
|
const workspaceCardStyle = useMemo(
|
||||||
() => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined),
|
() => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined),
|
||||||
[compactSheet, workspaceCardHeightPx],
|
[compactSheet, workspaceCardHeightPx],
|
||||||
);
|
);
|
||||||
|
const refreshWorkspaceAttachmentPolicy = useCallback(
|
||||||
|
async () => ({
|
||||||
|
uploadMaxMb: 0,
|
||||||
|
allowedAttachmentExtensions: [],
|
||||||
|
workspaceDownloadExtensions,
|
||||||
|
}),
|
||||||
|
[workspaceDownloadExtensions],
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
closeWorkspacePreview,
|
closeWorkspacePreview,
|
||||||
copyWorkspacePreviewPath,
|
copyWorkspacePreviewPath,
|
||||||
|
|
@ -145,7 +83,6 @@ export function PlatformBotRuntimeSection({
|
||||||
workspaceDownloadExtensionSet,
|
workspaceDownloadExtensionSet,
|
||||||
workspaceError,
|
workspaceError,
|
||||||
workspaceFileLoading,
|
workspaceFileLoading,
|
||||||
workspaceFiles,
|
|
||||||
workspaceHoverCard,
|
workspaceHoverCard,
|
||||||
workspaceLoading,
|
workspaceLoading,
|
||||||
workspaceParentPath,
|
workspaceParentPath,
|
||||||
|
|
@ -159,20 +96,43 @@ export function PlatformBotRuntimeSection({
|
||||||
workspacePreviewSaving,
|
workspacePreviewSaving,
|
||||||
workspaceQuery,
|
workspaceQuery,
|
||||||
workspaceSearchLoading,
|
workspaceSearchLoading,
|
||||||
} = useDashboardWorkspace({
|
} = useBotWorkspace({
|
||||||
selectedBotId: selectedBotInfo?.id || '',
|
selectedBotId: selectedBotInfo?.id || '',
|
||||||
selectedBotDockerStatus: selectedBotInfo?.docker_status || '',
|
selectedBotDockerStatus: selectedBotInfo?.docker_status || '',
|
||||||
workspaceDownloadExtensions,
|
workspaceDownloadExtensions,
|
||||||
refreshAttachmentPolicy: async () => ({
|
refreshAttachmentPolicy: refreshWorkspaceAttachmentPolicy,
|
||||||
uploadMaxMb: 0,
|
|
||||||
allowedAttachmentExtensions: [],
|
|
||||||
workspaceDownloadExtensions,
|
|
||||||
}),
|
|
||||||
notify,
|
notify,
|
||||||
t: dashboardT,
|
t: dashboardT,
|
||||||
isZh,
|
isZh,
|
||||||
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
|
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
|
||||||
});
|
});
|
||||||
|
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
|
||||||
|
const hasVisibleWorkspaceEntries = filteredWorkspaceEntries.length > 0;
|
||||||
|
const visibleWorkspaceFiles = filteredWorkspaceEntries.filter((entry) => entry.type === 'file');
|
||||||
|
const hasVisiblePreviewableFiles = visibleWorkspaceFiles.some((entry) =>
|
||||||
|
isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet),
|
||||||
|
);
|
||||||
|
const showWorkspaceEmptyState = !workspaceLoading && !workspaceSearchLoading && !workspaceError && !hasVisibleWorkspaceEntries;
|
||||||
|
const showNoPreviewableFilesHint = Boolean(
|
||||||
|
selectedBotInfo &&
|
||||||
|
!workspaceError &&
|
||||||
|
!normalizedWorkspaceQuery &&
|
||||||
|
visibleWorkspaceFiles.length > 0 &&
|
||||||
|
!hasVisiblePreviewableFiles,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
dockerLogsError,
|
||||||
|
dockerLogsHasMore,
|
||||||
|
dockerLogsLoading,
|
||||||
|
dockerLogsPage,
|
||||||
|
dockerLogTableRows,
|
||||||
|
fetchDockerLogsPage,
|
||||||
|
recentLogEntries,
|
||||||
|
} = usePlatformBotDockerLogs({
|
||||||
|
effectivePageSize,
|
||||||
|
isZh,
|
||||||
|
selectedBotInfo,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedBotInfo?.id) {
|
if (!selectedBotInfo?.id) {
|
||||||
|
|
@ -186,84 +146,6 @@ export function PlatformBotRuntimeSection({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resetWorkspaceState, selectedBotInfo?.id]);
|
}, [resetWorkspaceState, selectedBotInfo?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDockerLogsPage(1);
|
|
||||||
}, [selectedBotInfo?.id, effectivePageSize]);
|
|
||||||
|
|
||||||
const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => {
|
|
||||||
if (!selectedBotInfo?.id) {
|
|
||||||
setDockerLogs([]);
|
|
||||||
setDockerLogsHasMore(false);
|
|
||||||
setDockerLogsError('');
|
|
||||||
setDockerLogsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const safePage = Math.max(1, page);
|
|
||||||
if (!silent) setDockerLogsLoading(true);
|
|
||||||
setDockerLogsError('');
|
|
||||||
try {
|
|
||||||
const res = await axios.get<{
|
|
||||||
bot_id: string;
|
|
||||||
logs?: string[];
|
|
||||||
total?: number | null;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
has_more?: boolean;
|
|
||||||
reverse?: boolean;
|
|
||||||
}>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, {
|
|
||||||
params: {
|
|
||||||
offset: (safePage - 1) * effectivePageSize,
|
|
||||||
limit: effectivePageSize,
|
|
||||||
reverse: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const lines = Array.isArray(res.data?.logs)
|
|
||||||
? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
setDockerLogs(lines);
|
|
||||||
setDockerLogsHasMore(Boolean(res.data?.has_more));
|
|
||||||
setDockerLogsPage(safePage);
|
|
||||||
} catch (error: any) {
|
|
||||||
setDockerLogs([]);
|
|
||||||
setDockerLogsHasMore(false);
|
|
||||||
setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.'));
|
|
||||||
} finally {
|
|
||||||
if (!silent) {
|
|
||||||
setDockerLogsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [effectivePageSize, isZh, selectedBotInfo?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedBotInfo?.id) {
|
|
||||||
setDockerLogs([]);
|
|
||||||
setDockerLogsHasMore(false);
|
|
||||||
setDockerLogsError('');
|
|
||||||
setDockerLogsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let stopped = false;
|
|
||||||
void fetchDockerLogsPage(dockerLogsPage, false);
|
|
||||||
|
|
||||||
if (dockerLogsPage !== 1 || String(selectedBotInfo.docker_status || '').toUpperCase() !== 'RUNNING') {
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
if (!stopped) {
|
|
||||||
void fetchDockerLogsPage(1, true);
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (compactSheet) {
|
if (compactSheet) {
|
||||||
setWorkspaceCardHeightPx(null);
|
setWorkspaceCardHeightPx(null);
|
||||||
|
|
@ -364,11 +246,9 @@ export function PlatformBotRuntimeSection({
|
||||||
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div>
|
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div>
|
||||||
) : workspaceLoading || workspaceSearchLoading ? (
|
) : workspaceLoading || workspaceSearchLoading ? (
|
||||||
<div className="ops-empty-inline">{dashboardT.loadingDir}</div>
|
<div className="ops-empty-inline">{dashboardT.loadingDir}</div>
|
||||||
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
|
|
||||||
<div className="ops-empty-inline">
|
|
||||||
{workspaceQuery.trim() ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{(workspaceParentPath !== null || hasVisibleWorkspaceEntries) ? (
|
||||||
<WorkspaceEntriesList
|
<WorkspaceEntriesList
|
||||||
nodes={filteredWorkspaceEntries}
|
nodes={filteredWorkspaceEntries}
|
||||||
workspaceParentPath={workspaceParentPath}
|
workspaceParentPath={workspaceParentPath}
|
||||||
|
|
@ -389,6 +269,13 @@ export function PlatformBotRuntimeSection({
|
||||||
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
|
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
|
||||||
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
|
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{showWorkspaceEmptyState ? (
|
||||||
|
<div className="ops-empty-inline">
|
||||||
|
{normalizedWorkspaceQuery ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-hint">
|
<div className="workspace-hint">
|
||||||
|
|
@ -396,7 +283,7 @@ export function PlatformBotRuntimeSection({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedBotInfo && !workspaceFiles.length ? (
|
{showNoPreviewableFilesHint ? (
|
||||||
<div className="ops-empty-inline">{dashboardT.noPreviewFile}</div>
|
<div className="ops-empty-inline">{dashboardT.noPreviewFile}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@ import rehypeSanitize from 'rehype-sanitize';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import type { BotState } from '../../../types/bot';
|
import { repairCollapsedMarkdown } from '../../../shared/text/messageText';
|
||||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../../dashboard/constants';
|
import { ModalCardShell } from '../../../shared/ui/ModalCardShell';
|
||||||
import { DashboardModalCardShell } from '../../dashboard/components/DashboardModalCardShell';
|
import { MARKDOWN_SANITIZE_SCHEMA } from '../../../shared/workspace/constants';
|
||||||
import { repairCollapsedMarkdown } from '../../dashboard/messageParser';
|
import '../../../shared/workspace/WorkspaceOverlay.css';
|
||||||
import {
|
import {
|
||||||
createWorkspaceMarkdownComponents,
|
createWorkspaceMarkdownComponents,
|
||||||
decorateWorkspacePathsForMarkdown,
|
decorateWorkspacePathsForMarkdown,
|
||||||
} from '../../dashboard/shared/workspaceMarkdown';
|
} from '../../../shared/workspace/workspaceMarkdown';
|
||||||
|
import type { BotState } from '../../../types/bot';
|
||||||
import type { PlatformBotResourceSnapshot } from '../types';
|
import type { PlatformBotResourceSnapshot } from '../types';
|
||||||
import { formatPlatformBytes, formatPlatformPercent } from '../utils';
|
import { formatPlatformBytes, formatPlatformPercent } from '../utils';
|
||||||
import '../../dashboard/components/WorkspaceOverlay.css';
|
|
||||||
|
|
||||||
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
|
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function PlatformLastActionModal({
|
||||||
const closeLabel = isZh ? '关闭' : 'Close';
|
const closeLabel = isZh ? '关闭' : 'Close';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardModalCardShell
|
<ModalCardShell
|
||||||
cardClassName="platform-last-action-modal"
|
cardClassName="platform-last-action-modal"
|
||||||
closeLabel={closeLabel}
|
closeLabel={closeLabel}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|
@ -94,7 +94,7 @@ export function PlatformLastActionModal({
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardModalCardShell>
|
</ModalCardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +126,7 @@ export function PlatformResourceMonitorModal({
|
||||||
const closeLabel = isZh ? '关闭' : 'Close';
|
const closeLabel = isZh ? '关闭' : 'Close';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardModalCardShell
|
<ModalCardShell
|
||||||
cardClassName="modal-wide"
|
cardClassName="modal-wide"
|
||||||
closeLabel={closeLabel}
|
closeLabel={closeLabel}
|
||||||
headerActions={(
|
headerActions={(
|
||||||
|
|
@ -191,6 +191,6 @@ export function PlatformResourceMonitorModal({
|
||||||
) : (
|
) : (
|
||||||
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
|
||||||
)}
|
)}
|
||||||
</DashboardModalCardShell>
|
</ModalCardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||||
import { APP_ENDPOINTS } from '../../../config/env';
|
import { APP_ENDPOINTS } from '../../../config/env';
|
||||||
import { sortBotsByCreatedAtDesc } from '../../dashboard/utils';
|
import { sortBotsByCreatedAtDesc } from '../../../shared/bot/sortBots';
|
||||||
import { useAppStore } from '../../../store/appStore';
|
import { useAppStore } from '../../../store/appStore';
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,7 +15,6 @@ import type {
|
||||||
BotActivityStatsItem,
|
BotActivityStatsItem,
|
||||||
PlatformBotResourceSnapshot,
|
PlatformBotResourceSnapshot,
|
||||||
PlatformOverviewResponse,
|
PlatformOverviewResponse,
|
||||||
PlatformSettings,
|
|
||||||
PlatformUsageAnalyticsSeriesItem,
|
PlatformUsageAnalyticsSeriesItem,
|
||||||
PlatformUsageResponse,
|
PlatformUsageResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
@ -23,18 +22,20 @@ import {
|
||||||
buildBotPanelHref,
|
buildBotPanelHref,
|
||||||
buildPlatformUsageAnalyticsSeries,
|
buildPlatformUsageAnalyticsSeries,
|
||||||
buildPlatformUsageAnalyticsTicks,
|
buildPlatformUsageAnalyticsTicks,
|
||||||
clampPlatformPercent,
|
|
||||||
getPlatformChartCeiling,
|
getPlatformChartCeiling,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
interface UsePlatformDashboardOptions {
|
interface UsePlatformDashboardOptions {
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
|
mode?: 'admin' | 'management';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOptions) {
|
export function usePlatformDashboard({ compactMode, mode = 'management' }: UsePlatformDashboardOptions) {
|
||||||
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
||||||
const { notify, confirm } = useLucentPrompt();
|
const { notify, confirm } = useLucentPrompt();
|
||||||
const isZh = locale === 'zh';
|
const isZh = locale === 'zh';
|
||||||
|
const isAdminMode = mode === 'admin';
|
||||||
|
const isManagementMode = mode === 'management';
|
||||||
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
|
const [overview, setOverview] = useState<PlatformOverviewResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedBotId, setSelectedBotId] = useState('');
|
const [selectedBotId, setSelectedBotId] = useState('');
|
||||||
|
|
@ -52,7 +53,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
const [usageLoading, setUsageLoading] = useState(false);
|
const [usageLoading, setUsageLoading] = useState(false);
|
||||||
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
|
const [activityStatsData, setActivityStatsData] = useState<BotActivityStatsItem[] | null>(null);
|
||||||
const [activityLoading, setActivityLoading] = useState(false);
|
const [activityLoading, setActivityLoading] = useState(false);
|
||||||
const [usagePage, setUsagePage] = useState(1);
|
|
||||||
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
|
const [usagePageSize, setUsagePageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||||
const [botListPage, setBotListPage] = useState(1);
|
const [botListPage, setBotListPage] = useState(1);
|
||||||
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
||||||
|
|
@ -121,7 +121,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setUsageData(res.data);
|
setUsageData(res.data);
|
||||||
setUsagePage(page);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
|
notify(error?.response?.data?.detail || (isZh ? '读取用量统计失败。' : 'Failed to load usage analytics.'), { tone: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -180,26 +179,45 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
}, [loadOverview]);
|
}, [loadOverview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAdminMode) {
|
||||||
|
setUsageData(null);
|
||||||
|
setUsageLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadUsage(1);
|
void loadUsage(1);
|
||||||
}, [loadUsage, usagePageSize]);
|
}, [isAdminMode, loadUsage, usagePageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAdminMode) {
|
||||||
|
setActivityStatsData(null);
|
||||||
|
setActivityLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadActivityStats();
|
void loadActivityStats();
|
||||||
}, [loadActivityStats]);
|
}, [isAdminMode, loadActivityStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) return;
|
||||||
setBotListPage(1);
|
setBotListPage(1);
|
||||||
}, [search, botListPageSize]);
|
}, [botListPageSize, isManagementMode, search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) return;
|
||||||
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
|
setBotListPage((prev) => Math.min(Math.max(prev, 1), botListPageCount));
|
||||||
}, [botListPageCount]);
|
}, [botListPageCount, isManagementMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) return;
|
||||||
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
|
if (!selectedBotId && filteredBots[0]?.id) setSelectedBotId(filteredBots[0].id);
|
||||||
}, [filteredBots, selectedBotId]);
|
}, [filteredBots, isManagementMode, selectedBotId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) {
|
||||||
|
setShowCompactBotSheet(false);
|
||||||
|
setCompactSheetClosing(false);
|
||||||
|
setCompactSheetMounted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!compactMode) {
|
if (!compactMode) {
|
||||||
setShowCompactBotSheet(false);
|
setShowCompactBotSheet(false);
|
||||||
setCompactSheetClosing(false);
|
setCompactSheetClosing(false);
|
||||||
|
|
@ -208,9 +226,14 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
}
|
}
|
||||||
if (selectedBotId && showCompactBotSheet) return;
|
if (selectedBotId && showCompactBotSheet) return;
|
||||||
if (!selectedBotId) setShowCompactBotSheet(false);
|
if (!selectedBotId) setShowCompactBotSheet(false);
|
||||||
}, [compactMode, selectedBotId, showCompactBotSheet]);
|
}, [compactMode, isManagementMode, selectedBotId, showCompactBotSheet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) {
|
||||||
|
setSelectedBotDetail(null);
|
||||||
|
setSelectedBotUsageSummary(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedBotId) {
|
if (!selectedBotId) {
|
||||||
setSelectedBotDetail(null);
|
setSelectedBotDetail(null);
|
||||||
setSelectedBotUsageSummary(null);
|
setSelectedBotUsageSummary(null);
|
||||||
|
|
@ -233,7 +256,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [loadSelectedBotUsageSummary, selectedBotId]);
|
}, [isManagementMode, loadSelectedBotUsageSummary, selectedBotId]);
|
||||||
|
|
||||||
const resourceBot = useMemo(
|
const resourceBot = useMemo(
|
||||||
() => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined),
|
() => (resourceBotId ? botList.find((bot) => bot.id === resourceBotId) : undefined),
|
||||||
|
|
@ -271,15 +294,6 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
const usageSummary = usageData?.summary || overview?.usage.summary;
|
const usageSummary = usageData?.summary || overview?.usage.summary;
|
||||||
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
||||||
|
|
||||||
const memoryPercent =
|
|
||||||
overviewResources && overviewResources.live_memory_limit_bytes > 0
|
|
||||||
? clampPlatformPercent((overviewResources.live_memory_used_bytes / overviewResources.live_memory_limit_bytes) * 100)
|
|
||||||
: 0;
|
|
||||||
const storagePercent =
|
|
||||||
overviewResources && overviewResources.workspace_limit_bytes > 0
|
|
||||||
? clampPlatformPercent((overviewResources.workspace_used_bytes / overviewResources.workspace_limit_bytes) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>(
|
const usageAnalyticsSeries = useMemo<PlatformUsageAnalyticsSeriesItem[]>(
|
||||||
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
|
() => buildPlatformUsageAnalyticsSeries(usageAnalytics, isZh),
|
||||||
[isZh, usageAnalytics],
|
[isZh, usageAnalytics],
|
||||||
|
|
@ -296,10 +310,13 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]);
|
const usageAnalyticsTicks = useMemo(() => buildPlatformUsageAnalyticsTicks(usageAnalyticsMax), [usageAnalyticsMax]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
const jobs: Promise<unknown>[] = [loadOverview(), loadBots(), loadUsage(usagePage), loadActivityStats()];
|
const jobs: Promise<unknown>[] = [loadOverview(), loadBots()];
|
||||||
|
if (isAdminMode) {
|
||||||
|
jobs.push(loadUsage(), loadActivityStats());
|
||||||
|
}
|
||||||
if (selectedBotId) jobs.push(loadSelectedBotUsageSummary(selectedBotId));
|
if (selectedBotId) jobs.push(loadSelectedBotUsageSummary(selectedBotId));
|
||||||
await Promise.allSettled(jobs);
|
await Promise.allSettled(jobs);
|
||||||
}, [loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId, usagePage]);
|
}, [isAdminMode, loadActivityStats, loadBots, loadOverview, loadSelectedBotUsageSummary, loadUsage, selectedBotId]);
|
||||||
|
|
||||||
const toggleBot = useCallback(async (bot: BotState) => {
|
const toggleBot = useCallback(async (bot: BotState) => {
|
||||||
setOperatingBotId(bot.id);
|
setOperatingBotId(bot.id);
|
||||||
|
|
@ -392,6 +409,15 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
}, [loadResourceSnapshot]);
|
}, [loadResourceSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isManagementMode) {
|
||||||
|
setCompactSheetMounted(false);
|
||||||
|
setCompactSheetClosing(false);
|
||||||
|
if (compactSheetTimerRef.current) {
|
||||||
|
window.clearTimeout(compactSheetTimerRef.current);
|
||||||
|
compactSheetTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
if (compactMode && showCompactBotSheet && selectedBotInfo) {
|
||||||
if (compactSheetTimerRef.current) {
|
if (compactSheetTimerRef.current) {
|
||||||
window.clearTimeout(compactSheetTimerRef.current);
|
window.clearTimeout(compactSheetTimerRef.current);
|
||||||
|
|
@ -414,7 +440,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
compactSheetTimerRef.current = null;
|
compactSheetTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [compactMode, compactSheetMounted, selectedBotInfo, showCompactBotSheet]);
|
}, [compactMode, compactSheetMounted, isManagementMode, selectedBotInfo, showCompactBotSheet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showResourceModal || !resourceBotId) return;
|
if (!showResourceModal || !resourceBotId) return;
|
||||||
|
|
@ -444,18 +470,9 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer');
|
window.open(buildBotPanelHref(botId), '_blank', 'noopener,noreferrer');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlatformSettingsSaved = useCallback((settings: PlatformSettings) => {
|
|
||||||
setOverview((prev) => (prev ? { ...prev, settings } : prev));
|
|
||||||
const normalizedPageSize = normalizePlatformPageSize(settings.page_size, 10);
|
|
||||||
writeCachedPlatformPageSize(normalizedPageSize);
|
|
||||||
setUsagePageSize(normalizedPageSize);
|
|
||||||
setBotListPageSize(normalizedPageSize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
|
const closeResourceModal = useCallback(() => setShowResourceModal(false), []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
botList,
|
|
||||||
botListPage,
|
botListPage,
|
||||||
botListPageCount,
|
botListPageCount,
|
||||||
botListPageSize,
|
botListPageSize,
|
||||||
|
|
@ -465,13 +482,11 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
compactSheetClosing,
|
compactSheetClosing,
|
||||||
compactSheetMounted,
|
compactSheetMounted,
|
||||||
filteredBots,
|
filteredBots,
|
||||||
handlePlatformSettingsSaved,
|
|
||||||
handleSelectBot,
|
handleSelectBot,
|
||||||
isZh,
|
isZh,
|
||||||
lastActionPreview,
|
lastActionPreview,
|
||||||
loadResourceSnapshot,
|
loadResourceSnapshot,
|
||||||
loading,
|
loading,
|
||||||
memoryPercent,
|
|
||||||
openBotPanel,
|
openBotPanel,
|
||||||
openResourceMonitor,
|
openResourceMonitor,
|
||||||
operatingBotId,
|
operatingBotId,
|
||||||
|
|
@ -496,9 +511,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
setSearch,
|
setSearch,
|
||||||
setShowBotLastActionModal,
|
setShowBotLastActionModal,
|
||||||
showBotLastActionModal,
|
showBotLastActionModal,
|
||||||
showCompactBotSheet,
|
|
||||||
showResourceModal,
|
showResourceModal,
|
||||||
storagePercent,
|
|
||||||
toggleBot,
|
toggleBot,
|
||||||
usageAnalytics,
|
usageAnalytics,
|
||||||
activityStats,
|
activityStats,
|
||||||
|
|
@ -506,9 +519,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
||||||
usageAnalyticsMax,
|
usageAnalyticsMax,
|
||||||
usageAnalyticsSeries,
|
usageAnalyticsSeries,
|
||||||
usageAnalyticsTicks,
|
usageAnalyticsTicks,
|
||||||
usageData,
|
|
||||||
usageLoading,
|
usageLoading,
|
||||||
usagePage,
|
|
||||||
usageSummary,
|
usageSummary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,39 @@ export interface PlatformSettings {
|
||||||
page_size: number;
|
page_size: number;
|
||||||
chat_pull_page_size: number;
|
chat_pull_page_size: number;
|
||||||
command_auto_unlock_seconds: number;
|
command_auto_unlock_seconds: number;
|
||||||
|
auth_token_ttl_hours: number;
|
||||||
|
auth_token_max_active: number;
|
||||||
upload_max_mb: number;
|
upload_max_mb: number;
|
||||||
allowed_attachment_extensions: string[];
|
allowed_attachment_extensions: string[];
|
||||||
workspace_download_extensions: string[];
|
workspace_download_extensions: string[];
|
||||||
speech_enabled: boolean;
|
speech_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlatformLoginLogItem {
|
||||||
|
id: number;
|
||||||
|
auth_type: string;
|
||||||
|
subject_id: string;
|
||||||
|
bot_id?: string | null;
|
||||||
|
auth_source: string;
|
||||||
|
client_ip?: string | null;
|
||||||
|
user_agent?: string | null;
|
||||||
|
device_info?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
revoke_reason?: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformLoginLogResponse {
|
||||||
|
items: PlatformLoginLogItem[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemSettingItem {
|
export interface SystemSettingItem {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -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 ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||||
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
|
const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
|
||||||
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g;
|
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g;
|
||||||
|
|
@ -7,31 +5,15 @@ const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||||
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
|
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi;
|
||||||
const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi;
|
const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi;
|
||||||
|
|
||||||
function cleanLine(line: string) {
|
|
||||||
return line
|
|
||||||
.replace(OSC_RE, '')
|
|
||||||
.replace(ANSI_RE, '')
|
|
||||||
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
|
|
||||||
.replace(/\[(\d+)?K/g, '')
|
|
||||||
.replace(NON_TEXT_RE, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeUserMessageText(input: string) {
|
export function normalizeUserMessageText(input: string) {
|
||||||
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
let text = (input || '').replace(/\r\n/g, '\n').trim();
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
// Keep attachment list out of editable/visible command text.
|
|
||||||
text = text.replace(ATTACHMENT_BLOCK_RE, '').trim();
|
text = text.replace(ATTACHMENT_BLOCK_RE, '').trim();
|
||||||
// Keep quoted assistant context hidden in chat bubbles, but still allow backend delivery.
|
|
||||||
text = text.replace(QUOTED_REPLY_BLOCK_RE, '').trim();
|
text = text.replace(QUOTED_REPLY_BLOCK_RE, '').trim();
|
||||||
|
|
||||||
// Strip role prefixes injected by some gateways, e.g. "You: ...".
|
|
||||||
text = text.replace(/(^|\n)\s*(you|user|你)\s*[::]\s*/gi, '$1').trim();
|
text = text.replace(/(^|\n)\s*(you|user|你)\s*[::]\s*/gi, '$1').trim();
|
||||||
text = text.replace(/\n{3,}/g, '\n\n');
|
text = text.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
// Collapse duplicate echoes like "xxx You: xxx" / "xxx xxx".
|
|
||||||
const flat = text.replace(/\s+/g, ' ').trim();
|
const flat = text.replace(/\s+/g, ' ').trim();
|
||||||
const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|你)\s*[::]\s*\1$/iu);
|
const prefixedRepeat = flat.match(/^(.{4,}?)\s+(you|user|你)\s*[::]\s*\1$/iu);
|
||||||
if (prefixedRepeat) return prefixedRepeat[1].trim();
|
if (prefixedRepeat) return prefixedRepeat[1].trim();
|
||||||
|
|
@ -51,15 +33,10 @@ export function normalizeAssistantMessageText(input: string) {
|
||||||
.trim();
|
.trim();
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
// Remove dashboard wrapper if channel accidentally outputs raw marker line.
|
|
||||||
text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim();
|
text = text.replace(/__DASHBOARD_DATA_START__/g, '').replace(/__DASHBOARD_DATA_END__/g, '').trim();
|
||||||
text = text.replace(/<\/?tool_call>/gi, '').trim();
|
text = text.replace(/<\/?tool_call>/gi, '').trim();
|
||||||
|
|
||||||
// Keep HTML-enabled markdown readable: cap excessive HTML line breaks/empty paragraphs.
|
|
||||||
text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>');
|
text = text.replace(/(?:<br\s*\/?>\s*){4,}/gi, '<br><br>');
|
||||||
text = text.replace(/(?:<p>(?:\s| |<br\s*\/?>)*<\/p>\s*){3,}/gi, '<p><br></p>');
|
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');
|
text = text.replace(/\n{4,}/g, '\n\n\n');
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +73,8 @@ export function summarizeProgressText(input: string, isZh: boolean) {
|
||||||
if (!raw) return isZh ? '处理中...' : 'Processing...';
|
if (!raw) return isZh ? '处理中...' : 'Processing...';
|
||||||
const firstLine = raw
|
const firstLine = raw
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((v) => v.trim())
|
.map((value) => value.trim())
|
||||||
.find((v) => v.length > 0);
|
.find((value) => value.length > 0);
|
||||||
const line = (firstLine || raw)
|
const line = (firstLine || raw)
|
||||||
.replace(/[`*_>#|\[\]\(\)]/g, ' ')
|
.replace(/[`*_>#|\[\]\(\)]/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
|
|
@ -106,43 +83,13 @@ export function summarizeProgressText(input: string, isZh: boolean) {
|
||||||
return line.length > 96 ? `${line.slice(0, 96)}...` : line;
|
return line.length > 96 ? `${line.slice(0, 96)}...` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function cleanBotLogLine(line: string) {
|
||||||
* 核心逻辑:日志解析器仅用于提取“状态事件”(用于显示思考气泡)。
|
return line
|
||||||
* 所有的正式对话气泡(用户指令、AI回复)必须由结构化总线消息驱动。
|
.replace(OSC_RE, '')
|
||||||
*/
|
.replace(ANSI_RE, '')
|
||||||
export function parseLogToArtifacts(
|
.replace(/\[(\?|\d|;)+[A-Za-z]/g, '')
|
||||||
raw: string,
|
.replace(/\[(\d+)?K/g, '')
|
||||||
ts: number = Date.now(),
|
.replace(NON_TEXT_RE, ' ')
|
||||||
): { message?: ChatMessage; event?: BotEvent } | null {
|
.replace(/\s+/g, ' ')
|
||||||
const line = cleanLine(raw);
|
.trim();
|
||||||
if (!line || line.length < 3) return null;
|
|
||||||
const lower = line.toLowerCase();
|
|
||||||
|
|
||||||
// 1. 忽略结构化标签、系统日志和心跳干扰
|
|
||||||
if (
|
|
||||||
lower.includes('__dashboard_data') ||
|
|
||||||
lower.includes('litellm') ||
|
|
||||||
lower.includes('heartbeat') ||
|
|
||||||
lower.includes('starting nanobot gateway')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 仅提取思考/工具执行状态
|
|
||||||
if (lower.includes('nanobot is thinking')) {
|
|
||||||
return { event: { state: 'THINKING', text: 'Thinking', ts } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolMatch = line.match(/execut(?:e|ing) tool[:\s]+([\w\-./]+)/i);
|
|
||||||
if (toolMatch) {
|
|
||||||
return { event: { state: 'TOOL_CALL', text: `Executing Tool: ${toolMatch[1]}`, ts } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 错误状态提取
|
|
||||||
if (lower.includes('traceback') || (lower.includes('error') && !lower.includes('no error'))) {
|
|
||||||
return { event: { state: 'ERROR', text: 'Execution Error', ts } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绝对不返回 message 对象
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue