v0.1.4-p3

main
mula.liu 2026-03-18 03:52:50 +08:00
parent 3a151d7e8f
commit 20c70d2e69
27 changed files with 5617 additions and 588 deletions

View File

@ -24,28 +24,27 @@ REDIS_ENABLED=false
REDIS_URL= REDIS_URL=
REDIS_PREFIX=dashboard_nanobot REDIS_PREFIX=dashboard_nanobot
REDIS_DEFAULT_TTL=60 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. # Optional panel-level access password for all backend API/WS calls.
PANEL_ACCESS_PASSWORD= PANEL_ACCESS_PASSWORD=
# Max upload size for backend validation (MB) # The following platform-level items are now managed in sys_setting / 平台参数:
UPLOAD_MAX_MB=100 # - page_size
# Workspace files that should use direct download behavior in dashboard # - chat_pull_page_size
# Comma/space/semicolon separated, e.g. ".pdf,.docx,.xlsx,.zip" # - upload_max_mb
WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps # - 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) # Local speech-to-text (Whisper via whisper.cpp model file)
STT_ENABLED=true
STT_MODEL=ggml-small-q8_0.bin STT_MODEL=ggml-small-q8_0.bin
STT_MODEL_DIR=../data/model STT_MODEL_DIR=../data/model
STT_DEVICE=cpu 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`) # Local backend server options (for `python3 main.py`)
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0

View File

@ -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}

View File

@ -93,6 +93,23 @@ class BotConfigManager:
if tools_cfg: if tools_cfg:
config_data["tools"] = 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: for channel in channels:
channel_type = (channel.get("channel_type") or "").strip() channel_type = (channel.get("channel_type") or "").strip()
if not channel_type: if not channel_type:

View File

@ -13,6 +13,7 @@ from core.settings import (
# Ensure table models are registered in SQLModel metadata before create_all. # Ensure table models are registered in SQLModel metadata before create_all.
from models import bot as _bot_models # noqa: F401 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 from models import topic as _topic_models # noqa: F401
_engine_kwargs = { _engine_kwargs = {
@ -33,6 +34,232 @@ else:
engine = create_engine(DATABASE_URL, **_engine_kwargs) 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: def _ensure_botinstance_columns() -> None:
dialect = engine.dialect.name dialect = engine.dialect.name
@ -65,24 +292,24 @@ def _ensure_botinstance_columns() -> None:
} }
inspector = inspect(engine) inspector = inspect(engine)
if not inspector.has_table("botinstance"): if not inspector.has_table(BOT_INSTANCE_TABLE):
return return
with engine.connect() as conn: with engine.connect() as conn:
existing = { existing = {
str(row.get("name")) 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") if row.get("name")
} }
for col, ddl_map in required_columns.items(): for col, ddl_map in required_columns.items():
if col in existing: if col in existing:
continue continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") 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 "enabled" in existing:
if dialect == "sqlite": 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: 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() conn.commit()
@ -113,7 +340,7 @@ def _drop_legacy_botinstance_columns() -> None:
with engine.connect() as conn: with engine.connect() as conn:
existing = { existing = {
str(col.get("name")) 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") if col.get("name")
} }
for col in legacy_columns: for col in legacy_columns:
@ -121,11 +348,11 @@ def _drop_legacy_botinstance_columns() -> None:
continue continue
try: try:
if engine.dialect.name == "mysql": 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": 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: 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: except Exception:
# Keep startup resilient on mixed/legacy database engines. # Keep startup resilient on mixed/legacy database engines.
continue continue
@ -141,12 +368,12 @@ def _ensure_botmessage_columns() -> None:
"feedback_at": "DATETIME", "feedback_at": "DATETIME",
} }
with engine.connect() as conn: 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} existing = {str(row[1]) for row in existing_rows}
for col, ddl in required_columns.items(): for col, ddl in required_columns.items():
if col in existing: if col in existing:
continue 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() conn.commit()
@ -158,24 +385,97 @@ def _drop_legacy_skill_tables() -> None:
conn.commit() 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: def _ensure_topic_tables_sqlite() -> None:
if engine.dialect.name != "sqlite": if engine.dialect.name != "sqlite":
return return
with engine.connect() as conn: 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( conn.execute(
text( text(
""" """
@ -191,7 +491,7 @@ def _ensure_topic_tables_sqlite() -> None:
view_schema_json TEXT NOT NULL DEFAULT '{}', view_schema_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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, dedupe_key TEXT,
is_read INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 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_bot_id ON topic_topic(bot_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)"))
@ -237,23 +536,6 @@ def _ensure_topic_tables_sqlite() -> None:
def _ensure_topic_columns() -> None: def _ensure_topic_columns() -> None:
dialect = engine.dialect.name dialect = engine.dialect.name
required_columns = { 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": { "topic_topic": {
"name": { "name": {
"sqlite": "TEXT NOT NULL DEFAULT ''", "sqlite": "TEXT NOT NULL DEFAULT ''",
@ -360,7 +642,6 @@ def _ensure_topic_columns() -> None:
def _ensure_topic_indexes() -> None: def _ensure_topic_indexes() -> None:
required_indexes = [ 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), ("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_bot_id", "topic_topic", ["bot_id"], False),
("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False), ("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False),
@ -397,6 +678,17 @@ def _ensure_topic_indexes() -> None:
conn.commit() 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: def _cleanup_legacy_default_topics() -> None:
""" """
Remove legacy auto-created fallback topic rows from early topic-feed design. 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": if engine.dialect.name != "postgresql":
return return
sequence_targets = [ sequence_targets = [
("botmessage", "id"), (BOT_MESSAGE_TABLE, "id"),
(BOT_REQUEST_USAGE_TABLE, "id"),
(BOT_ACTIVITY_EVENT_TABLE, "id"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table_name, column_name in sequence_targets: for table_name, column_name in sequence_targets:
@ -478,15 +772,27 @@ def align_postgres_sequences() -> None:
def init_database() -> None: def init_database() -> None:
lock_conn = _acquire_migration_lock()
try:
_rename_legacy_tables()
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_migrate_legacy_table_rows()
_drop_legacy_skill_tables() _drop_legacy_skill_tables()
_ensure_sys_setting_columns()
_ensure_bot_request_usage_columns()
_ensure_botinstance_columns() _ensure_botinstance_columns()
_drop_legacy_botinstance_columns() _drop_legacy_botinstance_columns()
_ensure_botmessage_columns() _ensure_botmessage_columns()
_ensure_topic_tables_sqlite() _ensure_topic_tables_sqlite()
_repair_postgres_topic_foreign_keys()
_ensure_topic_columns() _ensure_topic_columns()
_ensure_topic_indexes()
_drop_obsolete_topic_tables()
_cleanup_legacy_default_topics() _cleanup_legacy_default_topics()
_drop_legacy_tables()
align_postgres_sequences() align_postgres_sequences()
finally:
_release_migration_lock(lock_conn)
def get_session(): def get_session():

View File

@ -619,6 +619,10 @@ class BotDockerManager:
media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()] 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_progress = bool(data.get("is_progress", False))
is_tool = bool(data.get("is_tool", 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": if event_type == "AGENT_STATE":
payload = data.get("payload") or {} payload = data.get("payload") or {}
@ -628,11 +632,21 @@ class BotDockerManager:
"type": "AGENT_STATE", "type": "AGENT_STATE",
"channel": "dashboard", "channel": "dashboard",
"payload": {"state": state, "action_msg": action_msg}, "payload": {"state": state, "action_msg": action_msg},
"request_id": request_id,
} }
if event_type == "ASSISTANT_MESSAGE": if event_type == "ASSISTANT_MESSAGE":
if content or media: 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 return None
if event_type == "BUS_EVENT" or is_progress: if event_type == "BUS_EVENT" or is_progress:
@ -643,6 +657,10 @@ class BotDockerManager:
"media": media, "media": media,
"is_progress": is_progress, "is_progress": is_progress,
"is_tool": is_tool, "is_tool": is_tool,
"usage": usage,
"request_id": request_id,
"provider": provider,
"model": model,
} }
if content or media: if content or media:
@ -651,6 +669,10 @@ class BotDockerManager:
"channel": "dashboard", "channel": "dashboard",
"text": content, "text": content,
"media": media, "media": media,
"usage": usage,
"request_id": request_id,
"provider": provider,
"model": model,
} }
except Exception: except Exception:
return None return None

View File

@ -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_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_TIMEOUT: Final[int] = _env_int("DATABASE_POOL_TIMEOUT", 30, 1, 300)
DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30, 86400) 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) DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = ( DEFAULT_PAGE_SIZE: Final[int] = 10
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = (
".pdf", ".pdf",
".doc", ".doc",
".docx", ".docx",
@ -193,11 +195,7 @@ WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = (
".odp", ".odp",
".wps", ".wps",
) )
WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = _env_extensions( STT_ENABLED_DEFAULT: Final[bool] = True
"WORKSPACE_DOWNLOAD_EXTENSIONS",
WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT,
)
STT_ENABLED: Final[bool] = _env_bool("STT_ENABLED", True)
STT_MODEL: Final[str] = str(os.getenv("STT_MODEL") or "ggml-small-q8_0.bin").strip() 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() _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))) _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: else:
STT_MODEL_DIR: Final[str] = _configured_stt_model_dir 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_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) DEFAULT_STT_MAX_AUDIO_SECONDS: Final[int] = 20
STT_DEFAULT_LANGUAGE: Final[str] = str(os.getenv("STT_DEFAULT_LANGUAGE") or "zh").strip().lower() or "zh" DEFAULT_STT_DEFAULT_LANGUAGE: Final[str] = "zh"
STT_FORCE_SIMPLIFIED: Final[bool] = _env_bool("STT_FORCE_SIMPLIFIED", True) DEFAULT_STT_FORCE_SIMPLIFIED: Final[bool] = True
STT_AUDIO_PREPROCESS: Final[bool] = _env_bool("STT_AUDIO_PREPROCESS", True) DEFAULT_STT_AUDIO_PREPROCESS: Final[bool] = True
STT_AUDIO_FILTER: Final[str] = str( DEFAULT_STT_AUDIO_FILTER: Final[str] = "highpass=f=120,lowpass=f=7600,afftdn=nf=-20"
os.getenv("STT_AUDIO_FILTER") or "highpass=f=120,lowpass=f=7600,afftdn=nf=-20" DEFAULT_STT_INITIAL_PROMPT: Final[str] = (
).strip() "以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。"
STT_INITIAL_PROMPT: Final[str] = str( )
os.getenv("STT_INITIAL_PROMPT")
or "以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。"
).strip()
REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False) REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False)
REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip() 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_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) 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() PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()

View File

@ -8,17 +8,8 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from core.settings import ( from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR
STT_AUDIO_FILTER, from services.platform_service import get_speech_runtime_settings
STT_AUDIO_PREPROCESS,
STT_DEVICE,
STT_ENABLED,
STT_FORCE_SIMPLIFIED,
STT_INITIAL_PROMPT,
STT_MAX_AUDIO_SECONDS,
STT_MODEL,
STT_MODEL_DIR,
)
class SpeechServiceError(RuntimeError): class SpeechServiceError(RuntimeError):
@ -78,6 +69,12 @@ class WhisperSpeechService:
) )
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: def _load_model(self) -> Any:
model_source = self._resolve_model_source() model_source = self._resolve_model_source()
if self._model is not None and self._model_source == model_source: if self._model is not None and self._model_source == model_source:
@ -103,8 +100,9 @@ class WhisperSpeechService:
@staticmethod @staticmethod
def _preprocess_audio(file_path: str) -> str: def _preprocess_audio(file_path: str) -> str:
settings = get_speech_runtime_settings()
target = str(file_path or "").strip() 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 return target
if shutil.which("ffmpeg") is None: if shutil.which("ffmpeg") is None:
return target return target
@ -122,7 +120,7 @@ class WhisperSpeechService:
"-ar", "-ar",
"16000", "16000",
] ]
audio_filter = str(STT_AUDIO_FILTER or "").strip() audio_filter = str(settings["audio_filter"] or "").strip()
if audio_filter: if audio_filter:
cmd.extend(["-af", audio_filter]) cmd.extend(["-af", audio_filter])
cmd.extend(["-c:a", "pcm_s16le", tmp_path]) cmd.extend(["-c:a", "pcm_s16le", tmp_path])
@ -163,8 +161,9 @@ class WhisperSpeechService:
@staticmethod @staticmethod
def _normalize_text(text: str) -> str: def _normalize_text(text: str) -> str:
settings = get_speech_runtime_settings()
content = str(text or "").strip() content = str(text or "").strip()
if not content or not STT_FORCE_SIMPLIFIED: if not content or not settings["force_simplified"]:
return content return content
try: try:
from opencc_purepy import OpenCC # type: ignore 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} 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]: 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") raise SpeechDisabledError("Speech-to-text is disabled")
target = str(file_path or "").strip() target = str(file_path or "").strip()
if not target or not os.path.isfile(target): if not target or not os.path.isfile(target):
raise SpeechServiceError("Audio file not found") raise SpeechServiceError("Audio file not found")
duration_seconds = self._probe_audio_duration_seconds(target) duration_seconds = self._probe_audio_duration_seconds(target)
if duration_seconds is not None and duration_seconds > float(STT_MAX_AUDIO_SECONDS) + 0.3: if duration_seconds is not None and duration_seconds > float(settings["max_audio_seconds"]) + 0.3:
raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds") raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds")
prepared_target = self._preprocess_audio(target) prepared_target = self._preprocess_audio(target)
try: try:
@ -211,7 +211,7 @@ class WhisperSpeechService:
} }
if normalized_lang: if normalized_lang:
kwargs["language"] = 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: if initial_prompt:
kwargs["initial_prompt"] = initial_prompt kwargs["initial_prompt"] = initial_prompt
kwargs = self._filter_supported_transcribe_kwargs(model, kwargs) 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) max_end = max(max_end, float(getattr(segment, "t1", 0.0) or 0.0) / 100.0)
except Exception: except Exception:
pass pass
if max_end > float(STT_MAX_AUDIO_SECONDS) + 0.3: if max_end > float(settings["max_audio_seconds"]) + 0.3:
raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds") raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds")
text = self._normalize_text(" ".join(texts).strip()) text = self._normalize_text(" ".join(texts).strip())
if not text: if not text:
@ -246,7 +246,7 @@ class WhisperSpeechService:
"text": text, "text": text,
"language": detected_language or None, "language": detected_language or None,
"duration_seconds": duration_seconds, "duration_seconds": duration_seconds,
"max_audio_seconds": STT_MAX_AUDIO_SECONDS, "max_audio_seconds": settings["max_audio_seconds"],
"model": STT_MODEL, "model": STT_MODEL,
"device": STT_DEVICE, "device": STT_DEVICE,
"backend": self._backend or "unknown", "backend": self._backend or "unknown",

View File

@ -3,15 +3,6 @@
BEGIN TRANSACTION; 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 ( CREATE TABLE IF NOT EXISTS topic_topic (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL, bot_id TEXT NOT NULL,
@ -24,7 +15,7 @@ CREATE TABLE IF NOT EXISTS topic_topic (
view_schema_json TEXT NOT NULL DEFAULT '{}', view_schema_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 ( CREATE TABLE IF NOT EXISTS topic_item (
@ -40,12 +31,9 @@ CREATE TABLE IF NOT EXISTS topic_item (
dedupe_key TEXT, dedupe_key TEXT,
is_read INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key
ON topic_topic(bot_id, topic_key); ON topic_topic(bot_id, topic_key);

View File

@ -9,12 +9,12 @@ import tempfile
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import unquote from urllib.parse import quote, unquote
import httpx import httpx
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect 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 fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select from sqlmodel import Session, select
@ -30,7 +30,6 @@ from core.speech_service import (
) )
from core.settings import ( from core.settings import (
BOTS_WORKSPACE_ROOT, BOTS_WORKSPACE_ROOT,
CHAT_PULL_PAGE_SIZE,
DATA_ROOT, DATA_ROOT,
DATABASE_ECHO, DATABASE_ECHO,
DATABASE_ENGINE, DATABASE_ENGINE,
@ -46,22 +45,28 @@ from core.settings import (
REDIS_ENABLED, REDIS_ENABLED,
REDIS_PREFIX, REDIS_PREFIX,
REDIS_URL, REDIS_URL,
STT_DEVICE,
STT_DEFAULT_LANGUAGE,
STT_ENABLED,
STT_MAX_AUDIO_SECONDS,
STT_MODEL,
TOPIC_PRESET_TEMPLATES, TOPIC_PRESET_TEMPLATES,
TOPIC_PRESETS_TEMPLATES_FILE, TOPIC_PRESETS_TEMPLATES_FILE,
UPLOAD_MAX_MB,
WORKSPACE_DOWNLOAD_EXTENSIONS,
load_agent_md_templates, load_agent_md_templates,
load_topic_presets_template, load_topic_presets_template,
) )
from models.bot import BotInstance, BotMessage, NanobotImage 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 api.topic_router import router as topic_router
from services.topic_runtime import publish_runtime_topic_packet 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") app = FastAPI(title="Dashboard Nanobot API")
logger = logging.getLogger("dashboard.backend") logger = logging.getLogger("dashboard.backend")
@ -73,6 +78,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(topic_router) app.include_router(topic_router)
app.include_router(platform_router)
os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True) os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True)
os.makedirs(DATA_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) docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT) config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
speech_service = WhisperSpeechService() 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_]+$") BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
@ -182,6 +190,10 @@ class MessageFeedbackRequest(BaseModel):
feedback: Optional[str] = None # up | down | null feedback: Optional[str] = None # up | down | null
class WorkspaceFileUpdateRequest(BaseModel):
content: str
class PanelLoginRequest(BaseModel): class PanelLoginRequest(BaseModel):
password: Optional[str] = None 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.add(message_row)
session.flush() session.flush()
persisted_message_id = message_row.id 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": elif packet_type == "USER_COMMAND":
text_msg = str(packet.get("text") or "").strip() text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id) 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.add(message_row)
session.flush() session.flush()
persisted_message_id = message_row.id 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": elif packet_type == "BUS_EVENT":
# Dashboard channel emits BUS_EVENT for both progress and final replies. # Dashboard channel emits BUS_EVENT for both progress and final replies.
# Persist only non-progress events to keep durable chat history clean. # Persist only non-progress events to keep durable chat history clean.
is_progress = bool(packet.get("is_progress")) is_progress = bool(packet.get("is_progress"))
detail_text = str(packet.get("content") or packet.get("text") or "").strip()
if not is_progress: 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) media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list: if text_msg or media_list:
bot.current_state = "IDLE" 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.add(message_row)
session.flush() session.flush()
persisted_message_id = message_row.id 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() bot.updated_at = datetime.utcnow()
session.add(bot) session.add(bot)
@ -528,6 +567,9 @@ async def on_startup():
init_database() init_database()
cache.delete_prefix("") cache.delete_prefix("")
with Session(engine) as session: 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(): for bot in session.exec(select(BotInstance)).all():
_migrate_bot_resources_store(bot.id) _migrate_bot_resources_store(bot.id)
running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all() 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(): def get_system_defaults():
md_templates = load_agent_md_templates() md_templates = load_agent_md_templates()
topic_presets = load_topic_presets_template() topic_presets = load_topic_presets_template()
platform_settings = get_platform_settings_snapshot()
speech_settings = get_speech_runtime_settings()
return { return {
"templates": { "templates": {
"soul_md": md_templates.get("soul_md") or DEFAULT_SOUL_MD, "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, "identity_md": md_templates.get("identity_md") or DEFAULT_IDENTITY_MD,
}, },
"limits": { "limits": {
"upload_max_mb": UPLOAD_MAX_MB, "upload_max_mb": platform_settings.upload_max_mb,
}, },
"workspace": { "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": { "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, "topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES,
"speech": { "speech": {
"enabled": STT_ENABLED, "enabled": speech_settings["enabled"],
"model": STT_MODEL, "model": speech_settings["model"],
"device": STT_DEVICE, "device": speech_settings["device"],
"max_audio_seconds": STT_MAX_AUDIO_SECONDS, "max_audio_seconds": speech_settings["max_audio_seconds"],
"default_language": STT_DEFAULT_LANGUAGE, "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) 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") @app.get("/api/system/templates")
def get_system_templates(): def get_system_templates():
return { return {
@ -1912,6 +1967,15 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
}, },
) )
session.refresh(bot) 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) _invalidate_bot_detail_cache(normalized_bot_id)
return _serialize_bot(bot) 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.", detail="Bot container failed shortly after startup. Check bot logs/config.",
) )
session.add(bot) session.add(bot)
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return {"status": "started"} return {"status": "started"}
@ -2165,6 +2230,7 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)):
docker_manager.stop_bot(bot_id) docker_manager.stop_bot(bot_id)
bot.docker_status = "STOPPED" bot.docker_status = "STOPPED"
session.add(bot) session.add(bot)
record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}")
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return {"status": "stopped"} return {"status": "stopped"}
@ -2178,6 +2244,7 @@ def enable_bot(bot_id: str, session: Session = Depends(get_session)):
bot.enabled = True bot.enabled = True
session.add(bot) session.add(bot)
record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled")
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return {"status": "enabled", "enabled": True} 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"}: if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE" bot.current_state = "IDLE"
session.add(bot) session.add(bot)
record_activity_event(session, bot_id, "bot_disabled", channel="system", detail=f"Bot {bot_id} disabled")
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return {"status": "disabled", "enabled": False} 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"}: if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE" bot.current_state = "IDLE"
session.add(bot) session.add(bot)
record_activity_event(session, bot_id, "bot_deactivated", channel="system", detail=f"Bot {bot_id} deactivated")
session.commit() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
return {"status": "deactivated"} 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() topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all()
for row in topics: for row in topics:
session.delete(row) session.delete(row)
topic_settings = session.exec(select(TopicBotSettings).where(TopicBotSettings.bot_id == bot_id)).all() usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all()
for row in topic_settings: 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(row)
session.delete(bot) 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"): if not filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported") 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 tmp_zip_path: Optional[str] = None
total_size = 0 total_size = 0
try: 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) row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row: if not row:
raise HTTPException(status_code=404, detail="Channel not found") 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) update_data = payload.model_dump(exclude_unset=True)
existing_type = str(row.get("channel_type") or "").strip().lower() 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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") raise HTTPException(status_code=404, detail="Bot not found")
runtime_snapshot = _read_bot_runtime_snapshot(bot)
attachments = _normalize_media_list(payload.attachments, bot_id) attachments = _normalize_media_list(payload.attachments, bot_id)
command = str(payload.command or "").strip() 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." "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 outbound_user_packet: Optional[Dict[str, Any]] = None
if display_command or checked_attachments: if display_command or checked_attachments:
outbound_user_packet = { outbound_user_packet = {
@ -2713,6 +2812,7 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend
"channel": "dashboard", "channel": "dashboard",
"text": display_command, "text": display_command,
"media": checked_attachments, "media": checked_attachments,
"request_id": request_id,
} }
_persist_runtime_packet(bot_id, outbound_user_packet) _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) success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media)
if not success: if not success:
detail = docker_manager.get_last_delivery_error(bot_id) 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(): if loop and loop.is_running():
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
manager.broadcast( 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") @app.get("/api/bots/{bot_id}/messages/page")
def list_bot_messages_page( def list_bot_messages_page(
bot_id: str, bot_id: str,
limit: int = CHAT_PULL_PAGE_SIZE, limit: Optional[int] = None,
before_id: Optional[int] = None, before_id: Optional[int] = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
@ -2792,7 +2902,8 @@ def list_bot_messages_page(
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") raise HTTPException(status_code=404, detail="Bot not found")
safe_limit = max(1, min(int(limit), 500)) 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 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) cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
cached = cache.get_json(cache_key) 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.current_state = "IDLE"
bot.updated_at = datetime.utcnow() bot.updated_at = datetime.utcnow()
session.add(bot) 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() session.commit()
_invalidate_bot_detail_cache(bot_id) _invalidate_bot_detail_cache(bot_id)
_invalidate_bot_messages_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 body = raw[:safe_max] if truncated else raw
text_body = body.decode("utf-8", errors="replace") text_body = body.decode("utf-8", errors="replace")
rel_path = os.path.relpath(target, root).replace("\\", "/") rel_path = os.path.relpath(target, root).replace("\\", "/")
is_markdown = ext in {".md", ".markdown"}
return { return {
"bot_id": bot_id, "bot_id": bot_id,
"path": rel_path, "path": rel_path,
"size": os.path.getsize(target), "size": os.path.getsize(target),
"is_markdown": rel_path.lower().endswith(".md"), "is_markdown": is_markdown,
"truncated": truncated, "truncated": truncated,
"content": text_body, "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): def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024):
with open(target, "rb") as fh: with open(target, "rb") as fh:
fh.seek(start) 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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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") raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(target) 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", "") range_header = request.headers.get("range", "")
if range_header and not download: if range_header and not download:
return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header) 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, request: Request = None,
session: Session = Depends(get_session), 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") @app.get("/public/bots/{bot_id}/workspace/download")
@ -3155,7 +3342,15 @@ def public_download_workspace_file(
request: Request = None, request: Request = None,
session: Session = Depends(get_session), 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}") @app.get("/api/bots/{bot_id}/workspace/raw/{path:path}")
@ -3166,7 +3361,15 @@ def raw_workspace_file(
request: Request = None, request: Request = None,
session: Session = Depends(get_session), 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}") @app.get("/public/bots/{bot_id}/workspace/raw/{path:path}")
@ -3177,7 +3380,15 @@ def public_raw_workspace_file(
request: Request = None, request: Request = None,
session: Session = Depends(get_session), 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") @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") raise HTTPException(status_code=404, detail="Bot not found")
if not files: if not files:
raise HTTPException(status_code=400, detail="no files uploaded") 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") root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads")
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
@ -3207,6 +3420,12 @@ async def upload_workspace_files(
name = re.sub(r"[^\w.\-()+@ ]+", "_", name) name = re.sub(r"[^\w.\-()+@ ]+", "_", name)
if not name: if not name:
name = "upload.bin" 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) abs_path = os.path.join(safe_dir_real, name)
if os.path.exists(abs_path): if os.path.exists(abs_path):
@ -3262,7 +3481,8 @@ async def transcribe_bot_speech(
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") 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") raise HTTPException(status_code=400, detail="Speech recognition is disabled")
if not file: if not file:
raise HTTPException(status_code=400, detail="no audio file uploaded") 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: 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") 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) result = await asyncio.to_thread(speech_service.transcribe_file, tmp_path, resolved_language)
text = str(result.get("text") or "").strip() text = str(result.get("text") or "").strip()
if not text: if not text:
@ -3295,9 +3515,9 @@ async def transcribe_bot_speech(
"bot_id": bot_id, "bot_id": bot_id,
"text": text, "text": text,
"duration_seconds": result.get("duration_seconds"), "duration_seconds": result.get("duration_seconds"),
"max_audio_seconds": STT_MAX_AUDIO_SECONDS, "max_audio_seconds": speech_settings["max_audio_seconds"],
"model": STT_MODEL, "model": speech_settings["model"],
"device": STT_DEVICE, "device": speech_settings["device"],
"language": result.get("language") or resolved_language, "language": result.get("language") or resolved_language,
} }
except SpeechDisabledError as exc: except SpeechDisabledError as exc:
@ -3315,9 +3535,9 @@ async def transcribe_bot_speech(
bot_id, bot_id,
safe_name, safe_name,
language, 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: except SpeechServiceError as exc:
logger.exception( logger.exception(
"speech transcribe failed bot_id=%s file=%s language=%s", "speech transcribe failed bot_id=%s file=%s language=%s",

View File

@ -3,6 +3,8 @@ from sqlmodel import Field, SQLModel
from datetime import datetime from datetime import datetime
class BotInstance(SQLModel, table=True): class BotInstance(SQLModel, table=True):
__tablename__ = "bot_instance"
id: str = Field(primary_key=True) id: str = Field(primary_key=True)
name: str name: str
enabled: bool = Field(default=True, index=True) enabled: bool = Field(default=True, index=True)
@ -16,8 +18,10 @@ class BotInstance(SQLModel, table=True):
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)
class BotMessage(SQLModel, table=True): class BotMessage(SQLModel, table=True):
__tablename__ = "bot_message"
id: Optional[int] = Field(default=None, primary_key=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)
role: str = Field(index=True) # user | assistant | system role: str = Field(index=True) # user | assistant | system
text: str text: str
media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths 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) created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class NanobotImage(SQLModel, table=True): class NanobotImage(SQLModel, table=True):
__tablename__ = "bot_image"
tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4 tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4
image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID
version: str # e.g., 0.1.4 version: str # e.g., 0.1.4

View File

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

View File

@ -5,19 +5,6 @@ from sqlalchemy import Index, UniqueConstraint
from sqlmodel import Field, SQLModel 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): class TopicTopic(SQLModel, table=True):
__tablename__ = "topic_topic" __tablename__ = "topic_topic"
__table_args__ = ( __table_args__ = (
@ -26,7 +13,7 @@ class TopicTopic(SQLModel, table=True):
) )
id: Optional[int] = Field(default=None, primary_key=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) topic_key: str = Field(index=True)
name: str = Field(default="") name: str = Field(default="")
description: 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) 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) topic_key: str = Field(index=True)
title: str = Field(default="") title: str = Field(default="")
content: str = Field(default="") content: str = Field(default="")

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,13 @@
from __future__ import annotations import json
import asyncio from types import SimpleNamespace
from typing import Any
from aiohttp import web from aiohttp import web
from loguru import logger 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.channels.base import BaseChannel
from nanobot.bus.events import InboundMessage, OutboundMessage
class DashboardChannel(BaseChannel): class DashboardChannel(BaseChannel):
""" """
@ -14,11 +15,42 @@ class DashboardChannel(BaseChannel):
它充当机器人内部总线 (Bus) 与宿主机面板之间的桥梁 它充当机器人内部总线 (Bus) 与宿主机面板之间的桥梁
""" """
def __init__(self, config: Any, bus: Any, host: str = "0.0.0.0", port: int = 9000): name = "dashboard"
super().__init__(config, bus) display_name = "Dashboard"
self.host = host
self.port = port @classmethod
self.runner = None 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: async def start(self) -> None:
"""启动 Dashboard HTTP 服务""" """启动 Dashboard HTTP 服务"""
@ -30,14 +62,15 @@ class DashboardChannel(BaseChannel):
site = web.TCPSite(self.runner, self.host, self.port) site = web.TCPSite(self.runner, self.host, self.port)
await site.start() await site.start()
self._is_running = True self._running = True
logger.info(f"🚀 Dashboard Channel 代理已上线,监听端口: {self.port}") logger.info(f"🚀 Dashboard Channel 代理已上线,监听端口: {self.port}")
async def stop(self) -> None: async def stop(self) -> None:
"""停止服务""" """停止服务"""
if self.runner: if self.runner:
await self.runner.cleanup() await self.runner.cleanup()
self._is_running = False self.runner = None
self._running = False
logger.info("Dashboard Channel 已下线") logger.info("Dashboard Channel 已下线")
async def send(self, message: OutboundMessage) -> None: async def send(self, message: OutboundMessage) -> None:
@ -49,8 +82,9 @@ class DashboardChannel(BaseChannel):
return return
# 核心:从元数据识别消息类型(进度更新 vs 最终回复) # 核心:从元数据识别消息类型(进度更新 vs 最终回复)
is_progress = message.metadata.get("_progress", False) metadata = message.metadata or {}
is_tool_hint = message.metadata.get("_tool_hint", False) is_progress = metadata.get("_progress", False)
is_tool_hint = metadata.get("_tool_hint", False)
payload = { payload = {
"type": "BUS_EVENT", "type": "BUS_EVENT",
@ -60,6 +94,18 @@ class DashboardChannel(BaseChannel):
"content": message.content, "content": message.content,
"media": media, "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 格式输出,方便面板后端精准解析,告别正则 # 使用 JSON 格式输出,方便面板后端精准解析,告别正则
print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True) 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 个字符,确保中文未乱码 # 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码
logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...") logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...")
# 将消息塞入总线 # 统一走基类入口,兼容不同核心的会话与权限逻辑。
await self.bus.publish_inbound(InboundMessage( await self._handle_message(
channel="dashboard",
sender_id="user", sender_id="user",
chat_id="direct", chat_id="direct",
content=user_message, content=user_message,
media=media, media=media,
)) )
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
except Exception as e: except Exception as e:

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +1,97 @@
import { useEffect, useMemo, useState, type ReactElement } from 'react'; import { useEffect, useState, type ReactElement } from 'react';
import axios from 'axios'; 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 { useAppStore } from './store/appStore';
import { useBotsSync } from './hooks/useBotsSync'; import { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env'; 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 { pickLocale } from './i18n';
import { appZhCn } from './i18n/app.zh-cn'; import { appZhCn } from './i18n/app.zh-cn';
import { appEn } from './i18n/app.en'; import { appEn } from './i18n/app.en';
import { LucentIconButton } from './components/lucent/LucentIconButton';
import { LucentTooltip } from './components/lucent/LucentTooltip'; import { LucentTooltip } from './components/lucent/LucentTooltip';
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; 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'; import './App.css';
function getSingleBotPasswordKey(botId: string) { function getSingleBotPasswordKey(botId: string) {
return `nanobot-bot-page-password:${String(botId || '').trim()}`; return `nanobot-bot-page-password:${String(botId || '').trim()}`;
} }
function AuthenticatedApp({ const defaultLoadingPage = {
forcedBotId, title: 'Dashboard Nanobot',
compactMode, subtitle: '平台正在准备管理面板',
}: { description: '请稍候,正在加载 Bot 平台数据。',
forcedBotId?: string; };
compactMode: boolean;
}) { function AuthenticatedApp() {
const route = useAppRoute();
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const [showImageFactory, setShowImageFactory] = useState(false); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const [showCreateWizard, setShowCreateWizard] = useState(false); 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 [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false); const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const forcedBotId = route.kind === 'bot' ? route.botId : '';
const isCompactShell = compactMode; useBotsSync(forcedBotId || undefined);
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);
useEffect(() => { 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; document.title = t.title;
return; return;
} }
const botName = String(forcedBot?.name || '').trim(); const botName = String(forcedBot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
}, [forced, forcedBot?.name, t.title]); }, [forcedBot?.name, forcedBotId, route.kind, t.title]);
useEffect(() => {
setHeaderCollapsed(isCompactShell);
}, [isCompactShell, forcedBotId]);
useEffect(() => { useEffect(() => {
setSingleBotUnlocked(false); setSingleBotUnlocked(false);
setSingleBotPassword(''); setSingleBotPassword('');
setSingleBotPasswordError(''); setSingleBotPasswordError('');
}, [forced]); }, [forcedBotId]);
useEffect(() => { useEffect(() => {
if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return; if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : ''; const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forcedBotId)) || '' : '';
if (!stored) return; if (!stored) return;
let alive = true; let alive = true;
const boot = async () => { const boot = async () => {
try { 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; if (!alive) return;
setSingleBotUnlocked(true); setSingleBotUnlocked(true);
setSingleBotPassword(''); setSingleBotPassword('');
setSingleBotPasswordError(''); setSingleBotPasswordError('');
} catch { } catch {
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
if (!alive) return; if (!alive) return;
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
} }
@ -82,27 +100,26 @@ function AuthenticatedApp({
return () => { return () => {
alive = false; alive = false;
}; };
}, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]); }, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
const unlockSingleBot = async () => { const unlockSingleBot = async () => {
const entered = String(singleBotPassword || '').trim(); const entered = String(singleBotPassword || '').trim();
if (!entered) { if (!entered || route.kind !== 'bot' || !forcedBotId) {
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.'); setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
return; return;
} }
if (!forced) return;
setSingleBotSubmitting(true); setSingleBotSubmitting(true);
try { 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') { if (typeof window !== 'undefined') {
window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered); window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered);
} }
setSingleBotPasswordError(''); setSingleBotPasswordError('');
setSingleBotUnlocked(true); setSingleBotUnlocked(true);
setSingleBotPassword(''); setSingleBotPassword('');
} catch { } catch {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(getSingleBotPasswordKey(forced)); window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
} }
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.'); setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
} finally { } finally {
@ -112,7 +129,8 @@ function AuthenticatedApp({
return ( return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}> <div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className="app-frame"> <div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
{!hideHeader ? (
<header <header
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`} className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
onClick={() => { onClick={() => {
@ -123,14 +141,19 @@ function AuthenticatedApp({
<div className="app-title"> <div className="app-title">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" /> <img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main"> <div className="app-title-main">
<h1>{t.title}</h1> <h1>{route.kind === 'dashboard' ? t.title : (forcedBot?.name || defaultLoadingPage.title)}</h1>
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? (locale === 'zh' ? '平台总览' : 'Platform overview')
: (locale === 'zh' ? 'Bot 首页' : 'Bot Home')}
</div>
{isCompactShell ? ( {isCompactShell ? (
<button <button
type="button" type="button"
className="app-header-toggle-inline" className="app-header-toggle-inline"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setHeaderCollapsed((v) => !v); setHeaderCollapsed((value) => !value);
}} }}
title={headerCollapsed ? t.expandHeader : t.collapseHeader} title={headerCollapsed ? t.expandHeader : t.collapseHeader}
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader} aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
@ -146,20 +169,12 @@ function AuthenticatedApp({
<div className="global-switches"> <div className="global-switches">
<div className="switch-compact"> <div className="switch-compact">
<LucentTooltip content={t.dark}> <LucentTooltip content={t.dark}>
<button <button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
className={`switch-btn ${theme === 'dark' ? 'active' : ''}`}
onClick={() => setTheme('dark')}
aria-label={t.dark}
>
<MoonStar size={14} /> <MoonStar size={14} />
</button> </button>
</LucentTooltip> </LucentTooltip>
<LucentTooltip content={t.light}> <LucentTooltip content={t.light}>
<button <button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
className={`switch-btn ${theme === 'light' ? 'active' : ''}`}
onClick={() => setTheme('light')}
aria-label={t.light}
>
<SunMedium size={14} /> <SunMedium size={14} />
</button> </button>
</LucentTooltip> </LucentTooltip>
@ -167,20 +182,12 @@ function AuthenticatedApp({
<div className="switch-compact"> <div className="switch-compact">
<LucentTooltip content={t.zh}> <LucentTooltip content={t.zh}>
<button <button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`}
onClick={() => setLocale('zh')}
aria-label={t.zh}
>
<span>ZH</span> <span>ZH</span>
</button> </button>
</LucentTooltip> </LucentTooltip>
<LucentTooltip content={t.en}> <LucentTooltip content={t.en}>
<button <button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`}
onClick={() => setLocale('en')}
aria-label={t.en}
>
<span>EN</span> <span>EN</span>
</button> </button>
</LucentTooltip> </LucentTooltip>
@ -190,67 +197,22 @@ function AuthenticatedApp({
</div> </div>
</div> </div>
</header> </header>
) : null}
<main className="main-stage"> <main className="main-stage">
<BotDashboardModule {route.kind === 'dashboard' ? (
onOpenCreateWizard={() => setShowCreateWizard(true)} <PlatformDashboardPage compactMode={compactMode} />
onOpenImageFactory={() => setShowImageFactory(true)} ) : (
forcedBotId={forcedBotId || undefined} <BotHomePage botId={forcedBotId} compactMode={compactMode} />
compactMode={compactMode} )}
/>
</main> </main>
</div> </div>
{!compactMode && showImageFactory && (
<div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.nav.images.title}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowImageFactory(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="app-modal-body">
<ImageFactoryModule />
</div>
</div>
</div>
)}
{!compactMode && showCreateWizard && (
<div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.nav.onboarding.title}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCreateWizard(false)} tooltip={t.close} aria-label={t.close}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="app-modal-body">
<BotWizardModule
onCreated={() => {
setShowCreateWizard(false);
}}
onGoDashboard={() => setShowCreateWizard(false)}
/>
</div>
</div>
</div>
)}
{shouldPromptSingleBotPassword ? ( {shouldPromptSingleBotPassword ? (
<div className="modal-mask app-modal-mask"> <div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}> <div className="app-login-card" onClick={(event) => event.stopPropagation()}>
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" /> <img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{forcedBot?.name || forced}</h1> <h1>{forcedBot?.name || forcedBotId}</h1>
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p> <p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
<div className="app-login-form"> <div className="app-login-form">
<input <input
@ -279,53 +241,17 @@ function AuthenticatedApp({
); );
} }
function PanelLoginGate({ function PanelLoginGate({ children }: { children: ReactElement }) {
children, const route = useAppRoute();
}: {
children: (props: { forcedBotId?: string; compactMode: boolean }) => ReactElement;
}) {
const { theme, locale } = useAppStore(); const { theme, locale } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const urlView = useMemo(() => {
const params = new URLSearchParams(window.location.search);
const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i);
let forcedBotIdFromPath = '';
if (pathMatch?.[1]) {
try {
forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim();
} catch {
forcedBotIdFromPath = String(pathMatch[1]).trim();
}
}
const forcedBotIdFromQuery =
(params.get('botId') || params.get('bot_id') || params.get('id') || '').trim();
const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery;
const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase();
const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw);
const compactMode = compactByFlag;
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 [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false); const [required, setRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim()); const bypassPanelGate = route.kind === 'bot';
useEffect(() => { useEffect(() => {
if (bypassPanelGate) { if (bypassPanelGate) {
@ -440,11 +366,15 @@ function PanelLoginGate({
); );
} }
return children({ forcedBotId: urlView.forcedBotId || undefined, compactMode }); return children;
} }
function App() { function App() {
return <PanelLoginGate>{(urlView) => <AuthenticatedApp {...urlView} />}</PanelLoginGate>; return (
<PanelLoginGate>
<AuthenticatedApp />
</PanelLoginGate>
);
} }
export default App; export default App;

View File

@ -20,6 +20,7 @@ export const dashboardEn = {
uploadFail: 'File upload failed.', uploadFail: 'File upload failed.',
attachmentAdded: (count: number) => `${count} attachment(s) added.`, attachmentAdded: (count: number) => `${count} attachment(s) added.`,
uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`, uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`,
uploadTypeNotAllowed: (files: string, allowed: string) => `These files are not allowed to upload: ${files}. Allowed extensions: ${allowed}`,
attachmentMessage: '[attachment message]', attachmentMessage: '[attachment message]',
removeAttachment: 'Remove attachment', removeAttachment: 'Remove attachment',
copyPrompt: 'Copy prompt', copyPrompt: 'Copy prompt',
@ -234,7 +235,6 @@ export const dashboardEn = {
previewTitle: 'Click to preview', previewTitle: 'Click to preview',
fileNotPreviewable: 'File type not previewable', fileNotPreviewable: 'File type not previewable',
baseConfig: 'Base Configuration', baseConfig: 'Base Configuration',
baseConfigSub: 'Edit name, access password, and resource limits',
accessPassword: 'Access Password', accessPassword: 'Access Password',
accessPasswordPlaceholder: 'Leave blank to disable password', accessPasswordPlaceholder: 'Leave blank to disable password',
botIdReadonly: 'Bot ID (Read-only)', botIdReadonly: 'Bot ID (Read-only)',
@ -254,7 +254,12 @@ export const dashboardEn = {
agentFiles: 'Agent Files', agentFiles: 'Agent Files',
saveFiles: 'Save Files', saveFiles: 'Save Files',
filePreview: 'File Preview', filePreview: 'File Preview',
editFile: 'Edit Markdown',
previewMode: 'Preview Mode',
fileTruncated: 'Large file: preview is truncated.', fileTruncated: 'Large file: preview is truncated.',
fileEditDisabled: 'Truncated previews cannot be edited directly.',
fileSaved: 'Markdown saved.',
fileSaveFail: 'Failed to save markdown.',
download: 'Download', download: 'Download',
copyAddress: 'Copy URL', copyAddress: 'Copy URL',
urlCopied: 'URL copied.', urlCopied: 'URL copied.',

View File

@ -20,6 +20,7 @@ export const dashboardZhCn = {
uploadFail: '文件上传失败。', uploadFail: '文件上传失败。',
attachmentAdded: (count: number) => `已添加 ${count} 个附件。`, attachmentAdded: (count: number) => `已添加 ${count} 个附件。`,
uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`, uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB${files}`,
uploadTypeNotAllowed: (files: string, allowed: string) => `以下文件类型不允许上传:${files}。允许的后缀:${allowed}`,
attachmentMessage: '[附件消息]', attachmentMessage: '[附件消息]',
removeAttachment: '移除附件', removeAttachment: '移除附件',
copyPrompt: '复制指令', copyPrompt: '复制指令',
@ -234,7 +235,6 @@ export const dashboardZhCn = {
previewTitle: '点击打开预览', previewTitle: '点击打开预览',
fileNotPreviewable: '该文件类型不可预览', fileNotPreviewable: '该文件类型不可预览',
baseConfig: '基础配置', baseConfig: '基础配置',
baseConfigSub: '可修改名称、访问密码与资源配额',
accessPassword: '访问密码', accessPassword: '访问密码',
accessPasswordPlaceholder: '留空表示不设置访问密码', accessPasswordPlaceholder: '留空表示不设置访问密码',
botIdReadonly: 'Bot ID只读', botIdReadonly: 'Bot ID只读',
@ -254,7 +254,12 @@ export const dashboardZhCn = {
agentFiles: '代理文件配置', agentFiles: '代理文件配置',
saveFiles: '保存文件', saveFiles: '保存文件',
filePreview: '文件预览', filePreview: '文件预览',
editFile: '编辑 Markdown',
previewMode: '预览模式',
fileTruncated: '文件较大,当前内容为截断预览。', fileTruncated: '文件较大,当前内容为截断预览。',
fileEditDisabled: '当前为截断预览,不能直接编辑保存。',
fileSaved: 'Markdown 已保存。',
fileSaveFail: 'Markdown 保存失败。',
download: '下载', download: '下载',
copyAddress: '复制地址', copyAddress: '复制地址',
urlCopied: '地址已复制。', urlCopied: '地址已复制。',

View File

@ -0,0 +1,10 @@
import { BotDashboardModule } from '../dashboard/BotDashboardModule';
interface BotHomePageProps {
botId: string;
compactMode: boolean;
}
export function BotHomePage({ botId, compactMode }: BotHomePageProps) {
return <BotDashboardModule forcedBotId={botId} compactMode={compactMode} />;
}

View File

@ -2874,6 +2874,7 @@
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
min-width: 0;
min-height: 340px; min-height: 340px;
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
@ -2883,6 +2884,7 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
min-width: 0;
} }
.workspace-path-wrap { .workspace-path-wrap {
@ -2899,6 +2901,9 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-left: auto; margin-left: auto;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
} }
.workspace-refresh-icon-btn { .workspace-refresh-icon-btn {
@ -2933,6 +2938,15 @@
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
user-select: none; 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 { .workspace-auto-switch input {
@ -2991,6 +3005,7 @@
background: var(--panel); background: var(--panel);
overflow: auto; overflow: auto;
padding: 8px; padding: 8px;
min-width: 0;
} }
.workspace-entry { .workspace-entry {
@ -3010,10 +3025,14 @@
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
margin-bottom: 6px; margin-bottom: 6px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
} }
.workspace-entry .workspace-entry-name { .workspace-entry .workspace-entry-name {
flex: 1; flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -3023,6 +3042,11 @@
color: var(--muted); color: var(--muted);
font-size: 11px; font-size: 11px;
margin-top: 1px; margin-top: 1px;
flex: 0 0 auto;
max-width: 84px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.workspace-entry.dir { .workspace-entry.dir {
@ -3087,6 +3111,17 @@
word-break: break-all; 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 { .workspace-hint {
background: var(--panel-soft); background: var(--panel-soft);
color: var(--muted); color: var(--muted);
@ -3098,6 +3133,8 @@
.modal-preview { .modal-preview {
width: min(1080px, 95vw); width: min(1080px, 95vw);
height: min(860px, 92vh);
max-height: 92vh;
} }
.modal-preview-fullscreen { .modal-preview-fullscreen {
@ -3141,7 +3178,29 @@
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { .workspace-preview-copy-name {
@ -3181,14 +3240,20 @@
} }
.workspace-preview-body { .workspace-preview-body {
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%); background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
min-height: 52vh; height: 100%;
max-height: 72vh;
overflow: auto; overflow: auto;
} }
.workspace-preview-body.is-editing {
display: flex;
overflow: hidden;
}
.workspace-preview-body.media { .workspace-preview-body.media {
display: flex; display: flex;
align-items: center; align-items: center;
@ -3218,6 +3283,28 @@
margin: 0; 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 { .workspace-preview-image {
display: block; display: block;
max-width: 100%; max-width: 100%;

View File

@ -4,7 +4,7 @@ import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { APP_ENDPOINTS } from '../../config/env'; import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore'; import { useAppStore } from '../../store/appStore';
import type { ChatMessage } from '../../types/bot'; import type { ChatMessage } from '../../types/bot';
@ -34,7 +34,6 @@ type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'sl
type RuntimeViewMode = 'visual' | 'topic'; type RuntimeViewMode = 'visual' | 'topic';
type CompactPanelTab = 'chat' | 'runtime'; type CompactPanelTab = 'chat' | 'runtime';
type QuotedReply = { id?: number; text: string; ts: number }; type QuotedReply = { id?: number; text: string; ts: number };
const BOT_LIST_PAGE_SIZE = 8;
interface WorkspaceNode { interface WorkspaceNode {
name: string; name: string;
@ -271,9 +270,11 @@ interface SystemDefaultsResponse {
upload_max_mb?: number; upload_max_mb?: number;
}; };
workspace?: { workspace?: {
allowed_attachment_extensions?: unknown;
download_extensions?: unknown; download_extensions?: unknown;
}; };
chat?: { chat?: {
page_size?: number;
pull_page_size?: number; pull_page_size?: number;
}; };
topic_presets?: unknown; topic_presets?: unknown;
@ -558,6 +559,19 @@ function parseWorkspaceDownloadExtensions(
return rows; 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<string>): boolean { function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
const normalized = String(path || '').trim().toLowerCase(); const normalized = String(path || '').trim().toLowerCase();
if (!normalized) return false; 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(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
}
parts.forEach((part, index) => {
if (index > 0) {
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
}
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
});
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 { function decorateWorkspacePathsInPlainChunk(source: string): string {
if (!source) return source; if (!source) return source;
const protectedLinks: string[] = []; const protectedLinks: string[] = [];
@ -970,6 +1058,9 @@ export function BotDashboardModule({
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false); const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null); const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false); 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 [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
const [workspaceQuery, setWorkspaceQuery] = useState(''); const [workspaceQuery, setWorkspaceQuery] = useState('');
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]); const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
@ -1046,6 +1137,8 @@ export function BotDashboardModule({
sendToolHints: false, sendToolHints: false,
}); });
const [uploadMaxMb, setUploadMaxMb] = useState(100); const [uploadMaxMb, setUploadMaxMb] = useState(100);
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
const [botListPageSize, setBotListPageSize] = useState(10);
const [chatPullPageSize, setChatPullPageSize] = useState(60); const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [chatHasMore, setChatHasMore] = useState(false); const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = useState(false); const [chatLoadingMore, setChatLoadingMore] = useState(false);
@ -1142,10 +1235,31 @@ export function BotDashboardModule({
const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`; const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`;
return forceDownload ? `${base}?download=1` : base; 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 = () => { const closeWorkspacePreview = () => {
setWorkspacePreview(null); setWorkspacePreview(null);
setWorkspacePreviewFullscreen(false); 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) => { const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return; if (!selectedBotId) return;
const normalized = String(filePath || '').trim(); const normalized = String(filePath || '').trim();
@ -1162,7 +1276,7 @@ export function BotDashboardModule({
const copyWorkspacePreviewUrl = async (filePath: string) => { const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim(); const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return; if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspaceRawHref(normalized, false); const hrefRaw = buildWorkspacePreviewHref(normalized);
const href = (() => { const href = (() => {
try { try {
return new URL(hrefRaw, window.location.origin).href; 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(); const src = String(srcRaw || '').trim();
if (!src || !selectedBotId) return src; if (!src || !selectedBotId) return src;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
if (resolvedWorkspacePath) {
return buildWorkspacePreviewHref(resolvedWorkspacePath);
}
const lower = src.toLowerCase(); const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) { 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; 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; return src;
}, [selectedBotId]); }, [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 renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || ''); const source = String(text || '');
if (!source) return [source]; if (!source) return [source];
@ -1448,7 +1562,6 @@ export function BotDashboardModule({
[activeBots], [activeBots],
); );
const hasForcedBot = Boolean(String(forcedBotId || '').trim()); const hasForcedBot = Boolean(String(forcedBotId || '').trim());
const singleBotHomeMode = hasForcedBot;
const compactListFirstMode = compactMode && !hasForcedBot; const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !selectedBotId; const isCompactListPage = compactListFirstMode && !selectedBotId;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
@ -1462,12 +1575,12 @@ export function BotDashboardModule({
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery); return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
}); });
}, [bots, 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 pagedBots = useMemo(() => {
const page = Math.min(Math.max(1, botListPage), botListTotalPages); const page = Math.min(Math.max(1, botListPage), botListTotalPages);
const start = (page - 1) * BOT_LIST_PAGE_SIZE; const start = (page - 1) * botListPageSize;
return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE); return filteredBots.slice(start, start + botListPageSize);
}, [filteredBots, botListPage, botListTotalPages]); }, [filteredBots, botListPage, botListTotalPages, botListPageSize]);
const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined; const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]); const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
const messages = selectedBot?.messages || []; const messages = selectedBot?.messages || [];
@ -1771,7 +1884,7 @@ export function BotDashboardModule({
) : ( ) : (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents} components={markdownComponents}
> >
{decorateWorkspacePathsForMarkdown(displayText)} {decorateWorkspacePathsForMarkdown(displayText)}
@ -2040,6 +2153,13 @@ export function BotDashboardModule({
if (Number.isFinite(configured) && configured > 0) { if (Number.isFinite(configured) && configured > 0) {
setUploadMaxMb(Math.max(1, Math.floor(configured))); 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( setWorkspaceDownloadExtensions(
parseWorkspaceDownloadExtensions( parseWorkspaceDownloadExtensions(
res.data?.workspace?.download_extensions, res.data?.workspace?.download_extensions,
@ -2285,6 +2405,39 @@ export function BotDashboardModule({
setWorkspaceFileLoading(false); 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<WorkspaceFileResponse>(
`${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 = '') => { const loadWorkspaceTree = async (botId: string, path: string = '') => {
if (!botId) return; if (!botId) return;
@ -3099,11 +3252,16 @@ export function BotDashboardModule({
}; };
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => { const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
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) => { const saveChannel = async (channel: BotChannel) => {
if (!selectedBot) return; if (!selectedBot || channel.locked || isDashboardChannel(channel)) return;
setIsSavingChannel(true); setIsSavingChannel(true);
try { try {
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, { await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
@ -3150,7 +3308,7 @@ export function BotDashboardModule({
}; };
const removeChannel = async (channel: BotChannel) => { 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({ const ok = await confirm({
title: t.channels, title: t.channels,
message: t.channelDeleteConfirm(channel.channel_type), message: t.channelDeleteConfirm(channel.channel_type),
@ -4138,11 +4296,44 @@ export function BotDashboardModule({
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => { const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
if (!selectedBot || !event.target.files || event.target.files.length === 0) return; if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
const maxBytes = uploadMaxMb * 1024 * 1024; let effectiveUploadMaxMb = uploadMaxMb;
let effectiveAllowedAttachmentExtensions = [...allowedAttachmentExtensions];
try {
const res = await axios.get<SystemDefaultsResponse>(`${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); const tooLarge = files.filter((f) => Number(f.size) > maxBytes);
if (tooLarge.length > 0) { if (tooLarge.length > 0) {
const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', '); 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 = ''; event.target.value = '';
return; return;
} }
@ -4887,7 +5078,7 @@ export function BotDashboardModule({
</div> </div>
<div className="ops-bot-list-pagination"> <div className="ops-bot-list-pagination">
<LucentIconButton <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => setBotListPage((p) => Math.max(1, p - 1))} onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
disabled={botListPage <= 1} disabled={botListPage <= 1}
tooltip={t.paginationPrev} tooltip={t.paginationPrev}
@ -4895,9 +5086,9 @@ export function BotDashboardModule({
> >
<ChevronLeft size={14} /> <ChevronLeft size={14} />
</LucentIconButton> </LucentIconButton>
<div className="ops-bot-list-page-indicator">{t.paginationPage(botListPage, botListTotalPages)}</div> <div className="ops-bot-list-page-indicator pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))} onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
disabled={botListPage >= botListTotalPages} disabled={botListPage >= botListTotalPages}
tooltip={t.paginationNext} tooltip={t.paginationNext}
@ -5083,6 +5274,7 @@ export function BotDashboardModule({
ref={filePickerRef} ref={filePickerRef}
type="file" type="file"
multiple multiple
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments} onChange={onPickAttachments}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
@ -5127,7 +5319,7 @@ export function BotDashboardModule({
) : null} ) : null}
<button <button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`} className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || isVoiceTranscribing || (!speechEnabled && !isVoiceRecording)} disabled={!canChat || isVoiceTranscribing}
onClick={onVoiceInput} onClick={onVoiceInput}
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart} aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
title={ title={
@ -5215,7 +5407,6 @@ export function BotDashboardModule({
<div className="row-between ops-runtime-head"> <div className="row-between ops-runtime-head">
<h2 style={{ fontSize: 18 }}>{t.runtime}</h2> <h2 style={{ fontSize: 18 }}>{t.runtime}</h2>
<div className="ops-panel-tools" ref={runtimeMenuRef}> <div className="ops-panel-tools" ref={runtimeMenuRef}>
{!singleBotHomeMode ? (
<LucentIconButton <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn"
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)} onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
@ -5225,7 +5416,6 @@ export function BotDashboardModule({
> >
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} /> <RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton> </LucentIconButton>
) : null}
<LucentIconButton <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)} onClick={() => setRuntimeMenuOpen((v) => !v)}
@ -5239,7 +5429,6 @@ export function BotDashboardModule({
</LucentIconButton> </LucentIconButton>
{runtimeMenuOpen ? ( {runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}> <div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
{!singleBotHomeMode ? (
<> <>
<button <button
className="ops-more-item" className="ops-more-item"
@ -5362,7 +5551,6 @@ export function BotDashboardModule({
<span>{t.agent}</span> <span>{t.agent}</span>
</button> </button>
</> </>
) : null}
<button <button
className="ops-more-item" className="ops-more-item"
role="menuitem" role="menuitem"
@ -5620,7 +5808,6 @@ export function BotDashboardModule({
<div className="modal-title-row modal-title-with-close"> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main"> <div className="modal-title-main">
<h3>{t.baseConfig}</h3> <h3>{t.baseConfig}</h3>
<span className="modal-sub">{t.baseConfigSub}</span>
</div> </div>
<div className="modal-title-actions"> <div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} tooltip={t.close} aria-label={t.close}> <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} tooltip={t.close} aria-label={t.close}>
@ -7010,7 +7197,9 @@ export function BotDashboardModule({
<div className="workspace-preview-header-text"> <div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3> <h3>{t.filePreview}</h3>
<span className="modal-sub mono workspace-preview-path-row"> <span className="modal-sub mono workspace-preview-path-row">
<span>{workspacePreview.path}</span> <span className="workspace-path-segments" title={workspacePreview.path}>
{renderWorkspacePathSegments(workspacePreview.path, 'preview-path')}
</span>
<LucentIconButton <LucentIconButton
className="workspace-preview-copy-name" className="workspace-preview-copy-name"
onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)} onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)}
@ -7022,6 +7211,22 @@ export function BotDashboardModule({
</span> </span>
</div> </div>
<div className="workspace-preview-header-actions"> <div className="workspace-preview-header-actions">
{workspacePreview.isMarkdown ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewEditing((value) => !value);
}}
tooltip={workspacePreviewEditing ? t.previewMode : t.editFile}
aria-label={workspacePreviewEditing ? t.previewMode : t.editFile}
>
{workspacePreviewEditing ? <Eye size={14} /> : <Pencil size={14} />}
</LucentIconButton>
) : null}
<LucentIconButton <LucentIconButton
className="btn btn-secondary btn-sm icon-btn" className="btn btn-secondary btn-sm icon-btn"
onClick={() => setWorkspacePreviewFullscreen((v) => !v)} onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
@ -7040,7 +7245,9 @@ export function BotDashboardModule({
</LucentIconButton> </LucentIconButton>
</div> </div>
</div> </div>
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}> <div
className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreviewEditing ? 'is-editing' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}
>
{workspacePreview.isImage ? ( {workspacePreview.isImage ? (
<img <img
className="workspace-preview-image" className="workspace-preview-image"
@ -7067,12 +7274,20 @@ export function BotDashboardModule({
src={buildWorkspaceRawHref(workspacePreview.path, false)} src={buildWorkspaceRawHref(workspacePreview.path, false)}
title={workspacePreview.path} title={workspacePreview.path}
/> />
) : workspacePreview.isMarkdown && workspacePreviewEditing ? (
<textarea
className="textarea md-area mono workspace-preview-editor"
value={workspacePreviewDraft}
onChange={(event) => setWorkspacePreviewDraft(event.target.value)}
spellCheck={false}
/>
) : workspacePreview.isMarkdown ? ( ) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown"> <div className="workspace-markdown">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents} components={markdownComponents}
urlTransform={transformWorkspacePreviewMarkdownUrl}
> >
{workspacePreview.content} {workspacePreview.content}
</ReactMarkdown> </ReactMarkdown>
@ -7087,6 +7302,28 @@ export function BotDashboardModule({
<div className="row-between"> <div className="row-between">
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span> <span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{workspacePreview.isMarkdown && workspacePreviewEditing ? (
<>
<button
className="btn btn-secondary"
onClick={() => {
setWorkspacePreviewDraft(workspacePreview.content || '');
setWorkspacePreviewEditing(false);
}}
disabled={workspacePreviewSaving}
>
{t.cancel}
</button>
<button
className="btn btn-primary"
onClick={() => void saveWorkspacePreviewMarkdown()}
disabled={workspacePreviewSaving}
>
{workspacePreviewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</>
) : null}
{workspacePreview.isHtml ? ( {workspacePreview.isHtml ? (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
@ -7122,8 +7359,14 @@ export function BotDashboardModule({
</div> </div>
<div className="workspace-entry-info-row"> <div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span> <span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
<span className="workspace-entry-info-value mono"> <span
{`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`} className="workspace-entry-info-value workspace-entry-info-path mono"
title={`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
>
{renderWorkspacePathSegments(
`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`,
'hover-path',
)}
</span> </span>
</div> </div>
<div className="workspace-entry-info-row"> <div className="workspace-entry-info-row">

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,409 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import type { PlatformSettings, SystemSettingItem } from '../types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
interface PlatformSettingsModalProps {
isZh: boolean;
open: boolean;
onClose: () => void;
onSaved: (settings: PlatformSettings) => void;
}
interface SystemSettingsResponse {
items: SystemSettingItem[];
}
interface SettingDraft {
key: string;
name: string;
category: string;
description: string;
value_type: string;
value: string;
is_public: boolean;
sort_order: string;
}
const emptyDraft: SettingDraft = {
key: '',
name: '',
category: 'general',
description: '',
value_type: 'string',
value: '',
is_public: false,
sort_order: '100',
};
function displayValue(item: SystemSettingItem) {
if (item.value_type === 'json') return JSON.stringify(item.value);
if (item.value_type === 'boolean') return String(Boolean(item.value));
return String(item.value ?? '');
}
function toDraft(item?: SystemSettingItem): SettingDraft {
if (!item) return emptyDraft;
return {
key: item.key,
name: item.name,
category: item.category,
description: item.description,
value_type: item.value_type,
value: item.value_type === 'json' ? JSON.stringify(item.value, null, 2) : String(item.value ?? ''),
is_public: item.is_public,
sort_order: String(item.sort_order ?? 100),
};
}
function parseValue(draft: SettingDraft) {
if (draft.value_type === 'integer') return Number.parseInt(draft.value || '0', 10) || 0;
if (draft.value_type === 'float') return Number.parseFloat(draft.value || '0') || 0;
if (draft.value_type === 'boolean') return ['1', 'true', 'yes', 'on'].includes(draft.value.trim().toLowerCase());
if (draft.value_type === 'json') return JSON.parse(draft.value || 'null');
return draft.value;
}
function normalizePageSize(value: unknown, fallback = 10) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.max(1, Math.min(100, Math.floor(parsed)));
}
export function PlatformSettingsModal({
isZh,
open,
onClose,
onSaved,
}: PlatformSettingsModalProps) {
const { notify, confirm } = useLucentPrompt();
const [items, setItems] = useState<SystemSettingItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState('');
const [showEditor, setShowEditor] = useState(false);
const [editingKey, setEditingKey] = useState('');
const [draft, setDraft] = useState<SettingDraft>(emptyDraft);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const loadRows = async () => {
setLoading(true);
try {
const res = await axios.get<SystemSettingsResponse>(`${APP_ENDPOINTS.apiBase}/platform/system-settings`);
setItems(Array.isArray(res.data?.items) ? res.data.items : []);
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取系统参数失败。' : 'Failed to load system settings.'), { tone: 'error' });
} finally {
setLoading(false);
}
};
const refreshSnapshot = async () => {
try {
const res = await axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
setPageSize(normalizePageSize(res.data?.page_size, 10));
onSaved(res.data);
} catch {
// Ignore snapshot refresh failures here; the table is still the source of truth in the modal.
}
};
useEffect(() => {
if (!open) return;
setPage(1);
void (async () => {
await Promise.allSettled([loadRows(), refreshSnapshot()]);
})();
}, [open]);
useEffect(() => {
setPage(1);
}, [search]);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.key, item.name, item.category, item.description].some((value) => String(value || '').toLowerCase().includes(keyword)),
);
}, [items, search]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / pageSize));
if (page > maxPage) setPage(maxPage);
}, [filtered.length, page, pageSize]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
const pagedItems = useMemo(() => filtered.slice((page - 1) * pageSize, page * pageSize), [filtered, page, pageSize]);
if (!open) return null;
return (
<>
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide platform-modal platform-settings-shell" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '平台参数' : 'Platform Settings'}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="platform-settings-info-card">
<div className="platform-settings-info-icon">i</div>
<div>
<strong>{isZh ? '参数生效说明' : 'Runtime Apply Notes'}</strong>
<div>
{isZh
? '参数修改后会立即同步到后端运行时,无需重启平台。'
: 'Parameter changes are applied to the backend runtime immediately, without restarting the platform.'}
</div>
</div>
</div>
<div className="platform-settings-toolbar">
<div className="ops-searchbar platform-searchbar platform-settings-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
aria-label={isZh ? '搜索参数' : 'Search settings'}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => setSearch('')}
title={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
aria-label={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
>
{search.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
<button
className="btn btn-primary"
type="button"
onClick={() => {
setEditingKey('');
setDraft(emptyDraft);
setShowEditor(true);
}}
>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{isZh ? '新增' : 'Add'}</span>
</button>
</div>
<div className="platform-settings-table-wrap">
<table className="table platform-settings-table">
<thead>
<tr>
<th>{isZh ? '参数键' : 'Key'}</th>
<th>{isZh ? '名称' : 'Name'}</th>
<th>{isZh ? '当前值' : 'Value'}</th>
<th>{isZh ? '类型' : 'Type'}</th>
<th>{isZh ? '分类' : 'Category'}</th>
<th>{isZh ? '描述' : 'Description'}</th>
<th>{isZh ? '操作' : 'Actions'}</th>
</tr>
</thead>
<tbody>
{pagedItems.map((item) => (
<tr key={item.key}>
<td>
<div className="mono">{item.key}</div>
{item.is_public ? <div className="platform-setting-public">{isZh ? '前端可访问' : 'Public to frontend'}</div> : null}
</td>
<td>{item.name}</td>
<td><span className="platform-setting-value">{displayValue(item)}</span></td>
<td>{item.value_type}</td>
<td>{item.category}</td>
<td>{item.description}</td>
<td>
<div className="platform-settings-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setEditingKey(item.key);
setDraft(toDraft(item));
setShowEditor(true);
}}
tooltip={isZh ? '编辑' : 'Edit'}
aria-label={isZh ? '编辑' : 'Edit'}
>
<Pencil size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => {
void (async () => {
const ok = await confirm({
title: isZh ? '删除系统参数' : 'Delete System Setting',
message: `${isZh ? '确认删除' : 'Delete'} ${item.key}?`,
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/platform/system-settings/${encodeURIComponent(item.key)}`);
await loadRows();
await refreshSnapshot();
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '删除失败。' : 'Delete failed.'), { tone: 'error' });
}
})();
}}
tooltip={isZh ? '删除' : 'Delete'}
aria-label={isZh ? '删除' : 'Delete'}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 ? <div className="ops-empty-inline">{isZh ? '暂无系统参数。' : 'No system settings.'}</div> : null}
</div>
<div className="platform-settings-pager">
<span className="pager-status">{isZh ? `${page} / ${pageCount} 页,共 ${filtered.length}` : `Page ${page} / ${pageCount}, ${filtered.length} rows`}</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</div>
</div>
{showEditor ? (
<div className="modal-mask" onClick={() => setShowEditor(false)}>
<div className="modal-card platform-setting-editor" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{editingKey ? (isZh ? '编辑参数' : 'Edit Setting') : (isZh ? '新增参数' : 'Create Setting')}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEditor(false)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<label className="field-label">{isZh ? '参数键' : 'Key'}</label>
<input className="input" value={draft.key} onChange={(event) => setDraft((prev) => ({ ...prev, key: event.target.value }))} disabled={Boolean(editingKey)} />
<label className="field-label">{isZh ? '名称' : 'Name'}</label>
<input className="input" value={draft.name} onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} />
<label className="field-label">{isZh ? '类型' : 'Type'}</label>
<select className="input" value={draft.value_type} onChange={(event) => setDraft((prev) => ({ ...prev, value_type: event.target.value }))}>
<option value="string">string</option>
<option value="integer">integer</option>
<option value="float">float</option>
<option value="boolean">boolean</option>
<option value="json">json</option>
</select>
<label className="field-label">{isZh ? '当前值' : 'Value'}</label>
{draft.value_type === 'json' ? (
<textarea className="textarea" rows={8} value={draft.value} onChange={(event) => setDraft((prev) => ({ ...prev, value: event.target.value }))} />
) : (
<input className="input" value={draft.value} onChange={(event) => setDraft((prev) => ({ ...prev, value: event.target.value }))} />
)}
<label className="field-label">{isZh ? '分类' : 'Category'}</label>
<input className="input" value={draft.category} onChange={(event) => setDraft((prev) => ({ ...prev, category: event.target.value }))} />
<label className="field-label">{isZh ? '描述' : 'Description'}</label>
<textarea className="textarea" rows={4} value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
<label className="field-label">{isZh ? '排序值' : 'Sort Order'}</label>
<input className="input" type="number" value={draft.sort_order} onChange={(event) => setDraft((prev) => ({ ...prev, sort_order: event.target.value }))} />
<label className="field-label">
<input type="checkbox" checked={draft.is_public} onChange={(event) => setDraft((prev) => ({ ...prev, is_public: event.target.checked }))} style={{ marginRight: 8 }} />
{isZh ? '前端可访问' : 'Public to frontend'}
</label>
<div className="row-between">
<button className="btn btn-secondary" type="button" onClick={() => setShowEditor(false)}>{isZh ? '取消' : 'Cancel'}</button>
<button
className="btn btn-primary"
type="button"
disabled={saving}
onClick={() => {
void (async () => {
setSaving(true);
try {
const payload = {
key: draft.key.trim(),
name: draft.name.trim(),
category: draft.category.trim(),
description: draft.description.trim(),
value_type: draft.value_type,
value: parseValue(draft),
is_public: draft.is_public,
sort_order: Number(draft.sort_order || '100') || 100,
};
if (editingKey) {
await axios.put(`${APP_ENDPOINTS.apiBase}/platform/system-settings/${encodeURIComponent(editingKey)}`, payload);
} else {
await axios.post(`${APP_ENDPOINTS.apiBase}/platform/system-settings`, payload);
}
await loadRows();
await refreshSnapshot();
notify(isZh ? '系统参数已保存。' : 'System setting saved.', { tone: 'success' });
setShowEditor(false);
} catch (error: any) {
const detail = error instanceof SyntaxError
? (isZh ? 'JSON 参数格式错误。' : 'Invalid JSON value.')
: (error?.response?.data?.detail || (isZh ? '保存参数失败。' : 'Failed to save setting.'));
notify(detail, { tone: 'error' });
} finally {
setSaving(false);
}
})();
}}
>
{saving ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: saving ? 6 : 0 }}>{isZh ? '保存' : 'Save'}</span>
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { RefreshCw, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
interface TemplateManagerModalProps {
isZh: boolean;
open: boolean;
onClose: () => void;
}
interface SystemTemplatesResponse {
agent_md_templates?: Record<string, string>;
topic_presets?: Record<string, unknown>;
}
type TemplateTabKey = 'agents_md' | 'soul_md' | 'user_md' | 'tools_md' | 'identity_md' | 'topic_presets';
const templateTabOrder: TemplateTabKey[] = ['agents_md', 'soul_md', 'user_md', 'tools_md', 'identity_md', 'topic_presets'];
const templateMeta: Record<TemplateTabKey, { zh: string; en: string; descriptionZh: string; descriptionEn: string }> = {
agents_md: {
zh: 'Agent 编排',
en: 'Agent Orchestration',
descriptionZh: '维护默认 Agent 结构、角色分工与执行约束。',
descriptionEn: 'Maintains the default agent structure, role split, and execution constraints.',
},
soul_md: {
zh: '人格设定',
en: 'Soul Profile',
descriptionZh: '维护默认人格、语气和长期行为偏好。',
descriptionEn: 'Maintains default persona, tone, and long-term behavioral preferences.',
},
user_md: {
zh: '用户规则',
en: 'User Rules',
descriptionZh: '维护用户交互规范、回复语言和输出边界。',
descriptionEn: 'Maintains user interaction rules, reply language, and output boundaries.',
},
tools_md: {
zh: '工具规则',
en: 'Tool Rules',
descriptionZh: '维护工具使用规范、调用策略和兜底行为。',
descriptionEn: 'Maintains tool usage rules, invocation strategy, and fallback behavior.',
},
identity_md: {
zh: '身份说明',
en: 'Identity',
descriptionZh: '维护默认身份描述、边界和对外表达。',
descriptionEn: 'Maintains the default identity description, boundaries, and outward framing.',
},
topic_presets: {
zh: 'Topic 预设',
en: 'Topic Presets',
descriptionZh: '维护 Topic 默认预设 JSON影响新建 Topic 的初始配置。',
descriptionEn: 'Maintains the topic preset JSON used when creating new topics.',
},
};
export function TemplateManagerModal({ isZh, open, onClose }: TemplateManagerModalProps) {
const { notify } = useLucentPrompt();
const [templates, setTemplates] = useState<Record<string, string>>({
soul_md: '',
agents_md: '',
user_md: '',
tools_md: '',
identity_md: '',
});
const [topicPresetsText, setTopicPresetsText] = useState('{\n "presets": []\n}');
const [activeTab, setActiveTab] = useState<TemplateTabKey>('agents_md');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
let alive = true;
const load = async () => {
setLoading(true);
try {
const res = await axios.get<SystemTemplatesResponse>(`${APP_ENDPOINTS.apiBase}/system/templates`);
if (!alive) return;
setTemplates({
soul_md: String(res.data?.agent_md_templates?.soul_md || ''),
agents_md: String(res.data?.agent_md_templates?.agents_md || ''),
user_md: String(res.data?.agent_md_templates?.user_md || ''),
tools_md: String(res.data?.agent_md_templates?.tools_md || ''),
identity_md: String(res.data?.agent_md_templates?.identity_md || ''),
});
setTopicPresetsText(JSON.stringify(res.data?.topic_presets || { presets: [] }, null, 2));
setActiveTab('agents_md');
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取模板失败。' : 'Failed to load templates.'), { tone: 'error' });
} finally {
if (alive) setLoading(false);
}
};
void load();
return () => {
alive = false;
};
}, [isZh, notify, open]);
const activeMeta = useMemo(() => templateMeta[activeTab], [activeTab]);
if (!open) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide platform-modal platform-template-shell" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '模版管理' : 'Template Manager'}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="platform-template-layout">
<div className="platform-template-tabs" role="tablist" aria-label={isZh ? '模版分类' : 'Template Categories'}>
{templateTabOrder.map((tab) => (
<button
key={tab}
className={`platform-template-tab ${activeTab === tab ? 'is-active' : ''}`}
type="button"
onClick={() => setActiveTab(tab)}
>
<strong>{isZh ? templateMeta[tab].zh : templateMeta[tab].en}</strong>
<span>{isZh ? templateMeta[tab].descriptionZh : templateMeta[tab].descriptionEn}</span>
</button>
))}
</div>
<div className="platform-template-editor">
<div className="platform-template-header">
<div>
<div className="section-mini-title">{isZh ? activeMeta.zh : activeMeta.en}</div>
<div className="platform-template-hint">{isZh ? activeMeta.descriptionZh : activeMeta.descriptionEn}</div>
</div>
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void (async () => {
setLoading(true);
try {
const res = await axios.get<SystemTemplatesResponse>(`${APP_ENDPOINTS.apiBase}/system/templates`);
setTemplates({
soul_md: String(res.data?.agent_md_templates?.soul_md || ''),
agents_md: String(res.data?.agent_md_templates?.agents_md || ''),
user_md: String(res.data?.agent_md_templates?.user_md || ''),
tools_md: String(res.data?.agent_md_templates?.tools_md || ''),
identity_md: String(res.data?.agent_md_templates?.identity_md || ''),
});
setTopicPresetsText(JSON.stringify(res.data?.topic_presets || { presets: [] }, null, 2));
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '刷新模板失败。' : 'Failed to refresh templates.'), { tone: 'error' });
} finally {
setLoading(false);
}
})()}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: loading ? 6 : 0 }}>{isZh ? '重载' : 'Reload'}</span>
</button>
</div>
{activeTab === 'topic_presets' ? (
<textarea
className="textarea md-area mono platform-template-textarea"
rows={18}
value={topicPresetsText}
onChange={(event) => setTopicPresetsText(event.target.value)}
placeholder='{"presets":[...]}'
/>
) : (
<textarea
className="textarea md-area mono platform-template-textarea"
rows={18}
value={templates[activeTab] || ''}
onChange={(event) => setTemplates((prev) => ({ ...prev, [activeTab]: event.target.value }))}
/>
)}
</div>
</div>
<div className="row-between">
<div className="field-label">{isZh ? '保存后,新建 Bot 与 Topic 会使用最新平台模板。' : 'Saved templates will be used by newly created bots and topics.'}</div>
<button
className="btn btn-primary"
type="button"
disabled={saving || loading}
onClick={() => {
void (async () => {
setSaving(true);
try {
const topicPresets = JSON.parse(topicPresetsText);
await axios.put(`${APP_ENDPOINTS.apiBase}/system/templates`, {
agent_md_templates: templates,
topic_presets: topicPresets,
});
notify(isZh ? '模版已保存。' : 'Templates saved.', { tone: 'success' });
} catch (error: any) {
const detail = error instanceof SyntaxError
? (isZh ? 'Topic 预设 JSON 解析失败。' : 'Invalid topic presets JSON.')
: (error?.response?.data?.detail || (isZh ? '保存模板失败。' : 'Failed to save templates.'));
notify(detail, { tone: 'error' });
} finally {
setSaving(false);
}
})();
}}
>
{saving ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: saving ? 6 : 0 }}>{isZh ? '保存全部模板' : 'Save All Templates'}</span>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,135 @@
export interface PlatformSettings {
page_size: number;
chat_pull_page_size: number;
upload_max_mb: number;
allowed_attachment_extensions: string[];
workspace_download_extensions: string[];
speech_enabled: boolean;
loading_page?: {
title: string;
subtitle: string;
description: string;
};
}
export interface SystemSettingItem {
key: string;
name: string;
category: string;
description: string;
value_type: string;
value: unknown;
is_public: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface PlatformUsageItem {
id: number;
bot_id: string;
message_id?: number | null;
request_id: string;
channel: string;
status: string;
provider?: string | null;
model?: string | null;
token_source: string;
content?: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_text_preview?: string | null;
output_text_preview?: string | null;
started_at: string;
completed_at?: string | null;
}
export interface PlatformUsageResponse {
summary: {
request_count: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
items: PlatformUsageItem[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface PlatformOverviewResponse {
summary: {
bots: {
total: number;
running: number;
stopped: number;
disabled: number;
};
images: {
total: number;
ready: number;
abnormal: number;
};
resources: {
configured_cpu_cores: number;
configured_memory_bytes: number;
configured_storage_bytes: number;
live_cpu_percent: number;
live_memory_used_bytes: number;
live_memory_limit_bytes: number;
workspace_used_bytes: number;
workspace_limit_bytes: number;
};
};
images: Array<{
tag: string;
version: string;
status: string;
source_dir?: string;
created_at: string;
}>;
bots: Array<{
id: string;
name: string;
enabled: boolean;
docker_status: string;
image_tag?: string;
llm_provider?: string;
llm_model?: string;
current_state?: string;
last_action?: string;
resources: {
cpu_cores: number;
memory_mb: number;
storage_gb: number;
};
workspace_usage_bytes: number;
workspace_limit_bytes?: number | null;
}>;
settings: PlatformSettings;
usage: {
summary: {
request_count: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
items: PlatformUsageItem[];
total?: number;
limit?: number;
offset?: number;
has_more?: boolean;
};
events: Array<{
id: number;
bot_id: string;
request_id?: string | null;
event_type: string;
channel: string;
detail?: string | null;
metadata?: Record<string, unknown>;
created_at: string;
}>;
}

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
export type AppRoute =
| { kind: 'dashboard' }
| { kind: 'bot'; botId: string };
function parsePathname(pathname: string): AppRoute {
const raw = String(pathname || '/').trim() || '/';
const botMatch = raw.match(/^\/bot\/([^/?#]+)/i);
if (botMatch?.[1]) {
try {
return { kind: 'bot', botId: decodeURIComponent(botMatch[1]).trim() };
} catch {
return { kind: 'bot', botId: String(botMatch[1]).trim() };
}
}
return { kind: 'dashboard' };
}
export function readCompactModeFromUrl(): boolean {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase();
return ['1', 'true', 'yes', 'on'].includes(compactRaw);
}
export function useAppRoute(): AppRoute {
const [route, setRoute] = useState<AppRoute>(() =>
typeof window === 'undefined' ? { kind: 'dashboard' } : parsePathname(window.location.pathname),
);
useEffect(() => {
if (typeof window === 'undefined') return;
const apply = () => {
const next = parsePathname(window.location.pathname);
setRoute(next);
if (window.location.pathname === '/') {
window.history.replaceState({}, '', '/dashboard');
}
};
apply();
window.addEventListener('popstate', apply);
return () => window.removeEventListener('popstate', apply);
}, []);
return route;
}