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 (
+
+
+
+

+
{t.title}
+
{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}
+
+
+
+ );
+ }
+
+ if (required && !authenticated) {
+ return (
+
+
+
+

+
{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' && (
-
-

-
- )}
- {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' && (
+
+
- ) : 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 ? (
+
+ ) : 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({