diff --git a/.env.prod.example b/.env.prod.example index 186130c..164e3db 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -27,8 +27,20 @@ NPM_REGISTRY=https://registry.npmmirror.com # Keep empty to use SQLite at: # sqlite:///{HOST_DATA_ROOT}/nanobot_dashboard.db # DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard -# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard -DATABASE_URL= +DATABASE_URL=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/nanobot_dashboard + +# Redis cache +REDIS_ENABLED=true +REDIS_URL=redis://127.0.0.1:6379/8 +REDIS_PREFIX=dashboard_nanobot +REDIS_DEFAULT_TTL=60 + +# Panel access protection +PANEL_ACCESS_PASSWORD=change_me_panel_password # Max upload size for backend validation (MB) UPLOAD_MAX_MB=100 + +# 升级迁移 +AUTO_MIGRATE_SQLITE_TO_PRIMARY=true +SQLITE_MIGRATION_SOURCE=../data/nanobot_dashboard.db diff --git a/backend/.env.example b/backend/.env.example index 8cf25a9..0bf35b0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,21 @@ BOTS_WORKSPACE_ROOT=../workspace/bots # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # Show SQL statements in backend logs (debug only). DATABASE_ECHO=true +# Auto-migrate legacy SQLite data into primary database on first startup. +AUTO_MIGRATE_SQLITE_TO_PRIMARY=true +# Legacy SQLite source path used by the auto-migrator. +SQLITE_MIGRATION_SOURCE=../data/nanobot_dashboard.db + +# Redis cache +REDIS_ENABLED=false +# Example: +# REDIS_URL=redis://127.0.0.1:6379/8 +REDIS_URL= +REDIS_PREFIX=dashboard_nanobot +REDIS_DEFAULT_TTL=60 + +# Optional panel-level access password for all backend API/WS calls. +PANEL_ACCESS_PASSWORD= # Max upload size for backend validation (MB) UPLOAD_MAX_MB=100 diff --git a/backend/core/cache.py b/backend/core/cache.py new file mode 100644 index 0000000..738725d --- /dev/null +++ b/backend/core/cache.py @@ -0,0 +1,88 @@ +import json +from typing import Any, Optional + +from core.settings import REDIS_DEFAULT_TTL, REDIS_ENABLED, REDIS_PREFIX, REDIS_URL + +try: + from redis import Redis +except Exception: # pragma: no cover + Redis = None # type: ignore + + +class RedisCache: + def __init__(self): + self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None) + self.prefix = REDIS_PREFIX + self.default_ttl = int(REDIS_DEFAULT_TTL) + self._client: Optional["Redis"] = None + if self.enabled: + try: + self._client = Redis.from_url(REDIS_URL, decode_responses=True) + self._client.ping() + except Exception: + self.enabled = False + self._client = None + + def _full_key(self, key: str) -> str: + return f"{self.prefix}:{key}" + + def ping(self) -> bool: + if not self.enabled or self._client is None: + return False + try: + return bool(self._client.ping()) + except Exception: + return False + + def get_json(self, key: str) -> Any: + if not self.enabled or self._client is None: + return None + try: + raw = self._client.get(self._full_key(key)) + if not raw: + return None + return json.loads(raw) + except Exception: + return None + + def set_json(self, key: str, value: Any, ttl: Optional[int] = None) -> None: + if not self.enabled or self._client is None: + return + try: + self._client.setex( + self._full_key(key), + int(ttl if ttl is not None else self.default_ttl), + json.dumps(value, ensure_ascii=False, default=str), + ) + except Exception: + return + + def delete(self, *keys: str) -> None: + if not self.enabled or self._client is None: + return + full_keys = [self._full_key(key) for key in keys if str(key or "").strip()] + if not full_keys: + return + try: + self._client.delete(*full_keys) + except Exception: + return + + def delete_prefix(self, prefix: str) -> None: + if not self.enabled or self._client is None: + return + pattern = self._full_key(f"{prefix}*") + try: + cursor = 0 + while True: + cursor, rows = self._client.scan(cursor=cursor, match=pattern, count=200) + if rows: + self._client.delete(*rows) + if cursor == 0: + break + except Exception: + return + + +cache = RedisCache() + diff --git a/backend/core/database.py b/backend/core/database.py index 9a02a42..1c180c8 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import text +from sqlalchemy import inspect, text from sqlmodel import SQLModel, Session, create_engine from core.settings import DATABASE_ECHO, DATABASE_URL @@ -29,8 +29,6 @@ def _ensure_botinstance_columns() -> None: def _drop_legacy_botinstance_columns() -> None: - if engine.dialect.name != "sqlite": - return legacy_columns = [ "avatar_model", "avatar_skin", @@ -55,12 +53,24 @@ def _drop_legacy_botinstance_columns() -> None: "bot_env_json", ] with engine.connect() as conn: - existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() - existing = {str(row[1]) for row in existing_rows} + existing = { + str(col.get("name")) + for col in inspect(conn).get_columns("botinstance") + if col.get("name") + } for col in legacy_columns: if col not in existing: continue - conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN "{col}"')) + try: + if engine.dialect.name == "mysql": + conn.execute(text(f"ALTER TABLE botinstance DROP COLUMN `{col}`")) + elif engine.dialect.name == "sqlite": + conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN "{col}"')) + else: + conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN IF EXISTS "{col}"')) + except Exception: + # Keep startup resilient on mixed/legacy database engines. + continue conn.commit() @@ -90,12 +100,42 @@ def _drop_legacy_skill_tables() -> None: conn.commit() +def align_postgres_sequences() -> None: + if engine.dialect.name != "postgresql": + return + sequence_targets = [ + ("botmessage", "id"), + ] + with engine.connect() as conn: + for table_name, column_name in sequence_targets: + seq_name = conn.execute( + text("SELECT pg_get_serial_sequence(:table_name, :column_name)"), + {"table_name": table_name, "column_name": column_name}, + ).scalar() + if not seq_name: + continue + max_id = conn.execute( + text(f'SELECT COALESCE(MAX("{column_name}"), 0) FROM "{table_name}"') + ).scalar() + max_id = int(max_id or 0) + conn.execute( + text("SELECT setval(:seq_name, :next_value, :is_called)"), + { + "seq_name": seq_name, + "next_value": max_id if max_id > 0 else 1, + "is_called": max_id > 0, + }, + ) + conn.commit() + + def init_database() -> None: SQLModel.metadata.create_all(engine) _drop_legacy_skill_tables() _ensure_botinstance_columns() _drop_legacy_botinstance_columns() _ensure_botmessage_columns() + align_postgres_sequences() def get_session(): diff --git a/backend/core/settings.py b/backend/core/settings.py index 254f81f..03935ab 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -112,6 +112,16 @@ DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048) +REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False) +REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip() +REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot" +REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400) +AUTO_MIGRATE_SQLITE_TO_PRIMARY: Final[bool] = _env_bool("AUTO_MIGRATE_SQLITE_TO_PRIMARY", True) +SQLITE_MIGRATION_SOURCE: Final[str] = _normalize_dir_path( + os.getenv("SQLITE_MIGRATION_SOURCE", str(Path(DATA_ROOT) / "nanobot_dashboard.db")) +) +PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() + DEFAULT_AGENTS_MD: Final[str] = _env_text( "DEFAULT_AGENTS_MD", "# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown(.md)格式。", diff --git a/backend/main.py b/backend/main.py index e8356c6..d7fa81d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ import mimetypes import os import re import shutil +import sqlite3 import tempfile import zipfile from datetime import datetime @@ -18,9 +19,11 @@ from fastapi.middleware.cors import CORSMiddleware from sqlmodel import Session, select from core.config_manager import BotConfigManager -from core.database import engine, get_session, init_database +from core.cache import cache +from core.database import align_postgres_sequences, engine, get_session, init_database from core.docker_manager import BotDockerManager from core.settings import ( + AUTO_MIGRATE_SQLITE_TO_PRIMARY, BOTS_WORKSPACE_ROOT, DATA_ROOT, DATABASE_ECHO, @@ -31,7 +34,11 @@ from core.settings import ( DEFAULT_SOUL_MD, DEFAULT_TOOLS_MD, DEFAULT_USER_MD, + PANEL_ACCESS_PASSWORD, PROJECT_ROOT, + REDIS_ENABLED, + REDIS_URL, + SQLITE_MIGRATION_SOURCE, UPLOAD_MAX_MB, ) from models.bot import BotInstance, BotMessage, NanobotImage @@ -141,6 +148,10 @@ class MessageFeedbackRequest(BaseModel): feedback: Optional[str] = None # up | down | null +class PanelLoginRequest(BaseModel): + password: Optional[str] = None + + def _normalize_packet_channel(packet: Dict[str, Any]) -> str: raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: @@ -250,6 +261,9 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int session.commit() if persisted_message_id: packet["message_id"] = persisted_message_id + if packet_type in {"ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: + _invalidate_bot_messages_cache(bot_id) + _invalidate_bot_detail_cache(bot_id) return persisted_message_id @@ -280,6 +294,7 @@ class WSConnectionManager: manager = WSConnectionManager() BOT_ACCESS_PASSWORD_HEADER = "x-bot-password" +PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" def _extract_bot_id_from_api_path(path: str) -> Optional[str]: @@ -307,11 +322,38 @@ def _get_supplied_bot_password_http(request: Request) -> str: return query_value +def _get_supplied_panel_password_http(request: Request) -> str: + header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip() + if header_value: + return header_value + query_value = str(request.query_params.get("panel_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: + return None + candidate = str(supplied or "").strip() + if not candidate: + return "Panel access password required" + if candidate != configured: + return "Invalid panel access password" + return None + + @app.middleware("http") async def bot_access_password_guard(request: Request, call_next): if request.method.upper() == "OPTIONS": return await call_next(request) + if request.url.path.startswith("/api/"): + if request.url.path in {"/api/panel/auth/status", "/api/panel/auth/login"}: + return await call_next(request) + 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: return await call_next(request) @@ -331,6 +373,23 @@ async def bot_access_password_guard(request: Request, call_next): return await call_next(request) +@app.get("/api/panel/auth/status") +def get_panel_auth_status(): + configured = str(PANEL_ACCESS_PASSWORD or "").strip() + return {"enabled": bool(configured)} + + +@app.post("/api/panel/auth/login") +def panel_login(payload: PanelLoginRequest): + configured = str(PANEL_ACCESS_PASSWORD or "").strip() + if not configured: + return {"success": True, "enabled": False} + supplied = str(payload.password or "").strip() + if supplied != configured: + raise HTTPException(status_code=401, detail="Invalid panel access password") + return {"success": True, "enabled": True} + + def docker_callback(bot_id: str, packet: Dict[str, Any]): _persist_runtime_packet(bot_id, packet) loop = getattr(app.state, "main_loop", None) @@ -339,13 +398,142 @@ def docker_callback(bot_id: str, packet: Dict[str, Any]): asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop) +def _cache_key_bots_list() -> str: + return "bots:list" + + +def _cache_key_bot_detail(bot_id: str) -> str: + return f"bot:detail:{bot_id}" + + +def _cache_key_bot_messages(bot_id: str, limit: int) -> str: + return f"bot:messages:{bot_id}:limit:{limit}" + + +def _cache_key_images() -> str: + return "images:list" + + +def _invalidate_bot_detail_cache(bot_id: str) -> None: + cache.delete(_cache_key_bots_list(), _cache_key_bot_detail(bot_id)) + + +def _invalidate_bot_messages_cache(bot_id: str) -> None: + cache.delete_prefix(f"bot:messages:{bot_id}:") + + +def _invalidate_images_cache() -> None: + cache.delete(_cache_key_images()) + + +def _parse_dt(raw: Any) -> datetime: + if isinstance(raw, datetime): + return raw + text = str(raw or "").strip() + if not text: + return datetime.utcnow() + for candidate in (text, text.replace("Z", "+00:00")): + try: + return datetime.fromisoformat(candidate) + except Exception: + continue + return datetime.utcnow() + + +def _target_has_data() -> bool: + with Session(engine) as session: + if session.exec(select(BotInstance).limit(1)).first() is not None: + return True + if session.exec(select(BotMessage).limit(1)).first() is not None: + return True + if session.exec(select(NanobotImage).limit(1)).first() is not None: + return True + return False + + +def _migrate_sqlite_if_needed() -> None: + if DATABASE_ENGINE == "sqlite": + return + if not AUTO_MIGRATE_SQLITE_TO_PRIMARY: + return + source_path = str(SQLITE_MIGRATION_SOURCE or "").strip() + if not source_path or not os.path.isfile(source_path): + return + if _target_has_data(): + return + + conn = sqlite3.connect(source_path) + conn.row_factory = sqlite3.Row + try: + with Session(engine) as session: + for row in conn.execute("SELECT * FROM botinstance"): + bot_id = str(row["id"]) + if session.get(BotInstance, bot_id): + continue + session.add( + BotInstance( + id=bot_id, + name=str(row["name"] or ""), + access_password=str(row["access_password"] or ""), + workspace_dir=str(row["workspace_dir"] or ""), + docker_status=str(row["docker_status"] or "STOPPED"), + current_state=str(row["current_state"] or "IDLE"), + last_action=str(row["last_action"] or "") or None, + image_tag=str(row["image_tag"] or "nanobot-base:v0.1.4"), + created_at=_parse_dt(row["created_at"]), + updated_at=_parse_dt(row["updated_at"]), + ) + ) + + for row in conn.execute("SELECT * FROM nanobotimage"): + tag = str(row["tag"]) + if session.get(NanobotImage, tag): + continue + session.add( + NanobotImage( + tag=tag, + image_id=str(row["image_id"] or "") or None, + version=str(row["version"] or ""), + status=str(row["status"] or "READY"), + source_dir=str(row["source_dir"] or "") or None, + created_at=_parse_dt(row["created_at"]), + ) + ) + + session.commit() + + for row in conn.execute("SELECT * FROM botmessage ORDER BY id ASC"): + message_id = int(row["id"]) + if session.get(BotMessage, message_id): + continue + session.add( + BotMessage( + id=message_id, + bot_id=str(row["bot_id"] or ""), + role=str(row["role"] or "assistant"), + text=str(row["text"] or ""), + media_json=str(row["media_json"] or "") or None, + feedback=str(row["feedback"] or "") or None, + feedback_at=_parse_dt(row["feedback_at"]) if row["feedback_at"] else None, + created_at=_parse_dt(row["created_at"]), + ) + ) + session.commit() + align_postgres_sequences() + finally: + conn.close() + + @app.on_event("startup") async def on_startup(): app.state.main_loop = asyncio.get_running_loop() print(f"📁 项目根目录: {PROJECT_ROOT}") print(f"🗄️ 数据库引擎: {DATABASE_ENGINE} (echo={DATABASE_ECHO})") print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") + print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})") init_database() + _migrate_sqlite_if_needed() + cache.delete_prefix("") with Session(engine) as session: for bot in session.exec(select(BotInstance)).all(): _migrate_bot_resources_store(bot.id) @@ -708,48 +896,6 @@ def _parse_message_media(bot_id: str, media_raw: Optional[str]) -> List[str]: return [] -def _default_tools_config() -> Dict[str, Any]: - return { - "web": { - "search": { - "apiKey": "", - "maxResults": 5, - } - } - } - - -def _normalize_tools_config(raw: Any) -> Dict[str, Any]: - cfg = _default_tools_config() - if not isinstance(raw, dict): - return cfg - - web_raw = raw.get("web") - if isinstance(web_raw, dict): - search_raw = web_raw.get("search") - if isinstance(search_raw, dict): - api_key = str(search_raw.get("apiKey") or search_raw.get("api_key") or "").strip() - max_results_raw = search_raw.get("maxResults", search_raw.get("max_results", 5)) - try: - max_results = int(max_results_raw) - except Exception: - max_results = 5 - max_results = max(1, min(max_results, 10)) - cfg["web"]["search"]["apiKey"] = api_key - cfg["web"]["search"]["maxResults"] = max_results - return cfg - - -def _parse_tools_config(raw: Optional[str]) -> Dict[str, Any]: - if not raw: - return _default_tools_config() - try: - parsed = json.loads(raw) - except Exception: - return _default_tools_config() - return _normalize_tools_config(parsed) - - _ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") @@ -837,7 +983,6 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: channels_cfg = config_data.get("channels") send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg) - tools_cfg = _normalize_tools_config(config_data.get("tools")) llm_provider = provider_name or "dashscope" llm_model = str(agents_defaults.get("model") or "") api_key = str(provider_cfg.get("apiKey") or "").strip() @@ -865,8 +1010,6 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: "tools_md": _read_workspace_md(bot.id, "TOOLS.md", DEFAULT_TOOLS_MD), "identity_md": _read_workspace_md(bot.id, "IDENTITY.md", DEFAULT_IDENTITY_MD), "system_prompt": soul_md, - "tools_config_json": json.dumps(tools_cfg, ensure_ascii=False), - "tools_config": tools_cfg, } @@ -906,6 +1049,19 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: } +def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]: + return { + "id": bot.id, + "name": bot.name, + "has_access_password": bool(str(bot.access_password or "").strip()), + "image_tag": bot.image_tag, + "docker_status": bot.docker_status, + "current_state": bot.current_state, + "last_action": bot.last_action, + "updated_at": bot.updated_at, + } + + def _sync_workspace_channels( session: Session, bot_id: str, @@ -937,7 +1093,6 @@ def _sync_workspace_channels( "storage_gb": _safe_int(snapshot.get("storage_gb"), 10), "send_progress": bool(snapshot.get("send_progress")), "send_tool_hints": bool(snapshot.get("send_tool_hints")), - "tools_config_json": json.dumps(_normalize_tools_config(snapshot.get("tools_config")), ensure_ascii=False), } if isinstance(runtime_overrides, dict): for key, value in runtime_overrides.items(): @@ -950,9 +1105,6 @@ def _sync_workspace_channels( bot_data["cpu_cores"] = resources["cpu_cores"] bot_data["memory_mb"] = resources["memory_mb"] bot_data["storage_gb"] = resources["storage_gb"] - if "tools_config" in bot_data: - bot_data["tools_config_json"] = json.dumps(_normalize_tools_config(bot_data.get("tools_config")), ensure_ascii=False) - send_progress = bool(bot_data.get("send_progress", False)) send_tool_hints = bool(bot_data.get("send_tool_hints", False)) if isinstance(global_delivery_override, dict): @@ -1285,8 +1437,16 @@ def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]: @app.get("/api/images", response_model=List[NanobotImage]) def list_images(session: Session = Depends(get_session)): + cached = cache.get_json(_cache_key_images()) + if isinstance(cached, list) and all(isinstance(row, dict) for row in cached): + return cached + if isinstance(cached, list): + _invalidate_images_cache() reconcile_image_registry(session) - return session.exec(select(NanobotImage)).all() + rows = session.exec(select(NanobotImage)).all() + payload = [row.model_dump() for row in rows] + cache.set_json(_cache_key_images(), payload, ttl=60) + return payload @app.delete("/api/images/{tag:path}") @@ -1302,6 +1462,7 @@ def delete_image(tag: str, session: Session = Depends(get_session)): session.delete(image) session.commit() + _invalidate_images_cache() return {"status": "deleted"} @@ -1345,6 +1506,7 @@ def register_image(payload: dict, session: Session = Depends(get_session)): session.add(row) session.commit() session.refresh(row) + _invalidate_images_cache() return row @@ -1458,17 +1620,20 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session "user_md": payload.user_md or DEFAULT_USER_MD, "tools_md": payload.tools_md or DEFAULT_TOOLS_MD, "identity_md": payload.identity_md or DEFAULT_IDENTITY_MD, - "tools_config_json": json.dumps(_normalize_tools_config(payload.tools_config), ensure_ascii=False), "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, }, ) session.refresh(bot) + _invalidate_bot_detail_cache(payload.id) return _serialize_bot(bot) @app.get("/api/bots") def list_bots(session: Session = Depends(get_session)): + cached = cache.get_json(_cache_key_bots_list()) + if isinstance(cached, list): + return cached bots = session.exec(select(BotInstance)).all() dirty = False for bot in bots: @@ -1483,7 +1648,22 @@ def list_bots(session: Session = Depends(get_session)): session.commit() for bot in bots: session.refresh(bot) - return [_serialize_bot(bot) for bot in bots] + rows = [_serialize_bot_list_item(bot) for bot in bots] + cache.set_json(_cache_key_bots_list(), rows, ttl=30) + return rows + + +@app.get("/api/bots/{bot_id}") +def get_bot_detail(bot_id: str, session: Session = Depends(get_session)): + cached = cache.get_json(_cache_key_bot_detail(bot_id)) + if isinstance(cached, dict): + return cached + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + row = _serialize_bot(bot) + cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30) + return row @app.get("/api/bots/{bot_id}/resources") @@ -1550,9 +1730,7 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None runtime_overrides: Dict[str, Any] = {} - tools_config = update_data.pop("tools_config", None) if isinstance(update_data, dict) else None - if tools_config is not None: - runtime_overrides["tools_config_json"] = json.dumps(_normalize_tools_config(tools_config), ensure_ascii=False) + update_data.pop("tools_config", None) if isinstance(update_data, dict) else None runtime_fields = { "llm_provider", @@ -1615,6 +1793,7 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend global_delivery_override=global_delivery_override, ) session.refresh(bot) + _invalidate_bot_detail_cache(bot_id) return _serialize_bot(bot) @@ -1647,12 +1826,14 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): if actual_status != "RUNNING": session.add(bot) session.commit() + _invalidate_bot_detail_cache(bot_id) raise HTTPException( status_code=500, detail="Bot container failed shortly after startup. Check bot logs/config.", ) session.add(bot) session.commit() + _invalidate_bot_detail_cache(bot_id) return {"status": "started"} @@ -1666,6 +1847,7 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)): bot.docker_status = "STOPPED" session.add(bot) session.commit() + _invalidate_bot_detail_cache(bot_id) return {"status": "stopped"} @@ -1679,6 +1861,7 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): bot.docker_status = "STOPPED" session.add(bot) session.commit() + _invalidate_bot_detail_cache(bot_id) return {"status": "deactivated"} @@ -1702,6 +1885,8 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De if os.path.isdir(workspace_root): shutil.rmtree(workspace_root, ignore_errors=True) + _invalidate_bot_detail_cache(bot_id) + _invalidate_bot_messages_cache(bot_id) return {"status": "deleted", "workspace_deleted": bool(delete_workspace)} @@ -1763,6 +1948,7 @@ def update_bot_env_params(bot_id: str, payload: BotEnvParamsUpdateRequest, sessi raise HTTPException(status_code=404, detail="Bot not found") normalized = _normalize_env_params(payload.env_params) _write_env_store(bot_id, normalized) + _invalidate_bot_detail_cache(bot_id) return { "status": "updated", "bot_id": bot_id, @@ -1940,6 +2126,7 @@ def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Sess channels_cfg[ctype] = _channel_api_to_cfg(new_row) _write_bot_config(bot_id, config_data) _sync_workspace_channels(session, bot_id) + _invalidate_bot_detail_cache(bot_id) return new_row @@ -2010,6 +2197,7 @@ def update_bot_channel( _write_bot_config(bot_id, config_data) session.commit() _sync_workspace_channels(session, bot_id) + _invalidate_bot_detail_cache(bot_id) return row @@ -2036,6 +2224,7 @@ def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends( _write_bot_config(bot_id, config_data) session.commit() _sync_workspace_channels(session, bot_id) + _invalidate_bot_detail_cache(bot_id) return {"status": "deleted"} @@ -2152,6 +2341,9 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( raise HTTPException(status_code=404, detail="Bot not found") safe_limit = max(1, min(int(limit), 500)) + cached = cache.get_json(_cache_key_bot_messages(bot_id, safe_limit)) + if isinstance(cached, list): + return cached rows = session.exec( select(BotMessage) .where(BotMessage.bot_id == bot_id) @@ -2159,7 +2351,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( .limit(safe_limit) ).all() ordered = list(reversed(rows)) - return [ + payload = [ { "id": row.id, "bot_id": row.bot_id, @@ -2171,6 +2363,8 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( } for row in ordered ] + cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30) + return payload @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback") @@ -2201,6 +2395,7 @@ def update_bot_message_feedback( session.add(row) session.commit() + _invalidate_bot_messages_cache(bot_id) return { "status": "updated", "bot_id": bot_id, @@ -2233,6 +2428,8 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): bot.updated_at = datetime.utcnow() session.add(bot) session.commit() + _invalidate_bot_detail_cache(bot_id) + _invalidate_bot_messages_cache(bot_id) return {"bot_id": bot_id, "deleted": deleted, "cleared_sessions": cleared_sessions} @@ -2482,6 +2679,17 @@ async def upload_workspace_files( @app.websocket("/ws/monitor/{bot_id}") async def websocket_endpoint(websocket: WebSocket, bot_id: str): + panel_error = _validate_panel_access_password( + str( + websocket.headers.get(PANEL_ACCESS_PASSWORD_HEADER) + or websocket.query_params.get("panel_access_password") + or "" + ).strip() + ) + if panel_error: + await websocket.close(code=4401, reason=panel_error) + return + with Session(engine) as session: bot = session.get(BotInstance, bot_id) if not bot: diff --git a/backend/requirements.txt b/backend/requirements.txt index bbacb10..e762f4d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.27.1 docker==7.0.0 sqlmodel==0.0.16 pydantic==2.6.3 -psycopg[binary]==3.2.1 +psycopg[binary]==3.2.13 pymysql==1.1.1 python-dotenv==1.0.1 httpx==0.27.0 @@ -14,3 +14,4 @@ jinja2==3.1.3 watchfiles==0.21.0 urllib3==1.26.18 requests==2.31.0 +redis==5.0.8 diff --git a/design/release-v0.1.4.md b/design/release-v0.1.4.md new file mode 100644 index 0000000..72d4e30 --- /dev/null +++ b/design/release-v0.1.4.md @@ -0,0 +1,85 @@ +# Dashboard Nanobot v0.1.4 升级日志(相对 v0.1.3) + +## 1. 架构与运行模式 +- Bot 运行模式从 `agent` 调整为 `gateway`,Dashboard 作为标准渠道接入总线。 +- 镜像管理策略调整为“手工构建 + 面板登记”,移除面板内自动构建镜像的主路径。 +- Bot 配置源统一为 workspace 下文件: + - `.nanobot/config.json` + - `.nanobot/workspace/*.md` + - `.nanobot/env.json` + - `.nanobot/resources.json` + +## 2. 数据层升级 +- 新增 `PostgreSQL` 主库支持(推荐 `pg17`)。 +- 新增 `Redis` 缓存层(可开关): + - `bots:list` + - `bot:detail:{bot_id}` + - `bot:messages:{bot_id}:limit:{n}` + - `images:list` +- 提供 SQLite -> 主库自动迁移入口(启动时可配置开启/关闭)。 +- `botmessage` 增加消息反馈字段: + - `feedback`(up/down) + - `feedback_at` +- 清理历史遗留表与字段: + - 废弃并移除 `channelroute` + - 废弃技能注册表(改为 workspace 文件系统) + - 清理 `botinstance` 中历史冗余配置列(配置以文件为准) + +## 3. 渠道与消息总线 +- Dashboard 渠道内建、不可删除,支持参数: + - `sendProgress` + - `sendToolHints` +- 多渠道配置保持与配置文件一致,支持新增/编辑/删除可选渠道(飞书/QQ/钉钉/Telegram/Slack)。 +- 对话区与状态区职责分离: + - 对话区仅展示 Dashboard 渠道消息(用户+最终回复) + - 运行状态面板展示全渠道状态与执行态 + +## 4. 对话与交互体验 +- 对话区改为标准 LLM 聊天气泡: + - 用户右侧,Bot 左侧 + - 支持长文本换行、折叠/展开“更多” +- 输入能力增强: + - 回车发送,`Shift+Enter` 换行 + - 附件上传、引用回复、复制消息 + - 发送中按钮可切换为“中断” +- Bot 回复支持反馈闭环: + - 点赞/点踩(可取消、可再次点击) + +## 5. Workspace 与文件系统 +- Workspace 浏览改为标准文件操作模式: + - 双击进入目录 + - 支持返回上级 + - 文件信息悬浮卡统一 +- 文件能力升级: + - Markdown 渲染预览 + - HTML/HTM 直接预览 + 复制地址 + - PDF 改为直接下载 + - 其他二进制按下载处理 +- 支持上传附件到 workspace,并用于对话消息发送。 + +## 6. Bot 运维与资源管理 +- 支持 Bot 生命周期操作: + - 启动 / 停止 / 重启 / 删除 +- 资源配额支持: + - CPU 核数、内存、存储 + - `0` 表示不限制 +- 资源监控面板: + - 配置配额 + - Docker 实际限制 + - 实时使用指标(CPU/内存/网络/块设备/进程) + +## 7. 安全能力 +- 新增面板访问密码(全局): + - 未配置则免登录 + - 配置后需先登录,再访问 API/WS +- 保留 Bot 级访问密码能力(按 Bot 控制)。 + +## 8. 环境配置与部署 +- 后端新增 `.env` 参数体系(数据库/缓存/默认模板/上传限制/访问密码等)。 +- 前端支持通过环境变量配置后端地址与 API 前缀。 +- 提供生产环境变量模板:`.env.prod.example`。 + +## 9. 兼容性与注意事项 +- v0.1.4 建议重启后端后再重启 Bot,使新配置完全生效。 +- 工具类密钥(如 Web Search API)改为环境变量管理,不再由向导写入工具配置。 +- 若从 SQLite 迁移到 PostgreSQL,建议首次启动观察日志确认迁移与序列对齐完成。 diff --git a/frontend/src/App.css b/frontend/src/App.css index c119e36..9d0385e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -172,6 +172,65 @@ body { height: 100%; } +.app-login-shell { + min-height: calc(100vh - 36px); + display: flex; + align-items: center; + justify-content: center; +} + +.app-login-card { + width: min(420px, calc(100vw - 32px)); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: var(--shadow); + padding: 28px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; +} + +.app-login-card h1 { + margin: 0; + font-size: 22px; + font-weight: 800; + color: var(--title); +} + +.app-login-card p { + margin: 0; + color: var(--subtitle); + font-size: 14px; + line-height: 1.6; +} + +.app-login-icon { + width: 34px; + height: 34px; + object-fit: contain; +} + +.app-login-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; +} + +.app-login-error { + color: var(--err); + font-size: 13px; + font-weight: 700; +} + +.app-login-submit { + width: 100%; + height: 42px; + justify-content: center; +} + .panel { background: var(--panel); border: 1px solid var(--line); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7437e7..81566be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactElement } from 'react'; +import axios from 'axios'; import { MoonStar, SunMedium, X } from 'lucide-react'; -import { - useAppStore, -} from './store/appStore'; +import { useAppStore } from './store/appStore'; import { useBotsSync } from './hooks/useBotsSync'; +import { APP_ENDPOINTS } from './config/env'; import { ImageFactoryModule } from './modules/images/ImageFactoryModule'; import { BotWizardModule } from './modules/onboarding/BotWizardModule'; import { BotDashboardModule } from './modules/dashboard/BotDashboardModule'; @@ -12,36 +12,24 @@ import { appZhCn } from './i18n/app.zh-cn'; import { appEn } from './i18n/app.en'; import { LucentIconButton } from './components/lucent/LucentIconButton'; import { LucentTooltip } from './components/lucent/LucentTooltip'; +import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; import './App.css'; -function App() { +function AuthenticatedApp({ + forcedBotId, + compactMode, +}: { + forcedBotId?: string; + compactMode: boolean; +}) { const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const [showImageFactory, setShowImageFactory] = useState(false); const [showCreateWizard, setShowCreateWizard] = useState(false); useBotsSync(); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); - const urlView = useMemo(() => { - const params = new URLSearchParams(window.location.search); - const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i); - let forcedBotIdFromPath = ''; - if (pathMatch?.[1]) { - try { - forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim(); - } catch { - forcedBotIdFromPath = String(pathMatch[1]).trim(); - } - } - const forcedBotIdFromQuery = - (params.get('botId') || params.get('bot_id') || params.get('id') || '').trim(); - const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery; - const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase(); - const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw); - const compactMode = compactByFlag || forcedBotId.length > 0; - return { forcedBotId, compactMode }; - }, []); useEffect(() => { - const forced = urlView.forcedBotId; + const forced = String(forcedBotId || '').trim(); if (!forced) { document.title = t.title; return; @@ -49,10 +37,10 @@ function App() { const bot = activeBots[forced]; const botName = String(bot?.name || '').trim(); document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; - }, [activeBots, t.title, urlView.forcedBotId]); + }, [activeBots, t.title, forcedBotId]); return ( -
+
@@ -113,13 +101,13 @@ function App() { setShowCreateWizard(true)} onOpenImageFactory={() => setShowImageFactory(true)} - forcedBotId={urlView.forcedBotId || undefined} - compactMode={urlView.compactMode} + forcedBotId={forcedBotId || undefined} + compactMode={compactMode} />
- {!urlView.compactMode && showImageFactory && ( + {!compactMode && showImageFactory && (
setShowImageFactory(false)}>
e.stopPropagation()}>
@@ -139,7 +127,7 @@ function App() {
)} - {!urlView.compactMode && showCreateWizard && ( + {!compactMode && showCreateWizard && (
setShowCreateWizard(false)}>
e.stopPropagation()}>
@@ -167,4 +155,152 @@ function App() { ); } +function PanelLoginGate({ + children, +}: { + children: (props: { forcedBotId?: string; compactMode: boolean }) => ReactElement; +}) { + const { theme, locale } = useAppStore(); + const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); + const urlView = useMemo(() => { + const params = new URLSearchParams(window.location.search); + const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i); + let forcedBotIdFromPath = ''; + if (pathMatch?.[1]) { + try { + forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim(); + } catch { + forcedBotIdFromPath = String(pathMatch[1]).trim(); + } + } + const forcedBotIdFromQuery = + (params.get('botId') || params.get('bot_id') || params.get('id') || '').trim(); + const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery; + const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase(); + const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw); + const compactMode = compactByFlag || forcedBotId.length > 0; + return { forcedBotId, compactMode }; + }, []); + + const [checking, setChecking] = useState(true); + const [required, setRequired] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let alive = true; + const boot = async () => { + try { + const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`); + if (!alive) return; + const enabled = Boolean(status.data?.enabled); + if (!enabled) { + setRequired(false); + setAuthenticated(true); + setChecking(false); + return; + } + setRequired(true); + const stored = getPanelAccessPassword(); + if (!stored) { + setChecking(false); + return; + } + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored }); + if (!alive) return; + setAuthenticated(true); + } catch { + clearPanelAccessPassword(); + if (!alive) return; + setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.'); + } finally { + if (alive) setChecking(false); + } + } catch { + if (!alive) return; + setRequired(false); + setAuthenticated(true); + setChecking(false); + } + }; + void boot(); + return () => { + alive = false; + }; + }, [locale]); + + const onSubmit = async () => { + const next = String(password || '').trim(); + if (!next) { + setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.'); + return; + } + setSubmitting(true); + setError(''); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next }); + setPanelAccessPassword(next); + setAuthenticated(true); + } catch { + clearPanelAccessPassword(); + setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.'); + } finally { + setSubmitting(false); + } + }; + + if (checking) { + return ( +
+
+
+ Nanobot +

{t.title}

+

{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}

+
+
+
+ ); + } + + if (required && !authenticated) { + return ( +
+
+
+ Nanobot +

{t.title}

+

{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}

+
+ setPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void onSubmit(); + }} + placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'} + /> + {error ?
{error}
: null} + +
+
+
+
+ ); + } + + return children(urlView); +} + +function App() { + return {(urlView) => }; +} + export default App; diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index d3e419b..721e25a 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -288,7 +288,8 @@ export function useBotsSync() { } if (data.type === 'USER_COMMAND') { if (!isDashboardChannel) return; - const text = normalizeUserMessageText(String(data.text || payload.text || payload.command || '')); + const rawText = String(data.text || payload.text || payload.command || ''); + const text = normalizeUserMessageText(rawText); const attachments = normalizeMedia(data.media || payload.media); const messageId = normalizeMessageId(data.message_id || payload.message_id); if (!text && attachments.length === 0) return; @@ -296,7 +297,7 @@ export function useBotsSync() { const prev = lastUserEchoRef.current[bot.id]; if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return; lastUserEchoRef.current[bot.id] = { text, ts: now }; - addBotMessage(bot.id, { id: messageId, role: 'user', text, attachments, ts: now, kind: 'final' }); + addBotMessage(bot.id, { id: messageId, role: 'user', text: rawText, attachments, ts: now, kind: 'final' }); return; } if (data.type === 'RAW_LOG') { diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index e999ca2..2e62d77 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -18,6 +18,7 @@ export const dashboardEn = { uploadFile: 'Upload file', uploadingFile: 'Uploading...', uploadFail: 'File upload failed.', + attachmentAdded: (count: number) => `${count} attachment(s) added.`, uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`, attachmentMessage: '[attachment message]', removeAttachment: 'Remove attachment', @@ -27,10 +28,15 @@ export const dashboardEn = { copyReply: 'Copy reply', copyReplyDone: 'Reply copied.', copyReplyFail: 'Failed to copy reply.', + quoteReply: 'Quote reply', + quotedReplyLabel: 'Quoted reply', + clearQuote: 'Clear quote', + quoteOnlyMessage: '[quoted reply]', goodReply: 'Good reply', badReply: 'Bad reply', feedbackUpSaved: 'Marked as good reply.', feedbackDownSaved: 'Marked as bad reply.', + feedbackCleared: 'Feedback removed.', feedbackSaveFail: 'Failed to save feedback.', feedbackMessagePending: 'Message is not synced yet. Please retry in a moment.', sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`, @@ -43,11 +49,18 @@ export const dashboardEn = { deleteBotDone: 'Bot and workspace deleted.', deleteFail: 'Delete failed.', titleBots: 'Bots', + botSearchPlaceholder: 'Search by bot name or ID', + botSearchNoResult: 'No matching bots.', + paginationPrev: 'Prev', + paginationNext: 'Next', + paginationPage: (current: number, total: number) => `${current} / ${total}`, newBot: 'New Bot', manageImages: 'Image Manager', image: 'Image', stop: 'Stop', start: 'Start', + restart: 'Restart Bot', + restartFail: 'Restart failed. Check backend logs.', delete: 'Delete', noConversation: 'No conversation yet. Send a command and bot replies will appear here.', clearHistory: 'Clear history', @@ -62,6 +75,8 @@ export const dashboardEn = { disabledPlaceholder: 'Bot is not running. Command input is disabled.', sending: 'Sending...', send: 'Send', + interrupt: 'Interrupt', + interruptSent: 'Interrupt command sent.', botStarting: 'Bot is starting...', botStopping: 'Bot is stopping...', chatDisabled: 'Bot is stopped. Chat area is disabled.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index ccfbf51..990f274 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -18,6 +18,7 @@ export const dashboardZhCn = { uploadFile: '上传文件', uploadingFile: '上传中...', uploadFail: '文件上传失败。', + attachmentAdded: (count: number) => `已添加 ${count} 个附件。`, uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB:${files}`, attachmentMessage: '[附件消息]', removeAttachment: '移除附件', @@ -27,10 +28,15 @@ export const dashboardZhCn = { copyReply: '复制回复', copyReplyDone: '回复已复制。', copyReplyFail: '复制回复失败。', + quoteReply: '引用回复', + quotedReplyLabel: '已引用回复', + clearQuote: '取消引用', + quoteOnlyMessage: '[引用回复]', goodReply: '好回复', badReply: '坏回复', feedbackUpSaved: '已标记为好回复。', feedbackDownSaved: '已标记为坏回复。', + feedbackCleared: '反馈已取消。', feedbackSaveFail: '反馈保存失败。', feedbackMessagePending: '消息尚未同步,暂不可反馈。', sendFailMsg: (msg: string) => `指令发送失败:${msg}`, @@ -43,11 +49,18 @@ export const dashboardZhCn = { deleteBotDone: 'Bot 与 workspace 已删除。', deleteFail: '删除失败。', titleBots: 'Bot 列表', + botSearchPlaceholder: '按 Bot 名称或 ID 搜索', + botSearchNoResult: '没有匹配的 Bot。', + paginationPrev: '上一页', + paginationNext: '下一页', + paginationPage: (current: number, total: number) => `${current} / ${total}`, newBot: '新建机器人', manageImages: '镜像管理', image: '镜像', stop: '停止', start: '启动', + restart: '重启 Bot', + restartFail: '重启失败,请查看后端日志。', delete: '删除', noConversation: '暂无对话消息。请先发送指令,Bot 回复会在这里按标准会话格式展示。', clearHistory: '清空对话', @@ -62,6 +75,8 @@ export const dashboardZhCn = { disabledPlaceholder: 'Bot 未运行,无法发送指令。', sending: '发送中...', send: '发送', + interrupt: '中断', + interruptSent: '已发送中断指令。', botStarting: 'Bot 正在启动中...', botStopping: 'Bot 正在停止中...', chatDisabled: 'Bot 已停止,对话区已禁用。', diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d00507a..b4f2645 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,9 @@ import './index.css' import App from './App.tsx' import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx' import { setupBotAccessAuth } from './utils/botAccess.ts' +import { setupPanelAccessAuth } from './utils/panelAccess.ts' +setupPanelAccessAuth(); setupBotAccessAuth(); createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 093f9e8..ac2af38 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -30,6 +30,36 @@ gap: 8px; } +.ops-bot-list-toolbar { + margin-top: 8px; +} + +.ops-bot-list-empty { + border: 1px dashed var(--line); + border-radius: 10px; + background: var(--panel-soft); + color: var(--subtitle); + text-align: center; + padding: 14px 10px; + font-size: 12px; + font-weight: 700; +} + +.ops-bot-list-pagination { + margin-top: 8px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; +} + +.ops-bot-list-page-indicator { + text-align: center; + font-size: 12px; + color: var(--subtitle); + font-weight: 700; +} + .ops-bot-card { position: relative; border: 1px solid var(--line); @@ -114,6 +144,43 @@ stroke-width: 2.1; } +.ops-bot-top-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ops-bot-name-row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-bot-open-inline { + width: 16px; + height: 16px; + min-width: 16px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-soft); + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.72; +} + +.ops-bot-open-inline:hover { + color: var(--brand); + opacity: 1; +} + +.ops-bot-open-inline svg { + width: 11px; + height: 11px; + stroke-width: 2.25; +} + .ops-bot-actions .ops-bot-action-monitor { background: color-mix(in oklab, var(--panel-soft) 75%, var(--brand-soft) 25%); border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%); @@ -258,7 +325,7 @@ position: relative; min-height: 0; display: grid; - grid-template-rows: 1fr auto; + grid-template-rows: minmax(0, 1fr) auto auto; gap: 10px; } @@ -329,7 +396,7 @@ } .ops-chat-scroll { - min-height: 440px; + min-height: 300px; max-height: 68vh; overflow: auto; border: 1px solid var(--line); @@ -344,6 +411,33 @@ margin-bottom: 12px; } +.ops-chat-date-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 6px 0 14px; + color: var(--subtitle); + font-size: 12px; + font-weight: 700; +} + +.ops-chat-date-divider::before, +.ops-chat-date-divider::after { + content: ''; + flex: 1 1 auto; + height: 1px; + background: color-mix(in oklab, var(--line) 82%, transparent); +} + +.ops-chat-date-divider > span { + flex: 0 0 auto; + padding: 2px 8px; + border: 1px solid color-mix(in oklab, var(--line) 80%, transparent); + border-radius: 999px; + background: color-mix(in oklab, var(--panel) 72%, var(--panel-soft) 28%); + color: var(--muted); +} + .ops-chat-item { display: flex; align-items: flex-end; @@ -689,11 +783,61 @@ background: var(--panel-soft); padding: 10px; display: grid; - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto auto auto; gap: 10px; align-items: flex-end; } +.ops-chat-top-context { + width: 100%; + min-width: 0; + margin: 0; + max-height: 120px; + overflow: auto; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-soft); +} + +.ops-chat-dock { + display: grid; + gap: 8px; + min-height: 0; +} + +.ops-composer-quote { + grid-column: 1 / -1; + border: 1px solid color-mix(in oklab, var(--brand) 42%, var(--line) 58%); + border-radius: 10px; + padding: 8px 10px; + background: color-mix(in oklab, var(--brand-soft) 34%, var(--panel) 66%); + min-width: 0; +} + +.ops-composer-quote-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + font-size: 12px; + font-weight: 700; + color: var(--title); +} + +.ops-composer-quote-text { + font-size: 12px; + line-height: 1.45; + color: var(--text); + white-space: nowrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + .ops-composer-input { min-height: 78px; resize: none; @@ -708,6 +852,43 @@ gap: 8px; } +.ops-send-toggle-interrupt { + border-color: color-mix(in oklab, #d14b4b 58%, var(--line) 42%); + background: color-mix(in oklab, #d14b4b 12%, var(--panel-soft) 88%); + color: #9b3030; +} + +.ops-send-toggle-interrupt:disabled { + opacity: 0.55; +} + +.ops-user-quoted-reply { + margin-bottom: 8px; + border-left: 3px solid color-mix(in oklab, var(--brand) 55%, var(--line) 45%); + border-radius: 8px; + background: color-mix(in oklab, var(--panel) 84%, var(--brand-soft) 16%); + padding: 6px 8px; +} + +.ops-user-quoted-label { + font-size: 11px; + font-weight: 700; + color: var(--subtitle); + margin-bottom: 4px; +} + +.ops-user-quoted-text { + font-size: 12px; + line-height: 1.4; + color: var(--text-soft); + white-space: nowrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + .ops-upload-progress { margin-top: 8px; display: grid; @@ -755,8 +936,8 @@ max-width: 100%; border: 1px solid var(--line); border-radius: 999px; - padding: 4px 8px; - font-size: 12px; + padding: 6px 12px; + font-size: 13px; color: var(--text); background: color-mix(in oklab, var(--panel-soft) 78%, transparent); } @@ -768,12 +949,29 @@ } .ops-chip-remove { - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; border-radius: 999px; - color: var(--muted); + border: 1px solid color-mix(in oklab, var(--brand) 28%, var(--line) 72%); + background: color-mix(in oklab, var(--panel) 92%, #f5f8ff 8%); + color: color-mix(in oklab, var(--text) 86%, #4b668f 14%); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.ops-chip-remove:hover { + border-color: color-mix(in oklab, var(--brand) 46%, var(--line) 54%); + background: color-mix(in oklab, var(--panel) 80%, var(--brand-soft) 20%); +} + +.ops-chip-remove svg { + width: 16px; + height: 16px; + stroke-width: 2.2; } .ops-runtime-panel { diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index f2fe43b..2a23951 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import axios from 'axios'; -import { Activity, Boxes, Check, Clock3, Copy, Download, EllipsisVertical, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Save, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; +import { Activity, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Maximize2, MessageSquareText, Minimize2, Paperclip, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, 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,6 +19,7 @@ import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess'; +import { getPanelAccessPassword } from '../../utils/panelAccess'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; @@ -32,6 +33,8 @@ type WorkspaceNodeType = 'dir' | 'file'; type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; type RuntimeViewMode = 'visual' | 'text'; type CompactPanelTab = 'chat' | 'runtime'; +type QuotedReply = { id?: number; text: string; ts: number }; +const BOT_LIST_PAGE_SIZE = 8; interface WorkspaceNode { name: string; @@ -262,6 +265,7 @@ const providerPresets: Record msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0)) .forEach((msg) => { - const cleanText = msg.role === 'user' ? normalizeUserMessageText(msg.text) : normalizeAssistantMessageText(msg.text); + const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text }; + const userQuoted = parsedUser.quoted; + const userBody = parsedUser.body; + const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text); const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean); - if (!cleanText && attachments.length === 0) return; + if (!cleanText && attachments.length === 0 && !userQuoted) return; const last = merged[merged.length - 1]; if (last && last.role === msg.role) { const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text); @@ -418,7 +458,8 @@ function mergeConversation(messages: ChatMessage[]) { const currentKind = msg.kind || 'final'; const sameAttachmentSet = JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments); - if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && Math.abs(msg.ts - last.ts) < 15000) { + const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted); + if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) { last.ts = msg.ts; last.id = msg.id || last.id; if (typeof msg.feedback !== 'undefined') { @@ -427,7 +468,7 @@ function mergeConversation(messages: ChatMessage[]) { return; } } - merged.push({ ...msg, text: cleanText, attachments }); + merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments }); }); return merged.slice(-120); } @@ -520,6 +561,7 @@ export function BotDashboardModule({ const { activeBots, setBots, + mergeBot, updateBotStatus, locale, addBotMessage, @@ -548,6 +590,7 @@ export function BotDashboardModule({ const [providerTestResult, setProviderTestResult] = useState(''); const [operatingBotId, setOperatingBotId] = useState(null); const [sendingByBot, setSendingByBot] = useState>({}); + const [interruptingByBot, setInterruptingByBot] = useState>({}); const [controlStateByBot, setControlStateByBot] = useState>({}); const chatBottomRef = useRef(null); const [workspaceEntries, setWorkspaceEntries] = useState([]); @@ -560,6 +603,7 @@ export function BotDashboardModule({ const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false); const [pendingAttachments, setPendingAttachments] = useState([]); + const [quotedReply, setQuotedReply] = useState(null); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [attachmentUploadPercent, setAttachmentUploadPercent] = useState(null); const filePickerRef = useRef(null); @@ -589,6 +633,8 @@ export function BotDashboardModule({ const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [compactPanelTab, setCompactPanelTab] = useState('chat'); const [isCompactMobile, setIsCompactMobile] = useState(false); + const [botListQuery, setBotListQuery] = useState(''); + const [botListPage, setBotListPage] = useState(1); const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState>({}); const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); @@ -596,9 +642,11 @@ export function BotDashboardModule({ const runtimeMenuRef = useRef(null); const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => { const accessPassword = selectedBotId ? getBotAccessPassword(selectedBotId) : ''; + const panelPassword = getPanelAccessPassword(); const query = [`path=${encodeURIComponent(filePath)}`]; if (forceDownload) query.push('download=1'); if (accessPassword) query.push(`access_password=${encodeURIComponent(accessPassword)}`); + if (panelPassword) query.push(`panel_access_password=${encodeURIComponent(panelPassword)}`); return `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?${query.join('&')}`; }; const closeWorkspacePreview = () => { @@ -823,6 +871,21 @@ export function BotDashboardModule({ }); const bots = useMemo(() => Object.values(activeBots), [activeBots]); + const normalizedBotListQuery = botListQuery.trim().toLowerCase(); + const filteredBots = useMemo(() => { + if (!normalizedBotListQuery) return bots; + return bots.filter((bot) => { + const id = String(bot.id || '').toLowerCase(); + const name = String(bot.name || '').toLowerCase(); + return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); + }); + }, [bots, normalizedBotListQuery]); + const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE)); + const pagedBots = useMemo(() => { + const page = Math.min(Math.max(1, botListPage), botListTotalPages); + const start = (page - 1) * BOT_LIST_PAGE_SIZE; + return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE); + }, [filteredBots, botListPage, botListTotalPages]); const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); const messages = selectedBot?.messages || []; @@ -832,16 +895,36 @@ export function BotDashboardModule({ const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const lc = isZh ? channelsZhCn : channelsEn; const botAccessCheckRef = useRef | undefined>>({}); + const botPasswordResolverRef = useRef<((value: string | null) => void) | null>(null); + const [botPasswordDialog, setBotPasswordDialog] = useState<{ + open: boolean; + botName: string; + invalid: boolean; + value: string; + }>({ + open: false, + botName: '', + invalid: false, + value: '', + }); - const promptForBotPassword = (botName: string, invalid: boolean): string | null => { - const title = invalid - ? (isZh ? `访问密码错误:${botName}` : `Invalid access password: ${botName}`) - : (isZh ? `请输入访问密码:${botName}` : `Enter access password for ${botName}`); - const value = window.prompt(title, ''); - if (value === null) return null; - const trimmed = String(value || '').trim(); - if (!trimmed) return null; - return trimmed; + const promptForBotPassword = (botName: string, invalid: boolean): Promise => { + setBotPasswordDialog({ + open: true, + botName, + invalid, + value: '', + }); + return new Promise((resolve) => { + botPasswordResolverRef.current = resolve; + }); + }; + + const closeBotPasswordDialog = (value: string | null) => { + const resolver = botPasswordResolverRef.current; + botPasswordResolverRef.current = null; + setBotPasswordDialog((prev) => ({ ...prev, open: false, value: '' })); + if (resolver) resolver(value && String(value).trim() ? String(value).trim() : null); }; const verifyBotPassword = async (botId: string): Promise => { @@ -864,7 +947,7 @@ export function BotDashboardModule({ for (let attempt = 0; attempt < 3; attempt += 1) { let password = getBotAccessPassword(normalizedBotId); if (!password || askForNewPassword) { - const input = promptForBotPassword(botName, askForNewPassword); + const input = await promptForBotPassword(botName, askForNewPassword); if (input === null) { notify(isZh ? '已取消密码输入,无法访问该机器人。' : 'Password input cancelled. Bot access blocked.', { tone: 'warning', @@ -974,19 +1057,27 @@ export function BotDashboardModule({ [...conversation].reverse().find((m) => m.role === 'assistant' && (m.kind || 'final') !== 'progress')?.ts || 0, [conversation], ); + const botUpdatedAtTs = useMemo(() => parseBotTimestamp(selectedBot?.updated_at), [selectedBot?.updated_at]); + const latestRuntimeSignalTs = useMemo(() => { + const latestEventTs = latestEvent?.ts || 0; + return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs); + }, [latestEvent?.ts, botUpdatedAtTs, lastUserTs]); + const hasFreshRuntimeSignal = useMemo( + () => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS, + [latestRuntimeSignalTs], + ); const isThinking = useMemo(() => { if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false; if (lastUserTs <= 0) return false; if (lastAssistantFinalTs >= lastUserTs) return false; - // Keep showing running/thinking state until a final assistant reply arrives. - return true; - }, [selectedBot, lastUserTs, lastAssistantFinalTs]); + return hasFreshRuntimeSignal; + }, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]); const displayState = useMemo(() => { if (!selectedBot) return 'IDLE'; const backendState = normalizeRuntimeState(selectedBot.current_state); if (selectedBot.docker_status !== 'RUNNING') return backendState; - if (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR') { + if (hasFreshRuntimeSignal && (backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')) { return backendState; } @@ -1005,7 +1096,7 @@ export function BotDashboardModule({ if (latestEvent?.state === 'ERROR') return 'ERROR'; return 'IDLE'; - }, [selectedBot, isThinking, latestEvent]); + }, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]); const runtimeAction = useMemo(() => { const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim(); if (action) return action; @@ -1061,134 +1152,160 @@ export function BotDashboardModule({ const collapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText)); const expanded = Boolean(expandedProgressByKey[itemKey]); const displayText = isProgressBubble && !expanded ? summaryText : fullText; + const currentDayKey = new Date(item.ts).toDateString(); + const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; + const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; return ( -
-
- {item.role !== 'user' && ( -
- Nanobot -
- )} - {item.role === 'user' ? ( -
- void copyUserPrompt(item.text)} - tooltip={t.copyPrompt} - aria-label={t.copyPrompt} - > - - +
+ {showDateDivider ? ( +
+ {formatConversationDate(item.ts, isZh)}
) : null} - -
-
- {item.role === 'user' ? t.you : 'Nanobot'} -
- {formatClock(item.ts)} - {collapsible ? ( - - setExpandedProgressByKey((prev) => ({ - ...prev, - [itemKey]: !prev[itemKey], - })) - } - tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} - aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} - > - {expanded ? '×' : '…'} - - ) : null} -
-
-
- {item.text ? ( - item.role === 'user' ? ( -
{normalizeUserMessageText(item.text)}
- ) : ( - - {decorateWorkspacePathsForMarkdown(displayText)} - - ) - ) : null} - {(item.attachments || []).length > 0 ? ( -
- {(item.attachments || []).map((rawPath) => { - const filePath = normalizeDashboardAttachmentPath(rawPath); - const fileAction = workspaceFileAction(filePath); - const filename = filePath.split('/').pop() || filePath; - return ( - { - event.preventDefault(); - openWorkspacePathFromChat(filePath); - }} - title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable} - > - {fileAction === 'download' ? ( - - ) : fileAction === 'preview' ? ( - - ) : ( - - )} - {filename} - - ); - })} +
+
+ {item.role !== 'user' && ( +
+ Nanobot
- ) : null} - {item.role === 'assistant' && !isProgressBubble ? ( -
- void submitAssistantFeedback(item, 'up')} - disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} - tooltip={t.goodReply} - aria-label={t.goodReply} - > - - - void submitAssistantFeedback(item, 'down')} - disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} - tooltip={t.badReply} - aria-label={t.badReply} - > - - + )} + {item.role === 'user' ? ( +
void copyAssistantReply(item.text)} - tooltip={t.copyReply} - aria-label={t.copyReply} + onClick={() => void copyUserPrompt(item.text)} + tooltip={t.copyPrompt} + aria-label={t.copyPrompt} >
) : null} -
-
- {item.role === 'user' && ( -
- +
+
+ {item.role === 'user' ? t.you : 'Nanobot'} +
+ {formatClock(item.ts)} + {collapsible ? ( + + setExpandedProgressByKey((prev) => ({ + ...prev, + [itemKey]: !prev[itemKey], + })) + } + tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} + aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} + > + {expanded ? '×' : '…'} + + ) : null} +
+
+
+ {item.text ? ( + item.role === 'user' ? ( + <> + {item.quoted_reply ? ( +
+
{t.quotedReplyLabel}
+
{normalizeAssistantMessageText(item.quoted_reply)}
+
+ ) : null} +
{normalizeUserMessageText(item.text)}
+ + ) : ( + + {decorateWorkspacePathsForMarkdown(displayText)} + + ) + ) : null} + {(item.attachments || []).length > 0 ? ( +
+ {(item.attachments || []).map((rawPath) => { + const filePath = normalizeDashboardAttachmentPath(rawPath); + const fileAction = workspaceFileAction(filePath); + const filename = filePath.split('/').pop() || filePath; + return ( + { + event.preventDefault(); + openWorkspacePathFromChat(filePath); + }} + title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable} + > + {fileAction === 'download' ? ( + + ) : fileAction === 'preview' ? ( + + ) : ( + + )} + {filename} + + ); + })} +
+ ) : null} + {item.role === 'assistant' && !isProgressBubble ? ( +
+ void submitAssistantFeedback(item, 'up')} + disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} + tooltip={t.goodReply} + aria-label={t.goodReply} + > + + + void submitAssistantFeedback(item, 'down')} + disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} + tooltip={t.badReply} + aria-label={t.badReply} + > + + + quoteAssistantReply(item)} + tooltip={t.quoteReply} + aria-label={t.quoteReply} + > + + + void copyAssistantReply(item.text)} + tooltip={t.copyReply} + aria-label={t.copyReply} + > + + +
+ ) : null} +
+
- )} + {item.role === 'user' && ( +
+ +
+ )} +
-
- )}), + ); + }), [ conversation, expandedProgressByKey, @@ -1198,12 +1315,22 @@ export function BotDashboardModule({ t.badReply, t.copyPrompt, t.copyReply, + t.quoteReply, + t.quotedReplyLabel, t.goodReply, t.user, t.you, ], ); + useEffect(() => { + setBotListPage(1); + }, [normalizedBotListQuery]); + + useEffect(() => { + setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages)); + }, [botListTotalPages]); + useEffect(() => { const forced = String(forcedBotId || '').trim(); if (forced) { @@ -1222,6 +1349,10 @@ export function BotDashboardModule({ chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }, [selectedBotId, conversation.length]); + useEffect(() => { + setQuotedReply(null); + }, [selectedBotId]); + useEffect(() => { const onPointerDown = (event: MouseEvent) => { if (!runtimeMenuRef.current) return; @@ -1243,6 +1374,23 @@ export function BotDashboardModule({ setWorkspaceHoverCard(null); }, [selectedBotId]); + useEffect(() => { + if (!selectedBotId) return; + let alive = true; + const loadBotDetail = async () => { + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); + if (alive) mergeBot(res.data); + } catch (error) { + console.error(`Failed to fetch bot detail for ${selectedBotId}`, error); + } + }; + void loadBotDetail(); + return () => { + alive = false; + }; + }, [selectedBotId, mergeBot]); + useEffect(() => { if (!workspaceHoverCard) return; const close = () => setWorkspaceHoverCard(null); @@ -1852,11 +2000,39 @@ export function BotDashboardModule({ } }; + const restartBot = async (id: string, status: string) => { + const normalized = String(status || '').toUpperCase(); + setOperatingBotId(id); + try { + if (normalized === 'RUNNING') { + setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' })); + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); + updateBotStatus(id, 'STOPPED'); + } + setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' })); + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); + updateBotStatus(id, 'RUNNING'); + await refresh(); + } catch { + notify(t.restartFail, { tone: 'error' }); + } finally { + setOperatingBotId(null); + setControlStateByBot((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + }; + const send = async () => { if (!selectedBot || !canChat || isSending) return; - if (!command.trim() && pendingAttachments.length === 0) return; + if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return; const text = normalizeUserMessageText(command); - const payloadText = text || (pendingAttachments.length > 0 ? t.attachmentMessage : ''); + const quoteText = normalizeAssistantMessageText(quotedReply?.text || ''); + const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : ''; + const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : ''); + const payloadText = `${quoteBlock}${payloadCore}`.trim(); if (!payloadText && pendingAttachments.length === 0) return; try { @@ -1871,6 +2047,7 @@ export function BotDashboardModule({ } setCommand(''); setPendingAttachments([]); + setQuotedReply(null); } catch (error: any) { const msg = error?.response?.data?.detail || error?.message || t.sendFail; addBotMessage(selectedBot.id, { @@ -1888,6 +2065,32 @@ export function BotDashboardModule({ } }; + const interruptExecution = async () => { + if (!selectedBot || !canChat) return; + if (interruptingByBot[selectedBot.id]) return; + try { + setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); + const res = await axios.post( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, + { command: '/stop' }, + { timeout: 12000 }, + ); + if (!res.data?.success) { + throw new Error(t.backendDeliverFail); + } + notify(t.interruptSent, { tone: 'success' }); + } catch (error: any) { + const msg = error?.response?.data?.detail || error?.message || t.sendFail; + notify(msg, { tone: 'error' }); + } finally { + setInterruptingByBot((prev) => { + const next = { ...prev }; + delete next[selectedBot.id]; + return next; + }); + } + }; + const copyUserPrompt = async (text: string) => { await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail); }; @@ -1896,6 +2099,17 @@ export function BotDashboardModule({ await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail); }; + const quoteAssistantReply = (message: ChatMessage) => { + const content = normalizeAssistantMessageText(message.text); + if (!content) return; + setQuotedReply((prev) => { + if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) { + return null; + } + return { id: message.id, ts: message.ts, text: content }; + }); + }; + const fetchBotMessages = async (botId: string): Promise => { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, { params: { limit: 300 }, @@ -1950,11 +2164,16 @@ export function BotDashboardModule({ return; } if (feedbackSavingByMessageId[targetMessageId]) return; + const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback; setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true })); try { - await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback }); - setBotMessageFeedback(selectedBotId, targetMessageId, feedback); - notify(feedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' }); + await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback }); + setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback); + if (nextFeedback === null) { + notify(t.feedbackCleared, { tone: 'success' }); + } else { + notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' }); + } } catch (error: any) { const msg = error?.response?.data?.detail || t.feedbackSaveFail; notify(msg, { tone: 'error' }); @@ -2376,8 +2595,13 @@ export function BotDashboardModule({ +
+
{normalizeAssistantMessageText(quotedReply.text)}
+
+ ) : null} + {pendingAttachments.length > 0 ? ( +
+ {pendingAttachments.map((p) => ( + + {(() => { + const filePath = normalizeDashboardAttachmentPath(p); + const fileAction = workspaceFileAction(filePath); + const filename = filePath.split('/').pop() || filePath; + return ( + { + event.preventDefault(); + event.stopPropagation(); + openWorkspacePathFromChat(filePath); + }} + > + {fileAction === 'download' ? ( + + ) : fileAction === 'preview' ? ( + + ) : ( + + )} + {filename} + + ); + })()} + + + ))} +
+ ) : null} +
+ ) : null}
+
{isUploadingAttachments ? (
@@ -2598,52 +2945,6 @@ export function BotDashboardModule({
) : null} - {pendingAttachments.length > 0 ? ( -
- {pendingAttachments.map((p) => ( - - {(() => { - const filePath = normalizeDashboardAttachmentPath(p); - const fileAction = workspaceFileAction(filePath); - const filename = filePath.split('/').pop() || filePath; - return ( - { - event.preventDefault(); - event.stopPropagation(); - openWorkspacePathFromChat(filePath); - }} - title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable} - > - {fileAction === 'download' ? ( - - ) : fileAction === 'preview' ? ( - - ) : ( - - )} - {filename} - - ); - })()} - { - event.preventDefault(); - event.stopPropagation(); - setPendingAttachments((prev) => prev.filter((v) => v !== p)); - }} - tooltip={t.removeAttachment} - aria-label={t.removeAttachment} - > - - - - ))} -
- ) : null} {!canChat ? (
@@ -2680,6 +2981,15 @@ export function BotDashboardModule({ > + void restartBot(selectedBot.id, selectedBot.docker_status)} + disabled={operatingBotId === selectedBot.id} + tooltip={t.restart} + aria-label={t.restart} + > + + setRuntimeMenuOpen((v) => !v)} @@ -3681,6 +3991,52 @@ export function BotDashboardModule({
) : null} + {botPasswordDialog.open ? ( +
closeBotPasswordDialog(null)}> +
event.stopPropagation()}> +
+
+

+ {botPasswordDialog.invalid + ? (isZh ? `访问密码错误:${botPasswordDialog.botName}` : `Invalid access password: ${botPasswordDialog.botName}`) + : (isZh ? `请输入访问密码:${botPasswordDialog.botName}` : `Enter access password for ${botPasswordDialog.botName}`)} +

+
+
+ closeBotPasswordDialog(null)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}> + + +
+
+
+ + setBotPasswordDialog((prev) => ({ + ...prev, + value: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key === 'Enter') closeBotPasswordDialog(botPasswordDialog.value); + }} + placeholder={isZh ? '输入 Bot 访问密码' : 'Enter bot access password'} + /> +
+ + +
+
+
+
+ ) : null} ); } diff --git a/frontend/src/modules/dashboard/messageParser.ts b/frontend/src/modules/dashboard/messageParser.ts index 9c41917..bec912e 100644 --- a/frontend/src/modules/dashboard/messageParser.ts +++ b/frontend/src/modules/dashboard/messageParser.ts @@ -5,6 +5,7 @@ const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g; const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。,!?:;、“”‘’()《》【】—…·\-_./:\\,%+*='"`|<>]/g; const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi; +const QUOTED_REPLY_BLOCK_RE = /\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]/gi; function cleanLine(line: string) { return line @@ -23,6 +24,8 @@ export function normalizeUserMessageText(input: string) { // Keep attachment list out of editable/visible command text. text = text.replace(ATTACHMENT_BLOCK_RE, '').trim(); + // Keep quoted assistant context hidden in chat bubbles, but still allow backend delivery. + text = text.replace(QUOTED_REPLY_BLOCK_RE, '').trim(); // Strip role prefixes injected by some gateways, e.g. "You: ...". text = text.replace(/(^|\n)\s*(you|user|你)\s*[::]\s*/gi, '$1').trim(); diff --git a/frontend/src/modules/management/ManagementModule.tsx b/frontend/src/modules/management/ManagementModule.tsx index da277d5..09ef83e 100644 --- a/frontend/src/modules/management/ManagementModule.tsx +++ b/frontend/src/modules/management/ManagementModule.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import axios from 'axios'; import { useAppStore } from '../../store/appStore'; import { Power, PowerOff, Terminal, ShieldCheck, Plus, Bot, Cpu, Layers, RefreshCw } from 'lucide-react'; @@ -11,7 +11,7 @@ import { managementEn } from '../../i18n/management.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; export function ManagementModule() { - const { activeBots, setBots, updateBotStatus, locale } = useAppStore(); + const { activeBots, setBots, mergeBot, updateBotStatus, locale } = useAppStore(); const { notify } = useLucentPrompt(); const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }); const [selectedBotId, setSelectedBotId] = useState(null); @@ -49,6 +49,23 @@ export function ManagementModule() { const selectedBot = selectedBotId ? activeBots[selectedBotId] : null; + useEffect(() => { + if (!selectedBotId) return; + let alive = true; + const loadDetail = async () => { + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); + if (alive) mergeBot(res.data); + } catch (err) { + console.error(err); + } + }; + void loadDetail(); + return () => { + alive = false; + }; + }, [selectedBotId, mergeBot]); + return (
diff --git a/frontend/src/store/appStore.ts b/frontend/src/store/appStore.ts index c922fba..03263c8 100644 --- a/frontend/src/store/appStore.ts +++ b/frontend/src/store/appStore.ts @@ -22,6 +22,7 @@ interface AppStore { theme: AppTheme; locale: AppLocale; setBots: (bots: BotState[]) => void; + mergeBot: (bot: BotState) => void; setView: (view: AppView) => void; setTheme: (theme: AppTheme) => void; setLocale: (locale: AppLocale) => void; @@ -58,6 +59,7 @@ export const useAppStore = create((set) => ({ const incomingAction = (bot.last_action || '').trim(); nextBots[bot.id] = { + ...prev, ...bot, logs: prev?.logs ?? [], messages: prev?.messages ?? [], @@ -68,6 +70,22 @@ export const useAppStore = create((set) => ({ }); return { activeBots: nextBots }; }), + mergeBot: (bot) => + set((state) => { + const prev = state.activeBots[bot.id]; + return { + activeBots: { + ...state.activeBots, + [bot.id]: { + ...prev, + ...bot, + logs: prev?.logs ?? bot.logs ?? [], + messages: prev?.messages ?? bot.messages ?? [], + events: prev?.events ?? bot.events ?? [], + }, + }, + }; + }), setView: (view) => set({ currentView: view }), setTheme: (theme) => { localStorage.setItem('nanobot-theme', theme); diff --git a/frontend/src/types/bot.ts b/frontend/src/types/bot.ts index 08ed18a..1fd1125 100644 --- a/frontend/src/types/bot.ts +++ b/frontend/src/types/bot.ts @@ -2,6 +2,7 @@ export interface ChatMessage { id?: number; role: 'user' | 'assistant' | 'system'; text: string; + quoted_reply?: string; ts: number; attachments?: string[]; kind?: 'progress' | 'final'; @@ -43,6 +44,8 @@ export interface BotState { identity_md?: string; last_action?: string; current_state?: string; + updated_at?: string | number; + created_at?: string | number; logs: string[]; messages?: ChatMessage[]; events?: BotEvent[]; diff --git a/frontend/src/utils/botAccess.ts b/frontend/src/utils/botAccess.ts index 6159a5e..ca2868b 100644 --- a/frontend/src/utils/botAccess.ts +++ b/frontend/src/utils/botAccess.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { appendPanelAccessPassword } from './panelAccess'; const BOT_PASSWORD_HEADER = 'X-Bot-Password'; @@ -64,6 +65,8 @@ export function clearAllBotAccessPasswords(): void { export function isBotUnauthorizedError(error: any, botId?: string): boolean { if (!axios.isAxiosError(error)) return false; if (Number(error.response?.status) !== 401) return false; + const detail = String(error.response?.data?.detail || '').trim().toLowerCase(); + if (!detail.includes('bot access password')) return false; if (!botId) return true; const fromConfig = extractBotIdFromApiPath(String(error.config?.url || '')); @@ -73,7 +76,7 @@ export function isBotUnauthorizedError(error: any, botId?: string): boolean { } export function buildMonitorWsUrl(base: string, botId: string): string { - const target = `${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`; + const target = appendPanelAccessPassword(`${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`); const password = getBotAccessPassword(botId); if (!password) return target; const joiner = target.includes('?') ? '&' : '?'; diff --git a/frontend/src/utils/panelAccess.ts b/frontend/src/utils/panelAccess.ts new file mode 100644 index 0000000..6cdebab --- /dev/null +++ b/frontend/src/utils/panelAccess.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; + +const PANEL_PASSWORD_HEADER = 'X-Panel-Password'; +const PANEL_STORAGE_KEY = 'nanobot-panel-access-password'; + +let initialized = false; +let memoryPassword = ''; + +function resolveAbsoluteUrl(input: string): string { + const url = String(input || '').trim(); + if (!url) return ''; + try { + if (url.startsWith('http://') || url.startsWith('https://')) { + return new URL(url).pathname; + } + if (typeof window !== 'undefined') { + return new URL(url, window.location.origin).pathname; + } + return url; + } catch { + return url; + } +} + +function isApiRequest(url: string): boolean { + const path = resolveAbsoluteUrl(url); + return /^\/api(\/|$)/i.test(path); +} + +export function getPanelAccessPassword(): string { + if (memoryPassword) return memoryPassword; + if (typeof window === 'undefined') return ''; + const stored = window.localStorage.getItem(PANEL_STORAGE_KEY) || ''; + if (stored) memoryPassword = stored; + return memoryPassword; +} + +export function setPanelAccessPassword(password: string): void { + const value = String(password || '').trim(); + memoryPassword = value; + if (typeof window === 'undefined') return; + if (value) { + window.localStorage.setItem(PANEL_STORAGE_KEY, value); + } else { + window.localStorage.removeItem(PANEL_STORAGE_KEY); + } +} + +export function clearPanelAccessPassword(): void { + memoryPassword = ''; + if (typeof window === 'undefined') return; + window.localStorage.removeItem(PANEL_STORAGE_KEY); +} + +export function appendPanelAccessPassword(target: string): string { + const password = getPanelAccessPassword(); + if (!password) return target; + const joiner = target.includes('?') ? '&' : '?'; + return `${target}${joiner}panel_access_password=${encodeURIComponent(password)}`; +} + +export function setupPanelAccessAuth(): void { + if (initialized) return; + initialized = true; + + axios.interceptors.request.use((config) => { + if (!isApiRequest(String(config.url || ''))) return config; + if (String(config.url || '').includes('/api/panel/auth/')) return config; + const password = getPanelAccessPassword(); + if (!password) return config; + const headers = config.headers || {}; + if (!(PANEL_PASSWORD_HEADER in (headers as Record))) { + (headers as Record)[PANEL_PASSWORD_HEADER] = password; + config.headers = headers; + } + return config; + }); +}