diff --git a/backend/core/database.py b/backend/core/database.py index 78f7a1d..5656174 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -14,6 +14,7 @@ from core.settings import ( # Ensure table models are registered in SQLModel metadata before create_all. from models import bot as _bot_models # noqa: F401 from models import platform as _platform_models # noqa: F401 +from models import skill as _skill_models # noqa: F401 from models import topic as _topic_models # noqa: F401 _engine_kwargs = { @@ -747,6 +748,8 @@ def align_postgres_sequences() -> None: (BOT_MESSAGE_TABLE, "id"), (BOT_REQUEST_USAGE_TABLE, "id"), (BOT_ACTIVITY_EVENT_TABLE, "id"), + ("skill_market_item", "id"), + ("bot_skill_install", "id"), ] with engine.connect() as conn: for table_name, column_name in sequence_targets: diff --git a/backend/main.py b/backend/main.py index b9958fa..02a14ce 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ import re import shutil import tempfile import zipfile -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from urllib.parse import quote, unquote @@ -52,6 +52,7 @@ from core.settings import ( ) from models.bot import BotInstance, BotMessage, NanobotImage from models.platform import BotActivityEvent, BotRequestUsage +from models.skill import BotSkillInstall, SkillMarketItem from models.topic import TopicItem, TopicTopic from api.platform_router import router as platform_router from api.topic_router import router as topic_router @@ -89,6 +90,7 @@ speech_service = WhisperSpeechService() app.state.docker_manager = docker_manager app.state.speech_service = speech_service BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") +BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password" class ChannelConfigRequest(BaseModel): @@ -409,6 +411,14 @@ def _get_supplied_panel_password_http(request: Request) -> str: 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: @@ -421,6 +431,18 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]: return None +def _validate_bot_access_password(bot: BotInstance, supplied: str) -> Optional[str]: + configured = str(getattr(bot, "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_panel_protected_api_path(path: str, method: str = "GET") -> bool: raw = str(path or "").strip() verb = str(method or "GET").strip().upper() @@ -475,19 +497,26 @@ async def bot_access_password_guard(request: Request, call_next): if request.method.upper() == "OPTIONS": return await call_next(request) - if _is_panel_protected_api_path(request.url.path, request.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}) - bot_id = _extract_bot_id_from_api_path(request.url.path) if not bot_id: + if _is_panel_protected_api_path(request.url.path, request.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) with Session(engine) as session: bot = session.get(BotInstance, bot_id) if not bot: return JSONResponse(status_code=404, content={"detail": "Bot not found"}) + + if _is_bot_panel_management_api_path(request.url.path, request.method): + panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) + if panel_error: + bot_error = _validate_bot_access_password(bot, _get_supplied_bot_access_password_http(request)) + if bot_error: + return JSONResponse(status_code=401, content={"detail": bot_error}) + enabled = bool(getattr(bot, "enabled", True)) if not enabled: is_enable_api = _is_bot_enable_api_path(request.url.path, request.method) @@ -532,12 +561,45 @@ def _cache_key_bot_detail(bot_id: str) -> str: def _cache_key_bot_messages(bot_id: str, limit: int) -> str: - return f"bot:messages:{bot_id}:limit:{limit}" + return f"bot:messages: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:{bot_id}:before:{cursor}:limit:{limit}" + return f"bot:messages:page:v2:{bot_id}:before:{cursor}:limit:{limit}" + + +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 _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_minutes = 0 + if tz_offset_minutes is not None: + try: + offset_minutes = int(tz_offset_minutes) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid timezone offset") from exc + + utc_start = local_day + timedelta(minutes=offset_minutes) + utc_end = utc_start + timedelta(days=1) + return utc_start, utc_end def _cache_key_images() -> str: @@ -1526,6 +1588,312 @@ def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]: return rows +def _skill_market_root() -> str: + return os.path.abspath(os.path.join(DATA_ROOT, "skills")) + + +def _parse_json_string_list(raw: Any) -> List[str]: + if not raw: + return [] + try: + data = json.loads(str(raw)) + except Exception: + return [] + if not isinstance(data, list): + return [] + rows: List[str] = [] + for item in data: + text = str(item or "").strip() + if text and text not in rows: + rows.append(text) + return rows + + +def _is_ignored_skill_zip_top_level(name: str) -> bool: + text = str(name or "").strip() + if not text: + return True + lowered = text.lower() + if lowered == "__macosx": + return True + if text.startswith("."): + return True + return False + + +def _read_description_from_text(raw: str) -> str: + for line in str(raw or "").splitlines(): + text = line.strip() + if text and not text.startswith("#"): + return text[:240] + return "" + + +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 fh: + preview = fh.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 _sanitize_skill_market_key(raw: Any) -> str: + value = str(raw or "").strip().lower() + value = re.sub(r"[^a-z0-9._-]+", "-", value) + value = re.sub(r"-{2,}", "-", value).strip("._-") + return value[:120] + + +def _sanitize_zip_filename(raw: Any) -> str: + filename = os.path.basename(str(raw or "").strip()) + if not filename: + return "" + filename = filename.replace("\\", "/").rsplit("/", 1)[-1] + stem, ext = os.path.splitext(filename) + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-") + if not safe_stem: + safe_stem = "skill-package" + safe_ext = ".zip" if ext.lower() == ".zip" else "" + return f"{safe_stem[:180]}{safe_ext}" + + +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 _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 _cron_store_path(bot_id: str) -> str: return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json") @@ -2320,6 +2688,9 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De 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() @@ -2342,6 +2713,121 @@ def list_bot_channels(bot_id: str, session: Session = Depends(get_session)): return _get_bot_channels_from_config(bot) +@app.get("/api/platform/skills") +def list_skill_market(session: Session = Depends(get_session)): + items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all() + 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 [ + _serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0)) + for item in items + ] + + +@app.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), +): + upload_meta = await _store_skill_market_zip_upload(session, file) + 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 + + +@app.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), +): + 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 file is not None: + upload_meta = await _store_skill_market_zip_upload( + session, + file, + 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) + + +@app.delete("/api/platform/skills/{skill_id}") +def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)): + 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} + + @app.get("/api/bots/{bot_id}/skills") def list_bot_skills(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) @@ -2350,6 +2836,124 @@ def list_bot_skills(bot_id: str, session: Session = Depends(get_session)): return _list_workspace_skills(bot_id) +@app.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") + 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} + all_install_rows = session.exec(select(BotSkillInstall)).all() + install_count_by_skill: Dict[int, int] = {} + for row in all_install_rows: + 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 [ + _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 + ] + + +@app.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") + 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 + + @app.get("/api/bots/{bot_id}/tools-config") def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) @@ -2453,16 +3057,15 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio if not bot: raise HTTPException(status_code=404, detail="Bot not found") - filename = str(file.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 tmp_zip_path: Optional[str] = None - total_size = 0 try: with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip: tmp_zip_path = tmp_zip.name + filename = str(file.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 file.read(1024 * 1024) if not chunk: @@ -2474,89 +3077,21 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)", ) tmp_zip.write(chunk) - except Exception: - if tmp_zip_path and os.path.exists(tmp_zip_path): - os.remove(tmp_zip_path) - raise + if total_size == 0: + raise HTTPException(status_code=400, detail="Zip package is empty") finally: await file.close() - - if total_size == 0: - if tmp_zip_path and os.path.exists(tmp_zip_path): - os.remove(tmp_zip_path) - raise HTTPException(status_code=400, detail="Zip package is empty") - try: - archive = zipfile.ZipFile(tmp_zip_path) - except Exception: - if tmp_zip_path and os.path.exists(tmp_zip_path): - os.remove(tmp_zip_path) - raise HTTPException(status_code=400, detail="Invalid zip file") - - skills_root = _skills_root(bot_id) - os.makedirs(skills_root, exist_ok=True) - - installed: List[str] = [] - try: - 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 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) + 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) - if not installed: - raise HTTPException(status_code=400, detail="No skill entries installed from zip") - return { "status": "installed", "bot_id": bot_id, - "installed": installed, - "skills": _list_workspace_skills(bot_id), + "installed": install_result["installed"], + "skills": install_result["skills"], } @@ -2886,18 +3421,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( .limit(safe_limit) ).all() ordered = list(reversed(rows)) - payload = [ - { - "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(row.created_at.timestamp() * 1000), - } - for row in ordered - ] + payload = [_serialize_bot_message_row(bot_id, row) for row in ordered] cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30) return payload @@ -2935,18 +3459,7 @@ def list_bot_messages_page( if has_more: rows = rows[:safe_limit] ordered = list(reversed(rows)) - items = [ - { - "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(row.created_at.timestamp() * 1000), - } - for row in ordered - ] + items = [_serialize_bot_message_row(bot_id, row) for row in ordered] next_before_id = rows[-1].id if rows else None payload = { "items": items, @@ -2958,6 +3471,113 @@ def list_bot_messages_page( return payload +@app.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), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + 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, + } + + @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback") def update_bot_message_feedback( bot_id: str, diff --git a/backend/models/skill.py b/backend/models/skill.py new file mode 100644 index 0000000..aad75ef --- /dev/null +++ b/backend/models/skill.py @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, SQLModel + + +class SkillMarketItem(SQLModel, table=True): + __tablename__ = "skill_market_item" + __table_args__ = ( + UniqueConstraint("skill_key", name="uq_skill_market_item_skill_key"), + UniqueConstraint("zip_filename", name="uq_skill_market_item_zip_filename"), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + skill_key: str = Field(index=True, max_length=120) + display_name: str = Field(default="", max_length=255) + description: str = Field(default="") + zip_filename: str = Field(max_length=255) + zip_size_bytes: int = Field(default=0) + entry_names_json: str = Field(default="[]") + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) + updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + +class BotSkillInstall(SQLModel, table=True): + __tablename__ = "bot_skill_install" + __table_args__ = ( + UniqueConstraint("bot_id", "skill_market_item_id", name="uq_bot_skill_install_bot_market"), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) + skill_market_item_id: int = Field(foreign_key="skill_market_item.id", index=True) + installed_entries_json: str = Field(default="[]") + source_zip_filename: str = Field(default="", max_length=255) + status: str = Field(default="INSTALLED", index=True, max_length=32) + last_error: Optional[str] = Field(default=None) + installed_at: datetime = Field(default_factory=datetime.utcnow, index=True) + updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) diff --git a/design/remote-node-management.md b/design/remote-node-management.md new file mode 100644 index 0000000..3a72180 --- /dev/null +++ b/design/remote-node-management.md @@ -0,0 +1,571 @@ +# Dashboard Nanobot 远端节点管理设计(方案 A) + +本文档定义 Dashboard Nanobot 的多节点远端管理方案,目标是在尽量少侵入 nanobot 核心源码的前提下,让当前面板可以统一管理部署在其它电脑上的 nanobot 实例,并保持与本机 Bot 相近的操作体验。 + +## 1. 目标与边界 + +### 1.1 目标 + +- 中心面板统一管理多台机器上的 nanobot 实例。 +- 远端 Bot 支持: + - 查看运行状态 + - 启动、停止、重启 + - 下发控制命令,如 `/new`、`/stop`、`/restart` + - 查看实时日志和结构化运行事件 + - 读取和编辑工作区文件 + - 创建新 Bot、升级已有 Bot 核心 +- 远端机器既支持 Docker 部署,也支持宿主机直接安装运行。 +- 尽量不修改 nanobot 核心,只复用自定义 `dashboard.py` channel。 + +### 1.2 非目标 + +- 不让中心面板直接通过 SSH 管理远端机器。 +- 不让中心面板直接操作远端 Docker Remote API。 +- 不在 nanobot 核心中引入“多节点”或“集群”概念。 +- 不要求用户手工编辑远端 `config.json`。 + +### 1.3 设计原则 + +- 控制平面与执行平面分离。 +- 远端机器自治,中心只负责编排与聚合。 +- nanobot 核心零或极低侵入。 +- Docker 与宿主机直装都作为一等运行模式支持。 +- 面向逐步上线,优先跑通命令与状态链路,再补管理能力。 + +## 2. 总体方案 + +采用“中心控制面 + 远端节点代理”的双层结构。 + +- 中心控制面: + - 当前 dashboard 的 frontend + backend + - 负责统一 UI、数据库、权限、审计、聚合视图 +- 远端节点代理: + - 每台远端机器部署一个轻量 `dashboard-edge` + - 负责本机 Bot 生命周期、工作区、日志流、命令投递 +- nanobot 实例: + - 继续使用自定义 `dashboard.py` + - 对外只暴露本地 Dashboard Channel + +### 2.1 架构图 + +```mermaid +flowchart LR + UI["Dashboard Frontend"] --> CP["Central Backend"] + CP <--> DB["Central DB"] + CP <--> EDGE1["Node Agent A"] + CP <--> EDGE2["Node Agent B"] + EDGE1 <--> BOT1["Nanobot (Host Process)"] + EDGE1 <--> BOT2["Nanobot (Docker)"] + EDGE2 <--> BOT3["Nanobot (Host Process)"] +``` + +### 2.2 关键结论 + +- 不扩展 nanobot 去理解“远端管理”。 +- 不要求面板直接连接远端 Bot 的文件系统或 Docker。 +- 所有远端执行动作都先到节点代理,再由节点代理在本机执行。 + +## 3. 为什么选择方案 A + +相对于“中心直接 SSH 管理远端机器”或“每台机器都跑一套完整 dashboard 后端”,方案 A 更适合当前项目: + +- 与现有架构兼容性最高。 +- 可以同时覆盖 Docker 和宿主机直装。 +- 中心端仍保留统一数据库与统一 UI,不会产生多套后台状态漂移。 +- 节点代理可以封装平台差异,如 macOS、Linux、Windows 的进程管理差异。 +- 自定义 `dashboard.py` 可以直接复用,不需要重新定义 bot 内部协议。 + +## 4. 与现有代码的关系 + +当前代码默认“控制端和执行端在同一台机器”: + +- `backend/core/docker_manager.py` + - 直接调用本机 Docker +- `backend/core/config_manager.py` + - 直接写本机 workspace 和 `.nanobot/config.json` +- `backend/main.py` + - `start/stop/command/workspace/monitor` 全部默认操作本机资源 + +因此,多节点能力不应直接在这些逻辑上继续叠加条件分支,而应先抽象出执行层接口,再分别实现本地与远端两类 Provider。 + +## 5. 运行模式设计 + +每个 Bot 必须明确声明运行模式: + +- `docker` +- `host` + +节点代理内部为两种模式提供统一执行接口。 + +### 5.1 Docker 模式 + +- Bot 运行在本机 Docker 容器中。 +- 节点代理负责: + - 启停容器 + - 监控容器日志 + - 发送命令到容器内 dashboard channel + - 管理挂载的 `.nanobot/workspace` + +### 5.2 Host 模式 + +- Bot 直接运行在宿主机进程中。 +- 节点代理负责: + - 启停本机进程或系统服务 + - 捕获 stdout/stderr + - 发送命令到本机 dashboard channel + - 管理本机工作区 + +### 5.3 Host 模式的关键约束 + +宿主机直装时,多个 Bot 不能共用固定端口,因此 Dashboard Channel 端口必须由节点代理自动分配并持久化。 + +推荐策略: + +- Docker 模式: + - 容器内可继续使用 `0.0.0.0:9000` +- Host 模式: + - 使用 `127.0.0.1:{auto_assigned_port}` + - 例如 `19101`、`19102`、`19103` + +原因: + +- 直装场景下避免端口冲突 +- 避免把 Bot 的本地控制端口暴露给局域网 +- 只允许节点代理本机访问 + +## 6. nanobot 侧侵入边界 + +### 6.1 保持不变 + +nanobot 核心本身不需要感知节点、中心控制面或跨机调度。 + +### 6.2 仅要求保留的自定义能力 + +- 自定义 `dashboard.py` channel 可用 +- `start()` 和 `stop()` 使用基类状态字段 `self._running` +- 支持 `config.json` 中启用 `channels.dashboard` +- `dashboard.py` 支持: + - `POST /chat` 入站命令 + - 结构化 stdout 出站消息 + +### 6.3 dashboard.py 的定位 + +它仍然只是“本机 Bot 控制通道”,不是中心与远端节点之间的通信协议。 + +分工如下: + +- 中心与节点代理通信:Node Agent API / WebSocket +- 节点代理与 Bot 通信:本机 `dashboard.py` + +## 7. 中心端改造方案 + +中心端不直接继续依赖本机 Docker 和本机文件系统,而是抽象出执行接口。 + +### 7.1 建议新增 Provider 抽象 + +- `RuntimeProvider` + - `start_bot` + - `stop_bot` + - `restart_bot` + - `send_command` + - `get_status` + - `get_recent_logs` + - `stream_monitor` +- `WorkspaceProvider` + - `list_tree` + - `read_file` + - `write_markdown` + - `upload_files` + - `download_file` +- `BotProvisionProvider` + - `create_bot` + - `upgrade_bot` + - `delete_bot` + - `sync_config` + +### 7.2 Provider 实现建议 + +- `LocalRuntimeProvider` + - 复用当前 `docker_manager.py` +- `LocalWorkspaceProvider` + - 复用当前本地 workspace 读写逻辑 +- `RemoteRuntimeProvider` + - 通过节点代理 API / WebSocket 调用远端动作 +- `RemoteWorkspaceProvider` + - 通过节点代理文件接口访问远端 workspace + +### 7.3 中心端路由层原则 + +前端 API 尽量保持稳定,例如: + +- `/api/bots/{bot_id}/start` +- `/api/bots/{bot_id}/stop` +- `/api/bots/{bot_id}/command` +- `/api/bots/{bot_id}/workspace/tree` + +但这些接口在后端内部根据 `bot.node_id` 和 `bot.runtime_kind` 路由到不同 Provider。 + +这样可以减少前端改动,并保持本机 Bot 与远端 Bot 的统一交互模型。 + +## 8. 节点代理设计 + +### 8.1 职责 + +远端节点代理 `dashboard-edge` 负责: + +- 本机 Bot 注册和枚举 +- Bot 创建、升级、删除 +- Bot 启停与重启 +- 命令下发 +- 日志采集与结构化事件解析 +- 工作区文件读写 +- Dashboard Channel 配置自动注入 +- 向中心汇报节点状态和 Bot 状态 + +### 8.2 统一执行接口 + +节点代理内部也需要抽象执行器: + +- `DockerExecutor` +- `HostProcessExecutor` + +两者都实现: + +- `start_bot` +- `stop_bot` +- `restart_bot` +- `send_command` +- `read_logs` +- `read_workspace_file` +- `write_workspace_file` +- `get_resource_snapshot` + +### 8.3 HostProcessExecutor 推荐实现 + +首版建议使用“节点代理托管子进程”的方式,而不是一开始就深度接入系统服务管理器。 + +优点: + +- 首版开发快 +- 可直接拿到 stdout/stderr +- 更容易复用现有 dashboard channel 日志解析逻辑 + +后续可演进为: + +- Linux: `systemd` +- macOS: `launchd` +- Windows: Service + +## 9. 节点与中心的通信模型 + +建议采用“节点主动连中心”的反向长连接优先模型,同时兼容同网环境下的 HTTPS 直连。 + +### 9.1 推荐默认模式 + +- 节点启动后主动向中心注册 +- 建立长期 WebSocket +- 中心通过该连接发送控制命令 +- 节点通过该连接回传: + - 心跳 + - Bot 状态 + - 结构化事件 + - 日志摘要 + +### 9.2 为什么优先反向长连接 + +- 适配 NAT 与家庭宽带场景 +- 远端节点无需开放入站端口 +- 更适合公网分散机器接入 + +### 9.3 可选直连模式 + +当节点与中心在同一内网或 VPN 内时,可允许中心通过 HTTPS 直接调用节点代理 API。 + +## 10. Bot 元数据模型调整 + +当前 `bot_instance` 只适合单机场景,需要扩展节点信息。 + +### 10.1 新增 node_instance + +建议新增 `node_instance` 表: + +- `id` +- `name` +- `status` +- `agent_version` +- `last_seen_at` +- `connection_mode` +- `auth_token_hash` +- `host_name` +- `os_type` +- `capabilities_json` +- `created_at` +- `updated_at` + +### 10.2 扩展 bot_instance + +建议为 `bot_instance` 增加: + +- `node_id` +- `runtime_kind` +- `remote_bot_id` +- `dashboard_host` +- `dashboard_port` +- `runtime_meta_json` + +说明: + +- `node_id` 标识该 Bot 归属哪个节点 +- `runtime_kind` 表示 `docker` 或 `host` +- `dashboard_port` 用于 Host 模式的本地控制端口映射 +- `remote_bot_id` 允许未来节点端使用独立内部 ID + +### 10.3 Bot ID 策略 + +建议中心平台继续保持 `bot_instance.id` 全局唯一。 + +如果远端已有同名 Bot,可: + +- 中心分配平台级 ID +- 节点侧保留原始 `remote_bot_id` + +这样可避免跨节点命名冲突。 + +## 11. Dashboard Channel 配置策略 + +### 11.1 配置自动注入 + +所有由中心或节点代理创建、升级、同步的 Bot,都由节点代理自动保证 `channels.dashboard` 存在。 + +用户不需要手工编辑 `config.json`。 + +### 11.2 生成策略 + +Docker 模式建议写入: + +```json +{ + "enabled": true, + "host": "0.0.0.0", + "port": 9000, + "allowFrom": ["*"] +} +``` + +Host 模式建议写入: + +```json +{ + "enabled": true, + "host": "127.0.0.1", + "port": 19101, + "allowFrom": ["127.0.0.1", "::1"] +} +``` + +其中 `port` 由节点代理自动分配,不能写死。 + +### 11.3 面板行为约束 + +中心面板与节点代理都应把 `dashboard` 视为内建保留渠道: + +- 自动存在 +- 不允许删除 +- 不允许普通用户手改关键监听参数 + +## 12. 关键数据流 + +### 12.1 命令下发流 + +```mermaid +sequenceDiagram + participant UI as Frontend + participant CP as Central Backend + participant EDGE as Node Agent + participant BOT as Nanobot Dashboard Channel + + UI->>CP: POST /api/bots/{id}/command + CP->>EDGE: send_command(bot_id, command) + EDGE->>BOT: POST /chat + BOT-->>EDGE: 200 OK + EDGE-->>CP: accepted + CP-->>UI: success +``` + +### 12.2 运行事件回传流 + +```mermaid +sequenceDiagram + participant BOT as Nanobot + participant EDGE as Node Agent + participant CP as Central Backend + participant UI as Frontend + + BOT->>EDGE: stdout structured packet + EDGE->>EDGE: parse packet + EDGE->>CP: runtime event push + CP->>CP: persist message/state/event + CP->>UI: websocket broadcast +``` + +### 12.3 工作区文件流 + +```mermaid +sequenceDiagram + participant UI as Frontend + participant CP as Central Backend + participant EDGE as Node Agent + + UI->>CP: GET /api/bots/{id}/workspace/file + CP->>EDGE: read_workspace_file(bot_id, path) + EDGE-->>CP: file content + CP-->>UI: markdown/text payload +``` + +## 13. 节点代理 API 草案 + +首版建议具备如下能力。 + +### 13.1 节点管理 + +- `POST /agent/handshake` +- `POST /agent/heartbeat` +- `GET /agent/capabilities` + +### 13.2 Bot 管理 + +- `GET /agent/bots` +- `POST /agent/bots` +- `POST /agent/bots/{bot_id}/upgrade` +- `DELETE /agent/bots/{bot_id}` +- `POST /agent/bots/{bot_id}/start` +- `POST /agent/bots/{bot_id}/stop` +- `POST /agent/bots/{bot_id}/restart` +- `POST /agent/bots/{bot_id}/command` + +### 13.3 工作区与文件 + +- `GET /agent/bots/{bot_id}/workspace/tree` +- `GET /agent/bots/{bot_id}/workspace/file` +- `PUT /agent/bots/{bot_id}/workspace/file` +- `POST /agent/bots/{bot_id}/workspace/upload` +- `GET /agent/bots/{bot_id}/logs` + +### 13.4 推送通道 + +- `WS /agent/ws` + +推送内容包括: + +- 节点上线/离线 +- 心跳 +- Bot 状态 +- 结构化运行消息 +- 运维事件 + +## 14. 安全模型 + +### 14.1 基础要求 + +- 节点与中心之间必须使用 HTTPS 或 WSS。 +- 节点首次接入使用一次性注册码。 +- 注册完成后发放长期令牌。 +- 中心只保存令牌摘要,不保存明文。 + +### 14.2 权限边界 + +- 中心不能直接访问远端文件系统,只能经节点代理。 +- 节点代理只能操作本机所属 Bot 工作区。 +- 所有文件接口必须保留路径越界校验。 + +### 14.3 命令幂等 + +每次命令下发都应带 `request_id`,节点代理应避免因网络重试导致重复执行。 + +### 14.4 审计 + +中心端应记录: + +- 谁对哪个 Bot 下发了什么命令 +- 命令发往哪个节点 +- 节点是否接受和执行 +- 最终结果和错误原因 + +## 15. 分阶段实施计划 + +### 15.1 第一阶段:跑通远端控制链路 + +目标: + +- 节点注册 +- 节点在线状态 +- 远端 Bot 列表 +- `start/stop/restart` +- `/new`、`/stop`、`/restart` 下发 +- 实时日志与状态回传 + +特点: + +- 优先打通“像本机一样可通讯、可下发指令” +- 暂不强求全部文件管理能力 + +### 15.2 第二阶段:补齐远端 Bot 生命周期管理 + +目标: + +- 新建远端 Bot +- 升级旧 Bot 到新核心 +- 自动补 `dashboard` 配置 +- 支持 Docker 与 Host 两类运行模式 + +### 15.3 第三阶段:补齐工作区能力 + +目标: + +- 文件树浏览 +- Markdown 在线编辑与保存 +- 文件上传下载 +- 技能包管理 + +### 15.4 第四阶段:稳定性与权限强化 + +目标: + +- 节点断线恢复 +- 节点版本兼容检查 +- 审计日志完善 +- 权限分级 +- 节点灰度升级 + +## 16. 风险与注意事项 + +### 16.1 最大技术风险 + +- Host 模式的端口分配与持久化管理 +- 节点代理重启后的进程状态恢复 +- 不同操作系统上的进程守护差异 +- 网络抖动下的命令幂等与状态一致性 + +### 16.2 兼容性风险 + +- 不同版本 nanobot 核心对 channel 生命周期字段要求可能不同 +- `dashboard.py` 需要继续保持与 post5 的 `self._running` 兼容 +- 老版本 Bot 升级时必须自动补全 `channels.dashboard` + +### 16.3 明确不建议的路径 + +- 不建议中心直接 SSH 管理远端机器 +- 不建议暴露 Docker Remote API 给中心 +- 不建议让每台远端机器都维护一套完整 dashboard 主后台数据库 + +## 17. 最终建议 + +项目应以“中心控制面 + 远端节点代理”为长期结构,宿主机直装作为一等公民,Docker 作为兼容执行后端。 + +实现上优先做三件事: + +1. 中心端抽象 Provider 层,解除对本机 Docker 和本机文件系统的硬绑定。 +2. 设计并落地 `dashboard-edge`,统一承接远端执行、日志、文件和命令。 +3. 在节点代理侧自动注入 `dashboard` channel 配置,避免用户手工维护远端 `config.json`。 + +这样可以在几乎不改 nanobot 核心设计的前提下,将当前面板平滑演进为多节点 Bot 控制平台。 diff --git a/frontend/src/App.css b/frontend/src/App.css index 7316a33..505f958 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -130,6 +130,7 @@ body { display: inline-flex; align-items: center; gap: 8px; + flex-wrap: wrap; } .app-title-icon { @@ -1185,6 +1186,22 @@ body { text-transform: uppercase; } +.app-route-crumb { + border: 0; + padding: 0; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.app-route-crumb:hover { + color: var(--text); +} + .platform-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr); @@ -1939,6 +1956,431 @@ body { flex: 1 1 auto; } +.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; +} + +.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, +.row-actions-inline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.skill-market-pager { + margin-top: 16px; + font-size: 12px; + color: var(--muted); +} + +.row-actions-inline { + justify-content: flex-end; + flex-wrap: wrap; +} + +.skill-market-page-size-hint { + white-space: nowrap; +} + +.skill-market-drawer-mask { + position: fixed; + inset: 0; + background: rgba(12, 18, 31, 0.26); + opacity: 0; + pointer-events: none; + transition: opacity 0.22s ease; + border-radius: 0; +} + +.skill-market-drawer-mask.is-open { + opacity: 1; + pointer-events: auto; +} + +.skill-market-drawer { + position: fixed; + top: 94px; + right: 18px; + bottom: 18px; + width: min(460px, calc(100vw - 36px)); + transform: translateX(calc(100% + 20px)); + transition: transform 0.22s ease; + z-index: 41; +} + +.skill-market-drawer.is-open { + transform: translateX(0); +} + +.skill-market-drawer .skill-market-editor { + height: 100%; + padding: 22px; + border-radius: 0; + box-shadow: 0 18px 42px rgba(13, 24, 45, 0.24); + overflow: auto; +} + +.app-shell[data-theme='light'] .skill-market-file-picker { + background: color-mix(in oklab, var(--panel) 80%, #f7fbff 20%); +} + +.app-shell[data-theme='light'] .skill-market-drawer-mask { + background: rgba(111, 138, 179, 0.16); +} + .app-shell[data-theme='light'] .platform-entry-card { border-color: #b7c7e6; background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); @@ -2118,6 +2560,16 @@ body { 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; } @@ -2212,7 +2664,16 @@ body { .platform-selected-bot-last-row, .platform-settings-pager, .platform-usage-pager, - .platform-template-header { + .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; } @@ -2225,4 +2686,21 @@ 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; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f58189a..f8504eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,16 +8,15 @@ import { pickLocale } from './i18n'; import { appZhCn } from './i18n/app.zh-cn'; import { appEn } from './i18n/app.en'; import { LucentTooltip } from './components/lucent/LucentTooltip'; +import { PasswordInput } from './components/PasswordInput'; +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 './App.css'; -function getSingleBotPasswordKey(botId: string) { - return `nanobot-bot-page-password:${String(botId || '').trim()}`; -} - const defaultLoadingPage = { title: 'Dashboard Nanobot', subtitle: '平台正在准备管理面板', @@ -37,6 +36,9 @@ function AuthenticatedApp() { const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); const [singleBotSubmitting, setSingleBotSubmitting] = useState(false); + const passwordToggleLabels = locale === 'zh' + ? { show: '显示密码', hide: '隐藏密码' } + : { show: 'Show password', hide: 'Hide password' }; const forcedBotId = route.kind === 'bot' ? route.botId : ''; useBotsSync(forcedBotId || undefined); @@ -61,15 +63,25 @@ function AuthenticatedApp() { const shouldPromptSingleBotPassword = Boolean( route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked, ); + const headerTitle = + route.kind === 'bot' + ? (forcedBot?.name || defaultLoadingPage.title) + : route.kind === 'dashboard-skills' + ? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace') + : t.title; useEffect(() => { - if (route.kind !== 'bot') { + if (route.kind === 'dashboard') { document.title = t.title; return; } + if (route.kind === 'dashboard-skills') { + document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`; + return; + } const botName = String(forcedBot?.name || '').trim(); document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`; - }, [forcedBot?.name, forcedBotId, route.kind, t.title]); + }, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]); useEffect(() => { setSingleBotUnlocked(false); @@ -79,18 +91,19 @@ function AuthenticatedApp() { useEffect(() => { if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return; - const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forcedBotId)) || '' : ''; + const stored = getBotAccessPassword(forcedBotId); if (!stored) return; let alive = true; const boot = async () => { try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored }); if (!alive) return; + setBotAccessPassword(forcedBotId, stored); setSingleBotUnlocked(true); setSingleBotPassword(''); setSingleBotPasswordError(''); } catch { - if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId)); + clearBotAccessPassword(forcedBotId); if (!alive) return; setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); } @@ -110,22 +123,24 @@ function AuthenticatedApp() { setSingleBotSubmitting(true); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered }); - if (typeof window !== 'undefined') { - window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered); - } + setBotAccessPassword(forcedBotId, entered); setSingleBotPasswordError(''); setSingleBotUnlocked(true); setSingleBotPassword(''); } catch { - if (typeof window !== 'undefined') { - window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId)); - } + clearBotAccessPassword(forcedBotId); setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.'); } finally { setSingleBotSubmitting(false); } }; + const navigateToDashboard = () => { + if (typeof window === 'undefined') return; + window.history.pushState({}, '', '/dashboard'); + window.dispatchEvent(new PopStateEvent('popstate')); + }; + return (
@@ -140,12 +155,18 @@ function AuthenticatedApp() {
Nanobot
-

{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}

-
- {route.kind === 'dashboard' - ? (locale === 'zh' ? '平台总览' : 'Platform overview') - : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} -
+

{headerTitle}

+ {route.kind === 'dashboard-skills' ? ( + + ) : ( +
+ {route.kind === 'dashboard' + ? (locale === 'zh' ? '平台总览' : 'Platform overview') + : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} +
+ )} {isCompactShell ? ( + + ); +}); diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 60c047f..87c0c7a 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -1333,6 +1333,7 @@ } .ops-control-command-drawer { + position: relative; display: flex; align-items: center; gap: 4px; @@ -1346,9 +1347,10 @@ } .ops-control-command-drawer.is-open { - max-width: 240px; + max-width: 332px; opacity: 1; transform: translateX(0); + overflow: visible; pointer-events: auto; } @@ -1384,6 +1386,39 @@ 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; @@ -1589,10 +1624,15 @@ @media (max-width: 720px) { .ops-control-command-drawer.is-open { - max-width: 220px; + 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; @@ -2723,6 +2763,32 @@ 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; @@ -2774,6 +2840,16 @@ 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; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index e169ddd..b1045ec 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -19,7 +19,10 @@ import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentSelect } from '../../components/lucent/LucentSelect'; +import { PasswordInput } from '../../components/PasswordInput'; import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel'; +import type { BotSkillMarketItem } from '../platform/types'; +import { SkillMarketInstallModal } from './components/SkillMarketInstallModal'; import { normalizePlatformPageSize, readCachedPlatformPageSize, @@ -92,6 +95,15 @@ interface WorkspaceUploadResponse { files: Array<{ name: string; path: string; size: number }>; } +interface BotMessagesByDateResponse { + items?: any[]; + anchor_id?: number | null; + resolved_ts?: number | null; + matched_exact_date?: boolean; + has_more_before?: boolean; + has_more_after?: boolean; +} + interface CronJob { id: string; name: string; @@ -270,6 +282,14 @@ interface SkillUploadResponse { skills: WorkspaceSkillOption[]; } +interface MarketSkillInstallResponse { + status: string; + bot_id: string; + skill_market_item_id: number; + installed: string[]; + skills: WorkspaceSkillOption[]; +} + interface SystemDefaultsResponse { limits?: { upload_max_mb?: number; @@ -448,6 +468,32 @@ function formatConversationDate(ts: number, isZh: boolean) { } } +function formatDateInputValue(ts: number): string { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function mapBotMessageResponseRow(row: any): ChatMessage { + const roleRaw = String(row?.role || '').toLowerCase(); + const role: ChatMessage['role'] = + roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; + const feedbackRaw = String(row?.feedback || '').trim().toLowerCase(); + const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null; + return { + id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined, + role, + text: String(row?.text || ''), + attachments: normalizeAttachmentPaths(row?.media), + ts: Number(row?.ts || Date.now()), + feedback, + kind: 'final', + } as ChatMessage; +} + function stateLabel(s?: string) { return (s || 'IDLE').toUpperCase(); } @@ -1034,6 +1080,8 @@ export function BotDashboardModule({ const [showChannelModal, setShowChannelModal] = useState(false); const [showTopicModal, setShowTopicModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false); + const [showSkillMarketInstallModal, setShowSkillMarketInstallModal] = useState(false); + const [skillAddMenuOpen, setSkillAddMenuOpen] = useState(false); const [showMcpModal, setShowMcpModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false); @@ -1082,6 +1130,7 @@ export function BotDashboardModule({ const [expandedChannelByKey, setExpandedChannelByKey] = useState>({}); const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false); const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false); + const channelCreateMenuRef = useRef(null); const [newChannelDraft, setNewChannelDraft] = useState({ id: 'draft-channel', bot_id: '', @@ -1098,6 +1147,7 @@ export function BotDashboardModule({ const [topicPresetTemplates, setTopicPresetTemplates] = useState([]); const [newTopicSource, setNewTopicSource] = useState(''); const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false); + const topicPresetMenuRef = useRef(null); const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false); const [newTopicKey, setNewTopicKey] = useState(''); const [newTopicName, setNewTopicName] = useState(''); @@ -1109,8 +1159,12 @@ export function BotDashboardModule({ const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState(''); const [newTopicPriority, setNewTopicPriority] = useState('50'); const [botSkills, setBotSkills] = useState([]); + const [marketSkills, setMarketSkills] = useState([]); const [isSkillUploading, setIsSkillUploading] = useState(false); + const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false); + const [marketSkillInstallingId, setMarketSkillInstallingId] = useState(null); const skillZipPickerRef = useRef(null); + const skillAddMenuRef = useRef(null); const [envParams, setEnvParams] = useState({}); const [mcpServers, setMcpServers] = useState([]); const [persistedMcpServers, setPersistedMcpServers] = useState([]); @@ -1129,8 +1183,6 @@ export function BotDashboardModule({ const [expandedMcpByKey, setExpandedMcpByKey] = useState>({}); const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftValue, setEnvDraftValue] = useState(''); - const [envDraftVisible, setEnvDraftVisible] = useState(false); - const [envVisibleByKey, setEnvVisibleByKey] = useState>({}); const [isSavingChannel, setIsSavingChannel] = useState(false); const [isSavingTopic, setIsSavingTopic] = useState(false); const [isSavingMcp, setIsSavingMcp] = useState(false); @@ -1151,6 +1203,11 @@ export function BotDashboardModule({ const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatHasMore, setChatHasMore] = useState(false); const [chatLoadingMore, setChatLoadingMore] = useState(false); + const [chatDatePickerOpen, setChatDatePickerOpen] = useState(false); + const [chatDateValue, setChatDateValue] = useState(''); + const [chatDateJumping, setChatDateJumping] = useState(false); + const [chatJumpAnchorId, setChatJumpAnchorId] = useState(null); + const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null); const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState( DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ); @@ -1197,6 +1254,8 @@ export function BotDashboardModule({ const voiceTimerRef = useRef(null); const runtimeMenuRef = useRef(null); const botListMenuRef = useRef(null); + const controlCommandPanelRef = useRef(null); + const chatDateTriggerRef = useRef(null); const botOrderRef = useRef>({}); const nextBotOrderRef = useRef(1); const applyEditFormFromBot = useCallback((bot?: any) => { @@ -1598,6 +1657,9 @@ export function BotDashboardModule({ const isZh = locale === 'zh'; const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); + const passwordToggleLabels = isZh + ? { show: '显示密码', hide: '隐藏密码' } + : { show: 'Show password', hide: 'Hide password' }; const activeTopicOptions = useMemo( () => topics @@ -1818,7 +1880,10 @@ export function BotDashboardModule({ const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; return ( -
+
{showDateDivider ? (
{formatConversationDate(item.ts, isZh)} @@ -2114,9 +2179,32 @@ export function BotDashboardModule({ if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) { setBotListMenuOpen(false); } + if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) { + setChatDatePickerOpen(false); + } + if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) { + setChannelCreateMenuOpen(false); + } + if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) { + setTopicPresetMenuOpen(false); + } + if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) { + setSkillAddMenuOpen(false); + } + }; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key !== 'Escape') return; + setChatDatePickerOpen(false); + setChannelCreateMenuOpen(false); + setTopicPresetMenuOpen(false); + setSkillAddMenuOpen(false); }; document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; }, []); useEffect(() => { @@ -2261,6 +2349,15 @@ export function BotDashboardModule({ }; const refresh = async () => { + const forced = String(forcedBotId || '').trim(); + if (forced) { + const targetId = String(selectedBotId || forced).trim() || forced; + const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`); + setBots(botRes.data ? [botRes.data] : []); + await loadImageOptions(); + return; + } + const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(botsRes.data); await loadImageOptions(); @@ -2996,6 +3093,20 @@ export function BotDashboardModule({ setBotSkills(Array.isArray(res.data) ? res.data : []); }; + const loadMarketSkills = async (botId: string) => { + if (!botId) return; + setIsMarketSkillsLoading(true); + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`); + setMarketSkills(Array.isArray(res.data) ? res.data : []); + } catch (error: any) { + setMarketSkills([]); + notify(error?.response?.data?.detail || t.toolsLoadFail, { tone: 'error' }); + } finally { + setIsMarketSkillsLoading(false); + } + }; + const loadBotEnvParams = async (botId: string) => { if (!botId) return; try { @@ -3189,11 +3300,39 @@ export function BotDashboardModule({ try { await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`); await loadBotSkills(selectedBot.id); + await loadMarketSkills(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' }); } }; + const installMarketSkill = async (marketSkill: BotSkillMarketItem) => { + if (!selectedBot) return; + const skillId = Number(marketSkill.id); + if (!Number.isFinite(skillId) || skillId <= 0) return; + setMarketSkillInstallingId(skillId); + try { + const res = await axios.post( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skill-market/${skillId}/install`, + ); + setBotSkills(Array.isArray(res.data?.skills) ? res.data.skills : []); + await loadMarketSkills(selectedBot.id); + notify( + isZh + ? `已安装技能:${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}` + : `Installed: ${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`, + { tone: 'success' }, + ); + } catch (error: any) { + notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); + if (selectedBot) { + await loadMarketSkills(selectedBot.id); + } + } finally { + setMarketSkillInstallingId(null); + } + }; + const triggerSkillZipUpload = () => { if (!selectedBot || isSkillUploading) return; skillZipPickerRef.current?.click(); @@ -3218,6 +3357,7 @@ export function BotDashboardModule({ ); const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : []; setBotSkills(nextSkills); + await loadMarketSkills(selectedBot.id); } catch (error: any) { notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); } finally { @@ -3420,7 +3560,7 @@ export function BotDashboardModule({ if (ctype === 'telegram') { return ( <> - onPatch({ app_secret: e.target.value })} autoComplete="new-password" /> + onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> - onPatch({ app_secret: e.target.value })} autoComplete="new-password" /> + onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" /> onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" /> @@ -3458,7 +3598,7 @@ export function BotDashboardModule({ return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> - onPatch({ app_secret: e.target.value })} autoComplete="new-password" /> + onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } @@ -3467,7 +3607,7 @@ export function BotDashboardModule({ return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> - onPatch({ app_secret: e.target.value })} autoComplete="new-password" /> + onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } @@ -3476,7 +3616,7 @@ export function BotDashboardModule({ return ( <> onPatch({ external_app_id: e.target.value })} autoComplete="off" /> - onPatch({ app_secret: e.target.value })} autoComplete="new-password" /> + onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} /> ); } @@ -3529,7 +3669,7 @@ export function BotDashboardModule({
- onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" /> + onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
@@ -3560,7 +3700,7 @@ export function BotDashboardModule({
- onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" /> + onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />