v0.1.4-p4
parent
d0e6171120
commit
41212a7ac9
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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("✅ 启动自检完成")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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)]}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
4408
backend/main.py
4408
backend/main.py
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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"],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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, "")
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 必须按主题稳定收口
|
||||
|
||||
后续所有新增功能与重构,均以本文档为准执行。
|
||||
|
|
@ -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.
|
||||
1977
frontend/src/App.css
1977
frontend/src/App.css
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.section-mini-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--subtitle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '允许发件人',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -61,4 +61,6 @@ export const wizardEn = {
|
|||
channels: 'Channels',
|
||||
autoStart: 'Auto start after creation',
|
||||
creating: 'Creating...',
|
||||
cancelCreating: 'Cancel creation',
|
||||
createCanceled: 'Creation canceled.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { createChannelManager } from './config-managers/channelManager';
|
||||
export { createMcpManager } from './config-managers/mcpManager';
|
||||
export { createTopicManager } from './config-managers/topicManager';
|
||||
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue