main
mula.liu 2026-03-09 12:53:15 +08:00
parent 296114f5b6
commit 5cee6e5e12
22 changed files with 1670 additions and 307 deletions

View File

@ -27,8 +27,20 @@ NPM_REGISTRY=https://registry.npmmirror.com
# Keep empty to use SQLite at: # Keep empty to use SQLite at:
# sqlite:///{HOST_DATA_ROOT}/nanobot_dashboard.db # sqlite:///{HOST_DATA_ROOT}/nanobot_dashboard.db
# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard # 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=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/nanobot_dashboard
DATABASE_URL=
# 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) # Max upload size for backend validation (MB)
UPLOAD_MAX_MB=100 UPLOAD_MAX_MB=100
# 升级迁移
AUTO_MIGRATE_SQLITE_TO_PRIMARY=true
SQLITE_MIGRATION_SOURCE=../data/nanobot_dashboard.db

View File

@ -12,6 +12,21 @@ BOTS_WORKSPACE_ROOT=../workspace/bots
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
# Show SQL statements in backend logs (debug only). # Show SQL statements in backend logs (debug only).
DATABASE_ECHO=true 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) # Max upload size for backend validation (MB)
UPLOAD_MAX_MB=100 UPLOAD_MAX_MB=100

View File

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

View File

@ -1,4 +1,4 @@
from sqlalchemy import text from sqlalchemy import inspect, text
from sqlmodel import SQLModel, Session, create_engine from sqlmodel import SQLModel, Session, create_engine
from core.settings import DATABASE_ECHO, DATABASE_URL from core.settings import DATABASE_ECHO, DATABASE_URL
@ -29,8 +29,6 @@ def _ensure_botinstance_columns() -> None:
def _drop_legacy_botinstance_columns() -> None: def _drop_legacy_botinstance_columns() -> None:
if engine.dialect.name != "sqlite":
return
legacy_columns = [ legacy_columns = [
"avatar_model", "avatar_model",
"avatar_skin", "avatar_skin",
@ -55,12 +53,24 @@ def _drop_legacy_botinstance_columns() -> None:
"bot_env_json", "bot_env_json",
] ]
with engine.connect() as conn: with engine.connect() as conn:
existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() existing = {
existing = {str(row[1]) for row in existing_rows} str(col.get("name"))
for col in inspect(conn).get_columns("botinstance")
if col.get("name")
}
for col in legacy_columns: for col in legacy_columns:
if col not in existing: if col not in existing:
continue 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() conn.commit()
@ -90,12 +100,42 @@ def _drop_legacy_skill_tables() -> None:
conn.commit() 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: def init_database() -> None:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_drop_legacy_skill_tables() _drop_legacy_skill_tables()
_ensure_botinstance_columns() _ensure_botinstance_columns()
_drop_legacy_botinstance_columns() _drop_legacy_botinstance_columns()
_ensure_botmessage_columns() _ensure_botmessage_columns()
align_postgres_sequences()
def get_session(): def get_session():

View File

@ -112,6 +112,16 @@ DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL)
DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True)
UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048) 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: Final[str] = _env_text(
"DEFAULT_AGENTS_MD", "DEFAULT_AGENTS_MD",
"# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown.md格式。", "# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown.md格式。",

View File

@ -4,6 +4,7 @@ import mimetypes
import os import os
import re import re
import shutil import shutil
import sqlite3
import tempfile import tempfile
import zipfile import zipfile
from datetime import datetime from datetime import datetime
@ -18,9 +19,11 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select from sqlmodel import Session, select
from core.config_manager import BotConfigManager 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.docker_manager import BotDockerManager
from core.settings import ( from core.settings import (
AUTO_MIGRATE_SQLITE_TO_PRIMARY,
BOTS_WORKSPACE_ROOT, BOTS_WORKSPACE_ROOT,
DATA_ROOT, DATA_ROOT,
DATABASE_ECHO, DATABASE_ECHO,
@ -31,7 +34,11 @@ from core.settings import (
DEFAULT_SOUL_MD, DEFAULT_SOUL_MD,
DEFAULT_TOOLS_MD, DEFAULT_TOOLS_MD,
DEFAULT_USER_MD, DEFAULT_USER_MD,
PANEL_ACCESS_PASSWORD,
PROJECT_ROOT, PROJECT_ROOT,
REDIS_ENABLED,
REDIS_URL,
SQLITE_MIGRATION_SOURCE,
UPLOAD_MAX_MB, UPLOAD_MAX_MB,
) )
from models.bot import BotInstance, BotMessage, NanobotImage from models.bot import BotInstance, BotMessage, NanobotImage
@ -141,6 +148,10 @@ class MessageFeedbackRequest(BaseModel):
feedback: Optional[str] = None # up | down | null feedback: Optional[str] = None # up | down | null
class PanelLoginRequest(BaseModel):
password: Optional[str] = None
def _normalize_packet_channel(packet: Dict[str, Any]) -> str: def _normalize_packet_channel(packet: Dict[str, Any]) -> str:
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: 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() session.commit()
if persisted_message_id: if persisted_message_id:
packet["message_id"] = 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 return persisted_message_id
@ -280,6 +294,7 @@ class WSConnectionManager:
manager = WSConnectionManager() manager = WSConnectionManager()
BOT_ACCESS_PASSWORD_HEADER = "x-bot-password" 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]: 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 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") @app.middleware("http")
async def bot_access_password_guard(request: Request, call_next): async def bot_access_password_guard(request: Request, call_next):
if request.method.upper() == "OPTIONS": if request.method.upper() == "OPTIONS":
return await call_next(request) return await call_next(request)
if 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) bot_id = _extract_bot_id_from_api_path(request.url.path)
if not bot_id: if not bot_id:
return await call_next(request) return await call_next(request)
@ -331,6 +373,23 @@ async def bot_access_password_guard(request: Request, call_next):
return await call_next(request) 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]): def docker_callback(bot_id: str, packet: Dict[str, Any]):
_persist_runtime_packet(bot_id, packet) _persist_runtime_packet(bot_id, packet)
loop = getattr(app.state, "main_loop", None) 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) 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") @app.on_event("startup")
async def on_startup(): async def on_startup():
app.state.main_loop = asyncio.get_running_loop() app.state.main_loop = asyncio.get_running_loop()
print(f"📁 项目根目录: {PROJECT_ROOT}") print(f"📁 项目根目录: {PROJECT_ROOT}")
print(f"🗄️ 数据库引擎: {DATABASE_ENGINE} (echo={DATABASE_ECHO})") print(f"🗄️ 数据库引擎: {DATABASE_ENGINE} (echo={DATABASE_ECHO})")
print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}")
print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})")
init_database() init_database()
_migrate_sqlite_if_needed()
cache.delete_prefix("")
with Session(engine) as session: with Session(engine) as session:
for bot in session.exec(select(BotInstance)).all(): for bot in session.exec(select(BotInstance)).all():
_migrate_bot_resources_store(bot.id) _migrate_bot_resources_store(bot.id)
@ -708,48 +896,6 @@ def _parse_message_media(bot_id: str, media_raw: Optional[str]) -> List[str]:
return [] 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}$") _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") channels_cfg = config_data.get("channels")
send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg) 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_provider = provider_name or "dashscope"
llm_model = str(agents_defaults.get("model") or "") llm_model = str(agents_defaults.get("model") or "")
api_key = str(provider_cfg.get("apiKey") or "").strip() 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), "tools_md": _read_workspace_md(bot.id, "TOOLS.md", DEFAULT_TOOLS_MD),
"identity_md": _read_workspace_md(bot.id, "IDENTITY.md", DEFAULT_IDENTITY_MD), "identity_md": _read_workspace_md(bot.id, "IDENTITY.md", DEFAULT_IDENTITY_MD),
"system_prompt": soul_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( def _sync_workspace_channels(
session: Session, session: Session,
bot_id: str, bot_id: str,
@ -937,7 +1093,6 @@ def _sync_workspace_channels(
"storage_gb": _safe_int(snapshot.get("storage_gb"), 10), "storage_gb": _safe_int(snapshot.get("storage_gb"), 10),
"send_progress": bool(snapshot.get("send_progress")), "send_progress": bool(snapshot.get("send_progress")),
"send_tool_hints": bool(snapshot.get("send_tool_hints")), "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): if isinstance(runtime_overrides, dict):
for key, value in runtime_overrides.items(): for key, value in runtime_overrides.items():
@ -950,9 +1105,6 @@ def _sync_workspace_channels(
bot_data["cpu_cores"] = resources["cpu_cores"] bot_data["cpu_cores"] = resources["cpu_cores"]
bot_data["memory_mb"] = resources["memory_mb"] bot_data["memory_mb"] = resources["memory_mb"]
bot_data["storage_gb"] = resources["storage_gb"] 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_progress = bool(bot_data.get("send_progress", False))
send_tool_hints = bool(bot_data.get("send_tool_hints", False)) send_tool_hints = bool(bot_data.get("send_tool_hints", False))
if isinstance(global_delivery_override, dict): 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]) @app.get("/api/images", response_model=List[NanobotImage])
def list_images(session: Session = Depends(get_session)): 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) 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}") @app.delete("/api/images/{tag:path}")
@ -1302,6 +1462,7 @@ def delete_image(tag: str, session: Session = Depends(get_session)):
session.delete(image) session.delete(image)
session.commit() session.commit()
_invalidate_images_cache()
return {"status": "deleted"} return {"status": "deleted"}
@ -1345,6 +1506,7 @@ def register_image(payload: dict, session: Session = Depends(get_session)):
session.add(row) session.add(row)
session.commit() session.commit()
session.refresh(row) session.refresh(row)
_invalidate_images_cache()
return row 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, "user_md": payload.user_md or DEFAULT_USER_MD,
"tools_md": payload.tools_md or DEFAULT_TOOLS_MD, "tools_md": payload.tools_md or DEFAULT_TOOLS_MD,
"identity_md": payload.identity_md or DEFAULT_IDENTITY_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_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, "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
}, },
) )
session.refresh(bot) session.refresh(bot)
_invalidate_bot_detail_cache(payload.id)
return _serialize_bot(bot) return _serialize_bot(bot)
@app.get("/api/bots") @app.get("/api/bots")
def list_bots(session: Session = Depends(get_session)): 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() bots = session.exec(select(BotInstance)).all()
dirty = False dirty = False
for bot in bots: for bot in bots:
@ -1483,7 +1648,22 @@ def list_bots(session: Session = Depends(get_session)):
session.commit() session.commit()
for bot in bots: for bot in bots:
session.refresh(bot) 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") @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 env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
runtime_overrides: Dict[str, Any] = {} runtime_overrides: Dict[str, Any] = {}
tools_config = update_data.pop("tools_config", None) if isinstance(update_data, dict) else None 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)
runtime_fields = { runtime_fields = {
"llm_provider", "llm_provider",
@ -1615,6 +1793,7 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend
global_delivery_override=global_delivery_override, global_delivery_override=global_delivery_override,
) )
session.refresh(bot) session.refresh(bot)
_invalidate_bot_detail_cache(bot_id)
return _serialize_bot(bot) return _serialize_bot(bot)
@ -1647,12 +1826,14 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)):
if actual_status != "RUNNING": if actual_status != "RUNNING":
session.add(bot) session.add(bot)
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Bot container failed shortly after startup. Check bot logs/config.", detail="Bot container failed shortly after startup. Check bot logs/config.",
) )
session.add(bot) session.add(bot)
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "started"} return {"status": "started"}
@ -1666,6 +1847,7 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)):
bot.docker_status = "STOPPED" bot.docker_status = "STOPPED"
session.add(bot) session.add(bot)
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "stopped"} return {"status": "stopped"}
@ -1679,6 +1861,7 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)):
bot.docker_status = "STOPPED" bot.docker_status = "STOPPED"
session.add(bot) session.add(bot)
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id)
return {"status": "deactivated"} 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): if os.path.isdir(workspace_root):
shutil.rmtree(workspace_root, ignore_errors=True) 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)} 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") raise HTTPException(status_code=404, detail="Bot not found")
normalized = _normalize_env_params(payload.env_params) normalized = _normalize_env_params(payload.env_params)
_write_env_store(bot_id, normalized) _write_env_store(bot_id, normalized)
_invalidate_bot_detail_cache(bot_id)
return { return {
"status": "updated", "status": "updated",
"bot_id": bot_id, "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) channels_cfg[ctype] = _channel_api_to_cfg(new_row)
_write_bot_config(bot_id, config_data) _write_bot_config(bot_id, config_data)
_sync_workspace_channels(session, bot_id) _sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return new_row return new_row
@ -2010,6 +2197,7 @@ def update_bot_channel(
_write_bot_config(bot_id, config_data) _write_bot_config(bot_id, config_data)
session.commit() session.commit()
_sync_workspace_channels(session, bot_id) _sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return row 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) _write_bot_config(bot_id, config_data)
session.commit() session.commit()
_sync_workspace_channels(session, bot_id) _sync_workspace_channels(session, bot_id)
_invalidate_bot_detail_cache(bot_id)
return {"status": "deleted"} 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") raise HTTPException(status_code=404, detail="Bot not found")
safe_limit = max(1, min(int(limit), 500)) 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( rows = session.exec(
select(BotMessage) select(BotMessage)
.where(BotMessage.bot_id == bot_id) .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) .limit(safe_limit)
).all() ).all()
ordered = list(reversed(rows)) ordered = list(reversed(rows))
return [ payload = [
{ {
"id": row.id, "id": row.id,
"bot_id": row.bot_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 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") @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
@ -2201,6 +2395,7 @@ def update_bot_message_feedback(
session.add(row) session.add(row)
session.commit() session.commit()
_invalidate_bot_messages_cache(bot_id)
return { return {
"status": "updated", "status": "updated",
"bot_id": bot_id, "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() bot.updated_at = datetime.utcnow()
session.add(bot) session.add(bot)
session.commit() 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} 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}") @app.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str): 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: with Session(engine) as session:
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:

View File

@ -3,7 +3,7 @@ uvicorn==0.27.1
docker==7.0.0 docker==7.0.0
sqlmodel==0.0.16 sqlmodel==0.0.16
pydantic==2.6.3 pydantic==2.6.3
psycopg[binary]==3.2.1 psycopg[binary]==3.2.13
pymysql==1.1.1 pymysql==1.1.1
python-dotenv==1.0.1 python-dotenv==1.0.1
httpx==0.27.0 httpx==0.27.0
@ -14,3 +14,4 @@ jinja2==3.1.3
watchfiles==0.21.0 watchfiles==0.21.0
urllib3==1.26.18 urllib3==1.26.18
requests==2.31.0 requests==2.31.0
redis==5.0.8

View File

@ -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建议首次启动观察日志确认迁移与序列对齐完成。

View File

@ -172,6 +172,65 @@ body {
height: 100%; 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 { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);

View File

@ -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 { MoonStar, SunMedium, X } from 'lucide-react';
import { import { useAppStore } from './store/appStore';
useAppStore,
} from './store/appStore';
import { useBotsSync } from './hooks/useBotsSync'; import { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env';
import { ImageFactoryModule } from './modules/images/ImageFactoryModule'; import { ImageFactoryModule } from './modules/images/ImageFactoryModule';
import { BotWizardModule } from './modules/onboarding/BotWizardModule'; import { BotWizardModule } from './modules/onboarding/BotWizardModule';
import { BotDashboardModule } from './modules/dashboard/BotDashboardModule'; import { BotDashboardModule } from './modules/dashboard/BotDashboardModule';
@ -12,36 +12,24 @@ import { appZhCn } from './i18n/app.zh-cn';
import { appEn } from './i18n/app.en'; import { appEn } from './i18n/app.en';
import { LucentIconButton } from './components/lucent/LucentIconButton'; import { LucentIconButton } from './components/lucent/LucentIconButton';
import { LucentTooltip } from './components/lucent/LucentTooltip'; import { LucentTooltip } from './components/lucent/LucentTooltip';
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import './App.css'; import './App.css';
function App() { function AuthenticatedApp({
forcedBotId,
compactMode,
}: {
forcedBotId?: string;
compactMode: boolean;
}) {
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const [showImageFactory, setShowImageFactory] = useState(false); const [showImageFactory, setShowImageFactory] = useState(false);
const [showCreateWizard, setShowCreateWizard] = useState(false); const [showCreateWizard, setShowCreateWizard] = useState(false);
useBotsSync(); useBotsSync();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); 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(() => { useEffect(() => {
const forced = urlView.forcedBotId; const forced = String(forcedBotId || '').trim();
if (!forced) { if (!forced) {
document.title = t.title; document.title = t.title;
return; return;
@ -49,10 +37,10 @@ function App() {
const bot = activeBots[forced]; const bot = activeBots[forced];
const botName = String(bot?.name || '').trim(); const botName = String(bot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`;
}, [activeBots, t.title, urlView.forcedBotId]); }, [activeBots, t.title, forcedBotId]);
return ( return (
<div className={`app-shell ${urlView.compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}> <div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header className="app-header">
<div className="row-between app-header-top"> <div className="row-between app-header-top">
@ -113,13 +101,13 @@ function App() {
<BotDashboardModule <BotDashboardModule
onOpenCreateWizard={() => setShowCreateWizard(true)} onOpenCreateWizard={() => setShowCreateWizard(true)}
onOpenImageFactory={() => setShowImageFactory(true)} onOpenImageFactory={() => setShowImageFactory(true)}
forcedBotId={urlView.forcedBotId || undefined} forcedBotId={forcedBotId || undefined}
compactMode={urlView.compactMode} compactMode={compactMode}
/> />
</main> </main>
</div> </div>
{!urlView.compactMode && showImageFactory && ( {!compactMode && showImageFactory && (
<div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}> <div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close"> <div className="modal-title-row modal-title-with-close">
@ -139,7 +127,7 @@ function App() {
</div> </div>
)} )}
{!urlView.compactMode && showCreateWizard && ( {!compactMode && showCreateWizard && (
<div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}> <div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close"> <div className="modal-title-row modal-title-with-close">
@ -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 (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
</div>
</div>
</div>
);
}
if (required && !authenticated) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form">
<input
className="input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit();
}}
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
</button>
</div>
</div>
</div>
</div>
);
}
return children(urlView);
}
function App() {
return <PanelLoginGate>{(urlView) => <AuthenticatedApp {...urlView} />}</PanelLoginGate>;
}
export default App; export default App;

View File

@ -288,7 +288,8 @@ export function useBotsSync() {
} }
if (data.type === 'USER_COMMAND') { if (data.type === 'USER_COMMAND') {
if (!isDashboardChannel) return; 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 attachments = normalizeMedia(data.media || payload.media);
const messageId = normalizeMessageId(data.message_id || payload.message_id); const messageId = normalizeMessageId(data.message_id || payload.message_id);
if (!text && attachments.length === 0) return; if (!text && attachments.length === 0) return;
@ -296,7 +297,7 @@ export function useBotsSync() {
const prev = lastUserEchoRef.current[bot.id]; const prev = lastUserEchoRef.current[bot.id];
if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return; if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return;
lastUserEchoRef.current[bot.id] = { text, ts: now }; 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; return;
} }
if (data.type === 'RAW_LOG') { if (data.type === 'RAW_LOG') {

View File

@ -18,6 +18,7 @@ export const dashboardEn = {
uploadFile: 'Upload file', uploadFile: 'Upload file',
uploadingFile: 'Uploading...', uploadingFile: 'Uploading...',
uploadFail: 'File upload failed.', 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}`, uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`,
attachmentMessage: '[attachment message]', attachmentMessage: '[attachment message]',
removeAttachment: 'Remove attachment', removeAttachment: 'Remove attachment',
@ -27,10 +28,15 @@ export const dashboardEn = {
copyReply: 'Copy reply', copyReply: 'Copy reply',
copyReplyDone: 'Reply copied.', copyReplyDone: 'Reply copied.',
copyReplyFail: 'Failed to copy reply.', copyReplyFail: 'Failed to copy reply.',
quoteReply: 'Quote reply',
quotedReplyLabel: 'Quoted reply',
clearQuote: 'Clear quote',
quoteOnlyMessage: '[quoted reply]',
goodReply: 'Good reply', goodReply: 'Good reply',
badReply: 'Bad reply', badReply: 'Bad reply',
feedbackUpSaved: 'Marked as good reply.', feedbackUpSaved: 'Marked as good reply.',
feedbackDownSaved: 'Marked as bad reply.', feedbackDownSaved: 'Marked as bad reply.',
feedbackCleared: 'Feedback removed.',
feedbackSaveFail: 'Failed to save feedback.', feedbackSaveFail: 'Failed to save feedback.',
feedbackMessagePending: 'Message is not synced yet. Please retry in a moment.', feedbackMessagePending: 'Message is not synced yet. Please retry in a moment.',
sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`, sendFailMsg: (msg: string) => `Command delivery failed: ${msg}`,
@ -43,11 +49,18 @@ export const dashboardEn = {
deleteBotDone: 'Bot and workspace deleted.', deleteBotDone: 'Bot and workspace deleted.',
deleteFail: 'Delete failed.', deleteFail: 'Delete failed.',
titleBots: 'Bots', 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', newBot: 'New Bot',
manageImages: 'Image Manager', manageImages: 'Image Manager',
image: 'Image', image: 'Image',
stop: 'Stop', stop: 'Stop',
start: 'Start', start: 'Start',
restart: 'Restart Bot',
restartFail: 'Restart failed. Check backend logs.',
delete: 'Delete', delete: 'Delete',
noConversation: 'No conversation yet. Send a command and bot replies will appear here.', noConversation: 'No conversation yet. Send a command and bot replies will appear here.',
clearHistory: 'Clear history', clearHistory: 'Clear history',
@ -62,6 +75,8 @@ export const dashboardEn = {
disabledPlaceholder: 'Bot is not running. Command input is disabled.', disabledPlaceholder: 'Bot is not running. Command input is disabled.',
sending: 'Sending...', sending: 'Sending...',
send: 'Send', send: 'Send',
interrupt: 'Interrupt',
interruptSent: 'Interrupt command sent.',
botStarting: 'Bot is starting...', botStarting: 'Bot is starting...',
botStopping: 'Bot is stopping...', botStopping: 'Bot is stopping...',
chatDisabled: 'Bot is stopped. Chat area is disabled.', chatDisabled: 'Bot is stopped. Chat area is disabled.',

View File

@ -18,6 +18,7 @@ export const dashboardZhCn = {
uploadFile: '上传文件', uploadFile: '上传文件',
uploadingFile: '上传中...', uploadingFile: '上传中...',
uploadFail: '文件上传失败。', uploadFail: '文件上传失败。',
attachmentAdded: (count: number) => `已添加 ${count} 个附件。`,
uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`, uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`,
attachmentMessage: '[附件消息]', attachmentMessage: '[附件消息]',
removeAttachment: '移除附件', removeAttachment: '移除附件',
@ -27,10 +28,15 @@ export const dashboardZhCn = {
copyReply: '复制回复', copyReply: '复制回复',
copyReplyDone: '回复已复制。', copyReplyDone: '回复已复制。',
copyReplyFail: '复制回复失败。', copyReplyFail: '复制回复失败。',
quoteReply: '引用回复',
quotedReplyLabel: '已引用回复',
clearQuote: '取消引用',
quoteOnlyMessage: '[引用回复]',
goodReply: '好回复', goodReply: '好回复',
badReply: '坏回复', badReply: '坏回复',
feedbackUpSaved: '已标记为好回复。', feedbackUpSaved: '已标记为好回复。',
feedbackDownSaved: '已标记为坏回复。', feedbackDownSaved: '已标记为坏回复。',
feedbackCleared: '反馈已取消。',
feedbackSaveFail: '反馈保存失败。', feedbackSaveFail: '反馈保存失败。',
feedbackMessagePending: '消息尚未同步,暂不可反馈。', feedbackMessagePending: '消息尚未同步,暂不可反馈。',
sendFailMsg: (msg: string) => `指令发送失败:${msg}`, sendFailMsg: (msg: string) => `指令发送失败:${msg}`,
@ -43,11 +49,18 @@ export const dashboardZhCn = {
deleteBotDone: 'Bot 与 workspace 已删除。', deleteBotDone: 'Bot 与 workspace 已删除。',
deleteFail: '删除失败。', deleteFail: '删除失败。',
titleBots: 'Bot 列表', titleBots: 'Bot 列表',
botSearchPlaceholder: '按 Bot 名称或 ID 搜索',
botSearchNoResult: '没有匹配的 Bot。',
paginationPrev: '上一页',
paginationNext: '下一页',
paginationPage: (current: number, total: number) => `${current} / ${total}`,
newBot: '新建机器人', newBot: '新建机器人',
manageImages: '镜像管理', manageImages: '镜像管理',
image: '镜像', image: '镜像',
stop: '停止', stop: '停止',
start: '启动', start: '启动',
restart: '重启 Bot',
restartFail: '重启失败,请查看后端日志。',
delete: '删除', delete: '删除',
noConversation: '暂无对话消息。请先发送指令Bot 回复会在这里按标准会话格式展示。', noConversation: '暂无对话消息。请先发送指令Bot 回复会在这里按标准会话格式展示。',
clearHistory: '清空对话', clearHistory: '清空对话',
@ -62,6 +75,8 @@ export const dashboardZhCn = {
disabledPlaceholder: 'Bot 未运行,无法发送指令。', disabledPlaceholder: 'Bot 未运行,无法发送指令。',
sending: '发送中...', sending: '发送中...',
send: '发送', send: '发送',
interrupt: '中断',
interruptSent: '已发送中断指令。',
botStarting: 'Bot 正在启动中...', botStarting: 'Bot 正在启动中...',
botStopping: 'Bot 正在停止中...', botStopping: 'Bot 正在停止中...',
chatDisabled: 'Bot 已停止,对话区已禁用。', chatDisabled: 'Bot 已停止,对话区已禁用。',

View File

@ -4,7 +4,9 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx' import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx'
import { setupBotAccessAuth } from './utils/botAccess.ts' import { setupBotAccessAuth } from './utils/botAccess.ts'
import { setupPanelAccessAuth } from './utils/panelAccess.ts'
setupPanelAccessAuth();
setupBotAccessAuth(); setupBotAccessAuth();
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@ -30,6 +30,36 @@
gap: 8px; 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 { .ops-bot-card {
position: relative; position: relative;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -114,6 +144,43 @@
stroke-width: 2.1; 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 { .ops-bot-actions .ops-bot-action-monitor {
background: color-mix(in oklab, var(--panel-soft) 75%, var(--brand-soft) 25%); 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%); border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%);
@ -258,7 +325,7 @@
position: relative; position: relative;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: minmax(0, 1fr) auto auto;
gap: 10px; gap: 10px;
} }
@ -329,7 +396,7 @@
} }
.ops-chat-scroll { .ops-chat-scroll {
min-height: 440px; min-height: 300px;
max-height: 68vh; max-height: 68vh;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -344,6 +411,33 @@
margin-bottom: 12px; 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 { .ops-chat-item {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -689,11 +783,61 @@
background: var(--panel-soft); background: var(--panel-soft);
padding: 10px; padding: 10px;
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto auto;
gap: 10px; gap: 10px;
align-items: flex-end; 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 { .ops-composer-input {
min-height: 78px; min-height: 78px;
resize: none; resize: none;
@ -708,6 +852,43 @@
gap: 8px; 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 { .ops-upload-progress {
margin-top: 8px; margin-top: 8px;
display: grid; display: grid;
@ -755,8 +936,8 @@
max-width: 100%; max-width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
padding: 4px 8px; padding: 6px 12px;
font-size: 12px; font-size: 13px;
color: var(--text); color: var(--text);
background: color-mix(in oklab, var(--panel-soft) 78%, transparent); background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
} }
@ -768,12 +949,29 @@
} }
.ops-chip-remove { .ops-chip-remove {
width: 18px; width: 34px;
height: 18px; height: 34px;
min-width: 18px; min-width: 34px;
min-height: 18px; min-height: 34px;
border-radius: 999px; 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 { .ops-runtime-panel {

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ const OSC_RE = /\x1b\][^\u0007]*(\u0007|\x1b\\)/g;
const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。、“”《》【】—…·\-_./:\\,%+*='"`|<>]/g; const NON_TEXT_RE = /[^\u0009\u0020-\u007E\u4E00-\u9FFF。、“”《》【】—…·\-_./:\\,%+*='"`|<>]/g;
const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
const ATTACHMENT_BLOCK_RE = /\[Attached Files\][\s\S]*?\[\/Attached Files\]/gi; 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) { function cleanLine(line: string) {
return line return line
@ -23,6 +24,8 @@ export function normalizeUserMessageText(input: string) {
// Keep attachment list out of editable/visible command text. // Keep attachment list out of editable/visible command text.
text = text.replace(ATTACHMENT_BLOCK_RE, '').trim(); 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: ...". // Strip role prefixes injected by some gateways, e.g. "You: ...".
text = text.replace(/(^|\n)\s*(you|user|你)\s*[:]\s*/gi, '$1').trim(); text = text.replace(/(^|\n)\s*(you|user|你)\s*[:]\s*/gi, '$1').trim();

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useAppStore } from '../../store/appStore'; import { useAppStore } from '../../store/appStore';
import { Power, PowerOff, Terminal, ShieldCheck, Plus, Bot, Cpu, Layers, RefreshCw } from 'lucide-react'; 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'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
export function ManagementModule() { export function ManagementModule() {
const { activeBots, setBots, updateBotStatus, locale } = useAppStore(); const { activeBots, setBots, mergeBot, updateBotStatus, locale } = useAppStore();
const { notify } = useLucentPrompt(); const { notify } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }); const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn });
const [selectedBotId, setSelectedBotId] = useState<string | null>(null); const [selectedBotId, setSelectedBotId] = useState<string | null>(null);
@ -49,6 +49,23 @@ export function ManagementModule() {
const selectedBot = selectedBotId ? activeBots[selectedBotId] : null; 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 ( return (
<div className="flex h-full gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="flex h-full gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="w-1/3 flex flex-col gap-4"> <div className="w-1/3 flex flex-col gap-4">

View File

@ -22,6 +22,7 @@ interface AppStore {
theme: AppTheme; theme: AppTheme;
locale: AppLocale; locale: AppLocale;
setBots: (bots: BotState[]) => void; setBots: (bots: BotState[]) => void;
mergeBot: (bot: BotState) => void;
setView: (view: AppView) => void; setView: (view: AppView) => void;
setTheme: (theme: AppTheme) => void; setTheme: (theme: AppTheme) => void;
setLocale: (locale: AppLocale) => void; setLocale: (locale: AppLocale) => void;
@ -58,6 +59,7 @@ export const useAppStore = create<AppStore>((set) => ({
const incomingAction = (bot.last_action || '').trim(); const incomingAction = (bot.last_action || '').trim();
nextBots[bot.id] = { nextBots[bot.id] = {
...prev,
...bot, ...bot,
logs: prev?.logs ?? [], logs: prev?.logs ?? [],
messages: prev?.messages ?? [], messages: prev?.messages ?? [],
@ -68,6 +70,22 @@ export const useAppStore = create<AppStore>((set) => ({
}); });
return { activeBots: nextBots }; 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 }), setView: (view) => set({ currentView: view }),
setTheme: (theme) => { setTheme: (theme) => {
localStorage.setItem('nanobot-theme', theme); localStorage.setItem('nanobot-theme', theme);

View File

@ -2,6 +2,7 @@ export interface ChatMessage {
id?: number; id?: number;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
text: string; text: string;
quoted_reply?: string;
ts: number; ts: number;
attachments?: string[]; attachments?: string[];
kind?: 'progress' | 'final'; kind?: 'progress' | 'final';
@ -43,6 +44,8 @@ export interface BotState {
identity_md?: string; identity_md?: string;
last_action?: string; last_action?: string;
current_state?: string; current_state?: string;
updated_at?: string | number;
created_at?: string | number;
logs: string[]; logs: string[];
messages?: ChatMessage[]; messages?: ChatMessage[];
events?: BotEvent[]; events?: BotEvent[];

View File

@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { appendPanelAccessPassword } from './panelAccess';
const BOT_PASSWORD_HEADER = 'X-Bot-Password'; const BOT_PASSWORD_HEADER = 'X-Bot-Password';
@ -64,6 +65,8 @@ export function clearAllBotAccessPasswords(): void {
export function isBotUnauthorizedError(error: any, botId?: string): boolean { export function isBotUnauthorizedError(error: any, botId?: string): boolean {
if (!axios.isAxiosError(error)) return false; if (!axios.isAxiosError(error)) return false;
if (Number(error.response?.status) !== 401) 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; if (!botId) return true;
const fromConfig = extractBotIdFromApiPath(String(error.config?.url || '')); 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 { 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); const password = getBotAccessPassword(botId);
if (!password) return target; if (!password) return target;
const joiner = target.includes('?') ? '&' : '?'; const joiner = target.includes('?') ? '&' : '?';

View File

@ -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<string, unknown>))) {
(headers as Record<string, string>)[PANEL_PASSWORD_HEADER] = password;
config.headers = headers;
}
return config;
});
}