v0.1.4-p3
parent
cf354d6bed
commit
e766025c94
|
|
@ -14,6 +14,7 @@ from core.settings import (
|
|||
# Ensure table models are registered in SQLModel metadata before create_all.
|
||||
from models import bot as _bot_models # noqa: F401
|
||||
from models import platform as _platform_models # noqa: F401
|
||||
from models import skill as _skill_models # noqa: F401
|
||||
from models import topic as _topic_models # noqa: F401
|
||||
|
||||
_engine_kwargs = {
|
||||
|
|
@ -747,6 +748,8 @@ def align_postgres_sequences() -> None:
|
|||
(BOT_MESSAGE_TABLE, "id"),
|
||||
(BOT_REQUEST_USAGE_TABLE, "id"),
|
||||
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
||||
("skill_market_item", "id"),
|
||||
("bot_skill_install", "id"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table_name, column_name in sequence_targets:
|
||||
|
|
|
|||
842
backend/main.py
842
backend/main.py
|
|
@ -7,7 +7,7 @@ import re
|
|||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ from core.settings import (
|
|||
)
|
||||
from models.bot import BotInstance, BotMessage, NanobotImage
|
||||
from models.platform import BotActivityEvent, BotRequestUsage
|
||||
from models.skill import BotSkillInstall, SkillMarketItem
|
||||
from models.topic import TopicItem, TopicTopic
|
||||
from api.platform_router import router as platform_router
|
||||
from api.topic_router import router as topic_router
|
||||
|
|
@ -89,6 +90,7 @@ speech_service = WhisperSpeechService()
|
|||
app.state.docker_manager = docker_manager
|
||||
app.state.speech_service = speech_service
|
||||
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password"
|
||||
|
||||
|
||||
class ChannelConfigRequest(BaseModel):
|
||||
|
|
@ -409,6 +411,14 @@ def _get_supplied_panel_password_http(request: Request) -> str:
|
|||
return query_value
|
||||
|
||||
|
||||
def _get_supplied_bot_access_password_http(request: Request) -> str:
|
||||
header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip()
|
||||
if header_value:
|
||||
return header_value
|
||||
query_value = str(request.query_params.get("bot_access_password") or "").strip()
|
||||
return query_value
|
||||
|
||||
|
||||
def _validate_panel_access_password(supplied: str) -> Optional[str]:
|
||||
configured = str(PANEL_ACCESS_PASSWORD or "").strip()
|
||||
if not configured:
|
||||
|
|
@ -421,6 +431,18 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _validate_bot_access_password(bot: BotInstance, supplied: str) -> Optional[str]:
|
||||
configured = str(getattr(bot, "access_password", "") or "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
candidate = str(supplied or "").strip()
|
||||
if not candidate:
|
||||
return "Bot access password required"
|
||||
if candidate != configured:
|
||||
return "Invalid bot access password"
|
||||
return None
|
||||
|
||||
|
||||
def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool:
|
||||
raw = str(path or "").strip()
|
||||
verb = str(method or "GET").strip().upper()
|
||||
|
|
@ -475,19 +497,26 @@ async def bot_access_password_guard(request: Request, call_next):
|
|||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
if _is_panel_protected_api_path(request.url.path, request.method):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if panel_error:
|
||||
return JSONResponse(status_code=401, content={"detail": panel_error})
|
||||
|
||||
bot_id = _extract_bot_id_from_api_path(request.url.path)
|
||||
if not bot_id:
|
||||
if _is_panel_protected_api_path(request.url.path, request.method):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if panel_error:
|
||||
return JSONResponse(status_code=401, content={"detail": panel_error})
|
||||
return await call_next(request)
|
||||
|
||||
with Session(engine) as session:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
return JSONResponse(status_code=404, content={"detail": "Bot not found"})
|
||||
|
||||
if _is_bot_panel_management_api_path(request.url.path, request.method):
|
||||
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
|
||||
if panel_error:
|
||||
bot_error = _validate_bot_access_password(bot, _get_supplied_bot_access_password_http(request))
|
||||
if bot_error:
|
||||
return JSONResponse(status_code=401, content={"detail": bot_error})
|
||||
|
||||
enabled = bool(getattr(bot, "enabled", True))
|
||||
if not enabled:
|
||||
is_enable_api = _is_bot_enable_api_path(request.url.path, request.method)
|
||||
|
|
@ -532,12 +561,45 @@ def _cache_key_bot_detail(bot_id: str) -> str:
|
|||
|
||||
|
||||
def _cache_key_bot_messages(bot_id: str, limit: int) -> str:
|
||||
return f"bot:messages:{bot_id}:limit:{limit}"
|
||||
return f"bot:messages:v2:{bot_id}:limit:{limit}"
|
||||
|
||||
|
||||
def _cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str:
|
||||
cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest"
|
||||
return f"bot:messages:page:{bot_id}:before:{cursor}:limit:{limit}"
|
||||
return f"bot:messages:page:v2:{bot_id}:before:{cursor}:limit:{limit}"
|
||||
|
||||
|
||||
def _serialize_bot_message_row(bot_id: str, row: BotMessage) -> Dict[str, Any]:
|
||||
created_at = row.created_at
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
return {
|
||||
"id": row.id,
|
||||
"bot_id": row.bot_id,
|
||||
"role": row.role,
|
||||
"text": row.text,
|
||||
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)),
|
||||
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
|
||||
"ts": int(created_at.timestamp() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_local_day_range(date_text: str, tz_offset_minutes: Optional[int]) -> tuple[datetime, datetime]:
|
||||
try:
|
||||
local_day = datetime.strptime(str(date_text or "").strip(), "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid date, expected YYYY-MM-DD") from exc
|
||||
|
||||
offset_minutes = 0
|
||||
if tz_offset_minutes is not None:
|
||||
try:
|
||||
offset_minutes = int(tz_offset_minutes)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid timezone offset") from exc
|
||||
|
||||
utc_start = local_day + timedelta(minutes=offset_minutes)
|
||||
utc_end = utc_start + timedelta(days=1)
|
||||
return utc_start, utc_end
|
||||
|
||||
|
||||
def _cache_key_images() -> str:
|
||||
|
|
@ -1526,6 +1588,312 @@ def _list_workspace_skills(bot_id: str) -> List[Dict[str, Any]]:
|
|||
return rows
|
||||
|
||||
|
||||
def _skill_market_root() -> str:
|
||||
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
|
||||
|
||||
|
||||
def _parse_json_string_list(raw: Any) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
rows: List[str] = []
|
||||
for item in data:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in rows:
|
||||
rows.append(text)
|
||||
return rows
|
||||
|
||||
|
||||
def _is_ignored_skill_zip_top_level(name: str) -> bool:
|
||||
text = str(name or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
lowered = text.lower()
|
||||
if lowered == "__macosx":
|
||||
return True
|
||||
if text.startswith("."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _read_description_from_text(raw: str) -> str:
|
||||
for line in str(raw or "").splitlines():
|
||||
text = line.strip()
|
||||
if text and not text.startswith("#"):
|
||||
return text[:240]
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_skill_zip_summary(zip_path: str) -> Dict[str, Any]:
|
||||
entry_names: List[str] = []
|
||||
description = ""
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
members = archive.infolist()
|
||||
file_members = [member for member in members if not member.is_dir()]
|
||||
for member in file_members:
|
||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
if not raw_name:
|
||||
continue
|
||||
first = raw_name.split("/", 1)[0].strip()
|
||||
if _is_ignored_skill_zip_top_level(first):
|
||||
continue
|
||||
if _is_valid_top_level_skill_name(first) and first not in entry_names:
|
||||
entry_names.append(first)
|
||||
|
||||
candidates = sorted(
|
||||
[
|
||||
str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
for member in file_members
|
||||
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
in {"skill.md", "readme.md"}
|
||||
],
|
||||
key=lambda value: (value.count("/"), value.lower()),
|
||||
)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
with archive.open(candidate, "r") as fh:
|
||||
preview = fh.read(4096).decode("utf-8", errors="ignore")
|
||||
description = _read_description_from_text(preview)
|
||||
if description:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return {
|
||||
"entry_names": entry_names,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_skill_market_key(raw: Any) -> str:
|
||||
value = str(raw or "").strip().lower()
|
||||
value = re.sub(r"[^a-z0-9._-]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value).strip("._-")
|
||||
return value[:120]
|
||||
|
||||
|
||||
def _sanitize_zip_filename(raw: Any) -> str:
|
||||
filename = os.path.basename(str(raw or "").strip())
|
||||
if not filename:
|
||||
return ""
|
||||
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
stem, ext = os.path.splitext(filename)
|
||||
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-")
|
||||
if not safe_stem:
|
||||
safe_stem = "skill-package"
|
||||
safe_ext = ".zip" if ext.lower() == ".zip" else ""
|
||||
return f"{safe_stem[:180]}{safe_ext}"
|
||||
|
||||
|
||||
def _resolve_unique_skill_market_key(session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
|
||||
base_key = _sanitize_skill_market_key(preferred_key) or "skill"
|
||||
candidate = base_key
|
||||
counter = 2
|
||||
while True:
|
||||
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
|
||||
rows = session.exec(stmt).all()
|
||||
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
||||
if not conflict:
|
||||
return candidate
|
||||
candidate = f"{base_key}-{counter}"
|
||||
counter += 1
|
||||
|
||||
|
||||
def _resolve_unique_skill_market_zip_filename(
|
||||
session: Session,
|
||||
filename: str,
|
||||
*,
|
||||
exclude_filename: Optional[str] = None,
|
||||
exclude_id: Optional[int] = None,
|
||||
) -> str:
|
||||
root = _skill_market_root()
|
||||
os.makedirs(root, exist_ok=True)
|
||||
safe_name = _sanitize_zip_filename(filename)
|
||||
if not safe_name.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||
candidate = safe_name
|
||||
stem, ext = os.path.splitext(safe_name)
|
||||
counter = 2
|
||||
while True:
|
||||
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
|
||||
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
|
||||
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
|
||||
if not file_conflict and not db_conflict:
|
||||
return candidate
|
||||
candidate = f"{stem}-{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
|
||||
async def _store_skill_market_zip_upload(
|
||||
session: Session,
|
||||
upload: UploadFile,
|
||||
*,
|
||||
exclude_filename: Optional[str] = None,
|
||||
exclude_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
root = _skill_market_root()
|
||||
os.makedirs(root, exist_ok=True)
|
||||
|
||||
incoming_name = _sanitize_zip_filename(upload.filename or "")
|
||||
if not incoming_name.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||
|
||||
target_filename = _resolve_unique_skill_market_zip_filename(
|
||||
session,
|
||||
incoming_name,
|
||||
exclude_filename=exclude_filename,
|
||||
exclude_id=exclude_id,
|
||||
)
|
||||
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||
total_size = 0
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
|
||||
tmp_path = tmp_zip.name
|
||||
while True:
|
||||
chunk = await upload.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
||||
)
|
||||
tmp_zip.write(chunk)
|
||||
if total_size == 0:
|
||||
raise HTTPException(status_code=400, detail="Zip package is empty")
|
||||
summary = _extract_skill_zip_summary(tmp_path)
|
||||
if not summary["entry_names"]:
|
||||
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
||||
final_path = os.path.join(root, target_filename)
|
||||
os.replace(tmp_path, final_path)
|
||||
tmp_path = None
|
||||
return {
|
||||
"zip_filename": target_filename,
|
||||
"zip_size_bytes": total_size,
|
||||
"entry_names": summary["entry_names"],
|
||||
"description": summary["description"],
|
||||
}
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
||||
finally:
|
||||
await upload.close()
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
def _serialize_skill_market_item(
|
||||
item: SkillMarketItem,
|
||||
*,
|
||||
install_count: int = 0,
|
||||
install_row: Optional[BotSkillInstall] = None,
|
||||
workspace_installed: Optional[bool] = None,
|
||||
installed_entries: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
zip_path = os.path.join(_skill_market_root(), str(item.zip_filename or ""))
|
||||
entry_names = _parse_json_string_list(item.entry_names_json)
|
||||
payload = {
|
||||
"id": item.id,
|
||||
"skill_key": item.skill_key,
|
||||
"display_name": item.display_name or item.skill_key,
|
||||
"description": item.description or "",
|
||||
"zip_filename": item.zip_filename,
|
||||
"zip_size_bytes": int(item.zip_size_bytes or 0),
|
||||
"entry_names": entry_names,
|
||||
"entry_count": len(entry_names),
|
||||
"zip_exists": os.path.isfile(zip_path),
|
||||
"install_count": int(install_count or 0),
|
||||
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
|
||||
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
|
||||
}
|
||||
if install_row is not None:
|
||||
resolved_entries = installed_entries if installed_entries is not None else _parse_json_string_list(install_row.installed_entries_json)
|
||||
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
|
||||
payload.update(
|
||||
{
|
||||
"installed": resolved_installed,
|
||||
"install_status": install_row.status,
|
||||
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
|
||||
"installed_entries": resolved_entries,
|
||||
"install_error": install_row.last_error,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, Any]:
|
||||
try:
|
||||
archive = zipfile.ZipFile(zip_path)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
|
||||
|
||||
skills_root = _skills_root(bot_id)
|
||||
os.makedirs(skills_root, exist_ok=True)
|
||||
|
||||
installed: List[str] = []
|
||||
with archive:
|
||||
members = archive.infolist()
|
||||
file_members = [m for m in members if not m.is_dir()]
|
||||
if not file_members:
|
||||
raise HTTPException(status_code=400, detail="Zip package has no files")
|
||||
|
||||
top_names: List[str] = []
|
||||
for member in file_members:
|
||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
if not raw_name:
|
||||
continue
|
||||
first = raw_name.split("/", 1)[0].strip()
|
||||
if _is_ignored_skill_zip_top_level(first):
|
||||
continue
|
||||
if not _is_valid_top_level_skill_name(first):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
|
||||
if first not in top_names:
|
||||
top_names.append(first)
|
||||
|
||||
if not top_names:
|
||||
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
||||
|
||||
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
|
||||
if conflicts:
|
||||
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_root) as tmp_dir:
|
||||
tmp_root = os.path.abspath(tmp_dir)
|
||||
for member in members:
|
||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
if not raw_name:
|
||||
continue
|
||||
target = os.path.abspath(os.path.join(tmp_root, raw_name))
|
||||
if os.path.commonpath([tmp_root, target]) != tmp_root:
|
||||
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
|
||||
if member.is_dir():
|
||||
os.makedirs(target, exist_ok=True)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with archive.open(member, "r") as source, open(target, "wb") as dest:
|
||||
shutil.copyfileobj(source, dest)
|
||||
|
||||
for name in top_names:
|
||||
src = os.path.join(tmp_root, name)
|
||||
dst = os.path.join(skills_root, name)
|
||||
if not os.path.exists(src):
|
||||
continue
|
||||
shutil.move(src, dst)
|
||||
installed.append(name)
|
||||
|
||||
if not installed:
|
||||
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
|
||||
|
||||
return {
|
||||
"installed": installed,
|
||||
"skills": _list_workspace_skills(bot_id),
|
||||
}
|
||||
|
||||
|
||||
def _cron_store_path(bot_id: str) -> str:
|
||||
return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json")
|
||||
|
||||
|
|
@ -2320,6 +2688,9 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De
|
|||
activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
|
||||
for row in activity_rows:
|
||||
session.delete(row)
|
||||
skill_install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
|
||||
for row in skill_install_rows:
|
||||
session.delete(row)
|
||||
|
||||
session.delete(bot)
|
||||
session.commit()
|
||||
|
|
@ -2342,6 +2713,121 @@ def list_bot_channels(bot_id: str, session: Session = Depends(get_session)):
|
|||
return _get_bot_channels_from_config(bot)
|
||||
|
||||
|
||||
@app.get("/api/platform/skills")
|
||||
def list_skill_market(session: Session = Depends(get_session)):
|
||||
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
||||
installs = session.exec(select(BotSkillInstall)).all()
|
||||
install_count_by_skill: Dict[int, int] = {}
|
||||
for row in installs:
|
||||
skill_id = int(row.skill_market_item_id or 0)
|
||||
if skill_id <= 0 or row.status != "INSTALLED":
|
||||
continue
|
||||
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
||||
return [
|
||||
_serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/platform/skills")
|
||||
async def create_skill_market_item(
|
||||
skill_key: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
file: UploadFile = File(...),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
upload_meta = await _store_skill_market_zip_upload(session, file)
|
||||
try:
|
||||
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
|
||||
next_key = _resolve_unique_skill_market_key(session, preferred_key)
|
||||
item = SkillMarketItem(
|
||||
skill_key=next_key,
|
||||
display_name=str(display_name or next_key).strip() or next_key,
|
||||
description=str(description or upload_meta["description"] or "").strip(),
|
||||
zip_filename=upload_meta["zip_filename"],
|
||||
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
|
||||
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
|
||||
)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return _serialize_skill_market_item(item, install_count=0)
|
||||
except Exception:
|
||||
target_path = os.path.join(_skill_market_root(), upload_meta["zip_filename"])
|
||||
if os.path.exists(target_path):
|
||||
os.remove(target_path)
|
||||
raise
|
||||
|
||||
|
||||
@app.put("/api/platform/skills/{skill_id}")
|
||||
async def update_skill_market_item(
|
||||
skill_id: int,
|
||||
skill_key: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
item = session.get(SkillMarketItem, skill_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||
|
||||
old_filename = str(item.zip_filename or "").strip()
|
||||
upload_meta: Optional[Dict[str, Any]] = None
|
||||
if file is not None:
|
||||
upload_meta = await _store_skill_market_zip_upload(
|
||||
session,
|
||||
file,
|
||||
exclude_filename=old_filename or None,
|
||||
exclude_id=item.id,
|
||||
)
|
||||
|
||||
next_key = _resolve_unique_skill_market_key(
|
||||
session,
|
||||
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
|
||||
exclude_id=item.id,
|
||||
)
|
||||
item.skill_key = next_key
|
||||
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
|
||||
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
|
||||
item.updated_at = datetime.utcnow()
|
||||
if upload_meta:
|
||||
item.zip_filename = upload_meta["zip_filename"]
|
||||
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
|
||||
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
|
||||
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
|
||||
old_path = os.path.join(_skill_market_root(), old_filename)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
||||
install_count = sum(1 for row in installs if row.status == "INSTALLED")
|
||||
return _serialize_skill_market_item(item, install_count=install_count)
|
||||
|
||||
|
||||
@app.delete("/api/platform/skills/{skill_id}")
|
||||
def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(SkillMarketItem, skill_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||
zip_filename = str(item.zip_filename or "").strip()
|
||||
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
|
||||
for row in installs:
|
||||
session.delete(row)
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
if zip_filename:
|
||||
zip_path = os.path.join(_skill_market_root(), zip_filename)
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
return {"status": "deleted", "id": skill_id}
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/skills")
|
||||
def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
|
|
@ -2350,6 +2836,124 @@ def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
|
|||
return _list_workspace_skills(bot_id)
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/skill-market")
|
||||
def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
|
||||
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
|
||||
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
|
||||
all_install_rows = session.exec(select(BotSkillInstall)).all()
|
||||
install_count_by_skill: Dict[int, int] = {}
|
||||
for row in all_install_rows:
|
||||
skill_id = int(row.skill_market_item_id or 0)
|
||||
if skill_id <= 0 or row.status != "INSTALLED":
|
||||
continue
|
||||
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
|
||||
return [
|
||||
_serialize_skill_market_item(
|
||||
item,
|
||||
install_count=install_count_by_skill.get(int(item.id or 0), 0),
|
||||
install_row=install_lookup.get(int(item.id or 0)),
|
||||
workspace_installed=(
|
||||
None
|
||||
if install_lookup.get(int(item.id or 0)) is None
|
||||
else (
|
||||
install_lookup[int(item.id or 0)].status == "INSTALLED"
|
||||
and all(
|
||||
os.path.exists(os.path.join(_skills_root(bot_id), name))
|
||||
for name in _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
||||
)
|
||||
)
|
||||
),
|
||||
installed_entries=(
|
||||
None
|
||||
if install_lookup.get(int(item.id or 0)) is None
|
||||
else _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
|
||||
),
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/bots/{bot_id}/skill-market/{skill_id}/install")
|
||||
def install_bot_skill_from_market(bot_id: str, skill_id: int, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
item = session.get(SkillMarketItem, skill_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Skill market item not found")
|
||||
|
||||
zip_path = os.path.join(_skill_market_root(), str(item.zip_filename or ""))
|
||||
if not os.path.isfile(zip_path):
|
||||
raise HTTPException(status_code=404, detail="Skill zip package not found")
|
||||
|
||||
install_row = session.exec(
|
||||
select(BotSkillInstall).where(
|
||||
BotSkillInstall.bot_id == bot_id,
|
||||
BotSkillInstall.skill_market_item_id == skill_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
try:
|
||||
install_result = _install_skill_zip_into_workspace(bot_id, zip_path)
|
||||
now = datetime.utcnow()
|
||||
if not install_row:
|
||||
install_row = BotSkillInstall(
|
||||
bot_id=bot_id,
|
||||
skill_market_item_id=skill_id,
|
||||
)
|
||||
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
|
||||
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||
install_row.status = "INSTALLED"
|
||||
install_row.last_error = None
|
||||
install_row.installed_at = now
|
||||
install_row.updated_at = now
|
||||
session.add(install_row)
|
||||
session.commit()
|
||||
session.refresh(install_row)
|
||||
return {
|
||||
"status": "installed",
|
||||
"bot_id": bot_id,
|
||||
"skill_market_item_id": skill_id,
|
||||
"installed": install_result["installed"],
|
||||
"skills": install_result["skills"],
|
||||
"market_item": _serialize_skill_market_item(item, install_count=0, install_row=install_row),
|
||||
}
|
||||
except HTTPException as exc:
|
||||
now = datetime.utcnow()
|
||||
if not install_row:
|
||||
install_row = BotSkillInstall(
|
||||
bot_id=bot_id,
|
||||
skill_market_item_id=skill_id,
|
||||
installed_at=now,
|
||||
)
|
||||
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||
install_row.status = "FAILED"
|
||||
install_row.last_error = str(exc.detail or "Install failed")
|
||||
install_row.updated_at = now
|
||||
session.add(install_row)
|
||||
session.commit()
|
||||
raise
|
||||
except Exception as exc:
|
||||
now = datetime.utcnow()
|
||||
if not install_row:
|
||||
install_row = BotSkillInstall(
|
||||
bot_id=bot_id,
|
||||
skill_market_item_id=skill_id,
|
||||
installed_at=now,
|
||||
)
|
||||
install_row.source_zip_filename = str(item.zip_filename or "")
|
||||
install_row.status = "FAILED"
|
||||
install_row.last_error = str(exc or "Install failed")[:1000]
|
||||
install_row.updated_at = now
|
||||
session.add(install_row)
|
||||
session.commit()
|
||||
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/tools-config")
|
||||
def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
|
|
@ -2453,16 +3057,15 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio
|
|||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
filename = str(file.filename or "").strip()
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||
|
||||
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||
tmp_zip_path: Optional[str] = None
|
||||
total_size = 0
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
|
||||
tmp_zip_path = tmp_zip.name
|
||||
filename = str(file.filename or "").strip()
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||
total_size = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
|
|
@ -2474,89 +3077,21 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio
|
|||
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
|
||||
)
|
||||
tmp_zip.write(chunk)
|
||||
except Exception:
|
||||
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
raise
|
||||
if total_size == 0:
|
||||
raise HTTPException(status_code=400, detail="Zip package is empty")
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
if total_size == 0:
|
||||
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
raise HTTPException(status_code=400, detail="Zip package is empty")
|
||||
|
||||
try:
|
||||
archive = zipfile.ZipFile(tmp_zip_path)
|
||||
except Exception:
|
||||
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
raise HTTPException(status_code=400, detail="Invalid zip file")
|
||||
|
||||
skills_root = _skills_root(bot_id)
|
||||
os.makedirs(skills_root, exist_ok=True)
|
||||
|
||||
installed: List[str] = []
|
||||
try:
|
||||
with archive:
|
||||
members = archive.infolist()
|
||||
file_members = [m for m in members if not m.is_dir()]
|
||||
if not file_members:
|
||||
raise HTTPException(status_code=400, detail="Zip package has no files")
|
||||
|
||||
top_names: List[str] = []
|
||||
for member in file_members:
|
||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
if not raw_name:
|
||||
continue
|
||||
first = raw_name.split("/", 1)[0].strip()
|
||||
if not _is_valid_top_level_skill_name(first):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
|
||||
if first not in top_names:
|
||||
top_names.append(first)
|
||||
|
||||
if not top_names:
|
||||
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
|
||||
|
||||
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
|
||||
if conflicts:
|
||||
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_root) as tmp_dir:
|
||||
tmp_root = os.path.abspath(tmp_dir)
|
||||
for member in members:
|
||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
||||
if not raw_name:
|
||||
continue
|
||||
target = os.path.abspath(os.path.join(tmp_root, raw_name))
|
||||
if os.path.commonpath([tmp_root, target]) != tmp_root:
|
||||
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
|
||||
if member.is_dir():
|
||||
os.makedirs(target, exist_ok=True)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with archive.open(member, "r") as source, open(target, "wb") as dest:
|
||||
shutil.copyfileobj(source, dest)
|
||||
|
||||
for name in top_names:
|
||||
src = os.path.join(tmp_root, name)
|
||||
dst = os.path.join(skills_root, name)
|
||||
if not os.path.exists(src):
|
||||
continue
|
||||
shutil.move(src, dst)
|
||||
installed.append(name)
|
||||
install_result = _install_skill_zip_into_workspace(bot_id, tmp_zip_path)
|
||||
finally:
|
||||
if tmp_zip_path and os.path.exists(tmp_zip_path):
|
||||
os.remove(tmp_zip_path)
|
||||
|
||||
if not installed:
|
||||
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
|
||||
|
||||
return {
|
||||
"status": "installed",
|
||||
"bot_id": bot_id,
|
||||
"installed": installed,
|
||||
"skills": _list_workspace_skills(bot_id),
|
||||
"installed": install_result["installed"],
|
||||
"skills": install_result["skills"],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2886,18 +3421,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(
|
|||
.limit(safe_limit)
|
||||
).all()
|
||||
ordered = list(reversed(rows))
|
||||
payload = [
|
||||
{
|
||||
"id": row.id,
|
||||
"bot_id": row.bot_id,
|
||||
"role": row.role,
|
||||
"text": row.text,
|
||||
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)),
|
||||
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
|
||||
"ts": int(row.created_at.timestamp() * 1000),
|
||||
}
|
||||
for row in ordered
|
||||
]
|
||||
payload = [_serialize_bot_message_row(bot_id, row) for row in ordered]
|
||||
cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30)
|
||||
return payload
|
||||
|
||||
|
|
@ -2935,18 +3459,7 @@ def list_bot_messages_page(
|
|||
if has_more:
|
||||
rows = rows[:safe_limit]
|
||||
ordered = list(reversed(rows))
|
||||
items = [
|
||||
{
|
||||
"id": row.id,
|
||||
"bot_id": row.bot_id,
|
||||
"role": row.role,
|
||||
"text": row.text,
|
||||
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)),
|
||||
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
|
||||
"ts": int(row.created_at.timestamp() * 1000),
|
||||
}
|
||||
for row in ordered
|
||||
]
|
||||
items = [_serialize_bot_message_row(bot_id, row) for row in ordered]
|
||||
next_before_id = rows[-1].id if rows else None
|
||||
payload = {
|
||||
"items": items,
|
||||
|
|
@ -2958,6 +3471,113 @@ def list_bot_messages_page(
|
|||
return payload
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/messages/by-date")
|
||||
def list_bot_messages_by_date(
|
||||
bot_id: str,
|
||||
date: str,
|
||||
tz_offset_minutes: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
utc_start, utc_end = _resolve_local_day_range(date, tz_offset_minutes)
|
||||
configured_limit = max(60, get_chat_pull_page_size())
|
||||
safe_limit = max(12, min(int(limit if limit is not None else configured_limit), 240))
|
||||
before_limit = max(3, min(18, safe_limit // 4))
|
||||
after_limit = max(0, safe_limit - before_limit - 1)
|
||||
|
||||
exact_anchor = session.exec(
|
||||
select(BotMessage)
|
||||
.where(
|
||||
BotMessage.bot_id == bot_id,
|
||||
BotMessage.created_at >= utc_start,
|
||||
BotMessage.created_at < utc_end,
|
||||
)
|
||||
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
anchor = exact_anchor
|
||||
matched_exact_date = exact_anchor is not None
|
||||
if anchor is None:
|
||||
next_row = session.exec(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.created_at >= utc_end)
|
||||
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||
.limit(1)
|
||||
).first()
|
||||
prev_row = session.exec(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.created_at < utc_start)
|
||||
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if next_row and prev_row:
|
||||
gap_after = next_row.created_at - utc_end
|
||||
gap_before = utc_start - prev_row.created_at
|
||||
anchor = next_row if gap_after <= gap_before else prev_row
|
||||
else:
|
||||
anchor = next_row or prev_row
|
||||
|
||||
if anchor is None or anchor.id is None:
|
||||
return {
|
||||
"items": [],
|
||||
"anchor_id": None,
|
||||
"resolved_ts": None,
|
||||
"matched_exact_date": False,
|
||||
"has_more_before": False,
|
||||
"has_more_after": False,
|
||||
}
|
||||
|
||||
before_rows = session.exec(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.id < anchor.id)
|
||||
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||
.limit(before_limit)
|
||||
).all()
|
||||
after_rows = session.exec(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.id > anchor.id)
|
||||
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
|
||||
.limit(after_limit)
|
||||
).all()
|
||||
|
||||
ordered = list(reversed(before_rows)) + [anchor] + after_rows
|
||||
first_row = ordered[0] if ordered else None
|
||||
last_row = ordered[-1] if ordered else None
|
||||
|
||||
has_more_before = False
|
||||
if first_row is not None and first_row.id is not None:
|
||||
has_more_before = session.exec(
|
||||
select(BotMessage.id)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.id < first_row.id)
|
||||
.order_by(BotMessage.id.desc())
|
||||
.limit(1)
|
||||
).first() is not None
|
||||
|
||||
has_more_after = False
|
||||
if last_row is not None and last_row.id is not None:
|
||||
has_more_after = session.exec(
|
||||
select(BotMessage.id)
|
||||
.where(BotMessage.bot_id == bot_id, BotMessage.id > last_row.id)
|
||||
.order_by(BotMessage.id.asc())
|
||||
.limit(1)
|
||||
).first() is not None
|
||||
|
||||
return {
|
||||
"items": [_serialize_bot_message_row(bot_id, row) for row in ordered],
|
||||
"anchor_id": anchor.id,
|
||||
"resolved_ts": int(anchor.created_at.timestamp() * 1000),
|
||||
"matched_exact_date": matched_exact_date,
|
||||
"has_more_before": has_more_before,
|
||||
"has_more_after": has_more_after,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
|
||||
def update_bot_message_feedback(
|
||||
bot_id: str,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 控制平台。
|
||||
|
|
@ -130,6 +130,7 @@ body {
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-title-icon {
|
||||
|
|
@ -1185,6 +1186,22 @@ body {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-route-crumb {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-route-crumb:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.platform-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
|
|
@ -1939,6 +1956,431 @@ body {
|
|||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.skill-market-modal-shell {
|
||||
max-width: min(1480px, 96vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: min(920px, calc(100dvh - 48px));
|
||||
}
|
||||
|
||||
.skill-market-browser-shell {
|
||||
max-width: min(1400px, 96vw);
|
||||
width: min(1400px, 96vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: min(920px, calc(100dvh - 48px));
|
||||
}
|
||||
|
||||
.skill-market-page-shell {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
gap: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100dvh - 126px);
|
||||
}
|
||||
|
||||
.skill-market-page-info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.skill-market-page-info-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.skill-market-page-info-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.skill-market-page-info-copy strong {
|
||||
display: block;
|
||||
color: var(--title);
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.skill-market-page-info-copy div {
|
||||
color: var(--subtitle);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.skill-market-admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-market-search {
|
||||
flex: 1 1 auto;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.skill-market-admin-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-market-create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-market-create-btn svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.skill-market-page-workspace {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
padding-top: 3px;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.skill-market-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.skill-market-list-shell {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.skill-market-browser-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
grid-auto-rows: 1fr;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.skill-market-card,
|
||||
.skill-market-empty-card {
|
||||
min-height: 188px;
|
||||
}
|
||||
|
||||
.skill-market-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 72%, #f0b36a 28%);
|
||||
background:
|
||||
radial-gradient(circle at top right, color-mix(in oklab, var(--brand-soft) 36%, transparent), transparent 38%),
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--panel) 88%, #ffffff 12%), color-mix(in oklab, var(--panel) 96%, #f4eadf 4%));
|
||||
box-shadow: 0 14px 30px rgba(13, 24, 45, 0.12);
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.skill-market-card:hover,
|
||||
.skill-market-card.is-active {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%);
|
||||
box-shadow: 0 18px 34px rgba(13, 24, 45, 0.16);
|
||||
}
|
||||
|
||||
.skill-market-card-top,
|
||||
.skill-market-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skill-market-card-title-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-market-card-title-wrap h4 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
color: var(--title);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.skill-market-card-key {
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.skill-market-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skill-market-card-desc {
|
||||
margin: 0;
|
||||
color: var(--subtitle);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
min-height: 60px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-market-card-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.skill-market-card-meta span,
|
||||
.skill-market-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skill-market-card-footer {
|
||||
margin-top: auto;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.skill-market-card-status.is-ok {
|
||||
color: #d98c1f;
|
||||
}
|
||||
|
||||
.skill-market-card-status.is-missing {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.skill-market-browser-card {
|
||||
min-height: 312px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.skill-market-browser-badge {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.skill-market-browser-desc {
|
||||
min-height: 80px;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.skill-market-browser-meta {
|
||||
margin-top: auto;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.skill-market-browser-footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.skill-market-install-btn {
|
||||
min-height: 38px;
|
||||
padding-inline: 14px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 24px rgba(43, 87, 199, 0.24);
|
||||
}
|
||||
|
||||
.skill-market-empty-card {
|
||||
border: 1px dashed color-mix(in oklab, var(--line) 78%, var(--brand) 22%);
|
||||
border-radius: 22px;
|
||||
background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%);
|
||||
}
|
||||
|
||||
.skill-market-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skill-market-editor-textarea {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.skill-market-upload-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%);
|
||||
background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%);
|
||||
}
|
||||
|
||||
.skill-market-upload-card.has-file {
|
||||
border-color: color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
|
||||
}
|
||||
|
||||
.skill-market-upload-foot {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.skill-market-file-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed color-mix(in oklab, var(--line) 60%, var(--brand) 40%);
|
||||
background: color-mix(in oklab, var(--panel) 82%, #ffffff 18%);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.skill-market-file-picker:hover {
|
||||
border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
|
||||
background: color-mix(in oklab, var(--panel) 74%, var(--brand-soft) 26%);
|
||||
}
|
||||
|
||||
.skill-market-file-picker input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skill-market-file-picker-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.skill-market-file-picker-title {
|
||||
color: var(--title);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.skill-market-file-picker-action {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--brand) 14%, transparent);
|
||||
color: var(--icon);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.skill-market-browser-toolbar,
|
||||
.skill-market-pager,
|
||||
.row-actions-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.skill-market-pager {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-actions-inline {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-market-page-size-hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-market-drawer-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 18, 31, 0.26);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.skill-market-drawer-mask.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.skill-market-drawer {
|
||||
position: fixed;
|
||||
top: 94px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: min(460px, calc(100vw - 36px));
|
||||
transform: translateX(calc(100% + 20px));
|
||||
transition: transform 0.22s ease;
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
.skill-market-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.skill-market-drawer .skill-market-editor {
|
||||
height: 100%;
|
||||
padding: 22px;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 18px 42px rgba(13, 24, 45, 0.24);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-shell[data-theme='light'] .skill-market-file-picker {
|
||||
background: color-mix(in oklab, var(--panel) 80%, #f7fbff 20%);
|
||||
}
|
||||
|
||||
.app-shell[data-theme='light'] .skill-market-drawer-mask {
|
||||
background: rgba(111, 138, 179, 0.16);
|
||||
}
|
||||
|
||||
.app-shell[data-theme='light'] .platform-entry-card {
|
||||
border-color: #b7c7e6;
|
||||
background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
|
||||
|
|
@ -2118,6 +2560,16 @@ body {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-market-admin-layout,
|
||||
.skill-market-card-grid,
|
||||
.skill-market-browser-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-market-list-shell {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.platform-template-tabs {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
|
@ -2212,7 +2664,16 @@ body {
|
|||
.platform-selected-bot-last-row,
|
||||
.platform-settings-pager,
|
||||
.platform-usage-pager,
|
||||
.platform-template-header {
|
||||
.platform-template-header,
|
||||
.skill-market-admin-toolbar,
|
||||
.skill-market-browser-toolbar,
|
||||
.skill-market-pager,
|
||||
.skill-market-page-info-card,
|
||||
.skill-market-page-info-main,
|
||||
.skill-market-editor-head,
|
||||
.skill-market-card-top,
|
||||
.skill-market-card-footer,
|
||||
.row-actions-inline {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
|
@ -2225,4 +2686,21 @@ body {
|
|||
max-height: calc(90dvh - 60px);
|
||||
padding: 0 10px 12px;
|
||||
}
|
||||
|
||||
.skill-market-list-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-market-drawer {
|
||||
position: fixed;
|
||||
top: 84px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: min(460px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.app-route-crumb {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,15 @@ import { pickLocale } from './i18n';
|
|||
import { appZhCn } from './i18n/app.zh-cn';
|
||||
import { appEn } from './i18n/app.en';
|
||||
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
||||
import { PasswordInput } from './components/PasswordInput';
|
||||
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
|
||||
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
|
||||
import { BotHomePage } from './modules/bot-home/BotHomePage';
|
||||
import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage';
|
||||
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
|
||||
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
|
||||
import './App.css';
|
||||
|
||||
function getSingleBotPasswordKey(botId: string) {
|
||||
return `nanobot-bot-page-password:${String(botId || '').trim()}`;
|
||||
}
|
||||
|
||||
const defaultLoadingPage = {
|
||||
title: 'Dashboard Nanobot',
|
||||
subtitle: '平台正在准备管理面板',
|
||||
|
|
@ -37,6 +36,9 @@ function AuthenticatedApp() {
|
|||
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
||||
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
||||
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
|
||||
const passwordToggleLabels = locale === 'zh'
|
||||
? { show: '显示密码', hide: '隐藏密码' }
|
||||
: { show: 'Show password', hide: 'Hide password' };
|
||||
|
||||
const forcedBotId = route.kind === 'bot' ? route.botId : '';
|
||||
useBotsSync(forcedBotId || undefined);
|
||||
|
|
@ -61,15 +63,25 @@ function AuthenticatedApp() {
|
|||
const shouldPromptSingleBotPassword = Boolean(
|
||||
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
|
||||
);
|
||||
const headerTitle =
|
||||
route.kind === 'bot'
|
||||
? (forcedBot?.name || defaultLoadingPage.title)
|
||||
: route.kind === 'dashboard-skills'
|
||||
? (locale === 'zh' ? '技能市场管理' : 'Skill Marketplace')
|
||||
: t.title;
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind !== 'bot') {
|
||||
if (route.kind === 'dashboard') {
|
||||
document.title = t.title;
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'dashboard-skills') {
|
||||
document.title = `${t.title} - ${locale === 'zh' ? '技能市场' : 'Skill Marketplace'}`;
|
||||
return;
|
||||
}
|
||||
const botName = String(forcedBot?.name || '').trim();
|
||||
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
|
||||
}, [forcedBot?.name, forcedBotId, route.kind, t.title]);
|
||||
}, [forcedBot?.name, forcedBotId, locale, route.kind, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
setSingleBotUnlocked(false);
|
||||
|
|
@ -79,18 +91,19 @@ function AuthenticatedApp() {
|
|||
|
||||
useEffect(() => {
|
||||
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
||||
const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forcedBotId)) || '' : '';
|
||||
const stored = getBotAccessPassword(forcedBotId);
|
||||
if (!stored) return;
|
||||
let alive = true;
|
||||
const boot = async () => {
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
|
||||
if (!alive) return;
|
||||
setBotAccessPassword(forcedBotId, stored);
|
||||
setSingleBotUnlocked(true);
|
||||
setSingleBotPassword('');
|
||||
setSingleBotPasswordError('');
|
||||
} catch {
|
||||
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
|
||||
clearBotAccessPassword(forcedBotId);
|
||||
if (!alive) return;
|
||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
|
||||
}
|
||||
|
|
@ -110,22 +123,24 @@ function AuthenticatedApp() {
|
|||
setSingleBotSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered);
|
||||
}
|
||||
setBotAccessPassword(forcedBotId, entered);
|
||||
setSingleBotPasswordError('');
|
||||
setSingleBotUnlocked(true);
|
||||
setSingleBotPassword('');
|
||||
} catch {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
|
||||
}
|
||||
clearBotAccessPassword(forcedBotId);
|
||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
|
||||
} finally {
|
||||
setSingleBotSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToDashboard = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.history.pushState({}, '', '/dashboard');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
||||
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
|
||||
|
|
@ -140,12 +155,18 @@ function AuthenticatedApp() {
|
|||
<div className="app-title">
|
||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
||||
<div className="app-title-main">
|
||||
<h1>{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}</h1>
|
||||
<div className="app-route-subtitle">
|
||||
{route.kind === 'dashboard'
|
||||
? (locale === 'zh' ? '平台总览' : 'Platform overview')
|
||||
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
|
||||
</div>
|
||||
<h1>{headerTitle}</h1>
|
||||
{route.kind === 'dashboard-skills' ? (
|
||||
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
|
||||
{locale === 'zh' ? '平台总览' : 'Platform Overview'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="app-route-subtitle">
|
||||
{route.kind === 'dashboard'
|
||||
? (locale === 'zh' ? '平台总览' : 'Platform overview')
|
||||
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
|
||||
</div>
|
||||
)}
|
||||
{isCompactShell ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -201,6 +222,8 @@ function AuthenticatedApp() {
|
|||
<main className="main-stage">
|
||||
{route.kind === 'dashboard' ? (
|
||||
<PlatformDashboardPage compactMode={compactMode} />
|
||||
) : route.kind === 'dashboard-skills' ? (
|
||||
<SkillMarketManagerPage isZh={locale === 'zh'} />
|
||||
) : (
|
||||
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
|
||||
)}
|
||||
|
|
@ -214,9 +237,8 @@ function AuthenticatedApp() {
|
|||
<h1>{forcedBot?.name || forcedBotId}</h1>
|
||||
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
|
||||
<div className="app-login-form">
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
value={singleBotPassword}
|
||||
onChange={(event) => {
|
||||
setSingleBotPassword(event.target.value);
|
||||
|
|
@ -227,6 +249,7 @@ function AuthenticatedApp() {
|
|||
}}
|
||||
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
|
||||
autoFocus
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
|
||||
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
|
||||
|
|
@ -250,6 +273,9 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
|
|||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const passwordToggleLabels = locale === 'zh'
|
||||
? { show: '显示密码', hide: '隐藏密码' }
|
||||
: { show: 'Show password', hide: 'Hide password' };
|
||||
const bypassPanelGate = route.kind === 'bot';
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -344,15 +370,15 @@ function PanelLoginGate({ children }: { children: ReactElement }) {
|
|||
<h1>{t.title}</h1>
|
||||
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
|
||||
<div className="app-login-form">
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void onSubmit();
|
||||
}}
|
||||
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
{error ? <div className="app-login-error">{error}</div> : null}
|
||||
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1333,6 +1333,7 @@
|
|||
}
|
||||
|
||||
.ops-control-command-drawer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
|
@ -1346,9 +1347,10 @@
|
|||
}
|
||||
|
||||
.ops-control-command-drawer.is-open {
|
||||
max-width: 240px;
|
||||
max-width: 332px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
overflow: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
|
@ -1384,6 +1386,39 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ops-control-date-panel {
|
||||
position: fixed;
|
||||
width: 220px;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 14px 28px rgba(9, 16, 31, 0.2);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
z-index: 140;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ops-control-date-label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--subtitle);
|
||||
}
|
||||
|
||||
.ops-control-date-input {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ops-control-date-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ops-control-command-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
@ -1589,10 +1624,15 @@
|
|||
|
||||
@media (max-width: 720px) {
|
||||
.ops-control-command-drawer.is-open {
|
||||
max-width: 220px;
|
||||
max-width: 288px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ops-control-date-panel {
|
||||
right: -2px;
|
||||
width: min(220px, calc(100vw - 44px));
|
||||
}
|
||||
|
||||
.ops-voice-wave {
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
|
|
@ -2723,6 +2763,32 @@
|
|||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ops-skill-add-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid color-mix(in oklab, var(--line) 82%, transparent);
|
||||
}
|
||||
|
||||
.ops-skill-add-hint {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ops-skill-create-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-skill-create-trigger svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ops-topic-create-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
|
@ -2774,6 +2840,16 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ops-skill-add-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ops-skill-create-trigger {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ops-config-footer {
|
||||
position: static;
|
||||
border-top: 0;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
|
||||
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
|
@ -19,7 +19,10 @@ import { dashboardEn } from '../../i18n/dashboard.en';
|
|||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||
import { PasswordInput } from '../../components/PasswordInput';
|
||||
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
|
||||
import type { BotSkillMarketItem } from '../platform/types';
|
||||
import { SkillMarketInstallModal } from './components/SkillMarketInstallModal';
|
||||
import {
|
||||
normalizePlatformPageSize,
|
||||
readCachedPlatformPageSize,
|
||||
|
|
@ -92,6 +95,15 @@ interface WorkspaceUploadResponse {
|
|||
files: Array<{ name: string; path: string; size: number }>;
|
||||
}
|
||||
|
||||
interface BotMessagesByDateResponse {
|
||||
items?: any[];
|
||||
anchor_id?: number | null;
|
||||
resolved_ts?: number | null;
|
||||
matched_exact_date?: boolean;
|
||||
has_more_before?: boolean;
|
||||
has_more_after?: boolean;
|
||||
}
|
||||
|
||||
interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -270,6 +282,14 @@ interface SkillUploadResponse {
|
|||
skills: WorkspaceSkillOption[];
|
||||
}
|
||||
|
||||
interface MarketSkillInstallResponse {
|
||||
status: string;
|
||||
bot_id: string;
|
||||
skill_market_item_id: number;
|
||||
installed: string[];
|
||||
skills: WorkspaceSkillOption[];
|
||||
}
|
||||
|
||||
interface SystemDefaultsResponse {
|
||||
limits?: {
|
||||
upload_max_mb?: number;
|
||||
|
|
@ -448,6 +468,32 @@ function formatConversationDate(ts: number, isZh: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatDateInputValue(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function mapBotMessageResponseRow(row: any): ChatMessage {
|
||||
const roleRaw = String(row?.role || '').toLowerCase();
|
||||
const role: ChatMessage['role'] =
|
||||
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
||||
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
||||
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
||||
return {
|
||||
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
||||
role,
|
||||
text: String(row?.text || ''),
|
||||
attachments: normalizeAttachmentPaths(row?.media),
|
||||
ts: Number(row?.ts || Date.now()),
|
||||
feedback,
|
||||
kind: 'final',
|
||||
} as ChatMessage;
|
||||
}
|
||||
|
||||
function stateLabel(s?: string) {
|
||||
return (s || 'IDLE').toUpperCase();
|
||||
}
|
||||
|
|
@ -1034,6 +1080,8 @@ export function BotDashboardModule({
|
|||
const [showChannelModal, setShowChannelModal] = useState(false);
|
||||
const [showTopicModal, setShowTopicModal] = useState(false);
|
||||
const [showSkillsModal, setShowSkillsModal] = useState(false);
|
||||
const [showSkillMarketInstallModal, setShowSkillMarketInstallModal] = useState(false);
|
||||
const [skillAddMenuOpen, setSkillAddMenuOpen] = useState(false);
|
||||
const [showMcpModal, setShowMcpModal] = useState(false);
|
||||
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
||||
const [showCronModal, setShowCronModal] = useState(false);
|
||||
|
|
@ -1082,6 +1130,7 @@ export function BotDashboardModule({
|
|||
const [expandedChannelByKey, setExpandedChannelByKey] = useState<Record<string, boolean>>({});
|
||||
const [newChannelPanelOpen, setNewChannelPanelOpen] = useState(false);
|
||||
const [channelCreateMenuOpen, setChannelCreateMenuOpen] = useState(false);
|
||||
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [newChannelDraft, setNewChannelDraft] = useState<BotChannel>({
|
||||
id: 'draft-channel',
|
||||
bot_id: '',
|
||||
|
|
@ -1098,6 +1147,7 @@ export function BotDashboardModule({
|
|||
const [topicPresetTemplates, setTopicPresetTemplates] = useState<TopicPresetTemplate[]>([]);
|
||||
const [newTopicSource, setNewTopicSource] = useState<string>('');
|
||||
const [topicPresetMenuOpen, setTopicPresetMenuOpen] = useState(false);
|
||||
const topicPresetMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [newTopicAdvancedOpen, setNewTopicAdvancedOpen] = useState(false);
|
||||
const [newTopicKey, setNewTopicKey] = useState('');
|
||||
const [newTopicName, setNewTopicName] = useState('');
|
||||
|
|
@ -1109,8 +1159,12 @@ export function BotDashboardModule({
|
|||
const [newTopicExamplesNegative, setNewTopicExamplesNegative] = useState('');
|
||||
const [newTopicPriority, setNewTopicPriority] = useState('50');
|
||||
const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
|
||||
const [marketSkills, setMarketSkills] = useState<BotSkillMarketItem[]>([]);
|
||||
const [isSkillUploading, setIsSkillUploading] = useState(false);
|
||||
const [isMarketSkillsLoading, setIsMarketSkillsLoading] = useState(false);
|
||||
const [marketSkillInstallingId, setMarketSkillInstallingId] = useState<number | null>(null);
|
||||
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
|
||||
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [envParams, setEnvParams] = useState<BotEnvParams>({});
|
||||
const [mcpServers, setMcpServers] = useState<MCPServerDraft[]>([]);
|
||||
const [persistedMcpServers, setPersistedMcpServers] = useState<MCPServerDraft[]>([]);
|
||||
|
|
@ -1129,8 +1183,6 @@ export function BotDashboardModule({
|
|||
const [expandedMcpByKey, setExpandedMcpByKey] = useState<Record<string, boolean>>({});
|
||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [isSavingTopic, setIsSavingTopic] = useState(false);
|
||||
const [isSavingMcp, setIsSavingMcp] = useState(false);
|
||||
|
|
@ -1151,6 +1203,11 @@ export function BotDashboardModule({
|
|||
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
||||
const [chatHasMore, setChatHasMore] = useState(false);
|
||||
const [chatLoadingMore, setChatLoadingMore] = useState(false);
|
||||
const [chatDatePickerOpen, setChatDatePickerOpen] = useState(false);
|
||||
const [chatDateValue, setChatDateValue] = useState('');
|
||||
const [chatDateJumping, setChatDateJumping] = useState(false);
|
||||
const [chatJumpAnchorId, setChatJumpAnchorId] = useState<number | null>(null);
|
||||
const [chatDatePanelPosition, setChatDatePanelPosition] = useState<{ bottom: number; right: number } | null>(null);
|
||||
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
|
||||
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
||||
);
|
||||
|
|
@ -1197,6 +1254,8 @@ export function BotDashboardModule({
|
|||
const voiceTimerRef = useRef<number | null>(null);
|
||||
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const botListMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const controlCommandPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const botOrderRef = useRef<Record<string, number>>({});
|
||||
const nextBotOrderRef = useRef(1);
|
||||
const applyEditFormFromBot = useCallback((bot?: any) => {
|
||||
|
|
@ -1598,6 +1657,9 @@ export function BotDashboardModule({
|
|||
const isZh = locale === 'zh';
|
||||
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
|
||||
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
|
||||
const passwordToggleLabels = isZh
|
||||
? { show: '显示密码', hide: '隐藏密码' }
|
||||
: { show: 'Show password', hide: 'Hide password' };
|
||||
const activeTopicOptions = useMemo<TopicFeedOption[]>(
|
||||
() =>
|
||||
topics
|
||||
|
|
@ -1818,7 +1880,10 @@ export function BotDashboardModule({
|
|||
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
|
||||
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
|
||||
return (
|
||||
<div key={itemKey}>
|
||||
<div
|
||||
key={itemKey}
|
||||
data-chat-message-id={item.id ? String(item.id) : undefined}
|
||||
>
|
||||
{showDateDivider ? (
|
||||
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
|
||||
<span>{formatConversationDate(item.ts, isZh)}</span>
|
||||
|
|
@ -2114,9 +2179,32 @@ export function BotDashboardModule({
|
|||
if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) {
|
||||
setBotListMenuOpen(false);
|
||||
}
|
||||
if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) {
|
||||
setChatDatePickerOpen(false);
|
||||
}
|
||||
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
|
||||
setChannelCreateMenuOpen(false);
|
||||
}
|
||||
if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) {
|
||||
setTopicPresetMenuOpen(false);
|
||||
}
|
||||
if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) {
|
||||
setSkillAddMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
setChatDatePickerOpen(false);
|
||||
setChannelCreateMenuOpen(false);
|
||||
setTopicPresetMenuOpen(false);
|
||||
setSkillAddMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2261,6 +2349,15 @@ export function BotDashboardModule({
|
|||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const forced = String(forcedBotId || '').trim();
|
||||
if (forced) {
|
||||
const targetId = String(selectedBotId || forced).trim() || forced;
|
||||
const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`);
|
||||
setBots(botRes.data ? [botRes.data] : []);
|
||||
await loadImageOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
|
||||
setBots(botsRes.data);
|
||||
await loadImageOptions();
|
||||
|
|
@ -2996,6 +3093,20 @@ export function BotDashboardModule({
|
|||
setBotSkills(Array.isArray(res.data) ? res.data : []);
|
||||
};
|
||||
|
||||
const loadMarketSkills = async (botId: string) => {
|
||||
if (!botId) return;
|
||||
setIsMarketSkillsLoading(true);
|
||||
try {
|
||||
const res = await axios.get<BotSkillMarketItem[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`);
|
||||
setMarketSkills(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (error: any) {
|
||||
setMarketSkills([]);
|
||||
notify(error?.response?.data?.detail || t.toolsLoadFail, { tone: 'error' });
|
||||
} finally {
|
||||
setIsMarketSkillsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBotEnvParams = async (botId: string) => {
|
||||
if (!botId) return;
|
||||
try {
|
||||
|
|
@ -3189,11 +3300,39 @@ export function BotDashboardModule({
|
|||
try {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
|
||||
await loadBotSkills(selectedBot.id);
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const installMarketSkill = async (marketSkill: BotSkillMarketItem) => {
|
||||
if (!selectedBot) return;
|
||||
const skillId = Number(marketSkill.id);
|
||||
if (!Number.isFinite(skillId) || skillId <= 0) return;
|
||||
setMarketSkillInstallingId(skillId);
|
||||
try {
|
||||
const res = await axios.post<MarketSkillInstallResponse>(
|
||||
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skill-market/${skillId}/install`,
|
||||
);
|
||||
setBotSkills(Array.isArray(res.data?.skills) ? res.data.skills : []);
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
notify(
|
||||
isZh
|
||||
? `已安装技能:${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`
|
||||
: `Installed: ${(res.data?.installed || []).join(', ') || marketSkill.display_name || marketSkill.skill_key}`,
|
||||
{ tone: 'success' },
|
||||
);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
|
||||
if (selectedBot) {
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
}
|
||||
} finally {
|
||||
setMarketSkillInstallingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSkillZipUpload = () => {
|
||||
if (!selectedBot || isSkillUploading) return;
|
||||
skillZipPickerRef.current?.click();
|
||||
|
|
@ -3218,6 +3357,7 @@ export function BotDashboardModule({
|
|||
);
|
||||
const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : [];
|
||||
setBotSkills(nextSkills);
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
|
||||
} finally {
|
||||
|
|
@ -3420,7 +3560,7 @@ export function BotDashboardModule({
|
|||
if (ctype === 'telegram') {
|
||||
return (
|
||||
<>
|
||||
<input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.proxy}
|
||||
|
|
@ -3447,7 +3587,7 @@ export function BotDashboardModule({
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
|
||||
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
|
||||
</>
|
||||
|
|
@ -3458,7 +3598,7 @@ export function BotDashboardModule({
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3467,7 +3607,7 @@ export function BotDashboardModule({
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3476,7 +3616,7 @@ export function BotDashboardModule({
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3529,7 +3669,7 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="ops-config-field">
|
||||
<label className="field-label">{lc.emailImapPassword}</label>
|
||||
<input className="input" type="password" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</div>
|
||||
<div className="ops-config-field">
|
||||
<label className="field-label">{lc.emailImapMailbox}</label>
|
||||
|
|
@ -3560,7 +3700,7 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="ops-config-field">
|
||||
<label className="field-label">{lc.emailSmtpPassword}</label>
|
||||
<input className="input" type="password" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</div>
|
||||
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
||||
<label className="field-label">
|
||||
|
|
@ -3929,6 +4069,7 @@ export function BotDashboardModule({
|
|||
setPendingAttachments([]);
|
||||
setQuotedReply(null);
|
||||
}
|
||||
setChatDatePickerOpen(false);
|
||||
setControlCommandPanelOpen(false);
|
||||
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
|
|
@ -3943,6 +4084,73 @@ export function BotDashboardModule({
|
|||
}
|
||||
};
|
||||
|
||||
const updateChatDatePanelPosition = useCallback(() => {
|
||||
const trigger = chatDateTriggerRef.current;
|
||||
if (!trigger || typeof window === 'undefined') return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const viewportPadding = 12;
|
||||
setChatDatePanelPosition({
|
||||
bottom: Math.max(viewportPadding, window.innerHeight - rect.top + 8),
|
||||
right: Math.max(viewportPadding, window.innerWidth - rect.right),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleChatDatePicker = () => {
|
||||
if (!selectedBotId || chatDateJumping) return;
|
||||
if (!chatDateValue) {
|
||||
const fallbackTs = conversation[conversation.length - 1]?.ts || Date.now();
|
||||
setChatDateValue(formatDateInputValue(fallbackTs));
|
||||
}
|
||||
setChatDatePickerOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) {
|
||||
setChatDatePanelPosition(null);
|
||||
return next;
|
||||
}
|
||||
updateChatDatePanelPosition();
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const jumpConversationToDate = async () => {
|
||||
if (!selectedBotId || chatDateJumping) return;
|
||||
const safeDate = String(chatDateValue || '').trim();
|
||||
if (!safeDate) {
|
||||
notify(isZh ? '请选择日期。' : 'Choose a date first.', { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setChatDateJumping(true);
|
||||
const result = await fetchBotMessagesByDate(selectedBotId, safeDate);
|
||||
if (result.items.length <= 0) {
|
||||
notify(isZh ? '该日期附近没有可显示的对话。' : 'No conversation found near that date.', { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
setBotMessages(selectedBotId, result.items);
|
||||
setChatHasMore(Boolean(result.hasMoreBefore));
|
||||
setChatLoadingMore(false);
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDatePanelPosition(null);
|
||||
setControlCommandPanelOpen(false);
|
||||
setChatJumpAnchorId(result.anchorId);
|
||||
chatAutoFollowRef.current = false;
|
||||
if (!result.matchedExactDate && result.resolvedTs) {
|
||||
notify(
|
||||
isZh
|
||||
? `所选日期没有消息,已定位到 ${formatConversationDate(result.resolvedTs, true)}。`
|
||||
: `No messages on that date. Jumped to ${formatConversationDate(result.resolvedTs, false)}.`,
|
||||
{ tone: 'warning' },
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || (isZh ? '按日期读取对话失败。' : 'Failed to load conversation by date.'), {
|
||||
tone: 'error',
|
||||
});
|
||||
} finally {
|
||||
setChatDateJumping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const interruptExecution = async () => {
|
||||
if (!selectedBot || !canChat) return;
|
||||
if (interruptingByBot[selectedBot.id]) return;
|
||||
|
|
@ -3956,6 +4164,7 @@ export function BotDashboardModule({
|
|||
if (!res.data?.success) {
|
||||
throw new Error(t.backendDeliverFail);
|
||||
}
|
||||
setChatDatePickerOpen(false);
|
||||
setControlCommandPanelOpen(false);
|
||||
notify(t.interruptSent, { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
|
|
@ -4010,22 +4219,7 @@ export function BotDashboardModule({
|
|||
});
|
||||
const rows = Array.isArray(res.data) ? res.data : [];
|
||||
return rows
|
||||
.map((row) => {
|
||||
const roleRaw = String(row?.role || '').toLowerCase();
|
||||
const role: ChatMessage['role'] =
|
||||
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
||||
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
||||
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
||||
return {
|
||||
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
||||
role,
|
||||
text: String(row?.text || ''),
|
||||
attachments: normalizeAttachmentPaths(row?.media),
|
||||
ts: Number(row?.ts || Date.now()),
|
||||
feedback,
|
||||
kind: 'final',
|
||||
} as ChatMessage;
|
||||
})
|
||||
.map((row) => mapBotMessageResponseRow(row))
|
||||
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
|
||||
.slice(-safeLimit);
|
||||
}, [chatPullPageSize]);
|
||||
|
|
@ -4051,22 +4245,7 @@ export function BotDashboardModule({
|
|||
);
|
||||
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||
const items = rows
|
||||
.map((row) => {
|
||||
const roleRaw = String(row?.role || '').toLowerCase();
|
||||
const role: ChatMessage['role'] =
|
||||
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
|
||||
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
|
||||
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
|
||||
return {
|
||||
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
|
||||
role,
|
||||
text: String(row?.text || ''),
|
||||
attachments: normalizeAttachmentPaths(row?.media),
|
||||
ts: Number(row?.ts || Date.now()),
|
||||
feedback,
|
||||
kind: 'final',
|
||||
} as ChatMessage;
|
||||
})
|
||||
.map((row) => mapBotMessageResponseRow(row))
|
||||
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
|
||||
const nextBeforeRaw = Number(res.data?.next_before_id);
|
||||
const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null;
|
||||
|
|
@ -4077,6 +4256,44 @@ export function BotDashboardModule({
|
|||
};
|
||||
}, [chatPullPageSize]);
|
||||
|
||||
const fetchBotMessagesByDate = useCallback(async (
|
||||
botId: string,
|
||||
dateValue: string,
|
||||
): Promise<{ items: ChatMessage[]; anchorId: number | null; resolvedTs: number | null; matchedExactDate: boolean; hasMoreBefore: boolean }> => {
|
||||
const safeDate = String(dateValue || '').trim();
|
||||
if (!safeDate) {
|
||||
return {
|
||||
items: [],
|
||||
anchorId: null,
|
||||
resolvedTs: null,
|
||||
matchedExactDate: false,
|
||||
hasMoreBefore: false,
|
||||
};
|
||||
}
|
||||
const safeLimit = Math.max(40, Math.min(180, chatPullPageSize));
|
||||
const tzOffsetMinutes = new Date().getTimezoneOffset();
|
||||
const res = await axios.get<BotMessagesByDateResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages/by-date`, {
|
||||
params: {
|
||||
date: safeDate,
|
||||
tz_offset_minutes: tzOffsetMinutes,
|
||||
limit: safeLimit,
|
||||
},
|
||||
});
|
||||
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||
const items = rows
|
||||
.map((row) => mapBotMessageResponseRow(row))
|
||||
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
|
||||
const anchorRaw = Number(res.data?.anchor_id);
|
||||
const resolvedRaw = Number(res.data?.resolved_ts);
|
||||
return {
|
||||
items,
|
||||
anchorId: Number.isFinite(anchorRaw) && anchorRaw > 0 ? Math.floor(anchorRaw) : null,
|
||||
resolvedTs: Number.isFinite(resolvedRaw) && resolvedRaw > 0 ? Math.floor(resolvedRaw) : null,
|
||||
matchedExactDate: Boolean(res.data?.matched_exact_date),
|
||||
hasMoreBefore: Boolean(res.data?.has_more_before),
|
||||
};
|
||||
}, [chatPullPageSize]);
|
||||
|
||||
const loadMoreChatMessages = useCallback(async () => {
|
||||
if (!selectedBotId || chatLoadingMore || !chatHasMore) return;
|
||||
const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress');
|
||||
|
|
@ -4133,6 +4350,24 @@ export function BotDashboardModule({
|
|||
}
|
||||
}, [chatHasMore, chatLoadingMore, loadMoreChatMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBotId || !chatJumpAnchorId) return;
|
||||
const anchorSelector = `[data-chat-message-id="${chatJumpAnchorId}"]`;
|
||||
const scrollToAnchor = () => {
|
||||
const box = chatScrollRef.current;
|
||||
if (!box) return;
|
||||
const anchor = box.querySelector<HTMLElement>(anchorSelector);
|
||||
if (anchor) {
|
||||
anchor.scrollIntoView({ block: 'start' });
|
||||
} else {
|
||||
box.scrollTop = 0;
|
||||
}
|
||||
setChatJumpAnchorId(null);
|
||||
};
|
||||
const raf = window.requestAnimationFrame(scrollToAnchor);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [selectedBotId, chatJumpAnchorId, messages.length]);
|
||||
|
||||
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
|
||||
if (!selectedBotId) {
|
||||
notify(t.feedbackMessagePending, { tone: 'warning' });
|
||||
|
|
@ -4503,6 +4738,10 @@ export function BotDashboardModule({
|
|||
if (!selectedBotId) {
|
||||
setChatHasMore(false);
|
||||
setChatLoadingMore(false);
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDateJumping(false);
|
||||
setChatJumpAnchorId(null);
|
||||
setChatDateValue('');
|
||||
setWorkspaceEntries([]);
|
||||
setWorkspaceCurrentPath('');
|
||||
setWorkspaceParentPath(null);
|
||||
|
|
@ -4527,6 +4766,9 @@ export function BotDashboardModule({
|
|||
setPendingAttachments([]);
|
||||
setCronJobs([]);
|
||||
setBotSkills([]);
|
||||
setMarketSkills([]);
|
||||
setShowSkillMarketInstallModal(false);
|
||||
setSkillAddMenuOpen(false);
|
||||
setEnvParams({});
|
||||
setExpandedMcpByKey({});
|
||||
setNewMcpPanelOpen(false);
|
||||
|
|
@ -4542,6 +4784,10 @@ export function BotDashboardModule({
|
|||
}
|
||||
setChatHasMore(false);
|
||||
setChatLoadingMore(false);
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDateJumping(false);
|
||||
setChatJumpAnchorId(null);
|
||||
setChatDateValue('');
|
||||
setTopics([]);
|
||||
setExpandedTopicByKey({});
|
||||
setNewTopicPanelOpen(false);
|
||||
|
|
@ -4587,6 +4833,12 @@ export function BotDashboardModule({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBotId || chatDateValue) return;
|
||||
const fallbackTs = messages[messages.length - 1]?.ts || Date.now();
|
||||
setChatDateValue(formatDateInputValue(fallbackTs));
|
||||
}, [selectedBotId, chatDateValue, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
|
||||
let stopped = false;
|
||||
|
|
@ -4608,6 +4860,29 @@ export function BotDashboardModule({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (controlCommandPanelOpen) return;
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDatePanelPosition(null);
|
||||
}, [controlCommandPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatDatePickerOpen) return;
|
||||
setChatDatePanelPosition(null);
|
||||
}, [chatDatePickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatDatePickerOpen) return;
|
||||
updateChatDatePanelPosition();
|
||||
const handleViewportChange = () => updateChatDatePanelPosition();
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [chatDatePickerOpen, updateChatDatePanelPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return;
|
||||
const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey);
|
||||
|
|
@ -5341,7 +5616,7 @@ export function BotDashboardModule({
|
|||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
||||
<div className="ops-composer-float-controls">
|
||||
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
||||
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -5376,11 +5651,63 @@ export function BotDashboardModule({
|
|||
{interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
|
||||
<span className="mono">/stop</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-control-command-chip"
|
||||
ref={chatDateTriggerRef}
|
||||
disabled={!selectedBotId || chatDateJumping}
|
||||
onClick={toggleChatDatePicker}
|
||||
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
|
||||
title={isZh ? '按日期定位对话' : 'Jump to date'}
|
||||
>
|
||||
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
|
||||
<span className="mono">/time</span>
|
||||
</button>
|
||||
</div>
|
||||
{chatDatePickerOpen ? (
|
||||
<div
|
||||
className="ops-control-date-panel"
|
||||
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
|
||||
>
|
||||
<label className="ops-control-date-label">
|
||||
<span>{isZh ? '选择日期' : 'Select date'}</span>
|
||||
<input
|
||||
className="input ops-control-date-input"
|
||||
type="date"
|
||||
value={chatDateValue}
|
||||
max={formatDateInputValue(Date.now())}
|
||||
onChange={(event) => setChatDateValue(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="ops-control-date-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setChatDatePickerOpen(false)}
|
||||
>
|
||||
{isZh ? '取消' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={chatDateJumping || !chatDateValue}
|
||||
onClick={() => void jumpConversationToDate()}
|
||||
>
|
||||
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
|
||||
<span style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
|
||||
{isZh ? '跳转' : 'Jump'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
|
||||
onClick={() => setControlCommandPanelOpen((prev) => !prev)}
|
||||
onClick={() => {
|
||||
setChatDatePickerOpen(false);
|
||||
setControlCommandPanelOpen((prev) => !prev);
|
||||
}}
|
||||
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
|
||||
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
|
||||
>
|
||||
|
|
@ -5931,12 +6258,12 @@ export function BotDashboardModule({
|
|||
<input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} />
|
||||
|
||||
<label className="field-label">{t.accessPassword}</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
value={editForm.access_password}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
|
||||
placeholder={t.accessPasswordPlaceholder}
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
|
||||
<label className="field-label">{t.baseImageReadonly}</label>
|
||||
|
|
@ -6018,7 +6345,7 @@ export function BotDashboardModule({
|
|||
<input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />
|
||||
|
||||
<label className="field-label">{t.newApiKey}</label>
|
||||
<input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />
|
||||
<PasswordInput className="input" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} toggleLabels={passwordToggleLabels} />
|
||||
|
||||
<label className="field-label">API Base</label>
|
||||
<input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />
|
||||
|
|
@ -6272,7 +6599,7 @@ export function BotDashboardModule({
|
|||
{!newChannelPanelOpen ? (
|
||||
<div className="row-between ops-config-footer">
|
||||
<span className="field-label">{lc.channelAddHint}</span>
|
||||
<div className="ops-topic-create-menu-wrap">
|
||||
<div className="ops-topic-create-menu-wrap" ref={channelCreateMenuRef}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
||||
|
|
@ -6643,7 +6970,7 @@ export function BotDashboardModule({
|
|||
{!newTopicPanelOpen ? (
|
||||
<div className="row-between ops-config-footer">
|
||||
<span className="field-label">{t.topicAddHint}</span>
|
||||
<div className="ops-topic-create-menu-wrap">
|
||||
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={isSavingTopic || !selectedBot}
|
||||
|
|
@ -6670,46 +6997,81 @@ export function BotDashboardModule({
|
|||
)}
|
||||
|
||||
{showSkillsModal && (
|
||||
<div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
|
||||
<div className="modal-mask" onClick={() => {
|
||||
setSkillAddMenuOpen(false);
|
||||
setShowSkillsModal(false);
|
||||
}}>
|
||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title-row modal-title-with-close">
|
||||
<div className="modal-title-main">
|
||||
<h3>{t.skillsPanel}</h3>
|
||||
<span className="modal-sub">
|
||||
{isZh ? '查看当前 Bot 已安装的技能。' : 'View the skills already installed for this bot.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="modal-title-actions">
|
||||
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} tooltip={t.close} aria-label={t.close}>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => {
|
||||
if (!selectedBot) return;
|
||||
void loadBotSkills(selectedBot.id);
|
||||
}}
|
||||
tooltip={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
|
||||
aria-label={isZh ? '刷新已安装技能' : 'Refresh installed skills'}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => {
|
||||
setSkillAddMenuOpen(false);
|
||||
setShowSkillsModal(false);
|
||||
}} tooltip={t.close} aria-label={t.close}>
|
||||
<X size={14} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wizard-channel-list ops-skills-list-scroll">
|
||||
{botSkills.length === 0 ? (
|
||||
<div className="ops-empty-inline">{t.skillsEmpty}</div>
|
||||
) : (
|
||||
botSkills.map((skill) => (
|
||||
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="row-between">
|
||||
<div>
|
||||
<strong>{skill.name || skill.id}</strong>
|
||||
<div className="field-label mono">{skill.path}</div>
|
||||
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
|
||||
<div className="field-label">{skill.description || '-'}</div>
|
||||
</div>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => void removeBotSkill(skill)}
|
||||
tooltip={t.removeSkill}
|
||||
aria-label={t.removeSkill}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
|
||||
<div className="stack">
|
||||
<div className="row-between">
|
||||
<div>
|
||||
<div className="section-mini-title">{isZh ? '已安装技能' : 'Installed Skills'}</div>
|
||||
<div className="field-label">
|
||||
{isZh ? '这里展示当前 Bot 工作区中的技能。' : 'These skills are already present in the bot workspace.'}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="field-label">
|
||||
{isZh ? `${botSkills.length} 个已安装` : `${botSkills.length} installed`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-channel-list ops-skills-list-scroll">
|
||||
{botSkills.length === 0 ? (
|
||||
<div className="ops-empty-inline">{t.skillsEmpty}</div>
|
||||
) : (
|
||||
botSkills.map((skill) => (
|
||||
<div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="row-between">
|
||||
<div>
|
||||
<strong>{skill.name || skill.id}</strong>
|
||||
<div className="field-label mono">{skill.path}</div>
|
||||
<div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
|
||||
<div className="field-label">{skill.description || '-'}</div>
|
||||
</div>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => void removeBotSkill(skill)}
|
||||
tooltip={t.removeSkill}
|
||||
aria-label={t.removeSkill}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-between">
|
||||
<div className="ops-skill-add-bar">
|
||||
<input
|
||||
ref={skillZipPickerRef}
|
||||
type="file"
|
||||
|
|
@ -6717,24 +7079,73 @@ export function BotDashboardModule({
|
|||
onChange={onPickSkillZip}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={isSkillUploading}
|
||||
onClick={triggerSkillZipUpload}
|
||||
title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
|
||||
aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
|
||||
>
|
||||
{isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null}
|
||||
<span style={{ marginLeft: isSkillUploading ? 6 : 0 }}>
|
||||
{isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
|
||||
</span>
|
||||
</button>
|
||||
<span className="field-label">{t.zipOnlyHint}</span>
|
||||
<div className="field-label ops-skill-add-hint">
|
||||
{isSkillUploading
|
||||
? (isZh ? '正在上传 ZIP 技能包...' : 'Uploading ZIP skill package...')
|
||||
: (isZh ? '支持上传本地 ZIP,或从技能市场安装技能到当前 Bot。' : 'Upload a local ZIP or install a skill from the marketplace into this bot.')}
|
||||
</div>
|
||||
<div className="ops-topic-create-menu-wrap" ref={skillAddMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm ops-skill-create-trigger"
|
||||
onClick={() => setSkillAddMenuOpen((prev) => !prev)}
|
||||
disabled={!selectedBot}
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>{isZh ? '新增技能' : 'Add Skill'}</span>
|
||||
</button>
|
||||
{skillAddMenuOpen ? (
|
||||
<div className="ops-topic-create-menu">
|
||||
<button
|
||||
className="ops-topic-create-menu-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSkillAddMenuOpen(false);
|
||||
triggerSkillZipUpload();
|
||||
}}
|
||||
>
|
||||
{isZh ? '本地上传 ZIP' : 'Upload Local ZIP'}
|
||||
</button>
|
||||
<button
|
||||
className="ops-topic-create-menu-item"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!selectedBot) return;
|
||||
setSkillAddMenuOpen(false);
|
||||
void loadMarketSkills(selectedBot.id);
|
||||
setShowSkillMarketInstallModal(true);
|
||||
}}
|
||||
>
|
||||
{isZh ? '从技能市场安装' : 'Install From Marketplace'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillMarketInstallModal
|
||||
isZh={isZh}
|
||||
open={showSkillMarketInstallModal}
|
||||
items={marketSkills}
|
||||
loading={isMarketSkillsLoading}
|
||||
installingId={marketSkillInstallingId}
|
||||
onClose={() => setShowSkillMarketInstallModal(false)}
|
||||
onRefresh={async () => {
|
||||
if (!selectedBot) return;
|
||||
await loadMarketSkills(selectedBot.id);
|
||||
}}
|
||||
onInstall={async (skill) => {
|
||||
await installMarketSkill(skill);
|
||||
if (selectedBot) {
|
||||
await loadBotSkills(selectedBot.id);
|
||||
}
|
||||
}}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
|
||||
{showMcpModal && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
|
|
@ -6845,14 +7256,14 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="ops-config-field">
|
||||
<label className="field-label">X-Bot-Secret</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
value={row.botSecret}
|
||||
placeholder={t.mcpBotSecretPlaceholder}
|
||||
onChange={(e) => updateMcpServer(idx, { botSecret: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
disabled={row.locked}
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
</div>
|
||||
<div className="ops-config-field">
|
||||
|
|
@ -6948,13 +7359,13 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="ops-config-field">
|
||||
<label className="field-label">X-Bot-Secret</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
value={newMcpDraft.botSecret}
|
||||
placeholder={t.mcpBotSecretPlaceholder}
|
||||
onChange={(e) => setNewMcpDraft((prev) => ({ ...prev, botSecret: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
</div>
|
||||
<div className="ops-config-field">
|
||||
|
|
@ -7024,22 +7435,18 @@ export function BotDashboardModule({
|
|||
<div key={key} className="card wizard-channel-card wizard-channel-compact">
|
||||
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} />
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type={envVisibleByKey[key] ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||
placeholder={t.envValue}
|
||||
autoComplete="off"
|
||||
wrapperClassName="is-inline"
|
||||
toggleLabels={{
|
||||
show: t.showEnvValue,
|
||||
hide: t.hideEnvValue,
|
||||
}}
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
tooltip={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
|
||||
aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
|
||||
>
|
||||
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => removeEnvParam(key)}
|
||||
|
|
@ -7062,22 +7469,18 @@ export function BotDashboardModule({
|
|||
placeholder={t.envKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type={envDraftVisible ? 'text' : 'password'}
|
||||
value={envDraftValue}
|
||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||
placeholder={t.envValue}
|
||||
autoComplete="off"
|
||||
wrapperClassName="is-inline"
|
||||
toggleLabels={{
|
||||
show: t.showEnvValue,
|
||||
hide: t.hideEnvValue,
|
||||
}}
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setEnvDraftVisible((v) => !v)}
|
||||
tooltip={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
|
||||
aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
|
||||
>
|
||||
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { managementZhCn } from '../../../i18n/management.zh-cn';
|
|||
import { managementEn } from '../../../i18n/management.en';
|
||||
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
||||
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
||||
import { PasswordInput } from '../../../components/PasswordInput';
|
||||
|
||||
interface CreateBotModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -25,6 +26,9 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
|||
const locale = useAppStore((s) => s.locale);
|
||||
const { notify } = useLucentPrompt();
|
||||
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create;
|
||||
const passwordToggleLabels = locale === 'zh'
|
||||
? { show: '显示密码', hide: '隐藏密码' }
|
||||
: { show: 'Show password', hide: 'Hide password' };
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
|
|
@ -146,12 +150,12 @@ export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalPro
|
|||
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
||||
<Key size={14} /> API Key
|
||||
</label>
|
||||
<input
|
||||
<PasswordInput
|
||||
required
|
||||
type="password"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
||||
placeholder="sk-..."
|
||||
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Eye, EyeOff, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
import { Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
import { APP_ENDPOINTS } from '../../config/env';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { channelsZhCn } from '../../i18n/channels.zh-cn';
|
||||
|
|
@ -11,6 +11,7 @@ import { wizardEn } from '../../i18n/wizard.en';
|
|||
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
|
||||
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||
import { LucentSelect } from '../../components/lucent/LucentSelect';
|
||||
import { PasswordInput } from '../../components/PasswordInput';
|
||||
|
||||
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
|
||||
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
|
||||
|
|
@ -146,8 +147,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
const [showToolsConfigModal, setShowToolsConfigModal] = useState(false);
|
||||
const [envDraftKey, setEnvDraftKey] = useState('');
|
||||
const [envDraftValue, setEnvDraftValue] = useState('');
|
||||
const [envDraftVisible, setEnvDraftVisible] = useState(false);
|
||||
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
|
||||
const [newChannelType, setNewChannelType] = useState<ChannelType | ''>('');
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState('');
|
||||
|
|
@ -162,6 +161,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
const isZh = locale === 'zh';
|
||||
const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn });
|
||||
const lc = isZh ? channelsZhCn : channelsEn;
|
||||
const passwordToggleLabels = isZh
|
||||
? { show: '显示密码', hide: '隐藏密码' }
|
||||
: { show: 'Show password', hide: 'Hide password' };
|
||||
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
|
||||
const activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
|
||||
const addableChannelTypes = useMemo(
|
||||
|
|
@ -518,13 +520,13 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
if (channel.channel_type === 'telegram') {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={lc.telegramToken}
|
||||
value={channel.app_secret}
|
||||
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
toggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
|
|
@ -556,7 +558,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder={lc.encryptKey}
|
||||
|
|
@ -583,7 +585,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -592,7 +594,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -601,7 +603,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
return (
|
||||
<>
|
||||
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} autoComplete="off" />
|
||||
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" />
|
||||
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -723,7 +725,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
<option value="xunfei">xunfei (spark)</option>
|
||||
</LucentSelect>
|
||||
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
|
||||
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
|
||||
<PasswordInput className="input" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} toggleLabels={passwordToggleLabels} />
|
||||
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
|
||||
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
||||
{providerPresets[form.llm_provider]?.note[noteLocale]}
|
||||
|
|
@ -968,22 +970,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
readOnly
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type={envVisibleByKey[key] ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => upsertEnvParam(key, e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
autoComplete="off"
|
||||
wrapperClassName="is-inline"
|
||||
toggleLabels={{
|
||||
show: ui.showEnvValue,
|
||||
hide: ui.hideEnvValue,
|
||||
}}
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm wizard-icon-btn"
|
||||
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
tooltip={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
|
||||
aria-label={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
|
||||
>
|
||||
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
onClick={() => removeEnvParam(key)}
|
||||
|
|
@ -1005,22 +1003,18 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
|
|||
placeholder={ui.envKey}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
<PasswordInput
|
||||
className="input"
|
||||
type={envDraftVisible ? 'text' : 'password'}
|
||||
value={envDraftValue}
|
||||
onChange={(e) => setEnvDraftValue(e.target.value)}
|
||||
placeholder={ui.envValue}
|
||||
autoComplete="off"
|
||||
wrapperClassName="is-inline"
|
||||
toggleLabels={{
|
||||
show: ui.showEnvValue,
|
||||
hide: ui.hideEnvValue,
|
||||
}}
|
||||
/>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setEnvDraftVisible((v) => !v)}
|
||||
tooltip={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
|
||||
aria-label={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
|
||||
>
|
||||
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ interface BotResourceSnapshot {
|
|||
collected_at?: string;
|
||||
}
|
||||
|
||||
function navigatePlatform(path: '/dashboard' | '/dashboard/skills') {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.history.pushState({}, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProps) {
|
||||
const { activeBots, setBots, updateBotStatus, locale } = useAppStore();
|
||||
const { notify, confirm } = useLucentPrompt();
|
||||
|
|
@ -625,11 +631,11 @@ export function PlatformDashboardPage({ compactMode }: PlatformDashboardPageProp
|
|||
<button
|
||||
className="platform-entry-card"
|
||||
type="button"
|
||||
onClick={() => notify(isZh ? '技能市场即将开放,敬请期待。' : 'Skill marketplace is coming soon.', { tone: 'warning' })}
|
||||
onClick={() => navigatePlatform('/dashboard/skills')}
|
||||
>
|
||||
<Hammer size={18} />
|
||||
<strong>{isZh ? '技能市场' : 'Skill Marketplace'}</strong>
|
||||
<span>{isZh ? '入口已预留,当前版本暂未开放。' : 'This entry is reserved and will open in a future release.'}</span>
|
||||
<span>{isZh ? '管理技能包元数据,并给 Bot 技能面板提供一键安装源。' : 'Manage marketplace metadata and provide one-click installs to bot skill panels.'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -59,6 +59,29 @@ export interface PlatformUsageResponse {
|
|||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface SkillMarketItem {
|
||||
id: number;
|
||||
skill_key: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
zip_filename: string;
|
||||
zip_size_bytes: number;
|
||||
entry_names: string[];
|
||||
entry_count: number;
|
||||
zip_exists: boolean;
|
||||
install_count: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface BotSkillMarketItem extends SkillMarketItem {
|
||||
installed?: boolean;
|
||||
install_status?: string;
|
||||
installed_at?: string | null;
|
||||
installed_entries?: string[];
|
||||
install_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformOverviewResponse {
|
||||
summary: {
|
||||
bots: {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
export type AppRoute =
|
||||
| { kind: 'dashboard' }
|
||||
| { kind: 'dashboard-skills' }
|
||||
| { kind: 'bot'; botId: string };
|
||||
|
||||
function parsePathname(pathname: string): AppRoute {
|
||||
const raw = String(pathname || '/').trim() || '/';
|
||||
if (/^\/dashboard\/skills\/?$/i.test(raw)) {
|
||||
return { kind: 'dashboard-skills' };
|
||||
}
|
||||
const botMatch = raw.match(/^\/bot\/([^/?#]+)/i);
|
||||
if (botMatch?.[1]) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import axios from 'axios';
|
||||
let initialized = false;
|
||||
const memoryMap = new Map<string, string>();
|
||||
const BOT_PASSWORD_HEADER = 'X-Bot-Access-Password';
|
||||
const BOT_STORAGE_KEY_PREFIX = 'nanobot-bot-page-password:';
|
||||
|
||||
function normalizeBotId(raw: string): string {
|
||||
return String(raw || '').trim();
|
||||
}
|
||||
|
||||
function buildBotAccessStorageKey(botId: string): string {
|
||||
return `${BOT_STORAGE_KEY_PREFIX}${normalizeBotId(botId)}`;
|
||||
}
|
||||
|
||||
function resolveAbsoluteUrl(input: string): string {
|
||||
const url = String(input || '').trim();
|
||||
if (!url) return '';
|
||||
|
|
@ -37,25 +43,44 @@ export function extractBotIdFromApiPath(rawPath: string): string | null {
|
|||
export function getBotAccessPassword(botId: string): string {
|
||||
const key = normalizeBotId(botId);
|
||||
if (!key) return '';
|
||||
return memoryMap.get(key) || '';
|
||||
const cached = memoryMap.get(key) || '';
|
||||
if (cached) return cached;
|
||||
if (typeof window === 'undefined') return '';
|
||||
const stored = window.sessionStorage.getItem(buildBotAccessStorageKey(key)) || '';
|
||||
if (stored) memoryMap.set(key, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function setBotAccessPassword(botId: string, password: string): void {
|
||||
const key = normalizeBotId(botId);
|
||||
const value = String(password || '').trim();
|
||||
if (!key || !value) return;
|
||||
memoryMap.set(key, value);
|
||||
if (!key) return;
|
||||
if (value) {
|
||||
memoryMap.set(key, value);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(buildBotAccessStorageKey(key), value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearBotAccessPassword(key);
|
||||
}
|
||||
|
||||
export function clearBotAccessPassword(botId: string): void {
|
||||
const key = normalizeBotId(botId);
|
||||
if (!key) return;
|
||||
memoryMap.delete(key);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(buildBotAccessStorageKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllBotAccessPasswords(): void {
|
||||
if (memoryMap.size === 0) return;
|
||||
const keys = Array.from(memoryMap.keys());
|
||||
memoryMap.clear();
|
||||
if (typeof window !== 'undefined') {
|
||||
keys.forEach((botId) => window.sessionStorage.removeItem(buildBotAccessStorageKey(botId)));
|
||||
}
|
||||
}
|
||||
|
||||
export function isBotUnauthorizedError(error: any, botId?: string): boolean {
|
||||
|
|
@ -71,5 +96,19 @@ export function buildMonitorWsUrl(base: string, botId: string): string {
|
|||
export function setupBotAccessAuth(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
void axios;
|
||||
axios.interceptors.request.use((config) => {
|
||||
const botId = extractBotIdFromApiPath(String(config.url || ''));
|
||||
if (!botId) return config;
|
||||
if (String(config.url || '').includes(`/api/bots/${encodeURIComponent(botId)}/auth/login`)) {
|
||||
return config;
|
||||
}
|
||||
const password = getBotAccessPassword(botId);
|
||||
if (!password) return config;
|
||||
const headers = config.headers || {};
|
||||
if (!(BOT_PASSWORD_HEADER in (headers as Record<string, unknown>))) {
|
||||
(headers as Record<string, string>)[BOT_PASSWORD_HEADER] = password;
|
||||
config.headers = headers;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue