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() {
-
{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 ? (