v0.1.4-p4

main
mula.liu 2026-03-31 12:31:47 +08:00
parent d0e6171120
commit 41212a7ac9
161 changed files with 31519 additions and 21868 deletions

View File

@ -50,10 +50,3 @@ STT_DEVICE=cpu
APP_HOST=0.0.0.0
APP_PORT=8000
APP_RELOAD=true
# Optional overrides (fallback only; usually keep empty when using template files)
DEFAULT_AGENTS_MD=
DEFAULT_SOUL_MD=
DEFAULT_USER_MD=
DEFAULT_TOOLS_MD=
DEFAULT_IDENTITY_MD=

View File

@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from core.database import get_session
from models.bot import BotInstance
from schemas.bot import (
BotEnvParamsUpdateRequest,
BotMcpConfigUpdateRequest,
BotToolsConfigUpdateRequest,
ChannelConfigRequest,
ChannelConfigUpdateRequest,
)
from services.bot_config_service import (
create_bot_channel_config,
delete_bot_channel_config,
get_bot_env_params_state,
get_bot_mcp_config_state,
get_bot_resources_snapshot,
get_bot_tools_config_state,
list_bot_channels_config,
reject_bot_tools_config_update,
update_bot_channel_config,
update_bot_env_params_state,
update_bot_mcp_config_state,
)
router = APIRouter()
@router.get("/api/bots/{bot_id}/resources")
def get_bot_resources(bot_id: str, session: Session = Depends(get_session)):
return get_bot_resources_snapshot(session, bot_id=bot_id)
@router.get("/api/bots/{bot_id}/channels")
def list_bot_channels(bot_id: str, session: Session = Depends(get_session)):
return list_bot_channels_config(session, bot_id=bot_id)
@router.get("/api/bots/{bot_id}/tools-config")
def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)):
return get_bot_tools_config_state(session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/tools-config")
def update_bot_tools_config(
bot_id: str,
payload: BotToolsConfigUpdateRequest,
session: Session = Depends(get_session),
):
return reject_bot_tools_config_update(session, bot_id=bot_id, payload=payload)
@router.get("/api/bots/{bot_id}/mcp-config")
def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
return get_bot_mcp_config_state(session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/mcp-config")
def update_bot_mcp_config(
bot_id: str,
payload: BotMcpConfigUpdateRequest,
session: Session = Depends(get_session),
):
return update_bot_mcp_config_state(session, bot_id=bot_id, payload=payload)
@router.get("/api/bots/{bot_id}/env-params")
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
return get_bot_env_params_state(session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/env-params")
def update_bot_env_params(
bot_id: str,
payload: BotEnvParamsUpdateRequest,
session: Session = Depends(get_session),
):
return update_bot_env_params_state(session, bot_id=bot_id, payload=payload)
@router.post("/api/bots/{bot_id}/channels")
def create_bot_channel(
bot_id: str,
payload: ChannelConfigRequest,
session: Session = Depends(get_session),
):
return create_bot_channel_config(session, bot_id=bot_id, payload=payload)
@router.put("/api/bots/{bot_id}/channels/{channel_id}")
def update_bot_channel(
bot_id: str,
channel_id: str,
payload: ChannelConfigUpdateRequest,
session: Session = Depends(get_session),
):
return update_bot_channel_config(session, bot_id=bot_id, channel_id=channel_id, payload=payload)
@router.delete("/api/bots/{bot_id}/channels/{channel_id}")
def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends(get_session)):
return delete_bot_channel_config(session, bot_id=bot_id, channel_id=channel_id)

View File

@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from core.database import get_session
from services.bot_lifecycle_service import (
deactivate_bot_instance,
delete_bot_instance,
disable_bot_instance,
enable_bot_instance,
start_bot_instance,
stop_bot_instance,
)
router = APIRouter()
@router.post("/api/bots/{bot_id}/start")
async def start_bot(bot_id: str, session: Session = Depends(get_session)):
try:
return await start_bot_instance(session, bot_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/api/bots/{bot_id}/stop")
def stop_bot(bot_id: str, session: Session = Depends(get_session)):
try:
return stop_bot_instance(session, bot_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
@router.post("/api/bots/{bot_id}/enable")
def enable_bot(bot_id: str, session: Session = Depends(get_session)):
try:
return enable_bot_instance(session, bot_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/bots/{bot_id}/disable")
def disable_bot(bot_id: str, session: Session = Depends(get_session)):
try:
return disable_bot_instance(session, bot_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/api/bots/{bot_id}/deactivate")
def deactivate_bot(bot_id: str, session: Session = Depends(get_session)):
try:
return deactivate_bot_instance(session, bot_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/api/bots/{bot_id}")
def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = Depends(get_session)):
try:
return delete_bot_instance(session, bot_id, delete_workspace=delete_workspace)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc

View File

@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session
from core.database import get_session
from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest
from services.bot_management_service import (
authenticate_bot_page_access,
create_bot_record,
get_bot_detail_cached,
list_bots_with_cache,
test_provider_connection,
update_bot_record,
)
router = APIRouter()
@router.post("/api/providers/test")
async def test_provider(payload: dict):
return await test_provider_connection(payload)
@router.post("/api/bots")
def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)):
return create_bot_record(session, payload=payload)
@router.get("/api/bots")
def list_bots(session: Session = Depends(get_session)):
return list_bots_with_cache(session)
@router.get("/api/bots/{bot_id}")
def get_bot_detail(bot_id: str, session: Session = Depends(get_session)):
return get_bot_detail_cached(session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/auth/login")
def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)):
return authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password)
@router.put("/api/bots/{bot_id}")
def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)):
return update_bot_record(session, bot_id=bot_id, payload=payload)

View File

@ -0,0 +1,185 @@
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlmodel import Session
from core.database import engine, get_session
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from core.websocket_manager import manager
from models.bot import BotInstance
from services.bot_channel_service import _get_bot_channels_from_config
from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance
from services.bot_storage_service import _read_bot_config, _write_bot_config
from services.bot_storage_service import _read_cron_store, _write_cron_store
from services.runtime_service import docker_callback
router = APIRouter()
logger = logging.getLogger("dashboard.backend")
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def _weixin_state_file_path(bot_id: str) -> Path:
return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json"
@router.get("/api/bots/{bot_id}/logs")
def get_bot_logs(
bot_id: str,
tail: Optional[int] = 300,
offset: int = 0,
limit: Optional[int] = None,
reverse: bool = False,
session: Session = Depends(get_session),
):
_get_bot_or_404(session, bot_id)
if limit is not None:
page = docker_manager.get_logs_page(
bot_id,
offset=max(0, int(offset)),
limit=max(1, int(limit)),
reverse=bool(reverse),
)
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)}
@router.post("/api/bots/{bot_id}/weixin/relogin")
async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)):
bot = _get_bot_or_404(session, bot_id)
weixin_channel = next(
(
row
for row in _get_bot_channels_from_config(bot)
if str(row.get("channel_type") or "").strip().lower() == "weixin"
),
None,
)
if not weixin_channel:
raise HTTPException(status_code=404, detail="Weixin channel not found")
state_file = _weixin_state_file_path(bot_id)
removed = False
try:
if state_file.is_file():
state_file.unlink()
removed = True
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to remove weixin state: {exc}") from exc
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {}
weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None
if isinstance(weixin_cfg, dict) and "token" in weixin_cfg:
weixin_cfg.pop("token", None)
_write_bot_config(bot_id, config_data)
restarted = False
if str(bot.docker_status or "").upper() == "RUNNING":
stop_bot_instance(session, bot_id)
await start_bot_instance(session, bot_id)
restarted = True
return {
"status": "relogin_started",
"bot_id": bot_id,
"removed_state": removed,
"restarted": restarted,
}
@router.get("/api/bots/{bot_id}/cron/jobs")
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id)
store = _read_cron_store(bot_id)
rows = []
for row in store.get("jobs", []):
if not isinstance(row, dict):
continue
enabled = bool(row.get("enabled", True))
if not include_disabled and not enabled:
continue
rows.append(row)
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
_get_bot_or_404(session, bot_id)
store = _read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
found = None
for row in jobs:
if isinstance(row, dict) and str(row.get("id")) == job_id:
found = row
break
if not found:
raise HTTPException(status_code=404, detail="Cron job not found")
found["enabled"] = False
found["updatedAtMs"] = int(datetime.utcnow().timestamp() * 1000)
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "stopped", "job_id": 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)):
_get_bot_or_404(session, bot_id)
store = _read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
if len(kept) == len(jobs):
raise HTTPException(status_code=404, detail="Cron job not found")
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
return {"status": "deleted", "job_id": job_id}
@router.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
with Session(engine) as session:
bot = session.get(BotInstance, bot_id)
if not bot:
await websocket.close(code=4404, reason="Bot not found")
return
connected = False
try:
await manager.connect(bot_id, websocket)
connected = True
except Exception as exc:
logger.warning("websocket connect failed bot_id=%s detail=%s", bot_id, exc)
try:
await websocket.close(code=1011, reason="WebSocket accept failed")
except Exception:
pass
return
docker_manager.ensure_monitor(bot_id, docker_callback)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
except RuntimeError as exc:
msg = str(exc or "").lower()
if "need to call \"accept\" first" not in msg and "not connected" not in msg:
logger.exception("websocket runtime error bot_id=%s", bot_id)
except Exception:
logger.exception("websocket unexpected error bot_id=%s", bot_id)
finally:
if connected:
manager.disconnect(bot_id, websocket)

View File

@ -0,0 +1,33 @@
import logging
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from sqlmodel import Session
from core.database import get_session
from core.speech_service import WhisperSpeechService
from services.speech_transcribe_service import transcribe_bot_speech_upload
router = APIRouter()
logger = logging.getLogger("dashboard.backend")
@router.post("/api/bots/{bot_id}/speech/transcribe")
async def transcribe_bot_speech(
bot_id: str,
request: Request,
file: UploadFile = File(...),
language: Optional[str] = Form(None),
session: Session = Depends(get_session),
):
speech_service = getattr(request.app.state, "speech_service", None)
if not isinstance(speech_service, WhisperSpeechService):
raise HTTPException(status_code=500, detail="Speech service is not initialized")
return await transcribe_bot_speech_upload(
session,
bot_id,
upload=file,
language=language,
speech_service=speech_service,
logger=logger,
)

View File

@ -0,0 +1,69 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlmodel import Session
from core.database import get_session
from schemas.bot import MessageFeedbackRequest
from services.chat_history_service import (
clear_bot_messages_payload,
clear_dashboard_direct_session_payload,
list_bot_messages_by_date_payload,
list_bot_messages_page_payload,
list_bot_messages_payload,
update_bot_message_feedback_payload,
)
router = APIRouter()
@router.get("/api/bots/{bot_id}/messages")
def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(get_session)):
return list_bot_messages_payload(session, bot_id, limit=limit)
@router.get("/api/bots/{bot_id}/messages/page")
def list_bot_messages_page(
bot_id: str,
limit: Optional[int] = None,
before_id: Optional[int] = None,
session: Session = Depends(get_session),
):
return list_bot_messages_page_payload(session, bot_id, limit=limit, before_id=before_id)
@router.get("/api/bots/{bot_id}/messages/by-date")
def list_bot_messages_by_date(
bot_id: str,
date: str,
tz_offset_minutes: Optional[int] = None,
limit: Optional[int] = None,
session: Session = Depends(get_session),
):
return list_bot_messages_by_date_payload(
session,
bot_id,
date=date,
tz_offset_minutes=tz_offset_minutes,
limit=limit,
)
@router.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
def update_bot_message_feedback(
bot_id: str,
message_id: int,
payload: MessageFeedbackRequest,
session: Session = Depends(get_session),
):
return update_bot_message_feedback_payload(session, bot_id, message_id, payload.feedback)
@router.delete("/api/bots/{bot_id}/messages")
def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
return clear_bot_messages_payload(session, bot_id)
@router.post("/api/bots/{bot_id}/sessions/dashboard-direct/clear")
def clear_bot_dashboard_direct_session(bot_id: str, session: Session = Depends(get_session)):
return clear_dashboard_direct_session_payload(session, bot_id)

View File

@ -0,0 +1,29 @@
from typing import Any, Dict, Tuple
from fastapi import APIRouter, Body, Depends
from sqlmodel import Session
from core.database import get_session
from services.chat_command_service import send_bot_command
router = APIRouter()
def _parse_command_payload(payload: Dict[str, Any] | None) -> Tuple[str, Any]:
body = payload if isinstance(payload, dict) else {}
return str(body.get("command") or ""), body.get("attachments")
@router.post("/api/bots/{bot_id}/command")
def send_command(
bot_id: str,
payload: Dict[str, Any] | None = Body(default=None),
session: Session = Depends(get_session),
):
command, attachments = _parse_command_payload(payload)
return send_bot_command(
session,
bot_id,
command=command,
attachments=attachments,
)

View File

@ -0,0 +1,126 @@
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from core.cache import cache
from core.database import get_session
from core.docker_instance import docker_manager
from models.bot import BotInstance, NanobotImage
from services.cache_service import _cache_key_images, _invalidate_images_cache
router = APIRouter()
def _serialize_image(row: NanobotImage) -> Dict[str, Any]:
created_at = row.created_at.isoformat() + "Z" if row.created_at else None
return {
"tag": row.tag,
"image_id": row.image_id,
"version": row.version,
"status": row.status,
"source_dir": row.source_dir,
"created_at": created_at,
}
def _reconcile_registered_images(session: Session) -> None:
rows = session.exec(select(NanobotImage)).all()
dirty = False
for row in rows:
docker_exists = docker_manager.has_image(row.tag)
next_status = "READY" if docker_exists else "ERROR"
next_image_id = row.image_id
if docker_exists and docker_manager.client:
try:
next_image_id = docker_manager.client.images.get(row.tag).id
except Exception:
next_image_id = row.image_id
if row.status != next_status or row.image_id != next_image_id:
row.status = next_status
row.image_id = next_image_id
session.add(row)
dirty = True
if dirty:
session.commit()
def reconcile_image_registry(session: Session) -> None:
"""Backward-compatible alias for older callers after router refactor."""
_reconcile_registered_images(session)
@router.get("/api/images")
def list_images(session: Session = Depends(get_session)):
cached = cache.get_json(_cache_key_images())
if isinstance(cached, list) and all(isinstance(row, dict) for row in cached):
return cached
if isinstance(cached, list):
_invalidate_images_cache()
try:
_reconcile_registered_images(session)
except Exception as exc:
# Docker status probing should not break image management in dev mode.
print(f"[image_router] reconcile images skipped: {exc}")
rows = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
payload = [_serialize_image(row) for row in rows]
cache.set_json(_cache_key_images(), payload, ttl=60)
return payload
@router.delete("/api/images/{tag:path}")
def delete_image(tag: str, session: Session = Depends(get_session)):
image = session.get(NanobotImage, tag)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
# 检查是否有机器人正在使用此镜像
bots_using = session.exec(select(BotInstance).where(BotInstance.image_tag == tag)).all()
if bots_using:
raise HTTPException(status_code=400, detail=f"Cannot delete image: {len(bots_using)} bots are using it.")
session.delete(image)
session.commit()
_invalidate_images_cache()
return {"status": "deleted"}
@router.get("/api/docker-images")
def list_docker_images(repository: str = "nanobot-base"):
rows = docker_manager.list_images_by_repo(repository)
return rows
@router.post("/api/images/register")
def register_image(payload: dict, session: Session = Depends(get_session)):
tag = (payload.get("tag") or "").strip()
source_dir = (payload.get("source_dir") or "manual").strip() or "manual"
if not tag:
raise HTTPException(status_code=400, detail="tag is required")
if not docker_manager.has_image(tag):
raise HTTPException(status_code=404, detail=f"Docker image not found: {tag}")
version = tag.split(":")[-1].removeprefix("v") if ":" in tag else tag
try:
docker_img = docker_manager.client.images.get(tag) if docker_manager.client else None
image_id = docker_img.id if docker_img else None
except Exception:
image_id = None
row = session.get(NanobotImage, tag)
if not row:
row = NanobotImage(
tag=tag,
version=version,
status="READY",
source_dir=source_dir,
image_id=image_id,
)
else:
row.version = version
row.status = "READY"
row.source_dir = source_dir
row.image_id = image_id
session.add(row)
session.commit()
session.refresh(row)
_invalidate_images_cache()
return _serialize_image(row)

View File

@ -0,0 +1,100 @@
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session
from core.database import get_session
from models.bot import BotInstance
from services.skill_market_service import (
create_skill_market_item_record,
delete_skill_market_item_record,
install_skill_market_item_for_bot,
list_bot_skill_market_items,
list_skill_market_items,
update_skill_market_item_record,
)
from services.skill_service import (
delete_workspace_skill_entry,
list_bot_skills as list_workspace_bot_skills,
upload_bot_skill_zip_to_workspace,
)
router = APIRouter()
@router.get("/api/platform/skills")
def list_skill_market(session: Session = Depends(get_session)):
return list_skill_market_items(session)
@router.post("/api/platform/skills")
async def create_skill_market_item(
skill_key: str = Form(""),
display_name: str = Form(""),
description: str = Form(""),
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
return await create_skill_market_item_record(
session,
skill_key=skill_key,
display_name=display_name,
description=description,
upload=file,
)
@router.put("/api/platform/skills/{skill_id}")
async def update_skill_market_item(
skill_id: int,
skill_key: str = Form(""),
display_name: str = Form(""),
description: str = Form(""),
file: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
):
return await update_skill_market_item_record(
session,
skill_id=skill_id,
skill_key=skill_key,
display_name=display_name,
description=description,
upload=file,
)
@router.delete("/api/platform/skills/{skill_id}")
def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)):
return delete_skill_market_item_record(session, skill_id=skill_id)
@router.get("/api/bots/{bot_id}/skills")
def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return list_workspace_bot_skills(bot_id)
@router.get("/api/bots/{bot_id}/skill-market")
def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return list_bot_skill_market_items(session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/skill-market/{skill_id}/install")
def install_bot_skill_from_market(bot_id: str, skill_id: int, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return install_skill_market_item_for_bot(session, bot_id=bot_id, skill_id=skill_id)
@router.post("/api/bots/{bot_id}/skills/upload")
async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return await upload_bot_skill_zip_to_workspace(bot_id, upload=file)
@router.delete("/api/bots/{bot_id}/skills/{skill_name}")
def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return delete_workspace_skill_entry(bot_id, skill_name=skill_name)

View File

@ -0,0 +1,121 @@
from typing import Any, Dict
from fastapi import APIRouter, HTTPException
from sqlmodel import Session, select
from core.database import engine, get_session
from core.settings import DATABASE_ENGINE, PANEL_ACCESS_PASSWORD, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL
from core.utils import _get_default_system_timezone
from models.bot import BotInstance
from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest
from core.cache import cache
from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings
from services.template_service import (
get_agent_md_templates,
get_topic_presets,
update_agent_md_templates,
update_topic_presets,
)
router = APIRouter()
@router.get("/api/panel/auth/status")
def get_panel_auth_status():
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
return {"enabled": bool(configured)}
@router.post("/api/panel/auth/login")
def panel_login(payload: PanelLoginRequest):
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
if not configured:
return {"success": True, "enabled": False}
supplied = str(payload.password or "").strip()
if supplied != configured:
raise HTTPException(status_code=401, detail="Invalid panel access password")
return {"success": True, "enabled": True}
@router.get("/api/system/defaults")
def get_system_defaults():
md_templates = get_agent_md_templates()
platform_settings = get_platform_settings_snapshot()
speech_settings = get_speech_runtime_settings()
return {
"templates": md_templates,
"limits": {
"upload_max_mb": platform_settings.upload_max_mb,
},
"workspace": {
"download_extensions": list(platform_settings.workspace_download_extensions),
"allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions),
},
"bot": {
"system_timezone": _get_default_system_timezone(),
},
"loading_page": platform_settings.loading_page.model_dump(),
"chat": {
"pull_page_size": platform_settings.chat_pull_page_size,
"page_size": platform_settings.page_size,
"command_auto_unlock_seconds": platform_settings.command_auto_unlock_seconds,
},
"topic_presets": get_topic_presets().get("presets", []),
"speech": {
"enabled": speech_settings["enabled"],
"model": speech_settings["model"],
"device": speech_settings["device"],
"max_audio_seconds": speech_settings["max_audio_seconds"],
"default_language": speech_settings["default_language"],
},
}
@router.get("/api/system/templates")
def get_system_templates():
return {
"agent_md_templates": get_agent_md_templates(),
"topic_presets": get_topic_presets(),
}
@router.put("/api/system/templates")
def update_system_templates(payload: SystemTemplatesUpdateRequest):
if payload.agent_md_templates is not None:
update_agent_md_templates(payload.agent_md_templates.model_dump())
if payload.topic_presets is not None:
try:
update_topic_presets(payload.topic_presets)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"status": "ok",
"agent_md_templates": get_agent_md_templates(),
"topic_presets": get_topic_presets(),
}
@router.get("/api/health")
def get_health():
try:
with Session(engine) as session:
session.exec(select(BotInstance).limit(1)).first()
return {"status": "ok", "database": DATABASE_ENGINE}
except Exception as e:
raise HTTPException(status_code=503, detail=f"database check failed: {e}")
@router.get("/api/health/cache")
def get_cache_health():
redis_url = str(REDIS_URL or "").strip()
configured = bool(REDIS_ENABLED and redis_url)
client_enabled = bool(getattr(cache, "enabled", False))
reachable = bool(cache.ping()) if client_enabled else False
status = "ok"
if configured and not reachable:
status = "degraded"
return {
"status": status,
"cache": {
"configured": configured,
"enabled": client_enabled,
"reachable": reachable,
"prefix": REDIS_PREFIX,
},
}

View File

@ -0,0 +1,146 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from sqlmodel import Session
from core.database import get_session
from models.bot import BotInstance
from schemas.system import WorkspaceFileUpdateRequest
from services.workspace_service import (
get_workspace_tree_data,
read_workspace_text_file,
serve_workspace_file,
update_workspace_markdown_file,
upload_workspace_files_to_workspace,
)
router = APIRouter()
@router.get("/api/bots/{bot_id}/workspace/tree")
def get_workspace_tree(
bot_id: str,
path: Optional[str] = None,
recursive: bool = False,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return get_workspace_tree_data(bot_id, path=path, recursive=recursive)
@router.get("/api/bots/{bot_id}/workspace/file")
def read_workspace_file(
bot_id: str,
path: str,
max_bytes: int = 200000,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return read_workspace_text_file(bot_id, path=path, max_bytes=max_bytes)
@router.put("/api/bots/{bot_id}/workspace/file")
def update_workspace_file(
bot_id: str,
path: str,
payload: WorkspaceFileUpdateRequest,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return update_workspace_markdown_file(bot_id, path=path, content=payload.content)
@router.get("/api/bots/{bot_id}/workspace/download")
def download_workspace_file(
bot_id: str,
path: str,
download: bool = False,
request: Request = None,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return serve_workspace_file(
bot_id=bot_id,
path=path,
download=download,
request=request,
public=False,
redirect_html_to_raw=True,
)
@router.get("/public/bots/{bot_id}/workspace/download")
def public_download_workspace_file(
bot_id: str,
path: str,
download: bool = False,
request: Request = None,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return serve_workspace_file(
bot_id=bot_id,
path=path,
download=download,
request=request,
public=True,
redirect_html_to_raw=True,
)
@router.get("/api/bots/{bot_id}/workspace/raw/{path:path}")
def raw_workspace_file(
bot_id: str,
path: str,
download: bool = False,
request: Request = None,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return serve_workspace_file(
bot_id=bot_id,
path=path,
download=download,
request=request,
public=False,
redirect_html_to_raw=False,
)
@router.get("/public/bots/{bot_id}/workspace/raw/{path:path}")
def public_raw_workspace_file(
bot_id: str,
path: str,
download: bool = False,
request: Request = None,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return serve_workspace_file(
bot_id=bot_id,
path=path,
download=download,
request=request,
public=True,
redirect_html_to_raw=False,
)
@router.post("/api/bots/{bot_id}/workspace/upload")
async def upload_workspace_files(
bot_id: str,
files: List[UploadFile] = File(...),
path: Optional[str] = None,
session: Session = Depends(get_session),
):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return await upload_workspace_files_to_workspace(bot_id, files=files, path=path)

View File

@ -0,0 +1,59 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.bot_config_router import router as bot_config_router
from api.bot_management_router import router as bot_management_router
from api.bot_router import router as bot_router
from api.bot_runtime_router import router as bot_runtime_router
from api.bot_speech_router import router as bot_speech_router
from api.chat_history_router import router as chat_history_router
from api.chat_router import router as chat_router
from api.image_router import router as image_router
from api.platform_router import router as platform_router
from api.skill_router import router as skill_router
from api.system_router import router as system_router
from api.topic_router import router as topic_router
from api.workspace_router import router as workspace_router
from bootstrap.app_runtime import register_app_runtime
from core.auth_middleware import PasswordProtectionMiddleware
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
from core.speech_service import WhisperSpeechService
def create_app() -> FastAPI:
app = FastAPI(title="Dashboard Nanobot API")
speech_service = WhisperSpeechService()
app.state.docker_manager = docker_manager
app.state.speech_service = speech_service
app.add_middleware(PasswordProtectionMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(platform_router)
app.include_router(topic_router)
app.include_router(system_router)
app.include_router(image_router)
app.include_router(skill_router)
app.include_router(chat_router)
app.include_router(chat_history_router)
app.include_router(bot_speech_router)
app.include_router(workspace_router)
app.include_router(bot_config_router)
app.include_router(bot_runtime_router)
app.include_router(bot_management_router)
app.include_router(bot_router)
os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True)
os.makedirs(DATA_ROOT, exist_ok=True)
register_app_runtime(app)
return app

View File

@ -0,0 +1,31 @@
import asyncio
from fastapi import FastAPI
from sqlmodel import Session, select
from core.database import engine, init_database
from core.docker_instance import docker_manager
from core.settings import DATABASE_URL_DISPLAY, REDIS_ENABLED
from models.bot import BotInstance
from services.bot_storage_service import _migrate_bot_resources_store
from services.platform_service import prune_expired_activity_events
from services.runtime_service import docker_callback, set_main_loop
def register_app_runtime(app: FastAPI) -> None:
@app.on_event("startup")
async def _on_startup() -> None:
print(
f"🚀 Dashboard Backend 启动中... (DB: {DATABASE_URL_DISPLAY}, REDIS: {'Enabled' if REDIS_ENABLED else 'Disabled'})"
)
current_loop = asyncio.get_running_loop()
app.state.main_loop = current_loop
set_main_loop(current_loop)
init_database()
with Session(engine) as session:
prune_expired_activity_events(session, force=True)
bots = session.exec(select(BotInstance)).all()
for bot in bots:
_migrate_bot_resources_store(bot.id)
docker_manager.ensure_monitor(bot.id, docker_callback)
print("✅ 启动自检完成")

View File

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

View File

@ -2,13 +2,26 @@ import json
import os
from typing import Any, Dict, List
from core.settings import (
DEFAULT_AGENTS_MD,
DEFAULT_IDENTITY_MD,
DEFAULT_SOUL_MD,
DEFAULT_TOOLS_MD,
DEFAULT_USER_MD,
)
from services.template_service import get_agent_md_templates
def _provider_default_api_base(provider: str) -> str:
normalized = str(provider or "").strip().lower()
if normalized == "openai":
return "https://api.openai.com/v1"
if normalized == "openrouter":
return "https://openrouter.ai/api/v1"
if normalized in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}:
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
if normalized == "deepseek":
return "https://api.deepseek.com/v1"
if normalized in {"xunfei", "iflytek", "xfyun"}:
return "https://spark-api-open.xf-yun.com/v1"
if normalized in {"kimi", "moonshot"}:
return "https://api.moonshot.cn/v1"
if normalized == "minimax":
return "https://api.minimax.chat/v1"
return ""
class BotConfigManager:
@ -26,11 +39,39 @@ class BotConfigManager:
for d in [dot_nanobot_dir, workspace_dir, memory_dir, skills_dir]:
os.makedirs(d, exist_ok=True)
raw_provider_name = (bot_data.get("llm_provider") or "openrouter").strip().lower()
template_defaults = get_agent_md_templates()
existing_config: Dict[str, Any] = {}
config_path = os.path.join(dot_nanobot_dir, "config.json")
if os.path.isfile(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
existing_config = loaded
except Exception:
existing_config = {}
existing_provider_name = ""
existing_provider_cfg: Dict[str, Any] = {}
existing_model_name = ""
providers_cfg = existing_config.get("providers")
if isinstance(providers_cfg, dict):
for provider_name, provider_cfg in providers_cfg.items():
existing_provider_name = str(provider_name or "").strip().lower()
if isinstance(provider_cfg, dict):
existing_provider_cfg = provider_cfg
break
agents_cfg = existing_config.get("agents")
if isinstance(agents_cfg, dict):
defaults_cfg = agents_cfg.get("defaults")
if isinstance(defaults_cfg, dict):
existing_model_name = str(defaults_cfg.get("model") or "").strip()
raw_provider_name = (bot_data.get("llm_provider") or existing_provider_name).strip().lower()
provider_name = raw_provider_name
model_name = (bot_data.get("llm_model") or "openai/gpt-4o-mini").strip()
api_key = (bot_data.get("api_key") or "").strip()
api_base = (bot_data.get("api_base") or "").strip() or None
model_name = (bot_data.get("llm_model") or existing_model_name).strip()
api_key = (bot_data.get("api_key") or existing_provider_cfg.get("apiKey") or "").strip()
api_base = (bot_data.get("api_base") or existing_provider_cfg.get("apiBase") or "").strip() or None
provider_alias = {
"aliyun": "dashscope",
@ -47,6 +88,8 @@ class BotConfigManager:
if provider_name == "openai" and raw_provider_name in {"xunfei", "iflytek", "xfyun"}:
if model_name and "/" not in model_name:
model_name = f"openai/{model_name}"
if not api_base:
api_base = _provider_default_api_base(raw_provider_name) or _provider_default_api_base(provider_name) or None
provider_cfg: Dict[str, Any] = {
"apiKey": api_key,
@ -61,17 +104,6 @@ class BotConfigManager:
"sendToolHints": bool(bot_data.get("send_tool_hints", False)),
}
existing_config: Dict[str, Any] = {}
config_path = os.path.join(dot_nanobot_dir, "config.json")
if os.path.isfile(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
existing_config = loaded
except Exception:
existing_config = {}
existing_tools = existing_config.get("tools")
tools_cfg: Dict[str, Any] = dict(existing_tools) if isinstance(existing_tools, dict) else {}
if "mcp_servers" in bot_data:
@ -88,9 +120,7 @@ class BotConfigManager:
"maxTokens": int(bot_data.get("max_tokens") or 8192),
}
},
"providers": {
provider_name: provider_cfg,
},
"providers": {provider_name: provider_cfg} if provider_name else {},
"channels": channels_cfg,
}
if tools_cfg:
@ -189,6 +219,32 @@ class BotConfigManager:
}
continue
if channel_type == "weixin":
weixin_cfg: Dict[str, Any] = {
"enabled": enabled,
"allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])),
}
route_tag = str(extra.get("routeTag") or "").strip()
if route_tag:
weixin_cfg["routeTag"] = route_tag
state_dir = str(extra.get("stateDir") or "").strip()
if state_dir:
weixin_cfg["stateDir"] = state_dir
base_url = str(extra.get("baseUrl") or "").strip()
if base_url:
weixin_cfg["baseUrl"] = base_url
cdn_base_url = str(extra.get("cdnBaseUrl") or "").strip()
if cdn_base_url:
weixin_cfg["cdnBaseUrl"] = cdn_base_url
poll_timeout = extra.get("pollTimeout", extra.get("poll_timeout"))
if poll_timeout not in {None, ""}:
try:
weixin_cfg["pollTimeout"] = max(1, int(poll_timeout))
except (TypeError, ValueError):
pass
channels_cfg["weixin"] = weixin_cfg
continue
if channel_type == "email":
channels_cfg["email"] = {
"enabled": enabled,
@ -227,11 +283,11 @@ class BotConfigManager:
json.dump(config_data, f, indent=4, ensure_ascii=False)
bootstrap_files = {
"AGENTS.md": bot_data.get("agents_md") or DEFAULT_AGENTS_MD,
"SOUL.md": bot_data.get("soul_md") or bot_data.get("system_prompt") or DEFAULT_SOUL_MD,
"USER.md": bot_data.get("user_md") or DEFAULT_USER_MD,
"TOOLS.md": bot_data.get("tools_md") or DEFAULT_TOOLS_MD,
"IDENTITY.md": bot_data.get("identity_md") or DEFAULT_IDENTITY_MD,
"AGENTS.md": bot_data.get("agents_md") or template_defaults.get("agents_md", ""),
"SOUL.md": bot_data.get("soul_md") or bot_data.get("system_prompt") or template_defaults.get("soul_md", ""),
"USER.md": bot_data.get("user_md") or template_defaults.get("user_md", ""),
"TOOLS.md": bot_data.get("tools_md") or template_defaults.get("tools_md", ""),
"IDENTITY.md": bot_data.get("identity_md") or template_defaults.get("identity_md", ""),
}
for filename, content in bootstrap_files.items():

View File

@ -0,0 +1,4 @@
from core.docker_manager import BotDockerManager
from core.settings import BOTS_WORKSPACE_ROOT
docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)

View File

@ -24,6 +24,8 @@ class BotDockerManager:
self.base_image = base_image
self.active_monitors = {}
self._last_delivery_error: Dict[str, str] = {}
self._storage_limit_supported: Optional[bool] = None
self._storage_limit_warning_emitted = False
@staticmethod
def _normalize_resource_limits(
@ -88,6 +90,84 @@ class BotDockerManager:
print(f"[DockerManager] list_images_by_repo failed: {e}")
return rows
@staticmethod
def _docker_error_message(exc: Exception) -> str:
explanation = getattr(exc, "explanation", None)
if isinstance(explanation, bytes):
try:
explanation = explanation.decode("utf-8", errors="replace")
except Exception:
explanation = str(explanation)
if explanation:
return str(explanation)
response = getattr(exc, "response", None)
text = getattr(response, "text", None)
if text:
return str(text)
return str(exc)
@classmethod
def _is_unsupported_storage_opt_error(cls, exc: Exception) -> bool:
message = cls._docker_error_message(exc).lower()
if "storage-opt" not in message and "storage opt" not in message:
return False
markers = (
"overlay over xfs",
"overlay2 over xfs",
"pquota",
"project quota",
"storage driver does not support",
"xfs",
)
return any(marker in message for marker in markers)
def _cleanup_container_if_exists(self, container_name: str) -> None:
if not self.client:
return
try:
container = self.client.containers.get(container_name)
container.remove(force=True)
except docker.errors.NotFound:
pass
except Exception as e:
print(f"[DockerManager] failed to cleanup container {container_name}: {e}")
def _run_container_with_storage_fallback(
self,
bot_id: str,
container_name: str,
storage_gb: int,
**base_kwargs: Any,
):
if not self.client:
raise RuntimeError("Docker client is not available")
if storage_gb <= 0:
return self.client.containers.run(**base_kwargs)
if self._storage_limit_supported is False:
return self.client.containers.run(**base_kwargs)
try:
container = self.client.containers.run(
storage_opt={"size": f"{storage_gb}G"},
**base_kwargs,
)
self._storage_limit_supported = True
return container
except Exception as exc:
if not self._is_unsupported_storage_opt_error(exc):
raise
self._storage_limit_supported = False
if not self._storage_limit_warning_emitted:
print(
"[DockerManager] storage limit not supported by current Docker storage driver; "
f"falling back to unlimited container filesystem size. Details: {self._docker_error_message(exc)}"
)
self._storage_limit_warning_emitted = True
else:
print(f"[DockerManager] storage limit skipped for {bot_id}: unsupported by current Docker storage driver")
self._cleanup_container_if_exists(container_name)
return self.client.containers.run(**base_kwargs)
def start_bot(
self,
bot_id: str,
@ -141,18 +221,12 @@ class BotDockerManager:
pass
container = None
if storage > 0:
try:
container = self.client.containers.run(
storage_opt={"size": f"{storage}G"},
**base_kwargs,
)
except Exception as e:
# Some Docker engines (e.g. Desktop/overlay2) may not support size storage option.
print(f"[DockerManager] storage limit not applied for {bot_id}: {e}")
container = self.client.containers.run(**base_kwargs)
else:
container = self.client.containers.run(**base_kwargs)
container = self._run_container_with_storage_fallback(
bot_id,
container_name,
storage,
**base_kwargs,
)
if on_state_change:
monitor_thread = threading.Thread(
@ -538,19 +612,60 @@ class BotDockerManager:
self._last_delivery_error[bot_id] = reason
return False
def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]:
def _read_log_lines(self, bot_id: str, tail: Optional[int] = None) -> List[str]:
if not self.client:
return []
container_name = f"worker_{bot_id}"
try:
container = self.client.containers.get(container_name)
raw = container.logs(tail=max(1, int(tail)))
raw = container.logs(tail=max(1, int(tail))) if tail is not None else container.logs()
text = raw.decode("utf-8", errors="ignore")
return [line for line in text.splitlines() if line.strip()]
except Exception as e:
print(f"[DockerManager] Error reading logs for {bot_id}: {e}")
return []
def get_recent_logs(self, bot_id: str, tail: int = 300) -> List[str]:
return self._read_log_lines(bot_id, tail=max(1, int(tail)))
def get_logs_page(
self,
bot_id: str,
offset: int = 0,
limit: int = 50,
reverse: bool = True,
) -> Dict[str, Any]:
safe_offset = max(0, int(offset))
safe_limit = max(1, int(limit))
if reverse:
# Docker logs API supports tail but not arbitrary offsets. For reverse pagination
# we only read the minimal newest slice needed for the requested page.
tail_count = safe_offset + safe_limit + 1
lines = self._read_log_lines(bot_id, tail=tail_count)
ordered = list(reversed(lines))
page = ordered[safe_offset:safe_offset + safe_limit]
has_more = len(lines) > safe_offset + safe_limit
return {
"logs": page,
"total": None,
"offset": safe_offset,
"limit": safe_limit,
"has_more": has_more,
"reverse": reverse,
}
lines = self._read_log_lines(bot_id, tail=None)
total = len(lines)
page = lines[safe_offset:safe_offset + safe_limit]
return {
"logs": page,
"total": total,
"offset": safe_offset,
"limit": safe_limit,
"has_more": safe_offset + safe_limit < total,
"reverse": reverse,
}
def _monitor_container_logs(self, bot_id: str, container, callback: Callable[[str, dict], None]):
try:
buffer = ""

View File

@ -1,4 +1,3 @@
import json
import os
import re
from pathlib import Path
@ -30,13 +29,6 @@ for _k, _v in _prod_env_values.items():
os.environ[_k] = str(_v)
def _env_text(name: str, default: str) -> str:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).replace("\\n", "\n")
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
@ -95,23 +87,6 @@ def _normalize_dir_path(path_value: str) -> str:
return str((BACKEND_ROOT / p).resolve())
def _load_json_object(path: Path) -> dict[str, object]:
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def _read_template_md(raw: object) -> str:
if raw is None:
return ""
return str(raw).replace("\r\n", "\n").strip()
DATA_ROOT: Final[str] = _normalize_dir_path(os.getenv("DATA_ROOT", str(PROJECT_ROOT / "data")))
BOTS_WORKSPACE_ROOT: Final[str] = _normalize_dir_path(
os.getenv("BOTS_WORKSPACE_ROOT", str(PROJECT_ROOT / "workspace" / "bots"))
@ -226,47 +201,3 @@ PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or ""
TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json"
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json"
_agent_md_templates_raw = _load_json_object(AGENT_MD_TEMPLATES_FILE)
DEFAULT_AGENTS_MD: Final[str] = _env_text(
"DEFAULT_AGENTS_MD",
_read_template_md(_agent_md_templates_raw.get("agents_md")),
).strip()
DEFAULT_SOUL_MD: Final[str] = _env_text(
"DEFAULT_SOUL_MD",
_read_template_md(_agent_md_templates_raw.get("soul_md")),
).strip()
DEFAULT_USER_MD: Final[str] = _env_text(
"DEFAULT_USER_MD",
_read_template_md(_agent_md_templates_raw.get("user_md")),
).strip()
DEFAULT_TOOLS_MD: Final[str] = _env_text(
"DEFAULT_TOOLS_MD",
_read_template_md(_agent_md_templates_raw.get("tools_md")),
).strip()
DEFAULT_IDENTITY_MD: Final[str] = _env_text(
"DEFAULT_IDENTITY_MD",
_read_template_md(_agent_md_templates_raw.get("identity_md")),
).strip()
_topic_presets_raw = _load_json_object(TOPIC_PRESETS_TEMPLATES_FILE)
_topic_presets_list = _topic_presets_raw.get("presets")
TOPIC_PRESET_TEMPLATES: Final[list[dict[str, object]]] = [
dict(row) for row in (_topic_presets_list if isinstance(_topic_presets_list, list) else []) if isinstance(row, dict)
]
def load_agent_md_templates() -> dict[str, str]:
raw = _load_json_object(AGENT_MD_TEMPLATES_FILE)
rows: dict[str, str] = {}
for key in ("agents_md", "soul_md", "user_md", "tools_md", "identity_md"):
rows[key] = _read_template_md(raw.get(key))
return rows
def load_topic_presets_template() -> dict[str, object]:
raw = _load_json_object(TOPIC_PRESETS_TEMPLATES_FILE)
presets = raw.get("presets")
if not isinstance(presets, list):
return {"presets": []}
return {"presets": [dict(row) for row in presets if isinstance(row, dict)]}

View File

@ -0,0 +1,160 @@
import os
import re
import json
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from core.settings import DEFAULT_BOT_SYSTEM_TIMEZONE
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
__all__ = [
"_calc_dir_size_bytes",
"_get_default_system_timezone",
"_is_ignored_skill_zip_top_level",
"_is_image_attachment_path",
"_is_valid_top_level_skill_name",
"_is_video_attachment_path",
"_is_visual_attachment_path",
"_normalize_env_params",
"_normalize_system_timezone",
"_parse_env_params",
"_parse_json_string_list",
"_read_description_from_text",
"_resolve_local_day_range",
"_safe_float",
"_safe_int",
"_sanitize_skill_market_key",
"_sanitize_zip_filename",
"_workspace_stat_ctime_iso",
]
def _resolve_local_day_range(date_text: str, tz_offset_minutes: Optional[int]) -> tuple[datetime, datetime]:
try:
local_day = datetime.strptime(str(date_text or "").strip(), "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid date, expected YYYY-MM-DD") from exc
offset = timedelta(minutes=tz_offset_minutes if tz_offset_minutes is not None else 0)
utc_start = (local_day).replace(tzinfo=timezone.utc) + offset
utc_end = utc_start + timedelta(days=1)
return utc_start, utc_end
def _sanitize_zip_filename(name: str) -> str:
s = str(name or "").strip()
s = re.sub(r"[^a-zA-Z0-9._-]", "_", s)
return s if s else "upload.zip"
def _normalize_env_params(raw: Any) -> Dict[str, str]:
if not isinstance(raw, dict):
return {}
res: Dict[str, str] = {}
for k, v in raw.items():
ks = str(k).strip()
if _ENV_KEY_RE.match(ks):
res[ks] = str(v or "").strip()
return res
def _get_default_system_timezone() -> str:
return str(DEFAULT_BOT_SYSTEM_TIMEZONE or "Asia/Shanghai").strip()
def _normalize_system_timezone(raw: Any) -> str:
s = str(raw or "").strip()
if not s:
return _get_default_system_timezone()
try:
ZoneInfo(s)
return s
except Exception:
return _get_default_system_timezone()
def _safe_float(raw: Any, default: float) -> float:
try:
return float(raw)
except (ValueError, TypeError):
return default
def _safe_int(raw: Any, default: int) -> int:
try:
return int(raw)
except (ValueError, TypeError):
return default
def _parse_env_params(raw: Any) -> Dict[str, str]:
if isinstance(raw, dict):
return _normalize_env_params(raw)
if isinstance(raw, str):
try:
parsed = json.loads(raw)
return _normalize_env_params(parsed)
except Exception:
pass
return {}
def _is_valid_top_level_skill_name(name: str) -> bool:
return bool(re.match(r"^[a-zA-Z0-9_-]+$", name))
def _parse_json_string_list(raw: Any) -> List[str]:
if not raw:
return []
if isinstance(raw, list):
return [str(v) for v in raw]
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(v) for v in parsed]
except Exception:
pass
return []
def _is_ignored_skill_zip_top_level(name: str) -> bool:
return name.startswith(".") or name.startswith("__") or name in {"venv", "node_modules"}
def _read_description_from_text(text: str) -> str:
if not text:
return ""
lines = text.strip().split("\n")
for line in lines:
s = line.strip()
if s and not s.startswith("#"):
return s[:200]
return ""
def _sanitize_skill_market_key(key: str) -> str:
s = str(key or "").strip().lower()
s = re.sub(r"[^a-z0-9_-]", "_", s)
return s
def _calc_dir_size_bytes(path: str) -> int:
total = 0
try:
for root, dirs, files in os.walk(path):
for f in files:
fp = os.path.join(root, f)
if not os.path.islink(fp):
total += os.path.getsize(fp)
except Exception:
pass
return total
def _is_image_attachment_path(path: str) -> bool:
ext = (os.path.splitext(path)[1] or "").lower()
return ext in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
def _is_video_attachment_path(path: str) -> bool:
ext = (os.path.splitext(path)[1] or "").lower()
return ext in {".mp4", ".mov", ".avi", ".mkv", ".webm"}
def _is_visual_attachment_path(path: str) -> bool:
return _is_image_attachment_path(path) or _is_video_attachment_path(path)
def _workspace_stat_ctime_iso(stat: os.stat_result) -> str:
ts = getattr(stat, "st_birthtime", None)
if ts is None:
ts = getattr(stat, "st_ctime", None)
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat().replace("+00:00", "Z")
except Exception:
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")

View File

@ -0,0 +1,27 @@
from typing import Any, Dict, List
from fastapi import WebSocket
class WSConnectionManager:
def __init__(self):
self.connections: Dict[str, List[WebSocket]] = {}
async def connect(self, bot_id: str, websocket: WebSocket):
await websocket.accept()
self.connections.setdefault(bot_id, []).append(websocket)
def disconnect(self, bot_id: str, websocket: WebSocket):
conns = self.connections.get(bot_id, [])
if websocket in conns:
conns.remove(websocket)
if not conns and bot_id in self.connections:
del self.connections[bot_id]
async def broadcast(self, bot_id: str, data: Dict[str, Any]):
conns = list(self.connections.get(bot_id, []))
for ws in conns:
try:
await ws.send_json(data)
except Exception:
self.disconnect(bot_id, ws)
manager = WSConnectionManager()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
from typing import Optional, Dict, Any, List
from pydantic import BaseModel
class ChannelConfigRequest(BaseModel):
channel_type: str
external_app_id: Optional[str] = None
app_secret: Optional[str] = None
internal_port: Optional[int] = None
is_active: bool = True
extra_config: Optional[Dict[str, Any]] = None
class ChannelConfigUpdateRequest(BaseModel):
channel_type: Optional[str] = None
external_app_id: Optional[str] = None
app_secret: Optional[str] = None
internal_port: Optional[int] = None
is_active: Optional[bool] = None
extra_config: Optional[Dict[str, Any]] = None
class BotCreateRequest(BaseModel):
id: str
name: str
enabled: Optional[bool] = True
image_tag: str
access_password: Optional[str] = None
llm_provider: str
llm_model: str
api_key: str
api_base: Optional[str] = None
system_prompt: Optional[str] = None
temperature: float = 0.2
top_p: float = 1.0
max_tokens: int = 8192
cpu_cores: float = 1.0
memory_mb: int = 1024
storage_gb: int = 10
system_timezone: Optional[str] = None
soul_md: Optional[str] = None
agents_md: Optional[str] = None
user_md: Optional[str] = None
tools_md: Optional[str] = None
tools_config: Optional[Dict[str, Any]] = None
env_params: Optional[Dict[str, str]] = None
identity_md: Optional[str] = None
channels: Optional[List[ChannelConfigRequest]] = None
send_progress: Optional[bool] = None
send_tool_hints: Optional[bool] = None
class BotUpdateRequest(BaseModel):
name: Optional[str] = None
enabled: Optional[bool] = None
image_tag: Optional[str] = None
access_password: Optional[str] = None
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
max_tokens: Optional[int] = None
cpu_cores: Optional[float] = None
memory_mb: Optional[int] = None
storage_gb: Optional[int] = None
system_timezone: Optional[str] = None
system_prompt: Optional[str] = None
agents_md: Optional[str] = None
soul_md: Optional[str] = None
user_md: Optional[str] = None
tools_md: Optional[str] = None
tools_config: Optional[Dict[str, Any]] = None
env_params: Optional[Dict[str, str]] = None
identity_md: Optional[str] = None
send_progress: Optional[bool] = None
send_tool_hints: Optional[bool] = None
class BotToolsConfigUpdateRequest(BaseModel):
tools_config: Optional[Dict[str, Any]] = None
class BotMcpConfigUpdateRequest(BaseModel):
mcp_servers: Optional[Dict[str, Any]] = None
class BotEnvParamsUpdateRequest(BaseModel):
env_params: Optional[Dict[str, str]] = None
class BotPageAuthLoginRequest(BaseModel):
password: str
class CommandRequest(BaseModel):
command: Optional[str] = None
attachments: Optional[List[str]] = None
class MessageFeedbackRequest(BaseModel):
feedback: Optional[str] = None

View File

@ -0,0 +1,23 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
class WorkspaceFileUpdateRequest(BaseModel):
content: str
class PanelLoginRequest(BaseModel):
password: str
class AgentMdTemplatesPayload(BaseModel):
agents_md: Optional[str] = None
soul_md: Optional[str] = None
user_md: Optional[str] = None
tools_md: Optional[str] = None
identity_md: Optional[str] = None
class SystemTemplatesUpdateRequest(BaseModel):
agent_md_templates: Optional[AgentMdTemplatesPayload] = None
topic_presets: Optional[Dict[str, Any]] = None

View File

@ -0,0 +1,366 @@
from pathlib import Path
from typing import Any, Dict, List, Optional
from sqlmodel import Session
from core.config_manager import BotConfigManager
from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance
from schemas.bot import ChannelConfigRequest
from services.bot_storage_service import (
_normalize_resource_limits,
_read_bot_config,
_write_bot_resources,
)
from services.template_service import get_agent_md_templates
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
def _normalize_channel_extra(raw: Any) -> Dict[str, Any]:
if not isinstance(raw, dict):
return {}
return raw
def _normalize_allow_from(raw: Any) -> List[str]:
rows: List[str] = []
if isinstance(raw, list):
for item in raw:
text = str(item or "").strip()
if text and text not in rows:
rows.append(text)
return rows or ["*"]
def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
if not isinstance(channels_cfg, dict):
return False, False
send_progress = channels_cfg.get("sendProgress")
send_tool_hints = channels_cfg.get("sendToolHints")
dashboard_cfg = channels_cfg.get("dashboard")
if isinstance(dashboard_cfg, dict):
if send_progress is None and "sendProgress" in dashboard_cfg:
send_progress = dashboard_cfg.get("sendProgress")
if send_tool_hints is None and "sendToolHints" in dashboard_cfg:
send_tool_hints = dashboard_cfg.get("sendToolHints")
return bool(send_progress), bool(send_tool_hints)
def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(ctype or "").strip().lower()
enabled = bool(cfg.get("enabled", True))
port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535))
extra: Dict[str, Any] = {}
external_app_id = ""
app_secret = ""
if ctype == "feishu":
external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("appSecret") or "")
extra = {
"encryptKey": cfg.get("encryptKey", ""),
"verificationToken": cfg.get("verificationToken", ""),
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
}
elif ctype == "dingtalk":
external_app_id = str(cfg.get("clientId") or "")
app_secret = str(cfg.get("clientSecret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
elif ctype == "telegram":
app_secret = str(cfg.get("token") or "")
extra = {
"proxy": cfg.get("proxy", ""),
"replyToMessage": bool(cfg.get("replyToMessage", False)),
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
}
elif ctype == "slack":
external_app_id = str(cfg.get("botToken") or "")
app_secret = str(cfg.get("appToken") or "")
extra = {
"mode": cfg.get("mode", "socket"),
"replyInThread": bool(cfg.get("replyInThread", True)),
"groupPolicy": cfg.get("groupPolicy", "mention"),
"groupAllowFrom": cfg.get("groupAllowFrom", []),
"reactEmoji": cfg.get("reactEmoji", "eyes"),
}
elif ctype == "qq":
external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
elif ctype == "weixin":
app_secret = ""
extra = {
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
}
elif ctype == "email":
extra = {
"consentGranted": bool(cfg.get("consentGranted", False)),
"imapHost": str(cfg.get("imapHost") or ""),
"imapPort": int(cfg.get("imapPort") or 993),
"imapUsername": str(cfg.get("imapUsername") or ""),
"imapPassword": str(cfg.get("imapPassword") or ""),
"imapMailbox": str(cfg.get("imapMailbox") or "INBOX"),
"imapUseSsl": bool(cfg.get("imapUseSsl", True)),
"smtpHost": str(cfg.get("smtpHost") or ""),
"smtpPort": int(cfg.get("smtpPort") or 587),
"smtpUsername": str(cfg.get("smtpUsername") or ""),
"smtpPassword": str(cfg.get("smtpPassword") or ""),
"smtpUseTls": bool(cfg.get("smtpUseTls", True)),
"smtpUseSsl": bool(cfg.get("smtpUseSsl", False)),
"fromAddress": str(cfg.get("fromAddress") or ""),
"autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)),
"pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30),
"markSeen": bool(cfg.get("markSeen", True)),
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
}
else:
external_app_id = str(cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "")
app_secret = str(cfg.get("appSecret") or cfg.get("clientSecret") or cfg.get("secret") or cfg.get("token") or cfg.get("appToken") or "")
extra = {
key: value
for key, value in cfg.items()
if key not in {"enabled", "port", "appId", "clientId", "botToken", "externalAppId", "appSecret", "clientSecret", "secret", "token", "appToken"}
}
return {
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": external_app_id,
"app_secret": app_secret,
"internal_port": port,
"is_active": enabled,
"extra_config": extra,
"locked": ctype == "dashboard",
}
def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(row.get("channel_type") or "").strip().lower()
enabled = bool(row.get("is_active", True))
extra = _normalize_channel_extra(row.get("extra_config"))
external_app_id = str(row.get("external_app_id") or "")
app_secret = str(row.get("app_secret") or "")
port = max(1, min(int(row.get("internal_port") or 8080), 65535))
if ctype == "feishu":
return {
"enabled": enabled,
"appId": external_app_id,
"appSecret": app_secret,
"encryptKey": extra.get("encryptKey", ""),
"verificationToken": extra.get("verificationToken", ""),
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "dingtalk":
return {
"enabled": enabled,
"clientId": external_app_id,
"clientSecret": app_secret,
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "telegram":
return {
"enabled": enabled,
"token": app_secret,
"proxy": extra.get("proxy", ""),
"replyToMessage": bool(extra.get("replyToMessage", False)),
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "slack":
return {
"enabled": enabled,
"mode": extra.get("mode", "socket"),
"botToken": external_app_id,
"appToken": app_secret,
"replyInThread": bool(extra.get("replyInThread", True)),
"groupPolicy": extra.get("groupPolicy", "mention"),
"groupAllowFrom": extra.get("groupAllowFrom", []),
"reactEmoji": extra.get("reactEmoji", "eyes"),
}
if ctype == "qq":
return {
"enabled": enabled,
"appId": external_app_id,
"secret": app_secret,
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
}
if ctype == "weixin":
return {
"enabled": enabled,
"token": app_secret,
}
if ctype == "email":
return {
"enabled": enabled,
"consentGranted": bool(extra.get("consentGranted", False)),
"imapHost": str(extra.get("imapHost") or ""),
"imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)),
"imapUsername": str(extra.get("imapUsername") or ""),
"imapPassword": str(extra.get("imapPassword") or ""),
"imapMailbox": str(extra.get("imapMailbox") or "INBOX"),
"imapUseSsl": bool(extra.get("imapUseSsl", True)),
"smtpHost": str(extra.get("smtpHost") or ""),
"smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)),
"smtpUsername": str(extra.get("smtpUsername") or ""),
"smtpPassword": str(extra.get("smtpPassword") or ""),
"smtpUseTls": bool(extra.get("smtpUseTls", True)),
"smtpUseSsl": bool(extra.get("smtpUseSsl", False)),
"fromAddress": str(extra.get("fromAddress") or ""),
"autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)),
"pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)),
"markSeen": bool(extra.get("markSeen", True)),
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
}
merged = dict(extra)
merged.update(
{
"enabled": enabled,
"appId": external_app_id,
"appSecret": app_secret,
"port": port,
}
)
return merged
def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
config_data = _read_bot_config(bot.id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg)
rows: List[Dict[str, Any]] = [
{
"id": "dashboard",
"bot_id": bot.id,
"channel_type": "dashboard",
"external_app_id": f"dashboard-{bot.id}",
"app_secret": "",
"internal_port": 9000,
"is_active": True,
"extra_config": {
"sendProgress": send_progress,
"sendToolHints": send_tool_hints,
},
"locked": True,
}
]
for ctype, cfg in channels_cfg.items():
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
continue
rows.append(_channel_cfg_to_api_dict(bot.id, ctype, cfg))
return rows
def _normalize_initial_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
seen_types: set[str] = set()
for channel in channels or []:
ctype = (channel.channel_type or "").strip().lower()
if not ctype or ctype == "dashboard" or ctype in seen_types:
continue
seen_types.add(ctype)
rows.append(
{
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": (channel.external_app_id or "").strip() or f"{ctype}-{bot_id}",
"app_secret": (channel.app_secret or "").strip(),
"internal_port": max(1, min(int(channel.internal_port or 8080), 65535)),
"is_active": bool(channel.is_active),
"extra_config": _normalize_channel_extra(channel.extra_config),
"locked": False,
}
)
return rows
def _sync_workspace_channels(
session: Session,
bot_id: str,
snapshot: Dict[str, Any],
*,
channels_override: Optional[List[Dict[str, Any]]] = None,
global_delivery_override: Optional[Dict[str, Any]] = None,
runtime_overrides: Optional[Dict[str, Any]] = None,
) -> None:
bot = session.get(BotInstance, bot_id)
if not bot:
return
template_defaults = get_agent_md_templates()
bot_data: Dict[str, Any] = {
"name": bot.name,
"system_prompt": snapshot.get("system_prompt") or template_defaults.get("soul_md", ""),
"soul_md": snapshot.get("soul_md") or template_defaults.get("soul_md", ""),
"agents_md": snapshot.get("agents_md") or template_defaults.get("agents_md", ""),
"user_md": snapshot.get("user_md") or template_defaults.get("user_md", ""),
"tools_md": snapshot.get("tools_md") or template_defaults.get("tools_md", ""),
"identity_md": snapshot.get("identity_md") or template_defaults.get("identity_md", ""),
"llm_provider": snapshot.get("llm_provider") or "",
"llm_model": snapshot.get("llm_model") or "",
"api_key": snapshot.get("api_key") or "",
"api_base": snapshot.get("api_base") or "",
"temperature": snapshot.get("temperature"),
"top_p": snapshot.get("top_p"),
"max_tokens": snapshot.get("max_tokens"),
"cpu_cores": snapshot.get("cpu_cores"),
"memory_mb": snapshot.get("memory_mb"),
"storage_gb": snapshot.get("storage_gb"),
"send_progress": bool(snapshot.get("send_progress")),
"send_tool_hints": bool(snapshot.get("send_tool_hints")),
}
if isinstance(runtime_overrides, dict):
for key, value in runtime_overrides.items():
if key in {"api_key", "llm_provider", "llm_model"}:
text = str(value or "").strip()
if not text:
continue
bot_data[key] = text
continue
if key == "api_base":
bot_data[key] = str(value or "").strip()
continue
bot_data[key] = value
resources = _normalize_resource_limits(
bot_data.get("cpu_cores"),
bot_data.get("memory_mb"),
bot_data.get("storage_gb"),
)
bot_data.update(resources)
send_progress = bool(bot_data.get("send_progress", False))
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
if isinstance(global_delivery_override, dict):
if "sendProgress" in global_delivery_override:
send_progress = bool(global_delivery_override.get("sendProgress"))
if "sendToolHints" in global_delivery_override:
send_tool_hints = bool(global_delivery_override.get("sendToolHints"))
channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot)
bot_data["send_progress"] = send_progress
bot_data["send_tool_hints"] = send_tool_hints
normalized_channels: List[Dict[str, Any]] = []
for row in channels_data:
ctype = str(row.get("channel_type") or "").strip().lower()
if not ctype or ctype == "dashboard":
continue
normalized_channels.append(
{
"channel_type": ctype,
"external_app_id": str(row.get("external_app_id") or ""),
"app_secret": str(row.get("app_secret") or ""),
"internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)),
"is_active": bool(row.get("is_active", True)),
"extra_config": _normalize_channel_extra(row.get("extra_config")),
}
)
config_manager.update_workspace(bot_id=bot_id, bot_data=bot_data, channels=normalized_channels)
_write_bot_resources(bot_id, bot_data.get("cpu_cores"), bot_data.get("memory_mb"), bot_data.get("storage_gb"))

View File

@ -0,0 +1,324 @@
from datetime import datetime
from typing import Any, Dict
from fastapi import HTTPException
from sqlmodel import Session
from core.docker_instance import docker_manager
from core.utils import _calc_dir_size_bytes
from models.bot import BotInstance
from schemas.bot import (
BotEnvParamsUpdateRequest,
BotMcpConfigUpdateRequest,
ChannelConfigRequest,
ChannelConfigUpdateRequest,
)
from services.bot_channel_service import (
_channel_api_to_cfg,
_get_bot_channels_from_config,
_normalize_channel_extra,
_read_global_delivery_flags,
_sync_workspace_channels,
)
from services.bot_mcp_service import (
_merge_mcp_servers_preserving_extras,
_normalize_mcp_servers,
)
from services.bot_storage_service import (
_normalize_env_params,
_read_bot_config,
_read_bot_resources,
_read_env_store,
_workspace_root,
_write_bot_config,
_write_env_store,
)
from services.cache_service import _invalidate_bot_detail_cache
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 get_bot_resources_snapshot(session: Session, *, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
configured = _read_bot_resources(bot_id)
runtime = docker_manager.get_bot_resource_snapshot(bot_id)
workspace_root = _workspace_root(bot_id)
workspace_bytes = _calc_dir_size_bytes(workspace_root)
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024
workspace_percent = 0.0
if configured_storage_bytes > 0:
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0
limits = runtime.get("limits") or {}
cpu_limited = (limits.get("cpu_cores") or 0) > 0
memory_limited = (limits.get("memory_bytes") or 0) > 0
storage_limited = bool(limits.get("storage_bytes")) or bool(limits.get("storage_opt_raw"))
return {
"bot_id": bot_id,
"docker_status": runtime.get("docker_status") or bot.docker_status,
"configured": configured,
"runtime": runtime,
"workspace": {
"path": workspace_root,
"usage_bytes": workspace_bytes,
"configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None,
"usage_percent": max(0.0, workspace_percent),
},
"enforcement": {
"cpu_limited": cpu_limited,
"memory_limited": memory_limited,
"storage_limited": storage_limited,
},
"note": (
"Resource value 0 means unlimited. CPU/Memory limits come from Docker HostConfig and are enforced by cgroup. "
"Storage limit depends on Docker storage driver support."
),
"collected_at": datetime.utcnow().isoformat() + "Z",
}
def list_bot_channels_config(session: Session, *, bot_id: str):
bot = _get_bot_or_404(session, bot_id)
return _get_bot_channels_from_config(bot)
def get_bot_tools_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
return {
"bot_id": bot_id,
"tools_config": {},
"managed_by_dashboard": False,
"hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.",
}
def reject_bot_tools_config_update(
session: Session,
*,
bot_id: str,
payload: Any,
) -> None:
_get_bot_or_404(session, bot_id)
raise HTTPException(
status_code=400,
detail="Tools config is no longer managed by dashboard. Please set required env vars manually.",
)
def get_bot_mcp_config_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id)
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
if not isinstance(tools_cfg, dict):
tools_cfg = {}
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
return {
"bot_id": bot_id,
"mcp_servers": mcp_servers,
"locked_servers": [],
"restart_required": True,
}
def update_bot_mcp_config_state(
session: Session,
*,
bot_id: str,
payload: BotMcpConfigUpdateRequest,
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
config_data = _read_bot_config(bot_id)
if not isinstance(config_data, dict):
config_data = {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
normalized_mcp_servers = _normalize_mcp_servers(payload.mcp_servers or {})
current_mcp_servers = tools_cfg.get("mcpServers")
merged_mcp_servers = _merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers)
tools_cfg["mcpServers"] = merged_mcp_servers
config_data["tools"] = tools_cfg
sanitized_after_save = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
_write_bot_config(bot_id, config_data)
_invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"mcp_servers": sanitized_after_save,
"locked_servers": [],
"restart_required": True,
}
def get_bot_env_params_state(session: Session, *, bot_id: str) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
return {
"bot_id": bot_id,
"env_params": _read_env_store(bot_id),
}
def update_bot_env_params_state(
session: Session,
*,
bot_id: str,
payload: BotEnvParamsUpdateRequest,
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
normalized = _normalize_env_params(payload.env_params)
_write_env_store(bot_id, normalized)
_invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"env_params": normalized,
"restart_required": True,
}
def create_bot_channel_config(
session: Session,
*,
bot_id: str,
payload: ChannelConfigRequest,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
ctype = (payload.channel_type or "").strip().lower()
if not ctype:
raise HTTPException(status_code=400, detail="channel_type is required")
if ctype == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually")
current_rows = _get_bot_channels_from_config(bot)
if any(str(row.get("channel_type") or "").lower() == ctype for row in current_rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {ctype}")
new_row = {
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": (payload.external_app_id or "").strip() or f"{ctype}-{bot_id}",
"app_secret": (payload.app_secret or "").strip(),
"internal_port": max(1, min(int(payload.internal_port or 8080), 65535)),
"is_active": bool(payload.is_active),
"extra_config": _normalize_channel_extra(payload.extra_config),
"locked": False,
}
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg[ctype] = _channel_api_to_cfg(new_row)
_write_bot_config(bot_id, config_data)
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return new_row
def update_bot_channel_config(
session: Session,
*,
bot_id: str,
channel_id: str,
payload: ChannelConfigUpdateRequest,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = _get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
update_data = payload.model_dump(exclude_unset=True)
existing_type = str(row.get("channel_type") or "").strip().lower()
new_type = existing_type
if "channel_type" in update_data and update_data["channel_type"] is not None:
new_type = str(update_data["channel_type"]).strip().lower()
if not new_type:
raise HTTPException(status_code=400, detail="channel_type cannot be empty")
if existing_type == "dashboard" and new_type != "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed")
if new_type != existing_type and any(str(r.get("channel_type") or "").lower() == new_type for r in rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {new_type}")
if "external_app_id" in update_data and update_data["external_app_id"] is not None:
row["external_app_id"] = str(update_data["external_app_id"]).strip()
if "app_secret" in update_data and update_data["app_secret"] is not None:
row["app_secret"] = str(update_data["app_secret"]).strip()
if "internal_port" in update_data and update_data["internal_port"] is not None:
row["internal_port"] = max(1, min(int(update_data["internal_port"]), 65535))
if "is_active" in update_data and update_data["is_active"] is not None:
next_active = bool(update_data["is_active"])
if existing_type == "dashboard" and not next_active:
raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
row["is_active"] = next_active
if "extra_config" in update_data:
row["extra_config"] = _normalize_channel_extra(update_data.get("extra_config"))
row["channel_type"] = new_type
row["id"] = new_type
row["locked"] = new_type == "dashboard"
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
current_send_progress, current_send_tool_hints = _read_global_delivery_flags(channels_cfg)
if new_type == "dashboard":
extra = _normalize_channel_extra(row.get("extra_config"))
channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
else:
channels_cfg["sendProgress"] = current_send_progress
channels_cfg["sendToolHints"] = current_send_tool_hints
channels_cfg.pop("dashboard", None)
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
channels_cfg.pop(existing_type, None)
if new_type != "dashboard":
channels_cfg[new_type] = _channel_api_to_cfg(row)
_write_bot_config(bot_id, config_data)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return row
def delete_bot_channel_config(
session: Session,
*,
bot_id: str,
channel_id: str,
) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = _get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").lower() == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
_write_bot_config(bot_id, config_data)
session.commit()
_sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return {"status": "deleted"}

View File

@ -0,0 +1,159 @@
import asyncio
import os
import shutil
from typing import Any, Dict
from sqlmodel import Session, select
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance, BotMessage
from models.platform import BotActivityEvent, BotRequestUsage
from models.skill import BotSkillInstall
from models.topic import TopicItem, TopicTopic
from services.bot_service import (
_read_bot_runtime_snapshot,
_resolve_bot_env_params,
_safe_float,
_safe_int,
_sync_workspace_channels,
)
from services.bot_storage_service import _write_env_store
from services.cache_service import _invalidate_bot_detail_cache, _invalidate_bot_messages_cache
from services.platform_service import record_activity_event
from services.runtime_service import _record_agent_loop_ready_warning, docker_callback
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise ValueError("Bot not found")
return bot
async def start_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
if not bool(getattr(bot, "enabled", True)):
raise PermissionError("Bot is disabled. Enable it first.")
_sync_workspace_channels(session, bot_id)
runtime_snapshot = _read_bot_runtime_snapshot(bot)
env_params = _resolve_bot_env_params(bot_id)
_write_env_store(bot_id, env_params)
success = docker_manager.start_bot(
bot_id,
image_tag=bot.image_tag,
on_state_change=docker_callback,
env_vars=env_params,
cpu_cores=_safe_float(runtime_snapshot.get("cpu_cores"), 1.0),
memory_mb=_safe_int(runtime_snapshot.get("memory_mb"), 1024),
storage_gb=_safe_int(runtime_snapshot.get("storage_gb"), 10),
)
if not success:
bot.docker_status = "STOPPED"
session.add(bot)
session.commit()
raise RuntimeError(f"Failed to start container with image {bot.image_tag}")
actual_status = docker_manager.get_bot_status(bot_id)
bot.docker_status = actual_status
if actual_status != "RUNNING":
session.add(bot)
session.commit()
_invalidate_bot_detail_cache(bot_id)
raise RuntimeError("Bot container failed shortly after startup. Check bot logs/config.")
asyncio.create_task(_record_agent_loop_ready_warning(bot_id))
session.add(bot)
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "started"}
def stop_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
if not bool(getattr(bot, "enabled", True)):
raise PermissionError("Bot is disabled. Enable it first.")
docker_manager.stop_bot(bot_id)
bot.docker_status = "STOPPED"
session.add(bot)
record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}")
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "stopped"}
def enable_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
bot.enabled = True
session.add(bot)
record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled")
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "enabled", "enabled": True}
def disable_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
docker_manager.stop_bot(bot_id)
bot.enabled = False
bot.docker_status = "STOPPED"
if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
session.add(bot)
record_activity_event(session, bot_id, "bot_disabled", channel="system", detail=f"Bot {bot_id} disabled")
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "disabled", "enabled": False}
def deactivate_bot_instance(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
docker_manager.stop_bot(bot_id)
bot.enabled = False
bot.docker_status = "STOPPED"
if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
session.add(bot)
record_activity_event(session, bot_id, "bot_deactivated", channel="system", detail=f"Bot {bot_id} deactivated")
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "deactivated"}
def delete_bot_instance(session: Session, bot_id: str, delete_workspace: bool = True) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
docker_manager.stop_bot(bot_id)
messages = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()
for row in messages:
session.delete(row)
topic_items = session.exec(select(TopicItem).where(TopicItem.bot_id == bot_id)).all()
for row in topic_items:
session.delete(row)
topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all()
for row in topics:
session.delete(row)
usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all()
for row in usage_rows:
session.delete(row)
activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
for row in activity_rows:
session.delete(row)
skill_install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
for row in skill_install_rows:
session.delete(row)
session.delete(bot)
session.commit()
if delete_workspace:
workspace_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id)
if os.path.isdir(workspace_root):
shutil.rmtree(workspace_root, ignore_errors=True)
_invalidate_bot_detail_cache(bot_id)
_invalidate_bot_messages_cache(bot_id)
return {"status": "deleted", "workspace_deleted": bool(delete_workspace)}

View File

@ -0,0 +1,383 @@
import os
import re
import shutil
from typing import Any, Dict, List, Optional
import httpx
from fastapi import HTTPException
from sqlmodel import Session, select
from core.cache import cache
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance, NanobotImage
from schemas.bot import BotCreateRequest, BotUpdateRequest
from services.bot_service import (
_normalize_env_params,
_normalize_initial_channels,
_normalize_resource_limits,
_normalize_system_timezone,
_provider_defaults,
_resolve_bot_env_params,
_serialize_bot,
_serialize_bot_list_item,
_sync_workspace_channels,
)
from services.bot_storage_service import _write_env_store
from services.cache_service import _cache_key_bot_detail, _cache_key_bots_list, _invalidate_bot_detail_cache
from services.platform_service import record_activity_event
from services.template_service import get_agent_md_templates
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
MANAGED_WORKSPACE_FILENAMES = ("AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md")
def _managed_bot_file_paths(bot_id: str) -> Dict[str, str]:
bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")
workspace_root = os.path.join(bot_root, "workspace")
paths = {
"config": os.path.join(bot_root, "config.json"),
"env": os.path.join(bot_root, "env.json"),
"resources": os.path.join(bot_root, "resources.json"),
}
for filename in MANAGED_WORKSPACE_FILENAMES:
paths[f"workspace:{filename}"] = os.path.join(workspace_root, filename)
return paths
def _snapshot_managed_bot_files(bot_id: str) -> Dict[str, Optional[bytes]]:
snapshot: Dict[str, Optional[bytes]] = {}
for key, path in _managed_bot_file_paths(bot_id).items():
if os.path.isfile(path):
with open(path, "rb") as file:
snapshot[key] = file.read()
else:
snapshot[key] = None
return snapshot
def _restore_managed_bot_files(bot_id: str, snapshot: Dict[str, Optional[bytes]]) -> None:
for key, path in _managed_bot_file_paths(bot_id).items():
payload = snapshot.get(key)
if payload is None:
if os.path.exists(path):
os.remove(path)
continue
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "wb") as file:
file.write(payload)
os.replace(tmp_path, path)
def _cleanup_bot_workspace_root(bot_id: str) -> None:
bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id)
if os.path.isdir(bot_root):
shutil.rmtree(bot_root, ignore_errors=True)
async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]:
provider = (payload.get("provider") or "").strip()
api_key = (payload.get("api_key") or "").strip()
model = (payload.get("model") or "").strip()
api_base = (payload.get("api_base") or "").strip()
if not provider or not api_key:
raise HTTPException(status_code=400, detail="provider and api_key are required")
normalized_provider, default_base = _provider_defaults(provider)
base = (api_base or default_base).rstrip("/")
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek"}:
raise HTTPException(status_code=400, detail=f"provider not supported for test: {provider}")
if not base:
raise HTTPException(status_code=400, detail=f"api_base is required for provider: {provider}")
headers = {"Authorization": f"Bearer {api_key}"}
timeout = httpx.Timeout(20.0, connect=10.0)
url = f"{base}/models"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=headers)
if response.status_code >= 400:
return {
"ok": False,
"provider": normalized_provider,
"status_code": response.status_code,
"detail": response.text[:500],
}
data = response.json()
models_raw = data.get("data", []) if isinstance(data, dict) else []
model_ids: List[str] = [
str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id")
]
return {
"ok": True,
"provider": normalized_provider,
"endpoint": url,
"models_preview": model_ids[:8],
"model_hint": (
"model_found"
if model and any(model in item for item in model_ids)
else ("model_not_listed" if model else "")
),
}
except Exception as exc:
return {
"ok": False,
"provider": normalized_provider,
"endpoint": url,
"detail": str(exc),
}
def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[str, Any]:
normalized_bot_id = str(payload.id or "").strip()
if not normalized_bot_id:
raise HTTPException(status_code=400, detail="Bot ID is required")
if not BOT_ID_PATTERN.fullmatch(normalized_bot_id):
raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores")
if session.get(BotInstance, normalized_bot_id):
raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}")
image_row = session.get(NanobotImage, payload.image_tag)
if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}")
if image_row.status != "READY":
raise HTTPException(status_code=400, detail=f"Image status is not READY: {payload.image_tag} ({image_row.status})")
if not docker_manager.has_image(payload.image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}")
normalized_env_params = _normalize_env_params(payload.env_params)
try:
normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
bot = BotInstance(
id=normalized_bot_id,
name=payload.name,
enabled=bool(payload.enabled) if payload.enabled is not None else True,
access_password=str(payload.access_password or ""),
image_tag=payload.image_tag,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
)
template_defaults = get_agent_md_templates()
resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
try:
session.add(bot)
session.flush()
_write_env_store(normalized_bot_id, normalized_env_params)
_sync_workspace_channels(
session,
normalized_bot_id,
channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels),
global_delivery_override={
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False,
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
},
runtime_overrides={
"llm_provider": payload.llm_provider,
"llm_model": payload.llm_model,
"api_key": payload.api_key,
"api_base": payload.api_base or "",
"temperature": payload.temperature,
"top_p": payload.top_p,
"max_tokens": payload.max_tokens,
"cpu_cores": resource_limits["cpu_cores"],
"memory_mb": resource_limits["memory_mb"],
"storage_gb": resource_limits["storage_gb"],
"system_prompt": payload.system_prompt or payload.soul_md or template_defaults.get("soul_md", ""),
"soul_md": payload.soul_md or payload.system_prompt or template_defaults.get("soul_md", ""),
"agents_md": payload.agents_md or template_defaults.get("agents_md", ""),
"user_md": payload.user_md or template_defaults.get("user_md", ""),
"tools_md": payload.tools_md or template_defaults.get("tools_md", ""),
"identity_md": payload.identity_md or template_defaults.get("identity_md", ""),
"send_progress": bool(payload.send_progress) if payload.send_progress is not None else False,
"send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
},
)
record_activity_event(
session,
normalized_bot_id,
"bot_created",
channel="system",
detail=f"Bot {normalized_bot_id} created",
metadata={"image_tag": payload.image_tag},
)
session.commit()
session.refresh(bot)
except Exception:
session.rollback()
_cleanup_bot_workspace_root(normalized_bot_id)
raise
_invalidate_bot_detail_cache(normalized_bot_id)
return _serialize_bot(bot)
def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]:
cached = cache.get_json(_cache_key_bots_list())
if isinstance(cached, list):
return cached
bots = session.exec(select(BotInstance)).all()
dirty = False
for bot in bots:
actual_status = docker_manager.get_bot_status(bot.id)
if bot.docker_status != actual_status:
bot.docker_status = actual_status
if actual_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
session.add(bot)
dirty = True
if dirty:
session.commit()
for bot in bots:
session.refresh(bot)
rows = [_serialize_bot_list_item(bot) for bot in bots]
cache.set_json(_cache_key_bots_list(), rows, ttl=30)
return rows
def get_bot_detail_cached(session: Session, *, bot_id: str) -> Dict[str, Any]:
cached = cache.get_json(_cache_key_bot_detail(bot_id))
if isinstance(cached, dict):
return cached
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
row = _serialize_bot(bot)
cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30)
return row
def authenticate_bot_page_access(session: Session, *, bot_id: str, password: str) -> Dict[str, Any]:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
configured = str(bot.access_password or "").strip()
if not configured:
return {"ok": True, "enabled": False, "bot_id": bot_id}
candidate = str(password or "").strip()
if not candidate:
raise HTTPException(status_code=401, detail="Bot access password required")
if candidate != configured:
raise HTTPException(status_code=401, detail="Invalid bot access password")
return {"ok": True, "enabled": True, "bot_id": bot_id}
def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateRequest) -> Dict[str, Any]:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
managed_file_snapshot = _snapshot_managed_bot_files(bot_id)
update_data = payload.model_dump(exclude_unset=True)
if "image_tag" in update_data and update_data["image_tag"]:
image_tag = str(update_data["image_tag"]).strip()
image_row = session.get(NanobotImage, image_tag)
if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {image_tag}")
if image_row.status != "READY":
raise HTTPException(status_code=400, detail=f"Image status is not READY: {image_tag} ({image_row.status})")
if not docker_manager.has_image(image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}")
env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None
normalized_system_timezone: Optional[str] = None
if system_timezone is not None:
try:
normalized_system_timezone = _normalize_system_timezone(system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
runtime_fields = {
"llm_provider",
"llm_model",
"api_key",
"api_base",
"temperature",
"top_p",
"max_tokens",
"cpu_cores",
"memory_mb",
"storage_gb",
"soul_md",
"agents_md",
"user_md",
"tools_md",
"identity_md",
"send_progress",
"send_tool_hints",
"system_prompt",
}
runtime_overrides: Dict[str, Any] = {}
update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
for field in runtime_fields:
if field in update_data:
runtime_overrides[field] = update_data.pop(field)
for text_field in ("llm_provider", "llm_model", "api_key"):
if text_field in runtime_overrides:
text = str(runtime_overrides.get(text_field) or "").strip()
if not text:
runtime_overrides.pop(text_field, None)
else:
runtime_overrides[text_field] = text
if "api_base" in runtime_overrides:
runtime_overrides["api_base"] = str(runtime_overrides.get("api_base") or "").strip()
if "system_prompt" in runtime_overrides and "soul_md" not in runtime_overrides:
runtime_overrides["soul_md"] = runtime_overrides["system_prompt"]
if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides:
runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
runtime_overrides.update(
_normalize_resource_limits(
runtime_overrides.get("cpu_cores"),
runtime_overrides.get("memory_mb"),
runtime_overrides.get("storage_gb"),
)
)
for key, value in update_data.items():
if key in {"name", "image_tag", "access_password", "enabled"}:
setattr(bot, key, value)
try:
session.add(bot)
session.flush()
if env_params is not None or normalized_system_timezone is not None:
next_env_params = _resolve_bot_env_params(bot_id)
if env_params is not None:
next_env_params = _normalize_env_params(env_params)
if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone
_write_env_store(bot_id, next_env_params)
global_delivery_override: Optional[Dict[str, Any]] = None
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
global_delivery_override = {}
if "send_progress" in runtime_overrides:
global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress"))
if "send_tool_hints" in runtime_overrides:
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
_sync_workspace_channels(
session,
bot_id,
runtime_overrides=runtime_overrides if runtime_overrides else None,
global_delivery_override=global_delivery_override,
)
session.commit()
session.refresh(bot)
except Exception:
session.rollback()
_restore_managed_bot_files(bot_id, managed_file_snapshot)
refreshed_bot = session.get(BotInstance, bot_id)
if refreshed_bot:
session.refresh(refreshed_bot)
bot = refreshed_bot
raise
_invalidate_bot_detail_cache(bot_id)
return _serialize_bot(bot)

View File

@ -0,0 +1,71 @@
import re
from typing import Any, Dict
_MCP_SERVER_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
def _normalize_mcp_servers(raw: Any) -> Dict[str, Dict[str, Any]]:
if not isinstance(raw, dict):
return {}
rows: Dict[str, Dict[str, Any]] = {}
for server_name, server_cfg in raw.items():
name = str(server_name or "").strip()
if not name or not _MCP_SERVER_NAME_RE.fullmatch(name):
continue
if not isinstance(server_cfg, dict):
continue
url = str(server_cfg.get("url") or "").strip()
if not url:
continue
transport_type = str(server_cfg.get("type") or "streamableHttp").strip()
if transport_type not in {"streamableHttp", "sse"}:
transport_type = "streamableHttp"
headers_raw = server_cfg.get("headers")
headers: Dict[str, str] = {}
if isinstance(headers_raw, dict):
for key, value in headers_raw.items():
header_key = str(key or "").strip()
if not header_key:
continue
headers[header_key] = str(value or "").strip()
timeout_raw = server_cfg.get("toolTimeout", 60)
try:
timeout = int(timeout_raw)
except Exception:
timeout = 60
rows[name] = {
"type": transport_type,
"url": url,
"headers": headers,
"toolTimeout": max(1, min(timeout, 600)),
}
return rows
def _merge_mcp_servers_preserving_extras(
current_raw: Any,
normalized: Dict[str, Dict[str, Any]],
) -> Dict[str, Dict[str, Any]]:
current_map = current_raw if isinstance(current_raw, dict) else {}
merged: Dict[str, Dict[str, Any]] = {}
for name, normalized_cfg in normalized.items():
base = current_map.get(name)
base_cfg = dict(base) if isinstance(base, dict) else {}
next_cfg = dict(base_cfg)
next_cfg.update(normalized_cfg)
merged[name] = next_cfg
return merged
def _sanitize_mcp_servers_in_config_data(config_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
if not isinstance(config_data, dict):
return {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
current_raw = tools_cfg.get("mcpServers")
normalized = _normalize_mcp_servers(current_raw)
merged = _merge_mcp_servers_preserving_extras(current_raw, normalized)
tools_cfg["mcpServers"] = merged
config_data["tools"] = tools_cfg
return merged

View File

@ -0,0 +1,264 @@
import os
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from sqlmodel import Session
from core.settings import DEFAULT_BOT_SYSTEM_TIMEZONE
from models.bot import BotInstance
from services.bot_storage_service import (
_bot_data_root,
_clear_bot_dashboard_direct_session,
_clear_bot_sessions,
_migrate_bot_resources_store,
_normalize_env_params,
_normalize_resource_limits,
_read_bot_config,
_read_bot_resources,
_read_cron_store,
_read_env_store,
_safe_float,
_safe_int,
_workspace_root,
_write_bot_config,
_write_bot_resources,
_write_cron_store,
_write_env_store,
)
from services.bot_channel_service import (
_channel_api_to_cfg,
_get_bot_channels_from_config,
_normalize_channel_extra,
_normalize_initial_channels,
_read_global_delivery_flags,
_sync_workspace_channels as _sync_workspace_channels_impl,
)
from services.bot_mcp_service import (
_merge_mcp_servers_preserving_extras,
_normalize_mcp_servers,
_sanitize_mcp_servers_in_config_data,
)
from services.template_service import get_agent_md_templates
__all__ = [
"_bot_data_root",
"_channel_api_to_cfg",
"_clear_bot_dashboard_direct_session",
"_clear_bot_sessions",
"_get_bot_channels_from_config",
"_migrate_bot_resources_store",
"_normalize_channel_extra",
"_normalize_env_params",
"_normalize_initial_channels",
"_normalize_mcp_servers",
"_normalize_resource_limits",
"_normalize_system_timezone",
"_provider_defaults",
"_read_bot_config",
"_read_bot_resources",
"_read_bot_runtime_snapshot",
"_read_cron_store",
"_read_env_store",
"_read_global_delivery_flags",
"_resolve_bot_env_params",
"_safe_float",
"_safe_int",
"_sanitize_mcp_servers_in_config_data",
"_serialize_bot",
"_serialize_bot_list_item",
"_sync_workspace_channels",
"_workspace_root",
"_write_bot_config",
"_write_bot_resources",
"_write_cron_store",
"_write_env_store",
"_merge_mcp_servers_preserving_extras",
]
def _get_default_system_timezone() -> str:
value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai"
try:
ZoneInfo(value)
return value
except Exception:
return "Asia/Shanghai"
def _normalize_system_timezone(raw: Any) -> str:
value = str(raw or "").strip()
if not value:
return _get_default_system_timezone()
try:
ZoneInfo(value)
except Exception as exc:
raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc
return value
def _resolve_bot_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id))
try:
env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ"))
except ValueError:
env_params["TZ"] = _get_default_system_timezone()
return env_params
def _provider_defaults(provider: str) -> tuple[str, str]:
normalized = provider.lower().strip()
if normalized in {"openai"}:
return "openai", "https://api.openai.com/v1"
if normalized in {"openrouter"}:
return "openrouter", "https://openrouter.ai/api/v1"
if normalized in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}:
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
if normalized in {"deepseek"}:
return "deepseek", "https://api.deepseek.com/v1"
if normalized in {"xunfei", "iflytek", "xfyun"}:
return "openai", "https://spark-api-open.xf-yun.com/v1"
if normalized in {"vllm"}:
return "openai", ""
if normalized in {"kimi", "moonshot"}:
return "kimi", "https://api.moonshot.cn/v1"
if normalized in {"minimax"}:
return "minimax", "https://api.minimax.chat/v1"
return normalized, ""
def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str:
path = os.path.join(_workspace_root(bot_id), filename)
if not os.path.isfile(path):
return default_value
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
return default_value
def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
config_data = _read_bot_config(bot.id)
env_params = _resolve_bot_env_params(bot.id)
template_defaults = get_agent_md_templates()
provider_name = ""
provider_cfg: Dict[str, Any] = {}
providers_cfg = config_data.get("providers")
if isinstance(providers_cfg, dict):
for p_name, p_cfg in providers_cfg.items():
provider_name = str(p_name or "").strip()
if isinstance(p_cfg, dict):
provider_cfg = p_cfg
break
agents_defaults: Dict[str, Any] = {}
agents_cfg = config_data.get("agents")
if isinstance(agents_cfg, dict):
defaults = agents_cfg.get("defaults")
if isinstance(defaults, dict):
agents_defaults = defaults
channels_cfg = config_data.get("channels")
send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg)
llm_provider = provider_name or ""
llm_model = str(agents_defaults.get("model") or "")
api_key = str(provider_cfg.get("apiKey") or "").strip()
api_base = str(provider_cfg.get("apiBase") or "").strip()
api_base_lower = api_base.lower()
provider_alias = str(provider_cfg.get("dashboardProviderAlias") or "").strip().lower()
if llm_provider == "openai" and provider_alias in {"xunfei", "iflytek", "xfyun", "vllm"}:
llm_provider = "xunfei" if provider_alias in {"iflytek", "xfyun"} else provider_alias
elif llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
llm_provider = "xunfei"
soul_md = _read_workspace_md(bot.id, "SOUL.md", template_defaults.get("soul_md", ""))
resources = _read_bot_resources(bot.id, config_data=config_data)
return {
"llm_provider": llm_provider,
"llm_model": llm_model,
"api_key": api_key,
"api_base": api_base,
"temperature": _safe_float(agents_defaults.get("temperature"), 0.2),
"top_p": _safe_float(agents_defaults.get("topP"), 1.0),
"max_tokens": _safe_int(agents_defaults.get("maxTokens"), 8192),
"cpu_cores": resources["cpu_cores"],
"memory_mb": resources["memory_mb"],
"storage_gb": resources["storage_gb"],
"system_timezone": env_params.get("TZ") or _get_default_system_timezone(),
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"soul_md": soul_md,
"agents_md": _read_workspace_md(bot.id, "AGENTS.md", template_defaults.get("agents_md", "")),
"user_md": _read_workspace_md(bot.id, "USER.md", template_defaults.get("user_md", "")),
"tools_md": _read_workspace_md(bot.id, "TOOLS.md", template_defaults.get("tools_md", "")),
"identity_md": _read_workspace_md(bot.id, "IDENTITY.md", template_defaults.get("identity_md", "")),
"system_prompt": soul_md,
}
def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
runtime = _read_bot_runtime_snapshot(bot)
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"access_password": bot.access_password or "",
"has_access_password": bool(str(bot.access_password or "").strip()),
"avatar_model": "base",
"avatar_skin": "blue_suit",
"image_tag": bot.image_tag,
"llm_provider": runtime.get("llm_provider") or "",
"llm_model": runtime.get("llm_model") or "",
"system_prompt": runtime.get("system_prompt") or "",
"api_base": runtime.get("api_base") or "",
"temperature": _safe_float(runtime.get("temperature"), 0.2),
"top_p": _safe_float(runtime.get("top_p"), 1.0),
"max_tokens": _safe_int(runtime.get("max_tokens"), 8192),
"cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0),
"memory_mb": _safe_int(runtime.get("memory_mb"), 1024),
"storage_gb": _safe_int(runtime.get("storage_gb"), 10),
"system_timezone": str(runtime.get("system_timezone") or _get_default_system_timezone()),
"send_progress": bool(runtime.get("send_progress")),
"send_tool_hints": bool(runtime.get("send_tool_hints")),
"soul_md": runtime.get("soul_md") or "",
"agents_md": runtime.get("agents_md") or "",
"user_md": runtime.get("user_md") or "",
"tools_md": runtime.get("tools_md") or "",
"identity_md": runtime.get("identity_md") or "",
"workspace_dir": bot.workspace_dir,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"created_at": bot.created_at,
"updated_at": bot.updated_at,
}
def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"has_access_password": bool(str(bot.access_password or "").strip()),
"image_tag": bot.image_tag,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"updated_at": bot.updated_at,
}
def _sync_workspace_channels(
session: Session,
bot_id: str,
channels_override: Optional[List[Dict[str, Any]]] = None,
global_delivery_override: Optional[Dict[str, Any]] = None,
runtime_overrides: Optional[Dict[str, Any]] = None,
) -> None:
bot = session.get(BotInstance, bot_id)
if not bot:
return
snapshot = _read_bot_runtime_snapshot(bot)
_sync_workspace_channels_impl(
session,
bot_id,
snapshot,
channels_override=channels_override,
global_delivery_override=global_delivery_override,
runtime_overrides=runtime_overrides,
)

View File

@ -0,0 +1,248 @@
from __future__ import annotations
import json
import os
import re
from typing import Any, Dict, Optional
from core.settings import BOTS_WORKSPACE_ROOT
_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
__all__ = [
"_bot_data_root",
"_clear_bot_dashboard_direct_session",
"_clear_bot_sessions",
"_migrate_bot_resources_store",
"_normalize_env_params",
"_normalize_resource_limits",
"_read_bot_config",
"_read_bot_resources",
"_read_cron_store",
"_read_env_store",
"_safe_float",
"_safe_int",
"_workspace_root",
"_write_bot_config",
"_write_bot_resources",
"_write_cron_store",
"_write_env_store",
]
def _workspace_root(bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
def _bot_data_root(bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot"))
def _safe_float(raw: Any, default: float) -> float:
try:
return float(raw)
except Exception:
return default
def _safe_int(raw: Any, default: int) -> int:
try:
return int(raw)
except Exception:
return default
def _normalize_resource_limits(cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]:
cpu = _safe_float(cpu_cores, 1.0)
mem = _safe_int(memory_mb, 1024)
storage = _safe_int(storage_gb, 10)
if cpu < 0:
cpu = 1.0
if mem < 0:
mem = 1024
if storage < 0:
storage = 10
normalized_cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu))
normalized_mem = 0 if mem == 0 else min(65536, max(256, mem))
normalized_storage = 0 if storage == 0 else min(1024, max(1, storage))
return {
"cpu_cores": normalized_cpu,
"memory_mb": normalized_mem,
"storage_gb": normalized_storage,
}
def _normalize_env_params(raw: Any) -> Dict[str, str]:
if not isinstance(raw, dict):
return {}
rows: Dict[str, str] = {}
for key, value in raw.items():
normalized_key = str(key or "").strip().upper()
if not normalized_key or not _ENV_KEY_RE.fullmatch(normalized_key):
continue
rows[normalized_key] = str(value or "").strip()
return rows
def _read_json_object(path: str) -> Dict[str, Any]:
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _config_json_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "config.json")
def _read_bot_config(bot_id: str) -> Dict[str, Any]:
return _read_json_object(_config_json_path(bot_id))
def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None:
_write_json_atomic(_config_json_path(bot_id), config_data)
def _resources_json_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "resources.json")
def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None:
normalized = _normalize_resource_limits(cpu_cores, memory_mb, storage_gb)
_write_json_atomic(
_resources_json_path(bot_id),
{
"cpuCores": normalized["cpu_cores"],
"memoryMB": normalized["memory_mb"],
"storageGB": normalized["storage_gb"],
},
)
def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
cpu_raw: Any = None
memory_raw: Any = None
storage_raw: Any = None
data = _read_json_object(_resources_json_path(bot_id))
if data:
cpu_raw = data.get("cpuCores", data.get("cpu_cores"))
memory_raw = data.get("memoryMB", data.get("memory_mb"))
storage_raw = data.get("storageGB", data.get("storage_gb"))
if cpu_raw is None or memory_raw is None or storage_raw is None:
cfg = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id)
runtime_cfg = cfg.get("runtime")
if isinstance(runtime_cfg, dict):
resources_raw = runtime_cfg.get("resources")
if isinstance(resources_raw, dict):
if cpu_raw is None:
cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores"))
if memory_raw is None:
memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb"))
if storage_raw is None:
storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb"))
return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw)
def _migrate_bot_resources_store(bot_id: str) -> None:
config_data = _read_bot_config(bot_id)
runtime_cfg = config_data.get("runtime")
resources_raw: Dict[str, Any] = {}
if isinstance(runtime_cfg, dict):
legacy_raw = runtime_cfg.get("resources")
if isinstance(legacy_raw, dict):
resources_raw = legacy_raw
path = _resources_json_path(bot_id)
if not os.path.isfile(path):
_write_bot_resources(
bot_id,
resources_raw.get("cpuCores", resources_raw.get("cpu_cores")),
resources_raw.get("memoryMB", resources_raw.get("memory_mb")),
resources_raw.get("storageGB", resources_raw.get("storage_gb")),
)
if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg:
runtime_cfg.pop("resources", None)
if not runtime_cfg:
config_data.pop("runtime", None)
_write_bot_config(bot_id, config_data)
def _env_store_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "env.json")
def _read_env_store(bot_id: str) -> Dict[str, str]:
return _normalize_env_params(_read_json_object(_env_store_path(bot_id)))
def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None:
_write_json_atomic(_env_store_path(bot_id), _normalize_env_params(env_params))
def _cron_store_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json")
def _read_cron_store(bot_id: str) -> Dict[str, Any]:
data = _read_json_object(_cron_store_path(bot_id))
if not data:
return {"version": 1, "jobs": []}
jobs = data.get("jobs")
if not isinstance(jobs, list):
data["jobs"] = []
if "version" not in data:
data["version"] = 1
return data
def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None:
_write_json_atomic(_cron_store_path(bot_id), store)
def _sessions_root(bot_id: str) -> str:
return os.path.join(_workspace_root(bot_id), "sessions")
def _clear_bot_sessions(bot_id: str) -> int:
root = _sessions_root(bot_id)
if not os.path.isdir(root):
return 0
deleted = 0
for name in os.listdir(root):
path = os.path.join(root, name)
if not os.path.isfile(path):
continue
if not name.lower().endswith(".jsonl"):
continue
try:
os.remove(path)
deleted += 1
except Exception:
continue
return deleted
def _clear_bot_dashboard_direct_session(bot_id: str) -> Dict[str, Any]:
root = _sessions_root(bot_id)
os.makedirs(root, exist_ok=True)
path = os.path.join(root, "dashboard_direct.jsonl")
existed = os.path.exists(path)
with open(path, "w", encoding="utf-8"):
pass
return {"path": path, "existed": existed}

View File

@ -0,0 +1,27 @@
from typing import Optional
from core.cache import cache
def _cache_key_bots_list() -> str:
return "bot:list:v2"
def _cache_key_bot_detail(bot_id: str) -> str:
return f"bot:detail:v2:{bot_id}"
def _cache_key_bot_messages(bot_id: str, limit: int) -> str:
return f"bot:messages:list:v2:{bot_id}:limit:{limit}"
def _cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str:
cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest"
return f"bot:messages:page:v2:{bot_id}:before:{cursor}:limit:{limit}"
def _cache_key_images() -> str:
return "images:list"
def _invalidate_bot_detail_cache(bot_id: str) -> None:
cache.delete(_cache_key_bots_list(), _cache_key_bot_detail(bot_id))
def _invalidate_bot_messages_cache(bot_id: str) -> None:
cache.delete_prefix(f"bot:messages:{bot_id}:")
def _invalidate_images_cache() -> None:
cache.delete(_cache_key_images())

View File

@ -0,0 +1,205 @@
import logging
import os
from typing import Any, Dict, List
from fastapi import HTTPException
from sqlmodel import Session
from core.docker_instance import docker_manager
from models.bot import BotInstance
from services.bot_service import _read_bot_runtime_snapshot
from services.platform_service import (
create_usage_request,
fail_latest_usage,
record_activity_event,
)
from services.runtime_service import _persist_runtime_packet, _queue_runtime_broadcast
from services.workspace_service import _resolve_workspace_path
from core.utils import _is_video_attachment_path, _is_visual_attachment_path
logger = logging.getLogger("dashboard.backend")
def _normalize_message_media_item(value: Any) -> str:
return str(value or "").strip().replace("\\", "/").lstrip("/")
def _normalize_message_media_list(raw: Any) -> List[str]:
if not isinstance(raw, list):
return []
rows: List[str] = []
for value in raw:
normalized = _normalize_message_media_item(value)
if normalized:
rows.append(normalized)
return rows
def _build_delivery_command(command: str, checked_attachments: List[str]) -> str:
if not checked_attachments:
return command
attachment_block = "\n".join(f"- {path}" for path in checked_attachments)
if all(_is_visual_attachment_path(path) for path in checked_attachments):
has_video = any(_is_video_attachment_path(path) for path in checked_attachments)
media_label = "图片/视频" if has_video else "图片"
capability_hint = (
"1) 附件已随请求附带;图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。\n"
if has_video
else "1) 附件中的图片已作为多模态输入提供,优先直接理解并回答。\n"
)
if command:
return (
f"{command}\n\n"
"[Attached files]\n"
f"{attachment_block}\n\n"
"【附件处理要求】\n"
f"{capability_hint}"
"2) 若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n"
"3) 除非用户明确要求,不要先调用工具读取附件文件。\n"
"4) 回复语言必须遵循 USER.md若未指定则与用户当前输入语言保持一致。\n"
"5) 仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。"
)
return (
"请先处理已附带的附件列表:\n"
f"{attachment_block}\n\n"
f"请直接分析已附带的{media_label}并总结关键信息。\n"
f"{'图片在可用时可直接作为多模态输入理解,视频请按附件路径处理。' if has_video else ''}\n"
"若当前模型或接口不支持直接理解该附件,请明确说明后再调用工具解析。\n"
"回复语言必须遵循 USER.md若未指定则与用户当前输入语言保持一致。\n"
"仅基于可见内容回答;看不清或无法确认的部分请明确说明,不要猜测。"
)
command_has_paths = all(path in command for path in checked_attachments) if command else False
if command and not command_has_paths:
return (
f"{command}\n\n"
"[Attached files]\n"
f"{attachment_block}\n\n"
"Please process the attached file(s) listed above when answering this request.\n"
"Reply language must follow USER.md. If not specified, use the same language as the user input."
)
if not command:
return (
"Please process the uploaded file(s) listed below:\n"
f"{attachment_block}\n\n"
"Reply language must follow USER.md. If not specified, use the same language as the user input."
)
return command
def send_bot_command(session: Session, bot_id: str, command: str, attachments: Any) -> Dict[str, Any]:
request_id = ""
try:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
runtime_snapshot = _read_bot_runtime_snapshot(bot)
normalized_attachments = _normalize_message_media_list(attachments)
text_command = str(command or "").strip()
if not text_command and not normalized_attachments:
raise HTTPException(status_code=400, detail="Command or attachments is required")
checked_attachments: List[str] = []
for rel_path in normalized_attachments:
_, target = _resolve_workspace_path(bot_id, rel_path)
if not os.path.isfile(target):
raise HTTPException(status_code=400, detail=f"attachment not found: {rel_path}")
checked_attachments.append(rel_path)
delivery_media = [f"/root/.nanobot/workspace/{path.lstrip('/')}" for path in checked_attachments]
display_command = text_command if text_command else "[attachment message]"
delivery_command = _build_delivery_command(text_command, checked_attachments)
request_id = create_usage_request(
session,
bot_id,
display_command,
attachments=checked_attachments,
channel="dashboard",
metadata={"attachment_count": len(checked_attachments)},
provider=str(runtime_snapshot.get("llm_provider") or "").strip() or None,
model=str(runtime_snapshot.get("llm_model") or "").strip() or None,
)
record_activity_event(
session,
bot_id,
"command_submitted",
request_id=request_id,
channel="dashboard",
detail="command submitted",
metadata={"attachment_count": len(checked_attachments), "has_text": bool(text_command)},
)
session.commit()
outbound_user_packet: Dict[str, Any] | None = None
if display_command or checked_attachments:
outbound_user_packet = {
"type": "USER_COMMAND",
"channel": "dashboard",
"text": display_command,
"media": checked_attachments,
"request_id": request_id,
}
_persist_runtime_packet(bot_id, outbound_user_packet)
if outbound_user_packet:
_queue_runtime_broadcast(bot_id, outbound_user_packet)
success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media)
if success:
return {"success": True}
detail = docker_manager.get_last_delivery_error(bot_id)
fail_latest_usage(session, bot_id, detail or "command delivery failed")
record_activity_event(
session,
bot_id,
"command_failed",
request_id=request_id,
channel="dashboard",
detail=(detail or "command delivery failed")[:400],
)
session.commit()
_queue_runtime_broadcast(
bot_id,
{
"type": "AGENT_STATE",
"channel": "dashboard",
"payload": {
"state": "ERROR",
"action_msg": detail or "command delivery failed",
},
},
)
raise HTTPException(
status_code=502,
detail=f"Failed to deliver command to bot dashboard channel{': ' + detail if detail else ''}",
)
except HTTPException:
raise
except Exception as exc:
logger.exception("send_bot_command failed for bot_id=%s", bot_id)
try:
session.rollback()
except Exception:
pass
if request_id:
try:
fail_latest_usage(session, bot_id, str(exc))
record_activity_event(
session,
bot_id,
"command_failed",
request_id=request_id,
channel="dashboard",
detail=str(exc)[:400],
)
session.commit()
except Exception:
try:
session.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail=f"Failed to process bot command: {exc}") from exc

View File

@ -0,0 +1,335 @@
import json
import os
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlmodel import Session, select
from core.cache import cache
from core.docker_instance import docker_manager
from core.utils import _resolve_local_day_range
from models.bot import BotInstance, BotMessage
from services.bot_storage_service import _clear_bot_dashboard_direct_session, _clear_bot_sessions, _workspace_root
from services.cache_service import (
_cache_key_bot_messages,
_cache_key_bot_messages_page,
_invalidate_bot_detail_cache,
_invalidate_bot_messages_cache,
)
from services.platform_service import get_chat_pull_page_size, record_activity_event
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 _normalize_message_media_item(bot_id: str, value: Any) -> str:
raw = str(value or "").strip().replace("\\", "/")
if not raw:
return ""
if raw.startswith("/root/.nanobot/workspace/"):
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
root = _workspace_root(bot_id)
if os.path.isabs(raw):
try:
if os.path.commonpath([root, raw]) == root:
return os.path.relpath(raw, root).replace("\\", "/")
except Exception:
pass
return raw.lstrip("/")
def _normalize_message_media_list(raw: Any, bot_id: str) -> List[str]:
if not isinstance(raw, list):
return []
rows: List[str] = []
for value in raw:
normalized = _normalize_message_media_item(bot_id, value)
if normalized:
rows.append(normalized)
return rows
def _parse_message_media(bot_id: str, media_raw: Optional[str]) -> List[str]:
if not media_raw:
return []
try:
parsed = json.loads(media_raw)
except Exception:
return []
return _normalize_message_media_list(parsed, bot_id)
def serialize_bot_message_row(bot_id: str, row: BotMessage) -> Dict[str, Any]:
created_at = row.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return {
"id": row.id,
"bot_id": row.bot_id,
"role": row.role,
"text": row.text,
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)),
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
"ts": int(created_at.timestamp() * 1000),
}
def list_bot_messages_payload(session: Session, bot_id: str, limit: int = 200) -> List[Dict[str, Any]]:
_get_bot_or_404(session, bot_id)
safe_limit = max(1, min(int(limit), 500))
cached = cache.get_json(_cache_key_bot_messages(bot_id, safe_limit))
if isinstance(cached, list):
return cached
rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(safe_limit)
).all()
payload = [serialize_bot_message_row(bot_id, row) for row in reversed(rows)]
cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30)
return payload
def list_bot_messages_page_payload(
session: Session,
bot_id: str,
limit: Optional[int],
before_id: Optional[int],
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
configured_limit = get_chat_pull_page_size()
safe_limit = max(1, min(int(limit if limit is not None else configured_limit), 500))
safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None
cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
cached = cache.get_json(cache_key)
if isinstance(cached, dict) and isinstance(cached.get("items"), list):
return cached
stmt = (
select(BotMessage)
.where(BotMessage.bot_id == bot_id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(safe_limit + 1)
)
if safe_before_id is not None:
stmt = stmt.where(BotMessage.id < safe_before_id)
rows = session.exec(stmt).all()
has_more = len(rows) > safe_limit
if has_more:
rows = rows[:safe_limit]
ordered = list(reversed(rows))
payload = {
"items": [serialize_bot_message_row(bot_id, row) for row in ordered],
"has_more": bool(has_more),
"next_before_id": rows[-1].id if rows else None,
"limit": safe_limit,
}
cache.set_json(cache_key, payload, ttl=30)
return payload
def list_bot_messages_by_date_payload(
session: Session,
bot_id: str,
date: str,
tz_offset_minutes: Optional[int],
limit: Optional[int],
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
utc_start, utc_end = _resolve_local_day_range(date, tz_offset_minutes)
configured_limit = max(60, get_chat_pull_page_size())
safe_limit = max(12, min(int(limit if limit is not None else configured_limit), 240))
before_limit = max(3, min(18, safe_limit // 4))
after_limit = max(0, safe_limit - before_limit - 1)
exact_anchor = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.created_at >= utc_start, BotMessage.created_at < utc_end)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(1)
).first()
anchor = exact_anchor
matched_exact_date = exact_anchor is not None
if anchor is None:
next_row = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.created_at >= utc_end)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(1)
).first()
prev_row = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.created_at < utc_start)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(1)
).first()
if next_row and prev_row:
gap_after = next_row.created_at - utc_end
gap_before = utc_start - prev_row.created_at
anchor = next_row if gap_after <= gap_before else prev_row
else:
anchor = next_row or prev_row
if anchor is None or anchor.id is None:
return {
"items": [],
"anchor_id": None,
"resolved_ts": None,
"matched_exact_date": False,
"has_more_before": False,
"has_more_after": False,
}
before_rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.id < anchor.id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(before_limit)
).all()
after_rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.id > anchor.id)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(after_limit)
).all()
ordered = list(reversed(before_rows)) + [anchor] + after_rows
first_row = ordered[0] if ordered else None
last_row = ordered[-1] if ordered else None
has_more_before = False
if first_row is not None and first_row.id is not None:
has_more_before = (
session.exec(
select(BotMessage.id)
.where(BotMessage.bot_id == bot_id, BotMessage.id < first_row.id)
.order_by(BotMessage.id.desc())
.limit(1)
).first()
is not None
)
has_more_after = False
if last_row is not None and last_row.id is not None:
has_more_after = (
session.exec(
select(BotMessage.id)
.where(BotMessage.bot_id == bot_id, BotMessage.id > last_row.id)
.order_by(BotMessage.id.asc())
.limit(1)
).first()
is not None
)
return {
"items": [serialize_bot_message_row(bot_id, row) for row in ordered],
"anchor_id": anchor.id,
"resolved_ts": int(anchor.created_at.timestamp() * 1000),
"matched_exact_date": matched_exact_date,
"has_more_before": has_more_before,
"has_more_after": has_more_after,
}
def update_bot_message_feedback_payload(
session: Session,
bot_id: str,
message_id: int,
feedback: Optional[str],
) -> Dict[str, Any]:
_get_bot_or_404(session, bot_id)
row = session.get(BotMessage, message_id)
if not row or row.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Message not found")
if row.role != "assistant":
raise HTTPException(status_code=400, detail="Only assistant messages support feedback")
raw = str(feedback or "").strip().lower()
if raw in {"", "none", "null"}:
row.feedback = None
row.feedback_at = None
elif raw in {"up", "down"}:
row.feedback = raw
row.feedback_at = datetime.utcnow()
else:
raise HTTPException(status_code=400, detail="feedback must be 'up' or 'down'")
session.add(row)
session.commit()
_invalidate_bot_messages_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"message_id": row.id,
"feedback": row.feedback,
"feedback_at": row.feedback_at.isoformat() if row.feedback_at else None,
}
def clear_bot_messages_payload(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()
deleted = 0
for row in rows:
session.delete(row)
deleted += 1
cleared_sessions = _clear_bot_sessions(bot_id)
if str(bot.docker_status or "").upper() == "RUNNING":
try:
docker_manager.send_command(bot_id, "/new")
except Exception:
pass
bot.last_action = ""
bot.current_state = "IDLE"
bot.updated_at = datetime.utcnow()
session.add(bot)
record_activity_event(
session,
bot_id,
"history_cleared",
channel="system",
detail=f"Cleared {deleted} stored messages",
metadata={"deleted_messages": deleted, "cleared_sessions": cleared_sessions},
)
session.commit()
_invalidate_bot_detail_cache(bot_id)
_invalidate_bot_messages_cache(bot_id)
return {"bot_id": bot_id, "deleted": deleted, "cleared_sessions": cleared_sessions}
def clear_dashboard_direct_session_payload(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id)
result = _clear_bot_dashboard_direct_session(bot_id)
if str(bot.docker_status or "").upper() == "RUNNING":
try:
docker_manager.send_command(bot_id, "/new")
except Exception:
pass
bot.updated_at = datetime.utcnow()
session.add(bot)
record_activity_event(
session,
bot_id,
"dashboard_session_cleared",
channel="dashboard",
detail="Cleared dashboard_direct session file",
metadata={"session_file": result["path"], "previously_existed": result["existed"]},
)
session.commit()
_invalidate_bot_detail_cache(bot_id)
return {
"bot_id": bot_id,
"cleared": True,
"session_file": result["path"],
"previously_existed": result["existed"],
}

View File

@ -0,0 +1,103 @@
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import delete as sql_delete
from sqlmodel import Session, select
from models.platform import BotActivityEvent
from schemas.platform import PlatformActivityItem
from services.platform_settings_service import get_activity_event_retention_days
ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10)
OPERATIONAL_ACTIVITY_EVENT_TYPES = {
"bot_created",
"bot_started",
"bot_stopped",
"bot_warning",
"bot_enabled",
"bot_disabled",
"bot_deactivated",
"command_submitted",
"command_failed",
"history_cleared",
}
_last_activity_event_prune_at: Optional[datetime] = None
def _utcnow() -> datetime:
return datetime.utcnow()
def prune_expired_activity_events(session: Session, force: bool = False) -> int:
global _last_activity_event_prune_at
now = _utcnow()
if not force and _last_activity_event_prune_at and now - _last_activity_event_prune_at < ACTIVITY_EVENT_PRUNE_INTERVAL:
return 0
retention_days = get_activity_event_retention_days(session)
cutoff = now - timedelta(days=retention_days)
result = session.exec(sql_delete(BotActivityEvent).where(BotActivityEvent.created_at < cutoff))
_last_activity_event_prune_at = now
return int(getattr(result, "rowcount", 0) or 0)
def record_activity_event(
session: Session,
bot_id: str,
event_type: str,
request_id: Optional[str] = None,
channel: str = "dashboard",
detail: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
normalized_event_type = str(event_type or "unknown").strip().lower() or "unknown"
if normalized_event_type not in OPERATIONAL_ACTIVITY_EVENT_TYPES:
return
prune_expired_activity_events(session, force=False)
row = BotActivityEvent(
bot_id=bot_id,
request_id=request_id,
event_type=normalized_event_type,
channel=str(channel or "dashboard").strip().lower() or "dashboard",
detail=(str(detail or "").strip() or None),
metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None,
created_at=_utcnow(),
)
session.add(row)
def list_activity_events(
session: Session,
bot_id: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
deleted = prune_expired_activity_events(session, force=False)
if deleted > 0:
session.commit()
safe_limit = max(1, min(int(limit), 500))
stmt = select(BotActivityEvent).order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc()).limit(safe_limit)
if bot_id:
stmt = stmt.where(BotActivityEvent.bot_id == bot_id)
rows = session.exec(stmt).all()
items: List[Dict[str, Any]] = []
for row in rows:
try:
metadata = json.loads(row.metadata_json or "{}")
except Exception:
metadata = {}
items.append(
PlatformActivityItem(
id=int(row.id or 0),
bot_id=row.bot_id,
request_id=row.request_id,
event_type=row.event_type,
channel=row.channel,
detail=row.detail,
metadata=metadata if isinstance(metadata, dict) else {},
created_at=row.created_at.isoformat() + "Z",
).model_dump()
)
return items

View File

@ -0,0 +1,121 @@
from typing import Any, Dict, List
from sqlmodel import Session, select
from core.utils import _calc_dir_size_bytes
from models.bot import BotInstance, NanobotImage
from services.bot_storage_service import _read_bot_resources, _workspace_root
from services.platform_activity_service import list_activity_events, prune_expired_activity_events
from services.platform_settings_service import get_platform_settings
from services.platform_usage_service import list_usage
def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, Any]:
deleted = prune_expired_activity_events(session, force=False)
if deleted > 0:
session.commit()
bots = session.exec(select(BotInstance)).all()
images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
settings = get_platform_settings(session)
running = 0
stopped = 0
disabled = 0
configured_cpu_total = 0.0
configured_memory_total = 0
configured_storage_total = 0
workspace_used_total = 0
workspace_limit_total = 0
live_cpu_percent_total = 0.0
live_memory_used_total = 0
live_memory_limit_total = 0
bot_rows: List[Dict[str, Any]] = []
for bot in bots:
enabled = bool(getattr(bot, "enabled", True))
runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED")
resources = _read_bot_resources(bot.id)
runtime = (
docker_manager.get_bot_resource_snapshot(bot.id)
if docker_manager
else {"usage": {}, "limits": {}, "docker_status": runtime_status}
)
workspace_root = _workspace_root(bot.id)
workspace_used = _calc_dir_size_bytes(workspace_root)
workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024
configured_cpu_total += float(resources["cpu_cores"] or 0)
configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024
configured_storage_total += workspace_limit
workspace_used_total += workspace_used
workspace_limit_total += workspace_limit
live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0)
live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0)
live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0)
if not enabled:
disabled += 1
elif runtime_status == "RUNNING":
running += 1
else:
stopped += 1
bot_rows.append(
{
"id": bot.id,
"name": bot.name,
"enabled": enabled,
"docker_status": runtime_status,
"image_tag": bot.image_tag,
"llm_provider": getattr(bot, "llm_provider", None),
"llm_model": getattr(bot, "llm_model", None),
"current_state": bot.current_state,
"last_action": bot.last_action,
"resources": resources,
"workspace_usage_bytes": workspace_used,
"workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None,
}
)
usage = list_usage(session, limit=20)
events = list_activity_events(session, limit=20)
return {
"summary": {
"bots": {
"total": len(bots),
"running": running,
"stopped": stopped,
"disabled": disabled,
},
"images": {
"total": len(images),
"ready": len([row for row in images if row.status == "READY"]),
"abnormal": len([row for row in images if row.status != "READY"]),
},
"resources": {
"configured_cpu_cores": round(configured_cpu_total, 2),
"configured_memory_bytes": configured_memory_total,
"configured_storage_bytes": configured_storage_total,
"live_cpu_percent": round(live_cpu_percent_total, 2),
"live_memory_used_bytes": live_memory_used_total,
"live_memory_limit_bytes": live_memory_limit_total,
"workspace_used_bytes": workspace_used_total,
"workspace_limit_bytes": workspace_limit_total,
},
},
"images": [
{
"tag": row.tag,
"version": row.version,
"status": row.status,
"source_dir": row.source_dir,
"created_at": row.created_at.isoformat() + "Z",
}
for row in images
],
"bots": bot_rows,
"settings": settings.model_dump(),
"usage": usage,
"events": events,
}

View File

@ -0,0 +1,147 @@
from typing import Any, Dict, List
from sqlmodel import Session, select
from core.database import engine
from core.settings import (
DEFAULT_STT_AUDIO_FILTER,
DEFAULT_STT_AUDIO_PREPROCESS,
DEFAULT_STT_DEFAULT_LANGUAGE,
DEFAULT_STT_FORCE_SIMPLIFIED,
DEFAULT_STT_INITIAL_PROMPT,
DEFAULT_STT_MAX_AUDIO_SECONDS,
STT_DEVICE,
STT_MODEL,
)
from models.platform import PlatformSetting
from schemas.platform import LoadingPageSettings, PlatformSettingsPayload
from services.platform_settings_core import (
SETTING_KEYS,
SYSTEM_SETTING_DEFINITIONS,
_bootstrap_platform_setting_values,
_normalize_extension_list,
_read_setting_value,
_upsert_setting_row,
)
from services.platform_system_settings_service import ensure_default_system_settings
def default_platform_settings() -> PlatformSettingsPayload:
bootstrap = _bootstrap_platform_setting_values()
return PlatformSettingsPayload(
page_size=int(bootstrap["page_size"]),
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
upload_max_mb=int(bootstrap["upload_max_mb"]),
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
speech_enabled=bool(bootstrap["speech_enabled"]),
speech_max_audio_seconds=DEFAULT_STT_MAX_AUDIO_SECONDS,
speech_default_language=DEFAULT_STT_DEFAULT_LANGUAGE,
speech_force_simplified=DEFAULT_STT_FORCE_SIMPLIFIED,
speech_audio_preprocess=DEFAULT_STT_AUDIO_PREPROCESS,
speech_audio_filter=DEFAULT_STT_AUDIO_FILTER,
speech_initial_prompt=DEFAULT_STT_INITIAL_PROMPT,
loading_page=LoadingPageSettings(),
)
def get_platform_settings(session: Session) -> PlatformSettingsPayload:
defaults = default_platform_settings()
ensure_default_system_settings(session)
rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all()
data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows}
merged = defaults.model_dump()
merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"])))
merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"])))
merged["command_auto_unlock_seconds"] = max(
1,
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
)
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
merged["allowed_attachment_extensions"] = _normalize_extension_list(
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
)
merged["workspace_download_extensions"] = _normalize_extension_list(
data.get("workspace_download_extensions", merged["workspace_download_extensions"])
)
merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"]))
loading_page = data.get("loading_page")
if isinstance(loading_page, dict):
current = dict(merged["loading_page"])
for key in ("title", "subtitle", "description"):
value = str(loading_page.get(key) or "").strip()
if value:
current[key] = value
merged["loading_page"] = current
return PlatformSettingsPayload.model_validate(merged)
def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload:
normalized = PlatformSettingsPayload(
page_size=max(1, min(100, int(payload.page_size))),
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
upload_max_mb=payload.upload_max_mb,
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions),
workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions),
speech_enabled=bool(payload.speech_enabled),
loading_page=LoadingPageSettings.model_validate(payload.loading_page.model_dump()),
)
payload_by_key = normalized.model_dump()
for key in SETTING_KEYS:
definition = SYSTEM_SETTING_DEFINITIONS[key]
_upsert_setting_row(
session,
key,
name=str(definition["name"]),
category=str(definition["category"]),
description=str(definition["description"]),
value_type=str(definition["value_type"]),
value=payload_by_key[key],
is_public=bool(definition["is_public"]),
sort_order=int(definition["sort_order"]),
)
session.commit()
return normalized
def get_platform_settings_snapshot() -> PlatformSettingsPayload:
with Session(engine) as session:
return get_platform_settings(session)
def get_upload_max_mb() -> int:
return get_platform_settings_snapshot().upload_max_mb
def get_allowed_attachment_extensions() -> List[str]:
return get_platform_settings_snapshot().allowed_attachment_extensions
def get_workspace_download_extensions() -> List[str]:
return get_platform_settings_snapshot().workspace_download_extensions
def get_page_size() -> int:
return get_platform_settings_snapshot().page_size
def get_chat_pull_page_size() -> int:
return get_platform_settings_snapshot().chat_pull_page_size
def get_speech_runtime_settings() -> Dict[str, Any]:
settings = get_platform_settings_snapshot()
return {
"enabled": bool(settings.speech_enabled),
"max_audio_seconds": int(DEFAULT_STT_MAX_AUDIO_SECONDS),
"default_language": str(DEFAULT_STT_DEFAULT_LANGUAGE or "zh").strip().lower() or "zh",
"force_simplified": bool(DEFAULT_STT_FORCE_SIMPLIFIED),
"audio_preprocess": bool(DEFAULT_STT_AUDIO_PREPROCESS),
"audio_filter": str(DEFAULT_STT_AUDIO_FILTER or "").strip(),
"initial_prompt": str(DEFAULT_STT_INITIAL_PROMPT or "").strip(),
"model": STT_MODEL,
"device": STT_DEVICE,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,300 @@
import json
import os
import re
from datetime import datetime
from typing import Any, Dict, List
from sqlmodel import Session
from core.settings import (
DEFAULT_CHAT_PULL_PAGE_SIZE,
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
DEFAULT_PAGE_SIZE,
DEFAULT_UPLOAD_MAX_MB,
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
STT_ENABLED_DEFAULT,
)
from models.platform import PlatformSetting
from schemas.platform import SystemSettingItem
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
SETTING_KEYS = (
"page_size",
"chat_pull_page_size",
"command_auto_unlock_seconds",
"upload_max_mb",
"allowed_attachment_extensions",
"workspace_download_extensions",
"speech_enabled",
)
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
DEPRECATED_SETTING_KEYS = {
"loading_page",
"speech_max_audio_seconds",
"speech_default_language",
"speech_force_simplified",
"speech_audio_preprocess",
"speech_audio_filter",
"speech_initial_prompt",
}
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"page_size": {
"name": "分页大小",
"category": "ui",
"description": "平台各类列表默认每页条数。",
"value_type": "integer",
"value": DEFAULT_PAGE_SIZE,
"is_public": True,
"sort_order": 5,
},
"chat_pull_page_size": {
"name": "对话懒加载条数",
"category": "chat",
"description": "Bot 对话区向上懒加载时每次读取的消息条数。",
"value_type": "integer",
"value": DEFAULT_CHAT_PULL_PAGE_SIZE,
"is_public": True,
"sort_order": 8,
},
"command_auto_unlock_seconds": {
"name": "发送按钮自动恢复秒数",
"category": "chat",
"description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。",
"value_type": "integer",
"value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
"is_public": True,
"sort_order": 9,
},
"upload_max_mb": {
"name": "上传大小限制",
"category": "upload",
"description": "单文件上传大小限制,单位 MB。",
"value_type": "integer",
"value": DEFAULT_UPLOAD_MAX_MB,
"is_public": False,
"sort_order": 10,
},
"allowed_attachment_extensions": {
"name": "允许附件后缀",
"category": "upload",
"description": "允许上传的附件后缀列表,留空表示不限制。",
"value_type": "json",
"value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
"is_public": False,
"sort_order": 20,
},
"workspace_download_extensions": {
"name": "工作区下载后缀",
"category": "workspace",
"description": "命中后缀的工作区文件默认走下载模式。",
"value_type": "json",
"value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
"is_public": False,
"sort_order": 30,
},
"speech_enabled": {
"name": "语音识别开关",
"category": "speech",
"description": "控制 Bot 语音转写功能是否启用。",
"value_type": "boolean",
"value": STT_ENABLED_DEFAULT,
"is_public": True,
"sort_order": 32,
},
ACTIVITY_EVENT_RETENTION_SETTING_KEY: {
"name": "活动事件保留天数",
"category": "maintenance",
"description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。",
"value_type": "integer",
"value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
"is_public": False,
"sort_order": 34,
},
}
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_activity_event_retention_days(raw: Any) -> int:
try:
value = int(raw)
except Exception:
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
return max(1, min(3650, value))
def _normalize_extension(raw: Any) -> str:
text = str(raw or "").strip().lower()
if not text:
return ""
if text.startswith("*."):
text = text[1:]
if not text.startswith("."):
text = f".{text}"
if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text):
return ""
return text
def _normalize_extension_list(rows: Any) -> List[str]:
if not isinstance(rows, list):
return []
normalized: List[str] = []
for item in rows:
ext = _normalize_extension(item)
if ext and ext not in normalized:
normalized.append(ext)
return normalized
def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(str(raw).strip())
except Exception:
value = default
return max(min_value, min(max_value, value))
def _legacy_env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _legacy_env_extensions(name: str, default: List[str]) -> List[str]:
raw = os.getenv(name)
if raw is None:
return list(default)
source = re.split(r"[,;\s]+", str(raw))
normalized: List[str] = []
for item in source:
ext = _normalize_extension(item)
if ext and ext not in normalized:
normalized.append(ext)
return normalized
def _bootstrap_platform_setting_values() -> Dict[str, Any]:
return {
"page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100),
"chat_pull_page_size": _legacy_env_int(
"CHAT_PULL_PAGE_SIZE",
DEFAULT_CHAT_PULL_PAGE_SIZE,
10,
500,
),
"command_auto_unlock_seconds": _legacy_env_int(
"COMMAND_AUTO_UNLOCK_SECONDS",
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
1,
600,
),
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
"allowed_attachment_extensions": _legacy_env_extensions(
"ALLOWED_ATTACHMENT_EXTENSIONS",
list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
),
"workspace_download_extensions": _legacy_env_extensions(
"WORKSPACE_DOWNLOAD_EXTENSIONS",
list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
),
"speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT),
}
def _normalize_setting_key(raw: Any) -> str:
text = str(raw or "").strip()
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower()
def _normalize_setting_value(value: Any, value_type: str) -> Any:
normalized_type = str(value_type or "json").strip().lower() or "json"
if normalized_type == "integer":
return int(value or 0)
if normalized_type == "float":
return float(value or 0)
if normalized_type == "boolean":
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
if normalized_type == "string":
return str(value or "")
if normalized_type == "json":
return value
raise ValueError(f"Unsupported value_type: {normalized_type}")
def _read_setting_value(row: PlatformSetting) -> Any:
try:
value = json.loads(row.value_json or "null")
except Exception:
value = None
return _normalize_setting_value(value, row.value_type)
def _setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]:
return SystemSettingItem(
key=row.key,
name=row.name,
category=row.category,
description=row.description,
value_type=row.value_type,
value=_read_setting_value(row),
is_public=bool(row.is_public),
sort_order=int(row.sort_order or 100),
created_at=row.created_at.isoformat() + "Z",
updated_at=row.updated_at.isoformat() + "Z",
).model_dump()
def _upsert_setting_row(
session: Session,
key: str,
*,
name: str,
category: str,
description: str,
value_type: str,
value: Any,
is_public: bool,
sort_order: int,
) -> PlatformSetting:
normalized_key = _normalize_setting_key(key)
if not normalized_key:
raise ValueError("Setting key is required")
normalized_type = str(value_type or "json").strip().lower() or "json"
normalized_value = _normalize_setting_value(value, normalized_type)
now = _utcnow()
row = session.get(PlatformSetting, normalized_key)
if row is None:
row = PlatformSetting(
key=normalized_key,
name=str(name or normalized_key),
category=str(category or "general"),
description=str(description or ""),
value_type=normalized_type,
value_json=json.dumps(normalized_value, ensure_ascii=False),
is_public=bool(is_public),
sort_order=int(sort_order or 100),
created_at=now,
updated_at=now,
)
else:
row.name = str(name or row.name or normalized_key)
row.category = str(category or row.category or "general")
row.description = str(description or row.description or "")
row.value_type = normalized_type
row.value_json = json.dumps(normalized_value, ensure_ascii=False)
row.is_public = bool(is_public)
row.sort_order = int(sort_order or row.sort_order or 100)
row.updated_at = now
session.add(row)
return row

View File

@ -0,0 +1,26 @@
from services.platform_runtime_settings_service import (
default_platform_settings,
get_allowed_attachment_extensions,
get_chat_pull_page_size,
get_page_size,
get_platform_settings,
get_platform_settings_snapshot,
get_speech_runtime_settings,
get_upload_max_mb,
get_workspace_download_extensions,
save_platform_settings,
)
from services.platform_settings_core import (
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS,
SETTING_KEYS,
SYSTEM_SETTING_DEFINITIONS,
)
from services.platform_system_settings_service import (
create_or_update_system_setting,
delete_system_setting,
ensure_default_system_settings,
get_activity_event_retention_days,
list_system_settings,
)

View File

@ -0,0 +1,153 @@
from typing import Any, Dict, List
from sqlmodel import Session, select
from models.platform import PlatformSetting
from schemas.platform import SystemSettingPayload
from services.platform_settings_core import (
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
DEPRECATED_SETTING_KEYS,
PROTECTED_SETTING_KEYS,
SETTING_KEYS,
SYSTEM_SETTING_DEFINITIONS,
_bootstrap_platform_setting_values,
_normalize_activity_event_retention_days,
_normalize_setting_key,
_read_setting_value,
_setting_item_from_row,
_upsert_setting_row,
_utcnow,
)
def ensure_default_system_settings(session: Session) -> None:
bootstrap_values = _bootstrap_platform_setting_values()
legacy_row = session.get(PlatformSetting, "global")
if legacy_row is not None:
try:
legacy_data = _read_setting_value(legacy_row)
except Exception:
legacy_data = {}
if isinstance(legacy_data, dict):
for key in SETTING_KEYS:
meta = SYSTEM_SETTING_DEFINITIONS[key]
_upsert_setting_row(
session,
key,
name=str(meta["name"]),
category=str(meta["category"]),
description=str(meta["description"]),
value_type=str(meta["value_type"]),
value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])),
is_public=bool(meta["is_public"]),
sort_order=int(meta["sort_order"]),
)
session.delete(legacy_row)
session.commit()
dirty = False
for key in DEPRECATED_SETTING_KEYS:
legacy_row = session.get(PlatformSetting, key)
if legacy_row is not None:
session.delete(legacy_row)
dirty = True
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
row = session.get(PlatformSetting, key)
if row is None:
_upsert_setting_row(
session,
key,
name=str(meta["name"]),
category=str(meta["category"]),
description=str(meta["description"]),
value_type=str(meta["value_type"]),
value=bootstrap_values.get(key, meta["value"]),
is_public=bool(meta["is_public"]),
sort_order=int(meta["sort_order"]),
)
dirty = True
continue
changed = False
for field in ("name", "category", "description", "value_type"):
value = str(meta[field])
if not getattr(row, field):
setattr(row, field, value)
changed = True
if getattr(row, "sort_order", None) is None:
row.sort_order = int(meta["sort_order"])
changed = True
if getattr(row, "is_public", None) is None:
row.is_public = bool(meta["is_public"])
changed = True
if changed:
row.updated_at = _utcnow()
session.add(row)
dirty = True
if dirty:
session.commit()
def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]:
ensure_default_system_settings(session)
stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc())
rows = session.exec(stmt).all()
keyword = str(search or "").strip().lower()
items = [_setting_item_from_row(row) for row in rows]
if not keyword:
return items
return [
item
for item in items
if keyword in str(item["key"]).lower()
or keyword in str(item["name"]).lower()
or keyword in str(item["category"]).lower()
or keyword in str(item["description"]).lower()
]
def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]:
ensure_default_system_settings(session)
normalized_key = _normalize_setting_key(payload.key)
definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {})
row = _upsert_setting_row(
session,
payload.key,
name=payload.name or str(definition.get("name") or payload.key),
category=payload.category or str(definition.get("category") or "general"),
description=payload.description or str(definition.get("description") or ""),
value_type=payload.value_type or str(definition.get("value_type") or "json"),
value=payload.value if payload.value is not None else definition.get("value"),
is_public=payload.is_public,
sort_order=payload.sort_order or int(definition.get("sort_order") or 100),
)
if normalized_key == ACTIVITY_EVENT_RETENTION_SETTING_KEY:
from services.platform_activity_service import prune_expired_activity_events
prune_expired_activity_events(session, force=True)
session.commit()
session.refresh(row)
return _setting_item_from_row(row)
def delete_system_setting(session: Session, key: str) -> None:
normalized_key = _normalize_setting_key(key)
if normalized_key in PROTECTED_SETTING_KEYS:
raise ValueError("Core platform settings cannot be deleted")
row = session.get(PlatformSetting, normalized_key)
if row is None:
raise ValueError("Setting not found")
session.delete(row)
session.commit()
def get_activity_event_retention_days(session: Session) -> int:
row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY)
if row is None:
return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
try:
value = _read_setting_value(row)
except Exception:
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
return _normalize_activity_event_retention_days(value)

View File

@ -0,0 +1,305 @@
import json
import math
import re
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import func
from sqlmodel import Session, select
from models.platform import BotRequestUsage
from schemas.platform import (
PlatformUsageAnalytics,
PlatformUsageAnalyticsSeries,
PlatformUsageItem,
PlatformUsageResponse,
PlatformUsageSummary,
)
def _utcnow() -> datetime:
return datetime.utcnow()
def estimate_tokens(text: str) -> int:
content = str(text or "").strip()
if not content:
return 0
pieces = re.findall(r"[\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]", content)
total = 0
for piece in pieces:
if re.fullmatch(r"[\u4e00-\u9fff]", piece):
total += 1
elif re.fullmatch(r"[A-Za-z0-9_]+", piece):
total += max(1, math.ceil(len(piece) / 4))
else:
total += 1
return max(1, total)
def create_usage_request(
session: Session,
bot_id: str,
command: str,
attachments: Optional[List[str]] = None,
channel: str = "dashboard",
metadata: Optional[Dict[str, Any]] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
) -> str:
request_id = uuid.uuid4().hex
rows = [str(item).strip() for item in (attachments or []) if str(item).strip()]
input_tokens = estimate_tokens(command)
usage = BotRequestUsage(
bot_id=bot_id,
request_id=request_id,
channel=channel,
status="PENDING",
provider=(str(provider or "").strip() or None),
model=(str(model or "").strip() or None),
token_source="estimated",
input_tokens=input_tokens,
output_tokens=0,
total_tokens=input_tokens,
input_text_preview=str(command or "")[:400],
attachments_json=json.dumps(rows, ensure_ascii=False) if rows else None,
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
started_at=_utcnow(),
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(usage)
session.flush()
return request_id
def _find_latest_pending_usage(session: Session, bot_id: str) -> Optional[BotRequestUsage]:
stmt = (
select(BotRequestUsage)
.where(BotRequestUsage.bot_id == bot_id)
.where(BotRequestUsage.status == "PENDING")
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
.limit(1)
)
return session.exec(stmt).first()
def _find_pending_usage_by_request_id(session: Session, bot_id: str, request_id: str) -> Optional[BotRequestUsage]:
if not request_id:
return None
stmt = (
select(BotRequestUsage)
.where(BotRequestUsage.bot_id == bot_id)
.where(BotRequestUsage.request_id == request_id)
.where(BotRequestUsage.status == "PENDING")
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
.limit(1)
)
return session.exec(stmt).first()
def bind_usage_message(
session: Session,
bot_id: str,
request_id: str,
message_id: Optional[int],
) -> Optional[BotRequestUsage]:
if not request_id or not message_id:
return None
usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id)
if not usage_row:
return None
usage_row.message_id = int(message_id)
usage_row.updated_at = _utcnow()
session.add(usage_row)
return usage_row
def finalize_usage_from_packet(session: Session, bot_id: str, packet: Dict[str, Any]) -> Optional[BotRequestUsage]:
request_id = str(packet.get("request_id") or "").strip()
usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) or _find_latest_pending_usage(session, bot_id)
if not usage_row:
return None
raw_usage = packet.get("usage")
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
source = "estimated"
if isinstance(raw_usage, dict):
for key in ("input_tokens", "prompt_tokens", "promptTokens"):
if raw_usage.get(key) is not None:
try:
input_tokens = int(raw_usage.get(key) or 0)
except Exception:
input_tokens = None
break
for key in ("output_tokens", "completion_tokens", "completionTokens"):
if raw_usage.get(key) is not None:
try:
output_tokens = int(raw_usage.get(key) or 0)
except Exception:
output_tokens = None
break
if input_tokens is not None or output_tokens is not None:
source = "exact"
text = str(packet.get("text") or packet.get("content") or "").strip()
provider = str(packet.get("provider") or "").strip()
model = str(packet.get("model") or "").strip()
message_id = packet.get("message_id")
if input_tokens is None:
input_tokens = usage_row.input_tokens
if output_tokens is None:
output_tokens = estimate_tokens(text)
if source == "exact":
source = "mixed"
if provider:
usage_row.provider = provider[:120]
if model:
usage_row.model = model[:255]
if message_id is not None:
try:
usage_row.message_id = int(message_id)
except Exception:
pass
usage_row.output_tokens = max(0, int(output_tokens or 0))
usage_row.input_tokens = max(0, int(input_tokens or 0))
usage_row.total_tokens = usage_row.input_tokens + usage_row.output_tokens
usage_row.output_text_preview = text[:400] if text else usage_row.output_text_preview
usage_row.status = "COMPLETED"
usage_row.token_source = source
usage_row.completed_at = _utcnow()
usage_row.updated_at = _utcnow()
session.add(usage_row)
return usage_row
def fail_latest_usage(session: Session, bot_id: str, detail: str) -> Optional[BotRequestUsage]:
usage_row = _find_latest_pending_usage(session, bot_id)
if not usage_row:
return None
usage_row.status = "ERROR"
usage_row.error_text = str(detail or "")[:500]
usage_row.completed_at = _utcnow()
usage_row.updated_at = _utcnow()
session.add(usage_row)
return usage_row
def _build_usage_analytics(
session: Session,
bot_id: Optional[str] = None,
window_days: int = 7,
) -> PlatformUsageAnalytics:
safe_window_days = max(1, int(window_days or 0))
today = _utcnow().date()
days = [today - timedelta(days=offset) for offset in range(safe_window_days - 1, -1, -1)]
day_keys = [day.isoformat() for day in days]
day_labels = [day.strftime("%m-%d") for day in days]
first_day = days[0]
first_started_at = datetime.combine(first_day, datetime.min.time())
stmt = select(BotRequestUsage.model, BotRequestUsage.started_at).where(BotRequestUsage.started_at >= first_started_at)
if bot_id:
stmt = stmt.where(BotRequestUsage.bot_id == bot_id)
counts_by_model: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
total_requests = 0
for model_name, started_at in session.exec(stmt).all():
if not started_at:
continue
day_key = started_at.date().isoformat()
if day_key not in day_keys:
continue
normalized_model = str(model_name or "").strip() or "Unknown"
counts_by_model[normalized_model][day_key] += 1
total_requests += 1
series = [
PlatformUsageAnalyticsSeries(
model=model_name,
total_requests=sum(day_counts.values()),
daily_counts=[int(day_counts.get(day_key, 0)) for day_key in day_keys],
)
for model_name, day_counts in counts_by_model.items()
]
series.sort(key=lambda item: (-item.total_requests, item.model.lower()))
return PlatformUsageAnalytics(
window_days=safe_window_days,
days=day_labels,
total_requests=total_requests,
series=series,
)
def list_usage(
session: Session,
bot_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
safe_limit = max(1, min(int(limit), 500))
safe_offset = max(0, int(offset or 0))
stmt = (
select(BotRequestUsage)
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
summary_stmt = select(
func.count(BotRequestUsage.id),
func.coalesce(func.sum(BotRequestUsage.input_tokens), 0),
func.coalesce(func.sum(BotRequestUsage.output_tokens), 0),
func.coalesce(func.sum(BotRequestUsage.total_tokens), 0),
)
total_stmt = select(func.count(BotRequestUsage.id))
if bot_id:
stmt = stmt.where(BotRequestUsage.bot_id == bot_id)
summary_stmt = summary_stmt.where(BotRequestUsage.bot_id == bot_id)
total_stmt = total_stmt.where(BotRequestUsage.bot_id == bot_id)
else:
since = _utcnow() - timedelta(days=1)
summary_stmt = summary_stmt.where(BotRequestUsage.created_at >= since)
rows = session.exec(stmt).all()
count, input_sum, output_sum, total_sum = session.exec(summary_stmt).one()
total = int(session.exec(total_stmt).one() or 0)
items = [
PlatformUsageItem(
id=int(row.id or 0),
bot_id=row.bot_id,
message_id=int(row.message_id) if row.message_id is not None else None,
request_id=row.request_id,
channel=row.channel,
status=row.status,
provider=row.provider,
model=row.model,
token_source=row.token_source,
content=row.input_text_preview or row.output_text_preview,
input_tokens=int(row.input_tokens or 0),
output_tokens=int(row.output_tokens or 0),
total_tokens=int(row.total_tokens or 0),
input_text_preview=row.input_text_preview,
output_text_preview=row.output_text_preview,
started_at=row.started_at.isoformat() + "Z",
completed_at=row.completed_at.isoformat() + "Z" if row.completed_at else None,
).model_dump()
for row in rows
]
return PlatformUsageResponse(
summary=PlatformUsageSummary(
request_count=int(count or 0),
input_tokens=int(input_sum or 0),
output_tokens=int(output_sum or 0),
total_tokens=int(total_sum or 0),
),
items=[PlatformUsageItem.model_validate(item) for item in items],
total=total,
limit=safe_limit,
offset=safe_offset,
has_more=safe_offset + len(items) < total,
analytics=_build_usage_analytics(session, bot_id=bot_id),
).model_dump()

View File

@ -0,0 +1,261 @@
import asyncio
import json
import logging
import os
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlmodel import Session
from core.database import engine
from core.docker_instance import docker_manager
from core.websocket_manager import manager
from models.bot import BotInstance, BotMessage
from services.bot_service import _workspace_root
from services.cache_service import _invalidate_bot_detail_cache, _invalidate_bot_messages_cache
from services.platform_service import bind_usage_message, finalize_usage_from_packet, record_activity_event
from services.topic_runtime import publish_runtime_topic_packet
logger = logging.getLogger("dashboard.backend")
_main_loop: Optional[asyncio.AbstractEventLoop] = None
_AGENT_LOOP_READY_MARKER = "Agent loop started"
def set_main_loop(loop: Optional[asyncio.AbstractEventLoop]) -> None:
global _main_loop
_main_loop = loop
def get_main_loop() -> Optional[asyncio.AbstractEventLoop]:
return _main_loop
def _queue_runtime_broadcast(bot_id: str, packet: Dict[str, Any]) -> None:
loop = get_main_loop()
if not loop or not loop.is_running():
return
asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop)
def _normalize_packet_channel(packet: Dict[str, Any]) -> str:
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}:
return "dashboard"
return raw
def _normalize_media_item(bot_id: str, value: Any) -> str:
raw = str(value or "").strip().replace("\\", "/")
if not raw:
return ""
if raw.startswith("/root/.nanobot/workspace/"):
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
root = _workspace_root(bot_id)
if os.path.isabs(raw):
try:
if os.path.commonpath([root, raw]) == root:
return os.path.relpath(raw, root).replace("\\", "/")
except Exception:
pass
return raw.lstrip("/")
def _normalize_media_list(raw: Any, bot_id: str) -> List[str]:
if not isinstance(raw, list):
return []
rows: List[str] = []
for value in raw:
normalized = _normalize_media_item(bot_id, value)
if normalized:
rows.append(normalized)
return rows
def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
return None
source_channel = _normalize_packet_channel(packet)
if source_channel != "dashboard":
return None
persisted_message_id: Optional[int] = None
with Session(engine) as session:
bot = session.get(BotInstance, bot_id)
if not bot:
return None
if packet_type == "AGENT_STATE":
payload = packet.get("payload") or {}
state = str(payload.get("state") or "").strip()
action = str(payload.get("action_msg") or payload.get("msg") or "").strip()
if state:
bot.current_state = state
if action:
bot.last_action = action[:4000]
elif packet_type == "ASSISTANT_MESSAGE":
bot.current_state = "IDLE"
text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000]
message_row = BotMessage(
bot_id=bot_id,
role="assistant",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
finalize_usage_from_packet(
session,
bot_id,
{
**packet,
"message_id": persisted_message_id,
},
)
elif packet_type == "USER_COMMAND":
text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
message_row = BotMessage(
bot_id=bot_id,
role="user",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
bind_usage_message(
session,
bot_id,
str(packet.get("request_id") or "").strip(),
persisted_message_id,
)
elif packet_type == "BUS_EVENT":
is_progress = bool(packet.get("is_progress"))
detail_text = str(packet.get("content") or packet.get("text") or "").strip()
if not is_progress:
text_msg = detail_text
media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
bot.current_state = "IDLE"
if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000]
message_row = BotMessage(
bot_id=bot_id,
role="assistant",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
finalize_usage_from_packet(
session,
bot_id,
{
"text": text_msg,
"usage": packet.get("usage"),
"request_id": packet.get("request_id"),
"provider": packet.get("provider"),
"model": packet.get("model"),
"message_id": persisted_message_id,
},
)
bot.updated_at = datetime.utcnow()
session.add(bot)
session.commit()
publish_runtime_topic_packet(
engine,
bot_id,
packet,
source_channel,
persisted_message_id,
logger,
)
if persisted_message_id:
packet["message_id"] = persisted_message_id
if packet_type in {"ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
_invalidate_bot_messages_cache(bot_id)
_invalidate_bot_detail_cache(bot_id)
return persisted_message_id
def docker_callback(bot_id: str, packet: Dict[str, Any]) -> None:
packet_type = str(packet.get("type", "")).upper()
if packet_type == "RAW_LOG":
_queue_runtime_broadcast(bot_id, packet)
return
persisted_message_id = _persist_runtime_packet(bot_id, packet)
if persisted_message_id:
packet["message_id"] = persisted_message_id
_queue_runtime_broadcast(bot_id, packet)
async def _wait_for_agent_loop_ready(
bot_id: str,
timeout_seconds: float = 12.0,
poll_interval_seconds: float = 0.5,
) -> bool:
deadline = time.monotonic() + max(1.0, timeout_seconds)
marker = _AGENT_LOOP_READY_MARKER.lower()
while time.monotonic() < deadline:
logs = docker_manager.get_recent_logs(bot_id, tail=200)
if any(marker in str(line or "").lower() for line in logs):
return True
await asyncio.sleep(max(0.1, poll_interval_seconds))
return False
async def _record_agent_loop_ready_warning(
bot_id: str,
timeout_seconds: float = 12.0,
poll_interval_seconds: float = 0.5,
) -> None:
try:
agent_loop_ready = await _wait_for_agent_loop_ready(
bot_id,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
)
if agent_loop_ready:
return
if docker_manager.get_bot_status(bot_id) != "RUNNING":
return
detail = (
"Bot container started, but ready marker was not found in logs within "
f"{int(timeout_seconds)}s. Check bot logs or MCP config if the bot stays unavailable."
)
logger.warning("bot_id=%s agent loop ready marker not found within %ss", bot_id, timeout_seconds)
with Session(engine) as background_session:
if not background_session.get(BotInstance, bot_id):
return
record_activity_event(
background_session,
bot_id,
"bot_warning",
channel="system",
detail=detail,
metadata={
"kind": "agent_loop_ready_timeout",
"marker": _AGENT_LOOP_READY_MARKER,
"timeout_seconds": timeout_seconds,
},
)
background_session.commit()
_invalidate_bot_detail_cache(bot_id)
except Exception:
logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id)

View File

@ -0,0 +1,434 @@
import json
import os
import tempfile
import zipfile
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import HTTPException, UploadFile
from sqlmodel import Session, select
from core.settings import DATA_ROOT
from core.utils import (
_is_ignored_skill_zip_top_level,
_is_valid_top_level_skill_name,
_parse_json_string_list,
_read_description_from_text,
_sanitize_skill_market_key,
_sanitize_zip_filename,
)
from models.skill import BotSkillInstall, SkillMarketItem
from services.platform_service import get_platform_settings_snapshot
from services.skill_service import _install_skill_zip_into_workspace, _skills_root
def _skill_market_root() -> str:
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
def _extract_skill_zip_summary(zip_path: str) -> Dict[str, Any]:
entry_names: List[str] = []
description = ""
with zipfile.ZipFile(zip_path) as archive:
members = archive.infolist()
file_members = [member for member in members if not member.is_dir()]
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if _is_ignored_skill_zip_top_level(first):
continue
if _is_valid_top_level_skill_name(first) and first not in entry_names:
entry_names.append(first)
candidates = sorted(
[
str(member.filename or "").replace("\\", "/").lstrip("/")
for member in file_members
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
in {"skill.md", "readme.md"}
],
key=lambda value: (value.count("/"), value.lower()),
)
for candidate in candidates:
try:
with archive.open(candidate, "r") as file:
preview = file.read(4096).decode("utf-8", errors="ignore")
description = _read_description_from_text(preview)
if description:
break
except Exception:
continue
return {
"entry_names": entry_names,
"description": description,
}
def _resolve_unique_skill_market_key(session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
base_key = _sanitize_skill_market_key(preferred_key) or "skill"
candidate = base_key
counter = 2
while True:
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
rows = session.exec(stmt).all()
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not conflict:
return candidate
candidate = f"{base_key}-{counter}"
counter += 1
def _resolve_unique_skill_market_zip_filename(
session: Session,
filename: str,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> str:
root = _skill_market_root()
os.makedirs(root, exist_ok=True)
safe_name = _sanitize_zip_filename(filename)
if not safe_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
candidate = safe_name
stem, ext = os.path.splitext(safe_name)
counter = 2
while True:
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not file_conflict and not db_conflict:
return candidate
candidate = f"{stem}-{counter}{ext}"
counter += 1
async def _store_skill_market_zip_upload(
session: Session,
upload: UploadFile,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> Dict[str, Any]:
root = _skill_market_root()
os.makedirs(root, exist_ok=True)
incoming_name = _sanitize_zip_filename(upload.filename or "")
if not incoming_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
target_filename = _resolve_unique_skill_market_zip_filename(
session,
incoming_name,
exclude_filename=exclude_filename,
exclude_id=exclude_id,
)
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
tmp_path = tmp_zip.name
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
summary = _extract_skill_zip_summary(tmp_path)
if not summary["entry_names"]:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
final_path = os.path.join(root, target_filename)
os.replace(tmp_path, final_path)
tmp_path = None
return {
"zip_filename": target_filename,
"zip_size_bytes": total_size,
"entry_names": summary["entry_names"],
"description": summary["description"],
}
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
finally:
await upload.close()
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
def _serialize_skill_market_item(
item: SkillMarketItem,
*,
install_count: int = 0,
install_row: Optional[BotSkillInstall] = None,
workspace_installed: Optional[bool] = None,
installed_entries: Optional[List[str]] = None,
) -> Dict[str, Any]:
zip_path = os.path.join(_skill_market_root(), str(item.zip_filename or ""))
entry_names = _parse_json_string_list(item.entry_names_json)
payload = {
"id": item.id,
"skill_key": item.skill_key,
"display_name": item.display_name or item.skill_key,
"description": item.description or "",
"zip_filename": item.zip_filename,
"zip_size_bytes": int(item.zip_size_bytes or 0),
"entry_names": entry_names,
"entry_count": len(entry_names),
"zip_exists": os.path.isfile(zip_path),
"install_count": int(install_count or 0),
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
}
if install_row is not None:
resolved_entries = (
installed_entries
if installed_entries is not None
else _parse_json_string_list(install_row.installed_entries_json)
)
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
payload.update(
{
"installed": resolved_installed,
"install_status": install_row.status,
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
"installed_entries": resolved_entries,
"install_error": install_row.last_error,
}
)
return payload
def _build_install_count_by_skill(session: Session) -> Dict[int, int]:
installs = session.exec(select(BotSkillInstall)).all()
install_count_by_skill: Dict[int, int] = {}
for row in installs:
skill_id = int(row.skill_market_item_id or 0)
if skill_id <= 0 or row.status != "INSTALLED":
continue
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
return install_count_by_skill
def list_skill_market_items(session: Session) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
install_count_by_skill = _build_install_count_by_skill(session)
return [
_serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
for item in items
]
async def create_skill_market_item_record(
session: Session,
*,
skill_key: str,
display_name: str,
description: str,
upload: UploadFile,
) -> Dict[str, Any]:
upload_meta = await _store_skill_market_zip_upload(session, upload)
try:
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
next_key = _resolve_unique_skill_market_key(session, preferred_key)
item = SkillMarketItem(
skill_key=next_key,
display_name=str(display_name or next_key).strip() or next_key,
description=str(description or upload_meta["description"] or "").strip(),
zip_filename=upload_meta["zip_filename"],
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
)
session.add(item)
session.commit()
session.refresh(item)
return _serialize_skill_market_item(item, install_count=0)
except Exception:
target_path = os.path.join(_skill_market_root(), upload_meta["zip_filename"])
if os.path.exists(target_path):
os.remove(target_path)
raise
async def update_skill_market_item_record(
session: Session,
*,
skill_id: int,
skill_key: str,
display_name: str,
description: str,
upload: Optional[UploadFile] = None,
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
old_filename = str(item.zip_filename or "").strip()
upload_meta: Optional[Dict[str, Any]] = None
if upload is not None:
upload_meta = await _store_skill_market_zip_upload(
session,
upload,
exclude_filename=old_filename or None,
exclude_id=item.id,
)
next_key = _resolve_unique_skill_market_key(
session,
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
exclude_id=item.id,
)
item.skill_key = next_key
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
item.updated_at = datetime.utcnow()
if upload_meta:
item.zip_filename = upload_meta["zip_filename"]
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
session.add(item)
session.commit()
session.refresh(item)
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
old_path = os.path.join(_skill_market_root(), old_filename)
if os.path.exists(old_path):
os.remove(old_path)
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
install_count = sum(1 for row in installs if row.status == "INSTALLED")
return _serialize_skill_market_item(item, install_count=install_count)
def delete_skill_market_item_record(session: Session, *, skill_id: int) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_filename = str(item.zip_filename or "").strip()
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
for row in installs:
session.delete(row)
session.delete(item)
session.commit()
if zip_filename:
zip_path = os.path.join(_skill_market_root(), zip_filename)
if os.path.exists(zip_path):
os.remove(zip_path)
return {"status": "deleted", "id": skill_id}
def list_bot_skill_market_items(session: Session, *, bot_id: str) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
install_count_by_skill = _build_install_count_by_skill(session)
return [
_serialize_skill_market_item(
item,
install_count=install_count_by_skill.get(int(item.id or 0), 0),
install_row=install_lookup.get(int(item.id or 0)),
workspace_installed=(
None
if install_lookup.get(int(item.id or 0)) is None
else (
install_lookup[int(item.id or 0)].status == "INSTALLED"
and all(
os.path.exists(os.path.join(_skills_root(bot_id), name))
for name in _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
)
)
),
installed_entries=(
None
if install_lookup.get(int(item.id or 0)) is None
else _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
),
)
for item in items
]
def install_skill_market_item_for_bot(
session: Session,
*,
bot_id: str,
skill_id: int,
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_path = os.path.join(_skill_market_root(), str(item.zip_filename or ""))
if not os.path.isfile(zip_path):
raise HTTPException(status_code=404, detail="Skill zip package not found")
install_row = session.exec(
select(BotSkillInstall).where(
BotSkillInstall.bot_id == bot_id,
BotSkillInstall.skill_market_item_id == skill_id,
)
).first()
try:
install_result = _install_skill_zip_into_workspace(bot_id, zip_path)
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
)
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "INSTALLED"
install_row.last_error = None
install_row.installed_at = now
install_row.updated_at = now
session.add(install_row)
session.commit()
session.refresh(install_row)
return {
"status": "installed",
"bot_id": bot_id,
"skill_market_item_id": skill_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
"market_item": _serialize_skill_market_item(item, install_count=0, install_row=install_row),
}
except HTTPException as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc.detail or "Install failed")
install_row.updated_at = now
session.add(install_row)
session.commit()
raise
except Exception as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc or "Install failed")[:1000]
install_row.updated_at = now
session.add(install_row)
session.commit()
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc

View File

@ -0,0 +1,199 @@
import shutil
import zipfile
import tempfile
from datetime import datetime
import os
from typing import Any, Dict, List, Optional
from fastapi import HTTPException, UploadFile
from core.utils import (
_is_ignored_skill_zip_top_level,
_is_valid_top_level_skill_name,
)
from services.bot_storage_service import _workspace_root
from services.platform_service import get_platform_settings_snapshot
def _skills_root(bot_id: str) -> str:
return os.path.join(_workspace_root(bot_id), "skills")
def _read_skill_description(entry_path: str) -> str:
candidates: List[str] = []
if os.path.isdir(entry_path):
candidates = [
os.path.join(entry_path, "SKILL.md"),
os.path.join(entry_path, "skill.md"),
os.path.join(entry_path, "README.md"),
os.path.join(entry_path, "readme.md"),
]
elif entry_path.lower().endswith(".md"):
candidates = [entry_path]
for candidate in candidates:
if not os.path.isfile(candidate):
continue
try:
with open(candidate, "r", encoding="utf-8") as f:
for line in f:
text = line.strip()
if text and not text.startswith("#"):
return text[:240]
except Exception:
continue
return ""
def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]:
root = _skills_root(bot_id)
os.makedirs(root, exist_ok=True)
rows: List[Dict[str, Any]] = []
names = sorted(os.listdir(root), key=lambda n: (not os.path.isdir(os.path.join(root, n)), n.lower()))
for name in names:
if not name or name.startswith("."):
continue
if not _is_valid_top_level_skill_name(name):
continue
abs_path = os.path.join(root, name)
if not os.path.exists(abs_path):
continue
stat = os.stat(abs_path)
rows.append(
{
"id": name,
"name": name,
"type": "dir" if os.path.isdir(abs_path) else "file",
"path": f"skills/{name}",
"size": stat.st_size if os.path.isfile(abs_path) else None,
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
"description": _read_skill_description(abs_path),
}
)
return rows
def _install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, Any]:
try:
archive = zipfile.ZipFile(zip_path)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
skills_root = _skills_root(bot_id)
os.makedirs(skills_root, exist_ok=True)
installed: List[str] = []
with archive:
members = archive.infolist()
file_members = [m for m in members if not m.is_dir()]
if not file_members:
raise HTTPException(status_code=400, detail="Zip package has no files")
top_names: List[str] = []
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if _is_ignored_skill_zip_top_level(first):
continue
if not _is_valid_top_level_skill_name(first):
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
if first not in top_names:
top_names.append(first)
if not top_names:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
if conflicts:
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_root) as tmp_dir:
tmp_root = os.path.abspath(tmp_dir)
for member in members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
target = os.path.abspath(os.path.join(tmp_root, raw_name))
if os.path.commonpath([tmp_root, target]) != tmp_root:
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
if member.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
with archive.open(member, "r") as source, open(target, "wb") as dest:
shutil.copyfileobj(source, dest)
for name in top_names:
src = os.path.join(tmp_root, name)
dst = os.path.join(skills_root, name)
if not os.path.exists(src):
continue
shutil.move(src, dst)
installed.append(name)
if not installed:
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
return {
"installed": installed,
"skills": _list_workspace_skills(bot_id),
}
def list_bot_skills(bot_id: str) -> List[Dict[str, Any]]:
return _list_workspace_skills(bot_id)
async def upload_bot_skill_zip_to_workspace(bot_id: str, *, upload: UploadFile) -> Dict[str, Any]:
tmp_zip_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
tmp_zip_path = tmp_zip.name
filename = str(upload.filename or "").strip()
if not filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
finally:
await upload.close()
try:
install_result = _install_skill_zip_into_workspace(bot_id, tmp_zip_path)
finally:
if tmp_zip_path and os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
return {
"status": "installed",
"bot_id": bot_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
}
def delete_workspace_skill_entry(bot_id: str, *, skill_name: str) -> Dict[str, Any]:
name = str(skill_name or "").strip()
if not _is_valid_top_level_skill_name(name):
raise HTTPException(status_code=400, detail="Invalid skill name")
root = _skills_root(bot_id)
target = os.path.abspath(os.path.join(root, name))
if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root):
raise HTTPException(status_code=400, detail="Invalid skill path")
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Skill not found in workspace")
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=False)
else:
os.remove(target)
return {"status": "deleted", "bot_id": bot_id, "skill": name}

View File

@ -0,0 +1,99 @@
import asyncio
import os
import tempfile
from typing import Any, Dict, Optional
from fastapi import HTTPException, UploadFile
from sqlmodel import Session
from core.settings import DATA_ROOT
from core.speech_service import (
SpeechDisabledError,
SpeechDurationError,
SpeechServiceError,
WhisperSpeechService,
)
from models.bot import BotInstance
from services.platform_service import get_speech_runtime_settings
async def transcribe_bot_speech_upload(
session: Session,
bot_id: str,
upload: UploadFile,
language: Optional[str],
speech_service: WhisperSpeechService,
logger: Any,
) -> Dict[str, Any]:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
speech_settings = get_speech_runtime_settings()
if not speech_settings["enabled"]:
raise HTTPException(status_code=400, detail="Speech recognition is disabled")
if not upload:
raise HTTPException(status_code=400, detail="no audio file uploaded")
original_name = str(upload.filename or "audio.webm").strip() or "audio.webm"
safe_name = os.path.basename(original_name).replace("\\", "_").replace("/", "_")
ext = os.path.splitext(safe_name)[1].strip().lower() or ".webm"
if len(ext) > 12:
ext = ".webm"
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=ext, prefix=".speech_", dir=DATA_ROOT) as tmp:
tmp_path = tmp.name
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
if not tmp_path or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) <= 0:
raise HTTPException(status_code=400, detail="audio payload is empty")
resolved_language = str(language or "").strip() or speech_settings["default_language"]
result = await asyncio.to_thread(speech_service.transcribe_file, tmp_path, resolved_language)
text = str(result.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="No speech detected")
return {
"bot_id": bot_id,
"text": text,
"duration_seconds": result.get("duration_seconds"),
"max_audio_seconds": speech_settings["max_audio_seconds"],
"model": speech_settings["model"],
"device": speech_settings["device"],
"language": result.get("language") or resolved_language,
}
except SpeechDisabledError as exc:
logger.warning("speech transcribe disabled bot_id=%s file=%s language=%s detail=%s", bot_id, safe_name, language, exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SpeechDurationError as exc:
logger.warning(
"speech transcribe too long bot_id=%s file=%s language=%s max_seconds=%s",
bot_id,
safe_name,
language,
speech_settings["max_audio_seconds"],
)
raise HTTPException(status_code=413, detail=f"Audio duration exceeds {speech_settings['max_audio_seconds']} seconds") from exc
except SpeechServiceError as exc:
logger.exception("speech transcribe failed bot_id=%s file=%s language=%s", bot_id, safe_name, language)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HTTPException:
raise
except Exception as exc:
logger.exception("speech transcribe unexpected error bot_id=%s file=%s language=%s", bot_id, safe_name, language)
raise HTTPException(status_code=500, detail=f"speech transcription failed: {exc}") from exc
finally:
try:
await upload.close()
except Exception:
pass
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass

View File

@ -0,0 +1,71 @@
from __future__ import annotations
from typing import Any, Dict, List
from core.settings import AGENT_MD_TEMPLATES_FILE, TOPIC_PRESETS_TEMPLATES_FILE
TEMPLATE_KEYS = ("agents_md", "soul_md", "user_md", "tools_md", "identity_md")
def _load_json_object(path: str) -> Dict[str, Any]:
import json
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def _normalize_md_text(value: Any) -> str:
return str(value or "").replace("\r\n", "\n").strip()
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
import json
import os
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def get_agent_md_templates() -> Dict[str, str]:
raw = _load_json_object(str(AGENT_MD_TEMPLATES_FILE))
return {key: _normalize_md_text(raw.get(key)) for key in TEMPLATE_KEYS}
def get_topic_presets() -> Dict[str, Any]:
raw = _load_json_object(str(TOPIC_PRESETS_TEMPLATES_FILE))
presets = raw.get("presets")
if not isinstance(presets, list):
return {"presets": []}
return {"presets": [dict(row) for row in presets if isinstance(row, dict)]}
def update_agent_md_templates(raw: Dict[str, Any]) -> Dict[str, str]:
payload = {key: _normalize_md_text(raw.get(key)) for key in TEMPLATE_KEYS}
_write_json_atomic(str(AGENT_MD_TEMPLATES_FILE), payload)
return payload
def update_topic_presets(raw: Dict[str, Any]) -> Dict[str, Any]:
presets = raw.get("presets") if isinstance(raw, dict) else None
if presets is None:
payload: Dict[str, List[Dict[str, Any]]] = {"presets": []}
elif isinstance(presets, list):
payload = {"presets": [dict(row) for row in presets if isinstance(row, dict)]}
else:
raise ValueError("topic_presets.presets must be an array")
_write_json_atomic(str(TOPIC_PRESETS_TEMPLATES_FILE), payload)
return payload
def get_agent_template_value(key: str) -> str:
return get_agent_md_templates().get(key, "")

View File

@ -0,0 +1,446 @@
import mimetypes
import os
import re
from datetime import datetime
from typing import Any, Dict, Generator, List, Optional
from urllib.parse import quote
from fastapi import HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, Response, StreamingResponse
from core.utils import _workspace_stat_ctime_iso
from services.bot_storage_service import _workspace_root
from services.platform_service import get_platform_settings_snapshot
TEXT_PREVIEW_EXTENSIONS = {
"",
".md",
".txt",
".log",
".json",
".yaml",
".yml",
".cfg",
".ini",
".csv",
".tsv",
".toml",
".py",
".sh",
}
MARKDOWN_EXTENSIONS = {".md", ".markdown"}
def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]:
root = _workspace_root(bot_id)
rel = (rel_path or "").strip().replace("\\", "/")
target = os.path.abspath(os.path.join(root, rel))
if os.path.commonpath([root, target]) != root:
raise HTTPException(status_code=400, detail="invalid workspace path")
return root, target
def _write_text_atomic(target: str, content: str) -> None:
os.makedirs(os.path.dirname(target), exist_ok=True)
tmp = f"{target}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(content)
os.replace(tmp, target)
def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
try:
names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower()))
except FileNotFoundError:
return rows
for name in names:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(path, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path)
base: Dict[str, Any] = {
"name": name,
"path": rel_path,
"ctime": _workspace_stat_ctime_iso(stat),
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
}
if os.path.isdir(abs_path):
node = {**base, "type": "dir"}
if depth > 0:
node["children"] = _build_workspace_tree(abs_path, root, depth - 1)
rows.append(node)
continue
rows.append(
{
**base,
"type": "file",
"size": stat.st_size,
"ext": os.path.splitext(name)[1].lower(),
}
)
return rows
def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower()))
for name in names:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(path, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path)
rows.append(
{
"name": name,
"path": rel_path,
"type": "dir" if os.path.isdir(abs_path) else "file",
"size": stat.st_size if os.path.isfile(abs_path) else None,
"ext": os.path.splitext(name)[1].lower() if os.path.isfile(abs_path) else "",
"ctime": _workspace_stat_ctime_iso(stat),
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
}
)
return rows
def _list_workspace_dir_recursive(path: str, root: str) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for walk_root, dirnames, filenames in os.walk(path):
dirnames.sort(key=lambda v: v.lower())
filenames.sort(key=lambda v: v.lower())
for name in dirnames:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(walk_root, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path)
rows.append(
{
"name": name,
"path": rel_path,
"type": "dir",
"size": None,
"ext": "",
"ctime": _workspace_stat_ctime_iso(stat),
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
}
)
for name in filenames:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(walk_root, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path)
rows.append(
{
"name": name,
"path": rel_path,
"type": "file",
"size": stat.st_size,
"ext": os.path.splitext(name)[1].lower(),
"ctime": _workspace_stat_ctime_iso(stat),
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
}
)
rows.sort(key=lambda v: (v.get("type") != "dir", str(v.get("path", "")).lower()))
return rows
def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
with open(target, "rb") as fh:
fh.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = fh.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
def _build_ranged_workspace_response(target: str, media_type: str, range_header: str) -> Response:
file_size = os.path.getsize(target)
range_match = re.match(r"bytes=(\d*)-(\d*)", range_header.strip())
if not range_match:
raise HTTPException(status_code=416, detail="Invalid range")
start_raw, end_raw = range_match.groups()
if start_raw == "" and end_raw == "":
raise HTTPException(status_code=416, detail="Invalid range")
if start_raw == "":
length = int(end_raw)
if length <= 0:
raise HTTPException(status_code=416, detail="Invalid range")
start = max(file_size - length, 0)
end = file_size - 1
else:
start = int(start_raw)
end = int(end_raw) if end_raw else file_size - 1
if start >= file_size or start < 0:
raise HTTPException(status_code=416, detail="Requested range not satisfiable")
end = min(end, file_size - 1)
if end < start:
raise HTTPException(status_code=416, detail="Requested range not satisfiable")
content_length = end - start + 1
headers = {
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(content_length),
}
return StreamingResponse(
_stream_file_range(target, start, end),
status_code=206,
media_type=media_type or "application/octet-stream",
headers=headers,
)
def _build_workspace_raw_url(bot_id: str, path: str, public: bool) -> str:
normalized = "/".join(part for part in str(path or "").strip().split("/") if part)
if not normalized:
return ""
prefix = "/public" if public else "/api"
return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}"
def _serve_workspace_file(
*,
bot_id: str,
path: str,
download: bool,
request: Request,
public: bool = False,
redirect_html_to_raw: bool = False,
) -> Response:
_root, target = _resolve_workspace_path(bot_id, path)
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(target)
if redirect_html_to_raw and not download and str(media_type or "").startswith("text/html"):
raw_url = _build_workspace_raw_url(bot_id, path, public=public)
if raw_url:
return RedirectResponse(url=raw_url, status_code=307)
range_header = request.headers.get("range", "") if request else ""
if range_header and not download:
return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header)
common_headers = {"Accept-Ranges": "bytes"}
if download:
return FileResponse(
target,
media_type=media_type or "application/octet-stream",
filename=os.path.basename(target),
headers=common_headers,
)
return FileResponse(target, media_type=media_type or "application/octet-stream", headers=common_headers)
def get_workspace_tree_data(
bot_id: str,
*,
path: Optional[str] = None,
recursive: bool = False,
) -> Dict[str, Any]:
root = _workspace_root(bot_id)
if not os.path.isdir(root):
return {"bot_id": bot_id, "root": root, "cwd": "", "parent": None, "entries": []}
_, target = _resolve_workspace_path(bot_id, path)
if not os.path.isdir(target):
raise HTTPException(status_code=400, detail="workspace path is not a directory")
cwd = os.path.relpath(target, root).replace("\\", "/")
if cwd == ".":
cwd = ""
parent = None
if cwd:
parent = os.path.dirname(cwd).replace("\\", "/")
if parent == ".":
parent = ""
return {
"bot_id": bot_id,
"root": root,
"cwd": cwd,
"parent": parent,
"entries": _list_workspace_dir_recursive(target, root) if recursive else _list_workspace_dir(target, root),
}
def read_workspace_text_file(
bot_id: str,
*,
path: str,
max_bytes: int = 200000,
) -> Dict[str, Any]:
root, target = _resolve_workspace_path(bot_id, path)
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="workspace file not found")
ext = os.path.splitext(target)[1].lower()
if ext not in TEXT_PREVIEW_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"unsupported file type: {ext or '(none)'}")
safe_max = max(4096, min(int(max_bytes), 1000000))
with open(target, "rb") as file:
raw = file.read(safe_max + 1)
if b"\x00" in raw:
raise HTTPException(status_code=400, detail="binary file is not previewable")
truncated = len(raw) > safe_max
body = raw[:safe_max] if truncated else raw
rel_path = os.path.relpath(target, root).replace("\\", "/")
return {
"bot_id": bot_id,
"path": rel_path,
"size": os.path.getsize(target),
"is_markdown": ext in MARKDOWN_EXTENSIONS,
"truncated": truncated,
"content": body.decode("utf-8", errors="replace"),
}
def update_workspace_markdown_file(
bot_id: str,
*,
path: str,
content: str,
) -> Dict[str, Any]:
root, target = _resolve_workspace_path(bot_id, path)
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="workspace file not found")
ext = os.path.splitext(target)[1].lower()
if ext not in MARKDOWN_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"editing is only supported for markdown files: {ext or '(none)'}",
)
normalized_content = str(content or "")
encoded = normalized_content.encode("utf-8")
if len(encoded) > 2_000_000:
raise HTTPException(status_code=413, detail="markdown file too large to save")
if "\x00" in normalized_content:
raise HTTPException(status_code=400, detail="markdown content contains invalid null bytes")
_write_text_atomic(target, normalized_content)
rel_path = os.path.relpath(target, root).replace("\\", "/")
return {
"bot_id": bot_id,
"path": rel_path,
"size": os.path.getsize(target),
"is_markdown": True,
"truncated": False,
"content": normalized_content,
}
def serve_workspace_file(
*,
bot_id: str,
path: str,
download: bool,
request: Request,
public: bool = False,
redirect_html_to_raw: bool = False,
) -> Response:
return _serve_workspace_file(
bot_id=bot_id,
path=path,
download=download,
request=request,
public=public,
redirect_html_to_raw=redirect_html_to_raw,
)
def _sanitize_upload_filename(original_name: str) -> str:
name = os.path.basename(original_name).replace("\\", "_").replace("/", "_")
name = re.sub(r"[^\w.\-()+@ ]+", "_", name)
return name or "upload.bin"
async def upload_workspace_files_to_workspace(
bot_id: str,
*,
files: List[UploadFile],
path: Optional[str] = None,
) -> Dict[str, Any]:
if not files:
raise HTTPException(status_code=400, detail="no files uploaded")
platform_settings = get_platform_settings_snapshot()
max_bytes = platform_settings.upload_max_mb * 1024 * 1024
allowed_extensions = set(platform_settings.allowed_attachment_extensions)
root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads")
os.makedirs(upload_dir, exist_ok=True)
safe_dir_real = os.path.abspath(upload_dir)
if os.path.commonpath([root, safe_dir_real]) != root:
raise HTTPException(status_code=400, detail="invalid upload target path")
rows: List[Dict[str, Any]] = []
for upload in files:
original = (upload.filename or "upload.bin").strip() or "upload.bin"
name = _sanitize_upload_filename(original)
ext = str(os.path.splitext(name)[1] or "").strip().lower()
if allowed_extensions and ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"File '{name}' extension is not allowed. Allowed: {', '.join(sorted(allowed_extensions))}",
)
abs_path = os.path.join(safe_dir_real, name)
if os.path.exists(abs_path):
base, file_ext = os.path.splitext(name)
name = f"{base}-{int(datetime.utcnow().timestamp())}{file_ext}"
abs_path = os.path.join(safe_dir_real, name)
total_size = 0
try:
with open(abs_path, "wb") as file:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"File '{name}' too large (max {max_bytes // (1024 * 1024)}MB)",
)
file.write(chunk)
except HTTPException:
if os.path.exists(abs_path):
os.remove(abs_path)
raise
except OSError as exc:
if os.path.exists(abs_path):
os.remove(abs_path)
raise HTTPException(
status_code=500,
detail=f"Failed to write file '{name}': {exc.strerror or str(exc)}",
)
except Exception:
if os.path.exists(abs_path):
os.remove(abs_path)
raise HTTPException(status_code=500, detail=f"Failed to upload file '{name}'")
finally:
await upload.close()
rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
rows.append({"name": name, "path": rel_path, "size": total_size})
return {"bot_id": bot_id, "files": rows}

View File

@ -1,355 +0,0 @@
"""LiteLLM provider implementation for multi-provider support."""
import hashlib
import os
import secrets
import string
from typing import Any
import json_repair
import litellm
from litellm import acompletion
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway
# Standard chat-completion message keys.
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"})
_ALNUM = string.ascii_letters + string.digits
def _short_tool_id() -> str:
"""Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral)."""
return "".join(secrets.choice(_ALNUM) for _ in range(9))
class LiteLLMProvider(LLMProvider):
"""
LLM provider using LiteLLM for multi-provider support.
Supports OpenRouter, Anthropic, OpenAI, Gemini, MiniMax, and many other providers through
a unified interface. Provider-specific logic is driven by the registry
(see providers/registry.py) no if-elif chains needed here.
"""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
default_model: str = "anthropic/claude-opus-4-5",
extra_headers: dict[str, str] | None = None,
provider_name: str | None = None,
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
# Detect gateway / local deployment.
# provider_name (from config key) is the primary signal;
# api_key / api_base are fallback for auto-detection.
self._gateway = find_gateway(provider_name, api_key, api_base)
# Configure environment variables
if api_key:
self._setup_env(api_key, api_base, default_model)
if api_base:
litellm.api_base = api_base
# Disable LiteLLM logging noise
litellm.suppress_debug_info = True
# Drop unsupported parameters for providers (e.g., gpt-5 rejects some params)
litellm.drop_params = True
self._langsmith_enabled = bool(os.getenv("LANGSMITH_API_KEY"))
def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None:
"""Set environment variables based on detected provider."""
spec = self._gateway or find_by_model(model)
if not spec:
return
if not spec.env_key:
# OAuth/provider-only specs (for example: openai_codex)
return
# Gateway/local overrides existing env; standard provider doesn't
if self._gateway:
os.environ[spec.env_key] = api_key
else:
os.environ.setdefault(spec.env_key, api_key)
# Resolve env_extras placeholders:
# {api_key} → user's API key
# {api_base} → user's api_base, falling back to spec.default_api_base
effective_base = api_base or spec.default_api_base
for env_name, env_val in spec.env_extras:
resolved = env_val.replace("{api_key}", api_key)
resolved = resolved.replace("{api_base}", effective_base)
os.environ.setdefault(env_name, resolved)
def _resolve_model(self, model: str) -> str:
"""Resolve model name by applying provider/gateway prefixes."""
if self._gateway:
prefix = self._gateway.litellm_prefix
if self._gateway.strip_model_prefix:
model = model.split("/")[-1]
if prefix:
model = f"{prefix}/{model}"
return model
# Standard mode: auto-prefix for known providers
spec = find_by_model(model)
if spec and spec.litellm_prefix:
model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
if not any(model.startswith(s) for s in spec.skip_prefixes):
model = f"{spec.litellm_prefix}/{model}"
return model
@staticmethod
def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
"""Normalize explicit provider prefixes like `github-copilot/...`."""
if "/" not in model:
return model
prefix, remainder = model.split("/", 1)
if prefix.lower().replace("-", "_") != spec_name:
return model
return f"{canonical_prefix}/{remainder}"
def _supports_cache_control(self, model: str) -> bool:
"""Return True when the provider supports cache_control on content blocks."""
if self._gateway is not None:
return self._gateway.supports_prompt_caching
spec = find_by_model(model)
return spec is not None and spec.supports_prompt_caching
def _apply_cache_control(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
"""Return copies of messages and tools with cache_control injected."""
new_messages = []
for msg in messages:
if msg.get("role") == "system":
content = msg["content"]
if isinstance(content, str):
new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
else:
new_content = list(content)
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
new_messages.append({**msg, "content": new_content})
else:
new_messages.append(msg)
new_tools = tools
if tools:
new_tools = list(tools)
new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
return new_messages, new_tools
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
model_lower = model.lower()
spec = find_by_model(model)
if spec:
for pattern, overrides in spec.model_overrides:
if pattern in model_lower:
kwargs.update(overrides)
return
@staticmethod
def _extra_msg_keys(original_model: str, resolved_model: str) -> frozenset[str]:
"""Return provider-specific extra keys to preserve in request messages."""
spec = find_by_model(original_model) or find_by_model(resolved_model)
if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"):
return _ANTHROPIC_EXTRA_KEYS
return frozenset()
@staticmethod
def _normalize_tool_call_id(tool_call_id: Any) -> Any:
"""Normalize tool_call_id to a provider-safe 9-char alphanumeric form."""
if not isinstance(tool_call_id, str):
return tool_call_id
if len(tool_call_id) == 9 and tool_call_id.isalnum():
return tool_call_id
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
allowed = _ALLOWED_MSG_KEYS | extra_keys
sanitized = LLMProvider._sanitize_request_messages(messages, allowed)
id_map: dict[str, str] = {}
def map_id(value: Any) -> Any:
if not isinstance(value, str):
return value
return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value))
for clean in sanitized:
# Keep assistant tool_calls[].id and tool tool_call_id in sync after
# shortening, otherwise strict providers reject the broken linkage.
if isinstance(clean.get("tool_calls"), list):
normalized_tool_calls = []
for tc in clean["tool_calls"]:
if not isinstance(tc, dict):
normalized_tool_calls.append(tc)
continue
tc_clean = dict(tc)
tc_clean["id"] = map_id(tc_clean.get("id"))
normalized_tool_calls.append(tc_clean)
clean["tool_calls"] = normalized_tool_calls
if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"])
return sanitized
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
"""
Send a chat completion request via LiteLLM.
Args:
messages: List of message dicts with 'role' and 'content'.
tools: Optional list of tool definitions in OpenAI format.
model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5').
max_tokens: Maximum tokens in response.
temperature: Sampling temperature.
Returns:
LLMResponse with content and/or tool calls.
"""
original_model = model or self.default_model
model = self._resolve_model(original_model)
extra_msg_keys = self._extra_msg_keys(original_model, model)
if self._supports_cache_control(original_model):
messages, tools = self._apply_cache_control(messages, tools)
# Clamp max_tokens to at least 1 — negative or zero values cause
# LiteLLM to reject the request with "max_tokens must be at least 1".
max_tokens = max(1, max_tokens)
kwargs: dict[str, Any] = {
"model": model,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys),
"max_tokens": max_tokens,
"temperature": temperature,
}
if self._gateway:
kwargs.update(self._gateway.litellm_kwargs)
# Apply model-specific overrides (e.g. kimi-k2.5 temperature)
self._apply_model_overrides(model, kwargs)
if self._langsmith_enabled:
kwargs.setdefault("callbacks", []).append("langsmith")
# Pass api_key directly — more reliable than env vars alone
if self.api_key:
kwargs["api_key"] = self.api_key
# Pass api_base for custom endpoints
if self.api_base:
kwargs["api_base"] = self.api_base
# Pass extra headers (e.g. APP-Code for AiHubMix)
if self.extra_headers:
kwargs["extra_headers"] = self.extra_headers
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
kwargs["drop_params"] = True
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto"
try:
response = await acompletion(**kwargs)
return self._parse_response(response)
except Exception as e:
# Return error as content for graceful handling
return LLMResponse(
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
def _parse_response(self, response: Any) -> LLMResponse:
"""Parse LiteLLM response into our standard format."""
choice = response.choices[0]
message = choice.message
content = message.content
finish_reason = choice.finish_reason
# Some providers (e.g. GitHub Copilot) split content and tool_calls
# across multiple choices. Merge them so tool_calls are not lost.
raw_tool_calls = []
for ch in response.choices:
msg = ch.message
if hasattr(msg, "tool_calls") and msg.tool_calls:
raw_tool_calls.extend(msg.tool_calls)
if ch.finish_reason in ("tool_calls", "stop"):
finish_reason = ch.finish_reason
if not content and msg.content:
content = msg.content
if len(response.choices) > 1:
logger.debug("LiteLLM response has {} choices, merged {} tool_calls",
len(response.choices), len(raw_tool_calls))
tool_calls = []
for tc in raw_tool_calls:
# Parse arguments from JSON string if needed
args = tc.function.arguments
if isinstance(args, str):
args = json_repair.loads(args)
provider_specific_fields = getattr(tc, "provider_specific_fields", None) or None
function_provider_specific_fields = (
getattr(tc.function, "provider_specific_fields", None) or None
)
tool_calls.append(ToolCallRequest(
id=_short_tool_id(),
name=tc.function.name,
arguments=args,
provider_specific_fields=provider_specific_fields,
function_provider_specific_fields=function_provider_specific_fields,
))
usage = {}
if hasattr(response, "usage") and response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
reasoning_content = getattr(message, "reasoning_content", None) or None
thinking_blocks = getattr(message, "thinking_blocks", None) or None
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason or "stop",
usage=usage,
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
)
def get_default_model(self) -> str:
"""Get the default model."""
return self.default_model

View File

@ -0,0 +1,304 @@
# Dashboard Nanobot 代码结构规范(强制执行)
本文档定义后续前端、后端、`dashboard-edge` 的结构边界与拆分规则。
目标不是“尽可能多拆文件”,而是:
- 保持装配层足够薄
- 保持业务边界清晰
- 避免再次出现单文件多职责膨胀
- 让后续迭代继续走低风险、小步验证路线
本文档自落地起作为**后续开发强制规范**执行。
---
## 1. 总原则
### 1.1 先分层,再分文件
- 优先先把“页面装配 / 业务编排 / 基础设施 / 纯视图”分开,再决定是否继续拆文件。
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
### 1.2 低风险重构优先
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。
- 同一轮改动里,默认**不要**同时做:
- 大规模结构调整
- 新功能
- 行为修复
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。
### 1.3 装配层必须薄
- 页面层、路由层、应用启动层都只负责装配。
- 装配层可以做依赖注入、状态接线、事件转发。
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
### 1.4 新文件必须按主题命名
- 文件名必须直接表达职责。
- 禁止模糊命名,例如:
- `helpers2.py`
- `misc.ts`
- `commonPage.tsx`
- `temp_service.py`
---
## 2. 前端结构规范
### 2.1 目录分层
前端统一按以下层次组织:
- `frontend/src/app`
- 应用壳、全局路由视图、全局初始化
- `frontend/src/modules/<domain>`
- 领域模块入口
- `frontend/src/modules/<domain>/components`
- 纯视图组件、弹层、区块组件
- `frontend/src/modules/<domain>/hooks`
- 领域内控制器 hook、状态编排 hook
- `frontend/src/modules/<domain>/api`
- 仅该领域使用的 API 请求封装
- `frontend/src/modules/<domain>/shared`
- 领域内共享的纯函数、常量、类型桥接
- `frontend/src/components`
- 跨模块通用 UI 组件
- `frontend/src/utils`
- 真正跨领域的通用工具
### 2.2 页面文件职责
页面文件如:
- `frontend/src/modules/platform/PlatformDashboardPage.tsx`
- `frontend/src/modules/platform/NodeWorkspacePage.tsx`
- `frontend/src/modules/platform/NodeHomePage.tsx`
必须遵守:
- 只做页面装配
- 只组织已有区块、弹层、控制器 hook
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
页面文件目标体量:
- 目标:`< 500`
- 可接受上限:`800` 行
- 超过 `800` 行必须优先拆出页面控制器 hook 或区块装配组件
### 2.3 控制器 hook 规范
控制器 hook 用于承载:
- 页面状态
- 副作用
- API 调用编排
- 事件处理
- 派生数据
典型命名:
- `useNodeHomePage`
- `useNodeWorkspacePage`
- `usePlatformDashboardPage`
规则:
- 一个 hook 只服务一个明确页面或一个明确子流程
- hook 不直接产出大量 JSX
- hook 内部允许组合更小的子 hook但不要为了拆分而拆分
控制器 hook 目标体量:
- 目标:`< 800`
- 可接受上限:`1000` 行
- 超过 `1000` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api`
### 2.4 视图组件规范
组件分为两类:
- 区块组件:例如列表区、详情区、摘要卡片区
- 弹层组件:例如 Drawer、Modal、Sheet
规则:
- 视图组件默认不直接请求接口
- 视图组件只接收已经整理好的 props
- 纯视图组件内部不保留与页面强耦合的业务缓存
### 2.5 前端复用原则
- 优先提炼“稳定复用的模式”,不要提炼“碰巧重复一次的代码”
- 三处以上重复,优先考虑抽取
- 同域复用优先放 `modules/<domain>/shared`
- 跨域复用优先放 `src/components``src/utils`
### 2.6 前端禁止事项
- 禁止再次把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
- 禁止创建无明确职责的超通用组件
- 禁止为减少行数而做不可读的过度抽象
---
## 3. 后端结构规范
### 3.1 目录分层
后端统一按以下边界组织:
- `backend/main.py`
- 仅启动入口
- `backend/app_factory.py`
- 应用实例创建
- `backend/bootstrap`
- 依赖装配、应用初始化、生命周期拼装
- `backend/api`
- FastAPI 路由层
- `backend/services`
- 业务用例与领域服务
- `backend/core`
- 数据库、缓存、配置、基础设施适配
- `backend/models`
- ORM 模型
- `backend/schemas`
- 请求/响应 DTO
- `backend/providers`
- runtime/workspace/provision 适配层
### 3.2 启动与装配层规范
以下文件必须保持装配层属性:
- `backend/main.py`
- `backend/app_factory.py`
- `backend/bootstrap/app_runtime.py`
规则:
- 只做依赖创建、注入、路由注册、生命周期绑定
- 不写业务 SQL
- 不写领域规则判断
- 不写长流程编排
### 3.3 Router 规范
`backend/api/*.py` 只允许承担:
- HTTP 参数接收
- schema 校验
- 调用 service
- 把领域异常转换成 HTTP 异常
Router 不允许承担:
- 多步业务编排
- 大量数据聚合
- 数据库表间拼装
- 本地文件系统读写细节
Router 文件体量规则:
- 目标:`< 300`
- 可接受上限:`400` 行
- 超过 `400` 行必须拆成子 router并由装配层统一 `include_router`
### 3.4 Service 规范
Service 必须按业务主题拆分。
允许的 service 类型:
- `*_settings_service.py`
- `*_usage_service.py`
- `*_activity_service.py`
- `*_analytics_service.py`
- `*_overview_service.py`
- `*_query_service.py`
- `*_command_service.py`
- `*_lifecycle_service.py`
Service 文件规则:
- 一个文件只负责一个主题
- 同一文件内允许有私有 helper但 helper 只能服务当前主题
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service
Service 体量规则:
- 目标:`< 350`
- 可接受上限:`500` 行
- 超过 `500` 行必须继续拆
### 3.6 Schema 规范
- `schemas` 只定义 DTO
- 不允许在 schema 中直接读数据库、读文件、发网络请求
- schema 字段演进必须保持前后端契约可追踪
### 3.7 Core 规范
`core` 只允许放:
- 数据库与 Session 管理
- 缓存
- 配置
- 基础设施适配器
不允许把领域业务塞回 `core` 来“躲避 service 变大”。
### 3.8 Provider 规范
`providers` 只处理运行时/工作区/部署目标差异。
不允许把平台业务逻辑塞进 provider。
---
## 4. 本项目后续开发的执行规则
### 4.1 每轮改动的默认顺序
1. 先审计职责边界
2. 先做装配层变薄
3. 再提炼稳定复用块
4. 最后再考虑继续细拆
### 4.2 校验规则
- 前端结构改动后,默认执行 `frontend` 构建校验
- 后端结构改动后,默认至少执行 `python3 -m py_compile`
- 如果改动触达运行时或边界协议,再考虑追加更高层验证
### 4.3 文档同步规则
以下情况必须同步设计文档:
- 新增一层目录边界
- 新增一个领域的标准拆法
- 改变页面/服务的职责划分
- 把兼容层正式降级为装配/导出层
### 4.4 禁止事项
- 禁止回到“大文件集中堆功能”的开发方式
- 禁止为了图省事把新逻辑加回兼容层
- 禁止在没有明确复用收益时过度抽象
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
---
## 5. 当前执行基线2026-03
当前结构治理目标分两层:
- 第一层:主入口、页面入口、路由入口必须变薄
- 第二层:领域内部的 service / hook / overlays / sections 必须按主题稳定收口
后续所有新增功能与重构,均以本文档为准执行。

22
fix_services.py 100644
View File

@ -0,0 +1,22 @@
import os
def fix_sf(path):
with open(path, 'r') as f:
lines = f.readlines()
new_lines = []
for line in lines:
if "from services.bot_service import _skills_root" in line: continue
if "from services.bot_service import _workspace_root" in line: continue
new_lines.append(line)
# Add corrected imports at the top
if path.endswith("skill_service.py"):
new_lines.insert(20, "from services.bot_service import _skills_root, _workspace_root\n")
elif path.endswith("workspace_service.py"):
new_lines.insert(20, "from services.bot_service import _workspace_root\n")
with open(path, 'w') as f:
f.writelines(new_lines)
# Wait, if _skills_root is defined in skill_service.py, it shouldn't be imported from bot_service.
# Let's check where it IS defined.

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,33 @@
background: var(--panel);
}
.app-nav-drawer-hero {
min-height: 220px;
}
.app-nav-drawer-groups {
overflow-y: auto;
padding: 12px 0 16px;
}
.app-nav-drawer-group {
display: grid;
gap: 8px;
}
.app-nav-drawer-group + .app-nav-drawer-group {
margin-top: 8px;
}
.app-nav-drawer-group-title {
padding: 0 24px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.app-bot-panel-drawer-item {
width: 100%;
border: 0;
@ -150,62 +177,19 @@
.grid-ops.grid-ops-compact {
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
}
.platform-summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.platform-resource-card {
grid-column: span 3;
}
}
@media (max-width: 1160px) {
.grid-2,
.grid-ops,
.wizard-steps,
.wizard-steps-4,
.factory-kpi-grid,
.summary-grid,
.wizard-agent-layout {
.factory-kpi-grid {
grid-template-columns: 1fr;
}
.platform-grid,
.platform-main-grid,
.platform-monitor-grid,
.platform-entry-grid,
.platform-summary-grid {
.app-layout.has-nav-rail {
grid-template-columns: 1fr;
}
.platform-resource-card {
grid-column: auto;
}
.platform-template-layout {
grid-template-columns: 1fr;
}
.skill-market-admin-layout,
.skill-market-card-grid,
.skill-market-browser-grid {
grid-template-columns: 1fr;
}
.skill-market-list-shell {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.platform-template-tabs {
max-height: 220px;
}
.platform-usage-head,
.platform-usage-row {
grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px;
}
.app-frame {
height: auto;
min-height: calc(100vh - 36px);
@ -241,23 +225,13 @@
justify-content: flex-end;
}
.wizard-shell {
min-height: 640px;
.app-route-trail {
row-gap: 4px;
}
}
@media (max-width: 980px) {
.app-shell-compact .platform-grid.is-compact {
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr);
}
.app-shell-compact .platform-bot-list-panel {
height: 100%;
min-height: 0;
}
.grid-ops.grid-ops-compact {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr);
@ -270,101 +244,6 @@
min-height: 0;
}
.platform-bot-list-panel {
min-height: calc(100dvh - 170px);
}
.platform-bot-actions,
.platform-image-row,
.platform-activity-row {
flex-direction: column;
align-items: flex-start;
}
.platform-selected-bot-headline {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.platform-selected-bot-head {
flex-direction: column;
align-items: stretch;
}
.platform-selected-bot-title-block,
.platform-selected-bot-statuses {
width: 100%;
}
.platform-selected-bot-actions {
justify-content: flex-start;
}
.platform-selected-bot-grid {
grid-template-columns: 1fr;
}
.platform-resource-meter {
grid-template-columns: 24px minmax(0, 1fr) 64px;
}
.platform-usage-head {
display: none;
}
.platform-model-analytics-head {
flex-direction: column;
align-items: stretch;
}
.platform-model-analytics-total {
justify-items: start;
text-align: left;
}
.platform-usage-row {
grid-template-columns: 1fr;
}
.platform-selected-bot-last-row,
.platform-settings-pager,
.platform-usage-pager,
.platform-template-header,
.skill-market-admin-toolbar,
.skill-market-browser-toolbar,
.skill-market-pager,
.skill-market-page-info-card,
.skill-market-page-info-main,
.skill-market-editor-head,
.skill-market-card-top,
.skill-market-card-footer,
.row-actions-inline {
flex-direction: column;
align-items: stretch;
}
.platform-compact-sheet-card {
max-height: 90dvh;
}
.platform-compact-sheet-body {
max-height: calc(90dvh - 60px);
padding: 0 10px 12px;
}
.skill-market-list-shell {
grid-template-columns: 1fr;
}
.skill-market-drawer {
position: fixed;
top: 84px;
right: 12px;
bottom: 12px;
width: min(460px, calc(100vw - 24px));
}
.app-route-crumb {
width: 100%;
text-align: left;

View File

@ -1,28 +1,31 @@
import { useEffect, useState, type ReactElement } from 'react';
import axios from 'axios';
import { Activity, ChevronDown, ChevronUp, Menu, MessageSquareText, MoonStar, SunMedium, X } from 'lucide-react';
import { useAppStore } from './store/appStore';
import { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env';
import { pickLocale } from './i18n';
import { appZhCn } from './i18n/app.zh-cn';
import { appEn } from './i18n/app.en';
import { LucentTooltip } from './components/lucent/LucentTooltip';
import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, SunMedium, X } from 'lucide-react';
import { PasswordInput } from './components/PasswordInput';
import { LucentTooltip } from './components/lucent/LucentTooltip';
import { APP_ENDPOINTS } from './config/env';
import { useBotsSync } from './hooks/useBotsSync';
import { appEn } from './i18n/app.en';
import { appZhCn } from './i18n/app.zh-cn';
import { pickLocale } from './i18n';
import { BotHomePage } from './modules/bot-home/BotHomePage';
import { PlatformAdminDashboardPage } from './modules/platform/PlatformAdminDashboardPage';
import { PlatformBotManagementPage } from './modules/platform/PlatformBotManagementPage';
import { PlatformImageManagementPage } from './modules/platform/PlatformImageManagementPage';
import { PlatformSettingsPage } from './modules/platform/components/PlatformSettingsModal';
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { TemplateManagerPage } from './modules/platform/components/TemplateManagerModal';
import { useAppStore } from './store/appStore';
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import { BotHomePage } from './modules/bot-home/BotHomePage';
import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage';
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
import './components/ui/SharedUi.css';
import './App.css';
import './App.h5.css';
import './modules/platform/PlatformDashboardPage.css';
const defaultLoadingPage = {
title: 'Dashboard Nanobot',
subtitle: '平台正在准备管理面板',
description: '请稍候,正在加载 Bot 平台数据。',
};
const defaultLoadingTitle = 'Dashboard Nanobot';
type CompactBotPanelTab = 'chat' | 'runtime';
@ -30,11 +33,12 @@ function AuthenticatedApp() {
const route = useAppRoute();
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isZh = locale === 'zh';
const [viewportCompact, setViewportCompact] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(max-width: 980px)').matches;
});
const [headerCollapsed, setHeaderCollapsed] = useState(false);
const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false);
const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false);
const [botCompactPanelTab, setBotCompactPanelTab] = useState<CompactBotPanelTab>('chat');
const [singleBotPassword, setSingleBotPassword] = useState('');
@ -57,44 +61,26 @@ function AuthenticatedApp() {
return () => media.removeEventListener('change', apply);
}, []);
useEffect(() => {
setHeaderCollapsed(readCompactModeFromUrl() || viewportCompact);
}, [viewportCompact, route.kind, forcedBotId]);
const compactMode = readCompactModeFromUrl() || viewportCompact;
const isCompactShell = compactMode;
const hideHeader = route.kind === 'dashboard' && compactMode;
const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode;
const allowHeaderCollapse = isCompactShell && !showBotPanelDrawerEntry;
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
const forcedBotName = String(forcedBot?.name || '').trim();
const forcedBotIdLabel = String(forcedBotId || '').trim();
const botHeaderTitle = forcedBotName || defaultLoadingPage.title;
const botHeaderSubtitle = forcedBotIdLabel || defaultLoadingPage.title;
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingPage.title;
const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle;
const shouldPromptSingleBotPassword = Boolean(
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
);
const headerTitle =
showBotPanelDrawerEntry
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
: route.kind === 'bot'
? botHeaderTitle
: route.kind === 'dashboard-skills'
? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace')
: t.title;
const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined });
const showNavRail = route.kind !== 'bot' && !compactMode;
const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode;
const showBotPanelDrawerEntry = route.kind === 'bot' && compactMode;
const useCompactSimpleHeader = showBotPanelDrawerEntry || showAppNavDrawerEntry;
const headerTitle = showBotPanelDrawerEntry
? (botCompactPanelTab === 'runtime' ? t.botPanels.runtime : t.botPanels.chat)
: routeMeta.title;
useEffect(() => {
if (route.kind === 'dashboard') {
document.title = t.title;
return;
}
if (route.kind === 'dashboard-skills') {
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
return;
}
document.title = `${t.title} - ${botDocumentTitle}`;
}, [botDocumentTitle, locale, route.kind, t.title]);
document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`;
}, [botDocumentTitle, route.kind, routeMeta.title, t.title]);
useEffect(() => {
setSingleBotUnlocked(false);
@ -109,6 +95,10 @@ function AuthenticatedApp() {
}
}, [forcedBotId, showBotPanelDrawerEntry]);
useEffect(() => {
if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false);
}, [route.kind, showAppNavDrawerEntry]);
useEffect(() => {
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
const stored = getBotAccessPassword(forcedBotId);
@ -155,81 +145,151 @@ function AuthenticatedApp() {
}
};
const navigateToDashboard = () => {
if (typeof window === 'undefined') return;
window.history.pushState({}, '', '/dashboard');
window.dispatchEvent(new PopStateEvent('popstate'));
};
const botPanelLabels = t.botPanels;
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingPage.title;
const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle;
const drawerBotId = String(forcedBotId || '').trim() || '-';
const nextTheme = theme === 'dark' ? 'light' : 'dark';
const nextLocale = locale === 'zh' ? 'en' : 'zh';
const navGroups: Array<{
key: 'admin' | 'system';
label: string;
items: Array<{
kind: Exclude<AppRoute['kind'], 'bot'>;
label: string;
icon: typeof LayoutDashboard;
}>;
}> = [
{
key: 'admin',
label: 'Admin',
items: [
{ kind: 'admin-dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ kind: 'admin-bots', label: isZh ? 'Bot 管理' : 'Bot Management', icon: Bot },
],
},
{
key: 'system',
label: 'System',
items: [
{ kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer },
{ kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText },
{ kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 },
{ kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes },
],
},
];
const currentNavItem = navGroups
.flatMap((group) => group.items)
.find((item) => item.kind === routeMeta.navKey);
const HeaderIcon = currentNavItem?.icon || Bot;
const desktopHeaderText = route.kind === 'bot'
? [forcedBotName || routeMeta.title, forcedBotIdLabel].filter(Boolean).join(' / ') || routeMeta.title
: routeMeta.headerTrail;
const renderRoutePage = () => {
switch (route.kind) {
case 'admin-dashboard':
return <PlatformAdminDashboardPage compactMode={compactMode} />;
case 'admin-bots':
return <PlatformBotManagementPage compactMode={compactMode} />;
case 'system-skills':
return <SkillMarketManagerPage isZh={isZh} />;
case 'system-templates':
return <TemplateManagerPage isZh={isZh} />;
case 'system-settings':
return <PlatformSettingsPage isZh={isZh} />;
case 'system-images':
return <PlatformImageManagementPage isZh={isZh} />;
case 'bot':
return (
<BotHomePage
botId={forcedBotId}
compactMode={compactMode}
compactPanelTab={botCompactPanelTab}
onCompactPanelTabChange={setBotCompactPanelTab}
/>
);
default:
return <PlatformAdminDashboardPage compactMode={compactMode} />;
}
};
return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
{!hideHeader ? (
<header
className={`app-header ${allowHeaderCollapse ? 'app-header-collapsible' : ''} ${allowHeaderCollapse && headerCollapsed ? 'is-collapsed' : ''} ${showBotPanelDrawerEntry ? 'app-header-bot-mobile' : ''}`}
onClick={() => {
if (allowHeaderCollapse && headerCollapsed) setHeaderCollapsed(false);
}}
>
<div className={`row-between app-header-top ${showBotPanelDrawerEntry ? 'app-header-top-bot-mobile' : ''}`}>
<div className={`app-layout ${showNavRail ? 'has-nav-rail' : ''}`}>
{showNavRail ? (
<aside className="app-nav-rail" aria-label={isZh ? '平台导航' : 'Platform navigation'}>
<div className="app-nav-rail-brand">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-nav-rail-brand-icon" />
</div>
<div className="app-nav-rail-groups">
{navGroups.map((group) => (
<div key={group.key} className="app-nav-group">
<div className="app-nav-group-label">{group.label}</div>
<div className="app-nav-group-items">
{group.items.map((item) => {
const Icon = item.icon;
const active = routeMeta.navKey === item.kind;
return (
<LucentTooltip key={item.kind} content={item.label}>
<button
type="button"
className={`app-nav-rail-item ${active ? 'is-active' : ''}`}
onClick={() => navigateToRoute({ kind: item.kind })}
aria-label={item.label}
>
<Icon size={18} />
</button>
</LucentTooltip>
);
})}
</div>
</div>
))}
</div>
</aside>
) : null}
<div className="app-frame">
<header className={`app-header ${useCompactSimpleHeader ? 'app-header-bot-mobile' : ''}`}>
<div className={`row-between app-header-top ${useCompactSimpleHeader ? 'app-header-top-bot-mobile' : ''}`}>
<div className="app-title">
{showBotPanelDrawerEntry ? (
{useCompactSimpleHeader ? (
<button
type="button"
className="app-bot-panel-menu-btn"
onClick={(event) => {
event.stopPropagation();
setBotPanelDrawerOpen(true);
if (showBotPanelDrawerEntry) {
setBotPanelDrawerOpen(true);
} else {
setAppNavDrawerOpen(true);
}
}}
aria-label={botPanelLabels.title}
title={botPanelLabels.title}
aria-label={showBotPanelDrawerEntry ? botPanelLabels.title : (isZh ? '打开导航' : 'Open navigation')}
title={showBotPanelDrawerEntry ? botPanelLabels.title : (isZh ? '打开导航' : 'Open navigation')}
>
<Menu size={14} />
</button>
) : null}
{!showBotPanelDrawerEntry ? (
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
) : null}
<div className="app-title-main">
<h1>{headerTitle}</h1>
{!showBotPanelDrawerEntry && route.kind === 'dashboard-skills' ? (
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
</button>
) : !showBotPanelDrawerEntry ? (
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: route.kind === 'bot'
? botHeaderSubtitle
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
{!useCompactSimpleHeader ? (
<div className="app-route-title-row">
<span className="app-route-title-icon" aria-hidden="true">
<HeaderIcon size={20} />
</span>
<h1 className={route.kind === 'bot' ? 'app-route-heading is-entity' : 'app-route-heading'}>
{desktopHeaderText}
</h1>
</div>
) : null}
{allowHeaderCollapse ? (
<button
type="button"
className="app-header-toggle-inline"
onClick={(event) => {
event.stopPropagation();
setHeaderCollapsed((value) => !value);
}}
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
>
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
) : null}
) : (
<h1>{headerTitle}</h1>
)}
</div>
</div>
<div className="app-header-actions">
{showBotPanelDrawerEntry ? (
{useCompactSimpleHeader ? (
<div className="global-switches global-switches-compact-lite">
<LucentTooltip content={nextTheme === 'light' ? t.light : t.dark}>
<button
@ -250,7 +310,7 @@ function AuthenticatedApp() {
</button>
</LucentTooltip>
</div>
) : !headerCollapsed ? (
) : (
<div className="global-switches">
<div className="switch-compact">
<LucentTooltip content={t.dark}>
@ -278,28 +338,72 @@ function AuthenticatedApp() {
</LucentTooltip>
</div>
</div>
) : null}
)}
</div>
</div>
</header>
) : null}
<main className="main-stage">
{route.kind === 'dashboard' ? (
<PlatformDashboardPage compactMode={compactMode} />
) : route.kind === 'dashboard-skills' ? (
<SkillMarketManagerPage isZh={locale === 'zh'} />
) : (
<BotHomePage
botId={forcedBotId}
compactMode={compactMode}
compactPanelTab={botCompactPanelTab}
onCompactPanelTabChange={setBotCompactPanelTab}
/>
)}
</main>
<main className="main-stage">
{renderRoutePage()}
</main>
</div>
</div>
{showAppNavDrawerEntry && appNavDrawerOpen ? (
<div className="app-bot-panel-drawer-mask" onClick={() => setAppNavDrawerOpen(false)}>
<aside
className="app-bot-panel-drawer app-nav-drawer"
onClick={(event) => event.stopPropagation()}
aria-label={isZh ? '平台导航' : 'Platform navigation'}
>
<div className="app-bot-panel-drawer-hero app-nav-drawer-hero">
<button
type="button"
className="app-bot-panel-drawer-close"
onClick={() => setAppNavDrawerOpen(false)}
aria-label={t.close}
title={t.close}
>
<X size={16} />
</button>
<div className="app-bot-panel-drawer-avatar">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-bot-panel-drawer-avatar-icon" />
</div>
<div className="app-bot-panel-drawer-title">{t.title}</div>
<div className="app-bot-panel-drawer-subtitle">{isZh ? '页面导航' : 'Page Navigation'}</div>
</div>
<div className="app-nav-drawer-groups">
{navGroups.map((group) => (
<section key={group.key} className="app-nav-drawer-group">
<div className="app-nav-drawer-group-title">{group.label}</div>
<div className="app-bot-panel-drawer-list">
{group.items.map((item) => {
const Icon = item.icon;
const active = routeMeta.navKey === item.kind;
return (
<button
key={item.kind}
type="button"
className={`app-bot-panel-drawer-item ${active ? 'is-active' : ''}`}
onClick={() => {
navigateToRoute({ kind: item.kind });
setAppNavDrawerOpen(false);
}}
>
<Icon size={16} />
<span>{item.label}</span>
</button>
);
})}
</div>
</section>
))}
</div>
</aside>
</div>
) : null}
{showBotPanelDrawerEntry && botPanelDrawerOpen ? (
<div className="app-bot-panel-drawer-mask" onClick={() => setBotPanelDrawerOpen(false)}>
<aside
@ -441,8 +545,10 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
}
} catch {
if (!alive) return;
setRequired(false);
setAuthenticated(true);
clearPanelAccessPassword();
setRequired(true);
setAuthenticated(false);
setError(locale === 'zh' ? '无法校验面板访问状态,请检查后端连接后重试。' : 'Unable to verify panel access. Check the backend connection and try again.');
setChecking(false);
}
};

View File

@ -0,0 +1,162 @@
.drawer-shell-mask {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
justify-content: flex-end;
background: rgba(12, 18, 31, 0.28);
opacity: 0;
pointer-events: none;
transition: opacity 260ms ease;
}
.drawer-shell-mask.is-open {
opacity: 1;
pointer-events: auto;
}
.drawer-shell {
height: 100dvh;
max-width: 100vw;
}
.drawer-shell.drawer-shell-standard {
width: min(640px, 100vw);
}
.drawer-shell.drawer-shell-extend {
width: min(1040px, 100vw);
}
.drawer-shell-surface {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background:
linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, var(--brand-soft) 4%), var(--panel)),
var(--panel);
border-left: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
box-shadow: -18px 0 42px rgba(13, 24, 45, 0.22);
opacity: 0.98;
transform: translateX(56px);
transition:
transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms ease,
box-shadow 260ms ease;
will-change: transform, opacity;
}
.drawer-shell-surface.is-open {
opacity: 1;
transform: translateX(0);
}
.drawer-shell-header,
.drawer-shell-footer {
flex: 0 0 auto;
background: inherit;
}
.drawer-shell-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid color-mix(in oklab, var(--line) 82%, transparent);
}
.drawer-shell-title {
min-width: 0;
display: grid;
gap: 6px;
}
.drawer-shell-title h3 {
margin: 0;
font-size: 20px;
line-height: 1.25;
color: var(--title);
}
.drawer-shell-subtitle {
color: var(--subtitle);
font-size: 13px;
line-height: 1.6;
}
.drawer-shell-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.drawer-shell-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 20px 24px 24px;
overscroll-behavior: contain;
}
.drawer-shell-footer {
position: sticky;
bottom: 0;
z-index: 2;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in oklab, var(--line) 82%, transparent);
box-shadow: 0 -14px 30px rgba(13, 24, 45, 0.08);
}
.drawer-shell-footer-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.drawer-shell-footer-content .drawer-shell-footer-main {
flex: 1 1 auto;
min-width: 0;
}
.app-shell[data-theme='light'] .drawer-shell-mask {
background: rgba(111, 138, 179, 0.18);
}
@media (max-width: 900px) {
.drawer-shell,
.drawer-shell.drawer-shell-standard,
.drawer-shell.drawer-shell-extend {
width: 100vw;
}
.drawer-shell-header,
.drawer-shell-body,
.drawer-shell-footer {
padding-inline: 18px;
}
.drawer-shell-footer-content {
align-items: stretch;
}
.drawer-shell-header {
padding-top: calc(18px + env(safe-area-inset-top, 0px));
}
}
@media (prefers-reduced-motion: reduce) {
.drawer-shell-mask,
.drawer-shell-surface {
transition: none;
}
}

View File

@ -0,0 +1,118 @@
import { useEffect, useState, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { LucentIconButton } from './lucent/LucentIconButton';
import './DrawerShell.css';
type DrawerShellSize = 'standard' | 'extend';
const DRAWER_ANIMATION_MS = 260;
interface DrawerShellProps {
open: boolean;
onClose: () => void;
title: ReactNode;
subtitle?: ReactNode;
headerActions?: ReactNode;
footer?: ReactNode;
children: ReactNode;
closeLabel?: string;
size?: DrawerShellSize;
surfaceClassName?: string;
bodyClassName?: string;
}
function joinClassNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
}
export function DrawerShell({
open,
onClose,
title,
subtitle,
headerActions,
footer,
children,
closeLabel,
size = 'standard',
surfaceClassName,
bodyClassName,
}: DrawerShellProps) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (!open) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, open]);
useEffect(() => {
if (open) {
setMounted(true);
const frameId = window.requestAnimationFrame(() => {
setVisible(true);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}
setVisible(false);
const timerId = window.setTimeout(() => {
setMounted(false);
}, DRAWER_ANIMATION_MS);
return () => {
window.clearTimeout(timerId);
};
}, [open]);
if (!mounted) return null;
const shellClassName = joinClassNames(
'drawer-shell',
size === 'extend' ? 'drawer-shell-extend' : 'drawer-shell-standard',
);
return (
<div className={joinClassNames('drawer-shell-mask', visible && 'is-open')} onClick={onClose}>
<aside className={shellClassName} onClick={(event) => event.stopPropagation()}>
<div className={joinClassNames('drawer-shell-surface', visible && 'is-open', surfaceClassName)}>
<div className="drawer-shell-header">
<div className="drawer-shell-title">
<h3>{title}</h3>
{subtitle ? <div className="drawer-shell-subtitle">{subtitle}</div> : null}
</div>
<div className="drawer-shell-header-actions">
{headerActions}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onClose}
tooltip={closeLabel || 'Close'}
aria-label={closeLabel || 'Close'}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className={joinClassNames('drawer-shell-body', bodyClassName)}>
{children}
</div>
{footer ? <div className="drawer-shell-footer">{footer}</div> : null}
</div>
</aside>
</div>
);
}

View File

@ -0,0 +1,407 @@
.skill-market-modal-shell {
max-width: min(1480px, 96vw);
display: flex;
flex-direction: column;
min-height: min(920px, calc(100dvh - 48px));
}
.skill-market-browser-shell {
max-width: min(1400px, 96vw);
width: min(1400px, 96vw);
display: flex;
flex-direction: column;
min-height: min(920px, calc(100dvh - 48px));
}
.skill-market-page-shell {
width: 100%;
margin: 0;
padding: 18px;
border-radius: 22px;
gap: 18px;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 126px);
}
.skill-market-page-info-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 22px 24px;
border-radius: 22px;
}
.skill-market-page-info-main {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
flex: 1 1 auto;
}
.skill-market-page-info-copy {
min-width: 0;
display: grid;
gap: 6px;
}
.skill-market-page-info-copy strong {
display: block;
color: var(--title);
font-size: 17px;
line-height: 1.35;
}
.skill-market-page-info-copy div {
color: var(--subtitle);
font-size: 13px;
line-height: 1.7;
}
.skill-market-admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.skill-market-search {
flex: 1 1 auto;
max-width: 560px;
}
.skill-market-admin-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.skill-market-create-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.skill-market-create-btn svg {
flex: 0 0 auto;
}
.skill-market-page-workspace {
position: relative;
min-height: 0;
flex: 1 1 auto;
padding-top: 3px;
padding-right: 4px;
overflow: auto;
}
.skill-market-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-content: start;
min-height: 0;
padding-right: 4px;
}
.skill-market-list-shell {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.skill-market-browser-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-height: 0;
flex: 1 1 auto;
align-content: start;
grid-auto-rows: 1fr;
padding-top: 3px;
}
.skill-market-card,
.skill-market-empty-card {
min-height: 188px;
}
.skill-market-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 18px;
border: 1px solid color-mix(in oklab, var(--line) 72%, #f0b36a 28%);
background:
radial-gradient(circle at top right, color-mix(in oklab, var(--brand-soft) 36%, transparent), transparent 38%),
linear-gradient(180deg, color-mix(in oklab, var(--panel) 88%, #ffffff 12%), color-mix(in oklab, var(--panel) 96%, #f4eadf 4%));
box-shadow: 0 14px 30px rgba(13, 24, 45, 0.12);
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.skill-market-card:hover,
.skill-market-card.is-active {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%);
box-shadow: 0 18px 34px rgba(13, 24, 45, 0.16);
}
.skill-market-card-top,
.skill-market-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.skill-market-card-title-wrap {
min-width: 0;
}
.skill-market-card-title-wrap h4 {
margin: 0;
font-size: 17px;
line-height: 1.25;
color: var(--title);
word-break: break-word;
}
.skill-market-card-key {
margin-top: 5px;
color: var(--muted);
font-size: 11px;
word-break: break-word;
}
.skill-market-card-actions {
display: flex;
gap: 8px;
}
.skill-market-card-desc {
margin: 0;
color: var(--subtitle);
font-size: 13px;
line-height: 1.55;
min-height: 60px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.skill-market-card-meta {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 11px;
}
.skill-market-card-meta span,
.skill-market-card-footer {
display: flex;
align-items: center;
gap: 8px;
}
.skill-market-card-footer {
margin-top: auto;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
color: var(--muted);
font-size: 11px;
}
.skill-market-card-status.is-ok {
color: #d98c1f;
}
.skill-market-card-status.is-missing {
color: var(--err);
}
.skill-market-browser-card {
min-height: 312px;
padding-bottom: 16px;
}
.skill-market-browser-badge {
font-size: 11px;
padding: 6px 10px;
border-radius: 16px;
}
.skill-market-browser-desc {
min-height: 80px;
-webkit-line-clamp: 4;
}
.skill-market-browser-meta {
margin-top: auto;
gap: 8px;
font-size: 12px;
}
.skill-market-browser-footer {
align-items: flex-end;
}
.skill-market-install-btn {
min-height: 38px;
padding-inline: 14px;
border-radius: 16px;
box-shadow: 0 10px 24px rgba(43, 87, 199, 0.24);
}
.skill-market-empty-card {
border: 1px dashed color-mix(in oklab, var(--line) 78%, var(--brand) 22%);
border-radius: 22px;
background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%);
}
.skill-market-editor {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
min-height: 0;
max-width: 720px;
}
.skill-market-drawer-body {
padding-bottom: 28px;
}
.skill-market-editor-textarea {
min-height: 180px;
}
.skill-market-upload-card {
display: grid;
gap: 10px;
padding: 14px;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%);
background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%);
}
.skill-market-upload-card.has-file {
border-color: color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
}
.skill-market-upload-foot {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.skill-market-file-picker {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 58px;
padding: 12px 14px;
border-radius: 12px;
border: 1px dashed color-mix(in oklab, var(--line) 60%, var(--brand) 40%);
background: color-mix(in oklab, var(--panel) 82%, #ffffff 18%);
color: var(--text);
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease;
}
.skill-market-file-picker:hover {
border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
background: color-mix(in oklab, var(--panel) 74%, var(--brand-soft) 26%);
}
.skill-market-file-picker input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.skill-market-file-picker-copy {
min-width: 0;
display: grid;
gap: 0;
}
.skill-market-file-picker-title {
color: var(--title);
font-size: 13px;
font-weight: 700;
line-height: 1.4;
word-break: break-word;
}
.skill-market-file-picker-action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
background: color-mix(in oklab, var(--brand) 14%, transparent);
color: var(--icon);
font-size: 12px;
font-weight: 700;
}
.skill-market-browser-toolbar,
.skill-market-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.skill-market-pager {
margin-top: 16px;
font-size: 12px;
color: var(--muted);
}
.app-shell[data-theme='light'] .skill-market-file-picker {
background: color-mix(in oklab, var(--panel) 80%, #f7fbff 20%);
}
@media (max-width: 1160px) {
.skill-market-card-grid,
.skill-market-browser-grid {
grid-template-columns: 1fr;
}
.skill-market-list-shell {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.skill-market-admin-toolbar,
.skill-market-browser-toolbar,
.skill-market-pager,
.skill-market-page-info-card,
.skill-market-page-info-main,
.skill-market-editor-head,
.skill-market-card-top,
.skill-market-card-footer {
flex-direction: column;
align-items: stretch;
}
.skill-market-list-shell {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,6 @@
.section-mini-title {
margin: 0;
font-size: 13px;
color: var(--subtitle);
font-weight: 700;
}

View File

@ -32,6 +32,16 @@ export const channelsEn = {
botToken: 'Bot Token',
appToken: 'App Token',
telegramToken: 'Telegram Bot Token',
weixinLoginHint: 'Use the link below to scan and log in.',
weixinLoginOpen: 'Open link',
weixinRelogin: 'Relogin',
weixinReloginDone: 'Weixin state cleared. If the bot was running, it has been restarted and should generate a fresh QR login flow.',
weixinReloginFail: 'Failed to trigger Weixin relogin.',
weixinRouteTag: 'Route Tag (optional)',
weixinStateDir: 'State Directory (optional)',
weixinPollTimeout: 'Long Poll Timeout (seconds)',
weixinBaseUrl: 'Base API URL (optional)',
weixinCdnBaseUrl: 'Media CDN URL (optional)',
emailConsentGranted: 'Mailbox consent granted',
emailFromAddress: 'From Address',
emailAllowFrom: 'Allowed Senders',

View File

@ -32,6 +32,16 @@ export const channelsZhCn = {
botToken: 'Bot Token',
appToken: 'App Token',
telegramToken: 'Telegram Bot Token',
weixinLoginHint: '请使用下方链接扫码登录',
weixinLoginOpen: '打开链接',
weixinRelogin: '重登录',
weixinReloginDone: '已清理微信登录状态;若 Bot 正在运行,已自动重启并重新进入扫码流程。',
weixinReloginFail: '微信重登录操作失败。',
weixinRouteTag: 'Route Tag可选',
weixinStateDir: '状态目录(可选)',
weixinPollTimeout: '长轮询超时(秒)',
weixinBaseUrl: '基础 API 地址(可选)',
weixinCdnBaseUrl: '媒体 CDN 地址(可选)',
emailConsentGranted: '已获得邮箱访问授权',
emailFromAddress: '发件人地址',
emailAllowFrom: '允许发件人',

View File

@ -2,6 +2,11 @@ export const managementEn = {
opFail: 'Operation failed. Check Docker.',
botInstances: 'Bot Instances',
newBot: 'New Bot',
botOverview: 'Bot Overview',
workspace: 'Workspace',
dockerLogs: 'Docker Logs',
dockerLogsHint: 'Showing the last 10 container log lines',
dockerLogsEmpty: 'No Docker logs yet.',
detailsTitle: 'Instance configuration and runtime details',
kernel: 'Kernel',
model: 'Model',
@ -25,6 +30,7 @@ export const managementEn = {
modelPlaceholder: 'gpt-4o / deepseek-chat',
soulLabel: 'Persona Prompt (SOUL.md)',
cancel: 'Cancel',
creating: 'Creating...',
submit: 'Create Bot',
source: 'Source',
pypi: 'PyPI',

View File

@ -2,6 +2,11 @@ export const managementZhCn = {
opFail: '操作失败,请检查 Docker',
botInstances: 'Bot 实例',
newBot: '新建 Bot',
botOverview: 'Bot 概览',
workspace: '工作区',
dockerLogs: 'Docker Logs',
dockerLogsHint: '仅展示最后 10 条容器日志',
dockerLogsEmpty: '暂无 Docker 日志',
detailsTitle: '实例配置与运行时信息',
kernel: '内核',
model: '模型',
@ -25,6 +30,7 @@ export const managementZhCn = {
modelPlaceholder: 'gpt-4o / deepseek-chat',
soulLabel: '人设提示词 (SOUL.md)',
cancel: '取消',
creating: '创建中...',
submit: '确认孵化',
source: '来源',
pypi: 'PyPI',

View File

@ -61,4 +61,6 @@ export const wizardEn = {
channels: 'Channels',
autoStart: 'Auto start after creation',
creating: 'Creating...',
cancelCreating: 'Cancel creation',
createCanceled: 'Creation canceled.',
};

View File

@ -61,4 +61,6 @@ export const wizardZhCn = {
channels: '渠道',
autoStart: '创建后自动启动',
creating: '创建中...',
cancelCreating: '取消创建',
createCanceled: '已取消创建。',
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { SystemDefaultsResponse } from '../types';
export interface SystemTemplatesResponse {
agent_md_templates?: Record<string, unknown>;
topic_presets?: Record<string, unknown>;
}
export function fetchDashboardSystemDefaults() {
return axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`).then((res) => res.data);
}
export function fetchDashboardSystemTemplates() {
return axios.get<SystemTemplatesResponse>(`${APP_ENDPOINTS.apiBase}/system/templates`).then((res) => res.data);
}
export function updateDashboardSystemTemplates(payload: {
agent_md_templates?: Record<string, string>;
topic_presets?: Record<string, unknown>;
}) {
return axios.put<SystemTemplatesResponse>(`${APP_ENDPOINTS.apiBase}/system/templates`, payload).then((res) => res.data);
}

View File

@ -0,0 +1,123 @@
import type { ComponentProps } from 'react';
import { MessageCircle, MessageSquareText, X } from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { CreateBotWizardModal } from '../../onboarding/CreateBotWizardModal';
import { TopicFeedPanel } from '../topic/TopicFeedPanel';
import type { CompactPanelTab, RuntimeViewMode } from '../types';
import { BotListPanel } from './BotListPanel';
import { DashboardChatPanel } from './DashboardChatPanel';
import { DashboardModalStack } from './DashboardModalStack';
import { RuntimePanel } from './RuntimePanel';
export interface BotDashboardViewProps {
compactMode: boolean;
hasForcedBot: boolean;
showBotListPanel: boolean;
botListPanelProps: ComponentProps<typeof BotListPanel>;
hasSelectedBot: boolean;
isCompactListPage: boolean;
compactPanelTab: CompactPanelTab;
showCompactBotPageClose: boolean;
forcedBotId?: string;
selectBotText: string;
isZh: boolean;
runtimeViewMode: RuntimeViewMode;
hasTopicUnread: boolean;
onRuntimeViewModeChange: (mode: RuntimeViewMode) => void;
topicFeedPanelProps: ComponentProps<typeof TopicFeedPanel>;
dashboardChatPanelProps: ComponentProps<typeof DashboardChatPanel>;
runtimePanelProps: ComponentProps<typeof RuntimePanel>;
onCompactClose: () => void;
dashboardModalStackProps: ComponentProps<typeof DashboardModalStack>;
createBotModalProps: ComponentProps<typeof CreateBotWizardModal>;
}
export function BotDashboardView({
compactMode,
hasForcedBot,
showBotListPanel,
botListPanelProps,
hasSelectedBot,
isCompactListPage,
compactPanelTab,
showCompactBotPageClose,
selectBotText,
isZh,
runtimeViewMode,
hasTopicUnread,
onRuntimeViewModeChange,
topicFeedPanelProps,
dashboardChatPanelProps,
runtimePanelProps,
onCompactClose,
dashboardModalStackProps,
createBotModalProps,
}: BotDashboardViewProps) {
return (
<>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
{showBotListPanel ? <BotListPanel {...botListPanelProps} /> : null}
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{hasSelectedBot ? (
<div className="ops-chat-shell">
<div className="ops-main-content-shell">
<div className="ops-main-content-frame">
<div className="ops-main-content-head">
<div className="ops-main-mode-rail" role="tablist" aria-label={isZh ? '主面板视图切换' : 'Main panel view switch'}>
<button
className={`ops-main-mode-tab ${runtimeViewMode === 'visual' ? 'is-active' : ''}`}
onClick={() => onRuntimeViewModeChange('visual')}
aria-label={isZh ? '对话视图' : 'Conversation view'}
role="tab"
aria-selected={runtimeViewMode === 'visual'}
>
<MessageCircle size={14} />
<span className="ops-main-mode-label">{isZh ? '对话' : 'Chat'}</span>
</button>
<button
className={`ops-main-mode-tab has-dot ${runtimeViewMode === 'topic' ? 'is-active' : ''}`}
onClick={() => onRuntimeViewModeChange('topic')}
aria-label={isZh ? '主题视图' : 'Topic view'}
role="tab"
aria-selected={runtimeViewMode === 'topic'}
>
<MessageSquareText size={14} />
<span className="ops-main-mode-label-wrap">
<span className="ops-main-mode-label">{isZh ? '主题' : 'Topic'}</span>
{hasTopicUnread ? <span className="ops-switch-dot" aria-hidden="true" /> : null}
</span>
</button>
</div>
</div>
<div className="ops-main-content-body">
{runtimeViewMode === 'topic' ? <TopicFeedPanel {...topicFeedPanelProps} /> : <DashboardChatPanel {...dashboardChatPanelProps} />}
</div>
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>{selectBotText}</div>
)}
</section>
<RuntimePanel {...runtimePanelProps} />
</div>
{showCompactBotPageClose ? (
<LucentIconButton
className="ops-compact-close-btn"
onClick={onCompactClose}
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
>
<X size={16} />
</LucentIconButton>
) : null}
<DashboardModalStack {...dashboardModalStackProps} />
<CreateBotWizardModal {...createBotModalProps} />
</>
);
}

View File

@ -0,0 +1,442 @@
.ops-bot-list {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 8px;
padding-top: 8px;
}
.ops-bot-list .list-scroll {
min-height: 0;
overflow: auto;
padding-top: 4px;
padding-right: 2px;
max-height: 72vh;
}
.app-shell-compact .ops-bot-list {
height: 100%;
min-height: 0;
}
.app-shell-compact .ops-bot-list .list-scroll {
max-height: none;
}
.ops-list-actions {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
}
.ops-bot-list-toolbar {
margin-top: 8px;
}
.ops-searchbar {
position: relative;
display: block;
}
.ops-searchbar-form {
margin: 0;
}
.ops-autofill-trap {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
.ops-search-input {
min-width: 0;
}
.ops-search-input-with-icon {
padding-right: 38px;
}
.ops-search-inline-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
color: var(--icon);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.ops-search-inline-btn:hover {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--brand-soft) 42%, var(--panel-soft) 58%);
}
.ops-bot-list-empty {
border: 1px dashed var(--line);
border-radius: 10px;
background: var(--panel-soft);
color: var(--subtitle);
text-align: center;
padding: 14px 10px;
font-size: 12px;
font-weight: 700;
}
.ops-bot-list-pagination {
margin-top: 0;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
position: sticky;
bottom: 0;
z-index: 6;
padding-top: 8px;
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
background: color-mix(in oklab, var(--panel) 88%, transparent);
backdrop-filter: blur(4px);
}
.ops-bot-list-page-indicator {
text-align: center;
font-size: 12px;
color: var(--subtitle);
font-weight: 700;
}
.ops-bot-card {
position: relative;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(145deg, color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%), color-mix(in oklab, var(--panel-soft) 94%, transparent 6%));
padding: 10px 10px 10px 14px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.ops-bot-card:hover {
border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
box-shadow: 0 8px 18px color-mix(in oklab, var(--brand) 14%, transparent);
}
.ops-bot-card.is-active {
border-color: color-mix(in oklab, var(--brand) 80%, var(--line) 20%);
box-shadow:
0 0 0 2px color-mix(in oklab, var(--brand) 70%, transparent),
0 16px 30px color-mix(in oklab, var(--brand) 28%, transparent),
inset 0 0 0 1px color-mix(in oklab, var(--brand) 84%, transparent);
transform: translateY(0);
z-index: 2;
}
.ops-bot-card.is-active::after {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
border: 2px solid color-mix(in oklab, var(--brand) 78%, transparent);
pointer-events: none;
}
.ops-bot-card.state-running {
background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%));
}
.ops-bot-card.state-stopped {
background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%));
}
.ops-bot-card.state-disabled {
background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%));
}
.ops-bot-top {
align-items: flex-start;
}
.ops-bot-name {
font-size: 16px;
font-weight: 800;
color: var(--title);
}
.ops-bot-id,
.ops-bot-meta {
margin-top: 2px;
color: var(--subtitle);
font-size: 12px;
font-weight: 600;
}
.ops-bot-actions {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.ops-bot-actions-main {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-bot-enable-switch {
position: relative;
display: inline-flex;
align-items: center;
user-select: none;
cursor: pointer;
}
.ops-bot-enable-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.ops-bot-enable-switch-track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line) 82%, transparent);
background: color-mix(in oklab, #9ca3b5 42%, var(--panel-soft) 58%);
transition: background 0.2s ease, border-color 0.2s ease;
}
.ops-bot-enable-switch-track::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: color-mix(in oklab, var(--text) 75%, #fff 25%);
transition: transform 0.2s ease, background 0.2s ease;
}
.ops-bot-enable-switch input:checked + .ops-bot-enable-switch-track {
border-color: color-mix(in oklab, var(--ok) 66%, var(--line) 34%);
background: color-mix(in oklab, var(--ok) 46%, var(--panel-soft) 54%);
}
.ops-bot-enable-switch input:checked + .ops-bot-enable-switch-track::after {
transform: translateX(16px);
background: #fff;
}
.ops-bot-enable-switch input:disabled + .ops-bot-enable-switch-track {
opacity: 0.58;
cursor: not-allowed;
}
.ops-bot-strip {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 999px;
background: color-mix(in oklab, var(--line) 80%, transparent);
opacity: 0.7;
}
.ops-bot-strip.is-running {
background: linear-gradient(180deg, color-mix(in oklab, var(--ok) 80%, #9be8c6 20%), color-mix(in oklab, var(--ok) 54%, transparent));
}
.ops-bot-strip.is-stopped {
background: linear-gradient(180deg, color-mix(in oklab, var(--err) 74%, #e7b1ba 26%), color-mix(in oklab, var(--err) 54%, transparent));
}
.ops-bot-strip.is-disabled {
background: linear-gradient(180deg, color-mix(in oklab, #9ca3b5 82%, #d4d9e2 18%), color-mix(in oklab, #9ca3b5 52%, transparent));
}
.ops-bot-icon-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-bot-icon-btn svg {
width: 17px;
height: 17px;
stroke-width: 2.1;
}
.ops-bot-top-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.ops-bot-name-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-bot-lock {
width: 16px;
height: 16px;
min-width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: color-mix(in oklab, #f0b14a 72%, var(--text) 28%);
}
.ops-bot-lock svg {
width: 12px;
height: 12px;
stroke-width: 2.2;
}
.ops-bot-open-inline {
width: 16px;
height: 16px;
min-width: 16px;
padding: 0;
border: 0;
background: transparent;
color: var(--text-soft);
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.72;
}
.ops-bot-open-inline:hover {
color: var(--brand);
opacity: 1;
}
.ops-bot-open-inline svg {
width: 11px;
height: 11px;
stroke-width: 2.25;
}
.ops-bot-actions .ops-bot-action-monitor {
background: color-mix(in oklab, var(--panel-soft) 75%, var(--brand-soft) 25%);
border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%);
color: color-mix(in oklab, var(--text) 76%, white 24%);
}
.ops-bot-actions .ops-bot-action-start {
background: color-mix(in oklab, var(--ok) 24%, var(--panel-soft) 76%);
border-color: color-mix(in oklab, var(--ok) 52%, var(--line) 48%);
color: color-mix(in oklab, var(--text) 76%, white 24%);
}
.ops-bot-actions .ops-bot-action-stop {
background: color-mix(in oklab, #f5af48 30%, var(--panel-soft) 70%);
border-color: color-mix(in oklab, #f5af48 58%, var(--line) 42%);
color: #5e3b00;
}
.ops-bot-actions .ops-bot-action-stop:hover {
background: color-mix(in oklab, #f5af48 38%, var(--panel-soft) 62%);
border-color: color-mix(in oklab, #f5af48 70%, var(--line) 30%);
}
.ops-bot-actions .ops-bot-action-delete {
background: color-mix(in oklab, var(--err) 20%, var(--panel-soft) 80%);
border-color: color-mix(in oklab, var(--err) 48%, var(--line) 52%);
color: color-mix(in oklab, var(--text) 72%, white 28%);
}
.ops-bot-actions .ops-bot-icon-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ops-control-pending {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-control-dots {
display: inline-flex;
align-items: center;
gap: 3px;
}
.ops-control-dots i {
width: 4px;
height: 4px;
border-radius: 999px;
display: block;
background: currentColor;
opacity: 0.35;
animation: ops-control-dot 1.2s infinite ease-in-out;
}
.ops-control-dots i:nth-child(2) {
animation-delay: 0.2s;
}
.ops-control-dots i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes ops-control-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-2px);
opacity: 1;
}
}
.app-shell[data-theme='light'] .ops-bot-card {
background: #f7fbff;
}
.app-shell[data-theme='light'] .ops-bot-card.state-running {
background: linear-gradient(145deg, #edfdf6, #f7fffb);
}
.app-shell[data-theme='light'] .ops-bot-card.state-stopped {
background: linear-gradient(145deg, #fdf0f2, #fff7f8);
}
.app-shell[data-theme='light'] .ops-bot-card.state-disabled {
background: linear-gradient(145deg, #eff2f6, #f8fafc);
}
.app-shell[data-theme='light'] .ops-bot-card.is-active {
border-color: #3f74df;
box-shadow:
0 0 0 2px rgba(63, 116, 223, 0.45),
0 16px 32px rgba(63, 116, 223, 0.26),
inset 0 0 0 1px rgba(63, 116, 223, 0.78);
}

View File

@ -0,0 +1,362 @@
import { Boxes, ChevronLeft, ChevronRight, EllipsisVertical, ExternalLink, FileText, Gauge, Lock, Plus, Power, Square, Trash2 } from 'lucide-react';
import type { RefObject } from 'react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { CompactPanelTab } from '../types';
import './DashboardMenus.css';
import './BotListPanel.css';
interface BotListLabels {
batchStart: string;
batchStop: string;
botSearchNoResult: string;
botSearchPlaceholder: string;
clearSearch: string;
delete: string;
disable: string;
disabled: string;
enable: string;
extensions: string;
image: string;
manageImages: string;
newBot: string;
paginationNext: string;
paginationPage: (page: number, totalPages: number) => string;
paginationPrev: string;
searchAction: string;
start: string;
stop: string;
syncingPageSize: string;
templateManager: string;
titleBots: string;
}
interface BotListPanelProps {
bots: any[];
filteredBots: any[];
pagedBots: any[];
selectedBotId: string;
normalizedBotListQuery: string;
botListQuery: string;
botListPageSizeReady: boolean;
botListPage: number;
botListTotalPages: number;
botListMenuOpen: boolean;
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
operatingBotId: string | null;
compactMode: boolean;
isZh: boolean;
isLoadingTemplates: boolean;
isBatchOperating: boolean;
labels: BotListLabels;
botSearchInputName: string;
botListMenuRef: RefObject<HTMLDivElement | null>;
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
onToggleMenu?: () => void;
onCloseMenu: () => void;
onOpenTemplateManager: () => Promise<void> | void;
onBatchStartBots: () => Promise<void> | void;
onBatchStopBots: () => Promise<void> | void;
onBotListQueryChange: (value: string) => void;
onBotListPageChange: (value: number | ((prev: number) => number)) => void;
onSelectBot: (botId: string) => void;
onSetCompactPanelTab: (tab: CompactPanelTab) => void;
onSetBotEnabled: (botId: string, enabled: boolean) => Promise<void> | void;
onStartBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onStopBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onOpenResourceMonitor: (botId: string) => void;
onRemoveBot: (botId: string) => Promise<void> | void;
}
export function BotListPanel({
bots,
filteredBots,
pagedBots,
selectedBotId,
normalizedBotListQuery,
botListQuery,
botListPageSizeReady,
botListPage,
botListTotalPages,
botListMenuOpen,
controlStateByBot,
operatingBotId,
compactMode,
isZh,
isLoadingTemplates,
isBatchOperating,
labels,
botSearchInputName,
botListMenuRef,
onOpenCreateWizard,
onOpenImageFactory,
onToggleMenu,
onCloseMenu,
onOpenTemplateManager,
onBatchStartBots,
onBatchStopBots,
onBotListQueryChange,
onBotListPageChange,
onSelectBot,
onSetCompactPanelTab,
onSetBotEnabled,
onStartBot,
onStopBot,
onOpenResourceMonitor,
onRemoveBot,
}: BotListPanelProps) {
return (
<section className="panel stack ops-bot-list">
<div className="row-between">
<h2 style={{ fontSize: 18 }}>
{normalizedBotListQuery
? `${labels.titleBots} (${filteredBots.length}/${bots.length})`
: `${labels.titleBots} (${bots.length})`}
</h2>
<div className="ops-list-actions" ref={botListMenuRef}>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
onClick={onOpenCreateWizard}
tooltip={labels.newBot}
aria-label={labels.newBot}
>
<Plus size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleMenu}
tooltip={labels.extensions}
aria-label={labels.extensions}
aria-haspopup="menu"
aria-expanded={botListMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{botListMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={labels.extensions}>
<button
className="ops-more-item"
role="menuitem"
disabled={!onOpenImageFactory}
onClick={() => {
onCloseMenu();
onOpenImageFactory?.();
}}
>
<Boxes size={14} />
<span>{labels.manageImages}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isLoadingTemplates}
onClick={() => {
void onOpenTemplateManager();
}}
>
<FileText size={14} />
<span>{labels.templateManager}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void onBatchStartBots();
}}
>
<Power size={14} />
<span>{labels.batchStart}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={() => {
void onBatchStopBots();
}}
>
<Square size={14} />
<span>{labels.batchStop}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-bot-list-toolbar">
<ProtectedSearchInput
value={botListQuery}
onChange={onBotListQueryChange}
onClear={() => {
onBotListQueryChange('');
onBotListPageChange(1);
}}
onSearchAction={() => onBotListPageChange(1)}
debounceMs={120}
placeholder={labels.botSearchPlaceholder}
ariaLabel={labels.botSearchPlaceholder}
clearTitle={labels.clearSearch}
searchTitle={labels.searchAction}
name={botSearchInputName}
id={botSearchInputName}
/>
</div>
<div className="list-scroll">
{!botListPageSizeReady ? (
<div className="ops-bot-list-empty">{labels.syncingPageSize}</div>
) : null}
{botListPageSizeReady
? pagedBots.map((bot) => {
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
return (
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
onClick={() => {
onSelectBot(bot.id);
if (compactMode) onSetCompactPanelTab('chat');
}}
>
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
{bot.has_access_password ? (
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
<Lock size={12} />
</span>
) : null}
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
onClick={(e) => {
e.stopPropagation();
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
window.open(target, '_blank', 'noopener,noreferrer');
}}
tooltip={isZh ? '新页面打开' : 'Open in new page'}
aria-label={isZh ? '新页面打开' : 'Open in new page'}
>
<ExternalLink size={11} />
</LucentIconButton>
</div>
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<div className="ops-bot-top-actions">
{!isEnabled ? (
<span className="badge badge-err">{labels.disabled}</span>
) : null}
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
</div>
<div className="ops-bot-meta">{labels.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
<label
className="ops-bot-enable-switch"
title={isEnabled ? labels.disable : labels.enable}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
disabled={isOperating || isEnabling || isDisabling}
onChange={(e) => {
void onSetBotEnabled(bot.id, e.target.checked);
}}
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
/>
<span className="ops-bot-enable-switch-track" />
</label>
<div className="ops-bot-actions-main">
<LucentIconButton
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void (isRunning ? onStopBot(bot.id, bot.docker_status) : onStartBot(bot.id, bot.docker_status));
}}
tooltip={isRunning ? labels.stop : labels.start}
aria-label={isRunning ? labels.stop : labels.start}
>
{isStarting || isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : isRunning ? <Square size={14} /> : <Power size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
onOpenResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void onRemoveBot(bot.id);
}}
tooltip={labels.delete}
aria-label={labels.delete}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
</div>
);
})
: null}
{botListPageSizeReady && filteredBots.length === 0 ? (
<div className="ops-bot-list-empty">{labels.botSearchNoResult}</div>
) : null}
</div>
{botListPageSizeReady ? (
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => onBotListPageChange((p) => Math.max(1, p - 1))}
disabled={botListPage <= 1}
tooltip={labels.paginationPrev}
aria-label={labels.paginationPrev}
>
<ChevronLeft size={14} />
</LucentIconButton>
<div className="ops-bot-list-page-indicator pager-status">{labels.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => onBotListPageChange((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages}
tooltip={labels.paginationNext}
aria-label={labels.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,934 @@
import { ChevronDown, ChevronUp, ExternalLink, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react';
import type { RefObject } from 'react';
import { DrawerShell } from '../../../components/DrawerShell';
import { PasswordInput } from '../../../components/PasswordInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotChannel, BotTopic, ChannelType, TopicPresetTemplate, WeixinLoginStatus } from '../types';
import './DashboardManagementModals.css';
interface PasswordToggleLabels {
show: string;
hide: string;
}
function parseChannelListValue(raw: unknown): string {
if (!Array.isArray(raw)) return '';
return raw
.map((item) => String(item || '').trim())
.filter(Boolean)
.join('\n');
}
function parseChannelListInput(raw: string): string[] {
const rows: string[] = [];
String(raw || '')
.split(/[\n,]/)
.forEach((item) => {
const text = String(item || '').trim();
if (text && !rows.includes(text)) rows.push(text);
});
return rows;
}
function isChannelConfigured(channel: BotChannel): boolean {
const ctype = String(channel.channel_type || '').trim().toLowerCase();
if (ctype === 'email') {
const extra = channel.extra_config || {};
return Boolean(
String(extra.imapHost || '').trim()
&& String(extra.imapUsername || '').trim()
&& String(extra.imapPassword || '').trim()
&& String(extra.smtpHost || '').trim()
&& String(extra.smtpUsername || '').trim()
&& String(extra.smtpPassword || '').trim(),
);
}
if (ctype === 'weixin') {
return true;
}
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
}
function ChannelFieldsEditor({
channel,
labels,
passwordToggleLabels,
onPatch,
}: {
channel: BotChannel;
labels: Record<string, any>;
passwordToggleLabels: PasswordToggleLabels;
onPatch: (patch: Partial<BotChannel>) => void;
}) {
const ctype = String(channel.channel_type).toLowerCase();
if (ctype === 'telegram') {
return (
<>
<PasswordInput className="input" placeholder={labels.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input
className="input"
placeholder={labels.proxy}
value={String((channel.extra_config || {}).proxy || '')}
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
autoComplete="off"
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
}
style={{ marginRight: 6 }}
/>
{labels.replyToMessage}
</label>
</>
);
}
if (ctype === 'feishu') {
return (
<>
<input className="input" placeholder={labels.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={labels.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input className="input" placeholder={labels.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
<input className="input" placeholder={labels.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
</>
);
}
if (ctype === 'dingtalk') {
return (
<>
<input className="input" placeholder={labels.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={labels.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'slack') {
return (
<>
<input className="input" placeholder={labels.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={labels.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'qq') {
return (
<>
<input className="input" placeholder={labels.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={labels.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'weixin') {
return null;
}
if (ctype === 'email') {
const extra = channel.extra_config || {};
return (
<>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.consentGranted)}
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailConsentGranted}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailFromAddress}</label>
<input
className="input"
value={String(extra.fromAddress || '')}
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.emailAllowFrom}</label>
<textarea
className="textarea"
rows={3}
value={parseChannelListValue(extra.allowFrom)}
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
placeholder={labels.emailAllowFromPlaceholder}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailImapHost}</label>
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailImapPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailImapUsername}</label>
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailImapPassword}</label>
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailImapMailbox}</label>
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.imapUseSsl ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailImapUseSsl}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailSmtpHost}</label>
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailSmtpPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailSmtpUsername}</label>
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailSmtpPassword}</label>
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseTls ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailSmtpUseTls}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseSsl)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailSmtpUseSsl}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.autoReplyEnabled ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailAutoReplyEnabled}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailPollIntervalSeconds}</label>
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailMaxBodyChars}</label>
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.emailSubjectPrefix}</label>
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.markSeen ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{labels.emailMarkSeen}
</label>
</div>
</>
);
}
return null;
}
interface ChannelConfigModalProps {
open: boolean;
channels: BotChannel[];
globalDelivery: { sendProgress: boolean; sendToolHints: boolean };
expandedChannelByKey: Record<string, boolean>;
newChannelDraft: BotChannel;
addableChannelTypes: ChannelType[];
newChannelPanelOpen: boolean;
channelCreateMenuOpen: boolean;
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
isSavingGlobalDelivery: boolean;
isSavingChannel: boolean;
weixinLoginStatus: WeixinLoginStatus | null;
hasSelectedBot: boolean;
isZh: boolean;
labels: Record<string, any>;
passwordToggleLabels: PasswordToggleLabels;
onClose: () => void;
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
onSaveGlobalDelivery: () => Promise<void> | void;
getChannelUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
isDashboardChannel: (channel: BotChannel) => boolean;
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
onToggleExpandedChannel: (key: string) => void;
onRemoveChannel: (channel: BotChannel) => Promise<void> | void;
onSaveChannel: (channel: BotChannel) => Promise<void> | void;
onReloginWeixin: () => Promise<void> | void;
onSetNewChannelPanelOpen: (value: boolean) => void;
onSetChannelCreateMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onResetNewChannelDraft: (channelType?: ChannelType) => void;
onUpdateNewChannelDraft: (patch: Partial<BotChannel>) => void;
onBeginChannelCreate: (channelType: ChannelType) => void;
onAddChannel: () => Promise<void> | void;
}
export function ChannelConfigModal({
open,
channels,
globalDelivery,
expandedChannelByKey,
newChannelDraft,
addableChannelTypes,
newChannelPanelOpen,
channelCreateMenuOpen,
channelCreateMenuRef,
isSavingGlobalDelivery,
isSavingChannel,
weixinLoginStatus,
hasSelectedBot,
isZh,
labels,
passwordToggleLabels,
onClose,
onUpdateGlobalDeliveryFlag,
onSaveGlobalDelivery,
getChannelUiKey,
isDashboardChannel,
onUpdateChannelLocal,
onToggleExpandedChannel,
onRemoveChannel,
onSaveChannel,
onReloginWeixin,
onSetNewChannelPanelOpen,
onSetChannelCreateMenuOpen,
onResetNewChannelDraft,
onUpdateNewChannelDraft,
onBeginChannelCreate,
onAddChannel,
}: ChannelConfigModalProps) {
if (!open) return null;
const renderWeixinLoginBlock = (channel: BotChannel) => {
const channelType = String(channel.channel_type || '').trim().toLowerCase();
if (String(channelType || '').trim().toLowerCase() !== 'weixin') return null;
const loginUrl = String(weixinLoginStatus?.login_url || '').trim();
return (
<div className="ops-config-weixin-login">
<div className="ops-config-weixin-actions">
<button
className="btn btn-primary btn-sm"
disabled={isSavingChannel}
onClick={() => void onReloginWeixin()}
>
{labels.weixinRelogin}
</button>
</div>
<div className="ops-config-weixin-login-hint">{labels.weixinLoginHint}</div>
{loginUrl ? (
<div className="ops-config-weixin-login-body">
<div className="ops-config-weixin-login-url mono" title={loginUrl}>{loginUrl}</div>
<a
className="ops-config-weixin-login-link"
href={loginUrl}
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} />
<span>{labels.weixinLoginOpen}</span>
</a>
</div>
) : null}
</div>
);
};
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.wizardSectionTitle}
subtitle={labels.wizardSectionDesc}
size="extend"
closeLabel={labels.close}
bodyClassName="ops-config-drawer-body"
footer={(
!newChannelPanelOpen ? (
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{labels.channelAddHint}</div>
<div className="ops-topic-create-menu-wrap" ref={channelCreateMenuRef}>
<button
className="btn btn-primary"
disabled={addableChannelTypes.length === 0 || isSavingChannel}
onClick={() => onSetChannelCreateMenuOpen((prev) => !prev)}
>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.addChannel}</span>
</button>
{channelCreateMenuOpen ? (
<div className="ops-topic-create-menu">
{addableChannelTypes.map((channelType) => (
<button key={channelType} className="ops-topic-create-menu-item" onClick={() => onBeginChannelCreate(channelType)}>
{String(channelType || '').toUpperCase()}
</button>
))}
</div>
) : null}
</div>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{labels.wizardSectionDesc}
</div>
<div className="card">
<div className="section-mini-title">{labels.globalDeliveryTitle}</div>
<div className="field-label">{labels.globalDeliveryDesc}</div>
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendProgress)}
onChange={(e) => onUpdateGlobalDeliveryFlag('sendProgress', e.target.checked)}
style={{ marginRight: 6 }}
/>
{labels.sendProgress}
</label>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendToolHints)}
onChange={(e) => onUpdateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
style={{ marginRight: 6 }}
/>
{labels.sendToolHints}
</label>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingGlobalDelivery || !hasSelectedBot}
onClick={() => void onSaveGlobalDelivery()}
tooltip={labels.saveChannel}
aria-label={labels.saveChannel}
>
<Save size={14} />
</LucentIconButton>
</div>
</div>
<div className="wizard-channel-list ops-config-list-scroll">
{channels.filter((channel) => !isDashboardChannel(channel)).length === 0 ? (
<div className="ops-empty-inline">{labels.channelEmpty}</div>
) : (
channels.map((channel, idx) => {
if (isDashboardChannel(channel)) return null;
const uiKey = getChannelUiKey(channel, idx);
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
const summary = [
String(channel.channel_type || '').toUpperCase(),
channel.is_active ? labels.enabled : labels.disabled,
isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending,
].join(' · ');
return (
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{String(channel.channel_type || '').toUpperCase()}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={channel.is_active}
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.enabled}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingChannel}
onClick={() => void onRemoveChannel(channel)}
tooltip={labels.remove}
aria-label={labels.remove}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleExpandedChannel(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">
<ChannelFieldsEditor
channel={channel}
labels={labels}
passwordToggleLabels={passwordToggleLabels}
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
/>
</div>
{renderWeixinLoginBlock(channel)}
<div className="row-between ops-config-footer">
<span className="field-label">{labels.customChannel}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onSaveChannel(channel)}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newChannelPanelOpen ? (
<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.addChannel}</strong>
<div className="ops-config-collapsed-meta">{String(newChannelDraft.channel_type || '').toUpperCase()}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
onSetNewChannelPanelOpen(false);
onResetNewChannelDraft();
}}
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.channelType}</label>
<input className="input mono" value={String(newChannelDraft.channel_type || '').toUpperCase()} readOnly />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label" style={{ visibility: 'hidden' }}>{labels.enabled}</label>
<label className="field-label">
<input
type="checkbox"
checked={newChannelDraft.is_active}
onChange={(e) => onUpdateNewChannelDraft({ is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.enabled}
</label>
</div>
<ChannelFieldsEditor
channel={newChannelDraft}
labels={labels}
passwordToggleLabels={passwordToggleLabels}
onPatch={onUpdateNewChannelDraft}
/>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.channelAddHint}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
onSetNewChannelPanelOpen(false);
onResetNewChannelDraft();
}}
>
{labels.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onAddChannel()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</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}
subtitle={labels.topicPanelDesc}
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="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{labels.topicPanelDesc}
</div>
<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 style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic}
onClick={() => {
onSetNewTopicPanelOpen(false);
onSetTopicPresetMenuOpen(false);
onResetNewTopicDraft();
}}
>
{labels.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic || !hasSelectedBot} onClick={() => void onAddTopic()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}

View File

@ -0,0 +1,557 @@
.ops-chat-frame {
position: relative;
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.ops-chat-frame.is-disabled .ops-chat-scroll,
.ops-chat-frame.is-disabled .ops-composer {
filter: grayscale(0.2) opacity(0.75);
}
.ops-chat-disabled-mask {
position: absolute;
inset: 0;
border-radius: 12px;
background: color-mix(in oklab, var(--panel) 60%, transparent);
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.ops-chat-disabled-card {
border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%);
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%);
color: var(--text);
font-size: 13px;
font-weight: 700;
padding: 10px 12px;
}
.ops-composer {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 10px;
display: block;
}
.ops-composer-shell {
position: relative;
display: grid;
grid-template-rows: minmax(96px, auto) auto;
gap: 0;
min-height: 108px;
overflow: hidden;
border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%);
border-radius: 10px;
background: var(--panel);
}
.ops-chat-top-context {
width: 100%;
min-width: 0;
margin: 0;
max-height: 120px;
overflow: auto;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
}
.ops-chat-dock {
display: grid;
gap: 8px;
min-height: 0;
}
.ops-composer-float-controls {
position: absolute;
top: 8px;
right: 15px;
z-index: 4;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
pointer-events: none;
}
.ops-control-command-drawer {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
transform: translateX(10px);
transition: max-width 0.22s ease, opacity 0.18s ease, transform 0.22s ease;
pointer-events: none;
}
.ops-control-command-drawer.is-open {
max-width: 332px;
opacity: 1;
transform: translateX(0);
overflow: visible;
pointer-events: auto;
}
.ops-control-command-chip {
height: 24px;
padding: 0 8px;
border: 1px solid color-mix(in oklab, var(--line) 70%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 94%, transparent);
color: var(--text);
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-shadow: 0 4px 10px rgba(9, 15, 28, 0.08);
}
.ops-control-command-chip .mono {
font-size: 11px;
line-height: 1;
color: var(--brand);
}
.ops-control-command-chip:hover:not(:disabled) {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--brand-soft) 28%, var(--panel) 72%);
}
.ops-control-command-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ops-control-date-panel {
position: fixed;
width: 220px;
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 14px 28px rgba(9, 16, 31, 0.2);
padding: 10px;
display: grid;
gap: 10px;
z-index: 140;
pointer-events: auto;
}
.ops-control-date-label {
display: grid;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--subtitle);
}
.ops-control-date-input {
min-height: 34px;
padding: 0 10px;
}
.ops-control-date-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.ops-control-command-toggle {
width: 24px;
height: 24px;
border: 1px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 95%, var(--brand-soft) 5%);
color: var(--brand);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
box-shadow: 0 4px 10px rgba(9, 15, 28, 0.08);
pointer-events: auto;
}
.ops-control-command-toggle:hover {
transform: translateX(-1px);
background: color-mix(in oklab, var(--brand-soft) 26%, var(--panel) 74%);
}
.ops-control-command-toggle.is-open {
background: color-mix(in oklab, var(--brand-soft) 34%, var(--panel) 66%);
}
.ops-composer-quote {
grid-column: 1 / -1;
border: 1px solid color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
border-radius: 10px;
padding: 8px 10px;
background: color-mix(in oklab, var(--brand-soft) 34%, var(--panel) 66%);
min-width: 0;
}
.ops-composer-quote-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 700;
color: var(--title);
}
.ops-composer-quote-text {
font-size: 12px;
line-height: 1.45;
color: var(--text);
white-space: nowrap;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.ops-composer-shell .ops-composer-input,
.ops-composer-shell .ops-composer-input.input {
min-height: 96px;
max-height: 220px;
overflow: auto;
resize: none;
font-size: 14px;
line-height: 1.45;
padding: 14px 42px 10px 14px;
border: 0 !important;
border-bottom: 0 !important;
border-radius: 10px 10px 0 0;
background: var(--panel) !important;
box-shadow: none;
outline: none;
}
.ops-composer-shell.is-command-open .ops-composer-input,
.ops-composer-shell.is-command-open .ops-composer-input.input {
padding-right: 230px;
}
.ops-composer-shell:focus-within {
border-color: var(--brand);
}
.ops-composer-shell .ops-composer-input.input:focus,
.ops-composer-shell .ops-composer-input:focus {
border: 0 !important;
border-bottom: 0 !important;
box-shadow: none;
}
.ops-voice-wave {
height: 28px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
background: color-mix(in oklab, var(--panel-soft) 78%, var(--panel) 22%);
display: flex;
align-items: center;
gap: 8px;
padding: 0 6px;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
}
.ops-voice-wave-segment {
height: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2px;
padding: 0 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 60%, rgba(255, 255, 255, 0.18) 40%);
}
.ops-voice-wave.is-mobile .ops-voice-wave-segment {
flex: 1 1 auto;
}
.ops-voice-wave.is-desktop .ops-voice-wave-segment {
flex: 1 1 0;
}
.ops-voice-wave-segment i {
display: inline-block;
width: 3px;
min-width: 3px;
height: 10px;
border-radius: 999px;
background: color-mix(in oklab, var(--line) 72%, var(--text) 28%);
opacity: 0.72;
}
.ops-voice-wave-segment i:nth-child(3n) {
height: 14px;
}
.ops-voice-wave-segment i:nth-child(4n) {
height: 18px;
}
.ops-voice-wave-segment i:nth-child(5n) {
height: 12px;
}
.ops-voice-wave.is-live .ops-voice-wave-segment i {
background: color-mix(in oklab, var(--brand) 60%, #8ec3ff 40%);
animation: ops-voice-wave 1.05s ease-in-out infinite;
}
.ops-voice-countdown {
flex: 0 0 auto;
font-size: 13px;
font-weight: 700;
color: var(--title);
min-width: 44px;
text-align: right;
}
.ops-composer-tools-right {
position: static;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-height: 44px;
padding: 4px 10px 6px 10px;
background: var(--panel);
border-radius: 0 0 10px 10px;
width: auto;
}
.ops-voice-inline {
min-width: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
margin-right: 4px;
}
.ops-composer-inline-btn {
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--icon-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-composer-inline-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--panel) 66%, var(--brand-soft) 34%);
color: var(--icon);
}
.ops-composer-inline-btn.is-active {
background: color-mix(in oklab, var(--brand-soft) 42%, var(--panel) 58%);
color: var(--brand);
}
.ops-composer-submit-btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: var(--text);
color: var(--panel);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 18px rgba(9, 15, 28, 0.22);
}
.ops-composer-submit-btn.is-interrupt {
width: 34px;
min-width: 34px;
padding: 0;
background: #0b1220;
color: #fff;
border: 1px solid color-mix(in oklab, #0b1220 72%, var(--line) 28%);
box-shadow: 0 8px 18px rgba(9, 15, 28, 0.22);
}
.ops-composer-submit-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.ops-composer-submit-btn:disabled,
.ops-composer-inline-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ops-pending-files {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ops-user-quoted-reply {
margin-bottom: 8px;
border-left: 3px solid color-mix(in oklab, var(--brand) 55%, var(--line) 45%);
border-radius: 8px;
background: color-mix(in oklab, var(--panel) 84%, var(--brand-soft) 16%);
padding: 6px 8px;
}
.ops-user-quoted-label {
font-size: 11px;
font-weight: 700;
color: var(--subtitle);
margin-bottom: 4px;
}
.ops-user-quoted-text {
font-size: 12px;
line-height: 1.4;
color: var(--text-soft);
white-space: nowrap;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.ops-upload-progress {
margin-top: 8px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
}
.ops-upload-progress-track {
height: 8px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
background: color-mix(in oklab, var(--panel) 78%, var(--panel-soft) 22%);
overflow: hidden;
}
.ops-upload-progress-fill {
height: 100%;
border-radius: inherit;
width: 0;
background: linear-gradient(90deg, color-mix(in oklab, var(--brand) 75%, #7fb8ff 25%), color-mix(in oklab, var(--brand) 55%, #b6ddff 45%));
transition: width 0.2s ease;
}
.ops-upload-progress-track.is-indeterminate .ops-upload-progress-fill {
width: 28%;
animation: ops-upload-indeterminate 1s linear infinite;
}
.ops-upload-progress-text {
font-size: 11px;
color: var(--subtitle);
white-space: nowrap;
}
@keyframes ops-upload-indeterminate {
0% { transform: translateX(-110%); }
100% { transform: translateX(430%); }
}
@keyframes ops-voice-wave {
0%, 100% {
transform: scaleY(0.55);
opacity: 0.35;
}
50% {
transform: scaleY(1.95);
opacity: 1;
}
}
.ops-pending-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
color: var(--text);
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
}
.ops-pending-open {
max-width: 300px;
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 720px) {
.ops-control-command-drawer.is-open {
max-width: 288px;
flex-wrap: wrap;
}
.ops-control-date-panel {
right: -2px;
width: min(220px, calc(100vw - 44px));
}
.ops-voice-wave {
gap: 4px;
padding: 0 4px;
}
.ops-voice-wave-segment {
padding: 0 4px;
}
}
@media (max-width: 1160px) {
.ops-chat-scroll {
min-height: 320px;
max-height: 55vh;
}
.ops-chat-bubble {
max-width: 96%;
}
.ops-composer {
padding: 8px;
}
}
.app-shell[data-theme='light'] .ops-chat-scroll,
.app-shell[data-theme='light'] .ops-chat-head,
.app-shell[data-theme='light'] .ops-composer,
.app-shell[data-theme='light'] .ops-bot-card {
background: #f7fbff;
}

View File

@ -0,0 +1,515 @@
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square, X } from 'lucide-react';
import type { Components } from 'react-markdown';
import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText } from '../messageParser';
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
import { formatDateInputValue, workspaceFileAction } from '../utils';
import { DashboardConversationMessages } from './DashboardConversationMessages';
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;
disabledPlaceholder: string;
download: string;
editPrompt: string;
fileNotPreviewable: string;
goodReply: string;
inputPlaceholder: string;
interrupt: string;
noConversation: string;
previewTitle: string;
quoteReply: string;
quotedReplyLabel: string;
send: string;
thinking: string;
uploadFile: string;
uploadingFile: string;
user: string;
voiceStart: string;
voiceStop: string;
voiceTranscribing: string;
you: string;
}
interface DashboardChatPanelProps {
conversation: ChatMessage[];
isZh: boolean;
labels: DashboardChatPanelLabels;
chatScrollRef: RefObject<HTMLDivElement | null>;
onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
isThinking: boolean;
canChat: boolean;
isChatEnabled: boolean;
selectedBotEnabled: boolean;
selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling';
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: () => void;
onTriggerPickAttachments: () => void;
showInterruptSubmitAction: boolean;
onSubmitAction: () => Promise<void> | void;
}
export function DashboardChatPanel({
conversation,
isZh,
labels,
chatScrollRef,
onChatScroll,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
isThinking,
canChat,
isChatEnabled,
selectedBotEnabled,
selectedBotControlState,
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,
onTriggerPickAttachments,
showInterruptSubmitAction,
onSubmitAction,
}: DashboardChatPanelProps) {
return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
{conversation.length === 0 ? (
<div className="ops-chat-empty">
{labels.noConversation}
</div>
) : (
<DashboardConversationMessages
conversation={conversation}
isZh={isZh}
labels={{
badReply: labels.badReply,
copyPrompt: labels.copyPrompt,
copyReply: labels.copyReply,
download: labels.download,
editPrompt: labels.editPrompt,
fileNotPreviewable: labels.fileNotPreviewable,
goodReply: labels.goodReply,
previewTitle: labels.previewTitle,
quoteReply: labels.quoteReply,
quotedReplyLabel: labels.quotedReplyLabel,
user: labels.user,
you: labels.you,
}}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onToggleProgressExpand={onToggleProgressExpand}
onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt}
onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply}
/>
)}
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{labels.thinking}</div>
</div>
</div>
</div>
) : null}
<div />
</div>
<div className="ops-chat-dock">
{(quotedReply || pendingAttachments.length > 0) ? (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{labels.quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={onClearQuotedReply}
>
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((path) => (
<span key={path} className="ops-pending-chip mono">
{(() => {
const filePath = normalizeDashboardAttachmentPath(path);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onOpenWorkspacePath(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})()}
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemovePendingAttachment(path);
}}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
</div>
) : null}
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/>
</div>
<span className="ops-upload-progress-text mono">
{attachmentUploadPercent === null
? labels.uploadingFile
: `${labels.uploadingFile} ${attachmentUploadPercent}%`}
</span>
</div>
) : null}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<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 style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
{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"
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 || isVoiceTranscribing}
onClick={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={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={
showInterruptSubmitAction
? isInterrupting
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| (!command.trim() && pendingAttachments.length === 0 && !quotedReply)
)
}
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>
{!canChat ? (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? labels.botStarting
: selectedBotControlState === 'stopping'
? labels.botStopping
: !selectedBotEnabled
? labels.botDisabledHint
: labels.chatDisabled}
</div>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,13 @@
.ops-runtime-row {
display: grid;
grid-template-columns: 90px 1fr;
gap: 8px;
align-items: start;
color: var(--text);
font-size: 13px;
}
.ops-runtime-row span {
color: var(--subtitle);
font-weight: 700;
}

View File

@ -0,0 +1,411 @@
import { RefreshCw, X } from 'lucide-react';
import { DrawerShell } from '../../../components/DrawerShell';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { PasswordInput } from '../../../components/PasswordInput';
import { buildLlmProviderOptions } from '../../../utils/llmProviders';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotState } from '../../../types/bot';
import type { SystemTimezoneOption } from '../../../utils/systemTimezones';
import type { BaseImageOption, BotEditForm, BotParamDraft, BotResourceSnapshot } from '../types';
import { clampTemperature, formatBytes, formatPercent } from '../utils';
import './DashboardConfigModals.css';
interface PasswordToggleLabels {
show: string;
hide: string;
}
interface ResourceMonitorModalProps {
open: boolean;
botId: string;
resourceBot?: BotState;
resourceSnapshot: BotResourceSnapshot | null;
resourceLoading: boolean;
resourceError: string;
isZh: boolean;
closeLabel: string;
onClose: () => void;
onRefresh: (botId: string) => Promise<void> | void;
}
export function ResourceMonitorModal({
open,
botId,
resourceBot,
resourceSnapshot,
resourceLoading,
resourceError,
isZh,
closeLabel,
onClose,
onRefresh,
}: ResourceMonitorModalProps) {
if (!open) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '资源监测' : 'Resource Monitor'}</h3>
<span className="modal-sub mono">{resourceBot?.name || botId}</span>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onRefresh(botId)}
tooltip={isZh ? '立即刷新' : 'Refresh now'}
aria-label={isZh ? '立即刷新' : 'Refresh now'}
>
<RefreshCw size={14} className={resourceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={closeLabel} aria-label={closeLabel}>
<X size={14} />
</LucentIconButton>
</div>
</div>
{resourceError ? <div className="card">{resourceError}</div> : null}
{resourceSnapshot ? (
<div className="stack">
<div className="card summary-grid">
<div>{isZh ? '容器状态' : 'Container'}: <strong className="mono">{resourceSnapshot.docker_status}</strong></div>
<div>{isZh ? '容器名' : 'Container Name'}: <span className="mono">{resourceSnapshot.bot_id ? `worker_${resourceSnapshot.bot_id}` : '-'}</span></div>
<div>{isZh ? '基础镜像' : 'Base Image'}: <span className="mono">{resourceBot?.image_tag || '-'}</span></div>
<div>Provider/Model: <span className="mono">{resourceBot?.llm_provider || '-'} / {resourceBot?.llm_model || '-'}</span></div>
<div>{isZh ? '采样时间' : 'Collected'}: <span className="mono">{resourceSnapshot.collected_at}</span></div>
<div>{isZh ? '策略说明' : 'Policy'}: <strong>{isZh ? '资源值 0 = 不限制' : 'Value 0 = Unlimited'}</strong></div>
</div>
<div className="grid-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
<div className="card stack">
<div className="section-mini-title">{isZh ? '配置配额' : 'Configured Limits'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : resourceSnapshot.configured.cpu_cores}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.memory_mb} MB`}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.storage_gb} GB`}</strong></div>
</div>
<div className="card stack">
<div className="section-mini-title">{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{resourceSnapshot.runtime.limits.storage_bytes ? formatBytes(resourceSnapshot.runtime.limits.storage_bytes) : (resourceSnapshot.runtime.limits.storage_opt_raw || (Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-'))}</strong></div>
</div>
</div>
<div className="card stack">
<div className="section-mini-title">{isZh ? '实时使用' : 'Live Usage'}</div>
<div className="ops-runtime-row"><span>CPU</span><strong>{formatPercent(resourceSnapshot.runtime.usage.cpu_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{formatBytes(resourceSnapshot.runtime.usage.memory_bytes)} / {resourceSnapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(resourceSnapshot.runtime.usage.memory_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '内存占比' : 'Memory %'}</span><strong>{formatPercent(resourceSnapshot.runtime.usage.memory_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '工作区占用' : 'Workspace Usage'}</span><strong>{formatBytes(resourceSnapshot.workspace.usage_bytes)} / {resourceSnapshot.workspace.configured_limit_bytes ? formatBytes(resourceSnapshot.workspace.configured_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '工作区占比' : 'Workspace %'}</span><strong>{formatPercent(resourceSnapshot.workspace.usage_percent)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '网络 I/O' : 'Network I/O'}</span><strong>RX {formatBytes(resourceSnapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(resourceSnapshot.runtime.usage.network_tx_bytes)}</strong></div>
<div className="ops-runtime-row"><span>{isZh ? '磁盘 I/O' : 'Block I/O'}</span><strong>R {formatBytes(resourceSnapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(resourceSnapshot.runtime.usage.blk_write_bytes)}</strong></div>
<div className="ops-runtime-row"><span>PIDs</span><strong>{resourceSnapshot.runtime.usage.pids || 0}</strong></div>
</div>
<div className="field-label">
{resourceSnapshot.note}
{isZh ? '(界面规则:资源配置填写 0 表示不限制)' : ' (UI rule: value 0 means unlimited)'}
</div>
</div>
) : (
<div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
)}
</div>
</div>
);
}
interface BaseConfigLabels {
accessPassword: string;
accessPasswordPlaceholder: string;
baseConfig: string;
baseImageReadonly: string;
botIdReadonly: string;
botName: string;
botNamePlaceholder: string;
cancel: string;
close: string;
save: string;
}
interface BaseConfigModalProps {
open: boolean;
selectedBotId: string;
editForm: BotEditForm;
paramDraft: BotParamDraft;
baseImageOptions: BaseImageOption[];
systemTimezoneOptions: SystemTimezoneOption[];
defaultSystemTimezone: string;
passwordToggleLabels: PasswordToggleLabels;
isSaving: boolean;
isZh: boolean;
labels: BaseConfigLabels;
onClose: () => void;
onEditFormChange: (patch: Partial<BotEditForm>) => void;
onParamDraftChange: (patch: Partial<BotParamDraft>) => void;
onSave: () => Promise<void> | void;
}
export function BaseConfigModal({
open,
selectedBotId,
editForm,
paramDraft,
baseImageOptions,
systemTimezoneOptions,
defaultSystemTimezone,
passwordToggleLabels,
isSaving,
isZh,
labels,
onClose,
onEditFormChange,
onParamDraftChange,
onSave,
}: BaseConfigModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.baseConfig}
subtitle={isZh ? '在这里维护 Bot 的基础信息和资源配额。' : 'Maintain the bot basics and resource limits here.'}
size="standard"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">
{isZh ? '保存后基础配置会同步到当前 Bot。' : 'Saving syncs the latest base configuration to the current bot.'}
</div>
<div style={{ display: 'inline-flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void onSave()}>{labels.save}</button>
</div>
</div>
)}
>
<div>
<div className="section-mini-title">{isZh ? '基础信息' : 'Basic Info'}</div>
<label className="field-label">{labels.botIdReadonly}</label>
<input className="input" value={selectedBotId} disabled />
<label className="field-label">{labels.botName}</label>
<input className="input" value={editForm.name} onChange={(e) => onEditFormChange({ name: e.target.value })} placeholder={labels.botNamePlaceholder} />
<label className="field-label">{labels.accessPassword}</label>
<PasswordInput
className="input"
value={editForm.access_password}
onChange={(e) => onEditFormChange({ access_password: e.target.value })}
placeholder={labels.accessPasswordPlaceholder}
toggleLabels={passwordToggleLabels}
/>
<label className="field-label">{labels.baseImageReadonly}</label>
<LucentSelect value={editForm.image_tag} onChange={(e) => onEditFormChange({ image_tag: e.target.value })}>
{baseImageOptions.map((img) => (
<option key={img.tag} value={img.tag} disabled={img.disabled}>
{img.label}
</option>
))}
</LucentSelect>
<label className="field-label">{isZh ? '系统时区' : 'System Timezone'}</label>
<LucentSelect
value={editForm.system_timezone || defaultSystemTimezone}
onChange={(e) => onEditFormChange({ system_timezone: e.target.value })}
>
{systemTimezoneOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</LucentSelect>
<div className="field-label">
{isZh ? '提示:系统时区保存后,在 Bot 重启后生效。' : 'Tip: timezone changes take effect after the bot restarts.'}
</div>
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '硬件资源' : 'Hardware Resources'}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
type="number"
min="0"
max="16"
step="0.1"
value={paramDraft.cpu_cores}
onChange={(e) => onParamDraftChange({ cpu_cores: e.target.value })}
/>
<label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
<input
className="input"
type="number"
min="0"
max="65536"
step="128"
value={paramDraft.memory_mb}
onChange={(e) => onParamDraftChange({ memory_mb: e.target.value })}
/>
<label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
<input
className="input"
type="number"
min="0"
max="1024"
step="1"
value={paramDraft.storage_gb}
onChange={(e) => onParamDraftChange({ storage_gb: e.target.value })}
/>
<div className="field-label">{isZh ? '提示:填写 0 表示不限制(保存后需手动重启 Bot 生效)。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}</div>
</div>
</DrawerShell>
);
}
interface ParamConfigLabels {
cancel: string;
close: string;
modelName: string;
modelNamePlaceholder: string;
modelParams: string;
newApiKey: string;
newApiKeyPlaceholder: string;
saveParams: string;
testModelConnection: string;
testing: string;
}
interface ParamConfigModalProps {
open: boolean;
editForm: BotEditForm;
paramDraft: BotParamDraft;
passwordToggleLabels: PasswordToggleLabels;
isZh: boolean;
isTestingProvider: boolean;
providerTestResult: string;
isSaving: boolean;
labels: ParamConfigLabels;
onClose: () => void;
onEditFormChange: (patch: Partial<BotEditForm>) => void;
onParamDraftChange: (patch: Partial<BotParamDraft>) => void;
onProviderChange: (provider: string) => void;
onTestProviderConnection: () => Promise<void> | void;
onSave: () => Promise<void> | void;
}
export function ParamConfigModal({
open,
editForm,
paramDraft,
passwordToggleLabels,
isZh,
isTestingProvider,
providerTestResult,
isSaving,
labels,
onClose,
onEditFormChange,
onParamDraftChange,
onProviderChange,
onTestProviderConnection,
onSave,
}: ParamConfigModalProps) {
if (!open) return null;
const providerOptions = buildLlmProviderOptions(editForm.llm_provider);
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.modelParams}
subtitle={isZh ? '维护 Provider、模型、密钥和采样参数。' : 'Maintain the provider, model, key, and sampling parameters.'}
size="standard"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">
{isTestingProvider
? labels.testing
: (providerTestResult || (isZh ? '保存后模型参数会同步到当前 Bot。' : 'Saving syncs the latest model parameters to the current bot.'))}
</div>
<div style={{ display: 'inline-flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={onClose}>{labels.cancel}</button>
<button className="btn btn-primary" disabled={isSaving} onClick={() => void onSave()}>{labels.saveParams}</button>
</div>
</div>
)}
>
<div>
<label className="field-label">Provider</label>
<LucentSelect value={editForm.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
<option value="" disabled>
{isZh ? '请选择 Provider' : 'Select provider'}
</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</LucentSelect>
<label className="field-label">{labels.modelName}</label>
<input className="input" value={editForm.llm_model} onChange={(e) => onEditFormChange({ llm_model: e.target.value })} placeholder={labels.modelNamePlaceholder} />
<label className="field-label">{labels.newApiKey}</label>
<PasswordInput
className="input"
value={editForm.api_key}
onChange={(e) => onEditFormChange({ api_key: e.target.value })}
placeholder={labels.newApiKeyPlaceholder}
toggleLabels={passwordToggleLabels}
/>
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => onEditFormChange({ api_base: e.target.value })} placeholder="API Base URL" />
<button className="btn btn-secondary" onClick={() => void onTestProviderConnection()} disabled={isTestingProvider}>
{isTestingProvider ? labels.testing : labels.testModelConnection}
</button>
{providerTestResult ? <div className="card">{providerTestResult}</div> : null}
<div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => onEditFormChange({ temperature: clampTemperature(Number(e.target.value)) })} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => onEditFormChange({ top_p: Number(e.target.value) })} />
</div>
<label className="field-label">Max Tokens</label>
<input
className="input"
type="number"
step="1"
min="256"
max="32768"
value={paramDraft.max_tokens}
onChange={(e) => onParamDraftChange({ max_tokens: e.target.value })}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[4096, 8192, 16384, 32768].map((value) => (
<button
key={value}
className="btn btn-secondary btn-sm"
type="button"
onClick={() => onParamDraftChange({ max_tokens: String(value) })}
>
{value}
</button>
))}
</div>
</div>
</DrawerShell>
);
}

View File

@ -0,0 +1,470 @@
.ops-chat-scroll {
min-height: 300px;
max-height: 68vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-soft);
padding: 14px;
}
.ops-chat-row {
display: flex;
width: 100%;
margin-bottom: 12px;
}
.ops-chat-date-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0 14px;
color: var(--subtitle);
font-size: 12px;
font-weight: 700;
}
.ops-chat-date-divider::before,
.ops-chat-date-divider::after {
content: '';
flex: 1 1 auto;
height: 1px;
background: color-mix(in oklab, var(--line) 82%, transparent);
}
.ops-chat-date-divider > span {
flex: 0 0 auto;
padding: 2px 8px;
border: 1px solid color-mix(in oklab, var(--line) 80%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 72%, var(--panel-soft) 28%);
color: var(--muted);
}
.ops-chat-item {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
min-width: 0;
}
.ops-chat-item.is-user {
justify-content: flex-end;
}
.ops-chat-item.is-assistant {
justify-content: flex-start;
}
.ops-chat-hover-actions {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
}
.ops-chat-hover-actions-user {
width: 0;
margin-right: 0;
overflow: visible;
opacity: 0;
pointer-events: none;
transform: translateX(6px) scale(0.95);
transition: opacity 0.18s ease, transform 0.18s ease, width 0.18s ease, margin-right 0.18s ease;
}
.ops-chat-row.is-user:hover .ops-chat-hover-actions-user,
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
width: 54px;
margin-right: 6px;
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);
}
.ops-chat-row.is-user {
justify-content: flex-end;
}
.ops-chat-row.is-assistant {
justify-content: flex-start;
}
.ops-chat-bubble {
width: auto;
min-width: 0;
flex: 0 1 auto;
max-width: min(860px, calc(100% - 52px));
border-radius: 14px;
border: 1px solid var(--line);
padding: 10px 12px;
position: relative;
overflow: visible;
}
.ops-chat-bubble.assistant {
--bubble-bg: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%);
--bubble-border: #3661aa;
border-color: #3661aa;
background: var(--bubble-bg);
border-bottom-left-radius: 4px;
}
.ops-chat-bubble.assistant.progress {
--bubble-bg: color-mix(in oklab, var(--brand-soft) 35%, var(--panel-soft) 65%);
--bubble-border: color-mix(in oklab, var(--brand) 55%, var(--line) 45%);
border-style: dashed;
border-color: color-mix(in oklab, var(--brand) 55%, var(--line) 45%);
background: var(--bubble-bg);
border-bottom-left-radius: 4px;
}
.ops-chat-bubble.user {
--bubble-bg: color-mix(in oklab, #d9fff0 36%, var(--panel-soft) 64%);
--bubble-border: #2f8f7f;
border-color: #2f8f7f;
background: var(--bubble-bg);
border-top-right-radius: 4px;
}
.ops-chat-bubble.assistant::before,
.ops-chat-bubble.assistant::after,
.ops-chat-bubble.user::before,
.ops-chat-bubble.user::after {
content: '';
position: absolute;
width: 0;
height: 0;
}
.ops-chat-bubble.assistant::before {
left: -9px;
bottom: 8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 9px solid var(--bubble-border);
}
.ops-chat-bubble.assistant::after {
left: -7px;
bottom: 9px;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-right: 8px solid var(--bubble-bg);
}
.ops-chat-bubble.user::before {
right: -9px;
top: 8px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 9px solid var(--bubble-border);
}
.ops-chat-bubble.user::after {
right: -7px;
top: 9px;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 8px solid var(--bubble-bg);
}
.ops-chat-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 11px;
font-weight: 700;
margin-bottom: 6px;
color: var(--muted);
}
.ops-chat-meta-right {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-chat-expand-icon-btn {
border: 1px solid color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--panel) 76%, var(--brand-soft) 24%);
color: var(--text);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
width: 20px;
height: 20px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-chat-inline-action {
width: 24px;
height: 24px;
padding: 0;
border-radius: 999px;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--panel) 80%, var(--panel-soft) 20%);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-chat-inline-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ops-chat-reply-actions {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-chat-inline-action.active-up {
border-color: color-mix(in oklab, #2ca476 58%, var(--line) 42%);
background: color-mix(in oklab, #2ca476 20%, var(--panel-soft) 80%);
color: #1f7e5e;
}
.ops-chat-inline-action.active-down {
border-color: color-mix(in oklab, #d14b4b 58%, var(--line) 42%);
background: color-mix(in oklab, #d14b4b 20%, var(--panel-soft) 80%);
color: #9c2f2f;
}
.ops-chat-text {
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.58;
font-size: 14px;
color: var(--text);
}
.ops-chat-text .whitespace-pre-wrap {
white-space: pre-wrap;
}
.ops-chat-text.is-collapsed {
max-height: 220px;
overflow: hidden;
position: relative;
}
.ops-chat-text.is-collapsed-user {
max-height: calc(1.58em * 5);
overflow: hidden;
position: relative;
}
.ops-chat-text.is-collapsed::after,
.ops-chat-text.is-collapsed-user::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 42px;
pointer-events: none;
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--panel-soft) 88%, var(--panel) 12%));
}
.ops-chat-text > *:first-child {
margin-top: 0;
}
.ops-chat-text > *:last-child {
margin-bottom: 0;
}
.ops-chat-text p,
.ops-chat-text ul,
.ops-chat-text ol {
margin: 8px 0;
}
.ops-chat-text p:empty {
margin: 0;
}
.ops-chat-text pre {
margin: 8px 0;
overflow: auto;
border-radius: 10px;
border: 1px solid color-mix(in oklab, var(--line) 75%, transparent);
background: color-mix(in oklab, var(--panel) 78%, #000 22%);
padding: 10px 12px;
}
.ops-chat-text code {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
word-break: break-word;
}
.ops-chat-attachments {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ops-attach-link {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 4px 8px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
text-decoration: none;
color: var(--text);
background: color-mix(in oklab, var(--panel) 78%, transparent);
}
.ops-attach-link-icon {
flex: 0 0 auto;
}
.ops-attach-link-name {
min-width: 0;
}
.ops-attach-link:hover {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
}
.ops-avatar {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--line);
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 34px;
overflow: hidden;
}
.ops-avatar.bot {
background: #102d63;
border: none;
box-shadow: none;
}
.ops-avatar.bot img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ops-avatar.user {
background: #1f4f8a;
border-color: #6d98cf;
color: #e9f2ff;
}
.ops-chat-row.is-user .ops-avatar.user {
margin-left: 10px;
}
.ops-chat-empty {
border: 1px dashed var(--line);
border-radius: 12px;
padding: 12px;
color: var(--muted);
font-size: 14px;
}
.ops-thinking-bubble {
border: 1px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
background: color-mix(in oklab, var(--brand-soft) 40%, var(--panel-soft) 60%);
border-radius: 14px;
padding: 10px 12px;
min-width: 190px;
}
.ops-thinking-cloud {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-thinking-cloud .dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: color-mix(in oklab, var(--brand) 75%, #fff 25%);
animation: ops-thinking-pulse 1.4s infinite ease-in-out;
}
.ops-thinking-cloud .dot:nth-child(2) {
animation-delay: 0.2s;
}
.ops-thinking-cloud .dot:nth-child(3) {
animation-delay: 0.4s;
}
.ops-thinking-text {
margin-top: 8px;
color: var(--subtitle);
font-size: 12px;
font-weight: 700;
}
@keyframes ops-thinking-pulse {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.45;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
.app-shell[data-theme='dark'] .ops-chat-bubble.assistant {
background: #112850;
}
.app-shell[data-theme='dark'] .ops-chat-bubble.user {
background: #0f2f35;
}
.app-shell[data-theme='light'] .ops-chat-bubble.assistant {
--bubble-bg: #eaf1ff;
--bubble-border: #a9c1ee;
background: var(--bubble-bg);
border-color: var(--bubble-border);
border-bottom-left-radius: 4px;
}
.app-shell[data-theme='light'] .ops-chat-bubble.user {
--bubble-bg: #e8f6f2;
--bubble-border: #9ccfc2;
background: var(--bubble-bg);
border-color: var(--bubble-border);
border-top-right-radius: 4px;
}
.app-shell[data-theme='light'] .ops-avatar.bot {
background: #e6efff;
border-color: #a5bfe8;
}
.app-shell[data-theme='light'] .ops-avatar.user {
background: #edf4ff;
border-color: #b1c7ea;
color: #274a7d;
}

View File

@ -0,0 +1,260 @@
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, UserRound } 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 { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png';
import type { ChatMessage } from '../../../types/bot';
import { MARKDOWN_SANITIZE_SCHEMA } from '../constants';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../messageParser';
import { workspaceFileAction, formatClock, formatConversationDate } from '../utils';
import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
import './DashboardConversationMessages.css';
interface DashboardConversationLabels {
badReply: string;
copyPrompt: string;
copyReply: string;
download: string;
editPrompt: string;
fileNotPreviewable: string;
goodReply: string;
previewTitle: string;
quoteReply: string;
quotedReplyLabel: string;
user: string;
you: string;
}
interface DashboardConversationMessagesProps {
conversation: ChatMessage[];
isZh: boolean;
labels: DashboardConversationLabels;
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
}
function shouldCollapseProgress(text: string) {
const normalized = String(text || '').trim();
if (!normalized) return false;
const lines = normalized.split('\n').length;
return lines > 6 || normalized.length > 520;
}
export function DashboardConversationMessages({
conversation,
isZh,
labels,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
}: DashboardConversationMessagesProps) {
return (
<>
{conversation.map((item, idx) => {
const itemKey = `${item.id || item.ts}-${idx}`;
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
return (
<div
key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined}
>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
</div>
) : null}
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
{item.role !== 'user' && (
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
)}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onEditUserPrompt(item.text)}
tooltip={labels.editPrompt}
aria-label={labels.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyUserPrompt(item.text)}
tooltip={labels.copyPrompt}
aria-label={labels.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta">
<span>{item.role === 'user' ? labels.you : 'Nanobot'}</span>
<div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<LucentIconButton
className="ops-chat-expand-icon-btn"
onClick={() => {
if (isProgressBubble) {
onToggleProgressExpand(itemKey);
return;
}
onToggleUserExpand(itemKey);
}}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
{item.text ? (
item.role === 'user' ? (
<>
{item.quoted_reply ? (
<div className="ops-user-quoted-reply">
<div className="ops-user-quoted-label">{labels.quotedReplyLabel}</div>
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div>
) : null}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href="#"
onClick={(event) => {
event.preventDefault();
void onOpenWorkspacePath(filePath);
}}
title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable}
>
{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>
);
})}
</div>
) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={labels.goodReply}
aria-label={labels.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={labels.badReply}
aria-label={labels.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onQuoteAssistantReply(item)}
tooltip={labels.quoteReply}
aria-label={labels.quoteReply}
>
<Reply size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyAssistantReply(item.text)}
tooltip={labels.copyReply}
aria-label={labels.copyReply}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
</div>
</div>
</div>
{item.role === 'user' && (
<div className="ops-avatar user" title={labels.user}>
<UserRound size={18} />
</div>
)}
</div>
</div>
);
})}
</>
);
}

View File

@ -0,0 +1,315 @@
.ops-skills-list-scroll {
max-height: min(56vh, 520px);
overflow: auto;
padding-right: 4px;
}
.ops-modal-scrollable {
max-height: min(92vh, 860px);
overflow: auto;
overscroll-behavior: contain;
}
.ops-config-modal {
min-height: clamp(480px, 68vh, 760px);
display: flex;
flex-direction: column;
}
.ops-config-drawer-body {
padding-bottom: 28px;
}
.ops-config-drawer-body .ops-config-modal {
min-height: 0;
}
.ops-config-drawer-body .ops-config-list-scroll,
.ops-config-drawer-body .ops-skills-list-scroll {
min-height: 0;
max-height: none;
overflow: visible;
padding-right: 0;
}
.ops-config-list-scroll {
min-height: clamp(240px, 38vh, 380px);
max-height: min(56vh, 600px);
overflow: auto;
padding-right: 4px;
}
.ops-config-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.ops-config-card-main {
min-width: 0;
display: grid;
gap: 2px;
}
.ops-config-card-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.ops-config-collapsed-meta {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 520px;
}
.ops-config-new-card {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background:
linear-gradient(180deg, color-mix(in oklab, var(--brand-soft) 22%, var(--panel) 78%), color-mix(in oklab, var(--panel-soft) 82%, transparent)),
var(--panel);
box-shadow:
inset 0 0 0 1px color-mix(in oklab, var(--brand) 12%, transparent),
0 10px 28px color-mix(in oklab, var(--brand-soft) 14%, transparent);
}
.ops-plain-icon-btn {
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ops-plain-icon-btn:hover {
background: color-mix(in oklab, var(--brand-soft) 22%, transparent);
color: var(--text);
}
.ops-plain-icon-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ops-topic-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
align-items: start;
}
.ops-config-field {
min-width: 0;
display: grid;
gap: 6px;
}
.ops-config-field-full {
grid-column: 1 / -1;
}
.ops-config-footer {
position: sticky;
bottom: 0;
background: var(--panel);
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
padding-top: 8px;
}
.ops-config-weixin-login {
margin-top: 12px;
display: grid;
gap: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--brand) 18%, var(--line) 82%);
background:
linear-gradient(180deg, color-mix(in oklab, var(--brand-soft) 16%, var(--panel) 84%), color-mix(in oklab, var(--panel-soft) 78%, transparent)),
var(--panel);
}
.ops-config-weixin-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ops-config-weixin-login-hint,
.ops-config-weixin-login-note {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.ops-config-weixin-login-status {
color: var(--text);
font-size: 13px;
line-height: 1.5;
}
.ops-config-weixin-login-body {
display: grid;
gap: 10px;
}
.ops-config-weixin-qr-frame {
width: min(100%, 240px);
aspect-ratio: 1;
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid color-mix(in oklab, var(--line) 72%, white 28%);
}
.ops-config-weixin-qr {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
border-radius: 10px;
background: #fff;
}
.ops-config-weixin-login-url-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ops-config-weixin-login-url {
padding: 10px 12px;
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 74%, var(--panel) 26%);
border: 1px solid color-mix(in oklab, var(--line) 75%, transparent 25%);
font-size: 12px;
word-break: break-all;
}
.ops-config-weixin-login-link {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
text-decoration: none;
font-size: 12px;
font-weight: 600;
}
.ops-config-weixin-login-link:hover {
text-decoration: underline;
}
.ops-topic-create-menu-wrap {
position: relative;
display: inline-flex;
}
.ops-skill-add-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 10px;
border-top: 1px solid color-mix(in oklab, var(--line) 82%, transparent);
}
.ops-skill-add-hint {
flex: 1 1 auto;
min-width: 0;
}
.ops-skill-create-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.ops-skill-create-trigger svg {
flex: 0 0 auto;
}
.ops-topic-create-menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 40;
min-width: 220px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 14px 32px rgba(9, 16, 31, 0.28);
display: grid;
gap: 4px;
}
.ops-topic-create-menu-item {
border: 0;
background: transparent;
color: var(--text);
border-radius: 8px;
text-align: left;
padding: 8px 10px;
font-size: 13px;
cursor: pointer;
}
.ops-topic-create-menu-item:hover {
background: color-mix(in oklab, var(--brand-soft) 22%, transparent);
}
@media (max-width: 920px) {
.ops-config-modal {
min-height: clamp(420px, 62vh, 640px);
}
.ops-config-list-scroll {
min-height: 220px;
}
.ops-topic-grid {
grid-template-columns: minmax(0, 1fr);
}
.ops-config-card-actions {
justify-content: flex-start;
}
.ops-config-collapsed-meta {
max-width: 100%;
}
.ops-skill-add-bar {
flex-direction: column;
align-items: stretch;
}
.ops-skill-create-trigger {
width: 100%;
justify-content: center;
}
.ops-config-footer {
position: static;
border-top: 0;
padding-top: 0;
}
}

View File

@ -0,0 +1,44 @@
.ops-more-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 190px;
z-index: 30;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 14px 32px rgba(9, 16, 31, 0.28);
padding: 6px;
display: grid;
gap: 4px;
}
.ops-more-item {
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text);
padding: 8px 10px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.ops-more-item:hover {
background: color-mix(in oklab, var(--brand-soft) 36%, var(--panel-soft) 64%);
border-color: color-mix(in oklab, var(--brand) 48%, var(--line) 52%);
}
.ops-more-item.danger {
color: #d76666;
}
.ops-more-item.danger:hover {
background: rgba(215, 102, 102, 0.14);
border-color: rgba(215, 102, 102, 0.34);
}

View File

@ -0,0 +1,65 @@
import type { ComponentProps } from 'react';
import { ChannelConfigModal, TopicConfigModal } from './DashboardChannelTopicModals';
import { BaseConfigModal, ParamConfigModal, ResourceMonitorModal } from './DashboardConfigModals';
import { McpConfigModal, SkillsModal } from './DashboardSkillsMcpModals';
import { AgentFilesModal, CronJobsModal, EnvParamsModal, RuntimeActionModal, TemplateManagerModal } from './DashboardSupportModals';
import { SkillMarketInstallModal } from './SkillMarketInstallModal';
import { WorkspaceHoverCard } from './WorkspaceHoverCard';
import { WorkspacePreviewModal } from './WorkspacePreviewModal';
interface DashboardModalStackProps {
agentFilesModal: ComponentProps<typeof AgentFilesModal>;
baseConfigModal: ComponentProps<typeof BaseConfigModal>;
channelConfigModal: ComponentProps<typeof ChannelConfigModal>;
cronJobsModal: ComponentProps<typeof CronJobsModal>;
envParamsModal: ComponentProps<typeof EnvParamsModal>;
mcpConfigModal: ComponentProps<typeof McpConfigModal>;
paramConfigModal: ComponentProps<typeof ParamConfigModal>;
resourceMonitorModal: ComponentProps<typeof ResourceMonitorModal>;
runtimeActionModal: ComponentProps<typeof RuntimeActionModal>;
skillMarketInstallModal: ComponentProps<typeof SkillMarketInstallModal>;
skillsModal: ComponentProps<typeof SkillsModal>;
templateManagerModal: ComponentProps<typeof TemplateManagerModal>;
topicConfigModal: ComponentProps<typeof TopicConfigModal>;
workspaceHoverCard: ComponentProps<typeof WorkspaceHoverCard>;
workspacePreviewModal: ComponentProps<typeof WorkspacePreviewModal>;
}
export function DashboardModalStack({
agentFilesModal,
baseConfigModal,
channelConfigModal,
cronJobsModal,
envParamsModal,
mcpConfigModal,
paramConfigModal,
resourceMonitorModal,
runtimeActionModal,
skillMarketInstallModal,
skillsModal,
templateManagerModal,
topicConfigModal,
workspaceHoverCard,
workspacePreviewModal,
}: DashboardModalStackProps) {
return (
<>
<ResourceMonitorModal {...resourceMonitorModal} />
<BaseConfigModal {...baseConfigModal} />
<ParamConfigModal {...paramConfigModal} />
<ChannelConfigModal {...channelConfigModal} />
<TopicConfigModal {...topicConfigModal} />
<SkillsModal {...skillsModal} />
<SkillMarketInstallModal {...skillMarketInstallModal} />
<McpConfigModal {...mcpConfigModal} />
<EnvParamsModal {...envParamsModal} />
<CronJobsModal {...cronJobsModal} />
<TemplateManagerModal {...templateManagerModal} />
<AgentFilesModal {...agentFilesModal} />
<RuntimeActionModal {...runtimeActionModal} />
<WorkspacePreviewModal {...workspacePreviewModal} />
<WorkspaceHoverCard {...workspaceHoverCard} />
</>
);
}

View File

@ -0,0 +1,76 @@
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.agent-tabs-vertical {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 150px;
}
.wizard-agent-layout {
display: grid;
grid-template-columns: 170px 1fr;
gap: 10px;
min-height: 420px;
}
.agent-tab {
border: 1px solid var(--line);
background: var(--panel-soft);
color: var(--text);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
}
.agent-tab.active {
border-color: var(--brand);
background: color-mix(in oklab, var(--brand-soft) 54%, var(--panel-soft) 46%);
}
.wizard-channel-list {
display: grid;
gap: 8px;
}
.wizard-channel-card {
min-width: 0;
display: grid;
gap: 6px;
}
.wizard-channel-compact {
padding: 10px;
border-radius: 10px;
}
.wizard-dashboard-switches {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
}
.wizard-icon-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ops-empty-inline {
color: var(--muted);
font-size: 13px;
padding: 4px 2px;
}
@media (max-width: 1160px) {
.summary-grid,
.wizard-agent-layout {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,372 @@
import { ChevronDown, ChevronUp, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react';
import type { ChangeEvent, RefObject } from 'react';
import { DrawerShell } from '../../../components/DrawerShell';
import { PasswordInput } from '../../../components/PasswordInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import type { MCPServerDraft, WorkspaceSkillOption } from '../types';
import './DashboardManagementModals.css';
interface PasswordToggleLabels {
show: string;
hide: string;
}
interface SkillsModalProps {
open: boolean;
botSkills: WorkspaceSkillOption[];
isSkillUploading: boolean;
isZh: boolean;
hasSelectedBot: boolean;
labels: Record<string, any>;
skillZipPickerRef: RefObject<HTMLInputElement | null>;
skillAddMenuRef: RefObject<HTMLDivElement | null>;
skillAddMenuOpen: boolean;
onClose: () => void;
onRefreshSkills: () => Promise<void> | void;
onRemoveSkill: (skill: WorkspaceSkillOption) => Promise<void> | void;
onPickSkillZip: (event: ChangeEvent<HTMLInputElement>) => void;
onSetSkillAddMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onTriggerSkillZipUpload: () => void;
onOpenSkillMarketplace: () => Promise<void> | void;
}
export function SkillsModal({
open,
botSkills,
isSkillUploading,
isZh,
hasSelectedBot,
labels,
skillZipPickerRef,
skillAddMenuRef,
skillAddMenuOpen,
onClose,
onRefreshSkills,
onRemoveSkill,
onPickSkillZip,
onSetSkillAddMenuOpen,
onTriggerSkillZipUpload,
onOpenSkillMarketplace,
}: SkillsModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.skillsPanel}
subtitle={isZh ? '查看当前 Bot 已安装的技能。' : 'View the skills already installed for this bot.'}
size="standard"
closeLabel={labels.close}
headerActions={(
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => void onRefreshSkills()} tooltip={isZh ? '刷新已安装技能' : 'Refresh installed skills'} aria-label={isZh ? '刷新已安装技能' : 'Refresh installed skills'}>
<RefreshCw size={14} />
</LucentIconButton>
)}
footer={(
<div className="drawer-shell-footer-content">
<input
ref={skillZipPickerRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={onPickSkillZip}
style={{ display: 'none' }}
/>
<div className="drawer-shell-footer-main field-label ops-skill-add-hint">
{isSkillUploading
? (isZh ? '正在上传 ZIP 技能包...' : 'Uploading ZIP skill package...')
: (isZh ? '支持上传本地 ZIP或从技能市场安装技能到当前 Bot。' : 'Upload a local ZIP or install a skill from the marketplace into this bot.')}
</div>
<div className="ops-topic-create-menu-wrap" ref={skillAddMenuRef}>
<button type="button" className="btn btn-primary ops-skill-create-trigger" onClick={() => onSetSkillAddMenuOpen((prev) => !prev)} disabled={!hasSelectedBot}>
<Plus size={18} />
<span>{isZh ? '新增技能' : 'Add Skill'}</span>
</button>
{skillAddMenuOpen ? (
<div className="ops-topic-create-menu">
<button className="ops-topic-create-menu-item" type="button" onClick={() => {
onSetSkillAddMenuOpen(false);
onTriggerSkillZipUpload();
}}>
{isZh ? '本地上传 ZIP' : 'Upload Local ZIP'}
</button>
<button className="ops-topic-create-menu-item" type="button" onClick={() => void onOpenSkillMarketplace()}>
{isZh ? '从技能市场安装' : 'Install From Marketplace'}
</button>
</div>
) : null}
</div>
</div>
)}
>
<div className="stack">
<div className="stack">
<div className="row-between">
<div>
<div className="section-mini-title">{isZh ? '已安装技能' : 'Installed Skills'}</div>
<div className="field-label">
{isZh ? '这里展示当前 Bot 工作区中的技能。' : 'These skills are already present in the bot workspace.'}
</div>
</div>
<div className="field-label">
{isZh ? `${botSkills.length} 个已安装` : `${botSkills.length} installed`}
</div>
</div>
<div className="wizard-channel-list ops-skills-list-scroll">
{botSkills.length === 0 ? (
<div className="ops-empty-inline">{labels.skillsEmpty}</div>
) : (
botSkills.map((skill) => (
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<div>
<strong>{skill.name || skill.id}</strong>
<div className="field-label mono">{skill.path}</div>
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
<div className="field-label">{skill.description || '-'}</div>
</div>
<LucentIconButton className="btn btn-danger btn-sm wizard-icon-btn" onClick={() => void onRemoveSkill(skill)} tooltip={labels.removeSkill} aria-label={labels.removeSkill}>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
))
)}
</div>
</div>
</div>
</DrawerShell>
);
}
interface McpConfigModalProps {
open: boolean;
mcpServers: MCPServerDraft[];
expandedMcpByKey: Record<string, boolean>;
newMcpDraft: MCPServerDraft;
newMcpPanelOpen: boolean;
isSavingMcp: boolean;
isZh: boolean;
labels: Record<string, any>;
passwordToggleLabels: PasswordToggleLabels;
onClose: () => void;
getMcpUiKey: (row: Pick<MCPServerDraft, 'name' | 'url'>, fallbackIndex: number) => string;
canRemoveMcpServer: (row?: MCPServerDraft | null) => boolean;
onRemoveMcpServer: (index: number) => void;
onToggleExpandedMcp: (key: string) => void;
onUpdateMcpServer: (index: number, patch: Partial<MCPServerDraft>) => void;
onSaveSingleMcpServer: (index: number) => Promise<void> | void;
onSetNewMcpPanelOpen: (value: boolean) => void;
onUpdateNewMcpDraft: (patch: Partial<MCPServerDraft>) => void;
onResetNewMcpDraft: () => void;
onSaveNewMcpServer: () => Promise<void> | void;
onBeginMcpCreate: () => void;
}
export function McpConfigModal({
open,
mcpServers,
expandedMcpByKey,
newMcpDraft,
newMcpPanelOpen,
isSavingMcp,
isZh,
labels,
passwordToggleLabels,
onClose,
getMcpUiKey,
canRemoveMcpServer,
onRemoveMcpServer,
onToggleExpandedMcp,
onUpdateMcpServer,
onSaveSingleMcpServer,
onSetNewMcpPanelOpen,
onUpdateNewMcpDraft,
onResetNewMcpDraft,
onSaveNewMcpServer,
onBeginMcpCreate,
}: McpConfigModalProps) {
if (!open) return null;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.mcpPanel}
subtitle={labels.mcpPanelDesc}
size="extend"
closeLabel={labels.close}
bodyClassName="ops-config-drawer-body"
footer={(
!newMcpPanelOpen ? (
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{labels.mcpHint}</div>
<button className="btn btn-primary" onClick={onBeginMcpCreate}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{labels.addMcpServer}</span>
</button>
</div>
) : undefined
)}
>
<div className="ops-config-modal">
<div className="field-label" style={{ marginBottom: 8 }}>{labels.mcpPanelDesc}</div>
<div className="wizard-channel-list ops-config-list-scroll">
{mcpServers.length === 0 ? (
<div className="ops-empty-inline">{labels.mcpEmpty}</div>
) : (
mcpServers.map((row, idx) => {
const uiKey = getMcpUiKey(row, idx);
const expanded = expandedMcpByKey[uiKey] ?? idx === 0;
const summary = `${row.type || 'streamableHttp'} · ${row.url || '-'}`;
return (
<div key={`mcp-${idx}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{row.name || `${labels.mcpServer} #${idx + 1}`}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingMcp || !canRemoveMcpServer(row)}
onClick={() => onRemoveMcpServer(idx)}
tooltip={labels.removeSkill}
aria-label={labels.removeSkill}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleExpandedMcp(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.mcpName}</label>
<input className="input mono" value={row.name} placeholder={labels.mcpNamePlaceholder} onChange={(e) => onUpdateMcpServer(idx, { name: e.target.value })} autoComplete="off" disabled={row.locked} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.mcpType}</label>
<LucentSelect value={row.type} onChange={(e) => onUpdateMcpServer(idx, { type: e.target.value as 'streamableHttp' | 'sse' })} disabled={row.locked}>
<option value="streamableHttp">streamableHttp</option>
<option value="sse">sse</option>
</LucentSelect>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">URL</label>
<input className="input mono" value={row.url} placeholder={labels.mcpUrlPlaceholder} onChange={(e) => onUpdateMcpServer(idx, { url: e.target.value })} autoComplete="off" disabled={row.locked} />
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Id</label>
<input className="input mono" value={row.botId} placeholder={labels.mcpBotIdPlaceholder} onChange={(e) => onUpdateMcpServer(idx, { botId: e.target.value })} autoComplete="off" disabled={row.locked} />
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label>
<PasswordInput className="input" value={row.botSecret} placeholder={labels.mcpBotSecretPlaceholder} onChange={(e) => onUpdateMcpServer(idx, { botSecret: e.target.value })} autoComplete="new-password" disabled={row.locked} toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.mcpToolTimeout}</label>
<input className="input mono" type="number" min="1" max="600" value={row.toolTimeout} onChange={(e) => onUpdateMcpServer(idx, { toolTimeout: e.target.value })} disabled={row.locked} />
</div>
</div>
{!row.locked ? (
<div className="row-between ops-config-footer">
<span className="field-label">{labels.mcpHint}</span>
<button className="btn btn-primary btn-sm" onClick={() => void onSaveSingleMcpServer(idx)} disabled={isSavingMcp}>
{isSavingMcp ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
) : null}
</>
) : null}
</div>
);
})
)}
</div>
{newMcpPanelOpen ? (
<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.addMcpServer}</strong>
<div className="ops-config-collapsed-meta">{newMcpDraft.type || 'streamableHttp'}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => {
onSetNewMcpPanelOpen(false);
onResetNewMcpDraft();
}}
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.mcpName}</label>
<input className="input mono" value={newMcpDraft.name} placeholder={labels.mcpNamePlaceholder} onChange={(e) => onUpdateNewMcpDraft({ name: e.target.value })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.mcpType}</label>
<LucentSelect value={newMcpDraft.type} onChange={(e) => onUpdateNewMcpDraft({ type: e.target.value as 'streamableHttp' | 'sse' })}>
<option value="streamableHttp">streamableHttp</option>
<option value="sse">sse</option>
</LucentSelect>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">URL</label>
<input className="input mono" value={newMcpDraft.url} placeholder={labels.mcpUrlPlaceholder} onChange={(e) => onUpdateNewMcpDraft({ url: e.target.value })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Id</label>
<input className="input mono" value={newMcpDraft.botId} placeholder={labels.mcpBotIdPlaceholder} onChange={(e) => onUpdateNewMcpDraft({ botId: e.target.value })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label>
<PasswordInput className="input" value={newMcpDraft.botSecret} placeholder={labels.mcpBotSecretPlaceholder} onChange={(e) => onUpdateNewMcpDraft({ botSecret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field">
<label className="field-label">{labels.mcpToolTimeout}</label>
<input className="input mono" type="number" min="1" max="600" value={newMcpDraft.toolTimeout} onChange={(e) => onUpdateNewMcpDraft({ toolTimeout: e.target.value })} />
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.mcpHint}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
onSetNewMcpPanelOpen(false);
onResetNewMcpDraft();
}}
>
{labels.cancel}
</button>
<button className="btn btn-primary btn-sm" disabled={isSavingMcp} onClick={() => void onSaveNewMcpServer()}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerShell>
);
}

View File

@ -0,0 +1,113 @@
.ops-template-tabs {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin: 2px 0 12px;
padding: 0 6px 8px;
}
.ops-template-tabs::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: color-mix(in oklab, var(--line) 78%, var(--panel-soft) 22%);
}
.ops-template-tab {
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: color-mix(in oklab, var(--text) 82%, var(--muted) 18%);
min-height: 54px;
padding: 10px 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: all 0.16s ease;
}
.ops-template-tab:hover {
background: color-mix(in oklab, var(--brand-soft) 52%, var(--panel) 48%);
}
.ops-template-tab.is-active {
background: color-mix(in oklab, var(--brand-soft) 74%, var(--panel) 26%);
border-color: color-mix(in oklab, var(--brand) 30%, transparent);
color: color-mix(in oklab, var(--text) 90%, var(--brand) 10%);
box-shadow: 0 8px 20px rgba(45, 93, 185, 0.08);
}
.ops-template-tab.is-active::after {
content: "";
position: relative;
display: block;
height: 2px;
width: calc(100% + 28px);
left: -14px;
top: 11px;
background: color-mix(in oklab, var(--brand) 78%, #ffffff 22%);
}
.ops-template-tab-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
text-align: center;
white-space: nowrap;
}
.ops-cron-list {
display: grid;
gap: 8px;
}
.ops-cron-list-scroll {
max-height: min(58vh, 560px);
overflow: auto;
padding-right: 4px;
}
.ops-cron-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 82%, transparent);
padding: 8px;
}
.ops-cron-main {
min-width: 0;
display: grid;
gap: 4px;
}
.ops-cron-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.ops-cron-meta {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-cron-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}

View File

@ -0,0 +1,606 @@
import { useEffect, useState } from 'react';
import { Clock3, Plus, PowerOff, RefreshCw, Save, 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 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;
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;
}
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}
subtitle={labels.envParamsDesc}
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="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{labels.envParamsDesc}
</div>
<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 style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-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;
isZh: boolean;
labels: CronModalLabels;
formatCronSchedule: (job: CronJob, isZh: boolean) => string;
onClose: () => void;
onReload: () => Promise<void> | void;
onStopJob: (jobId: string) => Promise<void> | void;
onDeleteJob: (jobId: string) => Promise<void> | void;
}
export function CronJobsModal({
open,
cronLoading,
cronJobs,
cronActionJobId,
isZh,
labels,
formatCronSchedule,
onClose,
onReload,
onStopJob,
onDeleteJob,
}: CronJobsModalProps) {
if (!open) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{labels.cronViewer}</h3>
</div>
<div className="modal-title-actions">
<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>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={labels.close} aria-label={labels.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
{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 stopping = cronActionJobId === job.id;
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-danger btn-sm icon-btn"
onClick={() => void onStopJob(job.id)}
tooltip={labels.cronStop}
aria-label={labels.cronStop}
disabled={stopping || job.enabled === false}
>
<PowerOff 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={stopping}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
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;
return (
<DrawerShell
open={open}
onClose={onClose}
title={labels.templateManagerTitle}
subtitle={templateTab === 'agent'
? `${labels.templateTabAgent} (${templateAgentCount})`
: `${labels.templateTabTopic} (${templateTopicCount})`}
size="extend"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">
{templateTab === 'agent'
? `${labels.templateTabAgent} (${templateAgentCount})`
: `${labels.templateTabTopic} (${templateTopicCount})`}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: '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>
<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-config-grid" style={{ gridTemplateColumns: '1fr' }}>
{templateTab === 'agent' ? (
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={templateAgentText}
onChange={(e) => onTemplateAgentTextChange(e.target.value)}
placeholder='{"agents_md":"..."}'
/>
</div>
) : (
<div className="ops-config-field">
<textarea
className="textarea md-area mono"
rows={16}
value={templateTopicText}
onChange={(e) => onTemplateTopicTextChange(e.target.value)}
placeholder='{"presets":[...]}'
/>
</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"
closeLabel={labels.close}
footer={(
<div className="drawer-shell-footer-content">
<div className="drawer-shell-footer-main field-label">{`${agentTab}.md`}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: '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>
<div className="wizard-agent-layout">
<div className="agent-tabs-vertical">
{(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => onAgentTabChange(tab)}>{tab}.md</button>
))}
</div>
<MarkdownLiteEditor
value={tabValue}
onChange={onTabValueChange}
/>
</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 (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{labels.lastAction}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={labels.close} aria-label={labels.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="workspace-preview-body">
<pre>{runtimeAction}</pre>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,615 @@
.ops-runtime-panel {
min-width: 0;
min-height: 0;
}
.ops-runtime-shell {
display: grid;
grid-template-rows: auto 1fr;
gap: 10px;
min-height: 0;
height: 100%;
}
.ops-runtime-head {
flex-wrap: wrap;
align-items: center;
}
.ops-panel-tools {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.ops-runtime-scroll {
min-height: 0;
overflow: auto;
display: grid;
gap: 10px;
align-content: start;
padding-right: 2px;
}
.ops-runtime-card {
display: grid;
gap: 8px;
}
.ops-runtime-state-card {
min-height: 0;
}
.ops-runtime-state-card:not(.is-visual) {
min-height: 210px;
align-content: start;
}
.ops-runtime-state-card.is-visual {
min-height: 210px;
padding: 0;
gap: 0;
overflow: hidden;
}
.ops-runtime-state-card.is-visual .ops-state-stage {
height: 100%;
min-height: 100%;
border-radius: inherit;
}
.ops-runtime-state-card.is-visual .ops-state-face {
width: 210px;
height: 140px;
gap: 34px;
}
.ops-runtime-state-card.is-visual .ops-state-eye {
width: 30px;
height: 30px;
}
.ops-runtime-state-card.is-visual .ops-state-eye::after {
width: 10px;
height: 10px;
}
.ops-runtime-state-card.is-visual .ops-state-idle .ops-state-eye {
width: 36px;
height: 6px;
}
.ops-runtime-state-card.is-visual .ops-state-caption {
bottom: 14px;
font-size: 12px;
}
.ops-runtime-state-card.is-visual .ops-state-float {
padding: 6px;
right: 22%;
top: 24%;
}
.ops-state-stage {
position: relative;
height: 160px;
border: 0;
border-radius: 10px;
display: grid;
place-items: center;
background:
radial-gradient(circle at 20% 25%, rgba(95, 136, 210, 0.12), transparent 40%),
radial-gradient(circle at 82% 70%, rgba(53, 94, 176, 0.1), transparent 40%),
color-mix(in oklab, var(--panel-soft) 72%, var(--panel) 28%);
overflow: hidden;
}
.ops-state-model {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - 28px);
border: 1px solid rgba(148, 179, 235, 0.55);
border-radius: 999px;
background: rgba(11, 24, 48, 0.58);
color: #dce9ff;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ops-state-face {
width: 148px;
height: 96px;
border-radius: 999px;
border: 1px solid rgba(148, 179, 235, 0.55);
background: rgba(19, 37, 74, 0.55);
box-shadow:
0 8px 30px rgba(10, 22, 44, 0.35),
inset 0 0 0 1px rgba(156, 185, 241, 0.18);
display: flex;
align-items: center;
justify-content: center;
gap: 26px;
}
.ops-state-eye {
width: 22px;
height: 22px;
position: relative;
border-radius: 999px;
border: 3px solid #d8e7ff;
background: rgba(232, 242, 255, 0.95);
box-shadow: 0 0 0 1px rgba(145, 178, 234, 0.35);
}
.ops-state-eye::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
border-radius: 999px;
background: #1b3569;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.ops-state-eye::before {
content: "";
display: none;
}
.ops-state-caption {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 0.08em;
color: color-mix(in oklab, var(--text) 70%, var(--muted) 30%);
}
.ops-state-float {
position: absolute;
color: #f2f7ff;
border: 1px solid rgba(163, 187, 226, 0.65);
background: rgba(17, 36, 71, 0.72);
border-radius: 999px;
padding: 4px;
right: 24%;
top: 26%;
}
.ops-state-float.state-tool {
animation: ops-tool-bob 0.7s ease-in-out infinite;
}
.ops-state-float.state-success {
color: #62e2ad;
border-color: rgba(98, 226, 173, 0.6);
}
.ops-state-float.state-error {
color: #ff9b9b;
border-color: rgba(255, 155, 155, 0.6);
}
.ops-state-idle .ops-state-eye {
width: 28px;
height: 4px;
border: 0;
border-radius: 999px;
background: #d8e7ff;
box-shadow: none;
}
.ops-state-idle .ops-state-eye::after {
display: none;
}
.ops-state-thinking .ops-state-face {
animation: ops-state-breathe 1.25s ease-in-out infinite;
}
.ops-state-thinking .ops-state-eye::after {
animation: ops-eye-scan 1.3s ease-in-out infinite;
}
.ops-state-tool_call .ops-state-face {
animation: ops-state-breathe 0.8s ease-in-out infinite;
}
.ops-state-tool_call .ops-state-eye::after {
animation: ops-eye-scan-fast 0.7s ease-in-out infinite;
}
.ops-state-success .ops-state-face {
box-shadow:
0 8px 30px rgba(11, 48, 37, 0.3),
inset 0 0 0 1px rgba(112, 225, 172, 0.35);
}
.ops-state-success .ops-state-eye {
width: 22px;
height: 12px;
border: 0;
border-top: 3px solid #78dfb4;
border-radius: 999px;
background: transparent;
box-shadow: none;
transform: translateY(3px);
}
.ops-state-success .ops-state-eye.left {
transform: translateY(3px) rotate(-8deg);
}
.ops-state-success .ops-state-eye.right {
transform: translateY(3px) rotate(8deg);
}
.ops-state-success .ops-state-eye::after {
display: none;
}
.ops-state-error .ops-state-face {
box-shadow:
0 8px 30px rgba(57, 19, 19, 0.32),
inset 0 0 0 1px rgba(255, 155, 155, 0.32);
}
.ops-state-error .ops-state-eye {
width: 20px;
height: 20px;
border: 0;
background: transparent;
box-shadow: none;
}
.ops-state-error .ops-state-eye::after,
.ops-state-error .ops-state-eye::before {
content: "";
display: block;
position: absolute;
left: 50%;
top: 50%;
width: 18px;
height: 3px;
border-radius: 999px;
background: #ff9b9b;
}
.ops-state-error .ops-state-eye::after {
transform: translate(-50%, -50%) rotate(45deg);
}
.ops-state-error .ops-state-eye::before {
transform: translate(-50%, -50%) rotate(-45deg);
}
.ops-state-error .ops-state-face,
.ops-state-stopped .ops-state-face,
.ops-state-exited .ops-state-face {
filter: saturate(0.75);
}
.ops-state-stopped .ops-state-eye,
.ops-state-exited .ops-state-eye {
width: 24px;
height: 3px;
border: 0;
background: color-mix(in oklab, var(--muted) 68%, #9fb4dc 32%);
box-shadow: none;
}
.ops-state-stopped .ops-state-eye::after,
.ops-state-exited .ops-state-eye::after {
display: none;
}
.ops-state-unknown .ops-state-eye,
.ops-state-info .ops-state-eye {
animation: none;
}
@keyframes ops-state-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes ops-eye-scan {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
30% { transform: translate(-50%, -50%) translateX(-2px); }
65% { transform: translate(-50%, -50%) translateX(2px); }
}
@keyframes ops-eye-scan-fast {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
50% { transform: translate(-50%, -50%) translateX(2px); }
}
@keyframes ops-tool-bob {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(-15deg) translateY(-1px); }
}
.workspace-panel {
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
min-width: 0;
min-height: 340px;
display: grid;
grid-template-rows: 1fr auto;
}
.workspace-toolbar {
display: flex;
gap: 8px;
align-items: stretch;
min-width: 0;
}
.workspace-path-wrap {
min-width: 0;
flex: 1 1 auto;
border: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
border-radius: 10px;
background: color-mix(in oklab, var(--panel) 58%, var(--panel-soft) 42%);
padding: 8px 10px;
}
.workspace-toolbar-actions {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
}
.workspace-refresh-icon-btn {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-soft);
color: var(--icon);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.15s ease;
}
.workspace-refresh-icon-btn:hover {
border-color: color-mix(in oklab, var(--brand) 60%, var(--line) 40%);
background: color-mix(in oklab, var(--brand-soft) 42%, var(--panel-soft) 58%);
}
.workspace-refresh-icon-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.workspace-auto-switch {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--subtitle);
font-size: 11px;
font-weight: 700;
user-select: none;
min-width: 0;
max-width: 100%;
}
.workspace-auto-switch-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-auto-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.workspace-auto-switch-track {
position: relative;
width: 34px;
height: 20px;
border-radius: 999px;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--panel-soft) 85%, var(--panel) 15%);
transition: background 0.2s ease, border-color 0.2s ease;
}
.workspace-auto-switch-track::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: color-mix(in oklab, var(--text) 75%, #fff 25%);
transition: transform 0.2s ease, background 0.2s ease;
}
.workspace-auto-switch input:checked + .workspace-auto-switch-track {
border-color: color-mix(in oklab, var(--brand) 70%, var(--line) 30%);
background: color-mix(in oklab, var(--brand) 42%, var(--panel-soft) 58%);
}
.workspace-auto-switch input:checked + .workspace-auto-switch-track::after {
transform: translateX(14px);
background: #fff;
}
.workspace-path {
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.workspace-search-toolbar {
margin-top: 8px;
}
.workspace-list {
background: var(--panel);
overflow: auto;
padding: 8px;
min-width: 0;
}
.workspace-entry {
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: color-mix(in oklab, var(--panel-soft) 70%, transparent);
color: var(--text);
text-align: left;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
padding-left: 8px;
padding-right: 8px;
margin-bottom: 6px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.workspace-entry .workspace-entry-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-entry .workspace-entry-meta {
color: var(--muted);
font-size: 11px;
margin-top: 1px;
flex: 0 0 auto;
max-width: 84px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-entry.dir {
border-color: color-mix(in oklab, var(--line) 80%, transparent);
}
.workspace-entry.nav-up {
border-style: dashed;
}
.workspace-entry.file {
cursor: pointer;
border-color: color-mix(in oklab, var(--line) 80%, transparent);
}
.workspace-entry:hover {
border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%);
background: color-mix(in oklab, var(--brand-soft) 45%, transparent);
}
.workspace-entry.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.workspace-hint {
background: var(--panel-soft);
color: var(--muted);
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--line);
padding: 7px 9px;
}
.app-shell[data-theme='light'] .ops-runtime-card {
background: #f7fbff;
}
.app-shell[data-theme='light'] .workspace-list {
background: #ffffff;
}
.app-shell[data-theme='light'] .workspace-hint,
.app-shell[data-theme='light'] .workspace-path,
.app-shell[data-theme='light'] .workspace-path-wrap {
background: #f7fbff;
}
@media (max-width: 1160px) {
.workspace-panel {
min-height: 280px;
grid-template-rows: 1fr auto;
}
.workspace-toolbar {
gap: 6px;
}
.workspace-path-wrap {
flex: 1;
}
.ops-runtime-state-card {
min-height: 0;
}
.ops-runtime-state-card:not(.is-visual) {
min-height: 170px;
}
.ops-runtime-state-card.is-visual {
min-height: 170px;
}
.ops-runtime-state-card.is-visual .ops-state-stage {
min-height: 100%;
}
.ops-runtime-state-card.is-visual .ops-state-face {
width: 172px;
height: 114px;
gap: 28px;
}
.ops-runtime-state-card.is-visual .ops-state-eye {
width: 24px;
height: 24px;
}
.ops-runtime-state-card.is-visual .ops-state-idle .ops-state-eye {
width: 30px;
height: 5px;
}
}

View File

@ -0,0 +1,326 @@
import { Boxes, Check, Clock3, EllipsisVertical, FileText, Hammer, MessageSquareText, RefreshCw, RotateCcw, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, Waypoints } from 'lucide-react';
import type { RefObject } from 'react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotState } from '../../../types/bot';
import type { WorkspaceNode } from '../types';
import { WorkspaceEntriesList } from './WorkspaceEntriesList';
import './DashboardMenus.css';
import './RuntimePanel.css';
interface RuntimePanelLabels {
agent: string;
autoRefresh: string;
base: string;
channels: string;
clearHistory: string;
cronViewer: string;
download: string;
emptyDir: string;
envParams: string;
exportHistory: string;
fileNotPreviewable: string;
folder: string;
goUp: string;
goUpTitle: string;
loadingDir: string;
mcp: string;
more: string;
noPreviewFile: string;
openingPreview: string;
openFolderTitle: string;
params: string;
previewTitle: string;
refreshHint: string;
restart: string;
runtime: string;
searchAction: string;
skills: string;
topic: string;
workspaceHint: string;
workspaceOutputs: string;
workspaceSearchNoResult: string;
workspaceSearchPlaceholder: string;
clearSearch: string;
}
interface RuntimePanelProps {
selectedBot?: BotState;
selectedBotEnabled: boolean;
operatingBotId: string | null;
runtimeMenuOpen: boolean;
runtimeMenuRef: RefObject<HTMLDivElement | null>;
displayState: string;
workspaceError: string;
workspacePathDisplay: string;
workspaceLoading: boolean;
workspaceQuery: string;
workspaceSearchInputName: string;
workspaceSearchLoading: boolean;
filteredWorkspaceEntries: WorkspaceNode[];
workspaceParentPath: string | null;
workspaceFileLoading: boolean;
workspaceDownloadExtensionSet: ReadonlySet<string>;
workspaceAutoRefresh: boolean;
hasPreviewFiles: boolean;
isCompactHidden: boolean;
showCompactSurface: boolean;
emptyStateText: string;
labels: RuntimePanelLabels;
onRestartBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onToggleRuntimeMenu: () => void;
onOpenBaseConfig: () => Promise<void> | void;
onOpenParamConfig: () => Promise<void> | void;
onOpenChannelConfig: () => Promise<void> | void;
onOpenTopicConfig: () => Promise<void> | void;
onOpenEnvParams: () => Promise<void> | void;
onOpenSkills: () => Promise<void> | void;
onOpenMcpConfig: () => Promise<void> | void;
onOpenCronJobs: () => Promise<void> | void;
onOpenAgentFiles: () => Promise<void> | void;
onExportHistory: () => void;
onClearHistory: () => Promise<void> | void;
onRefreshWorkspace: () => Promise<void> | void;
onWorkspaceQueryChange: (value: string) => void;
onWorkspaceQueryClear: () => void;
onWorkspaceQuerySearch: () => void;
onToggleWorkspaceAutoRefresh: () => void;
onLoadWorkspaceTree: (botId: string, path?: string) => Promise<void> | void;
onOpenWorkspaceFilePreview: (path: string) => Promise<void> | void;
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
onHideWorkspaceHoverCard: () => void;
}
export function RuntimePanel({
selectedBot,
selectedBotEnabled,
operatingBotId,
runtimeMenuOpen,
runtimeMenuRef,
displayState,
workspaceError,
workspacePathDisplay,
workspaceLoading,
workspaceQuery,
workspaceSearchInputName,
workspaceSearchLoading,
filteredWorkspaceEntries,
workspaceParentPath,
workspaceFileLoading,
workspaceDownloadExtensionSet,
workspaceAutoRefresh,
hasPreviewFiles,
isCompactHidden,
showCompactSurface,
emptyStateText,
labels,
onRestartBot,
onToggleRuntimeMenu,
onOpenBaseConfig,
onOpenParamConfig,
onOpenChannelConfig,
onOpenTopicConfig,
onOpenEnvParams,
onOpenSkills,
onOpenMcpConfig,
onOpenCronJobs,
onOpenAgentFiles,
onExportHistory,
onClearHistory,
onRefreshWorkspace,
onWorkspaceQueryChange,
onWorkspaceQueryClear,
onWorkspaceQuerySearch,
onToggleWorkspaceAutoRefresh,
onLoadWorkspaceTree,
onOpenWorkspaceFilePreview,
onShowWorkspaceHoverCard,
onHideWorkspaceHoverCard,
}: RuntimePanelProps) {
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
return (
<section className={`panel stack ops-runtime-panel ${isCompactHidden ? 'ops-compact-hidden' : ''} ${showCompactSurface ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-runtime-shell">
<div className="row-between ops-runtime-head">
<h2 style={{ fontSize: 18 }}>{labels.runtime}</h2>
<div className="ops-panel-tools" ref={runtimeMenuRef}>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onRestartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
tooltip={labels.restart}
aria-label={labels.restart}
>
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleRuntimeMenu}
disabled={!selectedBotEnabled}
tooltip={labels.more}
aria-label={labels.more}
aria-haspopup="menu"
aria-expanded={runtimeMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={labels.more}>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenBaseConfig()}>
<Settings2 size={14} />
<span>{labels.base}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenParamConfig()}>
<SlidersHorizontal size={14} />
<span>{labels.params}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenChannelConfig()}>
<Waypoints size={14} />
<span>{labels.channels}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenTopicConfig()}>
<MessageSquareText size={14} />
<span>{labels.topic}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenEnvParams()}>
<Settings2 size={14} />
<span>{labels.envParams}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenSkills()}>
<Hammer size={14} />
<span>{labels.skills}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenMcpConfig()}>
<Boxes size={14} />
<span>{labels.mcp}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenCronJobs()}>
<Clock3 size={14} />
<span>{labels.cronViewer}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenAgentFiles()}>
<FileText size={14} />
<span>{labels.agent}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onExportHistory}>
<Save size={14} />
<span>{labels.exportHistory}</span>
</button>
<button className="ops-more-item danger" role="menuitem" onClick={() => void onClearHistory()}>
<Trash2 size={14} />
<span>{labels.clearHistory}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-runtime-scroll">
<div className="card ops-runtime-card ops-runtime-state-card is-visual">
<div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
<div className="ops-state-model mono">{selectedBot.llm_model || '-'}</div>
<div className="ops-state-face" aria-hidden="true">
<span className="ops-state-eye left" />
<span className="ops-state-eye right" />
</div>
<div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
{displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
{displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
{displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
</div>
</div>
<div className="card ops-runtime-card">
<div className="section-mini-title">{labels.workspaceOutputs}</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar">
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading}
onClick={() => void onRefreshWorkspace()}
tooltip={labels.refreshHint}
aria-label={labels.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={labels.autoRefresh}>
<span className="workspace-auto-switch-label">{labels.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={onToggleWorkspaceAutoRefresh}
aria-label={labels.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div>
<div className="workspace-search-toolbar">
<ProtectedSearchInput
value={workspaceQuery}
onChange={onWorkspaceQueryChange}
onClear={onWorkspaceQueryClear}
onSearchAction={onWorkspaceQuerySearch}
debounceMs={200}
placeholder={labels.workspaceSearchPlaceholder}
ariaLabel={labels.workspaceSearchPlaceholder}
clearTitle={labels.clearSearch}
searchTitle={labels.searchAction}
name={workspaceSearchInputName}
id={workspaceSearchInputName}
/>
</div>
<div className="workspace-panel">
<div className="workspace-list">
{workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{labels.loadingDir}</div>
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
<div className="ops-empty-inline">{normalizedWorkspaceQuery ? labels.workspaceSearchNoResult : labels.emptyDir}</div>
) : (
<WorkspaceEntriesList
nodes={filteredWorkspaceEntries}
workspaceParentPath={workspaceParentPath}
selectedBotId={selectedBot.id}
workspaceFileLoading={workspaceFileLoading}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
labels={{
download: labels.download,
fileNotPreviewable: labels.fileNotPreviewable,
folder: labels.folder,
goUp: labels.goUp,
goUpTitle: labels.goUpTitle,
openFolderTitle: labels.openFolderTitle,
previewTitle: labels.previewTitle,
}}
onLoadWorkspaceTree={onLoadWorkspaceTree}
onOpenWorkspaceFilePreview={onOpenWorkspaceFilePreview}
onShowWorkspaceHoverCard={onShowWorkspaceHoverCard}
onHideWorkspaceHoverCard={onHideWorkspaceHoverCard}
/>
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading ? labels.openingPreview : labels.workspaceHint}
</div>
</div>
{!hasPreviewFiles ? (
<div className="ops-empty-inline">{labels.noPreviewFile}</div>
) : null}
</div>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>{emptyStateText}</div>
)}
</section>
);
}

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, Hammer, RefreshCw, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import '../../../components/skill-market/SkillMarketShared.css';
import type { BotSkillMarketItem } from '../../platform/types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { fetchPlatformSettings } from '../../platform/api/settings';
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
@ -45,8 +45,8 @@ export function SkillMarketInstallModal({
void onRefresh();
void (async () => {
try {
const res = await axios.get<{ page_size?: number }>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
const normalized = normalizePlatformPageSize(res.data?.page_size, readCachedPlatformPageSize(10));
const data = await fetchPlatformSettings();
const normalized = normalizePlatformPageSize(data?.page_size, readCachedPlatformPageSize(10));
writeCachedPlatformPageSize(normalized);
setPageSize(normalized);
} catch {

View File

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

View File

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

View File

@ -0,0 +1,376 @@
.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-body {
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
height: 100%;
overflow: auto;
}
.workspace-preview-body.is-editing {
display: flex;
overflow: hidden;
}
.workspace-preview-body.media {
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
}
.workspace-preview-media {
width: 100%;
max-height: min(72vh, 720px);
border-radius: 16px;
background: #000;
}
.workspace-preview-audio {
width: min(100%, 760px);
align-self: center;
margin: 0 auto;
}
.workspace-preview-embed {
width: 100%;
min-height: 68vh;
border: 0;
background: #fff;
}
.workspace-preview-body pre {
margin: 0;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.56;
color: var(--text);
font-size: 13px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.workspace-preview-body.markdown pre {
margin: 0;
}
.workspace-preview-editor {
display: block;
width: 100%;
height: 100%;
min-height: 100%;
max-height: none;
padding: 14px 16px;
border: 0;
border-radius: 10px;
resize: vertical;
background: transparent;
color: var(--text);
line-height: 1.68;
box-sizing: border-box;
overflow: auto;
}
.workspace-preview-editor:focus {
outline: none;
}
.workspace-preview-editor-shell {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
box-sizing: border-box;
}
.workspace-preview-image {
display: block;
max-width: 100%;
max-height: 70vh;
width: auto;
height: auto;
object-fit: contain;
margin: 0 auto;
}
.workspace-markdown {
padding: 12px 14px;
color: var(--text);
line-height: 1.72;
font-size: 14px;
font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.workspace-markdown > *:first-child {
margin-top: 0;
}
.workspace-markdown > *:last-child {
margin-bottom: 0;
}
.workspace-markdown h1,
.workspace-markdown h2,
.workspace-markdown h3,
.workspace-markdown h4 {
margin: 16px 0 8px;
color: var(--text);
font-weight: 800;
line-height: 1.3;
}
.workspace-markdown h1 {
font-size: 24px;
}
.workspace-markdown h2 {
font-size: 20px;
}
.workspace-markdown h3 {
font-size: 17px;
}
.workspace-markdown p {
margin: 8px 0;
}
.workspace-markdown ul,
.workspace-markdown ol {
margin: 8px 0 8px 20px;
padding: 0;
}
.workspace-markdown li {
margin: 4px 0;
}
.workspace-markdown blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 3px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
border-radius: 8px;
}
.workspace-markdown hr {
border: 0;
border-top: 1px solid var(--line);
margin: 14px 0;
}
.workspace-markdown code {
font-family: 'SF Mono', Menlo, Consolas, monospace;
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 6px;
font-size: 12px;
}
.workspace-markdown pre {
margin: 10px 0;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
background: color-mix(in oklab, var(--panel-soft) 70%, var(--panel) 30%);
border: 1px solid var(--line);
border-radius: 8px;
}
.workspace-markdown pre code {
border: none;
background: transparent;
padding: 0;
font-size: 12px;
}
.workspace-markdown table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 13px;
}
.workspace-markdown th,
.workspace-markdown td {
border: 1px solid var(--line);
padding: 6px 8px;
text-align: left;
}
.workspace-markdown th {
background: color-mix(in oklab, var(--panel-soft) 74%, var(--panel) 26%);
}
.workspace-markdown a {
color: color-mix(in oklab, var(--brand) 80%, #5fa6ff 20%);
text-decoration: underline;
}
.workspace-preview-meta {
color: var(--muted);
font-size: 11px;
}

View File

@ -0,0 +1,226 @@
import { Copy, Maximize2, Minimize2, RefreshCw, Save, X } 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 { 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 (
<div className="modal-mask" onClick={onClose}>
<div className={`modal-card modal-preview ${previewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{previewEditorEnabled ? labels.editFile : labels.filePreview}</h3>
<span className="modal-sub 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>
</div>
<div className="workspace-preview-header-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleFullscreen}
tooltip={fullscreenLabel}
aria-label={fullscreenLabel}
>
{previewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onClose}
tooltip={labels.close}
aria-label={labels.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<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 style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{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>
</div>
</div>
);
}

View File

@ -0,0 +1,261 @@
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotChannel, ChannelType } 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 PromptApi {
notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
interface ChannelManagerDeps extends PromptApi {
selectedBotId: string;
selectedBotDockerStatus: string;
t: any;
currentGlobalDelivery: { sendProgress: boolean; sendToolHints: boolean };
addableChannelTypes: ChannelType[];
currentNewChannelDraft: BotChannel;
refresh: () => Promise<void>;
setShowChannelModal: (value: boolean) => void;
setChannels: (value: BotChannel[] | ((prev: BotChannel[]) => BotChannel[])) => void;
setExpandedChannelByKey: (
value: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)
) => void;
setChannelCreateMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setNewChannelPanelOpen: (value: boolean) => void;
setNewChannelDraft: (value: BotChannel | ((prev: BotChannel) => BotChannel)) => void;
setIsSavingChannel: (value: boolean) => void;
setGlobalDelivery: (
value:
| { sendProgress: boolean; sendToolHints: boolean }
| ((prev: { sendProgress: boolean; sendToolHints: boolean }) => { sendProgress: boolean; sendToolHints: boolean })
) => void;
setIsSavingGlobalDelivery: (value: boolean) => void;
}
export function createChannelManager({
selectedBotId,
selectedBotDockerStatus,
t,
currentGlobalDelivery,
addableChannelTypes,
currentNewChannelDraft,
refresh,
notify,
confirm,
setShowChannelModal,
setChannels,
setExpandedChannelByKey,
setChannelCreateMenuOpen,
setNewChannelPanelOpen,
setNewChannelDraft,
setIsSavingChannel,
setGlobalDelivery,
setIsSavingGlobalDelivery,
}: ChannelManagerDeps) {
const createEmptyChannelExtra = (channelType: ChannelType): Record<string, unknown> =>
channelType === 'weixin' ? {} : {};
const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({
id: 'draft-channel',
bot_id: selectedBotId || '',
channel_type: channelType,
external_app_id: '',
app_secret: '',
internal_port: 8080,
is_active: true,
extra_config: createEmptyChannelExtra(channelType),
});
const channelDraftUiKey = (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => {
const id = String(channel.id || '').trim();
if (id) return id;
const type = String(channel.channel_type || '').trim().toLowerCase();
return type || `channel-${fallbackIndex}`;
};
const resetNewChannelDraft = (channelType: ChannelType = 'feishu') => {
setNewChannelDraft(createEmptyChannelDraft(channelType));
};
const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard';
const sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
const type = String(channelType || '').toLowerCase();
if (type === 'dashboard') return extra || {};
if (type === 'weixin') return {};
const next = { ...(extra || {}) };
delete next.sendProgress;
delete next.sendToolHints;
return next;
};
const loadChannels = async (botId: string) => {
if (!botId) return;
const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
const rows = Array.isArray(res.data) ? res.data : [];
setChannels(rows);
setExpandedChannelByKey((prev) => {
const next: Record<string, boolean> = {};
rows
.filter((channel) => !isDashboardChannel(channel))
.forEach((channel, index) => {
const key = channelDraftUiKey(channel, index);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
});
return next;
});
};
const openChannelModal = (botId: string) => {
if (!botId) return;
setExpandedChannelByKey({});
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
void loadChannels(botId);
setShowChannelModal(true);
};
const beginChannelCreate = (channelType: ChannelType) => {
setExpandedChannelByKey({});
setChannelCreateMenuOpen(false);
setNewChannelPanelOpen(true);
resetNewChannelDraft(channelType);
};
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
setChannels((prev) =>
prev.map((channel, channelIndex) => {
if (channelIndex !== index || channel.locked) return channel;
return { ...channel, ...patch };
}),
);
};
const saveChannel = async (channel: BotChannel) => {
if (!selectedBotId || channel.locked || isDashboardChannel(channel)) return;
setIsSavingChannel(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels/${channel.id}`, {
channel_type: channel.channel_type,
external_app_id: channel.external_app_id,
app_secret: channel.app_secret,
internal_port: Number(channel.internal_port),
is_active: channel.is_active,
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
});
await loadChannels(selectedBotId);
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelSaveFail;
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const addChannel = async () => {
if (!selectedBotId) return;
const channelType = String(currentNewChannelDraft.channel_type || '').trim().toLowerCase() as ChannelType;
if (!channelType || !addableChannelTypes.includes(channelType)) return;
setIsSavingChannel(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels`, {
channel_type: channelType,
is_active: Boolean(currentNewChannelDraft.is_active),
external_app_id: String(currentNewChannelDraft.external_app_id || ''),
app_secret: String(currentNewChannelDraft.app_secret || ''),
internal_port: Number(currentNewChannelDraft.internal_port) || 8080,
extra_config: sanitizeChannelExtra(channelType, currentNewChannelDraft.extra_config || {}),
});
await loadChannels(selectedBotId);
setNewChannelPanelOpen(false);
resetNewChannelDraft();
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelAddFail;
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const removeChannel = async (channel: BotChannel) => {
if (!selectedBotId || channel.locked || channel.channel_type === 'dashboard') return;
const ok = await confirm({
title: t.channels,
message: t.channelDeleteConfirm(channel.channel_type),
tone: 'warning',
});
if (!ok) return;
setIsSavingChannel(true);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels/${channel.id}`);
await loadChannels(selectedBotId);
notify(t.channelDeleted, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelDeleteFail;
notify(message, { tone: 'error' });
} finally {
setIsSavingChannel(false);
}
};
const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
setGlobalDelivery((prev) => ({ ...prev, [key]: value }));
};
const saveGlobalDelivery = async () => {
if (!selectedBotId) return;
setIsSavingGlobalDelivery(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`, {
send_progress: Boolean(currentGlobalDelivery.sendProgress),
send_tool_hints: Boolean(currentGlobalDelivery.sendToolHints),
});
if (selectedBotDockerStatus === 'RUNNING') {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/stop`);
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/start`);
}
await refresh();
notify(t.channelSaved, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.channelSaveFail;
notify(message, { tone: 'error' });
} finally {
setIsSavingGlobalDelivery(false);
}
};
return {
createEmptyChannelDraft,
channelDraftUiKey,
resetNewChannelDraft,
isDashboardChannel,
loadChannels,
openChannelModal,
beginChannelCreate,
updateChannelLocal,
saveChannel,
addChannel,
removeChannel,
updateGlobalDeliveryFlag,
saveGlobalDelivery,
};
}

View File

@ -0,0 +1,255 @@
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { MCPConfigResponse, MCPServerConfig, MCPServerDraft } from '../types';
import { mapMcpResponseToDrafts } from '../utils';
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 PromptApi {
notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
interface McpManagerDeps extends PromptApi {
selectedBotId: string;
isZh: boolean;
t: any;
currentMcpServers: MCPServerDraft[];
currentPersistedMcpServers: MCPServerDraft[];
currentNewMcpDraft: MCPServerDraft;
setShowMcpModal: (value: boolean) => void;
setMcpServers: (value: MCPServerDraft[] | ((prev: MCPServerDraft[]) => MCPServerDraft[])) => void;
setPersistedMcpServers: (value: MCPServerDraft[] | ((prev: MCPServerDraft[]) => MCPServerDraft[])) => void;
setExpandedMcpByKey: (
value: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)
) => void;
setNewMcpPanelOpen: (value: boolean) => void;
setNewMcpDraft: (value: MCPServerDraft | ((prev: MCPServerDraft) => MCPServerDraft)) => void;
setIsSavingMcp: (value: boolean) => void;
}
export function createMcpManager({
selectedBotId,
isZh,
t,
currentMcpServers,
currentPersistedMcpServers,
currentNewMcpDraft,
notify,
setShowMcpModal,
setMcpServers,
setPersistedMcpServers,
setExpandedMcpByKey,
setNewMcpPanelOpen,
setNewMcpDraft,
setIsSavingMcp,
}: McpManagerDeps) {
const resetNewMcpDraft = () => {
setNewMcpDraft({
name: '',
type: 'streamableHttp',
url: '',
botId: '',
botSecret: '',
toolTimeout: '60',
headers: {},
locked: false,
originName: '',
});
};
const mcpDraftUiKey = (_row: Pick<MCPServerDraft, 'name' | 'url'>, fallbackIndex: number) => `mcp-${fallbackIndex}`;
const applyMcpDrafts = (drafts: MCPServerDraft[]) => {
setMcpServers(drafts);
setPersistedMcpServers(drafts);
setExpandedMcpByKey((prev) => {
const next: Record<string, boolean> = {};
drafts.forEach((row, index) => {
const key = mcpDraftUiKey(row, index);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
});
return next;
});
return drafts;
};
const loadBotMcpConfig = async (botId: string): Promise<MCPServerDraft[]> => {
if (!botId) return [];
try {
const res = await axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`);
const drafts = mapMcpResponseToDrafts(res.data);
return applyMcpDrafts(drafts);
} catch {
applyMcpDrafts([]);
return [];
}
};
const openMcpModal = async (botId: string) => {
if (!botId) return;
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
resetNewMcpDraft();
await loadBotMcpConfig(botId);
setShowMcpModal(true);
};
const beginMcpCreate = () => {
setExpandedMcpByKey({});
setNewMcpPanelOpen(true);
resetNewMcpDraft();
};
const updateMcpServer = (index: number, patch: Partial<MCPServerDraft>) => {
setMcpServers((prev) => prev.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)));
};
const canRemoveMcpServer = (row?: MCPServerDraft | null) => Boolean(row && !row.locked);
const buildMcpHeaders = (row: MCPServerDraft): Record<string, string> => {
const headers: Record<string, string> = {};
Object.entries(row.headers || {}).forEach(([rawKey, rawValue]) => {
const key = String(rawKey || '').trim();
if (!key) return;
const lowered = key.toLowerCase();
if (!row.locked && (lowered === 'x-bot-id' || lowered === 'x-bot-secret')) {
return;
}
headers[key] = String(rawValue ?? '').trim();
});
if (!row.locked) {
const botId = String(row.botId || '').trim();
const botSecret = String(row.botSecret || '').trim();
if (botId) headers['X-Bot-Id'] = botId;
if (botSecret) headers['X-Bot-Secret'] = botSecret;
}
return headers;
};
const saveBotMcpConfig = async (
rows: MCPServerDraft[] = currentMcpServers,
options?: { closeDraft?: boolean; expandedKey?: string },
) => {
if (!selectedBotId) return;
const mcpServers: Record<string, MCPServerConfig> = {};
for (const row of rows) {
const name = String(row.name || '').trim();
const url = String(row.url || '').trim();
if (!name || !url) continue;
const timeout = Math.max(1, Math.min(600, Number(row.toolTimeout || 60) || 60));
mcpServers[name] = {
type: row.type === 'sse' ? 'sse' : 'streamableHttp',
url,
headers: buildMcpHeaders(row),
toolTimeout: timeout,
};
}
setIsSavingMcp(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/mcp-config`, { mcp_servers: mcpServers });
if (options?.expandedKey) {
setExpandedMcpByKey({ [options.expandedKey]: true });
}
await loadBotMcpConfig(selectedBotId);
if (options?.closeDraft) {
setNewMcpPanelOpen(false);
resetNewMcpDraft();
}
notify(t.mcpSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.mcpSaveFail, { tone: 'error' });
} finally {
setIsSavingMcp(false);
}
};
const saveNewMcpServer = async () => {
const name = String(currentNewMcpDraft.name || '').trim();
const url = String(currentNewMcpDraft.url || '').trim();
if (!name || !url) {
notify(t.mcpDraftRequired, { tone: 'warning' });
return;
}
const nextRow: MCPServerDraft = {
...currentNewMcpDraft,
name,
url,
botId: String(currentNewMcpDraft.botId || '').trim(),
botSecret: String(currentNewMcpDraft.botSecret || '').trim(),
toolTimeout: String(currentNewMcpDraft.toolTimeout || '60').trim() || '60',
headers: { ...(currentNewMcpDraft.headers || {}) },
locked: false,
originName: name,
};
const nextRows = [...currentPersistedMcpServers, nextRow];
const expandedKey = mcpDraftUiKey(nextRow, nextRows.length - 1);
await saveBotMcpConfig(nextRows, { closeDraft: true, expandedKey });
};
const saveSingleMcpServer = async (index: number) => {
const row = currentMcpServers[index];
if (!row || row.locked) return;
const originName = String(row.originName || row.name || '').trim();
const nextRows = [...currentPersistedMcpServers];
const targetIndex = nextRows.findIndex((candidate) => {
const candidateOrigin = String(candidate.originName || candidate.name || '').trim();
return candidateOrigin && candidateOrigin === originName;
});
if (targetIndex >= 0) {
nextRows[targetIndex] = { ...row };
} else {
nextRows.push({ ...row, originName: originName || String(row.name || '').trim() });
}
const expandedKey = mcpDraftUiKey(row, targetIndex >= 0 ? targetIndex : nextRows.length - 1);
await saveBotMcpConfig(nextRows, { expandedKey });
};
const removeMcpServer = async (index: number) => {
const row = currentMcpServers[index];
if (!canRemoveMcpServer(row)) {
notify(isZh ? '当前 MCP 服务不可删除。' : 'This MCP server cannot be removed.', { tone: 'warning' });
return;
}
const nextRows = currentMcpServers.filter((_, rowIndex) => rowIndex !== index);
setMcpServers(nextRows);
setPersistedMcpServers(nextRows);
setExpandedMcpByKey((prev) => {
const next: Record<string, boolean> = {};
nextRows.forEach((server, rowIndex) => {
const key = mcpDraftUiKey(server, rowIndex);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : rowIndex === 0;
});
return next;
});
await saveBotMcpConfig(nextRows);
};
return {
resetNewMcpDraft,
mcpDraftUiKey,
loadBotMcpConfig,
openMcpModal,
beginMcpCreate,
updateMcpServer,
canRemoveMcpServer,
saveNewMcpServer,
saveSingleMcpServer,
removeMcpServer,
};
}

View File

@ -0,0 +1,343 @@
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotTopic, TopicPresetTemplate } from '../types';
import { isSystemFallbackTopic, normalizePresetTextList, resolvePresetText } from '../utils';
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 PromptApi {
notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
interface TopicManagerDeps extends PromptApi {
selectedBotId: string;
isZh: boolean;
t: any;
effectiveTopicPresetTemplates: TopicPresetTemplate[];
setShowTopicModal: (value: boolean) => void;
setTopics: (value: BotTopic[] | ((prev: BotTopic[]) => BotTopic[])) => void;
setExpandedTopicByKey: (
value: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)
) => void;
setTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setNewTopicPanelOpen: (value: boolean) => void;
setNewTopicKey: (value: string) => void;
setNewTopicName: (value: string) => void;
setNewTopicDescription: (value: string) => void;
setNewTopicPurpose: (value: string) => void;
setNewTopicIncludeWhen: (value: string) => void;
setNewTopicExcludeWhen: (value: string) => void;
setNewTopicExamplesPositive: (value: string) => void;
setNewTopicExamplesNegative: (value: string) => void;
setNewTopicPriority: (value: string) => void;
setNewTopicAdvancedOpen: (value: boolean) => void;
setNewTopicSource: (value: string) => void;
setIsSavingTopic: (value: boolean) => void;
getNewTopicDraft: () => {
key: string;
name: string;
description: string;
purpose: string;
includeWhen: string;
excludeWhen: string;
examplesPositive: string;
examplesNegative: string;
priority: string;
};
}
export function createTopicManager({
selectedBotId,
isZh,
t,
effectiveTopicPresetTemplates,
notify,
confirm,
setShowTopicModal,
setTopics,
setExpandedTopicByKey,
setTopicPresetMenuOpen,
setNewTopicPanelOpen,
setNewTopicKey,
setNewTopicName,
setNewTopicDescription,
setNewTopicPurpose,
setNewTopicIncludeWhen,
setNewTopicExcludeWhen,
setNewTopicExamplesPositive,
setNewTopicExamplesNegative,
setNewTopicPriority,
setNewTopicAdvancedOpen,
setNewTopicSource,
setIsSavingTopic,
getNewTopicDraft,
}: TopicManagerDeps) {
const normalizeTopicKeyInput = (raw: string) =>
String(raw || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_.-]/g, '');
const resetNewTopicDraft = () => {
setNewTopicKey('');
setNewTopicName('');
setNewTopicDescription('');
setNewTopicPurpose('');
setNewTopicIncludeWhen('');
setNewTopicExcludeWhen('');
setNewTopicExamplesPositive('');
setNewTopicExamplesNegative('');
setNewTopicPriority('50');
setNewTopicAdvancedOpen(false);
setNewTopicSource('');
};
const topicDraftUiKey = (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => {
const key = String(topic.topic_key || topic.id || '').trim();
return key || `topic-${fallbackIndex}`;
};
const normalizeRoutingTextList = (raw: string): string[] =>
String(raw || '')
.split('\n')
.map((value) => String(value || '').trim())
.filter(Boolean);
const normalizeRoutingPriority = (raw: string): number => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 50;
return Math.max(0, Math.min(100, Math.round(parsed)));
};
const topicRoutingFromRaw = (routing?: Record<string, unknown>) => {
const row = routing && typeof routing === 'object' ? routing : {};
const examplesRaw = row.examples && typeof row.examples === 'object' ? (row.examples as Record<string, unknown>) : {};
const includeWhen = Array.isArray(row.include_when) ? row.include_when : [];
const excludeWhen = Array.isArray(row.exclude_when) ? row.exclude_when : [];
const positive = Array.isArray(examplesRaw.positive) ? examplesRaw.positive : [];
const negative = Array.isArray(examplesRaw.negative) ? examplesRaw.negative : [];
const priority = Number(row.priority);
return {
routing_purpose: String(row.purpose || ''),
routing_include_when: includeWhen.map((value) => String(value || '').trim()).filter(Boolean).join('\n'),
routing_exclude_when: excludeWhen.map((value) => String(value || '').trim()).filter(Boolean).join('\n'),
routing_examples_positive: positive.map((value) => String(value || '').trim()).filter(Boolean).join('\n'),
routing_examples_negative: negative.map((value) => String(value || '').trim()).filter(Boolean).join('\n'),
routing_priority: String(Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : 50),
};
};
const buildTopicRoutingPayload = (topic: {
routing?: Record<string, unknown>;
routing_purpose?: string;
routing_include_when?: string;
routing_exclude_when?: string;
routing_examples_positive?: string;
routing_examples_negative?: string;
routing_priority?: string;
}): Record<string, unknown> => {
const base = topic.routing && typeof topic.routing === 'object' ? { ...topic.routing } : {};
return {
...base,
purpose: String(topic.routing_purpose || '').trim(),
include_when: normalizeRoutingTextList(String(topic.routing_include_when || '')),
exclude_when: normalizeRoutingTextList(String(topic.routing_exclude_when || '')),
examples: {
positive: normalizeRoutingTextList(String(topic.routing_examples_positive || '')),
negative: normalizeRoutingTextList(String(topic.routing_examples_negative || '')),
},
priority: normalizeRoutingPriority(String(topic.routing_priority || '50')),
system_filters: {
progress: true,
tool_hint: true,
},
};
};
const loadTopics = async (botId: string) => {
if (!botId) return;
try {
const res = await axios.get<BotTopic[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`);
const rows = Array.isArray(res.data) ? res.data : [];
const mapped = rows
.map((row) => ({
...row,
topic_key: String(row.topic_key || '').trim().toLowerCase(),
name: String(row.name || ''),
description: String(row.description || ''),
is_active: Boolean(row.is_active),
routing: row.routing && typeof row.routing === 'object' ? row.routing : {},
...topicRoutingFromRaw(row.routing && typeof row.routing === 'object' ? row.routing : {}),
}))
.filter((row) => !isSystemFallbackTopic(row));
setTopics(mapped);
setExpandedTopicByKey((prev) => {
const next: Record<string, boolean> = {};
mapped.forEach((topic, index) => {
const key = topicDraftUiKey(topic, index);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
});
return next;
});
} catch {
setTopics([]);
setExpandedTopicByKey({});
}
};
const openTopicModal = (botId: string) => {
if (!botId) return;
setExpandedTopicByKey({});
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(false);
resetNewTopicDraft();
void loadTopics(botId);
setShowTopicModal(true);
};
const applyTopicPreset = (presetId: string, silent: boolean = false) => {
const preset = effectiveTopicPresetTemplates.find((row) => row.id === presetId);
if (!preset) return;
const localeKey: 'zh-cn' | 'en' = isZh ? 'zh-cn' : 'en';
setNewTopicKey(String(preset.topic_key || '').trim());
setNewTopicName(resolvePresetText(preset.name, localeKey));
setNewTopicDescription(resolvePresetText(preset.description, localeKey));
setNewTopicPurpose(resolvePresetText(preset.routing_purpose, localeKey));
setNewTopicIncludeWhen(normalizePresetTextList(preset.routing_include_when).join('\n'));
setNewTopicExcludeWhen(normalizePresetTextList(preset.routing_exclude_when).join('\n'));
setNewTopicExamplesPositive(normalizePresetTextList(preset.routing_examples_positive).join('\n'));
setNewTopicExamplesNegative(normalizePresetTextList(preset.routing_examples_negative).join('\n'));
setNewTopicPriority(String(Number.isFinite(Number(preset.routing_priority)) ? Number(preset.routing_priority) : 50));
setNewTopicAdvancedOpen(true);
if (!silent) {
notify(isZh ? '主题预设已填充。' : 'Topic preset applied.', { tone: 'success' });
}
};
const beginTopicCreate = (presetId: string) => {
setExpandedTopicByKey({});
setTopicPresetMenuOpen(false);
setNewTopicPanelOpen(true);
setNewTopicSource(presetId);
if (presetId === 'blank') {
resetNewTopicDraft();
setNewTopicPanelOpen(true);
setNewTopicSource('blank');
return;
}
applyTopicPreset(presetId, true);
};
const updateTopicLocal = (index: number, patch: Partial<BotTopic>) => {
setTopics((prev) => prev.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)));
};
const saveTopic = async (topic: BotTopic) => {
if (!selectedBotId) return;
const topicKey = String(topic.topic_key || '').trim().toLowerCase();
if (!topicKey) return;
setIsSavingTopic(true);
try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/topics/${encodeURIComponent(topicKey)}`, {
name: String(topic.name || '').trim(),
description: String(topic.description || '').trim(),
is_active: Boolean(topic.is_active),
routing: buildTopicRoutingPayload(topic),
});
await loadTopics(selectedBotId);
notify(t.topicSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.topicSaveFail, { tone: 'error' });
} finally {
setIsSavingTopic(false);
}
};
const addTopic = async () => {
if (!selectedBotId) return;
const draft = getNewTopicDraft();
const topicKey = normalizeTopicKeyInput(draft.key);
if (!topicKey) {
notify(t.topicKeyRequired, { tone: 'warning' });
return;
}
setIsSavingTopic(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/topics`, {
topic_key: topicKey,
name: String(draft.name || '').trim() || topicKey,
description: String(draft.description || '').trim(),
is_active: true,
routing: buildTopicRoutingPayload({
routing_purpose: draft.purpose,
routing_include_when: draft.includeWhen,
routing_exclude_when: draft.excludeWhen,
routing_examples_positive: draft.examplesPositive,
routing_examples_negative: draft.examplesNegative,
routing_priority: draft.priority,
}),
});
await loadTopics(selectedBotId);
resetNewTopicDraft();
setNewTopicPanelOpen(false);
notify(t.topicSaved, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.topicSaveFail, { tone: 'error' });
} finally {
setIsSavingTopic(false);
}
};
const removeTopic = async (topic: BotTopic) => {
if (!selectedBotId) return;
const topicKey = String(topic.topic_key || '').trim().toLowerCase();
if (!topicKey) return;
const ok = await confirm({
title: t.topic,
message: t.topicDeleteConfirm(topicKey),
tone: 'warning',
});
if (!ok) return;
setIsSavingTopic(true);
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/topics/${encodeURIComponent(topicKey)}`);
await loadTopics(selectedBotId);
notify(t.topicDeleted, { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || t.topicDeleteFail, { tone: 'error' });
} finally {
setIsSavingTopic(false);
}
};
return {
normalizeTopicKeyInput,
resetNewTopicDraft,
topicDraftUiKey,
normalizeRoutingTextList,
loadTopics,
openTopicModal,
beginTopicCreate,
updateTopicLocal,
saveTopic,
addTopic,
removeTopic,
};
}

View File

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

View File

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

View File

@ -0,0 +1,811 @@
import { useMemo, useState } from 'react';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { pickLocale } from '../../../i18n';
import { channelsEn } from '../../../i18n/channels.en';
import { channelsZhCn } from '../../../i18n/channels.zh-cn';
import { dashboardEn } from '../../../i18n/dashboard.en';
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
import { useAppStore } from '../../../store/appStore';
import type { BotDashboardModuleProps } from '../types';
import { useDashboardBotEditor } from './useDashboardBotEditor';
import { useDashboardBotManagement } from './useDashboardBotManagement';
import { useDashboardConfigPanels } from './useDashboardConfigPanels';
import { useDashboardConversation } from './useDashboardConversation';
import { useDashboardDerivedState } from './useDashboardDerivedState';
import { useDashboardLifecycle } from './useDashboardLifecycle';
import { useDashboardRuntimeControl } from './useDashboardRuntimeControl';
import { useDashboardShellState } from './useDashboardShellState';
import { useDashboardSupportData } from './useDashboardSupportData';
import { useDashboardSystemDefaults } from './useDashboardSystemDefaults';
import { useDashboardTemplateManager } from './useDashboardTemplateManager';
import { useDashboardVoiceInput } from './useDashboardVoiceInput';
import { useDashboardWorkspace } from './useDashboardWorkspace';
export function useBotDashboardModule({
forcedBotId,
compactMode = false,
compactPanelTab: compactPanelTabProp,
onCompactPanelTabChange,
}: BotDashboardModuleProps) {
const {
activeBots,
setBots,
mergeBot,
updateBotStatus,
locale,
addBotMessage,
setBotMessages,
setBotMessageFeedback,
} = useAppStore();
const { notify, confirm } = useLucentPrompt();
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
const [botListPageSize, setBotListPageSize] = useState(10);
const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10);
const botSearchInputName = useMemo(
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const workspaceSearchInputName = useMemo(
() => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const {
allowedAttachmentExtensions,
botListPageSizeReady,
defaultSystemTimezone,
refreshAttachmentPolicy,
setTopicPresetTemplates,
speechEnabled,
topicPresetTemplates,
voiceMaxSeconds,
workspaceDownloadExtensions,
} = useDashboardSystemDefaults({
setBotListPageSize,
});
const {
botListMenuOpen,
botListMenuRef,
botListPage,
botListQuery,
botListTotalPages,
bots,
compactPanelTab,
controlCommandPanelOpen,
controlCommandPanelRef,
filteredBots,
forcedBotMissing,
hasForcedBot,
isCompactListPage,
isCompactMobile,
normalizedBotListQuery,
pagedBots,
runtimeMenuOpen,
runtimeMenuRef,
runtimeViewMode,
selectedBot,
selectedBotId,
setBotListMenuOpen,
setBotListPage,
setBotListQuery,
setCompactPanelTab,
setControlCommandPanelOpen,
setRuntimeMenuOpen,
setRuntimeViewMode,
setSelectedBotId,
setShowRuntimeActionModal,
setTopicDetailOpen,
showBotListPanel,
showCompactBotPageClose,
showRuntimeActionModal,
topicDetailOpen,
} = useDashboardShellState({
activeBots,
botListPageSize,
compactMode,
compactPanelTabProp,
forcedBotId,
onCompactPanelTabChange,
});
const messages = selectedBot?.messages || [];
const events = selectedBot?.events || [];
const isZh = locale === 'zh';
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const lc = isZh ? channelsZhCn : channelsEn;
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const {
availableImages,
batchStartBots,
batchStopBots,
controlStateByBot,
ensureSelectedBotDetail,
isBatchOperating,
loadResourceSnapshot,
loadWeixinLoginStatus,
openResourceMonitor,
operatingBotId,
refresh,
reloginWeixin,
resourceBot,
resourceBotId,
resourceError,
resourceLoading,
resourceSnapshot,
restartBot,
setBotEnabled,
setShowResourceModal,
showResourceModal,
startBot,
stopBot,
weixinLoginStatus,
} = useDashboardRuntimeControl({
bots,
forcedBotId,
selectedBotId,
selectedBot,
isZh,
t,
lc,
mergeBot,
setBots,
updateBotStatus,
notify,
confirm,
});
const {
agentFieldByTab,
agentTab,
editForm,
isSaving,
isTestingProvider,
onBaseProviderChange,
openAgentFilesModal,
openBaseConfigModal,
openParamConfigModal,
paramDraft,
providerTestResult,
saveBot,
setAgentTab,
setShowAgentModal,
setShowBaseModal,
setShowParamModal,
showAgentModal,
showBaseModal,
showParamModal,
testProviderConnection,
updateAgentTabValue,
updateEditForm,
updateParamDraft,
} = useDashboardBotEditor({
defaultSystemTimezone,
ensureSelectedBotDetail,
isZh,
notify,
refresh,
selectedBotId,
selectedBot,
setRuntimeMenuOpen,
availableImages,
t,
});
const {
botSkills,
cronActionJobId,
cronJobs,
cronLoading,
createEnvParam,
deleteCronJob,
deleteEnvParam,
deleteTopicFeedItem,
envEntries,
installMarketSkill,
isMarketSkillsLoading,
isSkillUploading,
loadBotEnvParams,
loadBotSkills,
loadCronJobs,
loadMarketSkills,
loadTopicFeed,
loadTopicFeedStats,
markTopicFeedItemRead,
marketSkillInstallingId,
marketSkills,
onPickSkillZip,
removeBotSkill,
resetSupportState,
saveSingleEnvParam,
setTopicFeedTopicKey,
stopCronJob,
topicFeedDeleteSavingById,
topicFeedError,
topicFeedItems,
topicFeedLoading,
topicFeedLoadingMore,
topicFeedNextCursor,
topicFeedReadSavingById,
topicFeedTopicKey,
topicFeedUnreadCount,
} = useDashboardSupportData({
selectedBotId,
isZh,
t,
notify,
confirm,
onCloseEnvParamsModal: () => {},
});
const {
channelConfigModalProps,
cronJobsModalProps,
envParamsModalProps,
loadInitialConfigData,
loadTopics,
mcpConfigModalProps,
openChannelConfigModal,
openCronJobsModal,
openEnvParamsConfigModal,
openMcpConfigModal,
openSkillsConfigModal,
openTopicConfigModal,
prepareForBotChange,
resetAllConfigPanels,
skillMarketInstallModalProps,
skillsModalProps,
topicConfigModalProps,
topics,
} = useDashboardConfigPanels({
botSkills,
closeRuntimeMenu: () => setRuntimeMenuOpen(false),
confirm,
cronActionJobId,
cronJobs,
cronLoading,
createEnvParam,
deleteCronJob,
deleteEnvParam,
effectiveTopicPresetTemplates: topicPresetTemplates,
envEntries,
installMarketSkill,
isMarketSkillsLoading,
isSkillUploading,
isZh,
lc,
loadBotEnvParams,
loadBotSkills,
loadCronJobs,
loadMarketSkills,
loadTopicFeedStats,
loadWeixinLoginStatus,
marketSkillInstallingId,
marketSkills,
notify,
onPickSkillZip,
passwordToggleLabels,
refresh,
reloginWeixin,
removeBotSkill,
resetSupportState,
saveSingleEnvParam,
selectedBot,
selectedBotId,
stopCronJob,
t,
weixinLoginStatus,
});
const effectiveTopicPresetTemplates = topicPresetTemplates;
const {
isLoadingTemplates,
isSavingTemplates,
openTemplateManager,
saveTemplateManager,
setShowTemplateModal,
setTemplateAgentText,
setTemplateTab,
setTemplateTopicText,
showTemplateModal,
templateAgentCount,
templateAgentText,
templateTab,
templateTopicCount,
templateTopicText,
} = useDashboardTemplateManager({
currentTopicPresetCount: effectiveTopicPresetTemplates.length,
notify,
setTopicPresetTemplates,
t,
});
const {
clearConversationHistory,
removeBot,
} = useDashboardBotManagement({
confirm,
mergeBot,
notify,
refresh,
selectedBot,
selectedBotId,
setBotMessages,
setSelectedBotId,
t,
});
const {
attachmentUploadPercent,
closeWorkspacePreview,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
filteredWorkspaceEntries,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
hideWorkspaceHoverCard,
isUploadingAttachments,
loadWorkspaceTree,
markdownComponents,
onPickAttachments,
openWorkspaceFilePreview,
openWorkspacePathFromChat,
pendingAttachments,
resetWorkspaceState,
resolveWorkspaceMediaSrc,
saveWorkspacePreviewMarkdown,
setPendingAttachments,
setWorkspaceAutoRefresh,
setWorkspacePreviewDraft,
setWorkspacePreviewFullscreen,
setWorkspacePreviewMode,
setWorkspaceQuery,
showWorkspaceHoverCard,
workspaceAutoRefresh,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
workspaceError,
workspaceFileLoading,
workspaceFiles,
workspaceHoverCard,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspacePreview,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewEditorEnabled,
workspacePreviewFullscreen,
workspacePreviewMarkdownComponents,
workspacePreviewSaving,
workspaceQuery,
workspaceSearchLoading,
} = useDashboardWorkspace({
selectedBotId,
selectedBotDockerStatus: selectedBot?.docker_status || '',
workspaceDownloadExtensions,
refreshAttachmentPolicy,
notify,
t,
isZh,
fileNotPreviewableLabel,
});
const {
activeTopicOptions,
baseImageOptions,
canChat,
conversation,
hasTopicUnread,
selectedBotControlState,
selectedBotEnabled,
systemTimezoneOptions,
topicPanelState,
} = useDashboardDerivedState({
availableImages,
controlStateByBot,
defaultSystemTimezone,
editFormImageTag: editForm.image_tag,
editFormSystemTimezone: editForm.system_timezone,
events,
isZh,
messages,
selectedBot,
topicFeedUnreadCount,
topics,
});
const {
activeControlCommand,
chatDateJumping,
chatDatePanelPosition,
chatDatePickerOpen,
chatDateTriggerRef,
chatDateValue,
chatScrollRef,
command,
composerTextareaRef,
copyAssistantReply,
copyUserPrompt,
editUserPrompt,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
filePickerRef,
interruptExecution,
isCommandAutoUnlockWindowActive,
isInterrupting,
isSendingBlocked,
jumpConversationToDate,
loadInitialChatPage,
onChatScroll,
onComposerKeyDown,
quoteAssistantReply,
quotedReply,
scrollConversationToBottom,
send,
sendControlCommand,
setChatDatePickerOpen,
setChatDateValue,
setCommand,
setQuotedReply,
submitAssistantFeedback,
toggleChatDatePicker,
toggleProgressExpanded,
toggleUserExpanded,
triggerPickAttachments,
} = useDashboardConversation({
selectedBotId,
selectedBot,
messages,
conversation,
canChat,
chatPullPageSize,
commandAutoUnlockSeconds,
pendingAttachments,
setPendingAttachments,
isUploadingAttachments,
controlCommandPanelOpen,
setControlCommandPanelOpen,
addBotMessage,
setBotMessages,
setBotMessageFeedback,
notify,
t,
isZh,
});
const {
isVoiceRecording,
isVoiceTranscribing,
onVoiceInput,
voiceCountdown,
} = useDashboardVoiceInput({
selectedBotId,
canChat,
speechEnabled,
voiceMaxSeconds,
setCommand,
composerTextareaRef,
notify,
t,
});
const {
canSendControlCommand,
displayState,
isChatEnabled,
isThinking,
runtimeAction,
showInterruptSubmitAction,
} = useDashboardDerivedState({
availableImages,
controlStateByBot,
defaultSystemTimezone,
editFormImageTag: editForm.image_tag,
editFormSystemTimezone: editForm.system_timezone,
events,
isCommandAutoUnlockWindowActive,
isSendingBlocked,
isVoiceRecording,
isVoiceTranscribing,
isZh,
messages,
selectedBot,
topicFeedUnreadCount,
topics,
});
useDashboardLifecycle({
activeTopicOptions,
botListMenuRef,
channelConfigModalOpen: channelConfigModalProps.open,
chatPullPageSize,
controlCommandPanelRef,
hideWorkspaceHoverCard,
loadInitialChatPage,
loadInitialConfigData,
loadTopicFeed,
loadTopicFeedStats,
loadTopics,
loadWeixinLoginStatus,
loadWorkspaceTree,
notify,
prepareForBotChange,
resetAllConfigPanels,
resetWorkspaceState,
runtimeMenuRef,
runtimeViewMode,
scrollConversationToBottom,
selectedBot,
selectedBotId,
setBotListMenuOpen,
setChatDatePickerOpen,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
setPendingAttachments,
setShowRuntimeActionModal,
setRuntimeMenuOpen,
setTopicFeedTopicKey,
topicDetailOpen,
topicFeedTopicKey,
topics,
});
const exportConversationJson = () => {
if (!selectedBot) return;
try {
const payload = {
bot_id: selectedBot.id,
bot_name: selectedBot.name || selectedBot.id,
exported_at: new Date().toISOString(),
message_count: conversation.length,
messages: conversation.map((message) => ({
id: message.id || null,
role: message.role,
text: message.text,
attachments: message.attachments || [],
kind: message.kind || 'final',
feedback: message.feedback || null,
ts: message.ts,
datetime: new Date(message.ts).toISOString(),
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${selectedBot.id}-conversation-${stamp}.json`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch {
notify(t.exportHistoryFail, { tone: 'error' });
}
};
return {
compactMode,
forcedBotId,
isZh,
t,
lc,
hasForcedBot,
showBotListPanel,
selectedBot,
selectedBotId,
isCompactListPage,
compactPanelTab,
showCompactBotPageClose,
forcedBotMissing,
runtimeViewMode,
hasTopicUnread,
setRuntimeViewMode,
setSelectedBotId,
setCompactPanelTab,
bots,
filteredBots,
pagedBots,
normalizedBotListQuery,
botListQuery,
botListPageSizeReady,
botListPage,
botListTotalPages,
botListMenuOpen,
controlStateByBot,
operatingBotId,
isLoadingTemplates,
isBatchOperating,
botSearchInputName,
botListMenuRef,
setShowCreateBotModal,
setBotListMenuOpen,
openTemplateManager,
batchStartBots,
batchStopBots,
setBotListQuery,
setBotListPage,
setBotEnabled,
startBot,
stopBot,
openResourceMonitor,
removeBot,
topicFeedTopicKey,
activeTopicOptions,
topicPanelState,
topicFeedItems,
topicFeedLoading,
topicFeedLoadingMore,
topicFeedNextCursor,
topicFeedError,
topicFeedReadSavingById,
topicFeedDeleteSavingById,
setTopicFeedTopicKey,
loadTopicFeed,
markTopicFeedItemRead,
deleteTopicFeedItem,
openWorkspacePathFromChat,
resolveWorkspaceMediaSrc,
openTopicConfigModal,
setTopicDetailOpen,
conversation,
chatScrollRef,
onChatScroll,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
toggleProgressExpanded,
toggleUserExpanded,
editUserPrompt,
copyUserPrompt,
submitAssistantFeedback,
quoteAssistantReply,
copyAssistantReply,
isThinking,
canChat,
isChatEnabled,
selectedBotEnabled,
selectedBotControlState,
quotedReply,
setQuotedReply,
pendingAttachments,
setPendingAttachments,
attachmentUploadPercent,
isUploadingAttachments,
filePickerRef,
allowedAttachmentExtensions,
onPickAttachments,
controlCommandPanelOpen,
controlCommandPanelRef,
setChatDatePickerOpen,
setControlCommandPanelOpen,
activeControlCommand,
canSendControlCommand,
isInterrupting,
sendControlCommand,
interruptExecution,
chatDateTriggerRef,
chatDateJumping,
toggleChatDatePicker,
chatDatePickerOpen,
chatDatePanelPosition,
chatDateValue,
setChatDateValue,
jumpConversationToDate,
command,
setCommand,
composerTextareaRef,
onComposerKeyDown,
isVoiceRecording,
isVoiceTranscribing,
isCompactMobile,
voiceCountdown,
onVoiceInput,
triggerPickAttachments,
showInterruptSubmitAction,
send,
runtimeMenuOpen,
runtimeMenuRef,
displayState,
workspaceError,
workspacePathDisplay,
workspaceLoading,
workspaceQuery,
workspaceSearchInputName,
workspaceSearchLoading,
filteredWorkspaceEntries,
workspaceParentPath,
workspaceFileLoading,
workspaceAutoRefresh,
workspaceFiles,
restartBot,
setRuntimeMenuOpen,
openBaseConfigModal,
openParamConfigModal,
openChannelConfigModal,
openEnvParamsConfigModal,
openSkillsConfigModal,
openMcpConfigModal,
openCronJobsModal,
openAgentFilesModal,
exportConversationJson,
clearConversationHistory,
loadWorkspaceTree,
workspaceCurrentPath,
setWorkspaceQuery,
setWorkspaceAutoRefresh,
openWorkspaceFilePreview,
showWorkspaceHoverCard,
hideWorkspaceHoverCard,
showResourceModal,
resourceBotId,
resourceBot,
resourceSnapshot,
resourceLoading,
resourceError,
setShowResourceModal,
loadResourceSnapshot,
showBaseModal,
editForm,
paramDraft,
baseImageOptions,
systemTimezoneOptions,
defaultSystemTimezone,
passwordToggleLabels,
isSaving,
setShowBaseModal,
updateEditForm,
updateParamDraft,
saveBot,
showParamModal,
isTestingProvider,
providerTestResult,
setShowParamModal,
onBaseProviderChange,
testProviderConnection,
channelConfigModalProps,
topicConfigModalProps,
skillsModalProps,
skillMarketInstallModalProps,
mcpConfigModalProps,
envParamsModalProps,
cronJobsModalProps,
showTemplateModal,
templateTab,
templateAgentCount,
templateTopicCount,
templateAgentText,
templateTopicText,
isSavingTemplates,
setShowTemplateModal,
setTemplateTab,
setTemplateAgentText,
setTemplateTopicText,
saveTemplateManager,
showAgentModal,
agentTab,
agentFieldByTab,
setShowAgentModal,
setAgentTab,
updateAgentTabValue,
showRuntimeActionModal,
runtimeAction,
setShowRuntimeActionModal,
workspacePreview,
workspacePreviewFullscreen,
workspacePreviewEditorEnabled,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewSaving,
workspacePreviewMarkdownComponents,
closeWorkspacePreview,
setWorkspacePreviewFullscreen,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
setWorkspacePreviewDraft,
saveWorkspacePreviewMarkdown,
setWorkspacePreviewMode,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
workspaceHoverCard,
showCreateBotModal,
refresh,
};
}

View File

@ -0,0 +1,335 @@
import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import { getLlmProviderDefaultApiBase } from '../../../utils/llmProviders';
import type { AgentTab, BotEditForm, BotParamDraft, NanobotImage } from '../types';
import { clampCpuCores, clampMaxTokens, clampMemoryMb, clampStorageGb, clampTemperature } from '../utils';
const DEFAULT_EDIT_FORM: BotEditForm = {
name: '',
access_password: '',
llm_provider: '',
llm_model: '',
image_tag: '',
api_key: '',
api_base: '',
temperature: 0.2,
top_p: 1,
max_tokens: 8192,
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
system_timezone: '',
agents_md: '',
soul_md: '',
user_md: '',
tools_md: '',
identity_md: '',
};
const DEFAULT_PARAM_DRAFT: BotParamDraft = {
max_tokens: '8192',
cpu_cores: '1',
memory_mb: '1024',
storage_gb: '10',
};
const agentFieldByTab: Record<AgentTab, keyof BotEditForm> = {
AGENTS: 'agents_md',
SOUL: 'soul_md',
USER: 'user_md',
TOOLS: 'tools_md',
IDENTITY: 'identity_md',
};
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface UseDashboardBotEditorOptions {
defaultSystemTimezone: string;
ensureSelectedBotDetail: () => Promise<any>;
isZh: boolean;
notify: (message: string, options?: NotifyOptions) => void;
refresh: () => Promise<void>;
selectedBotId: string;
selectedBot?: any;
setRuntimeMenuOpen: (open: boolean) => void;
availableImages: NanobotImage[];
t: any;
}
export function useDashboardBotEditor({
defaultSystemTimezone,
ensureSelectedBotDetail,
isZh,
notify,
refresh,
selectedBotId,
selectedBot,
setRuntimeMenuOpen,
availableImages,
t,
}: UseDashboardBotEditorOptions) {
const [agentTab, setAgentTab] = useState<AgentTab>('AGENTS');
const [isSaving, setIsSaving] = useState(false);
const [isTestingProvider, setIsTestingProvider] = useState(false);
const [providerTestResult, setProviderTestResult] = useState('');
const [editForm, setEditForm] = useState<BotEditForm>(DEFAULT_EDIT_FORM);
const [paramDraft, setParamDraft] = useState<BotParamDraft>(DEFAULT_PARAM_DRAFT);
const [showBaseModal, setShowBaseModal] = useState(false);
const [showParamModal, setShowParamModal] = useState(false);
const [showAgentModal, setShowAgentModal] = useState(false);
const applyEditFormFromBot = useCallback((bot?: any) => {
if (!bot) return;
const provider = String(bot.llm_provider || '').trim().toLowerCase();
setProviderTestResult('');
setEditForm({
name: bot.name || '',
access_password: bot.access_password || '',
llm_provider: provider,
llm_model: bot.llm_model || '',
image_tag: bot.image_tag || '',
api_key: '',
api_base: bot.api_base || getLlmProviderDefaultApiBase(provider),
temperature: clampTemperature(bot.temperature ?? 0.2),
top_p: bot.top_p ?? 1,
max_tokens: clampMaxTokens(bot.max_tokens ?? 8192),
cpu_cores: clampCpuCores(bot.cpu_cores ?? 1),
memory_mb: clampMemoryMb(bot.memory_mb ?? 1024),
storage_gb: clampStorageGb(bot.storage_gb ?? 10),
system_timezone: bot.system_timezone || '',
agents_md: bot.agents_md || '',
soul_md: bot.soul_md || bot.system_prompt || '',
user_md: bot.user_md || '',
tools_md: bot.tools_md || '',
identity_md: bot.identity_md || '',
});
setParamDraft({
max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)),
cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)),
memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)),
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
});
}, []);
const updateEditForm = useCallback((patch: Partial<BotEditForm>) => {
setEditForm((prev) => ({ ...prev, ...patch }));
}, []);
const updateParamDraft = useCallback((patch: Partial<BotParamDraft>) => {
setParamDraft((prev) => ({ ...prev, ...patch }));
}, []);
const updateAgentTabValue = useCallback((tab: AgentTab, nextValue: string) => {
const field = agentFieldByTab[tab];
setEditForm((prev) => ({ ...prev, [field]: nextValue }));
}, []);
const onBaseProviderChange = useCallback((provider: string) => {
setEditForm((prev) => {
const nextProvider = String(provider || '').trim().toLowerCase();
const nextDefaultApiBase = getLlmProviderDefaultApiBase(nextProvider);
return {
...prev,
llm_provider: nextProvider,
api_base: nextDefaultApiBase,
};
});
setProviderTestResult('');
}, []);
const closeModals = useCallback(() => {
setShowBaseModal(false);
setShowParamModal(false);
setShowAgentModal(false);
}, []);
useEffect(() => {
if (!defaultSystemTimezone || editForm.system_timezone) return;
updateEditForm({ system_timezone: defaultSystemTimezone });
}, [defaultSystemTimezone, editForm.system_timezone, updateEditForm]);
useEffect(() => {
if (!selectedBotId) return;
if (showBaseModal || showParamModal || showAgentModal) return;
applyEditFormFromBot(selectedBot);
}, [
applyEditFormFromBot,
selectedBot,
selectedBot?.id,
selectedBot?.updated_at,
selectedBotId,
showAgentModal,
showBaseModal,
showParamModal,
]);
const openBaseConfigModal = useCallback(async () => {
setRuntimeMenuOpen(false);
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowBaseModal(true);
}, [applyEditFormFromBot, ensureSelectedBotDetail, setRuntimeMenuOpen]);
const openParamConfigModal = useCallback(async () => {
setRuntimeMenuOpen(false);
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowParamModal(true);
}, [applyEditFormFromBot, ensureSelectedBotDetail, setRuntimeMenuOpen]);
const openAgentFilesModal = useCallback(async () => {
setRuntimeMenuOpen(false);
const detail = await ensureSelectedBotDetail();
applyEditFormFromBot(detail);
setShowAgentModal(true);
}, [applyEditFormFromBot, ensureSelectedBotDetail, setRuntimeMenuOpen]);
const testProviderConnection = useCallback(async () => {
if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) {
notify(t.providerRequired, { tone: 'warning' });
return;
}
setIsTestingProvider(true);
setProviderTestResult('');
try {
const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
provider: editForm.llm_provider,
model: editForm.llm_model,
api_key: editForm.api_key.trim(),
api_base: editForm.api_base || undefined,
});
if (res.data?.ok) {
const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
setProviderTestResult(t.connOk(preview));
} else {
setProviderTestResult(t.connFail(res.data?.detail || 'unknown error'));
}
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || 'request failed';
setProviderTestResult(t.connFail(msg));
} finally {
setIsTestingProvider(false);
}
}, [editForm.api_base, editForm.api_key, editForm.llm_model, editForm.llm_provider, notify, t]);
const saveBot = useCallback(async (mode: 'params' | 'agent' | 'base') => {
const targetBotId = String(selectedBot?.id || selectedBotId || '').trim();
if (!targetBotId) {
notify(isZh ? '未选中 Bot无法保存。' : 'No bot selected.', { tone: 'warning' });
return;
}
setIsSaving(true);
try {
const payload: Record<string, string | number> = {};
if (mode === 'base') {
payload.name = editForm.name;
payload.access_password = editForm.access_password;
payload.image_tag = editForm.image_tag;
payload.system_timezone = editForm.system_timezone.trim() || defaultSystemTimezone;
const normalizedImageTag = String(editForm.image_tag || '').trim();
const selectedImage = availableImages.find((row) => String(row.tag || '').trim() === normalizedImageTag);
const selectedImageStatus = String(selectedImage?.status || '').toUpperCase();
if (normalizedImageTag && (!selectedImage || selectedImageStatus !== 'READY')) {
throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.');
}
const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
payload.cpu_cores = normalizedCpuCores;
payload.memory_mb = normalizedMemoryMb;
payload.storage_gb = normalizedStorageGb;
setEditForm((prev) => ({
...prev,
cpu_cores: normalizedCpuCores,
memory_mb: normalizedMemoryMb,
storage_gb: normalizedStorageGb,
}));
setParamDraft((prev) => ({
...prev,
cpu_cores: String(normalizedCpuCores),
memory_mb: String(normalizedMemoryMb),
storage_gb: String(normalizedStorageGb),
}));
}
if (mode === 'params') {
payload.llm_provider = editForm.llm_provider;
payload.llm_model = editForm.llm_model;
payload.api_base = editForm.api_base;
if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim();
payload.temperature = clampTemperature(Number(editForm.temperature));
payload.top_p = Number(editForm.top_p);
const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens));
payload.max_tokens = normalizedMaxTokens;
setEditForm((prev) => ({
...prev,
max_tokens: normalizedMaxTokens,
}));
setParamDraft((prev) => ({ ...prev, max_tokens: String(normalizedMaxTokens) }));
}
if (mode === 'agent') {
payload.agents_md = editForm.agents_md;
payload.soul_md = editForm.soul_md;
payload.user_md = editForm.user_md;
payload.tools_md = editForm.tools_md;
payload.identity_md = editForm.identity_md;
}
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
await refresh();
closeModals();
notify(t.configUpdated, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || t.saveFail;
notify(msg, { tone: 'error' });
} finally {
setIsSaving(false);
}
}, [
availableImages,
closeModals,
defaultSystemTimezone,
editForm,
isZh,
notify,
paramDraft,
refresh,
selectedBot?.id,
selectedBotId,
t,
]);
return {
agentFieldByTab,
agentTab,
applyEditFormFromBot,
editForm,
isSaving,
isTestingProvider,
onBaseProviderChange,
openAgentFilesModal,
openBaseConfigModal,
openParamConfigModal,
paramDraft,
providerTestResult,
saveBot,
setAgentTab,
setShowAgentModal,
setShowBaseModal,
setShowParamModal,
showAgentModal,
showBaseModal,
showParamModal,
testProviderConnection,
updateAgentTabValue,
updateEditForm,
updateParamDraft,
};
}

View File

@ -0,0 +1,105 @@
import { useCallback, useEffect } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotState } from '../../../types/bot';
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 UseDashboardBotManagementOptions {
confirm: (options: ConfirmOptions) => Promise<boolean>;
mergeBot: (bot: BotState) => void;
notify: (message: string, options?: NotifyOptions) => void;
refresh: () => Promise<void>;
selectedBot?: BotState;
selectedBotId: string;
setBotMessages: (botId: string, messages: any[]) => void;
setSelectedBotId: (botId: string) => void;
t: any;
}
export function useDashboardBotManagement({
confirm,
mergeBot,
notify,
refresh,
selectedBot,
selectedBotId,
setBotMessages,
setSelectedBotId,
t,
}: UseDashboardBotManagementOptions) {
useEffect(() => {
if (!selectedBotId) return;
let alive = true;
const loadBotDetail = async () => {
try {
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
if (alive) mergeBot(res.data);
} catch (error) {
console.error(`Failed to fetch bot detail for ${selectedBotId}`, error);
}
};
void loadBotDetail();
return () => {
alive = false;
};
}, [mergeBot, selectedBotId]);
const removeBot = useCallback(async (botId?: string) => {
const targetId = botId || selectedBot?.id;
if (!targetId) return;
const ok = await confirm({
title: t.delete,
message: t.deleteBotConfirm(targetId),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } });
await refresh();
if (selectedBotId === targetId) setSelectedBotId('');
notify(t.deleteBotDone, { tone: 'success' });
} catch {
notify(t.deleteFail, { tone: 'error' });
}
}, [confirm, notify, refresh, selectedBot?.id, selectedBotId, setSelectedBotId, t]);
const clearConversationHistory = useCallback(async () => {
if (!selectedBot) return;
const target = selectedBot.name || selectedBot.id;
const ok = await confirm({
title: t.clearHistory,
message: t.clearHistoryConfirm(target),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`);
setBotMessages(selectedBot.id, []);
notify(t.clearHistoryDone, { tone: 'success' });
} catch (error: any) {
const message = error?.response?.data?.detail || t.clearHistoryFail;
notify(message, { tone: 'error' });
}
}, [confirm, notify, selectedBot, setBotMessages, t]);
return {
clearConversationHistory,
removeBot,
};
}

View File

@ -0,0 +1,335 @@
import { useEffect, useRef, useState, type Dispatch, type KeyboardEvent, type SetStateAction } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import type { QuotedReply } from '../types';
import { loadComposerDraft, persistComposerDraft } from '../utils';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
title?: string;
tone?: PromptTone;
durationMs?: number;
}
interface UseDashboardChatComposerOptions {
selectedBotId: string;
selectedBot?: { id: string } | null;
canChat: boolean;
commandAutoUnlockSeconds: number;
pendingAttachments: string[];
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
isUploadingAttachments: boolean;
setChatDatePickerOpen: Dispatch<SetStateAction<boolean>>;
setControlCommandPanelOpen: Dispatch<SetStateAction<boolean>>;
addBotMessage: (botId: string, message: Partial<ChatMessage> & Pick<ChatMessage, 'role' | 'text' | 'ts'>) => void;
scrollConversationToBottom: (behavior?: ScrollBehavior) => void;
notify: (message: string, options?: NotifyOptions) => void;
t: any;
}
export function useDashboardChatComposer({
selectedBotId,
selectedBot,
canChat,
commandAutoUnlockSeconds,
pendingAttachments,
setPendingAttachments,
isUploadingAttachments,
setChatDatePickerOpen,
setControlCommandPanelOpen,
addBotMessage,
scrollConversationToBottom,
notify,
t,
}: UseDashboardChatComposerOptions) {
const [command, setCommand] = useState('');
const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
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 filePickerRef = useRef<HTMLInputElement | null>(null);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
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 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]);
useEffect(() => {
setComposerDraftHydrated(false);
if (!selectedBotId) {
setCommand('');
setPendingAttachments([]);
setComposerDraftHydrated(true);
return;
}
const draft = loadComposerDraft(selectedBotId);
setCommand(draft?.command || '');
setPendingAttachments(draft?.attachments || []);
setComposerDraftHydrated(true);
}, [selectedBotId, setPendingAttachments]);
useEffect(() => {
if (!selectedBotId || !composerDraftHydrated) return;
persistComposerDraft(selectedBotId, command, pendingAttachments);
}, [selectedBotId, composerDraftHydrated, command, pendingAttachments]);
useEffect(() => {
setQuotedReply(null);
}, [selectedBotId]);
useEffect(() => {
const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
if (!hasDraft && !isUploadingAttachments) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [command, isUploadingAttachments, pendingAttachments.length, quotedReply]);
const copyTextToClipboard = 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' });
}
};
const send = async () => {
if (!selectedBot || !canChat || isSendingBlocked) return;
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
const text = normalizeUserMessageText(command);
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
const payloadText = `${quoteBlock}${payloadCore}`.trim();
if (!payloadText && pendingAttachments.length === 0) return;
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: pendingAttachments },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
addBotMessage(selectedBot.id, {
role: 'user',
text: payloadText,
attachments: [...pendingAttachments],
ts: Date.now(),
kind: 'final',
});
requestAnimationFrame(() => scrollConversationToBottom('auto'));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
} 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' });
} 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 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) => {
await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
};
const editUserPrompt = (text: string) => {
const normalized = normalizeUserMessageText(text);
if (!normalized) return;
setCommand(normalized);
composerTextareaRef.current?.focus();
if (composerTextareaRef.current) {
const caret = normalized.length;
window.requestAnimationFrame(() => {
composerTextareaRef.current?.setSelectionRange(caret, caret);
});
}
notify(t.editPromptDone, { tone: 'success' });
};
const copyAssistantReply = async (text: string) => {
await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
};
const quoteAssistantReply = (message: ChatMessage) => {
const content = normalizeAssistantMessageText(message.text);
if (!content) return;
setQuotedReply((prev) => {
if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) {
return null;
}
return { id: message.id, ts: message.ts, text: content };
});
};
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const native = event.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number };
if (native.isComposing || native.keyCode === 229) return;
const isEnter = event.key === 'Enter' || event.key === 'NumpadEnter';
if (!isEnter || event.shiftKey) return;
event.preventDefault();
void send();
};
const triggerPickAttachments = () => {
if (!selectedBot || !canChat || isUploadingAttachments) return;
filePickerRef.current?.click();
};
return {
activeControlCommand,
command,
composerTextareaRef,
copyAssistantReply,
copyUserPrompt,
editUserPrompt,
filePickerRef,
interruptExecution,
isCommandAutoUnlockWindowActive,
isInterrupting,
isSending,
isSendingBlocked,
onComposerKeyDown,
quoteAssistantReply,
quotedReply,
send,
sendControlCommand,
setCommand,
setQuotedReply,
triggerPickAttachments,
};
}

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