v0.1.4
parent
296114f5b6
commit
5cee6e5e12
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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)格式。",
|
||||||
|
|
|
||||||
322
backend/main.py
322
backend/main.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,建议首次启动观察日志确认迁移与序列对齐完成。
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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 已停止,对话区已禁用。',
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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('?') ? '&' : '?';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue