From 9699b4e7c948c56c5271871806efef09de834f60 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 2 Apr 2026 12:14:08 +0800 Subject: [PATCH] v0.1.4-p4 --- README.md | 4 +- backend/.env.example | 7 +- backend/api/chat_history_router.py | 19 + backend/core/database.py | 662 ++---------------- backend/core/settings.py | 11 +- backend/db/dashboard.db | 0 backend/main.py | 8 +- backend/services/cache_service.py | 3 +- backend/services/chat_history_service.py | 31 + backend/services/platform_activity_service.py | 28 + backend/services/platform_overview_service.py | 8 +- backend/services/runtime_service.py | 21 +- design/database.md | 111 ++- frontend/src/i18n/dashboard.en.ts | 5 + frontend/src/i18n/dashboard.zh-cn.ts | 5 + .../modules/dashboard/BotDashboardModule.tsx | 4 + .../dashboard/components/BotListPanel.css | 32 +- .../components/DashboardChatPanel.tsx | 20 +- .../DashboardConversationMessages.css | 6 +- .../DashboardConversationMessages.tsx | 36 +- .../dashboard/hooks/useBotDashboardModule.ts | 10 +- .../hooks/useDashboardChatHistory.ts | 163 ++++- .../hooks/useDashboardConversation.ts | 10 + .../dashboard/hooks/useDashboardLifecycle.ts | 30 - .../hooks/useDashboardSystemDefaults.ts | 29 +- .../dashboard/hooks/useDashboardWorkspace.ts | 21 +- .../src/modules/dashboard/messageParser.ts | 27 + .../platform/PlatformAdminDashboardPage.tsx | 27 +- .../platform/PlatformBotManagementPage.tsx | 7 +- .../platform/PlatformDashboardPage.css | 39 +- .../PlatformBotActivityAnalyticsSection.tsx | 136 ++++ .../components/PlatformBotRuntimeSection.tsx | 6 +- .../components/PlatformDashboardModals.tsx | 27 +- .../PlatformUsageAnalyticsSection.tsx | 8 +- .../platform/hooks/usePlatformDashboard.ts | 2 + frontend/src/modules/platform/types.ts | 7 + 36 files changed, 813 insertions(+), 757 deletions(-) delete mode 100644 backend/db/dashboard.db create mode 100644 frontend/src/modules/platform/components/PlatformBotActivityAnalyticsSection.tsx diff --git a/README.md b/README.md index 829d2f8..d097d92 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Dashboard Nanobot 是面向 `nanobot` 的控制平面项目,提供镜像管理 graph TD User((User)) --> Frontend[Frontend Control Plane] Frontend --> API[FastAPI Backend] - API --> DB[(SQLite)] + API --> DB[(PostgreSQL)] API --> Docker[Docker Daemon] Docker --> BotA[Bot Container A] @@ -63,7 +63,7 @@ graph TD - 示例文件:`backend/.env.example` - 本地配置:`backend/.env` - 关键项: - - `DATABASE_URL`:数据库连接串(三选一:SQLite / PostgreSQL / MySQL) + - `DATABASE_URL`:数据库连接串(建议使用 PostgreSQL) - `DATABASE_ECHO`:SQL 日志输出开关 - 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串) - `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录 diff --git a/backend/.env.example b/backend/.env.example index 192806e..0ee3773 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,11 +3,8 @@ DATA_ROOT=../data BOTS_WORKSPACE_ROOT=../workspace/bots # Database -# SQLite (recommended): leave DATABASE_URL unset, backend will use: -# sqlite:///{DATA_ROOT}/nanobot_dashboard.db -# DATABASE_URL=sqlite:///../data/nanobot_dashboard.db -# PostgreSQL example: -# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard +# PostgreSQL is required: +DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard # MySQL example: # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # Show SQL statements in backend logs (debug only). diff --git a/backend/api/chat_history_router.py b/backend/api/chat_history_router.py index 1dbf7f7..d0854f1 100644 --- a/backend/api/chat_history_router.py +++ b/backend/api/chat_history_router.py @@ -8,6 +8,7 @@ from schemas.bot import MessageFeedbackRequest from services.chat_history_service import ( clear_bot_messages_payload, clear_dashboard_direct_session_payload, + delete_bot_message_payload, list_bot_messages_by_date_payload, list_bot_messages_page_payload, list_bot_messages_payload, @@ -59,6 +60,24 @@ def update_bot_message_feedback( return update_bot_message_feedback_payload(session, bot_id, message_id, payload.feedback) +@router.delete("/api/bots/{bot_id}/messages/{message_id}") +def delete_bot_message( + bot_id: str, + message_id: int, + session: Session = Depends(get_session), +): + return delete_bot_message_payload(session, bot_id, message_id) + + +@router.post("/api/bots/{bot_id}/messages/{message_id}/delete") +def delete_bot_message_post( + bot_id: str, + message_id: int, + session: Session = Depends(get_session), +): + return delete_bot_message_payload(session, bot_id, message_id) + + @router.delete("/api/bots/{bot_id}/messages") def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): return clear_bot_messages_payload(session, bot_id) diff --git a/backend/core/database.py b/backend/core/database.py index 5656174..089be49 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -3,7 +3,6 @@ from sqlmodel import SQLModel, Session, create_engine from core.settings import ( DATABASE_ECHO, - DATABASE_ENGINE, DATABASE_MAX_OVERFLOW, DATABASE_POOL_RECYCLE, DATABASE_POOL_SIZE, @@ -19,19 +18,12 @@ from models import topic as _topic_models # noqa: F401 _engine_kwargs = { "echo": DATABASE_ECHO, + "pool_pre_ping": True, + "pool_size": DATABASE_POOL_SIZE, + "max_overflow": DATABASE_MAX_OVERFLOW, + "pool_timeout": DATABASE_POOL_TIMEOUT, + "pool_recycle": DATABASE_POOL_RECYCLE, } -if DATABASE_ENGINE == "sqlite": - _engine_kwargs["connect_args"] = {"check_same_thread": False} -else: - _engine_kwargs.update( - { - "pool_pre_ping": True, - "pool_size": DATABASE_POOL_SIZE, - "max_overflow": DATABASE_MAX_OVERFLOW, - "pool_timeout": DATABASE_POOL_TIMEOUT, - "pool_recycle": DATABASE_POOL_RECYCLE, - } - ) engine = create_engine(DATABASE_URL, **_engine_kwargs) @@ -42,60 +34,17 @@ BOT_REQUEST_USAGE_TABLE = "bot_request_usage" BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event" SYS_SETTING_TABLE = "sys_setting" POSTGRES_MIGRATION_LOCK_KEY = 2026031801 -MYSQL_MIGRATION_LOCK_NAME = "dashboard_nanobot_schema_migration" -LEGACY_TABLE_PAIRS = [ - ("botinstance", BOT_INSTANCE_TABLE), - ("botmessage", BOT_MESSAGE_TABLE), - ("nanobotimage", BOT_IMAGE_TABLE), - ("platformsetting", SYS_SETTING_TABLE), - ("botrequestusage", BOT_REQUEST_USAGE_TABLE), - ("botactivityevent", BOT_ACTIVITY_EVENT_TABLE), -] def _quote_ident(name: str) -> str: - if engine.dialect.name == "mysql": - return f"`{str(name).replace('`', '``')}`" return f'"{str(name).replace(chr(34), chr(34) * 2)}"' -def _rename_table_if_needed(old_name: str, new_name: str) -> None: - inspector = inspect(engine) - if not inspector.has_table(old_name) or inspector.has_table(new_name): - return - dialect = engine.dialect.name - with engine.connect() as conn: - if dialect == "mysql": - conn.execute(text(f"RENAME TABLE `{old_name}` TO `{new_name}`")) - else: - conn.execute(text(f'ALTER TABLE "{old_name}" RENAME TO "{new_name}"')) - conn.commit() - - -def _rename_legacy_tables() -> None: - _rename_table_if_needed("botinstance", BOT_INSTANCE_TABLE) - _rename_table_if_needed("botmessage", BOT_MESSAGE_TABLE) - _rename_table_if_needed("nanobotimage", BOT_IMAGE_TABLE) - _rename_table_if_needed("platformsetting", SYS_SETTING_TABLE) - _rename_table_if_needed("botrequestusage", BOT_REQUEST_USAGE_TABLE) - _rename_table_if_needed("botactivityevent", BOT_ACTIVITY_EVENT_TABLE) - - def _acquire_migration_lock(): if engine.dialect.name == "postgresql": conn = engine.connect() conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY}) return conn - if engine.dialect.name == "mysql": - conn = engine.connect() - acquired = conn.execute( - text("SELECT GET_LOCK(:name, :timeout)"), - {"name": MYSQL_MIGRATION_LOCK_NAME, "timeout": 120}, - ).scalar() - if int(acquired or 0) != 1: - conn.close() - raise RuntimeError("Failed to acquire schema migration lock") - return conn return None @@ -105,191 +54,17 @@ def _release_migration_lock(lock_conn) -> None: try: if engine.dialect.name == "postgresql": lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY}) - elif engine.dialect.name == "mysql": - lock_conn.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": MYSQL_MIGRATION_LOCK_NAME}) finally: lock_conn.close() -def _table_row_count(table_name: str) -> int: - inspector = inspect(engine) - if not inspector.has_table(table_name): - return 0 - with engine.connect() as conn: - value = conn.execute(text(f"SELECT COUNT(*) FROM {_quote_ident(table_name)}")).scalar() - return int(value or 0) - - -def _copy_legacy_table_rows(old_name: str, new_name: str) -> None: - inspector = inspect(engine) - if not inspector.has_table(old_name) or not inspector.has_table(new_name): - return - if _table_row_count(old_name) <= 0: - return - - old_columns = { - str(row.get("name")) - for row in inspector.get_columns(old_name) - if row.get("name") - } - new_columns = [ - str(row.get("name")) - for row in inspector.get_columns(new_name) - if row.get("name") - ] - shared_columns = [col for col in new_columns if col in old_columns] - if not shared_columns: - return - pk = inspector.get_pk_constraint(new_name) or {} - pk_columns = [ - str(col) - for col in (pk.get("constrained_columns") or []) - if col and col in shared_columns and col in old_columns - ] - if not pk_columns: - return - - columns_sql = ", ".join(_quote_ident(col) for col in shared_columns) - join_sql = " AND ".join( - f'n.{_quote_ident(col)} = o.{_quote_ident(col)}' - for col in pk_columns - ) - null_check_col = _quote_ident(pk_columns[0]) - with engine.connect() as conn: - conn.execute( - text( - f"INSERT INTO {_quote_ident(new_name)} ({columns_sql}) " - f"SELECT {', '.join(f'o.{_quote_ident(col)}' for col in shared_columns)} " - f"FROM {_quote_ident(old_name)} o " - f"LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} " - f"WHERE n.{null_check_col} IS NULL" - ) - ) - conn.commit() - - -def _migrate_legacy_table_rows() -> None: - for old_name, new_name in LEGACY_TABLE_PAIRS: - _copy_legacy_table_rows(old_name, new_name) - - -def _topic_fk_target(table_name: str, constrained_column: str = "bot_id") -> str | None: - inspector = inspect(engine) - if not inspector.has_table(table_name): - return None - for fk in inspector.get_foreign_keys(table_name): - cols = [str(col) for col in (fk.get("constrained_columns") or []) if col] - if cols == [constrained_column]: - referred = fk.get("referred_table") - return str(referred) if referred else None - return None - - -def _repair_postgres_topic_foreign_keys() -> None: - if engine.dialect.name != "postgresql": - return - targets = { - "topic_topic": "topic_topic_bot_id_fkey", - "topic_item": "topic_item_bot_id_fkey", - } - with engine.connect() as conn: - changed = False - for table_name, constraint_name in targets.items(): - if _topic_fk_target(table_name) == BOT_INSTANCE_TABLE: - continue - conn.execute( - text( - f'ALTER TABLE {_quote_ident(table_name)} ' - f'DROP CONSTRAINT IF EXISTS {_quote_ident(constraint_name)}' - ) - ) - conn.execute( - text( - f'ALTER TABLE {_quote_ident(table_name)} ' - f'ADD CONSTRAINT {_quote_ident(constraint_name)} ' - f'FOREIGN KEY ({_quote_ident("bot_id")}) ' - f'REFERENCES {_quote_ident(BOT_INSTANCE_TABLE)}({_quote_ident("id")}) ' - f'ON DELETE CASCADE' - ) - ) - changed = True - if changed: - conn.commit() - - -def _legacy_rows_missing_in_new(old_name: str, new_name: str) -> int: - inspector = inspect(engine) - if not inspector.has_table(old_name) or not inspector.has_table(new_name): - return 0 - pk = inspector.get_pk_constraint(new_name) or {} - pk_columns = [ - str(col) - for col in (pk.get("constrained_columns") or []) - if col - ] - if not pk_columns: - return _table_row_count(old_name) - join_sql = " AND ".join( - f'n.{_quote_ident(col)} = o.{_quote_ident(col)}' - for col in pk_columns - ) - null_check_col = _quote_ident(pk_columns[0]) - with engine.connect() as conn: - value = conn.execute( - text( - f'SELECT COUNT(*) FROM {_quote_ident(old_name)} o ' - f'LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} ' - f'WHERE n.{null_check_col} IS NULL' - ) - ).scalar() - return int(value or 0) - - -def _drop_legacy_tables() -> None: - droppable = [ - old_name - for old_name, new_name in LEGACY_TABLE_PAIRS - if _legacy_rows_missing_in_new(old_name, new_name) <= 0 - ] - if not droppable: - return - with engine.connect() as conn: - for old_name in droppable: - if engine.dialect.name == "postgresql": - conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)} CASCADE')) - else: - conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)}')) - conn.commit() - - def _ensure_botinstance_columns() -> None: - dialect = engine.dialect.name required_columns = { - "current_state": { - "sqlite": "TEXT DEFAULT 'IDLE'", - "postgresql": "TEXT DEFAULT 'IDLE'", - "mysql": "VARCHAR(64) DEFAULT 'IDLE'", - }, - "last_action": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "LONGTEXT", - }, - "image_tag": { - "sqlite": "TEXT DEFAULT 'nanobot-base:v0.1.4'", - "postgresql": "TEXT DEFAULT 'nanobot-base:v0.1.4'", - "mysql": "VARCHAR(255) DEFAULT 'nanobot-base:v0.1.4'", - }, - "access_password": { - "sqlite": "TEXT DEFAULT ''", - "postgresql": "TEXT DEFAULT ''", - "mysql": "VARCHAR(255) DEFAULT ''", - }, - "enabled": { - "sqlite": "INTEGER NOT NULL DEFAULT 1", - "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", - "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", - }, + "current_state": "TEXT DEFAULT 'IDLE'", + "last_action": "TEXT", + "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'", + "access_password": "TEXT DEFAULT ''", + "enabled": "BOOLEAN NOT NULL DEFAULT TRUE", } inspector = inspect(engine) @@ -301,124 +76,24 @@ def _ensure_botinstance_columns() -> None: for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE) if row.get("name") } - for col, ddl_map in required_columns.items(): - if col in existing: - continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") - conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}")) - if "enabled" in existing: - if dialect == "sqlite": - conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL")) - else: - conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL")) - conn.commit() - - -def _drop_legacy_botinstance_columns() -> None: - legacy_columns = [ - "avatar_model", - "avatar_skin", - "system_prompt", - "soul_md", - "agents_md", - "user_md", - "tools_md", - "tools_config_json", - "identity_md", - "llm_provider", - "llm_model", - "api_key", - "api_base", - "temperature", - "top_p", - "max_tokens", - "presence_penalty", - "frequency_penalty", - "send_progress", - "send_tool_hints", - "bot_env_json", - ] - with engine.connect() as conn: - existing = { - str(col.get("name")) - for col in inspect(conn).get_columns(BOT_INSTANCE_TABLE) - if col.get("name") - } - for col in legacy_columns: - if col not in existing: - continue - try: - if engine.dialect.name == "mysql": - conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`")) - elif engine.dialect.name == "sqlite": - conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"')) - else: - conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN IF EXISTS "{col}"')) - except Exception: - # Keep startup resilient on mixed/legacy database engines. - continue - conn.commit() - - -def _ensure_botmessage_columns() -> None: - if engine.dialect.name != "sqlite": - return - required_columns = { - "media_json": "TEXT", - "feedback": "TEXT", - "feedback_at": "DATETIME", - } - with engine.connect() as conn: - existing_rows = conn.execute(text(f"PRAGMA table_info({BOT_MESSAGE_TABLE})")).fetchall() - existing = {str(row[1]) for row in existing_rows} for col, ddl in required_columns.items(): if col in existing: continue - conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}")) - conn.commit() - - -def _drop_legacy_skill_tables() -> None: - """Drop deprecated skill registry tables (moved to workspace filesystem mode).""" - with engine.connect() as conn: - conn.execute(text("DROP TABLE IF EXISTS botskillmapping")) - conn.execute(text("DROP TABLE IF EXISTS skillregistry")) + conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}")) + + if "enabled" in existing: + conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL")) conn.commit() def _ensure_sys_setting_columns() -> None: - dialect = engine.dialect.name required_columns = { - "name": { - "sqlite": "TEXT NOT NULL DEFAULT ''", - "postgresql": "TEXT NOT NULL DEFAULT ''", - "mysql": "VARCHAR(200) NOT NULL DEFAULT ''", - }, - "category": { - "sqlite": "TEXT NOT NULL DEFAULT 'general'", - "postgresql": "TEXT NOT NULL DEFAULT 'general'", - "mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'", - }, - "description": { - "sqlite": "TEXT NOT NULL DEFAULT ''", - "postgresql": "TEXT NOT NULL DEFAULT ''", - "mysql": "LONGTEXT", - }, - "value_type": { - "sqlite": "TEXT NOT NULL DEFAULT 'json'", - "postgresql": "TEXT NOT NULL DEFAULT 'json'", - "mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'", - }, - "is_public": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", - "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", - "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", - }, - "sort_order": { - "sqlite": "INTEGER NOT NULL DEFAULT 100", - "postgresql": "INTEGER NOT NULL DEFAULT 100", - "mysql": "INTEGER NOT NULL DEFAULT 100", - }, + "name": "TEXT NOT NULL DEFAULT ''", + "category": "TEXT NOT NULL DEFAULT 'general'", + "description": "TEXT NOT NULL DEFAULT ''", + "value_type": "TEXT NOT NULL DEFAULT 'json'", + "is_public": "BOOLEAN NOT NULL DEFAULT FALSE", + "sort_order": "INTEGER NOT NULL DEFAULT 100", } inspector = inspect(engine) if not inspector.has_table(SYS_SETTING_TABLE): @@ -429,32 +104,18 @@ def _ensure_sys_setting_columns() -> None: for row in inspect(conn).get_columns(SYS_SETTING_TABLE) if row.get("name") } - for col, ddl_map in required_columns.items(): + for col, ddl in required_columns.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}")) conn.commit() def _ensure_bot_request_usage_columns() -> None: - dialect = engine.dialect.name required_columns = { - "message_id": { - "sqlite": "INTEGER", - "postgresql": "INTEGER", - "mysql": "INTEGER", - }, - "provider": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "VARCHAR(120)", - }, - "model": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "VARCHAR(255)", - }, + "message_id": "INTEGER", + "provider": "TEXT", + "model": "TEXT", } inspector = inspect(engine) if not inspector.has_table(BOT_REQUEST_USAGE_TABLE): @@ -465,161 +126,34 @@ def _ensure_bot_request_usage_columns() -> None: for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE) if row.get("name") } - for col, ddl_map in required_columns.items(): + for col, ddl in required_columns.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}")) conn.commit() -def _ensure_topic_tables_sqlite() -> None: - if engine.dialect.name != "sqlite": - return - with engine.connect() as conn: - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS topic_topic ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_key TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - is_active INTEGER NOT NULL DEFAULT 1, - is_default_fallback INTEGER NOT NULL DEFAULT 0, - routing_json TEXT NOT NULL DEFAULT '{}', - view_schema_json TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES bot_instance(id) - ) - """ - ) - ) - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS topic_item ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_key TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL DEFAULT '', - level TEXT NOT NULL DEFAULT 'info', - tags_json TEXT, - view_json TEXT, - source TEXT NOT NULL DEFAULT 'mcp', - dedupe_key TEXT, - is_read INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES bot_instance(id) - ) - """ - ) - ) - - conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic(bot_id)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback ON topic_topic(bot_id, is_default_fallback)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id ON topic_item(bot_id)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key ON topic_item(topic_key)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_level ON topic_item(level)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_source ON topic_item(source)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_is_read ON topic_item(is_read)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_created_at ON topic_item(created_at)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at ON topic_item(bot_id, topic_key, created_at)")) - conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe ON topic_item(bot_id, dedupe_key)")) - conn.commit() - - def _ensure_topic_columns() -> None: - dialect = engine.dialect.name required_columns = { "topic_topic": { - "name": { - "sqlite": "TEXT NOT NULL DEFAULT ''", - "postgresql": "TEXT NOT NULL DEFAULT ''", - "mysql": "VARCHAR(255) NOT NULL DEFAULT ''", - }, - "description": { - "sqlite": "TEXT NOT NULL DEFAULT ''", - "postgresql": "TEXT NOT NULL DEFAULT ''", - "mysql": "LONGTEXT", - }, - "is_active": { - "sqlite": "INTEGER NOT NULL DEFAULT 1", - "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", - "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", - }, - "is_default_fallback": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", - "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", - "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", - }, - "routing_json": { - "sqlite": "TEXT NOT NULL DEFAULT '{}'", - "postgresql": "TEXT NOT NULL DEFAULT '{}'", - "mysql": "LONGTEXT", - }, - "view_schema_json": { - "sqlite": "TEXT NOT NULL DEFAULT '{}'", - "postgresql": "TEXT NOT NULL DEFAULT '{}'", - "mysql": "LONGTEXT", - }, - "created_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", - "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - }, - "updated_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", - "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - }, + "name": "TEXT NOT NULL DEFAULT ''", + "description": "TEXT NOT NULL DEFAULT ''", + "is_active": "BOOLEAN NOT NULL DEFAULT TRUE", + "is_default_fallback": "BOOLEAN NOT NULL DEFAULT FALSE", + "routing_json": "TEXT NOT NULL DEFAULT '{}'", + "view_schema_json": "TEXT NOT NULL DEFAULT '{}'", + "created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", }, "topic_item": { - "title": { - "sqlite": "TEXT NOT NULL DEFAULT ''", - "postgresql": "TEXT NOT NULL DEFAULT ''", - "mysql": "VARCHAR(2000) NOT NULL DEFAULT ''", - }, - "level": { - "sqlite": "TEXT NOT NULL DEFAULT 'info'", - "postgresql": "TEXT NOT NULL DEFAULT 'info'", - "mysql": "VARCHAR(32) NOT NULL DEFAULT 'info'", - }, - "tags_json": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "LONGTEXT", - }, - "view_json": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "LONGTEXT", - }, - "source": { - "sqlite": "TEXT NOT NULL DEFAULT 'mcp'", - "postgresql": "TEXT NOT NULL DEFAULT 'mcp'", - "mysql": "VARCHAR(64) NOT NULL DEFAULT 'mcp'", - }, - "dedupe_key": { - "sqlite": "TEXT", - "postgresql": "TEXT", - "mysql": "VARCHAR(200)", - }, - "is_read": { - "sqlite": "INTEGER NOT NULL DEFAULT 0", - "postgresql": "BOOLEAN NOT NULL DEFAULT FALSE", - "mysql": "BOOLEAN NOT NULL DEFAULT FALSE", - }, - "created_at": { - "sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", - "mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - }, + "title": "TEXT NOT NULL DEFAULT ''", + "level": "TEXT NOT NULL DEFAULT 'info'", + "tags_json": "TEXT", + "view_json": "TEXT", + "source": "TEXT NOT NULL DEFAULT 'mcp'", + "dedupe_key": "TEXT", + "is_read": "BOOLEAN NOT NULL DEFAULT FALSE", + "created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP", }, } @@ -633,114 +167,13 @@ def _ensure_topic_columns() -> None: for row in inspector.get_columns(table_name) if row.get("name") } - for col, ddl_map in cols.items(): + for col, ddl in cols.items(): if col in existing: continue - ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}")) conn.commit() -def _ensure_topic_indexes() -> None: - required_indexes = [ - ("uq_topic_topic_bot_topic_key", "topic_topic", ["bot_id", "topic_key"], True), - ("idx_topic_topic_bot_id", "topic_topic", ["bot_id"], False), - ("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False), - ("idx_topic_topic_bot_fallback", "topic_topic", ["bot_id", "is_default_fallback"], False), - ("idx_topic_item_bot_id", "topic_item", ["bot_id"], False), - ("idx_topic_item_topic_key", "topic_item", ["topic_key"], False), - ("idx_topic_item_level", "topic_item", ["level"], False), - ("idx_topic_item_source", "topic_item", ["source"], False), - ("idx_topic_item_is_read", "topic_item", ["is_read"], False), - ("idx_topic_item_created_at", "topic_item", ["created_at"], False), - ("idx_topic_item_bot_topic_created_at", "topic_item", ["bot_id", "topic_key", "created_at"], False), - ("idx_topic_item_bot_dedupe", "topic_item", ["bot_id", "dedupe_key"], False), - ] - inspector = inspect(engine) - with engine.connect() as conn: - for name, table_name, columns, unique in required_indexes: - if not inspector.has_table(table_name): - continue - existing = { - str(item.get("name")) - for item in inspector.get_indexes(table_name) - if item.get("name") - } - existing.update( - str(item.get("name")) - for item in inspector.get_unique_constraints(table_name) - if item.get("name") - ) - if name in existing: - continue - unique_sql = "UNIQUE " if unique else "" - cols_sql = ", ".join(columns) - conn.execute(text(f"CREATE {unique_sql}INDEX {name} ON {table_name} ({cols_sql})")) - conn.commit() - - -def _drop_obsolete_topic_tables() -> None: - with engine.connect() as conn: - if engine.dialect.name == "postgresql": - conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"')) - elif engine.dialect.name == "mysql": - conn.execute(text("DROP TABLE IF EXISTS `topic_bot_settings`")) - else: - conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"')) - conn.commit() - - -def _cleanup_legacy_default_topics() -> None: - """ - Remove legacy auto-created fallback topic rows from early topic-feed design. - - Historical rows look like: - - topic_key = inbox - - name = Inbox - - description = Default topic for uncategorized items - - routing_json contains "Fallback topic" - """ - with engine.connect() as conn: - legacy_rows = conn.execute( - text( - """ - SELECT bot_id, topic_key - FROM topic_topic - WHERE lower(coalesce(topic_key, '')) = 'inbox' - AND lower(coalesce(name, '')) = 'inbox' - AND lower(coalesce(description, '')) = 'default topic for uncategorized items' - AND lower(coalesce(routing_json, '')) LIKE '%fallback topic%' - """ - ) - ).fetchall() - if not legacy_rows: - return - for row in legacy_rows: - bot_id = str(row[0] or "").strip() - topic_key = str(row[1] or "").strip().lower() - if not bot_id or not topic_key: - continue - conn.execute( - text( - """ - DELETE FROM topic_item - WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key - """ - ), - {"bot_id": bot_id, "topic_key": topic_key}, - ) - conn.execute( - text( - """ - DELETE FROM topic_topic - WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key - """ - ), - {"bot_id": bot_id, "topic_key": topic_key}, - ) - conn.commit() - - def align_postgres_sequences() -> None: if engine.dialect.name != "postgresql": return @@ -777,22 +210,11 @@ def align_postgres_sequences() -> None: def init_database() -> None: lock_conn = _acquire_migration_lock() try: - _rename_legacy_tables() SQLModel.metadata.create_all(engine) - _migrate_legacy_table_rows() - _drop_legacy_skill_tables() _ensure_sys_setting_columns() _ensure_bot_request_usage_columns() _ensure_botinstance_columns() - _drop_legacy_botinstance_columns() - _ensure_botmessage_columns() - _ensure_topic_tables_sqlite() - _repair_postgres_topic_foreign_keys() _ensure_topic_columns() - _ensure_topic_indexes() - _drop_obsolete_topic_tables() - _cleanup_legacy_default_topics() - _drop_legacy_tables() align_postgres_sequences() finally: _release_migration_lock(lock_conn) diff --git a/backend/core/settings.py b/backend/core/settings.py index 8ba1eba..ca07171 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -143,9 +143,10 @@ def _mask_database_url(url: str) -> str: _db_env = str(os.getenv("DATABASE_URL") or "").strip() -DATABASE_URL: Final[str] = _normalize_database_url( - _db_env if _db_env else f"sqlite:///{Path(DATA_ROOT) / 'nanobot_dashboard.db'}" -) +if not _db_env: + raise RuntimeError("DATABASE_URL is not set in environment. PostgreSQL is required.") + +DATABASE_URL: Final[str] = _normalize_database_url(_db_env) DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL) DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) @@ -198,6 +199,10 @@ REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot") REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400) PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() +APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip() +APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535) +APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False) + TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json" TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json" diff --git a/backend/db/dashboard.db b/backend/db/dashboard.db deleted file mode 100644 index e69de29..0000000 diff --git a/backend/main.py b/backend/main.py index be8b6d6..9412460 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,8 +1,14 @@ from app_factory import create_app +from core.settings import APP_HOST, APP_PORT, APP_RELOAD app = create_app() if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + if APP_RELOAD: + # Use import string to support hot-reloading + uvicorn.run("main:app", host=APP_HOST, port=APP_PORT, reload=True) + else: + # Use app object for faster/direct startup + uvicorn.run(app, host=APP_HOST, port=APP_PORT) diff --git a/backend/services/cache_service.py b/backend/services/cache_service.py index 3e4153d..3f8d1a2 100644 --- a/backend/services/cache_service.py +++ b/backend/services/cache_service.py @@ -21,7 +21,8 @@ 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}:") + cache.delete_prefix(f"bot:messages:list:v2:{bot_id}:") + cache.delete_prefix(f"bot:messages:page:v2:{bot_id}:") def _invalidate_images_cache() -> None: cache.delete(_cache_key_images()) diff --git a/backend/services/chat_history_service.py b/backend/services/chat_history_service.py index 97993c0..4113179 100644 --- a/backend/services/chat_history_service.py +++ b/backend/services/chat_history_service.py @@ -275,6 +275,37 @@ def update_bot_message_feedback_payload( } +def delete_bot_message_payload( + session: Session, + bot_id: str, + message_id: int, +) -> Dict[str, Any]: + _get_bot_or_404(session, bot_id) + row = session.get(BotMessage, message_id) + if not row or row.bot_id != bot_id: + raise HTTPException(status_code=404, detail="Message not found") + + deleted_role = str(row.role or "").strip() or "assistant" + session.delete(row) + record_activity_event( + session, + bot_id, + "message_deleted", + channel="dashboard", + detail=f"Deleted {deleted_role} message #{message_id}", + metadata={"message_id": message_id, "role": deleted_role}, + ) + session.commit() + _invalidate_bot_detail_cache(bot_id) + _invalidate_bot_messages_cache(bot_id) + return { + "status": "deleted", + "bot_id": bot_id, + "message_id": message_id, + "role": deleted_role, + } + + def clear_bot_messages_payload(session: Session, bot_id: str) -> Dict[str, Any]: bot = _get_bot_or_404(session, bot_id) rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() diff --git a/backend/services/platform_activity_service.py b/backend/services/platform_activity_service.py index 8bb7fdf..a607ff3 100644 --- a/backend/services/platform_activity_service.py +++ b/backend/services/platform_activity_service.py @@ -101,3 +101,31 @@ def list_activity_events( ).model_dump() ) return items + + +def get_bot_activity_stats(session: Session) -> List[Dict[str, Any]]: + from sqlalchemy import and_, func + from models.bot import BotInstance + + stmt = ( + select(BotInstance.id, BotInstance.name, func.count(BotActivityEvent.id).label("count")) + .select_from(BotInstance) + .join( + BotActivityEvent, + and_( + BotActivityEvent.bot_id == BotInstance.id, + BotActivityEvent.request_id.is_not(None), + func.length(func.trim(BotActivityEvent.request_id)) > 0, + ), + isouter=True, + ) + .where(BotInstance.enabled.is_(True)) + .group_by(BotInstance.id, BotInstance.name) + .order_by(func.count(BotActivityEvent.id).desc(), BotInstance.name.asc(), BotInstance.id.asc()) + ) + results = session.exec(stmt).all() + + return [ + {"bot_id": row[0], "name": row[1] or row[0], "count": row[2]} + for row in results + ] diff --git a/backend/services/platform_overview_service.py b/backend/services/platform_overview_service.py index 62e5e1e..f5e9994 100644 --- a/backend/services/platform_overview_service.py +++ b/backend/services/platform_overview_service.py @@ -5,7 +5,11 @@ from sqlmodel import Session, select from core.utils import _calc_dir_size_bytes from models.bot import BotInstance, NanobotImage from services.bot_storage_service import _read_bot_resources, _workspace_root -from services.platform_activity_service import list_activity_events, prune_expired_activity_events +from services.platform_activity_service import ( + get_bot_activity_stats, + list_activity_events, + prune_expired_activity_events, +) from services.platform_settings_service import get_platform_settings from services.platform_usage_service import list_usage @@ -63,6 +67,7 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, usage = list_usage(session, limit=20) events = list_activity_events(session, limit=20) + activity_stats = get_bot_activity_stats(session) return { "summary": { @@ -101,4 +106,5 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, "settings": settings.model_dump(), "usage": usage, "events": events, + "activity_stats": activity_stats, } diff --git a/backend/services/runtime_service.py b/backend/services/runtime_service.py index 2d782cc..dc9abb5 100644 --- a/backend/services/runtime_service.py +++ b/backend/services/runtime_service.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import re import time from datetime import datetime from typing import Any, Dict, List, Optional @@ -21,6 +22,7 @@ logger = logging.getLogger("dashboard.backend") _main_loop: Optional[asyncio.AbstractEventLoop] = None _AGENT_LOOP_READY_MARKER = "Agent loop started" +_LAST_ACTION_CONTROL_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]") def set_main_loop(loop: Optional[asyncio.AbstractEventLoop]) -> None: @@ -73,6 +75,17 @@ def _normalize_media_list(raw: Any, bot_id: str) -> List[str]: return rows +def _normalize_last_action_text(value: Any) -> str: + text = str(value or "") + if not text: + return "" + text = _LAST_ACTION_CONTROL_RE.sub("", text) + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = "\n".join(line.rstrip() for line in text.split("\n")) + text = re.sub(r"\n{4,}", "\n\n\n", text).strip() + return text[:4000] + + def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]: packet_type = str(packet.get("type", "")).upper() if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: @@ -91,18 +104,18 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int if packet_type == "AGENT_STATE": payload = packet.get("payload") or {} state = str(payload.get("state") or "").strip() - action = str(payload.get("action_msg") or payload.get("msg") or "").strip() + action = _normalize_last_action_text(payload.get("action_msg") or payload.get("msg") or "") if state: bot.current_state = state if action: - bot.last_action = action[:4000] + bot.last_action = action elif packet_type == "ASSISTANT_MESSAGE": bot.current_state = "IDLE" text_msg = str(packet.get("text") or "").strip() media_list = _normalize_media_list(packet.get("media"), bot_id) if text_msg or media_list: if text_msg: - bot.last_action = " ".join(text_msg.split())[:4000] + bot.last_action = _normalize_last_action_text(text_msg) message_row = BotMessage( bot_id=bot_id, role="assistant", @@ -148,7 +161,7 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int if text_msg or media_list: bot.current_state = "IDLE" if text_msg: - bot.last_action = " ".join(text_msg.split())[:4000] + bot.last_action = _normalize_last_action_text(text_msg) message_row = BotMessage( bot_id=bot_id, role="assistant", diff --git a/design/database.md b/design/database.md index 71237db..8d63ab1 100644 --- a/design/database.md +++ b/design/database.md @@ -1,36 +1,42 @@ -# Dashboard Nanobot 数据库设计文档(当前实现) +# Dashboard Nanobot 数据库设计文档 -数据库默认使用 SQLite:`data/nanobot_dashboard.db`。 +数据库默认使用 PostgreSQL(推荐使用 psycopg3 驱动)。 ## 1. ERD ```mermaid erDiagram - BOTINSTANCE ||--o{ BOTMESSAGE : "messages" - NANOBOTIMAGE ||--o{ BOTINSTANCE : "referenced by" + bot_instance ||--o{ bot_message : "messages" + bot_instance ||--o{ bot_request_usage : "usage" + bot_instance ||--o{ bot_activity_event : "events" + bot_image ||--o{ bot_instance : "referenced by" - BOTINSTANCE { + bot_instance { string id PK string name + boolean enabled + string access_password string workspace_dir UK string docker_status - string image_tag string current_state - text last_action + string last_action + string image_tag datetime created_at datetime updated_at } - BOTMESSAGE { + bot_message { int id PK string bot_id FK string role text text text media_json + string feedback + datetime feedback_at datetime created_at } - NANOBOTIMAGE { + bot_image { string tag PK string image_id string version @@ -38,48 +44,81 @@ erDiagram string source_dir datetime created_at } + + bot_request_usage { + int id PK + string bot_id FK + string request_id + string channel + string status + string provider + string model + int input_tokens + int output_tokens + int total_tokens + datetime started_at + datetime completed_at + datetime created_at + } + + bot_activity_event { + int id PK + string bot_id FK + string request_id + string event_type + string channel + string detail + text metadata_json + datetime created_at + } + + sys_setting { + string key PK + string name + string category + string description + string value_type + text value_json + boolean is_public + int sort_order + datetime created_at + datetime updated_at + } ``` ## 2. 设计原则 -- 数据库只保留运行索引和历史消息。 -- Bot 参数(模型、渠道、资源配额、5 个 MD 文件)统一持久化在: +- 数据库保留运行索引、历史消息、用量统计与运维事件。 +- Bot 核心配置(渠道、资源配额、5 个 MD 文件)统一持久化在文件系统: - `.nanobot/config.json` - `.nanobot/workspace/*.md` - `.nanobot/env.json` -- `channelroute` 已废弃,不再使用数据库存储渠道。 ## 3. 表说明 -### 3.1 `botinstance` +### 3.1 `bot_instance` +存储 Bot 基础索引与运行态。 -仅存基础索引与运行态: +### 3.2 `bot_message` +Dashboard 渠道对话历史(用于会话回放与反馈)。 -- 标识与展示:`id`、`name` -- 容器与镜像:`docker_status`、`image_tag` -- 运行状态:`current_state`、`last_action` -- 路径与时间:`workspace_dir`、`created_at`、`updated_at` +### 3.3 `bot_image` +基础镜像登记表。 -### 3.2 `botmessage` +### 3.4 `bot_request_usage` +模型调用用量详细记录。 -Dashboard 渠道对话历史(用于会话回放): +### 3.5 `bot_activity_event` +运维事件记录(如容器启动/停止、指令提交、系统告警等)。 -- `role`: `user | assistant` -- `text`: 文本内容 -- `media_json`: 附件相对路径 JSON +### 3.6 `sys_setting` +平台全局参数设置。 -### 3.3 `nanobotimage` +## 4. 初始化与迁移策略 -基础镜像登记表(手动注册): +服务启动时(`backend/core/database.py`): -- `tag`: 如 `nanobot-base:v0.1.4` -- `status`: `READY | UNKNOWN | ERROR` -- `source_dir`: 来源标识(通常 `manual`) - -## 4. 迁移策略 - -服务启动时: - -1. `SQLModel.metadata.create_all(engine)` -2. 清理废弃表:`DROP TABLE IF EXISTS channelroute` -3. 对 `botinstance` 做列对齐,删除历史遗留配置列(保留当前最小字段集) +1. 使用 PostgreSQL Advisory Lock 确保多节点部署时的单实例初始化。 +2. `SQLModel.metadata.create_all(engine)` 自动创建缺失表。 +3. 执行列对齐检查,确保旧表结构平滑升级。 +4. 自动对齐 PostgreSQL Sequences 以防 ID 冲突。 diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 6396845..971e944 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -45,6 +45,11 @@ export const dashboardEn = { copyReply: 'Copy reply', copyReplyDone: 'Reply copied.', copyReplyFail: 'Failed to copy reply.', + deleteMessage: 'Delete message', + deleteMessageConfirm: (role: string) => `Delete this ${role} message?`, + deleteMessageDone: 'Message deleted.', + deleteMessageFail: 'Failed to delete message.', + deleteMessagePending: 'Message is not synced yet. Please retry in a moment.', quoteReply: 'Quote reply', quotedReplyLabel: 'Quoted reply', clearQuote: 'Clear quote', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index e9c4949..3534401 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -45,6 +45,11 @@ export const dashboardZhCn = { copyReply: '复制回复', copyReplyDone: '回复已复制。', copyReplyFail: '复制回复失败。', + deleteMessage: '删除消息', + deleteMessageConfirm: (role: string) => `确认删除这条${role}消息?`, + deleteMessageDone: '消息已删除。', + deleteMessageFail: '删除消息失败。', + deleteMessagePending: '消息尚未同步,暂不可删除。', quoteReply: '引用回复', quotedReplyLabel: '已引用回复', clearQuote: '取消引用', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 0eb8cde..fa8d3b7 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -122,6 +122,7 @@ export function BotDashboardModule({ controlCommandsShow: dashboard.t.controlCommandsShow, copyPrompt: dashboard.t.copyPrompt, copyReply: dashboard.t.copyReply, + deleteMessage: dashboard.t.deleteMessage, disabledPlaceholder: dashboard.t.disabledPlaceholder, download: dashboard.t.download, editPrompt: dashboard.t.editPrompt, @@ -147,6 +148,7 @@ export function BotDashboardModule({ onChatScroll: dashboard.onChatScroll, expandedProgressByKey: dashboard.expandedProgressByKey, expandedUserByKey: dashboard.expandedUserByKey, + deletingMessageIdMap: dashboard.deletingMessageIdMap, feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId, markdownComponents: dashboard.markdownComponents, workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet, @@ -154,6 +156,7 @@ export function BotDashboardModule({ onToggleUserExpand: dashboard.toggleUserExpanded, onEditUserPrompt: dashboard.editUserPrompt, onCopyUserPrompt: dashboard.copyUserPrompt, + onDeleteConversationMessage: dashboard.deleteConversationMessage, onOpenWorkspacePath: dashboard.openWorkspacePathFromChat, onSubmitAssistantFeedback: dashboard.submitAssistantFeedback, onQuoteAssistantReply: dashboard.quoteAssistantReply, @@ -161,6 +164,7 @@ export function BotDashboardModule({ isThinking: dashboard.isThinking, canChat: dashboard.canChat, isChatEnabled: dashboard.isChatEnabled, + speechEnabled: dashboard.speechEnabled, selectedBotEnabled: dashboard.selectedBotEnabled, selectedBotControlState: dashboard.selectedBotControlState, quotedReply: dashboard.quotedReply, diff --git a/frontend/src/modules/dashboard/components/BotListPanel.css b/frontend/src/modules/dashboard/components/BotListPanel.css index 897d0e8..18e7ffa 100644 --- a/frontend/src/modules/dashboard/components/BotListPanel.css +++ b/frontend/src/modules/dashboard/components/BotListPanel.css @@ -124,8 +124,8 @@ border: 1px solid var(--line); border-radius: 12px; background: linear-gradient(145deg, color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%), color-mix(in oklab, var(--panel-soft) 94%, transparent 6%)); - padding: 10px 10px 10px 14px; - margin-bottom: 10px; + padding: 8px 9px 8px 12px; + margin-bottom: 8px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; @@ -179,31 +179,33 @@ } .ops-bot-name { - font-size: 16px; + font-size: 15px; font-weight: 800; color: var(--title); + line-height: 1.15; } .ops-bot-id, .ops-bot-meta { - margin-top: 2px; + margin-top: 1px; color: var(--subtitle); - font-size: 12px; + font-size: 11px; font-weight: 600; + line-height: 1.25; } .ops-bot-actions { - margin-top: 10px; + margin-top: 7px; display: flex; align-items: center; justify-content: space-between; - gap: 6px; + gap: 5px; } .ops-bot-actions-main { display: inline-flex; align-items: center; - gap: 6px; + gap: 5px; } .ops-bot-enable-switch { @@ -285,10 +287,10 @@ } .ops-bot-icon-btn { - width: 36px; - height: 36px; + width: 32px; + height: 32px; padding: 0; - border-radius: 10px; + border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; @@ -296,21 +298,21 @@ } .ops-bot-icon-btn svg { - width: 17px; - height: 17px; + width: 15px; + height: 15px; stroke-width: 2.1; } .ops-bot-top-actions { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; } .ops-bot-name-row { display: inline-flex; align-items: center; - gap: 6px; + gap: 5px; } .ops-bot-lock { diff --git a/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx b/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx index 0055fd5..14e3fe2 100644 --- a/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx @@ -22,6 +22,7 @@ interface DashboardChatPanelLabels { controlCommandsShow: string; copyPrompt: string; copyReply: string; + deleteMessage: string; disabledPlaceholder: string; download: string; editPrompt: string; @@ -52,6 +53,7 @@ interface DashboardChatPanelProps { onChatScroll: () => void; expandedProgressByKey: Record; expandedUserByKey: Record; + deletingMessageIdMap: Record; feedbackSavingByMessageId: Record; markdownComponents: Components; workspaceDownloadExtensionSet: ReadonlySet; @@ -59,6 +61,7 @@ interface DashboardChatPanelProps { onToggleUserExpand: (key: string) => void; onEditUserPrompt: (text: string) => void; onCopyUserPrompt: (text: string) => Promise | void; + onDeleteConversationMessage: (message: ChatMessage) => Promise | void; onOpenWorkspacePath: (path: string) => Promise | void; onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise | void; onQuoteAssistantReply: (message: ChatMessage) => void; @@ -66,6 +69,7 @@ interface DashboardChatPanelProps { isThinking: boolean; canChat: boolean; isChatEnabled: boolean; + speechEnabled: boolean; selectedBotEnabled: boolean; selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling'; quotedReply: { text: string } | null; @@ -103,8 +107,8 @@ interface DashboardChatPanelProps { isVoiceTranscribing: boolean; isCompactMobile: boolean; voiceCountdown: number; - onVoiceInput: () => void; - onTriggerPickAttachments: () => void; + onVoiceInput: () => Promise | void; + onTriggerPickAttachments: () => Promise | void; showInterruptSubmitAction: boolean; onSubmitAction: () => Promise | void; } @@ -117,6 +121,7 @@ export function DashboardChatPanel({ onChatScroll, expandedProgressByKey, expandedUserByKey, + deletingMessageIdMap, feedbackSavingByMessageId, markdownComponents, workspaceDownloadExtensionSet, @@ -124,6 +129,7 @@ export function DashboardChatPanel({ onToggleUserExpand, onEditUserPrompt, onCopyUserPrompt, + onDeleteConversationMessage, onOpenWorkspacePath, onSubmitAssistantFeedback, onQuoteAssistantReply, @@ -131,6 +137,7 @@ export function DashboardChatPanel({ isThinking, canChat, isChatEnabled, + speechEnabled, selectedBotEnabled, selectedBotControlState, quotedReply, @@ -188,6 +195,7 @@ export function DashboardChatPanel({ badReply: labels.badReply, copyPrompt: labels.copyPrompt, copyReply: labels.copyReply, + deleteMessage: labels.deleteMessage, download: labels.download, editPrompt: labels.editPrompt, fileNotPreviewable: labels.fileNotPreviewable, @@ -200,6 +208,7 @@ export function DashboardChatPanel({ }} expandedProgressByKey={expandedProgressByKey} expandedUserByKey={expandedUserByKey} + deletingMessageIdMap={deletingMessageIdMap} feedbackSavingByMessageId={feedbackSavingByMessageId} markdownComponents={markdownComponents} workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} @@ -207,6 +216,7 @@ export function DashboardChatPanel({ onToggleUserExpand={onToggleUserExpand} onEditUserPrompt={onEditUserPrompt} onCopyUserPrompt={onCopyUserPrompt} + onDeleteConversationMessage={onDeleteConversationMessage} onOpenWorkspacePath={onOpenWorkspacePath} onSubmitAssistantFeedback={onSubmitAssistantFeedback} onQuoteAssistantReply={onQuoteAssistantReply} @@ -453,8 +463,8 @@ export function DashboardChatPanel({ ) : null}