diff --git a/backend/.env.example b/backend/.env.example index 05d0664..f5a95b5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,28 +24,27 @@ REDIS_ENABLED=false REDIS_URL= REDIS_PREFIX=dashboard_nanobot REDIS_DEFAULT_TTL=60 -# Chat history page size for upward lazy loading (per request) -CHAT_PULL_PAGE_SIZE=60 # Optional panel-level access password for all backend API/WS calls. PANEL_ACCESS_PASSWORD= -# Max upload size for backend validation (MB) -UPLOAD_MAX_MB=100 -# Workspace files that should use direct download behavior in dashboard -# Comma/space/semicolon separated, e.g. ".pdf,.docx,.xlsx,.zip" -WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps +# The following platform-level items are now managed in sys_setting / 平台参数: +# - page_size +# - chat_pull_page_size +# - upload_max_mb +# - allowed_attachment_extensions +# - workspace_download_extensions +# - speech_enabled +# - speech_max_audio_seconds +# - speech_default_language +# - speech_force_simplified +# - speech_audio_preprocess +# - speech_audio_filter +# - speech_initial_prompt # Local speech-to-text (Whisper via whisper.cpp model file) -STT_ENABLED=true STT_MODEL=ggml-small-q8_0.bin STT_MODEL_DIR=../data/model STT_DEVICE=cpu -STT_MAX_AUDIO_SECONDS=20 -STT_DEFAULT_LANGUAGE=zh -STT_FORCE_SIMPLIFIED=true -STT_AUDIO_PREPROCESS=true -STT_AUDIO_FILTER=highpass=f=120,lowpass=f=7600,afftdn=nf=-20 -STT_INITIAL_PROMPT=以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。 # Local backend server options (for `python3 main.py`) APP_HOST=0.0.0.0 diff --git a/backend/api/platform_router.py b/backend/api/platform_router.py new file mode 100644 index 0000000..04289a6 --- /dev/null +++ b/backend/api/platform_router.py @@ -0,0 +1,107 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session + +from core.cache import cache +from core.database import get_session +from schemas.platform import PlatformSettingsPayload, SystemSettingPayload +from services.platform_service import ( + build_platform_overview, + create_or_update_system_setting, + delete_system_setting, + get_platform_settings, + list_system_settings, + list_activity_events, + list_usage, + save_platform_settings, +) + +router = APIRouter() + + +def _apply_platform_runtime_changes(request: Request) -> None: + cache.delete_prefix("") + speech_service = getattr(request.app.state, "speech_service", None) + if speech_service is not None and hasattr(speech_service, "reset_runtime"): + speech_service.reset_runtime() + + +@router.get("/api/platform/overview") +def get_platform_overview(request: Request, session: Session = Depends(get_session)): + docker_manager = getattr(request.app.state, "docker_manager", None) + return build_platform_overview(session, docker_manager) + + +@router.get("/api/platform/settings") +def get_platform_settings_api(session: Session = Depends(get_session)): + return get_platform_settings(session).model_dump() + + +@router.put("/api/platform/settings") +def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)): + result = save_platform_settings(session, payload).model_dump() + _apply_platform_runtime_changes(request) + return result + + +@router.post("/api/platform/cache/clear") +def clear_platform_cache(): + cache.delete_prefix("") + return {"status": "cleared"} + + +@router.post("/api/platform/reload") +def reload_platform_runtime(request: Request): + _apply_platform_runtime_changes(request) + return {"status": "reloaded"} + + +@router.get("/api/platform/usage") +def get_platform_usage( + bot_id: Optional[str] = None, + limit: int = 100, + offset: int = 0, + session: Session = Depends(get_session), +): + return list_usage(session, bot_id=bot_id, limit=limit, offset=offset) + + +@router.get("/api/platform/events") +def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session: Session = Depends(get_session)): + return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)} + + +@router.get("/api/platform/system-settings") +def get_system_settings(search: str = "", session: Session = Depends(get_session)): + return {"items": list_system_settings(session, search=search)} + + +@router.post("/api/platform/system-settings") +def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)): + try: + result = create_or_update_system_setting(session, payload) + _apply_platform_runtime_changes(request) + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/api/platform/system-settings/{key}") +def update_system_setting(key: str, payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)): + try: + result = create_or_update_system_setting(session, payload.model_copy(update={"key": key})) + _apply_platform_runtime_changes(request) + return result + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/api/platform/system-settings/{key}") +def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)): + try: + delete_system_setting(session, key) + _apply_platform_runtime_changes(request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"status": "deleted", "key": key} diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index b378cd2..c9e9c1a 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -93,6 +93,23 @@ class BotConfigManager: if tools_cfg: config_data["tools"] = tools_cfg + existing_channels = existing_config.get("channels") + existing_dashboard_cfg = ( + existing_channels.get("dashboard") + if isinstance(existing_channels, dict) and isinstance(existing_channels.get("dashboard"), dict) + else {} + ) + dashboard_cfg: Dict[str, Any] = { + "enabled": True, + "host": "0.0.0.0", + "port": 9000, + "allowFrom": ["*"], + } + for key in ("host", "port", "allowFrom"): + if key in existing_dashboard_cfg: + dashboard_cfg[key] = existing_dashboard_cfg[key] + channels_cfg["dashboard"] = dashboard_cfg + for channel in channels: channel_type = (channel.get("channel_type") or "").strip() if not channel_type: diff --git a/backend/core/database.py b/backend/core/database.py index 3b92bc6..78f7a1d 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -13,6 +13,7 @@ from core.settings import ( # Ensure table models are registered in SQLModel metadata before create_all. from models import bot as _bot_models # noqa: F401 +from models import platform as _platform_models # noqa: F401 from models import topic as _topic_models # noqa: F401 _engine_kwargs = { @@ -33,6 +34,232 @@ else: engine = create_engine(DATABASE_URL, **_engine_kwargs) +BOT_INSTANCE_TABLE = "bot_instance" +BOT_MESSAGE_TABLE = "bot_message" +BOT_IMAGE_TABLE = "bot_image" +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 + + +def _release_migration_lock(lock_conn) -> None: + if lock_conn is None: + return + 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 @@ -65,24 +292,24 @@ def _ensure_botinstance_columns() -> None: } inspector = inspect(engine) - if not inspector.has_table("botinstance"): + if not inspector.has_table(BOT_INSTANCE_TABLE): return with engine.connect() as conn: existing = { str(row.get("name")) - for row in inspect(conn).get_columns("botinstance") + 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 botinstance ADD COLUMN {col} {ddl}")) + conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}")) if "enabled" in existing: if dialect == "sqlite": - conn.execute(text("UPDATE botinstance SET enabled = 1 WHERE enabled IS NULL")) + conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL")) else: - conn.execute(text("UPDATE botinstance SET enabled = TRUE WHERE enabled IS NULL")) + conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL")) conn.commit() @@ -113,7 +340,7 @@ def _drop_legacy_botinstance_columns() -> None: with engine.connect() as conn: existing = { str(col.get("name")) - for col in inspect(conn).get_columns("botinstance") + for col in inspect(conn).get_columns(BOT_INSTANCE_TABLE) if col.get("name") } for col in legacy_columns: @@ -121,11 +348,11 @@ def _drop_legacy_botinstance_columns() -> None: continue try: if engine.dialect.name == "mysql": - conn.execute(text(f"ALTER TABLE botinstance DROP COLUMN `{col}`")) + conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`")) elif engine.dialect.name == "sqlite": - conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN "{col}"')) + conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"')) else: - conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN IF EXISTS "{col}"')) + 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 @@ -141,12 +368,12 @@ def _ensure_botmessage_columns() -> None: "feedback_at": "DATETIME", } with engine.connect() as conn: - existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).fetchall() + 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 botmessage ADD COLUMN {col} {ddl}")) + conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}")) conn.commit() @@ -158,24 +385,97 @@ def _drop_legacy_skill_tables() -> None: 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", + }, + } + inspector = inspect(engine) + if not inspector.has_table(SYS_SETTING_TABLE): + return + with engine.connect() as conn: + existing = { + str(row.get("name")) + for row in inspect(conn).get_columns(SYS_SETTING_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 {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)", + }, + } + inspector = inspect(engine) + if not inspector.has_table(BOT_REQUEST_USAGE_TABLE): + return + with engine.connect() as conn: + existing = { + str(row.get("name")) + for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_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_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_bot_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES botinstance(id) - ) - """ - ) - ) conn.execute( text( """ @@ -191,7 +491,7 @@ def _ensure_topic_tables_sqlite() -> None: 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 botinstance(id) + FOREIGN KEY(bot_id) REFERENCES bot_instance(id) ) """ ) @@ -212,13 +512,12 @@ def _ensure_topic_tables_sqlite() -> None: dedupe_key TEXT, is_read INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES botinstance(id) + FOREIGN KEY(bot_id) REFERENCES bot_instance(id) ) """ ) ) - conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_bot_settings_bot_id ON topic_bot_settings(bot_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)")) @@ -237,23 +536,6 @@ def _ensure_topic_tables_sqlite() -> None: def _ensure_topic_columns() -> None: dialect = engine.dialect.name required_columns = { - "topic_bot_settings": { - "topic_enabled": { - "sqlite": "INTEGER NOT NULL DEFAULT 1", - "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", - "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", - }, - "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", - }, - }, "topic_topic": { "name": { "sqlite": "TEXT NOT NULL DEFAULT ''", @@ -360,7 +642,6 @@ def _ensure_topic_columns() -> None: def _ensure_topic_indexes() -> None: required_indexes = [ - ("uq_topic_bot_settings_bot_id", "topic_bot_settings", ["bot_id"], True), ("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), @@ -397,6 +678,17 @@ def _ensure_topic_indexes() -> None: 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. @@ -452,7 +744,9 @@ def align_postgres_sequences() -> None: if engine.dialect.name != "postgresql": return sequence_targets = [ - ("botmessage", "id"), + (BOT_MESSAGE_TABLE, "id"), + (BOT_REQUEST_USAGE_TABLE, "id"), + (BOT_ACTIVITY_EVENT_TABLE, "id"), ] with engine.connect() as conn: for table_name, column_name in sequence_targets: @@ -478,15 +772,27 @@ def align_postgres_sequences() -> None: def init_database() -> None: - SQLModel.metadata.create_all(engine) - _drop_legacy_skill_tables() - _ensure_botinstance_columns() - _drop_legacy_botinstance_columns() - _ensure_botmessage_columns() - _ensure_topic_tables_sqlite() - _ensure_topic_columns() - _cleanup_legacy_default_topics() - align_postgres_sequences() + 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) def get_session(): diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index 8584674..5e1f05b 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -619,6 +619,10 @@ class BotDockerManager: media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()] is_progress = bool(data.get("is_progress", False)) is_tool = bool(data.get("is_tool", False)) + usage = data.get("usage") if isinstance(data.get("usage"), dict) else None + request_id = str(data.get("request_id") or "").strip() or None + provider = str(data.get("provider") or "").strip() or None + model = str(data.get("model") or "").strip() or None if event_type == "AGENT_STATE": payload = data.get("payload") or {} @@ -628,11 +632,21 @@ class BotDockerManager: "type": "AGENT_STATE", "channel": "dashboard", "payload": {"state": state, "action_msg": action_msg}, + "request_id": request_id, } if event_type == "ASSISTANT_MESSAGE": if content or media: - return {"type": "ASSISTANT_MESSAGE", "channel": "dashboard", "text": content, "media": media} + return { + "type": "ASSISTANT_MESSAGE", + "channel": "dashboard", + "text": content, + "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, + } return None if event_type == "BUS_EVENT" or is_progress: @@ -643,6 +657,10 @@ class BotDockerManager: "media": media, "is_progress": is_progress, "is_tool": is_tool, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, } if content or media: @@ -651,6 +669,10 @@ class BotDockerManager: "channel": "dashboard", "text": content, "media": media, + "usage": usage, + "request_id": request_id, + "provider": provider, + "model": model, } except Exception: return None diff --git a/backend/core/settings.py b/backend/core/settings.py index 10f8378..42754ba 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -178,8 +178,10 @@ DATABASE_POOL_SIZE: Final[int] = _env_int("DATABASE_POOL_SIZE", 20, 1, 200) DATABASE_MAX_OVERFLOW: Final[int] = _env_int("DATABASE_MAX_OVERFLOW", 40, 0, 200) DATABASE_POOL_TIMEOUT: Final[int] = _env_int("DATABASE_POOL_TIMEOUT", 30, 1, 300) DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30, 86400) -UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048) -WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = ( +DEFAULT_UPLOAD_MAX_MB: Final[int] = 100 +DEFAULT_PAGE_SIZE: Final[int] = 10 +DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 +DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = ( ".pdf", ".doc", ".docx", @@ -193,11 +195,7 @@ WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = ( ".odp", ".wps", ) -WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = _env_extensions( - "WORKSPACE_DOWNLOAD_EXTENSIONS", - WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT, -) -STT_ENABLED: Final[bool] = _env_bool("STT_ENABLED", True) +STT_ENABLED_DEFAULT: Final[bool] = True STT_MODEL: Final[str] = str(os.getenv("STT_MODEL") or "ggml-small-q8_0.bin").strip() _DEFAULT_STT_MODEL_DIR: Final[Path] = (Path(DATA_ROOT) / "model").resolve() _configured_stt_model_dir = _normalize_dir_path(os.getenv("STT_MODEL_DIR", str(_DEFAULT_STT_MODEL_DIR))) @@ -206,23 +204,19 @@ if _configured_stt_model_dir and not Path(_configured_stt_model_dir).exists() an else: STT_MODEL_DIR: Final[str] = _configured_stt_model_dir STT_DEVICE: Final[str] = str(os.getenv("STT_DEVICE") or "cpu").strip().lower() or "cpu" -STT_MAX_AUDIO_SECONDS: Final[int] = _env_int("STT_MAX_AUDIO_SECONDS", 20, 5, 600) -STT_DEFAULT_LANGUAGE: Final[str] = str(os.getenv("STT_DEFAULT_LANGUAGE") or "zh").strip().lower() or "zh" -STT_FORCE_SIMPLIFIED: Final[bool] = _env_bool("STT_FORCE_SIMPLIFIED", True) -STT_AUDIO_PREPROCESS: Final[bool] = _env_bool("STT_AUDIO_PREPROCESS", True) -STT_AUDIO_FILTER: Final[str] = str( - os.getenv("STT_AUDIO_FILTER") or "highpass=f=120,lowpass=f=7600,afftdn=nf=-20" -).strip() -STT_INITIAL_PROMPT: Final[str] = str( - os.getenv("STT_INITIAL_PROMPT") - or "以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。" -).strip() +DEFAULT_STT_MAX_AUDIO_SECONDS: Final[int] = 20 +DEFAULT_STT_DEFAULT_LANGUAGE: Final[str] = "zh" +DEFAULT_STT_FORCE_SIMPLIFIED: Final[bool] = True +DEFAULT_STT_AUDIO_PREPROCESS: Final[bool] = True +DEFAULT_STT_AUDIO_FILTER: Final[str] = "highpass=f=120,lowpass=f=7600,afftdn=nf=-20" +DEFAULT_STT_INITIAL_PROMPT: Final[str] = ( + "以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。" +) 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) -CHAT_PULL_PAGE_SIZE: Final[int] = _env_int("CHAT_PULL_PAGE_SIZE", 60, 10, 500) PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() diff --git a/backend/core/speech_service.py b/backend/core/speech_service.py index 177d833..9aeac87 100644 --- a/backend/core/speech_service.py +++ b/backend/core/speech_service.py @@ -8,17 +8,8 @@ import threading from pathlib import Path from typing import Any, Dict, Optional -from core.settings import ( - STT_AUDIO_FILTER, - STT_AUDIO_PREPROCESS, - STT_DEVICE, - STT_ENABLED, - STT_FORCE_SIMPLIFIED, - STT_INITIAL_PROMPT, - STT_MAX_AUDIO_SECONDS, - STT_MODEL, - STT_MODEL_DIR, -) +from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR +from services.platform_service import get_speech_runtime_settings class SpeechServiceError(RuntimeError): @@ -76,7 +67,13 @@ class WhisperSpeechService: raise SpeechServiceError( f"STT model file not found under STT_MODEL_DIR: {candidate}" ) - return str(candidate.resolve()) + return str(candidate.resolve()) + + def reset_runtime(self) -> None: + with self._model_lock: + self._model = None + self._model_source = "" + self._backend = "" def _load_model(self) -> Any: model_source = self._resolve_model_source() @@ -103,8 +100,9 @@ class WhisperSpeechService: @staticmethod def _preprocess_audio(file_path: str) -> str: + settings = get_speech_runtime_settings() target = str(file_path or "").strip() - if not STT_AUDIO_PREPROCESS or not target or not os.path.isfile(target): + if not settings["audio_preprocess"] or not target or not os.path.isfile(target): return target if shutil.which("ffmpeg") is None: return target @@ -122,7 +120,7 @@ class WhisperSpeechService: "-ar", "16000", ] - audio_filter = str(STT_AUDIO_FILTER or "").strip() + audio_filter = str(settings["audio_filter"] or "").strip() if audio_filter: cmd.extend(["-af", audio_filter]) cmd.extend(["-c:a", "pcm_s16le", tmp_path]) @@ -163,8 +161,9 @@ class WhisperSpeechService: @staticmethod def _normalize_text(text: str) -> str: + settings = get_speech_runtime_settings() content = str(text or "").strip() - if not content or not STT_FORCE_SIMPLIFIED: + if not content or not settings["force_simplified"]: return content try: from opencc_purepy import OpenCC # type: ignore @@ -182,15 +181,16 @@ class WhisperSpeechService: return {key: value for key, value in kwargs.items() if key in available} def transcribe_file(self, file_path: str, language: Optional[str] = None) -> Dict[str, Any]: - if not STT_ENABLED: + settings = get_speech_runtime_settings() + if not settings["enabled"]: raise SpeechDisabledError("Speech-to-text is disabled") target = str(file_path or "").strip() if not target or not os.path.isfile(target): raise SpeechServiceError("Audio file not found") duration_seconds = self._probe_audio_duration_seconds(target) - if duration_seconds is not None and duration_seconds > float(STT_MAX_AUDIO_SECONDS) + 0.3: - raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds") + if duration_seconds is not None and duration_seconds > float(settings["max_audio_seconds"]) + 0.3: + raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds") prepared_target = self._preprocess_audio(target) try: @@ -211,7 +211,7 @@ class WhisperSpeechService: } if normalized_lang: kwargs["language"] = normalized_lang - initial_prompt = str(STT_INITIAL_PROMPT or "").strip() + initial_prompt = str(settings["initial_prompt"] or "").strip() if initial_prompt: kwargs["initial_prompt"] = initial_prompt kwargs = self._filter_supported_transcribe_kwargs(model, kwargs) @@ -232,8 +232,8 @@ class WhisperSpeechService: max_end = max(max_end, float(getattr(segment, "t1", 0.0) or 0.0) / 100.0) except Exception: pass - if max_end > float(STT_MAX_AUDIO_SECONDS) + 0.3: - raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds") + if max_end > float(settings["max_audio_seconds"]) + 0.3: + raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds") text = self._normalize_text(" ".join(texts).strip()) if not text: @@ -246,7 +246,7 @@ class WhisperSpeechService: "text": text, "language": detected_language or None, "duration_seconds": duration_seconds, - "max_audio_seconds": STT_MAX_AUDIO_SECONDS, + "max_audio_seconds": settings["max_audio_seconds"], "model": STT_MODEL, "device": STT_DEVICE, "backend": self._backend or "unknown", diff --git a/backend/db/sql/20260311_create_topic_tables.sql b/backend/db/sql/20260311_create_topic_tables.sql index 2c5c33f..3aa1d08 100644 --- a/backend/db/sql/20260311_create_topic_tables.sql +++ b/backend/db/sql/20260311_create_topic_tables.sql @@ -3,15 +3,6 @@ BEGIN TRANSACTION; -CREATE TABLE IF NOT EXISTS topic_bot_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bot_id TEXT NOT NULL, - topic_enabled INTEGER NOT NULL DEFAULT 1, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES botinstance(id) -); - CREATE TABLE IF NOT EXISTS topic_topic ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id TEXT NOT NULL, @@ -24,7 +15,7 @@ CREATE TABLE IF NOT EXISTS topic_topic ( 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 botinstance(id) + FOREIGN KEY(bot_id) REFERENCES bot_instance(id) ); CREATE TABLE IF NOT EXISTS topic_item ( @@ -40,12 +31,9 @@ CREATE TABLE IF NOT EXISTS topic_item ( dedupe_key TEXT, is_read INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(bot_id) REFERENCES botinstance(id) + FOREIGN KEY(bot_id) REFERENCES bot_instance(id) ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_bot_settings_bot_id -ON topic_bot_settings(bot_id); - CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key); diff --git a/backend/main.py b/backend/main.py index 42f0c9c..c3e2620 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,12 +9,12 @@ import tempfile import zipfile from datetime import datetime from typing import Any, Dict, List, Optional -from urllib.parse import unquote +from urllib.parse import quote, unquote import httpx from pydantic import BaseModel from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from sqlmodel import Session, select @@ -30,7 +30,6 @@ from core.speech_service import ( ) from core.settings import ( BOTS_WORKSPACE_ROOT, - CHAT_PULL_PAGE_SIZE, DATA_ROOT, DATABASE_ECHO, DATABASE_ENGINE, @@ -46,22 +45,28 @@ from core.settings import ( REDIS_ENABLED, REDIS_PREFIX, REDIS_URL, - STT_DEVICE, - STT_DEFAULT_LANGUAGE, - STT_ENABLED, - STT_MAX_AUDIO_SECONDS, - STT_MODEL, TOPIC_PRESET_TEMPLATES, TOPIC_PRESETS_TEMPLATES_FILE, - UPLOAD_MAX_MB, - WORKSPACE_DOWNLOAD_EXTENSIONS, load_agent_md_templates, load_topic_presets_template, ) from models.bot import BotInstance, BotMessage, NanobotImage -from models.topic import TopicBotSettings, TopicItem, TopicTopic +from models.platform import BotActivityEvent, BotRequestUsage +from models.topic import TopicItem, TopicTopic +from api.platform_router import router as platform_router from api.topic_router import router as topic_router from services.topic_runtime import publish_runtime_topic_packet +from services.platform_service import ( + bind_usage_message, + create_usage_request, + fail_latest_usage, + finalize_usage_from_packet, + get_chat_pull_page_size, + get_platform_settings_snapshot, + get_speech_runtime_settings, + prune_expired_activity_events, + record_activity_event, +) app = FastAPI(title="Dashboard Nanobot API") logger = logging.getLogger("dashboard.backend") @@ -73,6 +78,7 @@ app.add_middleware( allow_headers=["*"], ) app.include_router(topic_router) +app.include_router(platform_router) os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True) os.makedirs(DATA_ROOT, exist_ok=True) @@ -80,6 +86,8 @@ os.makedirs(DATA_ROOT, exist_ok=True) docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT) config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT) speech_service = WhisperSpeechService() +app.state.docker_manager = docker_manager +app.state.speech_service = speech_service BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") @@ -182,6 +190,10 @@ class MessageFeedbackRequest(BaseModel): feedback: Optional[str] = None # up | down | null +class WorkspaceFileUpdateRequest(BaseModel): + content: str + + class PanelLoginRequest(BaseModel): password: Optional[str] = None @@ -261,6 +273,14 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int session.add(message_row) session.flush() persisted_message_id = message_row.id + usage_row = finalize_usage_from_packet( + session, + bot_id, + { + **packet, + "message_id": persisted_message_id, + }, + ) elif packet_type == "USER_COMMAND": text_msg = str(packet.get("text") or "").strip() media_list = _normalize_media_list(packet.get("media"), bot_id) @@ -274,12 +294,19 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int session.add(message_row) session.flush() persisted_message_id = message_row.id + bind_usage_message( + session, + bot_id, + str(packet.get("request_id") or "").strip(), + persisted_message_id, + ) elif packet_type == "BUS_EVENT": # Dashboard channel emits BUS_EVENT for both progress and final replies. # Persist only non-progress events to keep durable chat history clean. is_progress = bool(packet.get("is_progress")) + detail_text = str(packet.get("content") or packet.get("text") or "").strip() if not is_progress: - text_msg = str(packet.get("content") or packet.get("text") or "").strip() + text_msg = detail_text media_list = _normalize_media_list(packet.get("media"), bot_id) if text_msg or media_list: bot.current_state = "IDLE" @@ -294,6 +321,18 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int session.add(message_row) session.flush() persisted_message_id = message_row.id + usage_row = finalize_usage_from_packet( + session, + bot_id, + { + "text": text_msg, + "usage": packet.get("usage"), + "request_id": packet.get("request_id"), + "provider": packet.get("provider"), + "model": packet.get("model"), + "message_id": persisted_message_id, + }, + ) bot.updated_at = datetime.utcnow() session.add(bot) @@ -528,6 +567,9 @@ async def on_startup(): init_database() cache.delete_prefix("") with Session(engine) as session: + pruned_events = prune_expired_activity_events(session, force=True) + if pruned_events > 0: + session.commit() for bot in session.exec(select(BotInstance)).all(): _migrate_bot_resources_store(bot.id) running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all() @@ -554,6 +596,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]: def get_system_defaults(): md_templates = load_agent_md_templates() topic_presets = load_topic_presets_template() + platform_settings = get_platform_settings_snapshot() + speech_settings = get_speech_runtime_settings() return { "templates": { "soul_md": md_templates.get("soul_md") or DEFAULT_SOUL_MD, @@ -563,21 +607,24 @@ def get_system_defaults(): "identity_md": md_templates.get("identity_md") or DEFAULT_IDENTITY_MD, }, "limits": { - "upload_max_mb": UPLOAD_MAX_MB, + "upload_max_mb": platform_settings.upload_max_mb, }, "workspace": { - "download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS), + "download_extensions": list(platform_settings.workspace_download_extensions), + "allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions), }, + "loading_page": platform_settings.loading_page.model_dump(), "chat": { - "pull_page_size": CHAT_PULL_PAGE_SIZE, + "pull_page_size": platform_settings.chat_pull_page_size, + "page_size": platform_settings.page_size, }, "topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES, "speech": { - "enabled": STT_ENABLED, - "model": STT_MODEL, - "device": STT_DEVICE, - "max_audio_seconds": STT_MAX_AUDIO_SECONDS, - "default_language": STT_DEFAULT_LANGUAGE, + "enabled": speech_settings["enabled"], + "model": speech_settings["model"], + "device": speech_settings["device"], + "max_audio_seconds": speech_settings["max_audio_seconds"], + "default_language": speech_settings["default_language"], }, } @@ -590,6 +637,14 @@ def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None: os.replace(tmp, path) +def _write_text_atomic(path: str, content: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8", newline="") as f: + f.write(str(content or "")) + os.replace(tmp, path) + + @app.get("/api/system/templates") def get_system_templates(): return { @@ -1912,6 +1967,15 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session }, ) session.refresh(bot) + record_activity_event( + session, + normalized_bot_id, + "bot_created", + channel="system", + detail=f"Bot {normalized_bot_id} created", + metadata={"image_tag": payload.image_tag}, + ) + session.commit() _invalidate_bot_detail_cache(normalized_bot_id) return _serialize_bot(bot) @@ -2149,6 +2213,7 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)): detail="Bot container failed shortly after startup. Check bot logs/config.", ) session.add(bot) + record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}") session.commit() _invalidate_bot_detail_cache(bot_id) return {"status": "started"} @@ -2165,6 +2230,7 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)): docker_manager.stop_bot(bot_id) bot.docker_status = "STOPPED" session.add(bot) + record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}") session.commit() _invalidate_bot_detail_cache(bot_id) return {"status": "stopped"} @@ -2178,6 +2244,7 @@ def enable_bot(bot_id: str, session: Session = Depends(get_session)): bot.enabled = True session.add(bot) + record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled") session.commit() _invalidate_bot_detail_cache(bot_id) return {"status": "enabled", "enabled": True} @@ -2195,6 +2262,7 @@ def disable_bot(bot_id: str, session: Session = Depends(get_session)): if str(bot.current_state or "").upper() not in {"ERROR"}: bot.current_state = "IDLE" session.add(bot) + record_activity_event(session, bot_id, "bot_disabled", channel="system", detail=f"Bot {bot_id} disabled") session.commit() _invalidate_bot_detail_cache(bot_id) return {"status": "disabled", "enabled": False} @@ -2212,6 +2280,7 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): if str(bot.current_state or "").upper() not in {"ERROR"}: bot.current_state = "IDLE" session.add(bot) + record_activity_event(session, bot_id, "bot_deactivated", channel="system", detail=f"Bot {bot_id} deactivated") session.commit() _invalidate_bot_detail_cache(bot_id) return {"status": "deactivated"} @@ -2234,8 +2303,11 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all() for row in topics: session.delete(row) - topic_settings = session.exec(select(TopicBotSettings).where(TopicBotSettings.bot_id == bot_id)).all() - for row in topic_settings: + usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all() + for row in usage_rows: + session.delete(row) + activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all() + for row in activity_rows: session.delete(row) session.delete(bot) @@ -2374,7 +2446,7 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio if not filename.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Only .zip skill package is supported") - max_bytes = UPLOAD_MAX_MB * 1024 * 1024 + max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024 tmp_zip_path: Optional[str] = None total_size = 0 try: @@ -2553,6 +2625,8 @@ def update_bot_channel( row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None) if not row: raise HTTPException(status_code=404, detail="Channel not found") + if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")): + raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified") update_data = payload.model_dump(exclude_unset=True) existing_type = str(row.get("channel_type") or "").strip().lower() @@ -2640,6 +2714,7 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") + runtime_snapshot = _read_bot_runtime_snapshot(bot) attachments = _normalize_media_list(payload.attachments, bot_id) command = str(payload.command or "").strip() @@ -2706,6 +2781,30 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend "Reply language must follow USER.md. If not specified, use the same language as the user input." ) + request_id = create_usage_request( + session, + bot_id, + display_command, + attachments=checked_attachments, + channel="dashboard", + metadata={"attachment_count": len(checked_attachments)}, + provider=str(runtime_snapshot.get("llm_provider") or "").strip() or None, + model=str(runtime_snapshot.get("llm_model") or "").strip() or None, + ) + record_activity_event( + session, + bot_id, + "command_submitted", + request_id=request_id, + channel="dashboard", + detail="command submitted", + metadata={ + "attachment_count": len(checked_attachments), + "has_text": bool(command), + }, + ) + session.commit() + outbound_user_packet: Optional[Dict[str, Any]] = None if display_command or checked_attachments: outbound_user_packet = { @@ -2713,6 +2812,7 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend "channel": "dashboard", "text": display_command, "media": checked_attachments, + "request_id": request_id, } _persist_runtime_packet(bot_id, outbound_user_packet) @@ -2726,6 +2826,16 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media) if not success: detail = docker_manager.get_last_delivery_error(bot_id) + fail_latest_usage(session, bot_id, detail or "command delivery failed") + record_activity_event( + session, + bot_id, + "command_failed", + request_id=request_id, + channel="dashboard", + detail=(detail or "command delivery failed")[:400], + ) + session.commit() if loop and loop.is_running(): asyncio.run_coroutine_threadsafe( manager.broadcast( @@ -2784,7 +2894,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( @app.get("/api/bots/{bot_id}/messages/page") def list_bot_messages_page( bot_id: str, - limit: int = CHAT_PULL_PAGE_SIZE, + limit: Optional[int] = None, before_id: Optional[int] = None, session: Session = Depends(get_session), ): @@ -2792,7 +2902,8 @@ def list_bot_messages_page( if not bot: raise HTTPException(status_code=404, detail="Bot not found") - safe_limit = max(1, min(int(limit), 500)) + configured_limit = get_chat_pull_page_size() + safe_limit = max(1, min(int(limit if limit is not None else configured_limit), 500)) safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id) cached = cache.get_json(cache_key) @@ -2896,6 +3007,14 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): bot.current_state = "IDLE" bot.updated_at = datetime.utcnow() session.add(bot) + record_activity_event( + session, + bot_id, + "history_cleared", + channel="system", + detail=f"Cleared {deleted} stored messages", + metadata={"deleted_messages": deleted, "cleared_sessions": cleared_sessions}, + ) session.commit() _invalidate_bot_detail_cache(bot_id) _invalidate_bot_messages_cache(bot_id) @@ -2992,17 +3111,56 @@ def read_workspace_file( body = raw[:safe_max] if truncated else raw text_body = body.decode("utf-8", errors="replace") rel_path = os.path.relpath(target, root).replace("\\", "/") + is_markdown = ext in {".md", ".markdown"} return { "bot_id": bot_id, "path": rel_path, "size": os.path.getsize(target), - "is_markdown": rel_path.lower().endswith(".md"), + "is_markdown": is_markdown, "truncated": truncated, "content": text_body, } +@app.put("/api/bots/{bot_id}/workspace/file") +def update_workspace_file( + bot_id: str, + path: str, + payload: WorkspaceFileUpdateRequest, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + root, target = _resolve_workspace_path(bot_id, path) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="workspace file not found") + + ext = os.path.splitext(target)[1].lower() + if ext not in {".md", ".markdown"}: + raise HTTPException(status_code=400, detail=f"editing is only supported for markdown files: {ext or '(none)'}") + + content = str(payload.content or "") + encoded = content.encode("utf-8") + if len(encoded) > 2_000_000: + raise HTTPException(status_code=413, detail="markdown file too large to save") + if "\x00" in content: + raise HTTPException(status_code=400, detail="markdown content contains invalid null bytes") + + _write_text_atomic(target, content) + rel_path = os.path.relpath(target, root).replace("\\", "/") + return { + "bot_id": bot_id, + "path": rel_path, + "size": os.path.getsize(target), + "is_markdown": True, + "truncated": False, + "content": content, + } + + def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024): with open(target, "rb") as fh: fh.seek(start) @@ -3055,7 +3213,24 @@ def _build_ranged_workspace_response(target: str, media_type: str, range_header: ) -def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Request, session: Session): +def _build_workspace_raw_url(bot_id: str, path: str, public: bool) -> str: + normalized = "/".join(part for part in str(path or "").strip().split("/") if part) + if not normalized: + return "" + prefix = "/public" if public else "/api" + return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}" + + +def _serve_workspace_file( + bot_id: str, + path: str, + download: bool, + request: Request, + session: Session, + *, + public: bool = False, + redirect_html_to_raw: bool = False, +): bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") @@ -3065,6 +3240,10 @@ def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Reque raise HTTPException(status_code=404, detail="File not found") media_type, _ = mimetypes.guess_type(target) + if redirect_html_to_raw and not download and str(media_type or "").startswith("text/html"): + raw_url = _build_workspace_raw_url(bot_id, path, public=public) + if raw_url: + return RedirectResponse(url=raw_url, status_code=307) range_header = request.headers.get("range", "") if range_header and not download: return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header) @@ -3144,7 +3323,15 @@ def download_workspace_file( request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) + return _serve_workspace_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + session=session, + public=False, + redirect_html_to_raw=True, + ) @app.get("/public/bots/{bot_id}/workspace/download") @@ -3155,7 +3342,15 @@ def public_download_workspace_file( request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) + return _serve_workspace_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + session=session, + public=True, + redirect_html_to_raw=True, + ) @app.get("/api/bots/{bot_id}/workspace/raw/{path:path}") @@ -3166,7 +3361,15 @@ def raw_workspace_file( request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) + return _serve_workspace_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + session=session, + public=False, + redirect_html_to_raw=False, + ) @app.get("/public/bots/{bot_id}/workspace/raw/{path:path}") @@ -3177,7 +3380,15 @@ def public_raw_workspace_file( request: Request = None, session: Session = Depends(get_session), ): - return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session) + return _serve_workspace_file( + bot_id=bot_id, + path=path, + download=download, + request=request, + session=session, + public=True, + redirect_html_to_raw=False, + ) @app.post("/api/bots/{bot_id}/workspace/upload") @@ -3192,7 +3403,9 @@ async def upload_workspace_files( raise HTTPException(status_code=404, detail="Bot not found") if not files: raise HTTPException(status_code=400, detail="no files uploaded") - max_bytes = UPLOAD_MAX_MB * 1024 * 1024 + platform_settings = get_platform_settings_snapshot() + max_bytes = platform_settings.upload_max_mb * 1024 * 1024 + allowed_extensions = set(platform_settings.allowed_attachment_extensions) root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads") os.makedirs(upload_dir, exist_ok=True) @@ -3207,6 +3420,12 @@ async def upload_workspace_files( name = re.sub(r"[^\w.\-()+@ ]+", "_", name) if not name: name = "upload.bin" + ext = str(os.path.splitext(name)[1] or "").strip().lower() + if allowed_extensions and ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"File '{name}' extension is not allowed. Allowed: {', '.join(sorted(allowed_extensions))}", + ) abs_path = os.path.join(safe_dir_real, name) if os.path.exists(abs_path): @@ -3262,7 +3481,8 @@ async def transcribe_bot_speech( bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - if not STT_ENABLED: + speech_settings = get_speech_runtime_settings() + if not speech_settings["enabled"]: raise HTTPException(status_code=400, detail="Speech recognition is disabled") if not file: raise HTTPException(status_code=400, detail="no audio file uploaded") @@ -3286,7 +3506,7 @@ async def transcribe_bot_speech( if not tmp_path or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) <= 0: raise HTTPException(status_code=400, detail="audio payload is empty") - resolved_language = str(language or "").strip() or STT_DEFAULT_LANGUAGE + resolved_language = str(language or "").strip() or speech_settings["default_language"] result = await asyncio.to_thread(speech_service.transcribe_file, tmp_path, resolved_language) text = str(result.get("text") or "").strip() if not text: @@ -3295,9 +3515,9 @@ async def transcribe_bot_speech( "bot_id": bot_id, "text": text, "duration_seconds": result.get("duration_seconds"), - "max_audio_seconds": STT_MAX_AUDIO_SECONDS, - "model": STT_MODEL, - "device": STT_DEVICE, + "max_audio_seconds": speech_settings["max_audio_seconds"], + "model": speech_settings["model"], + "device": speech_settings["device"], "language": result.get("language") or resolved_language, } except SpeechDisabledError as exc: @@ -3315,9 +3535,9 @@ async def transcribe_bot_speech( bot_id, safe_name, language, - STT_MAX_AUDIO_SECONDS, + speech_settings["max_audio_seconds"], ) - raise HTTPException(status_code=413, detail=f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds") + raise HTTPException(status_code=413, detail=f"Audio duration exceeds {speech_settings['max_audio_seconds']} seconds") except SpeechServiceError as exc: logger.exception( "speech transcribe failed bot_id=%s file=%s language=%s", diff --git a/backend/models/bot.py b/backend/models/bot.py index 8d9cdd6..d7a16ac 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -3,6 +3,8 @@ from sqlmodel import Field, SQLModel from datetime import datetime class BotInstance(SQLModel, table=True): + __tablename__ = "bot_instance" + id: str = Field(primary_key=True) name: str enabled: bool = Field(default=True, index=True) @@ -16,8 +18,10 @@ class BotInstance(SQLModel, table=True): updated_at: datetime = Field(default_factory=datetime.utcnow) class BotMessage(SQLModel, table=True): + __tablename__ = "bot_message" + id: Optional[int] = Field(default=None, primary_key=True) - bot_id: str = Field(foreign_key="botinstance.id", index=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) role: str = Field(index=True) # user | assistant | system text: str media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths @@ -26,6 +30,8 @@ class BotMessage(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.utcnow, index=True) class NanobotImage(SQLModel, table=True): + __tablename__ = "bot_image" + tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4 image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID version: str # e.g., 0.1.4 diff --git a/backend/models/platform.py b/backend/models/platform.py new file mode 100644 index 0000000..59bd054 --- /dev/null +++ b/backend/models/platform.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class PlatformSetting(SQLModel, table=True): + __tablename__ = "sys_setting" + + key: str = Field(primary_key=True, max_length=120) + name: str = Field(default="", max_length=200) + category: str = Field(default="general", max_length=64, index=True) + description: str = Field(default="") + value_type: str = Field(default="json", max_length=32) + value_json: str = Field(default="{}") + is_public: bool = Field(default=False, index=True) + sort_order: int = Field(default=100, index=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + +class BotRequestUsage(SQLModel, table=True): + __tablename__ = "bot_request_usage" + + id: Optional[int] = Field(default=None, primary_key=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) + message_id: Optional[int] = Field(default=None, index=True) + request_id: str = Field(index=True, max_length=120) + channel: str = Field(default="dashboard", index=True, max_length=64) + status: str = Field(default="PENDING", index=True, max_length=32) + provider: Optional[str] = Field(default=None, max_length=120) + model: Optional[str] = Field(default=None, max_length=255) + token_source: str = Field(default="estimated", max_length=32) + input_tokens: int = Field(default=0) + output_tokens: int = Field(default=0) + total_tokens: int = Field(default=0) + input_text_preview: Optional[str] = Field(default=None) + output_text_preview: Optional[str] = Field(default=None) + attachments_json: Optional[str] = Field(default=None) + error_text: Optional[str] = Field(default=None) + metadata_json: Optional[str] = Field(default=None) + started_at: datetime = Field(default_factory=datetime.utcnow, index=True) + completed_at: Optional[datetime] = Field(default=None, index=True) + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) + updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) + + +class BotActivityEvent(SQLModel, table=True): + __tablename__ = "bot_activity_event" + + id: Optional[int] = Field(default=None, primary_key=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) + request_id: Optional[str] = Field(default=None, index=True, max_length=120) + event_type: str = Field(index=True, max_length=64) + channel: str = Field(default="dashboard", index=True, max_length=64) + detail: Optional[str] = Field(default=None) + metadata_json: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) diff --git a/backend/models/topic.py b/backend/models/topic.py index cb8d836..ef26930 100644 --- a/backend/models/topic.py +++ b/backend/models/topic.py @@ -5,19 +5,6 @@ from sqlalchemy import Index, UniqueConstraint from sqlmodel import Field, SQLModel -class TopicBotSettings(SQLModel, table=True): - __tablename__ = "topic_bot_settings" - __table_args__ = ( - UniqueConstraint("bot_id", name="uq_topic_bot_settings_bot_id"), - ) - - id: Optional[int] = Field(default=None, primary_key=True) - bot_id: str = Field(foreign_key="botinstance.id", index=True) - topic_enabled: bool = Field(default=True) - created_at: datetime = Field(default_factory=datetime.utcnow, index=True) - updated_at: datetime = Field(default_factory=datetime.utcnow, index=True) - - class TopicTopic(SQLModel, table=True): __tablename__ = "topic_topic" __table_args__ = ( @@ -26,7 +13,7 @@ class TopicTopic(SQLModel, table=True): ) id: Optional[int] = Field(default=None, primary_key=True) - bot_id: str = Field(foreign_key="botinstance.id", index=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) topic_key: str = Field(index=True) name: str = Field(default="") description: str = Field(default="") @@ -46,7 +33,7 @@ class TopicItem(SQLModel, table=True): ) id: Optional[int] = Field(default=None, primary_key=True) - bot_id: str = Field(foreign_key="botinstance.id", index=True) + bot_id: str = Field(foreign_key="bot_instance.id", index=True) topic_key: str = Field(index=True) title: str = Field(default="") content: str = Field(default="") diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py new file mode 100644 index 0000000..54f1cbb --- /dev/null +++ b/backend/schemas/platform.py @@ -0,0 +1,98 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class LoadingPageSettings(BaseModel): + title: str = "Dashboard Nanobot" + subtitle: str = "平台正在准备管理面板" + description: str = "请稍候,正在加载 Bot 平台数据。" + + +class PlatformSettingsPayload(BaseModel): + page_size: int = Field(default=10, ge=1, le=100) + chat_pull_page_size: int = Field(default=60, ge=10, le=500) + upload_max_mb: int = Field(default=100, ge=1, le=2048) + allowed_attachment_extensions: List[str] = Field(default_factory=list) + workspace_download_extensions: List[str] = Field(default_factory=list) + speech_enabled: bool = True + speech_max_audio_seconds: int = Field(default=20, ge=5, le=600) + speech_default_language: str = Field(default="zh", min_length=1, max_length=16) + speech_force_simplified: bool = True + speech_audio_preprocess: bool = True + speech_audio_filter: str = Field(default="highpass=f=120,lowpass=f=7600,afftdn=nf=-20") + speech_initial_prompt: str = Field( + default="以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。" + ) + loading_page: LoadingPageSettings = Field(default_factory=LoadingPageSettings) + + +class PlatformUsageItem(BaseModel): + id: int + bot_id: str + message_id: Optional[int] = None + request_id: str + channel: str + status: str + provider: Optional[str] = None + model: Optional[str] = None + token_source: str + content: Optional[str] = None + input_tokens: int + output_tokens: int + total_tokens: int + input_text_preview: Optional[str] = None + output_text_preview: Optional[str] = None + started_at: str + completed_at: Optional[str] = None + + +class PlatformUsageSummary(BaseModel): + request_count: int + input_tokens: int + output_tokens: int + total_tokens: int + + +class PlatformUsageResponse(BaseModel): + summary: PlatformUsageSummary + items: List[PlatformUsageItem] + total: int + limit: int + offset: int + has_more: bool + + +class PlatformActivityItem(BaseModel): + id: int + bot_id: str + request_id: Optional[str] = None + event_type: str + channel: str + detail: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + created_at: str + + +class SystemSettingPayload(BaseModel): + key: str + name: str = "" + category: str = "general" + description: str = "" + value_type: str = "json" + value: Any = None + is_public: bool = False + sort_order: int = 100 + + +class SystemSettingItem(BaseModel): + key: str + name: str + category: str + description: str + value_type: str + value: Any = None + is_public: bool = False + sort_order: int = 100 + created_at: str + updated_at: str diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py new file mode 100644 index 0000000..620b33a --- /dev/null +++ b/backend/services/platform_service.py @@ -0,0 +1,1042 @@ +import json +import math +import os +import re +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from sqlalchemy import delete as sql_delete, func +from sqlmodel import Session, select + +from core.database import engine +from core.settings import ( + BOTS_WORKSPACE_ROOT, + DEFAULT_CHAT_PULL_PAGE_SIZE, + DEFAULT_PAGE_SIZE, + DEFAULT_STT_AUDIO_FILTER, + DEFAULT_STT_AUDIO_PREPROCESS, + DEFAULT_STT_DEFAULT_LANGUAGE, + DEFAULT_STT_FORCE_SIMPLIFIED, + DEFAULT_STT_INITIAL_PROMPT, + DEFAULT_STT_MAX_AUDIO_SECONDS, + DEFAULT_UPLOAD_MAX_MB, + DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, + STT_DEVICE, + STT_ENABLED_DEFAULT, + STT_MODEL, +) +from models.bot import BotInstance, NanobotImage +from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting +from schemas.platform import ( + LoadingPageSettings, + PlatformActivityItem, + PlatformSettingsPayload, + PlatformUsageResponse, + PlatformUsageItem, + PlatformUsageSummary, + SystemSettingItem, + SystemSettingPayload, +) + +DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = () +DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7 +ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days" +ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10) +OPERATIONAL_ACTIVITY_EVENT_TYPES = { + "bot_created", + "bot_started", + "bot_stopped", + "bot_enabled", + "bot_disabled", + "bot_deactivated", + "command_submitted", + "command_failed", + "history_cleared", +} +SETTING_KEYS = ( + "page_size", + "chat_pull_page_size", + "upload_max_mb", + "allowed_attachment_extensions", + "workspace_download_extensions", + "speech_enabled", +) +PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY} +DEPRECATED_SETTING_KEYS = { + "loading_page", + "speech_max_audio_seconds", + "speech_default_language", + "speech_force_simplified", + "speech_audio_preprocess", + "speech_audio_filter", + "speech_initial_prompt", +} +SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { + "page_size": { + "name": "分页大小", + "category": "ui", + "description": "平台各类列表默认每页条数。", + "value_type": "integer", + "value": DEFAULT_PAGE_SIZE, + "is_public": True, + "sort_order": 5, + }, + "chat_pull_page_size": { + "name": "对话懒加载条数", + "category": "chat", + "description": "Bot 对话区向上懒加载时每次读取的消息条数。", + "value_type": "integer", + "value": DEFAULT_CHAT_PULL_PAGE_SIZE, + "is_public": True, + "sort_order": 8, + }, + "upload_max_mb": { + "name": "上传大小限制", + "category": "upload", + "description": "单文件上传大小限制,单位 MB。", + "value_type": "integer", + "value": DEFAULT_UPLOAD_MAX_MB, + "is_public": False, + "sort_order": 10, + }, + "allowed_attachment_extensions": { + "name": "允许附件后缀", + "category": "upload", + "description": "允许上传的附件后缀列表,留空表示不限制。", + "value_type": "json", + "value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), + "is_public": False, + "sort_order": 20, + }, + "workspace_download_extensions": { + "name": "工作区下载后缀", + "category": "workspace", + "description": "命中后缀的工作区文件默认走下载模式。", + "value_type": "json", + "value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), + "is_public": False, + "sort_order": 30, + }, + "speech_enabled": { + "name": "语音识别开关", + "category": "speech", + "description": "控制 Bot 语音转写功能是否启用。", + "value_type": "boolean", + "value": STT_ENABLED_DEFAULT, + "is_public": True, + "sort_order": 32, + }, + ACTIVITY_EVENT_RETENTION_SETTING_KEY: { + "name": "活动事件保留天数", + "category": "maintenance", + "description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。", + "value_type": "integer", + "value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS, + "is_public": False, + "sort_order": 34, + }, +} + +_last_activity_event_prune_at: Optional[datetime] = None + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def _normalize_activity_event_retention_days(raw: Any) -> int: + try: + value = int(raw) + except Exception: + value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS + return max(1, min(3650, value)) + + +def _normalize_extension(raw: Any) -> str: + text = str(raw or "").strip().lower() + if not text: + return "" + if text.startswith("*."): + text = text[1:] + if not text.startswith("."): + text = f".{text}" + if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text): + return "" + return text + + +def _normalize_extension_list(rows: Any) -> List[str]: + if not isinstance(rows, list): + return [] + normalized: List[str] = [] + for item in rows: + ext = _normalize_extension(item) + if ext and ext not in normalized: + normalized.append(ext) + return normalized + + +def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(str(raw).strip()) + except Exception: + value = default + return max(min_value, min(max_value, value)) + + +def _legacy_env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +def _legacy_env_extensions(name: str, default: List[str]) -> List[str]: + raw = os.getenv(name) + if raw is None: + return list(default) + source = re.split(r"[,;\s]+", str(raw)) + normalized: List[str] = [] + for item in source: + ext = _normalize_extension(item) + if ext and ext not in normalized: + normalized.append(ext) + return normalized + + +def _bootstrap_platform_setting_values() -> Dict[str, Any]: + return { + "page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100), + "chat_pull_page_size": _legacy_env_int( + "CHAT_PULL_PAGE_SIZE", + DEFAULT_CHAT_PULL_PAGE_SIZE, + 10, + 500, + ), + "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), + "allowed_attachment_extensions": _legacy_env_extensions( + "ALLOWED_ATTACHMENT_EXTENSIONS", + list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), + ), + "workspace_download_extensions": _legacy_env_extensions( + "WORKSPACE_DOWNLOAD_EXTENSIONS", + list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), + ), + "speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT), + } + + +def _bot_workspace_root(bot_id: str) -> str: + return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) + + +def _bot_data_root(bot_id: str) -> str: + return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")) + + +def _calc_dir_size_bytes(path: str) -> int: + total = 0 + if not os.path.isdir(path): + return 0 + for root, _, files in os.walk(path): + for name in files: + target = os.path.join(root, name) + try: + if os.path.islink(target): + continue + total += int(os.path.getsize(target)) + except OSError: + continue + return total + + +def _read_bot_resources(bot_id: str) -> Dict[str, Any]: + path = os.path.join(_bot_data_root(bot_id), "resources.json") + raw: Dict[str, Any] = {} + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + raw = loaded + except Exception: + raw = {} + + def _safe_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return default + + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + cpu = _safe_float(raw.get("cpuCores", raw.get("cpu_cores", 1.0)), 1.0) + memory = _safe_int(raw.get("memoryMB", raw.get("memory_mb", 1024)), 1024) + storage = _safe_int(raw.get("storageGB", raw.get("storage_gb", 10)), 10) + cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) + memory = 0 if memory == 0 else min(65536, max(256, memory)) + storage = 0 if storage == 0 else min(1024, max(1, storage)) + return { + "cpu_cores": cpu, + "memory_mb": memory, + "storage_gb": storage, + } + + +def estimate_tokens(text: str) -> int: + content = str(text or "").strip() + if not content: + return 0 + pieces = re.findall(r"[\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]", content) + total = 0 + for piece in pieces: + if re.fullmatch(r"[\u4e00-\u9fff]", piece): + total += 1 + elif re.fullmatch(r"[A-Za-z0-9_]+", piece): + total += max(1, math.ceil(len(piece) / 4)) + else: + total += 1 + return max(1, total) + + +def default_platform_settings() -> PlatformSettingsPayload: + bootstrap = _bootstrap_platform_setting_values() + return PlatformSettingsPayload( + page_size=int(bootstrap["page_size"]), + chat_pull_page_size=int(bootstrap["chat_pull_page_size"]), + upload_max_mb=int(bootstrap["upload_max_mb"]), + allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), + workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), + speech_enabled=bool(bootstrap["speech_enabled"]), + speech_max_audio_seconds=DEFAULT_STT_MAX_AUDIO_SECONDS, + speech_default_language=DEFAULT_STT_DEFAULT_LANGUAGE, + speech_force_simplified=DEFAULT_STT_FORCE_SIMPLIFIED, + speech_audio_preprocess=DEFAULT_STT_AUDIO_PREPROCESS, + speech_audio_filter=DEFAULT_STT_AUDIO_FILTER, + speech_initial_prompt=DEFAULT_STT_INITIAL_PROMPT, + loading_page=LoadingPageSettings(), + ) + + +def _normalize_setting_key(raw: Any) -> str: + text = str(raw or "").strip() + return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower() + + +def _normalize_setting_value(value: Any, value_type: str) -> Any: + normalized_type = str(value_type or "json").strip().lower() or "json" + if normalized_type == "integer": + return int(value or 0) + if normalized_type == "float": + return float(value or 0) + if normalized_type == "boolean": + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + if normalized_type == "string": + return str(value or "") + if normalized_type == "json": + return value + raise ValueError(f"Unsupported value_type: {normalized_type}") + + +def _read_setting_value(row: PlatformSetting) -> Any: + try: + value = json.loads(row.value_json or "null") + except Exception: + value = None + return _normalize_setting_value(value, row.value_type) + + +def _setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]: + return SystemSettingItem( + key=row.key, + name=row.name, + category=row.category, + description=row.description, + value_type=row.value_type, + value=_read_setting_value(row), + is_public=bool(row.is_public), + sort_order=int(row.sort_order or 100), + created_at=row.created_at.isoformat() + "Z", + updated_at=row.updated_at.isoformat() + "Z", + ).model_dump() + + +def _upsert_setting_row( + session: Session, + key: str, + *, + name: str, + category: str, + description: str, + value_type: str, + value: Any, + is_public: bool, + sort_order: int, +) -> PlatformSetting: + normalized_key = _normalize_setting_key(key) + if not normalized_key: + raise ValueError("Setting key is required") + normalized_type = str(value_type or "json").strip().lower() or "json" + normalized_value = _normalize_setting_value(value, normalized_type) + now = _utcnow() + row = session.get(PlatformSetting, normalized_key) + if row is None: + row = PlatformSetting( + key=normalized_key, + name=str(name or normalized_key), + category=str(category or "general"), + description=str(description or ""), + value_type=normalized_type, + value_json=json.dumps(normalized_value, ensure_ascii=False), + is_public=bool(is_public), + sort_order=int(sort_order or 100), + created_at=now, + updated_at=now, + ) + else: + row.name = str(name or row.name or normalized_key) + row.category = str(category or row.category or "general") + row.description = str(description or row.description or "") + row.value_type = normalized_type + row.value_json = json.dumps(normalized_value, ensure_ascii=False) + row.is_public = bool(is_public) + row.sort_order = int(sort_order or row.sort_order or 100) + row.updated_at = now + session.add(row) + return row + + +def ensure_default_system_settings(session: Session) -> None: + bootstrap_values = _bootstrap_platform_setting_values() + legacy_row = session.get(PlatformSetting, "global") + if legacy_row is not None: + try: + legacy_data = json.loads(legacy_row.value_json or "{}") + except Exception: + legacy_data = {} + if isinstance(legacy_data, dict): + for key in SETTING_KEYS: + meta = SYSTEM_SETTING_DEFINITIONS[key] + _upsert_setting_row( + session, + key, + name=str(meta["name"]), + category=str(meta["category"]), + description=str(meta["description"]), + value_type=str(meta["value_type"]), + value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])), + is_public=bool(meta["is_public"]), + sort_order=int(meta["sort_order"]), + ) + session.delete(legacy_row) + session.commit() + + dirty = False + for key in DEPRECATED_SETTING_KEYS: + legacy_row = session.get(PlatformSetting, key) + if legacy_row is not None: + session.delete(legacy_row) + dirty = True + + for key, meta in SYSTEM_SETTING_DEFINITIONS.items(): + row = session.get(PlatformSetting, key) + if row is None: + _upsert_setting_row( + session, + key, + name=str(meta["name"]), + category=str(meta["category"]), + description=str(meta["description"]), + value_type=str(meta["value_type"]), + value=bootstrap_values.get(key, meta["value"]), + is_public=bool(meta["is_public"]), + sort_order=int(meta["sort_order"]), + ) + dirty = True + continue + changed = False + for field in ("name", "category", "description", "value_type"): + value = str(meta[field]) + if not getattr(row, field): + setattr(row, field, value) + changed = True + if getattr(row, "sort_order", None) is None: + row.sort_order = int(meta["sort_order"]) + changed = True + if getattr(row, "is_public", None) is None: + row.is_public = bool(meta["is_public"]) + changed = True + if changed: + row.updated_at = _utcnow() + session.add(row) + dirty = True + if dirty: + session.commit() + + +def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]: + ensure_default_system_settings(session) + stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc()) + rows = session.exec(stmt).all() + keyword = str(search or "").strip().lower() + items = [_setting_item_from_row(row) for row in rows] + if not keyword: + return items + return [ + item + for item in items + if keyword in str(item["key"]).lower() + or keyword in str(item["name"]).lower() + or keyword in str(item["category"]).lower() + or keyword in str(item["description"]).lower() + ] + + +def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]: + ensure_default_system_settings(session) + normalized_key = _normalize_setting_key(payload.key) + definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {}) + row = _upsert_setting_row( + session, + payload.key, + name=payload.name or str(definition.get("name") or payload.key), + category=payload.category or str(definition.get("category") or "general"), + description=payload.description or str(definition.get("description") or ""), + value_type=payload.value_type or str(definition.get("value_type") or "json"), + value=payload.value if payload.value is not None else definition.get("value"), + is_public=payload.is_public, + sort_order=payload.sort_order or int(definition.get("sort_order") or 100), + ) + if normalized_key == ACTIVITY_EVENT_RETENTION_SETTING_KEY: + prune_expired_activity_events(session, force=True) + session.commit() + session.refresh(row) + return _setting_item_from_row(row) + + +def delete_system_setting(session: Session, key: str) -> None: + normalized_key = _normalize_setting_key(key) + if normalized_key in PROTECTED_SETTING_KEYS: + raise ValueError("Core platform settings cannot be deleted") + row = session.get(PlatformSetting, normalized_key) + if row is None: + raise ValueError("Setting not found") + session.delete(row) + session.commit() + + +def get_platform_settings(session: Session) -> PlatformSettingsPayload: + defaults = default_platform_settings() + ensure_default_system_settings(session) + rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all() + data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows} + + merged = defaults.model_dump() + merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"]))) + merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"]))) + merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"]) + merged["allowed_attachment_extensions"] = _normalize_extension_list( + data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]) + ) + merged["workspace_download_extensions"] = _normalize_extension_list( + data.get("workspace_download_extensions", merged["workspace_download_extensions"]) + ) + merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"])) + loading_page = data.get("loading_page") + if isinstance(loading_page, dict): + current = dict(merged["loading_page"]) + for key in ("title", "subtitle", "description"): + value = str(loading_page.get(key) or "").strip() + if value: + current[key] = value + merged["loading_page"] = current + return PlatformSettingsPayload.model_validate(merged) + + +def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload: + normalized = PlatformSettingsPayload( + page_size=max(1, min(100, int(payload.page_size))), + chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))), + upload_max_mb=payload.upload_max_mb, + allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions), + workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions), + speech_enabled=bool(payload.speech_enabled), + loading_page=LoadingPageSettings.model_validate(payload.loading_page.model_dump()), + ) + payload_by_key = normalized.model_dump() + for key in SETTING_KEYS: + definition = SYSTEM_SETTING_DEFINITIONS[key] + _upsert_setting_row( + session, + key, + name=str(definition["name"]), + category=str(definition["category"]), + description=str(definition["description"]), + value_type=str(definition["value_type"]), + value=payload_by_key[key], + is_public=bool(definition["is_public"]), + sort_order=int(definition["sort_order"]), + ) + session.commit() + return normalized + + +def get_platform_settings_snapshot() -> PlatformSettingsPayload: + with Session(engine) as session: + return get_platform_settings(session) + + +def get_upload_max_mb() -> int: + return get_platform_settings_snapshot().upload_max_mb + + +def get_allowed_attachment_extensions() -> List[str]: + return get_platform_settings_snapshot().allowed_attachment_extensions + + +def get_workspace_download_extensions() -> List[str]: + return get_platform_settings_snapshot().workspace_download_extensions + + +def get_page_size() -> int: + return get_platform_settings_snapshot().page_size + + +def get_chat_pull_page_size() -> int: + return get_platform_settings_snapshot().chat_pull_page_size + + +def get_speech_runtime_settings() -> Dict[str, Any]: + settings = get_platform_settings_snapshot() + return { + "enabled": bool(settings.speech_enabled), + "max_audio_seconds": int(DEFAULT_STT_MAX_AUDIO_SECONDS), + "default_language": str(DEFAULT_STT_DEFAULT_LANGUAGE or "zh").strip().lower() or "zh", + "force_simplified": bool(DEFAULT_STT_FORCE_SIMPLIFIED), + "audio_preprocess": bool(DEFAULT_STT_AUDIO_PREPROCESS), + "audio_filter": str(DEFAULT_STT_AUDIO_FILTER or "").strip(), + "initial_prompt": str(DEFAULT_STT_INITIAL_PROMPT or "").strip(), + "model": STT_MODEL, + "device": STT_DEVICE, + } + + +def get_activity_event_retention_days(session: Session) -> int: + row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY) + if row is None: + return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS + try: + value = _read_setting_value(row) + except Exception: + value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS + return _normalize_activity_event_retention_days(value) + + +def create_usage_request( + session: Session, + bot_id: str, + command: str, + attachments: Optional[List[str]] = None, + channel: str = "dashboard", + metadata: Optional[Dict[str, Any]] = None, + provider: Optional[str] = None, + model: Optional[str] = None, +) -> str: + request_id = uuid.uuid4().hex + rows = [str(item).strip() for item in (attachments or []) if str(item).strip()] + input_tokens = estimate_tokens(command) + usage = BotRequestUsage( + bot_id=bot_id, + request_id=request_id, + channel=channel, + status="PENDING", + provider=(str(provider or "").strip() or None), + model=(str(model or "").strip() or None), + token_source="estimated", + input_tokens=input_tokens, + output_tokens=0, + total_tokens=input_tokens, + input_text_preview=str(command or "")[:400], + attachments_json=json.dumps(rows, ensure_ascii=False) if rows else None, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + started_at=_utcnow(), + created_at=_utcnow(), + updated_at=_utcnow(), + ) + session.add(usage) + session.flush() + return request_id + + +def bind_usage_message( + session: Session, + bot_id: str, + request_id: str, + message_id: Optional[int], +) -> Optional[BotRequestUsage]: + if not request_id or not message_id: + return None + usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) + if not usage_row: + return None + usage_row.message_id = int(message_id) + usage_row.updated_at = _utcnow() + session.add(usage_row) + return usage_row + + +def _find_latest_pending_usage(session: Session, bot_id: str) -> Optional[BotRequestUsage]: + stmt = ( + select(BotRequestUsage) + .where(BotRequestUsage.bot_id == bot_id) + .where(BotRequestUsage.status == "PENDING") + .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) + .limit(1) + ) + return session.exec(stmt).first() + + +def _find_pending_usage_by_request_id(session: Session, bot_id: str, request_id: str) -> Optional[BotRequestUsage]: + if not request_id: + return None + stmt = ( + select(BotRequestUsage) + .where(BotRequestUsage.bot_id == bot_id) + .where(BotRequestUsage.request_id == request_id) + .where(BotRequestUsage.status == "PENDING") + .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) + .limit(1) + ) + return session.exec(stmt).first() + + +def finalize_usage_from_packet(session: Session, bot_id: str, packet: Dict[str, Any]) -> Optional[BotRequestUsage]: + request_id = str(packet.get("request_id") or "").strip() + usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) or _find_latest_pending_usage(session, bot_id) + if not usage_row: + return None + + raw_usage = packet.get("usage") + input_tokens: Optional[int] = None + output_tokens: Optional[int] = None + source = "estimated" + if isinstance(raw_usage, dict): + for key in ("input_tokens", "prompt_tokens", "promptTokens"): + if raw_usage.get(key) is not None: + try: + input_tokens = int(raw_usage.get(key) or 0) + except Exception: + input_tokens = None + break + for key in ("output_tokens", "completion_tokens", "completionTokens"): + if raw_usage.get(key) is not None: + try: + output_tokens = int(raw_usage.get(key) or 0) + except Exception: + output_tokens = None + break + if input_tokens is not None or output_tokens is not None: + source = "exact" + + text = str(packet.get("text") or packet.get("content") or "").strip() + provider = str(packet.get("provider") or "").strip() + model = str(packet.get("model") or "").strip() + message_id = packet.get("message_id") + if input_tokens is None: + input_tokens = usage_row.input_tokens + if output_tokens is None: + output_tokens = estimate_tokens(text) + if source == "exact": + source = "mixed" + + if provider: + usage_row.provider = provider[:120] + if model: + usage_row.model = model[:255] + if message_id is not None: + try: + usage_row.message_id = int(message_id) + except Exception: + pass + usage_row.output_tokens = max(0, int(output_tokens or 0)) + usage_row.input_tokens = max(0, int(input_tokens or 0)) + usage_row.total_tokens = usage_row.input_tokens + usage_row.output_tokens + usage_row.output_text_preview = text[:400] if text else usage_row.output_text_preview + usage_row.status = "COMPLETED" + usage_row.token_source = source + usage_row.completed_at = _utcnow() + usage_row.updated_at = _utcnow() + session.add(usage_row) + return usage_row + + +def fail_latest_usage(session: Session, bot_id: str, detail: str) -> Optional[BotRequestUsage]: + usage_row = _find_latest_pending_usage(session, bot_id) + if not usage_row: + return None + usage_row.status = "ERROR" + usage_row.error_text = str(detail or "")[:500] + usage_row.completed_at = _utcnow() + usage_row.updated_at = _utcnow() + session.add(usage_row) + return usage_row + + +def prune_expired_activity_events(session: Session, force: bool = False) -> int: + global _last_activity_event_prune_at + + now = _utcnow() + if not force and _last_activity_event_prune_at and now - _last_activity_event_prune_at < ACTIVITY_EVENT_PRUNE_INTERVAL: + return 0 + + retention_days = get_activity_event_retention_days(session) + cutoff = now - timedelta(days=retention_days) + result = session.exec( + sql_delete(BotActivityEvent).where(BotActivityEvent.created_at < cutoff) + ) + _last_activity_event_prune_at = now + return int(getattr(result, "rowcount", 0) or 0) + + +def record_activity_event( + session: Session, + bot_id: str, + event_type: str, + request_id: Optional[str] = None, + channel: str = "dashboard", + detail: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> None: + normalized_event_type = str(event_type or "unknown").strip().lower() or "unknown" + if normalized_event_type not in OPERATIONAL_ACTIVITY_EVENT_TYPES: + return + prune_expired_activity_events(session, force=False) + row = BotActivityEvent( + bot_id=bot_id, + request_id=request_id, + event_type=normalized_event_type, + channel=str(channel or "dashboard").strip().lower() or "dashboard", + detail=(str(detail or "").strip() or None), + metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None, + created_at=_utcnow(), + ) + session.add(row) + + +def list_usage( + session: Session, + bot_id: Optional[str] = None, + limit: int = 100, + offset: int = 0, +) -> Dict[str, Any]: + safe_limit = max(1, min(int(limit), 500)) + safe_offset = max(0, int(offset or 0)) + stmt = ( + select(BotRequestUsage) + .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) + .offset(safe_offset) + .limit(safe_limit) + ) + summary_stmt = select( + func.count(BotRequestUsage.id), + func.coalesce(func.sum(BotRequestUsage.input_tokens), 0), + func.coalesce(func.sum(BotRequestUsage.output_tokens), 0), + func.coalesce(func.sum(BotRequestUsage.total_tokens), 0), + ) + total_stmt = select(func.count(BotRequestUsage.id)) + if bot_id: + stmt = stmt.where(BotRequestUsage.bot_id == bot_id) + summary_stmt = summary_stmt.where(BotRequestUsage.bot_id == bot_id) + total_stmt = total_stmt.where(BotRequestUsage.bot_id == bot_id) + else: + since = _utcnow() - timedelta(days=1) + summary_stmt = summary_stmt.where(BotRequestUsage.created_at >= since) + rows = session.exec(stmt).all() + count, input_sum, output_sum, total_sum = session.exec(summary_stmt).one() + total = int(session.exec(total_stmt).one() or 0) + items = [ + PlatformUsageItem( + id=int(row.id or 0), + bot_id=row.bot_id, + message_id=int(row.message_id) if row.message_id is not None else None, + request_id=row.request_id, + channel=row.channel, + status=row.status, + provider=row.provider, + model=row.model, + token_source=row.token_source, + content=row.input_text_preview or row.output_text_preview, + input_tokens=int(row.input_tokens or 0), + output_tokens=int(row.output_tokens or 0), + total_tokens=int(row.total_tokens or 0), + input_text_preview=row.input_text_preview, + output_text_preview=row.output_text_preview, + started_at=row.started_at.isoformat() + "Z", + completed_at=row.completed_at.isoformat() + "Z" if row.completed_at else None, + ).model_dump() + for row in rows + ] + return PlatformUsageResponse( + summary=PlatformUsageSummary( + request_count=int(count or 0), + input_tokens=int(input_sum or 0), + output_tokens=int(output_sum or 0), + total_tokens=int(total_sum or 0), + ), + items=[PlatformUsageItem.model_validate(item) for item in items], + total=total, + limit=safe_limit, + offset=safe_offset, + has_more=safe_offset + len(items) < total, + ).model_dump() + + +def list_activity_events( + session: Session, + bot_id: Optional[str] = None, + limit: int = 100, +) -> List[Dict[str, Any]]: + deleted = prune_expired_activity_events(session, force=False) + if deleted > 0: + session.commit() + safe_limit = max(1, min(int(limit), 500)) + stmt = select(BotActivityEvent).order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc()).limit(safe_limit) + if bot_id: + stmt = stmt.where(BotActivityEvent.bot_id == bot_id) + rows = session.exec(stmt).all() + items: List[Dict[str, Any]] = [] + for row in rows: + try: + metadata = json.loads(row.metadata_json or "{}") + except Exception: + metadata = {} + items.append( + PlatformActivityItem( + id=int(row.id or 0), + bot_id=row.bot_id, + request_id=row.request_id, + event_type=row.event_type, + channel=row.channel, + detail=row.detail, + metadata=metadata if isinstance(metadata, dict) else {}, + created_at=row.created_at.isoformat() + "Z", + ).model_dump() + ) + return items + + +def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str, Any]: + deleted = prune_expired_activity_events(session, force=False) + if deleted > 0: + session.commit() + bots = session.exec(select(BotInstance)).all() + images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all() + settings = get_platform_settings(session) + + running = 0 + stopped = 0 + disabled = 0 + configured_cpu_total = 0.0 + configured_memory_total = 0 + configured_storage_total = 0 + workspace_used_total = 0 + workspace_limit_total = 0 + live_cpu_percent_total = 0.0 + live_memory_used_total = 0 + live_memory_limit_total = 0 + + bot_rows: List[Dict[str, Any]] = [] + for bot in bots: + enabled = bool(getattr(bot, "enabled", True)) + runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED") + resources = _read_bot_resources(bot.id) + runtime = docker_manager.get_bot_resource_snapshot(bot.id) if docker_manager else {"usage": {}, "limits": {}, "docker_status": runtime_status} + workspace_root = _bot_workspace_root(bot.id) + workspace_used = _calc_dir_size_bytes(workspace_root) + workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 + + configured_cpu_total += float(resources["cpu_cores"] or 0) + configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 + configured_storage_total += workspace_limit + workspace_used_total += workspace_used + workspace_limit_total += workspace_limit + live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) + live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) + live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) + + if not enabled: + disabled += 1 + elif runtime_status == "RUNNING": + running += 1 + else: + stopped += 1 + + bot_rows.append( + { + "id": bot.id, + "name": bot.name, + "enabled": enabled, + "docker_status": runtime_status, + "image_tag": bot.image_tag, + "llm_provider": getattr(bot, "llm_provider", None), + "llm_model": getattr(bot, "llm_model", None), + "current_state": bot.current_state, + "last_action": bot.last_action, + "resources": resources, + "workspace_usage_bytes": workspace_used, + "workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None, + } + ) + + usage = list_usage(session, limit=20) + events = list_activity_events(session, limit=20) + + return { + "summary": { + "bots": { + "total": len(bots), + "running": running, + "stopped": stopped, + "disabled": disabled, + }, + "images": { + "total": len(images), + "ready": len([row for row in images if row.status == "READY"]), + "abnormal": len([row for row in images if row.status != "READY"]), + }, + "resources": { + "configured_cpu_cores": round(configured_cpu_total, 2), + "configured_memory_bytes": configured_memory_total, + "configured_storage_bytes": configured_storage_total, + "live_cpu_percent": round(live_cpu_percent_total, 2), + "live_memory_used_bytes": live_memory_used_total, + "live_memory_limit_bytes": live_memory_limit_total, + "workspace_used_bytes": workspace_used_total, + "workspace_limit_bytes": workspace_limit_total, + }, + }, + "images": [ + { + "tag": row.tag, + "version": row.version, + "status": row.status, + "source_dir": row.source_dir, + "created_at": row.created_at.isoformat() + "Z", + } + for row in images + ], + "bots": bot_rows, + "settings": settings.model_dump(), + "usage": usage, + "events": events, + } diff --git a/bot-images/dashboard.py b/bot-images/dashboard.py index ae0c3a2..7e40593 100644 --- a/bot-images/dashboard.py +++ b/bot-images/dashboard.py @@ -1,24 +1,56 @@ -from __future__ import annotations -import asyncio +import json +from types import SimpleNamespace +from typing import Any + from aiohttp import web from loguru import logger -from typing import Any -import json +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel -from nanobot.bus.events import InboundMessage, OutboundMessage class DashboardChannel(BaseChannel): """ 专门为管理面板设计的渠道。 它充当机器人内部总线 (Bus) 与宿主机面板之间的桥梁。 """ - - def __init__(self, config: Any, bus: Any, host: str = "0.0.0.0", port: int = 9000): - super().__init__(config, bus) - self.host = host - self.port = port - self.runner = None + + name = "dashboard" + display_name = "Dashboard" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return { + "enabled": False, + "host": "0.0.0.0", + "port": 9000, + "allow_from": ["*"], + } + + @classmethod + def _coerce_config(cls, config: Any) -> Any: + if config is None: + return SimpleNamespace(**cls.default_config()) + if isinstance(config, dict): + merged = cls.default_config() + merged.update(config) + if "allowFrom" in config and "allow_from" not in config: + merged["allow_from"] = config.get("allowFrom") + return SimpleNamespace(**merged) + return config + + def __init__( + self, + config: Any, + bus: MessageBus, + host: str | None = None, + port: int | None = None, + ): + config_obj = self._coerce_config(config) + super().__init__(config_obj, bus) + self.host = host if host is not None else getattr(config_obj, "host", "0.0.0.0") + self.port = port if port is not None else getattr(config_obj, "port", 9000) + self.runner: web.AppRunner | None = None async def start(self) -> None: """启动 Dashboard HTTP 服务""" @@ -30,14 +62,15 @@ class DashboardChannel(BaseChannel): site = web.TCPSite(self.runner, self.host, self.port) await site.start() - self._is_running = True + self._running = True logger.info(f"🚀 Dashboard Channel 代理已上线,监听端口: {self.port}") async def stop(self) -> None: """停止服务""" if self.runner: await self.runner.cleanup() - self._is_running = False + self.runner = None + self._running = False logger.info("Dashboard Channel 已下线") async def send(self, message: OutboundMessage) -> None: @@ -49,8 +82,9 @@ class DashboardChannel(BaseChannel): return # 核心:从元数据识别消息类型(进度更新 vs 最终回复) - is_progress = message.metadata.get("_progress", False) - is_tool_hint = message.metadata.get("_tool_hint", False) + metadata = message.metadata or {} + is_progress = metadata.get("_progress", False) + is_tool_hint = metadata.get("_tool_hint", False) payload = { "type": "BUS_EVENT", @@ -60,6 +94,18 @@ class DashboardChannel(BaseChannel): "content": message.content, "media": media, } + usage = metadata.get("usage") + if isinstance(usage, dict): + payload["usage"] = usage + request_id = str(metadata.get("request_id") or "").strip() + if request_id: + payload["request_id"] = request_id + provider = str(metadata.get("provider") or "").strip() + if provider: + payload["provider"] = provider + model = str(metadata.get("model") or "").strip() + if model: + payload["model"] = model # 使用 JSON 格式输出,方便面板后端精准解析,告别正则 print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True) @@ -79,14 +125,13 @@ class DashboardChannel(BaseChannel): # 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码 logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...") - # 将消息塞入总线 - await self.bus.publish_inbound(InboundMessage( - channel="dashboard", - sender_id="user", - chat_id="direct", + # 统一走基类入口,兼容不同核心的会话与权限逻辑。 + await self._handle_message( + sender_id="user", + chat_id="direct", content=user_message, media=media, - )) + ) return web.json_response({"status": "ok"}) except Exception as e: diff --git a/frontend/src/App.css b/frontend/src/App.css index dc3c69b..7316a33 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -72,6 +72,11 @@ body { gap: 14px; } +.app-frame.app-frame-no-header { + grid-template-rows: 1fr; + gap: 0; +} + .app-shell-compact .app-frame { height: calc(100dvh - 36px); min-height: calc(100dvh - 36px); @@ -1173,6 +1178,897 @@ body { color: var(--title); } +.app-route-subtitle { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.platform-grid { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 18px; + min-height: 0; +} + +.platform-grid.is-compact { + grid-template-columns: 1fr; +} + +.platform-main { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.platform-bot-list-panel { + min-height: 0; +} + +.platform-list-actions { + display: flex; + gap: 8px; +} + +.platform-loading-card { + background: linear-gradient(135deg, rgba(55, 162, 255, 0.16), rgba(23, 43, 88, 0.28)); + border: 1px solid rgba(99, 170, 255, 0.22); +} + +.platform-loading-title { + font-size: 18px; + font-weight: 800; + color: var(--title); +} + +.platform-loading-subtitle { + margin-top: 6px; + font-size: 13px; + color: var(--text); +} + +.platform-loading-description { + margin-top: 8px; + font-size: 12px; + color: var(--muted); +} + +.platform-bot-list-scroll { + display: flex; + flex-direction: column; + gap: 10px; +} + +.platform-bot-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(8, 13, 22, 0.72); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.platform-bot-card:hover, +.platform-bot-card.is-selected { + transform: translateY(-1px); + border-color: rgba(97, 174, 255, 0.45); + box-shadow: 0 16px 40px rgba(8, 25, 60, 0.18); +} + +.platform-bot-name { + font-size: 15px; + font-weight: 700; + color: var(--title); +} + +.platform-bot-id { + margin-top: 4px; + font-size: 11px; + color: var(--muted); +} + +.platform-bot-meta { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.platform-bot-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.platform-enable-switch { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); +} + +.platform-bot-actions-main { + display: flex; + gap: 8px; +} + +.platform-summary-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; +} + +.platform-summary-card { + display: flex; + flex-direction: column; + gap: 8px; +} + +.platform-summary-icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + color: #fff; +} + +.platform-summary-icon.icon-bot { + background: linear-gradient(145deg, #5474ff 0%, #2f59d7 100%); +} + +.platform-summary-icon.icon-image { + background: linear-gradient(145deg, #28b7a1 0%, #17967d 100%); +} + +.platform-summary-icon.icon-token { + background: linear-gradient(145deg, #f0877d 0%, #d95a7d 100%); +} + +.platform-summary-icon.icon-resource { + background: linear-gradient(145deg, #ed7f9a 0%, #f5a65d 46%, #ffe56c 100%); +} + +.platform-resource-card { + grid-column: span 3; + gap: 14px; +} + +.platform-summary-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.platform-summary-value { + font-size: 28px; + font-weight: 800; + color: var(--title); +} + +.platform-summary-meta { + font-size: 12px; + color: var(--muted); +} + +.platform-resource-head { + display: flex; + align-items: center; + gap: 12px; +} + +.platform-resource-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} + +.platform-resource-meters { + display: flex; + flex-direction: column; + gap: 12px; +} + +.platform-resource-meter { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 80px; + align-items: center; + gap: 12px; +} + +.platform-resource-meter-label { + display: flex; + align-items: center; + justify-content: center; + color: var(--title); +} + +.platform-resource-meter-track { + position: relative; + height: 14px; + overflow: hidden; + border-radius: 999px; + background: rgba(225, 232, 245, 0.58); +} + +.platform-resource-meter-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #7f90ff 0%, #6c5ac3 100%); +} + +.platform-resource-meter-fill.is-memory { + background: linear-gradient(90deg, #6f72e0 0%, #5a43ad 100%); +} + +.platform-resource-meter-fill.is-storage { + background: linear-gradient(90deg, #7f90ff 0%, #6b49b9 100%); +} + +.platform-resource-meter-value { + font-size: 18px; + font-weight: 700; + color: var(--title); + text-align: right; +} + +.platform-resource-footnote { + font-size: 12px; + color: var(--muted); +} + +.platform-main-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 18px; +} + +.platform-monitor-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.platform-monitor-card { + min-height: 132px; +} + +.platform-monitor-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + color: var(--title); +} + +.platform-monitor-main { + margin-top: 14px; + font-size: 20px; + font-weight: 800; + color: var(--title); +} + +.platform-monitor-meta { + margin-top: 8px; + font-size: 12px; + color: var(--muted); +} + +.platform-selected-bot-card { + min-height: 240px; +} + +.platform-selected-bot-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.platform-selected-bot-headline { + display: flex; + align-items: baseline; + gap: 14px; + flex-wrap: wrap; +} + +.platform-selected-bot-name-row { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.platform-selected-bot-actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.platform-more-menu-anchor { + position: relative; +} + +.platform-selected-bot-action-btn { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 14px; + border-radius: 999px; +} + +.platform-selected-bot-name { + font-size: 26px; + font-weight: 800; + color: var(--title); +} + +.platform-selected-bot-id { + font-size: 14px; + color: var(--muted); +} + +.platform-selected-bot-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 14px; + margin-top: 18px; +} + +.platform-selected-bot-info { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(97, 174, 255, 0.14); + background: rgba(255, 255, 255, 0.03); +} + +.platform-selected-bot-info-label { + font-size: 12px; + color: var(--muted); +} + +.platform-selected-bot-info-value { + font-size: 14px; + color: var(--title); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.platform-selected-bot-last-row { + margin-top: 18px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} + +.platform-selected-bot-last-copy { + min-width: 0; + flex: 1 1 auto; +} + +.platform-selected-bot-last-label { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 700; + color: var(--title); +} + +.platform-selected-bot-last-preview { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); +} + +.platform-selected-bot-last-body { + margin-top: 10px; + padding: 14px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.platform-last-action-btn { + flex-shrink: 0; + align-self: flex-end; + margin-bottom: 2px; +} + +.platform-compact-sheet-mask { + align-items: flex-end; + padding: 0; + background: rgba(8, 13, 22, 0.28); + backdrop-filter: blur(10px); + animation: platform-sheet-mask-in 240ms ease-out both; +} + +.platform-compact-sheet-mask.is-closing { + animation: platform-sheet-mask-out 220ms ease-in both; +} + +.platform-compact-sheet-card { + position: relative; + width: 100%; + max-height: min(84vh, 760px); + overflow: hidden; + border-radius: 28px 28px 0 0; + border: 1px solid color-mix(in oklab, var(--line) 78%, #ffffff 22%); + border-bottom: 0; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel) 94%, #ffffff 6%) 0%, var(--panel) 100%); + box-shadow: + 0 -24px 60px rgba(8, 17, 34, 0.28), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + animation: platform-sheet-up 240ms cubic-bezier(0.18, 0.84, 0.26, 1) both; +} + +.platform-compact-sheet-card.is-closing { + animation: platform-sheet-down 220ms ease-in both; +} + +.platform-compact-sheet-handle { + width: 58px; + height: 6px; + border-radius: 999px; + margin: 12px auto 8px; + background: color-mix(in oklab, var(--line) 70%, var(--title) 30%); +} + +.platform-compact-sheet-body { + display: flex; + flex-direction: column; + gap: 0; + max-height: calc(min(84vh, 760px) - 34px); + overflow-y: auto; + padding: 8px 12px 14px; +} + +.platform-compact-sheet-close { + position: absolute; + top: 14px; + right: 14px; + z-index: 2; + background: color-mix(in oklab, var(--panel-soft) 82%, #ffffff 18%); +} + +.platform-compact-overview { + display: flex; + flex-direction: column; + gap: 12px; +} + +.platform-compact-overview-head h2 { + margin: 0; + font-size: 18px; +} + +.platform-selected-bot-card-compact { + border-radius: 22px; + border-color: color-mix(in oklab, var(--line) 72%, #ffffff 28%); + background: color-mix(in oklab, var(--panel-soft) 88%, #ffffff 12%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); +} + +@keyframes platform-sheet-up { + from { + transform: translateY(48px) scale(0.985); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes platform-sheet-down { + from { + transform: translateY(0) scale(1); + opacity: 1; + } + to { + transform: translateY(42px) scale(0.988); + opacity: 0; + } +} + +@keyframes platform-sheet-mask-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes platform-sheet-mask-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.platform-image-list, +.platform-activity-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.platform-image-row, +.platform-activity-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.platform-image-meta, +.platform-activity-detail, +.platform-activity-type { + font-size: 12px; + color: var(--muted); +} + +.platform-activity-row strong { + margin-right: 8px; +} + +.platform-activity-empty { + padding: 12px 14px; + border-radius: 14px; + border: 1px dashed rgba(97, 174, 255, 0.18); + color: var(--muted); + font-size: 12px; +} + +.platform-entry-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.platform-entry-card { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; + padding: 16px; + border-radius: 16px; + border: 1px solid rgba(97, 174, 255, 0.18); + background: linear-gradient(180deg, rgba(14, 22, 38, 0.84), rgba(8, 12, 21, 0.92)); + color: var(--text); + text-align: left; + transition: transform 0.18s ease, border-color 0.18s ease; +} + +.platform-entry-card:hover { + transform: translateY(-1px); + border-color: rgba(97, 174, 255, 0.34); +} + +.platform-entry-card strong { + color: var(--title); +} + +.platform-entry-card span { + font-size: 12px; + color: var(--muted); +} + +.platform-settings-shell { + max-width: min(1400px, 96vw); +} + +.platform-settings-info-card { + display: flex; + gap: 16px; + padding: 18px 20px; + border-radius: 18px; + border: 1px solid rgba(97, 174, 255, 0.28); + background: color-mix(in oklab, var(--panel) 68%, #dff0ff 32%); + color: var(--text); +} + +.platform-settings-info-icon { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + flex: 0 0 36px; + aspect-ratio: 1 / 1; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #4273f2; + color: #fff; + font-weight: 800; +} + +.platform-settings-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.platform-settings-search { + flex: 1 1 auto; + max-width: 460px; +} + +.platform-settings-table-wrap { + border: 1px solid var(--line); + border-radius: 0; + overflow: hidden; + max-height: 56vh; + overflow-y: auto; +} + +.platform-settings-table th, +.platform-settings-table td { + vertical-align: top; +} + +.platform-setting-public { + margin-top: 6px; + font-size: 12px; + color: #7bcf57; +} + +.platform-setting-value { + display: inline-block; + max-width: 320px; + white-space: pre-wrap; + word-break: break-word; +} + +.platform-settings-actions { + display: flex; + gap: 8px; +} + +.platform-setting-editor { + width: min(640px, 92vw); +} + +.platform-settings-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + font-size: 12px; + color: var(--muted); +} + +.platform-template-shell { + max-width: min(1400px, 96vw); +} + +.platform-template-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 16px; + min-height: 60vh; +} + +.platform-template-tabs { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 4px; +} + +.platform-template-tab { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(97, 174, 255, 0.16); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + text-align: left; +} + +.platform-template-tab strong { + color: var(--title); +} + +.platform-template-tab span, +.platform-template-hint { + font-size: 12px; + color: var(--muted); + line-height: 1.6; +} + +.platform-template-tab.is-active { + border-color: rgba(97, 174, 255, 0.42); + background: rgba(97, 174, 255, 0.08); +} + +.platform-template-editor { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.platform-template-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.platform-template-textarea { + min-height: 0; + flex: 1 1 auto; +} + +.app-shell[data-theme='light'] .platform-entry-card { + border-color: #b7c7e6; + background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); + color: #173057; +} + +.app-shell[data-theme='light'] .platform-entry-card strong { + color: #173057; +} + +.app-shell[data-theme='light'] .platform-entry-card span { + color: #49648f; +} + +.app-shell[data-theme='light'] .platform-template-tab { + background: #f6f9ff; + border-color: #d4e1f7; +} + +.app-shell[data-theme='light'] .platform-template-tab.is-active { + background: #e9f1ff; + border-color: #8db4ff; +} + +.app-shell[data-theme='light'] .platform-selected-bot-last-body, +.app-shell[data-theme='light'] .platform-selected-bot-info, +.app-shell[data-theme='light'] .platform-image-row, +.app-shell[data-theme='light'] .platform-activity-row, +.app-shell[data-theme='light'] .platform-usage-row { + background: #f6f9ff; + border-color: #d4e1f7; +} + +.app-shell[data-theme='light'] .platform-resource-meter-track { + background: #e9eef9; +} + +.platform-usage-summary { + display: inline-flex; + gap: 16px; + font-size: 12px; + color: var(--muted); +} + +.platform-usage-table { + display: flex; + flex-direction: column; + gap: 6px; +} + +.platform-usage-head, +.platform-usage-row { + display: grid; + grid-template-columns: minmax(150px, 1.3fr) minmax(220px, 2fr) minmax(180px, 1.2fr) 90px 90px 90px minmax(130px, 1fr); + gap: 12px; + align-items: start; +} + +.platform-usage-head { + padding: 0 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.platform-usage-row { + padding: 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + font-size: 12px; +} + +.platform-usage-meta { + margin-top: 4px; + color: var(--muted); +} + +.platform-usage-content-cell, +.platform-usage-model { + min-width: 0; +} + +.platform-usage-preview { + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.platform-usage-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + font-size: 12px; + color: var(--muted); +} + +.platform-usage-pager-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.pager-icon-btn { + width: 36px; + height: 36px; + border-radius: 999px; + border-width: 1px; + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--line) 55%, transparent); +} + +.pager-status { + font-size: 12px; + font-weight: 700; + color: var(--subtitle); +} + +.platform-last-action-modal { + width: min(760px, 92vw); +} + +.platform-modal { + max-height: 84vh; + overflow-y: auto; +} + @media (max-width: 1400px) { .grid-ops { grid-template-columns: 280px 1fr 320px; @@ -1185,6 +2081,14 @@ body { .grid-ops.grid-ops-compact { grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); } + + .platform-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .platform-resource-card { + grid-column: span 3; + } } @media (max-width: 1160px) { @@ -1198,6 +2102,31 @@ body { grid-template-columns: 1fr; } + .platform-grid, + .platform-main-grid, + .platform-monitor-grid, + .platform-entry-grid, + .platform-summary-grid { + grid-template-columns: 1fr; + } + + .platform-resource-card { + grid-column: auto; + } + + .platform-template-layout { + grid-template-columns: 1fr; + } + + .platform-template-tabs { + max-height: 220px; + } + + .platform-usage-head, + .platform-usage-row { + grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px; + } + .app-frame { height: auto; min-height: calc(100vh - 36px); @@ -1239,4 +2168,61 @@ body { height: 100%; min-height: 0; } + + .platform-bot-list-panel { + min-height: calc(100dvh - 170px); + } + + .platform-bot-actions, + .platform-image-row, + .platform-activity-row { + flex-direction: column; + align-items: flex-start; + } + + .platform-selected-bot-headline { + gap: 8px; + } + + .platform-selected-bot-head { + flex-direction: column; + align-items: stretch; + } + + .platform-selected-bot-actions { + justify-content: flex-start; + } + + .platform-selected-bot-grid { + grid-template-columns: 1fr; + } + + .platform-resource-meter { + grid-template-columns: 24px minmax(0, 1fr) 64px; + } + + .platform-usage-head { + display: none; + } + + .platform-usage-row { + grid-template-columns: 1fr; + } + + .platform-selected-bot-last-row, + .platform-settings-pager, + .platform-usage-pager, + .platform-template-header { + flex-direction: column; + align-items: stretch; + } + + .platform-compact-sheet-card { + max-height: 90dvh; + } + + .platform-compact-sheet-body { + max-height: calc(90dvh - 60px); + padding: 0 10px 12px; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 526bbf6..3ed2c41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,79 +1,97 @@ -import { useEffect, useMemo, useState, type ReactElement } from 'react'; +import { useEffect, useState, type ReactElement } from 'react'; import axios from 'axios'; -import { ChevronDown, ChevronUp, MoonStar, SunMedium, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react'; import { useAppStore } from './store/appStore'; import { useBotsSync } from './hooks/useBotsSync'; import { APP_ENDPOINTS } from './config/env'; -import { ImageFactoryModule } from './modules/images/ImageFactoryModule'; -import { BotWizardModule } from './modules/onboarding/BotWizardModule'; -import { BotDashboardModule } from './modules/dashboard/BotDashboardModule'; import { pickLocale } from './i18n'; import { appZhCn } from './i18n/app.zh-cn'; import { appEn } from './i18n/app.en'; -import { LucentIconButton } from './components/lucent/LucentIconButton'; import { LucentTooltip } from './components/lucent/LucentTooltip'; import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; +import { BotHomePage } from './modules/bot-home/BotHomePage'; +import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage'; +import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute'; import './App.css'; function getSingleBotPasswordKey(botId: string) { return `nanobot-bot-page-password:${String(botId || '').trim()}`; } -function AuthenticatedApp({ - forcedBotId, - compactMode, -}: { - forcedBotId?: string; - compactMode: boolean; -}) { +const defaultLoadingPage = { + title: 'Dashboard Nanobot', + subtitle: '平台正在准备管理面板', + description: '请稍候,正在加载 Bot 平台数据。', +}; + +function AuthenticatedApp() { + const route = useAppRoute(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); - const [showImageFactory, setShowImageFactory] = useState(false); - const [showCreateWizard, setShowCreateWizard] = useState(false); + const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); + const [viewportCompact, setViewportCompact] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia('(max-width: 980px)').matches; + }); + const [headerCollapsed, setHeaderCollapsed] = useState(false); const [singleBotPassword, setSingleBotPassword] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); const [singleBotSubmitting, setSingleBotSubmitting] = useState(false); - useBotsSync(forcedBotId); - const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); - const isCompactShell = compactMode; - const [headerCollapsed, setHeaderCollapsed] = useState(isCompactShell); - const forced = String(forcedBotId || '').trim(); - const forcedBot = forced ? activeBots[forced] : undefined; - const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked); + + const forcedBotId = route.kind === 'bot' ? route.botId : ''; + useBotsSync(forcedBotId || undefined); useEffect(() => { - if (!forced) { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(max-width: 980px)'); + const apply = () => setViewportCompact(media.matches); + apply(); + media.addEventListener('change', apply); + return () => media.removeEventListener('change', apply); + }, []); + + useEffect(() => { + setHeaderCollapsed(readCompactModeFromUrl() || viewportCompact); + }, [viewportCompact, route.kind, forcedBotId]); + + const compactMode = readCompactModeFromUrl() || viewportCompact; + const isCompactShell = compactMode; + const hideHeader = route.kind === 'dashboard' && compactMode; + const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined; + const fromPanelOpen = typeof window !== 'undefined' && (new URLSearchParams(window.location.search).get('from_panel') || '').trim() === '1'; + const shouldPromptSingleBotPassword = Boolean( + route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked && !fromPanelOpen, + ); + + useEffect(() => { + if (route.kind !== 'bot') { document.title = t.title; return; } const botName = String(forcedBot?.name || '').trim(); - document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; - }, [forced, forcedBot?.name, t.title]); - - useEffect(() => { - setHeaderCollapsed(isCompactShell); - }, [isCompactShell, forcedBotId]); + document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`; + }, [forcedBot?.name, forcedBotId, route.kind, t.title]); useEffect(() => { setSingleBotUnlocked(false); setSingleBotPassword(''); setSingleBotPasswordError(''); - }, [forced]); + }, [forcedBotId]); useEffect(() => { - if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return; - const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : ''; + if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return; + const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forcedBotId)) || '' : ''; if (!stored) return; let alive = true; const boot = async () => { try { - await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: stored }); + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored }); if (!alive) return; setSingleBotUnlocked(true); setSingleBotPassword(''); setSingleBotPasswordError(''); } catch { - if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); + if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId)); if (!alive) return; setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); } @@ -82,27 +100,26 @@ function AuthenticatedApp({ return () => { alive = false; }; - }, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]); + }, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]); const unlockSingleBot = async () => { const entered = String(singleBotPassword || '').trim(); - if (!entered) { + if (!entered || route.kind !== 'bot' || !forcedBotId) { setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.'); return; } - if (!forced) return; setSingleBotSubmitting(true); try { - await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: entered }); + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered }); if (typeof window !== 'undefined') { - window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered); + window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered); } setSingleBotPasswordError(''); setSingleBotUnlocked(true); setSingleBotPassword(''); } catch { if (typeof window !== 'undefined') { - window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); + window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId)); } setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.'); } finally { @@ -112,145 +129,90 @@ function AuthenticatedApp({ return (
-
-
{ - if (isCompactShell && headerCollapsed) setHeaderCollapsed(false); - }} - > -
-
- Nanobot -
-

