v0.1.4-p3

main
mula.liu 2026-03-19 16:53:44 +08:00
parent cf354d6bed
commit e766025c94
18 changed files with 3407 additions and 291 deletions

View File

@ -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:

View File

@ -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,

View File

@ -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)

View File

@ -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 控制平台。

View File

@ -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;
}
}

View File

@ -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 (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
@ -140,12 +155,18 @@ function AuthenticatedApp() {
<div className="app-title">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main">
<h1>{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}</h1>
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
</div>
<h1>{headerTitle}</h1>
{route.kind === 'dashboard-skills' ? (
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
</button>
) : (
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
</div>
)}
{isCompactShell ? (
<button
type="button"
@ -201,6 +222,8 @@ function AuthenticatedApp() {
<main className="main-stage">
{route.kind === 'dashboard' ? (
<PlatformDashboardPage compactMode={compactMode} />
) : route.kind === 'dashboard-skills' ? (
<SkillMarketManagerPage isZh={locale === 'zh'} />
) : (
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
)}
@ -214,9 +237,8 @@ function AuthenticatedApp() {
<h1>{forcedBot?.name || forcedBotId}</h1>
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
<div className="app-login-form">
<input
<PasswordInput
className="input"
type="password"
value={singleBotPassword}
onChange={(event) => {
setSingleBotPassword(event.target.value);
@ -227,6 +249,7 @@ function AuthenticatedApp() {
}}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus
toggleLabels={passwordToggleLabels}
/>
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
@ -250,6 +273,9 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const bypassPanelGate = route.kind === 'bot';
useEffect(() => {
@ -344,15 +370,15 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form">
<input
<PasswordInput
className="input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit();
}}
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
toggleLabels={passwordToggleLabels}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>

View File

@ -0,0 +1,38 @@
.password-input-wrap {
position: relative;
display: block;
width: 100%;
min-width: 0;
}
.password-input-wrap.is-inline {
flex: 1 1 auto;
}
.password-input-toggle {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.password-input-toggle:hover:not(:disabled) {
color: var(--text);
background: color-mix(in oklab, var(--brand-soft) 26%, transparent);
}
.password-input-toggle:disabled {
cursor: not-allowed;
opacity: 0.5;
}

View File

@ -0,0 +1,60 @@
import { forwardRef, useMemo, useState, type InputHTMLAttributes } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import './PasswordInput.css';
interface PasswordToggleLabels {
show: string;
hide: string;
}
interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
wrapperClassName?: string;
toggleClassName?: string;
toggleLabels?: PasswordToggleLabels;
}
function joinClassNames(...items: Array<string | false | null | undefined>): string {
return items.filter(Boolean).join(' ');
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(
{
className,
wrapperClassName,
toggleClassName,
style,
disabled,
toggleLabels,
...props
},
ref,
) {
const [visible, setVisible] = useState(false);
const labels = useMemo<PasswordToggleLabels>(
() => toggleLabels || { show: 'Show password', hide: 'Hide password' },
[toggleLabels],
);
return (
<span className={joinClassNames('password-input-wrap', wrapperClassName)}>
<input
{...props}
ref={ref}
type={visible ? 'text' : 'password'}
disabled={disabled}
className={joinClassNames('password-input-field', className)}
style={{ ...style, paddingRight: 40 }}
/>
<button
type="button"
className={joinClassNames('password-input-toggle', toggleClassName)}
onClick={() => setVisible((value) => !value)}
aria-label={visible ? labels.hide : labels.show}
title={visible ? labels.hide : labels.show}
disabled={disabled}
>
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</span>
);
});

View File

@ -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;

View File

@ -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<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
id: 'draft-channel',
bot_id: '',
@ -1098,6 +1147,7 @@ export function BotDashboardModule({
const [topicPresetTemplates, setTopicPresetTemplates] = useState<TopicPresetTemplate[]>([]);
const [newTopicSource, setNewTopicSource] = useState<string>('');
const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false);
const topicPresetMenuRef = useRef<HTMLDivElement | null>(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<WorkspaceSkillOption[]>([]);
const [marketSkills, setMarketSkills] = useState<BotSkillMarketItem[]>([]);
const [isSkillUploading, setIsSkillUploading] = useState(false);
const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false);
const [marketSkillInstallingId, setMarketSkillInstallingId] = useState<number | null>(null);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
const [envParams, setEnvParams] = useState<BotEnvParams>({});
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
@ -1129,8 +1183,6 @@ export function BotDashboardModule({
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
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<number | null>(null);
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
);
@ -1197,6 +1254,8 @@ export function BotDashboardModule({
const voiceTimerRef = useRef<number | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const botListMenuRef = useRef<HTMLDivElement | null>(null);
const controlCommandPanelRef = useRef<HTMLDivElement | null>(null);
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
const botOrderRef = useRef<Record<string, number>>({});
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<TopicFeedOption[]>(
() =>
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 (
<div key={itemKey}>
<div
key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined}
>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
@ -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<BotSkillMarketItem[]>(`${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<MarketSkillInstallResponse>(
`${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 (
<>
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input
className="input"
placeholder={lc.proxy}
@ -3447,7 +3587,7 @@ export function BotDashboardModule({
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
</>
@ -3458,7 +3598,7 @@ export function BotDashboardModule({
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -3467,7 +3607,7 @@ export function BotDashboardModule({
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -3476,7 +3616,7 @@ export function BotDashboardModule({
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -3529,7 +3669,7 @@ export function BotDashboardModule({
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPassword}</label>
<input className="input" type="password" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" />
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapMailbox}</label>
@ -3560,7 +3700,7 @@ export function BotDashboardModule({
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPassword}</label>
<input className="input" type="password" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" />
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
@ -3929,6 +4069,7 @@ export function BotDashboardModule({
setPendingAttachments([]);
setQuotedReply(null);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) {
@ -3943,6 +4084,73 @@ export function BotDashboardModule({
}
};
const updateChatDatePanelPosition = useCallback(() => {
const trigger = chatDateTriggerRef.current;
if (!trigger || typeof window === 'undefined') return;
const rect = trigger.getBoundingClientRect();
const viewportPadding = 12;
setChatDatePanelPosition({
bottom: Math.max(viewportPadding, window.innerHeight - rect.top + 8),
right: Math.max(viewportPadding, window.innerWidth - rect.right),
});
}, []);
const toggleChatDatePicker = () => {
if (!selectedBotId || chatDateJumping) return;
if (!chatDateValue) {
const fallbackTs = conversation[conversation.length - 1]?.ts || Date.now();
setChatDateValue(formatDateInputValue(fallbackTs));
}
setChatDatePickerOpen((prev) => {
const next = !prev;
if (!next) {
setChatDatePanelPosition(null);
return next;
}
updateChatDatePanelPosition();
return next;
});
};
const jumpConversationToDate = async () => {
if (!selectedBotId || chatDateJumping) return;
const safeDate = String(chatDateValue || '').trim();
if (!safeDate) {
notify(isZh ? '请选择日期。' : 'Choose a date first.', { tone: 'warning' });
return;
}
try {
setChatDateJumping(true);
const result = await fetchBotMessagesByDate(selectedBotId, safeDate);
if (result.items.length <= 0) {
notify(isZh ? '该日期附近没有可显示的对话。' : 'No conversation found near that date.', { tone: 'warning' });
return;
}
setBotMessages(selectedBotId, result.items);
setChatHasMore(Boolean(result.hasMoreBefore));
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDatePanelPosition(null);
setControlCommandPanelOpen(false);
setChatJumpAnchorId(result.anchorId);
chatAutoFollowRef.current = false;
if (!result.matchedExactDate && result.resolvedTs) {
notify(
isZh
? `所选日期没有消息,已定位到 ${formatConversationDate(result.resolvedTs, true)}`
: `No messages on that date. Jumped to ${formatConversationDate(result.resolvedTs, false)}.`,
{ tone: 'warning' },
);
}
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '按日期读取对话失败。' : 'Failed to load conversation by date.'), {
tone: 'error',
});
} finally {
setChatDateJumping(false);
}
};
const interruptExecution = async () => {
if (!selectedBot || !canChat) return;
if (interruptingByBot[selectedBot.id]) return;
@ -3956,6 +4164,7 @@ export function BotDashboardModule({
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
@ -4010,22 +4219,7 @@ export function BotDashboardModule({
});
const rows = Array.isArray(res.data) ? res.data : [];
return rows
.map((row) => {
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;
})
.map((row) => mapBotMessageResponseRow(row))
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-safeLimit);
}, [chatPullPageSize]);
@ -4051,22 +4245,7 @@ export function BotDashboardModule({
);
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows
.map((row) => {
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;
})
.map((row) => mapBotMessageResponseRow(row))
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const nextBeforeRaw = Number(res.data?.next_before_id);
const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null;
@ -4077,6 +4256,44 @@ export function BotDashboardModule({
};
}, [chatPullPageSize]);
const fetchBotMessagesByDate = useCallback(async (
botId: string,
dateValue: string,
): Promise<{ items: ChatMessage[]; anchorId: number | null; resolvedTs: number | null; matchedExactDate: boolean; hasMoreBefore: boolean }> => {
const safeDate = String(dateValue || '').trim();
if (!safeDate) {
return {
items: [],
anchorId: null,
resolvedTs: null,
matchedExactDate: false,
hasMoreBefore: false,
};
}
const safeLimit = Math.max(40, Math.min(180, chatPullPageSize));
const tzOffsetMinutes = new Date().getTimezoneOffset();
const res = await axios.get<BotMessagesByDateResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages/by-date`, {
params: {
date: safeDate,
tz_offset_minutes: tzOffsetMinutes,
limit: safeLimit,
},
});
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows
.map((row) => mapBotMessageResponseRow(row))
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const anchorRaw = Number(res.data?.anchor_id);
const resolvedRaw = Number(res.data?.resolved_ts);
return {
items,
anchorId: Number.isFinite(anchorRaw) && anchorRaw > 0 ? Math.floor(anchorRaw) : null,
resolvedTs: Number.isFinite(resolvedRaw) && resolvedRaw > 0 ? Math.floor(resolvedRaw) : null,
matchedExactDate: Boolean(res.data?.matched_exact_date),
hasMoreBefore: Boolean(res.data?.has_more_before),
};
}, [chatPullPageSize]);
const loadMoreChatMessages = useCallback(async () => {
if (!selectedBotId || chatLoadingMore || !chatHasMore) return;
const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress');
@ -4133,6 +4350,24 @@ export function BotDashboardModule({
}
}, [chatHasMore, chatLoadingMore, loadMoreChatMessages]);
useEffect(() => {
if (!selectedBotId || !chatJumpAnchorId) return;
const anchorSelector = `[data-chat-message-id="${chatJumpAnchorId}"]`;
const scrollToAnchor = () => {
const box = chatScrollRef.current;
if (!box) return;
const anchor = box.querySelector<HTMLElement>(anchorSelector);
if (anchor) {
anchor.scrollIntoView({ block: 'start' });
} else {
box.scrollTop = 0;
}
setChatJumpAnchorId(null);
};
const raf = window.requestAnimationFrame(scrollToAnchor);
return () => window.cancelAnimationFrame(raf);
}, [selectedBotId, chatJumpAnchorId, messages.length]);
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' });
@ -4503,6 +4738,10 @@ export function BotDashboardModule({
if (!selectedBotId) {
setChatHasMore(false);
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
@ -4527,6 +4766,9 @@ export function BotDashboardModule({
setPendingAttachments([]);
setCronJobs([]);
setBotSkills([]);
setMarketSkills([]);
setShowSkillMarketInstallModal(false);
setSkillAddMenuOpen(false);
setEnvParams({});
setExpandedMcpByKey({});
setNewMcpPanelOpen(false);
@ -4542,6 +4784,10 @@ export function BotDashboardModule({
}
setChatHasMore(false);
setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
@ -4587,6 +4833,12 @@ export function BotDashboardModule({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
useEffect(() => {
if (!selectedBotId || chatDateValue) return;
const fallbackTs = messages[messages.length - 1]?.ts || Date.now();
setChatDateValue(formatDateInputValue(fallbackTs));
}, [selectedBotId, chatDateValue, messages]);
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
let stopped = false;
@ -4608,6 +4860,29 @@ export function BotDashboardModule({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);
useEffect(() => {
if (controlCommandPanelOpen) return;
setChatDatePickerOpen(false);
setChatDatePanelPosition(null);
}, [controlCommandPanelOpen]);
useEffect(() => {
if (chatDatePickerOpen) return;
setChatDatePanelPosition(null);
}, [chatDatePickerOpen]);
useEffect(() => {
if (!chatDatePickerOpen) return;
updateChatDatePanelPosition();
const handleViewportChange = () => updateChatDatePanelPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [chatDatePickerOpen, updateChatDatePanelPosition]);
useEffect(() => {
if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return;
const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey);
@ -5341,7 +5616,7 @@ export function BotDashboardModule({
style={{ display: 'none' }}
/>
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls">
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
@ -5376,11 +5651,63 @@ export function BotDashboardModule({
{interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!selectedBotId || chatDateJumping}
onClick={toggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={formatDateInputValue(Date.now())}
onChange={(event) => setChatDateValue(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setChatDatePickerOpen(false)}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={() => void jumpConversationToDate()}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={() => setControlCommandPanelOpen((prev) => !prev)}
onClick={() => {
setChatDatePickerOpen(false);
setControlCommandPanelOpen((prev) => !prev);
}}
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
>
@ -5931,12 +6258,12 @@ export function BotDashboardModule({
<input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} />
<label className="field-label">{t.accessPassword}</label>
<input
<PasswordInput
className="input"
type="password"
value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder}
toggleLabels={passwordToggleLabels}
/>
<label className="field-label">{t.baseImageReadonly}</label>
@ -6018,7 +6345,7 @@ export function BotDashboardModule({
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
<label className="field-label">{t.newApiKey}</label>
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
<PasswordInput className="input" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} toggleLabels={passwordToggleLabels} />
<label className="field-label">API Base</label>
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
@ -6272,7 +6599,7 @@ export function BotDashboardModule({
{!newChannelPanelOpen ? (
<div className="row-between ops-config-footer">
<span className="field-label">{lc.channelAddHint}</span>
<div className="ops-topic-create-menu-wrap">
<div className="ops-topic-create-menu-wrap" ref={channelCreateMenuRef}>
<button
className="btn btn-secondary btn-sm"
disabled={addableChannelTypes.length === 0 || isSavingChannel}
@ -6643,7 +6970,7 @@ export function BotDashboardModule({
{!newTopicPanelOpen ? (
<div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span>
<div className="ops-topic-create-menu-wrap">
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
<button
className="btn btn-secondary btn-sm"
disabled={isSavingTopic || !selectedBot}
@ -6670,46 +6997,81 @@ export function BotDashboardModule({
)}
{showSkillsModal && (
<div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
<div className="modal-mask" onClick={() => {
setSkillAddMenuOpen(false);
setShowSkillsModal(false);
}}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.skillsPanel}</h3>
<span className="modal-sub">
{isZh ? '查看当前 Bot 已安装的技能。' : 'View the skills already installed for this bot.'}
</span>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} tooltip={t.close} aria-label={t.close}>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
if (!selectedBot) return;
void loadBotSkills(selectedBot.id);
}}
tooltip={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
aria-label={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
>
<RefreshCw size={14} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => {
setSkillAddMenuOpen(false);
setShowSkillsModal(false);
}} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="wizard-channel-list ops-skills-list-scroll">
{botSkills.length === 0 ? (
<div className="ops-empty-inline">{t.skillsEmpty}</div>
) : (
botSkills.map((skill) => (
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<div>
<strong>{skill.name || skill.id}</strong>
<div className="field-label mono">{skill.path}</div>
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
<div className="field-label">{skill.description || '-'}</div>
</div>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void removeBotSkill(skill)}
tooltip={t.removeSkill}
aria-label={t.removeSkill}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
<div className="stack">
<div className="row-between">
<div>
<div className="section-mini-title">{isZh ? '已安装技能' : 'Installed Skills'}</div>
<div className="field-label">
{isZh ? '这里展示当前 Bot 工作区中的技能。' : 'These skills are already present in the bot workspace.'}
</div>
))
)}
</div>
<div className="field-label">
{isZh ? `${botSkills.length} 个已安装` : `${botSkills.length} installed`}
</div>
</div>
<div className="wizard-channel-list ops-skills-list-scroll">
{botSkills.length === 0 ? (
<div className="ops-empty-inline">{t.skillsEmpty}</div>
) : (
botSkills.map((skill) => (
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<div>
<strong>{skill.name || skill.id}</strong>
<div className="field-label mono">{skill.path}</div>
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
<div className="field-label">{skill.description || '-'}</div>
</div>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => void removeBotSkill(skill)}
tooltip={t.removeSkill}
aria-label={t.removeSkill}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
))
)}
</div>
</div>
<div className="row-between">
<div className="ops-skill-add-bar">
<input
ref={skillZipPickerRef}
type="file"
@ -6717,24 +7079,73 @@ export function BotDashboardModule({
onChange={onPickSkillZip}
style={{ display: 'none' }}
/>
<button
className="btn btn-secondary btn-sm"
disabled={isSkillUploading}
onClick={triggerSkillZipUpload}
title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
>
{isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: isSkillUploading ? 6 : 0 }}>
{isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
</span>
</button>
<span className="field-label">{t.zipOnlyHint}</span>
<div className="field-label ops-skill-add-hint">
{isSkillUploading
? (isZh ? '正在上传 ZIP 技能包...' : 'Uploading ZIP skill package...')
: (isZh ? '支持上传本地 ZIP或从技能市场安装技能到当前 Bot。' : 'Upload a local ZIP or install a skill from the marketplace into this bot.')}
</div>
<div className="ops-topic-create-menu-wrap" ref={skillAddMenuRef}>
<button
type="button"
className="btn btn-secondary btn-sm ops-skill-create-trigger"
onClick={() => setSkillAddMenuOpen((prev) => !prev)}
disabled={!selectedBot}
>
<Plus size={18} />
<span>{isZh ? '新增技能' : 'Add Skill'}</span>
</button>
{skillAddMenuOpen ? (
<div className="ops-topic-create-menu">
<button
className="ops-topic-create-menu-item"
type="button"
onClick={() => {
setSkillAddMenuOpen(false);
triggerSkillZipUpload();
}}
>
{isZh ? '本地上传 ZIP' : 'Upload Local ZIP'}
</button>
<button
className="ops-topic-create-menu-item"
type="button"
onClick={() => {
if (!selectedBot) return;
setSkillAddMenuOpen(false);
void loadMarketSkills(selectedBot.id);
setShowSkillMarketInstallModal(true);
}}
>
{isZh ? '从技能市场安装' : 'Install From Marketplace'}
</button>
</div>
) : null}
</div>
</div>
</div>
</div>
)}
<SkillMarketInstallModal
isZh={isZh}
open={showSkillMarketInstallModal}
items={marketSkills}
loading={isMarketSkillsLoading}
installingId={marketSkillInstallingId}
onClose={() => setShowSkillMarketInstallModal(false)}
onRefresh={async () => {
if (!selectedBot) return;
await loadMarketSkills(selectedBot.id);
}}
onInstall={async (skill) => {
await installMarketSkill(skill);
if (selectedBot) {
await loadBotSkills(selectedBot.id);
}
}}
formatBytes={formatBytes}
/>
{showMcpModal && (
<div
className="modal-mask"
@ -6845,14 +7256,14 @@ export function BotDashboardModule({
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label>
<input
<PasswordInput
className="input"
type="password"
value={row.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })}
autoComplete="new-password"
disabled={row.locked}
toggleLabels={passwordToggleLabels}
/>
</div>
<div className="ops-config-field">
@ -6948,13 +7359,13 @@ export function BotDashboardModule({
</div>
<div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label>
<input
<PasswordInput
className="input"
type="password"
value={newMcpDraft.botSecret}
placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))}
autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/>
</div>
<div className="ops-config-field">
@ -7024,22 +7435,18 @@ export function BotDashboardModule({
<div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
<input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} />
<input
<PasswordInput
className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={t.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: t.showEnvValue,
hide: t.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
tooltip={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
@ -7062,22 +7469,18 @@ export function BotDashboardModule({
placeholder={t.envKey}
autoComplete="off"
/>
<input
<PasswordInput
className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={t.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: t.showEnvValue,
hide: t.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
tooltip={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {

View File

@ -0,0 +1,212 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, Hammer, RefreshCw, Search, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotSkillMarketItem } from '../../platform/types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
writeCachedPlatformPageSize,
} from '../../../utils/platformPageSize';
interface SkillMarketInstallModalProps {
isZh: boolean;
open: boolean;
items: BotSkillMarketItem[];
loading: boolean;
installingId: number | null;
onClose: () => void;
onRefresh: () => Promise<void> | void;
onInstall: (item: BotSkillMarketItem) => Promise<void> | void;
formatBytes: (bytes: number) => string;
}
export function SkillMarketInstallModal({
isZh,
open,
items,
loading,
installingId,
onClose,
onRefresh,
onInstall,
formatBytes,
}: SkillMarketInstallModalProps) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => readCachedPlatformPageSize(10));
useEffect(() => {
if (!open) return;
setSearch('');
setPage(1);
void onRefresh();
void (async () => {
try {
const res = await axios.get<{ page_size?: number }>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
const normalized = normalizePlatformPageSize(res.data?.page_size, readCachedPlatformPageSize(10));
writeCachedPlatformPageSize(normalized);
setPageSize(normalized);
} catch {
setPageSize(readCachedPlatformPageSize(10));
}
})();
}, [open]);
useEffect(() => {
setPage(1);
}, [search, pageSize]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.display_name, item.skill_key, item.description, item.zip_filename].some((value) =>
String(value || '').toLowerCase().includes(keyword),
),
);
}, [items, search]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pagedItems = useMemo(
() => filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[currentPage, filteredItems, pageSize],
);
if (!open) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide platform-modal skill-market-browser-shell" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '从市场安装技能' : 'Install From Marketplace'}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onRefresh()}
tooltip={isZh ? '刷新市场技能' : 'Refresh marketplace skills'}
aria-label={isZh ? '刷新市场技能' : 'Refresh marketplace skills'}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="skill-market-browser-toolbar">
<div className="ops-searchbar platform-searchbar skill-market-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => setSearch('')}
title={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
aria-label={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
>
{search.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
</div>
<div className="skill-market-browser-grid">
{loading ? (
<div className="ops-empty-inline skill-market-empty-card">{isZh ? '正在读取技能市场...' : 'Loading marketplace skills...'}</div>
) : pagedItems.length === 0 ? (
<div className="ops-empty-inline skill-market-empty-card">
{filteredItems.length === 0
? (isZh ? '没有匹配的技能。' : 'No matching skills found.')
: (isZh ? '当前页没有技能。' : 'No skills on this page.')}
</div>
) : (
pagedItems.map((skill) => {
const isInstalled = Boolean(skill.installed);
const isInstalling = installingId === skill.id;
return (
<article key={skill.id} className={`skill-market-card skill-market-browser-card ${isInstalled ? 'is-active' : ''}`}>
<div className="skill-market-card-top">
<div className="skill-market-card-title-wrap">
<h4>{skill.display_name || skill.skill_key}</h4>
<div className="skill-market-card-key mono">{skill.skill_key}</div>
</div>
<span className={`badge ${isInstalled ? 'badge-ok' : 'badge-unknown'} skill-market-browser-badge`}>
{isInstalled ? (isZh ? '已安装' : 'Installed') : (isZh ? '未安装' : 'Not installed')}
</span>
</div>
<p className="skill-market-card-desc skill-market-browser-desc">{skill.description || (isZh ? '暂无简介。' : 'No description yet.')}</p>
<div className="skill-market-card-meta skill-market-browser-meta">
<span>{isZh ? 'ZIP' : 'ZIP'}: {skill.zip_filename}</span>
<span>{isZh ? '体积' : 'Size'}: {formatBytes(skill.zip_size_bytes)}</span>
<span>{isZh ? '安装次数' : 'Installs'}: {skill.install_count}</span>
</div>
{skill.install_error && !isInstalled ? (
<div className="field-label" style={{ color: 'var(--err)' }}>
{skill.install_error}
</div>
) : null}
<div className="skill-market-card-footer skill-market-browser-footer">
<span className={skill.zip_exists ? 'skill-market-card-status is-ok' : 'skill-market-card-status is-missing'}>
{skill.zip_exists ? (isZh ? '市场包可用' : 'Package ready') : (isZh ? '市场包缺失' : 'Package missing')}
</span>
<button
className="btn btn-primary btn-sm skill-market-install-btn"
disabled={isInstalled || isInstalling || !skill.zip_exists}
onClick={() => void onInstall(skill)}
>
{isInstalling ? <RefreshCw size={14} className="animate-spin" /> : <Hammer size={14} />}
<span style={{ marginLeft: 6 }}>
{isInstalling ? (isZh ? '安装中...' : 'Installing...') : isInstalled ? (isZh ? '已安装' : 'Installed') : (isZh ? '一键安装' : 'Install')}
</span>
</button>
</div>
</article>
);
})
)}
</div>
<div className="skill-market-pager">
<span className="pager-status">
{isZh
? `${currentPage} / ${pageCount} 页,共 ${filteredItems.length} 个技能`
: `Page ${currentPage} / ${pageCount}, ${filteredItems.length} skills`}
</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</div>
</div>
);
}

View File

@ -8,6 +8,7 @@ import { managementZhCn } from '../../../i18n/management.zh-cn';
import { managementEn } from '../../../i18n/management.en';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { PasswordInput } from '../../../components/PasswordInput';
interface CreateBotModalProps {
isOpen: boolean;
@ -25,6 +26,9 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
const locale = useAppStore((s) => s.locale);
const { notify } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create;
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const [formData, setFormData] = useState({
id: '',
name: '',
@ -146,12 +150,12 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
<Key size={14} /> API Key
</label>
<input
<PasswordInput
required
type="password"
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
placeholder="sk-..."
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
toggleLabels={passwordToggleLabels}
/>
</div>

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { Eye, EyeOff, Plus, Settings2, Trash2 } from 'lucide-react';
import { Plus, Settings2, Trash2 } from 'lucide-react';
import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore';
import { channelsZhCn } from '../../i18n/channels.zh-cn';
@ -11,6 +11,7 @@ import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { PasswordInput } from '../../components/PasswordInput';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
@ -146,8 +147,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const [showToolsConfigModal, setShowToolsConfigModal] = useState(false);
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
const [form, setForm] = useState(initialForm);
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState('');
@ -162,6 +161,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const isZh = locale === 'zh';
const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn });
const lc = isZh ? channelsZhCn : channelsEn;
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
const addableChannelTypes = useMemo(
@ -518,13 +520,13 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
if (channel.channel_type === 'telegram') {
return (
<>
<input
<PasswordInput
className="input"
type="password"
placeholder={lc.telegramToken}
value={channel.app_secret}
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/>
<input
className="input"
@ -556,7 +558,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input
className="input"
placeholder={lc.encryptKey}
@ -583,7 +585,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -592,7 +594,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -601,7 +603,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
@ -723,7 +725,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<option value="xunfei">xunfei (spark)</option>
</LucentSelect>
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
<PasswordInput className="input" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} toggleLabels={passwordToggleLabels} />
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[form.llm_provider]?.note[noteLocale]}
@ -968,22 +970,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
readOnly
style={{ maxWidth: 280 }}
/>
<input
<PasswordInput
className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={ui.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: ui.showEnvValue,
hide: ui.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
tooltip={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
@ -1005,22 +1003,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
placeholder={ui.envKey}
autoComplete="off"
/>
<input
<PasswordInput
className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={ui.envValue}
autoComplete="off"
wrapperClassName="is-inline"
toggleLabels={{
show: ui.showEnvValue,
hide: ui.hideEnvValue,
}}
/>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
tooltip={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {

View File

@ -124,6 +124,12 @@ interface BotResourceSnapshot {
collected_at?: string;
}
function navigatePlatform(path: '/dashboard' | '/dashboard/skills') {
if (typeof window === 'undefined') return;
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProps) {
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
const { notify, confirm } = useLucentPrompt();
@ -625,11 +631,11 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
<button
className="platform-entry-card"
type="button"
onClick={() => notify(isZh ? '技能市场即将开放,敬请期待。' : 'Skill marketplace is coming soon.', { tone: 'warning' })}
onClick={() => navigatePlatform('/dashboard/skills')}
>
<Hammer size={18} />
<strong>{isZh ? '技能市场' : 'Skill Marketplace'}</strong>
<span>{isZh ? '入口已预留,当前版本暂未开放。' : 'This entry is reserved and will open in a future release.'}</span>
<span>{isZh ? '管理技能包元数据,并给 Bot 技能面板提供一键安装源。' : 'Manage marketplace metadata and provide one-click installs to bot skill panels.'}</span>
</button>
</div>
</section>

View File

@ -0,0 +1,519 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, FileArchive, Hammer, Pencil, Plus, RefreshCw, Search, Trash2, Upload, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import type { SkillMarketItem } from '../types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
writeCachedPlatformPageSize,
} from '../../../utils/platformPageSize';
interface SkillMarketManagerModalProps {
isZh: boolean;
open: boolean;
onClose: () => void;
}
interface SkillMarketManagerPageProps {
isZh: boolean;
}
interface SkillMarketListResponse extends Array<SkillMarketItem> {}
interface SkillDraft {
skillKey: string;
displayName: string;
description: string;
file: File | null;
}
const emptyDraft: SkillDraft = {
skillKey: '',
displayName: '',
description: '',
file: null,
};
function buildEmptyDraft(): SkillDraft {
return {
skillKey: '',
displayName: '',
description: '',
file: null,
};
}
function formatBytes(bytes: number) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
const sized = value / Math.pow(1024, index);
return `${sized >= 100 ? sized.toFixed(0) : sized >= 10 ? sized.toFixed(1) : sized.toFixed(2)} ${units[index]}`;
}
function buildDraft(item?: SkillMarketItem | null): SkillDraft {
if (!item) return emptyDraft;
return {
skillKey: item.skill_key || '',
displayName: item.display_name || '',
description: item.description || '',
file: null,
};
}
function SkillMarketManagerView({
isZh,
onClose,
embedded,
}: {
isZh: boolean;
onClose?: () => void;
embedded: boolean;
}) {
const { notify, confirm } = useLucentPrompt();
const [items, setItems] = useState<SkillMarketItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [draft, setDraft] = useState<SkillDraft>(buildEmptyDraft);
const [editorOpen, setEditorOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => readCachedPlatformPageSize(10));
const editorRef = useRef<HTMLDivElement | null>(null);
const infoTitle = isZh ? '技能市场管理说明' : 'Skill Marketplace Management';
const infoDescription = isZh
? '技能市场仅接收人工上传的 ZIP 技能包。平台将统一保存技能元数据与归档文件,并为 Bot 安装提供标准化来源,不再自动扫描 /data/skills 目录。'
: 'The marketplace accepts manually uploaded ZIP skill packages only. The platform stores the package metadata and archives as the standard source for bot installation, without auto-scanning /data/skills.';
const loadRows = async () => {
setLoading(true);
try {
const res = await axios.get<SkillMarketListResponse>(`${APP_ENDPOINTS.apiBase}/platform/skills`);
const rows = Array.isArray(res.data) ? res.data : [];
setItems(rows);
return rows;
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取技能市场失败。' : 'Failed to load the skill marketplace.'), {
tone: 'error',
});
return [] as SkillMarketItem[];
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRows();
setPage(1);
void (async () => {
try {
const res = await axios.get<{ page_size?: number }>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
const normalized = normalizePlatformPageSize(res.data?.page_size, readCachedPlatformPageSize(10));
writeCachedPlatformPageSize(normalized);
setPageSize(normalized);
} catch {
setPageSize(readCachedPlatformPageSize(10));
}
})();
}, []);
useEffect(() => {
setPage(1);
}, [search, pageSize, items.length]);
useEffect(() => {
if (!editorOpen) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (editorRef.current?.contains(target)) return;
setEditorOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setEditorOpen(false);
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [editorOpen]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.display_name, item.skill_key, item.description, item.zip_filename].some((value) =>
String(value || '').toLowerCase().includes(keyword),
),
);
}, [items, search]);
const editingItem = useMemo(
() => (editingId == null ? null : items.find((item) => item.id === editingId) || null),
[editingId, items],
);
const editorTitle = editingItem ? (isZh ? '编辑技能' : 'Edit Skill') : (isZh ? '新增技能' : 'Create Skill');
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pagedItems = useMemo(
() => filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[currentPage, filteredItems, pageSize],
);
const submit = async () => {
const displayName = draft.displayName.trim();
if (!displayName) {
notify(isZh ? '请填写技能名称。' : 'Enter a skill name.', { tone: 'warning' });
return;
}
if (editingId == null && !draft.file) {
notify(isZh ? '新增技能时必须上传 ZIP 包。' : 'A ZIP package is required when creating a skill.', {
tone: 'warning',
});
return;
}
const formData = new FormData();
formData.append('skill_key', draft.skillKey.trim());
formData.append('display_name', displayName);
formData.append('description', draft.description.trim());
if (draft.file) {
formData.append('file', draft.file);
}
setSaving(true);
try {
let savedItemId: number | null = null;
if (editingId == null) {
const created = await axios.post<SkillMarketItem>(`${APP_ENDPOINTS.apiBase}/platform/skills`, formData);
savedItemId = Number(created.data?.id || 0) || null;
notify(isZh ? '技能已加入市场。' : 'Skill added to marketplace.', { tone: 'success' });
} else {
const updated = await axios.put<SkillMarketItem>(`${APP_ENDPOINTS.apiBase}/platform/skills/${editingId}`, formData);
savedItemId = Number(updated.data?.id || editingId || 0) || null;
notify(isZh ? '技能已更新。' : 'Skill updated.', { tone: 'success' });
}
setSearch('');
setDraft(buildEmptyDraft());
setEditingId(null);
setEditorOpen(false);
const rows = await loadRows();
if (savedItemId) {
const nextIndex = rows.findIndex((row) => row.id === savedItemId);
if (nextIndex >= 0) {
setPage(Math.floor(nextIndex / pageSize) + 1);
}
}
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '保存技能失败。' : 'Failed to save skill.'), { tone: 'error' });
} finally {
setSaving(false);
}
};
const removeItem = async (item: SkillMarketItem) => {
const ok = await confirm({
title: isZh ? '删除技能' : 'Delete Skill',
message: isZh ? `确认删除技能 ${item.display_name}` : `Delete skill ${item.display_name}?`,
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/platform/skills/${item.id}`);
if (editingId === item.id) {
setEditingId(null);
setDraft(buildEmptyDraft());
setEditorOpen(false);
}
await loadRows();
notify(isZh ? '技能已删除。' : 'Skill deleted.', { tone: 'success' });
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '删除技能失败。' : 'Failed to delete skill.'), { tone: 'error' });
}
};
const onPickFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
if (file && !String(file.name || '').toLowerCase().endsWith('.zip')) {
notify(isZh ? '只能上传 .zip 文件。' : 'Only .zip files are allowed.', { tone: 'warning' });
event.target.value = '';
return;
}
setDraft((prev) => ({ ...prev, file }));
};
const openCreateDrawer = () => {
setEditingId(null);
setDraft(buildEmptyDraft());
setEditorOpen(true);
};
const openEditDrawer = (item: SkillMarketItem) => {
setEditingId(item.id);
setDraft(buildDraft(item));
setEditorOpen(true);
};
const content = (
<div className={embedded ? 'panel stack skill-market-page-shell' : 'modal-card modal-wide platform-modal skill-market-modal-shell'}>
{!embedded ? (
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '技能市场管理' : 'Skill Marketplace'}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
) : null}
<div className="platform-settings-info-card skill-market-page-info-card">
<div className="skill-market-page-info-main">
<div className="platform-settings-info-icon">
<Hammer size={18} />
</div>
<div className="skill-market-page-info-copy">
<strong>{infoTitle}</strong>
<div>{infoDescription}</div>
</div>
</div>
</div>
<div className="skill-market-admin-toolbar">
<div className="ops-searchbar platform-searchbar skill-market-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
aria-label={isZh ? '搜索技能市场' : 'Search skill marketplace'}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => setSearch('')}
title={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
aria-label={search.trim() ? (isZh ? '清空搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
>
{search.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
<div className="skill-market-admin-actions">
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadRows()}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: 6 }}>{isZh ? '刷新' : 'Refresh'}</span>
</button>
<button className="btn btn-secondary btn-sm skill-market-create-btn" type="button" onClick={openCreateDrawer}>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{isZh ? '新增技能' : 'Add Skill'}</span>
</button>
</div>
</div>
<div className="skill-market-page-workspace">
<div className="skill-market-card-grid skill-market-list-shell">
{pagedItems.length === 0 ? (
<div className="ops-empty-inline skill-market-empty-card">
{filteredItems.length === 0
? (isZh ? '当前没有可展示的技能。' : 'No skills available right now.')
: (isZh ? '当前页没有技能。' : 'No skills on this page.')}
</div>
) : (
pagedItems.map((item) => (
<article key={item.id} className={`skill-market-card ${editingId === item.id ? 'is-active' : ''}`}>
<div className="skill-market-card-top">
<div className="skill-market-card-title-wrap">
<h4>{item.display_name || item.skill_key}</h4>
<div className="skill-market-card-key mono">{item.skill_key}</div>
</div>
<div className="skill-market-card-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => openEditDrawer(item)}
tooltip={isZh ? '编辑技能' : 'Edit skill'}
aria-label={isZh ? '编辑技能' : 'Edit skill'}
>
<Pencil size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => void removeItem(item)}
tooltip={isZh ? '删除技能' : 'Delete skill'}
aria-label={isZh ? '删除技能' : 'Delete skill'}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
<p className="skill-market-card-desc">{item.description || (isZh ? '暂无简介。' : 'No description yet.')}</p>
<div className="skill-market-card-meta">
<span><FileArchive size={13} /> {item.zip_filename}</span>
<span><Upload size={13} /> {formatBytes(item.zip_size_bytes)}</span>
</div>
<div className="skill-market-card-footer">
<span>{isZh ? `安装 ${item.install_count}` : `${item.install_count} installs`}</span>
<span className={item.zip_exists ? 'skill-market-card-status is-ok' : 'skill-market-card-status is-missing'}>
{item.zip_exists ? (isZh ? 'ZIP 可用' : 'ZIP ready') : (isZh ? 'ZIP 缺失' : 'ZIP missing')}
</span>
</div>
</article>
))
)}
</div>
</div>
<div className="skill-market-pager">
<span className="pager-status">
{isZh
? `${currentPage} / ${pageCount} 页,共 ${filteredItems.length} 个技能`
: `Page ${currentPage} / ${pageCount}, ${filteredItems.length} skills`}
</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
<div className={`skill-market-drawer-mask ${editorOpen ? 'is-open' : ''}`} onClick={() => setEditorOpen(false)} />
<aside className={`skill-market-drawer ${editorOpen ? 'is-open' : ''}`} aria-hidden={!editorOpen}>
<div ref={editorRef} className="skill-market-editor card" onClick={(event) => event.stopPropagation()}>
<div className="skill-market-editor-head">
<div>
<div className="section-mini-title">{editorTitle}</div>
<div className="field-label">
{editingItem
? (isZh ? '可以更新名称、介绍与归档 ZIP。' : 'Update the name, description, and archived ZIP package here.')
: (isZh ? '上传技能 ZIP 后,会立即生成可管理的市场卡片。' : 'Upload a skill ZIP package to create a manageable marketplace card immediately.')}
</div>
</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEditorOpen(false)}
tooltip={isZh ? '关闭面板' : 'Close panel'}
aria-label={isZh ? '关闭面板' : 'Close panel'}
>
<X size={14} />
</LucentIconButton>
</div>
<label className="field-label">{isZh ? '技能标识' : 'Skill Key'}</label>
<input
className="input mono"
value={draft.skillKey}
onChange={(event) => setDraft((prev) => ({ ...prev, skillKey: event.target.value }))}
placeholder={isZh ? '留空则按名称或 ZIP 自动生成' : 'Optional. Auto-generated from name or ZIP if empty'}
/>
<label className="field-label">{isZh ? '技能名称' : 'Skill Name'}</label>
<input
className="input"
value={draft.displayName}
onChange={(event) => setDraft((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder={isZh ? '例如 Self Improving Agent' : 'e.g. Self Improving Agent'}
/>
<label className="field-label">{isZh ? '技能介绍' : 'Description'}</label>
<textarea
className="textarea md-area skill-market-editor-textarea"
rows={7}
value={draft.description}
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
placeholder={isZh ? '描述该技能的用途、适用场景和安装说明。' : 'Describe what the skill does, when to use it, and any install notes.'}
/>
<label className="field-label">{editingItem ? (isZh ? '替换 ZIP 包' : 'Replace ZIP Package') : (isZh ? 'ZIP 技能包' : 'ZIP Package')}</label>
<div className={`skill-market-upload-card ${draft.file ? 'has-file' : ''}`}>
<label className="skill-market-file-picker">
<input
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={onPickFile}
/>
<span className="skill-market-file-picker-copy">
<span className="skill-market-file-picker-title">
{isZh ? '请选择本地技能包(.zip格式' : 'Please select a local skill package (.zip)'}
</span>
</span>
<span className="skill-market-file-picker-action">{draft.file ? (isZh ? '重新选择' : 'Choose Again') : (isZh ? '选择 ZIP' : 'Choose ZIP')}</span>
</label>
{draft.file ? (
<div className="skill-market-upload-foot">
{isZh ? `已选择文件:${draft.file.name}` : `Selected file: ${draft.file.name}`}
</div>
) : editingItem ? (
<div className="skill-market-upload-foot">
{isZh ? `当前归档文件:${editingItem.zip_filename}` : `Current archive: ${editingItem.zip_filename}`}
</div>
) : (
<div className="skill-market-upload-foot">
{isZh ? '上传后立即加入技能市场。' : 'The skill will appear in the marketplace after upload.'}
</div>
)}
</div>
<div className="row-between">
<div className="field-label">
{isZh ? '保存后将更新该技能的市场资料与归档文件。' : 'Saving updates the marketplace metadata and archived package for this skill.'}
</div>
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void submit()}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Hammer size={14} />}
<span style={{ marginLeft: 6 }}>
{saving ? (isZh ? '保存中...' : 'Saving...') : editingItem ? (isZh ? '保存修改' : 'Save Changes') : (isZh ? '创建技能' : 'Create Skill')}
</span>
</button>
</div>
</div>
</aside>
</div>
);
if (embedded) {
return content;
}
return (
<div className="modal-mask" onClick={onClose}>
<div onClick={(event) => event.stopPropagation()}>
{content}
</div>
</div>
);
}
export function SkillMarketManagerModal({ isZh, open, onClose }: SkillMarketManagerModalProps) {
if (!open) return null;
return <SkillMarketManagerView isZh={isZh} onClose={onClose} embedded={false} />;
}
export function SkillMarketManagerPage({ isZh }: SkillMarketManagerPageProps) {
return <SkillMarketManagerView isZh={isZh} embedded />;
}

View File

@ -59,6 +59,29 @@ export interface PlatformUsageResponse {
has_more: boolean;
}
export interface SkillMarketItem {
id: number;
skill_key: string;
display_name: string;
description: string;
zip_filename: string;
zip_size_bytes: number;
entry_names: string[];
entry_count: number;
zip_exists: boolean;
install_count: number;
created_at?: string | null;
updated_at?: string | null;
}
export interface BotSkillMarketItem extends SkillMarketItem {
installed?: boolean;
install_status?: string;
installed_at?: string | null;
installed_entries?: string[];
install_error?: string | null;
}
export interface PlatformOverviewResponse {
summary: {
bots: {

View File

@ -2,10 +2,14 @@ import { useEffect, useState } from 'react';
export type AppRoute =
| { kind: 'dashboard' }
| { kind: 'dashboard-skills' }
| { kind: 'bot'; botId: string };
function parsePathname(pathname: string): AppRoute {
const raw = String(pathname || '/').trim() || '/';
if (/^\/dashboard\/skills\/?$/i.test(raw)) {
return { kind: 'dashboard-skills' };
}
const botMatch = raw.match(/^\/bot\/([^/?#]+)/i);
if (botMatch?.[1]) {
try {

View File

@ -1,11 +1,17 @@
import axios from 'axios';
let initialized = false;
const memoryMap = new Map<string, string>();
const BOT_PASSWORD_HEADER = 'X-Bot-Access-Password';
const BOT_STORAGE_KEY_PREFIX = 'nanobot-bot-page-password:';
function normalizeBotId(raw: string): string {
return String(raw || '').trim();
}
function buildBotAccessStorageKey(botId: string): string {
return `${BOT_STORAGE_KEY_PREFIX}${normalizeBotId(botId)}`;
}
function resolveAbsoluteUrl(input: string): string {
const url = String(input || '').trim();
if (!url) return '';
@ -37,25 +43,44 @@ export function extractBotIdFromApiPath(rawPath: string): string | null {
export function getBotAccessPassword(botId: string): string {
const key = normalizeBotId(botId);
if (!key) return '';
return memoryMap.get(key) || '';
const cached = memoryMap.get(key) || '';
if (cached) return cached;
if (typeof window === 'undefined') return '';
const stored = window.sessionStorage.getItem(buildBotAccessStorageKey(key)) || '';
if (stored) memoryMap.set(key, stored);
return stored;
}
export function setBotAccessPassword(botId: string, password: string): void {
const key = normalizeBotId(botId);
const value = String(password || '').trim();
if (!key || !value) return;
memoryMap.set(key, value);
if (!key) return;
if (value) {
memoryMap.set(key, value);
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(buildBotAccessStorageKey(key), value);
}
return;
}
clearBotAccessPassword(key);
}
export function clearBotAccessPassword(botId: string): void {
const key = normalizeBotId(botId);
if (!key) return;
memoryMap.delete(key);
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(buildBotAccessStorageKey(key));
}
}
export function clearAllBotAccessPasswords(): void {
if (memoryMap.size === 0) return;
const keys = Array.from(memoryMap.keys());
memoryMap.clear();
if (typeof window !== 'undefined') {
keys.forEach((botId) => window.sessionStorage.removeItem(buildBotAccessStorageKey(botId)));
}
}
export function isBotUnauthorizedError(error: any, botId?: string): boolean {
@ -71,5 +96,19 @@ export function buildMonitorWsUrl(base: string, botId: string): string {
export function setupBotAccessAuth(): void {
if (initialized) return;
initialized = true;
void axios;
axios.interceptors.request.use((config) => {
const botId = extractBotIdFromApiPath(String(config.url || ''));
if (!botId) return config;
if (String(config.url || '').includes(`/api/bots/${encodeURIComponent(botId)}/auth/login`)) {
return config;
}
const password = getBotAccessPassword(botId);
if (!password) return config;
const headers = config.headers || {};
if (!(BOT_PASSWORD_HEADER in (headers as Record<string, unknown>))) {
(headers as Record<string, string>)[BOT_PASSWORD_HEADER] = password;
config.headers = headers;
}
return config;
});
}