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. # Ensure table models are registered in SQLModel metadata before create_all.
from models import bot as _bot_models # noqa: F401 from models import bot as _bot_models # noqa: F401
from models import platform as _platform_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 from models import topic as _topic_models # noqa: F401
_engine_kwargs = { _engine_kwargs = {
@ -747,6 +748,8 @@ def align_postgres_sequences() -> None:
(BOT_MESSAGE_TABLE, "id"), (BOT_MESSAGE_TABLE, "id"),
(BOT_REQUEST_USAGE_TABLE, "id"), (BOT_REQUEST_USAGE_TABLE, "id"),
(BOT_ACTIVITY_EVENT_TABLE, "id"), (BOT_ACTIVITY_EVENT_TABLE, "id"),
("skill_market_item", "id"),
("bot_skill_install", "id"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table_name, column_name in sequence_targets: for table_name, column_name in sequence_targets:

View File

@ -7,7 +7,7 @@ import re
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
@ -52,6 +52,7 @@ from core.settings import (
) )
from models.bot import BotInstance, BotMessage, NanobotImage from models.bot import BotInstance, BotMessage, NanobotImage
from models.platform import BotActivityEvent, BotRequestUsage from models.platform import BotActivityEvent, BotRequestUsage
from models.skill import BotSkillInstall, SkillMarketItem
from models.topic import TopicItem, TopicTopic from models.topic import TopicItem, TopicTopic
from api.platform_router import router as platform_router from api.platform_router import router as platform_router
from api.topic_router import router as topic_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.docker_manager = docker_manager
app.state.speech_service = speech_service app.state.speech_service = speech_service
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password"
class ChannelConfigRequest(BaseModel): class ChannelConfigRequest(BaseModel):
@ -409,6 +411,14 @@ def _get_supplied_panel_password_http(request: Request) -> str:
return query_value 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]: def _validate_panel_access_password(supplied: str) -> Optional[str]:
configured = str(PANEL_ACCESS_PASSWORD or "").strip() configured = str(PANEL_ACCESS_PASSWORD or "").strip()
if not configured: if not configured:
@ -421,6 +431,18 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
return None 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: def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool:
raw = str(path or "").strip() raw = str(path or "").strip()
verb = str(method or "GET").strip().upper() 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": if request.method.upper() == "OPTIONS":
return await call_next(request) 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) bot_id = _extract_bot_id_from_api_path(request.url.path)
if not bot_id: 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) return await call_next(request)
with Session(engine) as session: with Session(engine) as session:
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
return JSONResponse(status_code=404, content={"detail": "Bot not found"}) 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)) enabled = bool(getattr(bot, "enabled", True))
if not enabled: if not enabled:
is_enable_api = _is_bot_enable_api_path(request.url.path, request.method) 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: 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: 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" 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: def _cache_key_images() -> str:
@ -1526,6 +1588,312 @@ def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]:
return rows 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: def _cron_store_path(bot_id: str) -> str:
return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json") 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() activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
for row in activity_rows: for row in activity_rows:
session.delete(row) 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.delete(bot)
session.commit() 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) 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") @app.get("/api/bots/{bot_id}/skills")
def list_bot_skills(bot_id: str, session: Session = Depends(get_session)): def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) 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) 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") @app.get("/api/bots/{bot_id}/tools-config")
def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)): def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) 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: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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 tmp_zip_path: Optional[str] = None
total_size = 0
try: try:
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip: with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
tmp_zip_path = tmp_zip.name 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: while True:
chunk = await file.read(1024 * 1024) chunk = await file.read(1024 * 1024)
if not chunk: 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)", detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
) )
tmp_zip.write(chunk) tmp_zip.write(chunk)
except Exception: if total_size == 0:
if tmp_zip_path and os.path.exists(tmp_zip_path): raise HTTPException(status_code=400, detail="Zip package is empty")
os.remove(tmp_zip_path)
raise
finally: finally:
await file.close() 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: try:
archive = zipfile.ZipFile(tmp_zip_path) install_result = _install_skill_zip_into_workspace(bot_id, 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)
finally: finally:
if tmp_zip_path and os.path.exists(tmp_zip_path): if tmp_zip_path and os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path) os.remove(tmp_zip_path)
if not installed:
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
return { return {
"status": "installed", "status": "installed",
"bot_id": bot_id, "bot_id": bot_id,
"installed": installed, "installed": install_result["installed"],
"skills": _list_workspace_skills(bot_id), "skills": install_result["skills"],
} }
@ -2886,18 +3421,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(
.limit(safe_limit) .limit(safe_limit)
).all() ).all()
ordered = list(reversed(rows)) ordered = list(reversed(rows))
payload = [ payload = [_serialize_bot_message_row(bot_id, row) for row in ordered]
{
"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
]
cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30) cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30)
return payload return payload
@ -2935,18 +3459,7 @@ def list_bot_messages_page(
if has_more: if has_more:
rows = rows[:safe_limit] rows = rows[:safe_limit]
ordered = list(reversed(rows)) ordered = list(reversed(rows))
items = [ items = [_serialize_bot_message_row(bot_id, row) for row in ordered]
{
"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
]
next_before_id = rows[-1].id if rows else None next_before_id = rows[-1].id if rows else None
payload = { payload = {
"items": items, "items": items,
@ -2958,6 +3471,113 @@ def list_bot_messages_page(
return payload 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") @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
def update_bot_message_feedback( def update_bot_message_feedback(
bot_id: str, 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; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.app-title-icon { .app-title-icon {
@ -1185,6 +1186,22 @@ body {
text-transform: uppercase; 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 { .platform-grid {
display: grid; display: grid;
grid-template-columns: 320px minmax(0, 1fr); grid-template-columns: 320px minmax(0, 1fr);
@ -1939,6 +1956,431 @@ body {
flex: 1 1 auto; 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 { .app-shell[data-theme='light'] .platform-entry-card {
border-color: #b7c7e6; border-color: #b7c7e6;
background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
@ -2118,6 +2560,16 @@ body {
grid-template-columns: 1fr; 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 { .platform-template-tabs {
max-height: 220px; max-height: 220px;
} }
@ -2212,7 +2664,16 @@ body {
.platform-selected-bot-last-row, .platform-selected-bot-last-row,
.platform-settings-pager, .platform-settings-pager,
.platform-usage-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; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
@ -2225,4 +2686,21 @@ body {
max-height: calc(90dvh - 60px); max-height: calc(90dvh - 60px);
padding: 0 10px 12px; 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 { appZhCn } from './i18n/app.zh-cn';
import { appEn } from './i18n/app.en'; import { appEn } from './i18n/app.en';
import { LucentTooltip } from './components/lucent/LucentTooltip'; 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 { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import { BotHomePage } from './modules/bot-home/BotHomePage'; import { BotHomePage } from './modules/bot-home/BotHomePage';
import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage'; import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage';
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute'; import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
import './App.css'; import './App.css';
function getSingleBotPasswordKey(botId: string) {
return `nanobot-bot-page-password:${String(botId || '').trim()}`;
}
const defaultLoadingPage = { const defaultLoadingPage = {
title: 'Dashboard Nanobot', title: 'Dashboard Nanobot',
subtitle: '平台正在准备管理面板', subtitle: '平台正在准备管理面板',
@ -37,6 +36,9 @@ function AuthenticatedApp() {
const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
const [singleBotSubmitting, setSingleBotSubmitting] = 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 : ''; const forcedBotId = route.kind === 'bot' ? route.botId : '';
useBotsSync(forcedBotId || undefined); useBotsSync(forcedBotId || undefined);
@ -61,15 +63,25 @@ function AuthenticatedApp() {
const shouldPromptSingleBotPassword = Boolean( const shouldPromptSingleBotPassword = Boolean(
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked, 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(() => { useEffect(() => {
if (route.kind !== 'bot') { if (route.kind === 'dashboard') {
document.title = t.title; document.title = t.title;
return; return;
} }
if (route.kind === 'dashboard-skills') {
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
return;
}
const botName = String(forcedBot?.name || '').trim(); const botName = String(forcedBot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`; 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(() => { useEffect(() => {
setSingleBotUnlocked(false); setSingleBotUnlocked(false);
@ -79,18 +91,19 @@ function AuthenticatedApp() {
useEffect(() => { useEffect(() => {
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return; 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; if (!stored) return;
let alive = true; let alive = true;
const boot = async () => { const boot = async () => {
try { try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored }); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
if (!alive) return; if (!alive) return;
setBotAccessPassword(forcedBotId, stored);
setSingleBotUnlocked(true); setSingleBotUnlocked(true);
setSingleBotPassword(''); setSingleBotPassword('');
setSingleBotPasswordError(''); setSingleBotPasswordError('');
} catch { } catch {
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId)); clearBotAccessPassword(forcedBotId);
if (!alive) return; if (!alive) return;
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
} }
@ -110,22 +123,24 @@ function AuthenticatedApp() {
setSingleBotSubmitting(true); setSingleBotSubmitting(true);
try { try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered }); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
if (typeof window !== 'undefined') { setBotAccessPassword(forcedBotId, entered);
window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered);
}
setSingleBotPasswordError(''); setSingleBotPasswordError('');
setSingleBotUnlocked(true); setSingleBotUnlocked(true);
setSingleBotPassword(''); setSingleBotPassword('');
} catch { } catch {
if (typeof window !== 'undefined') { clearBotAccessPassword(forcedBotId);
window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
}
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.'); setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
} finally { } finally {
setSingleBotSubmitting(false); setSingleBotSubmitting(false);
} }
}; };
const navigateToDashboard = () => {
if (typeof window === 'undefined') return;
window.history.pushState({}, '', '/dashboard');
window.dispatchEvent(new PopStateEvent('popstate'));
};
return ( return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}> <div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}> <div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
@ -140,12 +155,18 @@ function AuthenticatedApp() {
<div className="app-title"> <div className="app-title">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" /> <img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main"> <div className="app-title-main">
<h1>{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}</h1> <h1>{headerTitle}</h1>
<div className="app-route-subtitle"> {route.kind === 'dashboard-skills' ? (
{route.kind === 'dashboard' <button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
? (locale === 'zh' ? '平台总览' : 'Platform overview') {locale === 'zh' ? '平台总览' : 'Platform Overview'}
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} </button>
</div> ) : (
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
</div>
)}
{isCompactShell ? ( {isCompactShell ? (
<button <button
type="button" type="button"
@ -201,6 +222,8 @@ function AuthenticatedApp() {
<main className="main-stage"> <main className="main-stage">
{route.kind === 'dashboard' ? ( {route.kind === 'dashboard' ? (
<PlatformDashboardPage compactMode={compactMode} /> <PlatformDashboardPage compactMode={compactMode} />
) : route.kind === 'dashboard-skills' ? (
<SkillMarketManagerPage isZh={locale === 'zh'} />
) : ( ) : (
<BotHomePage botId={forcedBotId} compactMode={compactMode} /> <BotHomePage botId={forcedBotId} compactMode={compactMode} />
)} )}
@ -214,9 +237,8 @@ function AuthenticatedApp() {
<h1>{forcedBot?.name || forcedBotId}</h1> <h1>{forcedBot?.name || forcedBotId}</h1>
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p> <p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
<div className="app-login-form"> <div className="app-login-form">
<input <PasswordInput
className="input" className="input"
type="password"
value={singleBotPassword} value={singleBotPassword}
onChange={(event) => { onChange={(event) => {
setSingleBotPassword(event.target.value); setSingleBotPassword(event.target.value);
@ -227,6 +249,7 @@ function AuthenticatedApp() {
}} }}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'} placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus autoFocus
toggleLabels={passwordToggleLabels}
/> />
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null} {singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}> <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 [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const bypassPanelGate = route.kind === 'bot'; const bypassPanelGate = route.kind === 'bot';
useEffect(() => { useEffect(() => {
@ -344,15 +370,15 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
<h1>{t.title}</h1> <h1>{t.title}</h1>
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p> <p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form"> <div className="app-login-form">
<input <PasswordInput
className="input" className="input"
type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit(); if (event.key === 'Enter') void onSubmit();
}} }}
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'} placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
toggleLabels={passwordToggleLabels}
/> />
{error ? <div className="app-login-error">{error}</div> : null} {error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}> <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 { .ops-control-command-drawer {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@ -1346,9 +1347,10 @@
} }
.ops-control-command-drawer.is-open { .ops-control-command-drawer.is-open {
max-width: 240px; max-width: 332px;
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
overflow: visible;
pointer-events: auto; pointer-events: auto;
} }
@ -1384,6 +1386,39 @@
cursor: not-allowed; 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 { .ops-control-command-toggle {
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -1589,10 +1624,15 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.ops-control-command-drawer.is-open { .ops-control-command-drawer.is-open {
max-width: 220px; max-width: 288px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.ops-control-date-panel {
right: -2px;
width: min(220px, calc(100vw - 44px));
}
.ops-voice-wave { .ops-voice-wave {
gap: 4px; gap: 4px;
padding: 0 4px; padding: 0 4px;
@ -2723,6 +2763,32 @@
display: inline-flex; 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 { .ops-topic-create-menu {
position: absolute; position: absolute;
right: 0; right: 0;
@ -2774,6 +2840,16 @@
max-width: 100%; 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 { .ops-config-footer {
position: static; position: static;
border-top: 0; 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 { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios'; 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
@ -19,7 +19,10 @@ import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect'; import { LucentSelect } from '../../components/lucent/LucentSelect';
import { PasswordInput } from '../../components/PasswordInput';
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel'; import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
import type { BotSkillMarketItem } from '../platform/types';
import { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
import { import {
normalizePlatformPageSize, normalizePlatformPageSize,
readCachedPlatformPageSize, readCachedPlatformPageSize,
@ -92,6 +95,15 @@ interface WorkspaceUploadResponse {
files: Array<{ name: string; path: string; size: number }>; 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 { interface CronJob {
id: string; id: string;
name: string; name: string;
@ -270,6 +282,14 @@ interface SkillUploadResponse {
skills: WorkspaceSkillOption[]; skills: WorkspaceSkillOption[];
} }
interface MarketSkillInstallResponse {
status: string;
bot_id: string;
skill_market_item_id: number;
installed: string[];
skills: WorkspaceSkillOption[];
}
interface SystemDefaultsResponse { interface SystemDefaultsResponse {
limits?: { limits?: {
upload_max_mb?: number; 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) { function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase(); return (s || 'IDLE').toUpperCase();
} }
@ -1034,6 +1080,8 @@ export function BotDashboardModule({
const [showChannelModal, setShowChannelModal] = useState(false); const [showChannelModal, setShowChannelModal] = useState(false);
const [showTopicModal, setShowTopicModal] = useState(false); const [showTopicModal, setShowTopicModal] = useState(false);
const [showSkillsModal, setShowSkillsModal] = useState(false); const [showSkillsModal, setShowSkillsModal] = useState(false);
const [showSkillMarketInstallModal, setShowSkillMarketInstallModal] = useState(false);
const [skillAddMenuOpen, setSkillAddMenuOpen] = useState(false);
const [showMcpModal, setShowMcpModal] = useState(false); const [showMcpModal, setShowMcpModal] = useState(false);
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false); const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
const [showCronModal, setShowCronModal] = useState(false); const [showCronModal, setShowCronModal] = useState(false);
@ -1082,6 +1130,7 @@ export function BotDashboardModule({
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({}); const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false); const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false); const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({ const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
id: 'draft-channel', id: 'draft-channel',
bot_id: '', bot_id: '',
@ -1098,6 +1147,7 @@ export function BotDashboardModule({
const [topicPresetTemplates, setTopicPresetTemplates] = useState<TopicPresetTemplate[]>([]); const [topicPresetTemplates, setTopicPresetTemplates] = useState<TopicPresetTemplate[]>([]);
const [newTopicSource, setNewTopicSource] = useState<string>(''); const [newTopicSource, setNewTopicSource] = useState<string>('');
const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false); const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false);
const topicPresetMenuRef = useRef<HTMLDivElement | null>(null);
const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false); const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false);
const [newTopicKey, setNewTopicKey] = useState(''); const [newTopicKey, setNewTopicKey] = useState('');
const [newTopicName, setNewTopicName] = useState(''); const [newTopicName, setNewTopicName] = useState('');
@ -1109,8 +1159,12 @@ export function BotDashboardModule({
const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState(''); const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState('');
const [newTopicPriority, setNewTopicPriority] = useState('50'); const [newTopicPriority, setNewTopicPriority] = useState('50');
const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]); const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
const [marketSkills, setMarketSkills] = useState<BotSkillMarketItem[]>([]);
const [isSkillUploading, setIsSkillUploading] = useState(false); const [isSkillUploading, setIsSkillUploading] = useState(false);
const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false);
const [marketSkillInstallingId, setMarketSkillInstallingId] = useState<number | null>(null);
const skillZipPickerRef = useRef<HTMLInputElement | null>(null); const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
const [envParams, setEnvParams] = useState<BotEnvParams>({}); const [envParams, setEnvParams] = useState<BotEnvParams>({});
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]); const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]); const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
@ -1129,8 +1183,6 @@ export function BotDashboardModule({
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({}); const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [isSavingChannel, setIsSavingChannel] = useState(false); const [isSavingChannel, setIsSavingChannel] = useState(false);
const [isSavingTopic, setIsSavingTopic] = useState(false); const [isSavingTopic, setIsSavingTopic] = useState(false);
const [isSavingMcp, setIsSavingMcp] = useState(false); const [isSavingMcp, setIsSavingMcp] = useState(false);
@ -1151,6 +1203,11 @@ export function BotDashboardModule({
const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [chatHasMore, setChatHasMore] = useState(false); const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = 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[]>( const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
); );
@ -1197,6 +1254,8 @@ export function BotDashboardModule({
const voiceTimerRef = useRef<number | null>(null); const voiceTimerRef = useRef<number | null>(null);
const runtimeMenuRef = useRef<HTMLDivElement | null>(null); const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
const botListMenuRef = 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 botOrderRef = useRef<Record<string, number>>({});
const nextBotOrderRef = useRef(1); const nextBotOrderRef = useRef(1);
const applyEditFormFromBot = useCallback((bot?: any) => { const applyEditFormFromBot = useCallback((bot?: any) => {
@ -1598,6 +1657,9 @@ export function BotDashboardModule({
const isZh = locale === 'zh'; const isZh = locale === 'zh';
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const passwordToggleLabels = isZh
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const activeTopicOptions = useMemo<TopicFeedOption[]>( const activeTopicOptions = useMemo<TopicFeedOption[]>(
() => () =>
topics topics
@ -1818,7 +1880,10 @@ export function BotDashboardModule({
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
return ( return (
<div key={itemKey}> <div
key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined}
>
{showDateDivider ? ( {showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}> <div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span> <span>{formatConversationDate(item.ts, isZh)}</span>
@ -2114,9 +2179,32 @@ export function BotDashboardModule({
if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) { if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) {
setBotListMenuOpen(false); 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); document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -2261,6 +2349,15 @@ export function BotDashboardModule({
}; };
const refresh = async () => { 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`); const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(botsRes.data); setBots(botsRes.data);
await loadImageOptions(); await loadImageOptions();
@ -2996,6 +3093,20 @@ export function BotDashboardModule({
setBotSkills(Array.isArray(res.data) ? res.data : []); 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) => { const loadBotEnvParams = async (botId: string) => {
if (!botId) return; if (!botId) return;
try { try {
@ -3189,11 +3300,39 @@ export function BotDashboardModule({
try { try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`); await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
await loadBotSkills(selectedBot.id); await loadBotSkills(selectedBot.id);
await loadMarketSkills(selectedBot.id);
} catch (error: any) { } catch (error: any) {
notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' }); 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 = () => { const triggerSkillZipUpload = () => {
if (!selectedBot || isSkillUploading) return; if (!selectedBot || isSkillUploading) return;
skillZipPickerRef.current?.click(); skillZipPickerRef.current?.click();
@ -3218,6 +3357,7 @@ export function BotDashboardModule({
); );
const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : []; const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : [];
setBotSkills(nextSkills); setBotSkills(nextSkills);
await loadMarketSkills(selectedBot.id);
} catch (error: any) { } catch (error: any) {
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' }); notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
} finally { } finally {
@ -3420,7 +3560,7 @@ export function BotDashboardModule({
if (ctype === 'telegram') { if (ctype === 'telegram') {
return ( 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 <input
className="input" className="input"
placeholder={lc.proxy} placeholder={lc.proxy}
@ -3447,7 +3587,7 @@ export function BotDashboardModule({
return ( 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" 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.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" /> <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 ( 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" 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 ( 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" 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 ( 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" 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>
<div className="ops-config-field"> <div className="ops-config-field">
<label className="field-label">{lc.emailImapPassword}</label> <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>
<div className="ops-config-field"> <div className="ops-config-field">
<label className="field-label">{lc.emailImapMailbox}</label> <label className="field-label">{lc.emailImapMailbox}</label>
@ -3560,7 +3700,7 @@ export function BotDashboardModule({
</div> </div>
<div className="ops-config-field"> <div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPassword}</label> <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>
<div className="ops-config-field" style={{ alignSelf: 'end' }}> <div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label"> <label className="field-label">
@ -3929,6 +4069,7 @@ export function BotDashboardModule({
setPendingAttachments([]); setPendingAttachments([]);
setQuotedReply(null); setQuotedReply(null);
} }
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false); setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' }); notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) { } 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 () => { const interruptExecution = async () => {
if (!selectedBot || !canChat) return; if (!selectedBot || !canChat) return;
if (interruptingByBot[selectedBot.id]) return; if (interruptingByBot[selectedBot.id]) return;
@ -3956,6 +4164,7 @@ export function BotDashboardModule({
if (!res.data?.success) { if (!res.data?.success) {
throw new Error(t.backendDeliverFail); throw new Error(t.backendDeliverFail);
} }
setChatDatePickerOpen(false);
setControlCommandPanelOpen(false); setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' }); notify(t.interruptSent, { tone: 'success' });
} catch (error: any) { } catch (error: any) {
@ -4010,22 +4219,7 @@ export function BotDashboardModule({
}); });
const rows = Array.isArray(res.data) ? res.data : []; const rows = Array.isArray(res.data) ? res.data : [];
return rows return rows
.map((row) => { .map((row) => mapBotMessageResponseRow(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;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-safeLimit); .slice(-safeLimit);
}, [chatPullPageSize]); }, [chatPullPageSize]);
@ -4051,22 +4245,7 @@ export function BotDashboardModule({
); );
const rows = Array.isArray(res.data?.items) ? res.data.items : []; const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows const items = rows
.map((row) => { .map((row) => mapBotMessageResponseRow(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;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0); .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const nextBeforeRaw = Number(res.data?.next_before_id); const nextBeforeRaw = Number(res.data?.next_before_id);
const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null; const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null;
@ -4077,6 +4256,44 @@ export function BotDashboardModule({
}; };
}, [chatPullPageSize]); }, [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 () => { const loadMoreChatMessages = useCallback(async () => {
if (!selectedBotId || chatLoadingMore || !chatHasMore) return; if (!selectedBotId || chatLoadingMore || !chatHasMore) return;
const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress'); const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress');
@ -4133,6 +4350,24 @@ export function BotDashboardModule({
} }
}, [chatHasMore, chatLoadingMore, loadMoreChatMessages]); }, [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') => { const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) { if (!selectedBotId) {
notify(t.feedbackMessagePending, { tone: 'warning' }); notify(t.feedbackMessagePending, { tone: 'warning' });
@ -4503,6 +4738,10 @@ export function BotDashboardModule({
if (!selectedBotId) { if (!selectedBotId) {
setChatHasMore(false); setChatHasMore(false);
setChatLoadingMore(false); setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setWorkspaceEntries([]); setWorkspaceEntries([]);
setWorkspaceCurrentPath(''); setWorkspaceCurrentPath('');
setWorkspaceParentPath(null); setWorkspaceParentPath(null);
@ -4527,6 +4766,9 @@ export function BotDashboardModule({
setPendingAttachments([]); setPendingAttachments([]);
setCronJobs([]); setCronJobs([]);
setBotSkills([]); setBotSkills([]);
setMarketSkills([]);
setShowSkillMarketInstallModal(false);
setSkillAddMenuOpen(false);
setEnvParams({}); setEnvParams({});
setExpandedMcpByKey({}); setExpandedMcpByKey({});
setNewMcpPanelOpen(false); setNewMcpPanelOpen(false);
@ -4542,6 +4784,10 @@ export function BotDashboardModule({
} }
setChatHasMore(false); setChatHasMore(false);
setChatLoadingMore(false); setChatLoadingMore(false);
setChatDatePickerOpen(false);
setChatDateJumping(false);
setChatJumpAnchorId(null);
setChatDateValue('');
setTopics([]); setTopics([]);
setExpandedTopicByKey({}); setExpandedTopicByKey({});
setNewTopicPanelOpen(false); setNewTopicPanelOpen(false);
@ -4587,6 +4833,12 @@ export function BotDashboardModule({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]); }, [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(() => { useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return; if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
let stopped = false; let stopped = false;
@ -4608,6 +4860,29 @@ export function BotDashboardModule({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]); }, [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(() => { useEffect(() => {
if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return; if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return;
const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey); const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey);
@ -5341,7 +5616,7 @@ export function BotDashboardModule({
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}> <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' : ''}`}> <div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button <button
type="button" type="button"
@ -5376,11 +5651,63 @@ export function BotDashboardModule({
{interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />} {interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span> <span className="mono">/stop</span>
</button> </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> </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 <button
type="button" type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`} 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} aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
title={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} /> <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> <label className="field-label">{t.accessPassword}</label>
<input <PasswordInput
className="input" className="input"
type="password"
value={editForm.access_password} value={editForm.access_password}
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))} onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
placeholder={t.accessPasswordPlaceholder} placeholder={t.accessPasswordPlaceholder}
toggleLabels={passwordToggleLabels}
/> />
<label className="field-label">{t.baseImageReadonly}</label> <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} /> <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> <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> <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" /> <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 ? ( {!newChannelPanelOpen ? (
<div className="row-between ops-config-footer"> <div className="row-between ops-config-footer">
<span className="field-label">{lc.channelAddHint}</span> <span className="field-label">{lc.channelAddHint}</span>
<div className="ops-topic-create-menu-wrap"> <div className="ops-topic-create-menu-wrap" ref={channelCreateMenuRef}>
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
disabled={addableChannelTypes.length === 0 || isSavingChannel} disabled={addableChannelTypes.length === 0 || isSavingChannel}
@ -6643,7 +6970,7 @@ export function BotDashboardModule({
{!newTopicPanelOpen ? ( {!newTopicPanelOpen ? (
<div className="row-between ops-config-footer"> <div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span> <span className="field-label">{t.topicAddHint}</span>
<div className="ops-topic-create-menu-wrap"> <div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
disabled={isSavingTopic || !selectedBot} disabled={isSavingTopic || !selectedBot}
@ -6670,46 +6997,81 @@ export function BotDashboardModule({
)} )}
{showSkillsModal && ( {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-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close"> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main"> <div className="modal-title-main">
<h3>{t.skillsPanel}</h3> <h3>{t.skillsPanel}</h3>
<span className="modal-sub">
{isZh ? '查看当前 Bot 已安装的技能。' : 'View the skills already installed for this bot.'}
</span>
</div> </div>
<div className="modal-title-actions"> <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} /> <X size={14} />
</LucentIconButton> </LucentIconButton>
</div> </div>
</div> </div>
<div className="wizard-channel-list ops-skills-list-scroll">
{botSkills.length === 0 ? ( <div className="stack">
<div className="ops-empty-inline">{t.skillsEmpty}</div> <div className="row-between">
) : ( <div>
botSkills.map((skill) => ( <div className="section-mini-title">{isZh ? '已安装技能' : 'Installed Skills'}</div>
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact"> <div className="field-label">
<div className="row-between"> {isZh ? '这里展示当前 Bot 工作区中的技能。' : 'These skills are already present in the bot workspace.'}
<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="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>
<div className="row-between"> <div className="ops-skill-add-bar">
<input <input
ref={skillZipPickerRef} ref={skillZipPickerRef}
type="file" type="file"
@ -6717,24 +7079,73 @@ export function BotDashboardModule({
onChange={onPickSkillZip} onChange={onPickSkillZip}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<button <div className="field-label ops-skill-add-hint">
className="btn btn-secondary btn-sm" {isSkillUploading
disabled={isSkillUploading} ? (isZh ? '正在上传 ZIP 技能包...' : 'Uploading ZIP skill package...')
onClick={triggerSkillZipUpload} : (isZh ? '支持上传本地 ZIP或从技能市场安装技能到当前 Bot。' : 'Upload a local ZIP or install a skill from the marketplace into this bot.')}
title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill} </div>
aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill} <div className="ops-topic-create-menu-wrap" ref={skillAddMenuRef}>
> <button
{isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null} type="button"
<span style={{ marginLeft: isSkillUploading ? 6 : 0 }}> className="btn btn-secondary btn-sm ops-skill-create-trigger"
{isSkillUploading ? t.uploadingFile : t.uploadZipSkill} onClick={() => setSkillAddMenuOpen((prev) => !prev)}
</span> disabled={!selectedBot}
</button> >
<span className="field-label">{t.zipOnlyHint}</span> <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> </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 && ( {showMcpModal && (
<div <div
className="modal-mask" className="modal-mask"
@ -6845,14 +7256,14 @@ export function BotDashboardModule({
</div> </div>
<div className="ops-config-field"> <div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label> <label className="field-label">X-Bot-Secret</label>
<input <PasswordInput
className="input" className="input"
type="password"
value={row.botSecret} value={row.botSecret}
placeholder={t.mcpBotSecretPlaceholder} placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })} onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })}
autoComplete="new-password" autoComplete="new-password"
disabled={row.locked} disabled={row.locked}
toggleLabels={passwordToggleLabels}
/> />
</div> </div>
<div className="ops-config-field"> <div className="ops-config-field">
@ -6948,13 +7359,13 @@ export function BotDashboardModule({
</div> </div>
<div className="ops-config-field"> <div className="ops-config-field">
<label className="field-label">X-Bot-Secret</label> <label className="field-label">X-Bot-Secret</label>
<input <PasswordInput
className="input" className="input"
type="password"
value={newMcpDraft.botSecret} value={newMcpDraft.botSecret}
placeholder={t.mcpBotSecretPlaceholder} placeholder={t.mcpBotSecretPlaceholder}
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))} onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))}
autoComplete="new-password" autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/> />
</div> </div>
<div className="ops-config-field"> <div className="ops-config-field">
@ -7024,22 +7435,18 @@ export function BotDashboardModule({
<div key={key} className="card wizard-channel-card wizard-channel-compact"> <div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}> <div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
<input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} /> <input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} />
<input <PasswordInput
className="input" className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value} value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)} onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={t.envValue} placeholder={t.envValue}
autoComplete="off" 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 <LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn" className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)} onClick={() => removeEnvParam(key)}
@ -7062,22 +7469,18 @@ export function BotDashboardModule({
placeholder={t.envKey} placeholder={t.envKey}
autoComplete="off" autoComplete="off"
/> />
<input <PasswordInput
className="input" className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue} value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)} onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={t.envValue} placeholder={t.envValue}
autoComplete="off" 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 <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn"
onClick={() => { 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 { managementEn } from '../../../i18n/management.en';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { LucentSelect } from '../../../components/lucent/LucentSelect'; import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { PasswordInput } from '../../../components/PasswordInput';
interface CreateBotModalProps { interface CreateBotModalProps {
isOpen: boolean; isOpen: boolean;
@ -25,6 +26,9 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
const locale = useAppStore((s) => s.locale); const locale = useAppStore((s) => s.locale);
const { notify } = useLucentPrompt(); const { notify } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create; 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({ const [formData, setFormData] = useState({
id: '', id: '',
name: '', 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"> <label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
<Key size={14} /> API Key <Key size={14} /> API Key
</label> </label>
<input <PasswordInput
required 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" 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-..." placeholder="sk-..."
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })} onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
toggleLabels={passwordToggleLabels}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import axios from 'axios'; 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 { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore'; import { useAppStore } from '../../store/appStore';
import { channelsZhCn } from '../../i18n/channels.zh-cn'; import { channelsZhCn } from '../../i18n/channels.zh-cn';
@ -11,6 +11,7 @@ import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect'; import { LucentSelect } from '../../components/lucent/LucentSelect';
import { PasswordInput } from '../../components/PasswordInput';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
@ -146,8 +147,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const [showToolsConfigModal, setShowToolsConfigModal] = useState(false); const [showToolsConfigModal, setShowToolsConfigModal] = useState(false);
const [envDraftKey, setEnvDraftKey] = useState(''); const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState(''); const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>(''); const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
const [form, setForm] = useState(initialForm); const [form, setForm] = useState(initialForm);
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(''); const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState('');
@ -162,6 +161,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const isZh = locale === 'zh'; const isZh = locale === 'zh';
const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn }); const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn });
const lc = isZh ? channelsZhCn : channelsEn; 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 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 activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
const addableChannelTypes = useMemo( const addableChannelTypes = useMemo(
@ -518,13 +520,13 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
if (channel.channel_type === 'telegram') { if (channel.channel_type === 'telegram') {
return ( return (
<> <>
<input <PasswordInput
className="input" className="input"
type="password"
placeholder={lc.telegramToken} placeholder={lc.telegramToken}
value={channel.app_secret} value={channel.app_secret}
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
autoComplete="new-password" autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/> />
<input <input
className="input" className="input"
@ -556,7 +558,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return ( 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" 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 <input
className="input" className="input"
placeholder={lc.encryptKey} placeholder={lc.encryptKey}
@ -583,7 +585,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
return ( 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" 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 ( 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" 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 ( 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" 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> <option value="xunfei">xunfei (spark)</option>
</LucentSelect> </LucentSelect>
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} /> <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 }))} /> <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)' }}> <div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[form.llm_provider]?.note[noteLocale]} {providerPresets[form.llm_provider]?.note[noteLocale]}
@ -968,22 +970,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
readOnly readOnly
style={{ maxWidth: 280 }} style={{ maxWidth: 280 }}
/> />
<input <PasswordInput
className="input" className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value} value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)} onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={ui.envValue} placeholder={ui.envValue}
autoComplete="off" 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 <LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn" className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)} onClick={() => removeEnvParam(key)}
@ -1005,22 +1003,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
placeholder={ui.envKey} placeholder={ui.envKey}
autoComplete="off" autoComplete="off"
/> />
<input <PasswordInput
className="input" className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue} value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)} onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={ui.envValue} placeholder={ui.envValue}
autoComplete="off" 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 <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn"
onClick={() => { onClick={() => {

View File

@ -124,6 +124,12 @@ interface BotResourceSnapshot {
collected_at?: string; 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) { export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProps) {
const { activeBots, setBots, updateBotStatus, locale } = useAppStore(); const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
const { notify, confirm } = useLucentPrompt(); const { notify, confirm } = useLucentPrompt();
@ -625,11 +631,11 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
<button <button
className="platform-entry-card" className="platform-entry-card"
type="button" type="button"
onClick={() => notify(isZh ? '技能市场即将开放,敬请期待。' : 'Skill marketplace is coming soon.', { tone: 'warning' })} onClick={() => navigatePlatform('/dashboard/skills')}
> >
<Hammer size={18} /> <Hammer size={18} />
<strong>{isZh ? '技能市场' : 'Skill Marketplace'}</strong> <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> </button>
</div> </div>
</section> </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; 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 { export interface PlatformOverviewResponse {
summary: { summary: {
bots: { bots: {

View File

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

View File

@ -1,11 +1,17 @@
import axios from 'axios'; import axios from 'axios';
let initialized = false; let initialized = false;
const memoryMap = new Map<string, string>(); 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 { function normalizeBotId(raw: string): string {
return String(raw || '').trim(); return String(raw || '').trim();
} }
function buildBotAccessStorageKey(botId: string): string {
return `${BOT_STORAGE_KEY_PREFIX}${normalizeBotId(botId)}`;
}
function resolveAbsoluteUrl(input: string): string { function resolveAbsoluteUrl(input: string): string {
const url = String(input || '').trim(); const url = String(input || '').trim();
if (!url) return ''; if (!url) return '';
@ -37,25 +43,44 @@ export function extractBotIdFromApiPath(rawPath: string): string | null {
export function getBotAccessPassword(botId: string): string { export function getBotAccessPassword(botId: string): string {
const key = normalizeBotId(botId); const key = normalizeBotId(botId);
if (!key) return ''; 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 { export function setBotAccessPassword(botId: string, password: string): void {
const key = normalizeBotId(botId); const key = normalizeBotId(botId);
const value = String(password || '').trim(); const value = String(password || '').trim();
if (!key || !value) return; if (!key) return;
memoryMap.set(key, value); 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 { export function clearBotAccessPassword(botId: string): void {
const key = normalizeBotId(botId); const key = normalizeBotId(botId);
if (!key) return; if (!key) return;
memoryMap.delete(key); memoryMap.delete(key);
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(buildBotAccessStorageKey(key));
}
} }
export function clearAllBotAccessPasswords(): void { export function clearAllBotAccessPasswords(): void {
if (memoryMap.size === 0) return; if (memoryMap.size === 0) return;
const keys = Array.from(memoryMap.keys());
memoryMap.clear(); memoryMap.clear();
if (typeof window !== 'undefined') {
keys.forEach((botId) => window.sessionStorage.removeItem(buildBotAccessStorageKey(botId)));
}
} }
export function isBotUnauthorizedError(error: any, botId?: string): boolean { export function isBotUnauthorizedError(error: any, botId?: string): boolean {
@ -71,5 +96,19 @@ export function buildMonitorWsUrl(base: string, botId: string): string {
export function setupBotAccessAuth(): void { export function setupBotAccessAuth(): void {
if (initialized) return; if (initialized) return;
initialized = true; 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;
});
} }