{t.title}

- {isCompactShell ? ( - +
+ {!hideHeader ? ( +
{ + if (isCompactShell && headerCollapsed) setHeaderCollapsed(false); + }} + > +
+
+ Nanobot +
+

{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}

+
+ {route.kind === 'dashboard' + ? (locale === 'zh' ? '平台总览' : 'Platform overview') + : (locale === 'zh' ? 'Bot 首页' : 'Bot Home')} +
+ {isCompactShell ? ( + + ) : null} +
+
+ +
+ {!headerCollapsed ? ( +
+
+ + + + + + +
+ +
+ + + + + + +
+
) : null}
- -
- {!headerCollapsed ? ( -
-
- - - - - - -
- -
- - - - - - -
-
- ) : null} -
-
-
+ + ) : null}
- setShowCreateWizard(true)} - onOpenImageFactory={() => setShowImageFactory(true)} - forcedBotId={forcedBotId || undefined} - compactMode={compactMode} - /> + {route.kind === 'dashboard' ? ( + + ) : ( + + )}
- {!compactMode && showImageFactory && ( -
setShowImageFactory(false)}> -
e.stopPropagation()}> -
-
-

{t.nav.images.title}

-
-
- setShowImageFactory(false)} tooltip={t.close} aria-label={t.close}> - - -
-
-
- -
-
-
- )} - - {!compactMode && showCreateWizard && ( -
setShowCreateWizard(false)}> -
e.stopPropagation()}> -
-
-

{t.nav.onboarding.title}

-
-
- setShowCreateWizard(false)} tooltip={t.close} aria-label={t.close}> - - -
-
-
- { - setShowCreateWizard(false); - }} - onGoDashboard={() => setShowCreateWizard(false)} - /> -
-
-
- )} - {shouldPromptSingleBotPassword ? (
event.stopPropagation()}> Nanobot -

{forcedBot?.name || forced}

+

{forcedBot?.name || forcedBotId}

{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}

ReactElement; -}) { +function PanelLoginGate({ children }: { children: ReactElement }) { + const route = useAppRoute(); 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; - return { forcedBotId, compactMode }; - }, []); - const [viewportCompact, setViewportCompact] = useState(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; - return window.matchMedia('(max-width: 980px)').matches; - }); - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; - const media = window.matchMedia('(max-width: 980px)'); - const apply = () => setViewportCompact(media.matches); - apply(); - media.addEventListener('change', apply); - return () => media.removeEventListener('change', apply); - }, []); - const compactMode = urlView.compactMode || viewportCompact; - 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); - const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim()); + const bypassPanelGate = route.kind === 'bot'; useEffect(() => { if (bypassPanelGate) { @@ -440,11 +366,15 @@ function PanelLoginGate({ ); } - return children({ forcedBotId: urlView.forcedBotId || undefined, compactMode }); + return children; } function App() { - return {(urlView) => }; + return ( + + + + ); } export default App; diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index e139b3a..b74fe35 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -20,6 +20,7 @@ export const dashboardEn = { 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}`, + uploadTypeNotAllowed: (files: string, allowed: string) => `These files are not allowed to upload: ${files}. Allowed extensions: ${allowed}`, attachmentMessage: '[attachment message]', removeAttachment: 'Remove attachment', copyPrompt: 'Copy prompt', @@ -234,7 +235,6 @@ export const dashboardEn = { previewTitle: 'Click to preview', fileNotPreviewable: 'File type not previewable', baseConfig: 'Base Configuration', - baseConfigSub: 'Edit name, access password, and resource limits', accessPassword: 'Access Password', accessPasswordPlaceholder: 'Leave blank to disable password', botIdReadonly: 'Bot ID (Read-only)', @@ -254,7 +254,12 @@ export const dashboardEn = { agentFiles: 'Agent Files', saveFiles: 'Save Files', filePreview: 'File Preview', + editFile: 'Edit Markdown', + previewMode: 'Preview Mode', fileTruncated: 'Large file: preview is truncated.', + fileEditDisabled: 'Truncated previews cannot be edited directly.', + fileSaved: 'Markdown saved.', + fileSaveFail: 'Failed to save markdown.', download: 'Download', copyAddress: 'Copy URL', urlCopied: 'URL copied.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index b632ed5..c42e975 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -20,6 +20,7 @@ export const dashboardZhCn = { uploadFail: '文件上传失败。', attachmentAdded: (count: number) => `已添加 ${count} 个附件。`, uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB:${files}`, + uploadTypeNotAllowed: (files: string, allowed: string) => `以下文件类型不允许上传:${files}。允许的后缀:${allowed}`, attachmentMessage: '[附件消息]', removeAttachment: '移除附件', copyPrompt: '复制指令', @@ -234,7 +235,6 @@ export const dashboardZhCn = { previewTitle: '点击打开预览', fileNotPreviewable: '该文件类型不可预览', baseConfig: '基础配置', - baseConfigSub: '可修改名称、访问密码与资源配额', accessPassword: '访问密码', accessPasswordPlaceholder: '留空表示不设置访问密码', botIdReadonly: 'Bot ID(只读)', @@ -254,7 +254,12 @@ export const dashboardZhCn = { agentFiles: '代理文件配置', saveFiles: '保存文件', filePreview: '文件预览', + editFile: '编辑 Markdown', + previewMode: '预览模式', fileTruncated: '文件较大,当前内容为截断预览。', + fileEditDisabled: '当前为截断预览,不能直接编辑保存。', + fileSaved: 'Markdown 已保存。', + fileSaveFail: 'Markdown 保存失败。', download: '下载', copyAddress: '复制地址', urlCopied: '地址已复制。', diff --git a/frontend/src/modules/bot-home/BotHomePage.tsx b/frontend/src/modules/bot-home/BotHomePage.tsx new file mode 100644 index 0000000..487218c --- /dev/null +++ b/frontend/src/modules/bot-home/BotHomePage.tsx @@ -0,0 +1,10 @@ +import { BotDashboardModule } from '../dashboard/BotDashboardModule'; + +interface BotHomePageProps { + botId: string; + compactMode: boolean; +} + +export function BotHomePage({ botId, compactMode }: BotHomePageProps) { + return ; +} diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index e768f67..3cdcb9d 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -2874,6 +2874,7 @@ border: 1px solid var(--line); border-radius: 10px; overflow: hidden; + min-width: 0; min-height: 340px; display: grid; grid-template-rows: 1fr auto; @@ -2883,6 +2884,7 @@ display: flex; gap: 8px; align-items: stretch; + min-width: 0; } .workspace-path-wrap { @@ -2899,6 +2901,9 @@ align-items: center; gap: 8px; margin-left: auto; + min-width: 0; + flex: 0 1 auto; + overflow: hidden; } .workspace-refresh-icon-btn { @@ -2933,6 +2938,15 @@ font-size: 11px; font-weight: 700; user-select: none; + min-width: 0; + max-width: 100%; +} + +.workspace-auto-switch-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .workspace-auto-switch input { @@ -2991,6 +3005,7 @@ background: var(--panel); overflow: auto; padding: 8px; + min-width: 0; } .workspace-entry { @@ -3010,10 +3025,14 @@ padding-left: 8px; padding-right: 8px; margin-bottom: 6px; + min-width: 0; + max-width: 100%; + box-sizing: border-box; } .workspace-entry .workspace-entry-name { flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -3023,6 +3042,11 @@ color: var(--muted); font-size: 11px; margin-top: 1px; + flex: 0 0 auto; + max-width: 84px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .workspace-entry.dir { @@ -3087,6 +3111,17 @@ word-break: break-all; } +.workspace-entry-info-path { + min-width: 0; + word-break: normal; +} + +.workspace-entry-info-path .workspace-path-segments { + display: flex; + width: 100%; + overflow: visible; +} + .workspace-hint { background: var(--panel-soft); color: var(--muted); @@ -3098,6 +3133,8 @@ .modal-preview { width: min(1080px, 95vw); + height: min(860px, 92vh); + max-height: 92vh; } .modal-preview-fullscreen { @@ -3141,7 +3178,29 @@ min-width: 0; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; +} + +.workspace-path-segments { + min-width: 0; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + row-gap: 2px; + column-gap: 0; + overflow: hidden; +} + +.workspace-path-segment { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.workspace-path-separator { + opacity: 0.6; + flex: 0 0 auto; } .workspace-preview-copy-name { @@ -3181,14 +3240,20 @@ } .workspace-preview-body { + flex: 1 1 auto; + min-height: 0; border: 1px solid var(--line); border-radius: 10px; background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%); - min-height: 52vh; - max-height: 72vh; + height: 100%; overflow: auto; } +.workspace-preview-body.is-editing { + display: flex; + overflow: hidden; +} + .workspace-preview-body.media { display: flex; align-items: center; @@ -3218,6 +3283,28 @@ margin: 0; } +.workspace-preview-editor { + display: block; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: 100%; + max-height: none; + padding: 14px 16px; + border: 0; + border-radius: 10px; + resize: vertical; + background: transparent; + color: var(--text); + line-height: 1.68; + box-sizing: border-box; + overflow: auto; +} + +.workspace-preview-editor:focus { + outline: none; +} + .workspace-preview-image { display: block; max-width: 100%; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index d08922d..6d14c08 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -4,7 +4,7 @@ import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; -import rehypeSanitize from 'rehype-sanitize'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { APP_ENDPOINTS } from '../../config/env'; import { useAppStore } from '../../store/appStore'; import type { ChatMessage } from '../../types/bot'; @@ -34,7 +34,6 @@ type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'sl type RuntimeViewMode = 'visual' | 'topic'; type CompactPanelTab = 'chat' | 'runtime'; type QuotedReply = { id?: number; text: string; ts: number }; -const BOT_LIST_PAGE_SIZE = 8; interface WorkspaceNode { name: string; @@ -271,9 +270,11 @@ interface SystemDefaultsResponse { upload_max_mb?: number; }; workspace?: { + allowed_attachment_extensions?: unknown; download_extensions?: unknown; }; chat?: { + page_size?: number; pull_page_size?: number; }; topic_presets?: unknown; @@ -558,6 +559,19 @@ function parseWorkspaceDownloadExtensions( return rows; } +function parseAllowedAttachmentExtensions(raw: unknown): string[] { + if (raw === null || raw === undefined) return []; + if (Array.isArray(raw) && raw.length === 0) return []; + if (typeof raw === 'string' && raw.trim() === '') return []; + const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/); + const rows: string[] = []; + source.forEach((item) => { + const ext = normalizeWorkspaceExtension(item); + if (ext && !rows.includes(ext)) rows.push(ext); + }); + return rows; +} + function pathHasExtension(path: string, extensions: ReadonlySet): boolean { const normalized = String(path || '').trim().toLowerCase(); if (!normalized) return false; @@ -660,6 +674,80 @@ function parseWorkspaceLink(href: string): string | null { } } +function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] { + const path = String(pathRaw || ''); + if (!path) return ['-']; + const normalized = path.replace(/\\/g, '/'); + const hasLeadingSlash = normalized.startsWith('/'); + const parts = normalized.split('/').filter((part) => part.length > 0); + const nodes: ReactNode[] = []; + + if (hasLeadingSlash) { + nodes.push(/); + } + + parts.forEach((part, index) => { + if (index > 0) { + nodes.push(/); + } + nodes.push({part}); + }); + + return nodes.length > 0 ? nodes : ['-']; +} + +const MARKDOWN_SANITIZE_SCHEMA = { + ...defaultSchema, + tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])], + attributes: { + ...defaultSchema.attributes, + audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'], + source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'], + video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'], + }, +}; + +function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null { + const target = String(targetRaw || '').trim(); + if (!target || target.startsWith('#')) return null; + const linkedPath = parseWorkspaceLink(target); + if (linkedPath) return linkedPath; + if (target.startsWith('/root/.nanobot/workspace/')) { + return normalizeDashboardAttachmentPath(target); + } + const lower = target.toLowerCase(); + if ( + lower.startsWith('blob:') || + lower.startsWith('data:') || + lower.startsWith('http://') || + lower.startsWith('https://') || + lower.startsWith('javascript:') || + lower.startsWith('mailto:') || + lower.startsWith('tel:') || + target.startsWith('//') + ) { + return null; + } + + const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || ''); + if (!normalizedBase) { + return null; + } + + try { + const baseUrl = new URL(`https://workspace.local/${normalizedBase}`); + const resolvedUrl = new URL(target, baseUrl); + if (resolvedUrl.origin !== 'https://workspace.local') return null; + try { + return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname)); + } catch { + return normalizeDashboardAttachmentPath(resolvedUrl.pathname); + } + } catch { + return null; + } +} + function decorateWorkspacePathsInPlainChunk(source: string): string { if (!source) return source; const protectedLinks: string[] = []; @@ -970,6 +1058,9 @@ export function BotDashboardModule({ const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); const [workspacePreview, setWorkspacePreview] = useState(null); const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); + const [workspacePreviewEditing, setWorkspacePreviewEditing] = useState(false); + const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false); + const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState(''); const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false); const [workspaceQuery, setWorkspaceQuery] = useState(''); const [pendingAttachments, setPendingAttachments] = useState([]); @@ -1046,6 +1137,8 @@ export function BotDashboardModule({ sendToolHints: false, }); const [uploadMaxMb, setUploadMaxMb] = useState(100); + const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([]); + const [botListPageSize, setBotListPageSize] = useState(10); const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatHasMore, setChatHasMore] = useState(false); const [chatLoadingMore, setChatLoadingMore] = useState(false); @@ -1142,10 +1235,31 @@ export function BotDashboardModule({ const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`; return forceDownload ? `${base}?download=1` : base; }; + const buildWorkspacePreviewHref = (filePath: string) => { + const normalized = String(filePath || '').trim(); + if (!normalized) return ''; + return isHtmlPath(normalized) + ? buildWorkspaceRawHref(normalized, false) + : buildWorkspaceDownloadHref(normalized, false); + }; const closeWorkspacePreview = () => { setWorkspacePreview(null); setWorkspacePreviewFullscreen(false); + setWorkspacePreviewEditing(false); + setWorkspacePreviewSaving(false); + setWorkspacePreviewDraft(''); }; + useEffect(() => { + if (!workspacePreview) { + setWorkspacePreviewEditing(false); + setWorkspacePreviewSaving(false); + setWorkspacePreviewDraft(''); + return; + } + setWorkspacePreviewEditing(false); + setWorkspacePreviewSaving(false); + setWorkspacePreviewDraft(workspacePreview.content || ''); + }, [workspacePreview?.path, workspacePreview?.content]); const triggerWorkspaceFileDownload = (filePath: string) => { if (!selectedBotId) return; const normalized = String(filePath || '').trim(); @@ -1162,7 +1276,7 @@ export function BotDashboardModule({ const copyWorkspacePreviewUrl = async (filePath: string) => { const normalized = String(filePath || '').trim(); if (!selectedBotId || !normalized) return; - const hrefRaw = buildWorkspaceRawHref(normalized, false); + const hrefRaw = buildWorkspacePreviewHref(normalized); const href = (() => { try { return new URL(hrefRaw, window.location.origin).href; @@ -1239,28 +1353,28 @@ export function BotDashboardModule({ } } }; - const resolveWorkspaceMediaSrc = useCallback((srcRaw: string): string => { + const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => { const src = String(srcRaw || '').trim(); if (!src || !selectedBotId) return src; + const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath); + if (resolvedWorkspacePath) { + return buildWorkspacePreviewHref(resolvedWorkspacePath); + } const lower = src.toLowerCase(); if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) { - const workspacePathFromLink = parseWorkspaceLink(src); - if (workspacePathFromLink) { - return buildWorkspaceDownloadHref(workspacePathFromLink, false); - } return src; } - if (src.startsWith('/root/.nanobot/workspace/')) { - const normalized = normalizeDashboardAttachmentPath(src); - if (normalized) return buildWorkspaceDownloadHref(normalized, false); - return src; - } - const workspacePathFromLink = parseWorkspaceLink(src); - if (workspacePathFromLink) { - return buildWorkspaceDownloadHref(workspacePathFromLink, false); - } return src; }, [selectedBotId]); + const transformWorkspacePreviewMarkdownUrl = (url: string, key: string): string => { + if (!workspacePreview?.isMarkdown || !selectedBotId) return url; + const resolvedWorkspacePath = resolveWorkspaceDocumentPath(url, workspacePreview.path); + if (!resolvedWorkspacePath) return url; + if (key === 'href') { + return buildWorkspaceLink(resolvedWorkspacePath); + } + return buildWorkspacePreviewHref(resolvedWorkspacePath); + }; const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { const source = String(text || ''); if (!source) return [source]; @@ -1448,7 +1562,6 @@ export function BotDashboardModule({ [activeBots], ); const hasForcedBot = Boolean(String(forcedBotId || '').trim()); - const singleBotHomeMode = hasForcedBot; const compactListFirstMode = compactMode && !hasForcedBot; const isCompactListPage = compactListFirstMode && !selectedBotId; const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); @@ -1462,12 +1575,12 @@ export function BotDashboardModule({ return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); }); }, [bots, normalizedBotListQuery]); - const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE)); + const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize)); const pagedBots = useMemo(() => { const page = Math.min(Math.max(1, botListPage), botListTotalPages); - const start = (page - 1) * BOT_LIST_PAGE_SIZE; - return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE); - }, [filteredBots, botListPage, botListTotalPages]); + const start = (page - 1) * botListPageSize; + return filteredBots.slice(start, start + botListPageSize); + }, [filteredBots, botListPage, botListTotalPages, botListPageSize]); const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); const messages = selectedBot?.messages || []; @@ -1771,7 +1884,7 @@ export function BotDashboardModule({ ) : ( {decorateWorkspacePathsForMarkdown(displayText)} @@ -2040,6 +2153,13 @@ export function BotDashboardModule({ if (Number.isFinite(configured) && configured > 0) { setUploadMaxMb(Math.max(1, Math.floor(configured))); } + const configuredPageSize = Number(res.data?.chat?.page_size); + if (Number.isFinite(configuredPageSize) && configuredPageSize > 0) { + setBotListPageSize(Math.max(1, Math.min(100, Math.floor(configuredPageSize)))); + } + setAllowedAttachmentExtensions( + parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions), + ); setWorkspaceDownloadExtensions( parseWorkspaceDownloadExtensions( res.data?.workspace?.download_extensions, @@ -2285,6 +2405,39 @@ export function BotDashboardModule({ setWorkspaceFileLoading(false); } }; + const saveWorkspacePreviewMarkdown = async () => { + if (!selectedBotId || !workspacePreview?.isMarkdown) return; + if (workspacePreview.truncated) { + notify(t.fileEditDisabled, { tone: 'warning' }); + return; + } + setWorkspacePreviewSaving(true); + try { + const res = await axios.put( + `${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, + { content: workspacePreviewDraft }, + { params: { path: workspacePreview.path } }, + ); + const filePath = res.data.path || workspacePreview.path; + const textExt = (filePath.split('.').pop() || '').toLowerCase(); + const content = res.data.content || workspacePreviewDraft; + setWorkspacePreview({ + ...workspacePreview, + path: filePath, + content, + truncated: false, + ext: textExt ? `.${textExt}` : '', + isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown), + }); + setWorkspacePreviewEditing(false); + notify(t.fileSaved, { tone: 'success' }); + void loadWorkspaceTree(selectedBotId, workspaceCurrentPath); + } catch (error: any) { + notify(error?.response?.data?.detail || t.fileSaveFail, { tone: 'error' }); + } finally { + setWorkspacePreviewSaving(false); + } + }; const loadWorkspaceTree = async (botId: string, path: string = '') => { if (!botId) return; @@ -3099,11 +3252,16 @@ export function BotDashboardModule({ }; const updateChannelLocal = (index: number, patch: Partial) => { - setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c))); + setChannels((prev) => + prev.map((c, i) => { + if (i !== index || c.locked) return c; + return { ...c, ...patch }; + }), + ); }; const saveChannel = async (channel: BotChannel) => { - if (!selectedBot) return; + if (!selectedBot || channel.locked || isDashboardChannel(channel)) return; setIsSavingChannel(true); try { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, { @@ -3150,7 +3308,7 @@ export function BotDashboardModule({ }; const removeChannel = async (channel: BotChannel) => { - if (!selectedBot || channel.channel_type === 'dashboard') return; + if (!selectedBot || channel.locked || channel.channel_type === 'dashboard') return; const ok = await confirm({ title: t.channels, message: t.channelDeleteConfirm(channel.channel_type), @@ -4138,11 +4296,44 @@ export function BotDashboardModule({ const onPickAttachments = async (event: ChangeEvent) => { if (!selectedBot || !event.target.files || event.target.files.length === 0) return; const files = Array.from(event.target.files); - const maxBytes = uploadMaxMb * 1024 * 1024; + let effectiveUploadMaxMb = uploadMaxMb; + let effectiveAllowedAttachmentExtensions = [...allowedAttachmentExtensions]; + try { + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/system/defaults`); + const latestUploadMaxMb = Number(res.data?.limits?.upload_max_mb); + if (Number.isFinite(latestUploadMaxMb) && latestUploadMaxMb > 0) { + effectiveUploadMaxMb = Math.max(1, Math.floor(latestUploadMaxMb)); + setUploadMaxMb(effectiveUploadMaxMb); + } + const latestAllowedAttachmentExtensions = parseAllowedAttachmentExtensions( + res.data?.workspace?.allowed_attachment_extensions, + ); + effectiveAllowedAttachmentExtensions = latestAllowedAttachmentExtensions; + setAllowedAttachmentExtensions(latestAllowedAttachmentExtensions); + } catch { + // Fall back to the most recently loaded defaults in memory. + } + + const effectiveAllowedAttachmentExtensionSet = new Set(effectiveAllowedAttachmentExtensions); + if (effectiveAllowedAttachmentExtensionSet.size > 0) { + const disallowed = files.filter((file) => { + const name = String(file.name || '').trim().toLowerCase(); + const dot = name.lastIndexOf('.'); + const ext = dot >= 0 ? name.slice(dot) : ''; + return !ext || !effectiveAllowedAttachmentExtensionSet.has(ext); + }); + if (disallowed.length > 0) { + const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', '); + notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' }); + event.target.value = ''; + return; + } + } + const maxBytes = effectiveUploadMaxMb * 1024 * 1024; const tooLarge = files.filter((f) => Number(f.size) > maxBytes); if (tooLarge.length > 0) { const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', '); - notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' }); + notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' }); event.target.value = ''; return; } @@ -4887,7 +5078,7 @@ export function BotDashboardModule({
setBotListPage((p) => Math.max(1, p - 1))} disabled={botListPage <= 1} tooltip={t.paginationPrev} @@ -4895,9 +5086,9 @@ export function BotDashboardModule({ > -
{t.paginationPage(botListPage, botListTotalPages)}
+
{t.paginationPage(botListPage, botListTotalPages)}
setBotListPage((p) => Math.min(botListTotalPages, p + 1))} disabled={botListPage >= botListTotalPages} tooltip={t.paginationNext} @@ -5083,6 +5274,7 @@ export function BotDashboardModule({ ref={filePickerRef} type="file" multiple + accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined} onChange={onPickAttachments} style={{ display: 'none' }} /> @@ -5127,7 +5319,7 @@ export function BotDashboardModule({ ) : null} - - - - - - - - - - ) : null} + <> + + + + + + + + + +