v0.1.4-p3
parent
3a151d7e8f
commit
20c70d2e69
|
|
@ -24,28 +24,27 @@ REDIS_ENABLED=false
|
|||
REDIS_URL=
|
||||
REDIS_PREFIX=dashboard_nanobot
|
||||
REDIS_DEFAULT_TTL=60
|
||||
# Chat history page size for upward lazy loading (per request)
|
||||
CHAT_PULL_PAGE_SIZE=60
|
||||
|
||||
# Optional panel-level access password for all backend API/WS calls.
|
||||
PANEL_ACCESS_PASSWORD=
|
||||
# Max upload size for backend validation (MB)
|
||||
UPLOAD_MAX_MB=100
|
||||
# Workspace files that should use direct download behavior in dashboard
|
||||
# Comma/space/semicolon separated, e.g. ".pdf,.docx,.xlsx,.zip"
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps
|
||||
# The following platform-level items are now managed in sys_setting / 平台参数:
|
||||
# - page_size
|
||||
# - chat_pull_page_size
|
||||
# - upload_max_mb
|
||||
# - allowed_attachment_extensions
|
||||
# - workspace_download_extensions
|
||||
# - speech_enabled
|
||||
# - speech_max_audio_seconds
|
||||
# - speech_default_language
|
||||
# - speech_force_simplified
|
||||
# - speech_audio_preprocess
|
||||
# - speech_audio_filter
|
||||
# - speech_initial_prompt
|
||||
|
||||
# Local speech-to-text (Whisper via whisper.cpp model file)
|
||||
STT_ENABLED=true
|
||||
STT_MODEL=ggml-small-q8_0.bin
|
||||
STT_MODEL_DIR=../data/model
|
||||
STT_DEVICE=cpu
|
||||
STT_MAX_AUDIO_SECONDS=20
|
||||
STT_DEFAULT_LANGUAGE=zh
|
||||
STT_FORCE_SIMPLIFIED=true
|
||||
STT_AUDIO_PREPROCESS=true
|
||||
STT_AUDIO_FILTER=highpass=f=120,lowpass=f=7600,afftdn=nf=-20
|
||||
STT_INITIAL_PROMPT=以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。
|
||||
|
||||
# Local backend server options (for `python3 main.py`)
|
||||
APP_HOST=0.0.0.0
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -93,6 +93,23 @@ class BotConfigManager:
|
|||
if tools_cfg:
|
||||
config_data["tools"] = tools_cfg
|
||||
|
||||
existing_channels = existing_config.get("channels")
|
||||
existing_dashboard_cfg = (
|
||||
existing_channels.get("dashboard")
|
||||
if isinstance(existing_channels, dict) and isinstance(existing_channels.get("dashboard"), dict)
|
||||
else {}
|
||||
)
|
||||
dashboard_cfg: Dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"host": "0.0.0.0",
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"],
|
||||
}
|
||||
for key in ("host", "port", "allowFrom"):
|
||||
if key in existing_dashboard_cfg:
|
||||
dashboard_cfg[key] = existing_dashboard_cfg[key]
|
||||
channels_cfg["dashboard"] = dashboard_cfg
|
||||
|
||||
for channel in channels:
|
||||
channel_type = (channel.get("channel_type") or "").strip()
|
||||
if not channel_type:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from core.settings import (
|
|||
|
||||
# Ensure table models are registered in SQLModel metadata before create_all.
|
||||
from models import bot as _bot_models # noqa: F401
|
||||
from models import platform as _platform_models # noqa: F401
|
||||
from models import topic as _topic_models # noqa: F401
|
||||
|
||||
_engine_kwargs = {
|
||||
|
|
@ -33,6 +34,232 @@ else:
|
|||
|
||||
engine = create_engine(DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
BOT_INSTANCE_TABLE = "bot_instance"
|
||||
BOT_MESSAGE_TABLE = "bot_message"
|
||||
BOT_IMAGE_TABLE = "bot_image"
|
||||
BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
||||
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
||||
SYS_SETTING_TABLE = "sys_setting"
|
||||
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
||||
MYSQL_MIGRATION_LOCK_NAME = "dashboard_nanobot_schema_migration"
|
||||
LEGACY_TABLE_PAIRS = [
|
||||
("botinstance", BOT_INSTANCE_TABLE),
|
||||
("botmessage", BOT_MESSAGE_TABLE),
|
||||
("nanobotimage", BOT_IMAGE_TABLE),
|
||||
("platformsetting", SYS_SETTING_TABLE),
|
||||
("botrequestusage", BOT_REQUEST_USAGE_TABLE),
|
||||
("botactivityevent", BOT_ACTIVITY_EVENT_TABLE),
|
||||
]
|
||||
|
||||
|
||||
def _quote_ident(name: str) -> str:
|
||||
if engine.dialect.name == "mysql":
|
||||
return f"`{str(name).replace('`', '``')}`"
|
||||
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
|
||||
|
||||
|
||||
def _rename_table_if_needed(old_name: str, new_name: str) -> None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or inspector.has_table(new_name):
|
||||
return
|
||||
dialect = engine.dialect.name
|
||||
with engine.connect() as conn:
|
||||
if dialect == "mysql":
|
||||
conn.execute(text(f"RENAME TABLE `{old_name}` TO `{new_name}`"))
|
||||
else:
|
||||
conn.execute(text(f'ALTER TABLE "{old_name}" RENAME TO "{new_name}"'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _rename_legacy_tables() -> None:
|
||||
_rename_table_if_needed("botinstance", BOT_INSTANCE_TABLE)
|
||||
_rename_table_if_needed("botmessage", BOT_MESSAGE_TABLE)
|
||||
_rename_table_if_needed("nanobotimage", BOT_IMAGE_TABLE)
|
||||
_rename_table_if_needed("platformsetting", SYS_SETTING_TABLE)
|
||||
_rename_table_if_needed("botrequestusage", BOT_REQUEST_USAGE_TABLE)
|
||||
_rename_table_if_needed("botactivityevent", BOT_ACTIVITY_EVENT_TABLE)
|
||||
|
||||
|
||||
def _acquire_migration_lock():
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
||||
return conn
|
||||
if engine.dialect.name == "mysql":
|
||||
conn = engine.connect()
|
||||
acquired = conn.execute(
|
||||
text("SELECT GET_LOCK(:name, :timeout)"),
|
||||
{"name": MYSQL_MIGRATION_LOCK_NAME, "timeout": 120},
|
||||
).scalar()
|
||||
if int(acquired or 0) != 1:
|
||||
conn.close()
|
||||
raise RuntimeError("Failed to acquire schema migration lock")
|
||||
return conn
|
||||
return None
|
||||
|
||||
|
||||
def _release_migration_lock(lock_conn) -> None:
|
||||
if lock_conn is None:
|
||||
return
|
||||
try:
|
||||
if engine.dialect.name == "postgresql":
|
||||
lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
||||
elif engine.dialect.name == "mysql":
|
||||
lock_conn.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": MYSQL_MIGRATION_LOCK_NAME})
|
||||
finally:
|
||||
lock_conn.close()
|
||||
|
||||
|
||||
def _table_row_count(table_name: str) -> int:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return 0
|
||||
with engine.connect() as conn:
|
||||
value = conn.execute(text(f"SELECT COUNT(*) FROM {_quote_ident(table_name)}")).scalar()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _copy_legacy_table_rows(old_name: str, new_name: str) -> None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
|
||||
return
|
||||
if _table_row_count(old_name) <= 0:
|
||||
return
|
||||
|
||||
old_columns = {
|
||||
str(row.get("name"))
|
||||
for row in inspector.get_columns(old_name)
|
||||
if row.get("name")
|
||||
}
|
||||
new_columns = [
|
||||
str(row.get("name"))
|
||||
for row in inspector.get_columns(new_name)
|
||||
if row.get("name")
|
||||
]
|
||||
shared_columns = [col for col in new_columns if col in old_columns]
|
||||
if not shared_columns:
|
||||
return
|
||||
pk = inspector.get_pk_constraint(new_name) or {}
|
||||
pk_columns = [
|
||||
str(col)
|
||||
for col in (pk.get("constrained_columns") or [])
|
||||
if col and col in shared_columns and col in old_columns
|
||||
]
|
||||
if not pk_columns:
|
||||
return
|
||||
|
||||
columns_sql = ", ".join(_quote_ident(col) for col in shared_columns)
|
||||
join_sql = " AND ".join(
|
||||
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
|
||||
for col in pk_columns
|
||||
)
|
||||
null_check_col = _quote_ident(pk_columns[0])
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f"INSERT INTO {_quote_ident(new_name)} ({columns_sql}) "
|
||||
f"SELECT {', '.join(f'o.{_quote_ident(col)}' for col in shared_columns)} "
|
||||
f"FROM {_quote_ident(old_name)} o "
|
||||
f"LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} "
|
||||
f"WHERE n.{null_check_col} IS NULL"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_legacy_table_rows() -> None:
|
||||
for old_name, new_name in LEGACY_TABLE_PAIRS:
|
||||
_copy_legacy_table_rows(old_name, new_name)
|
||||
|
||||
|
||||
def _topic_fk_target(table_name: str, constrained_column: str = "bot_id") -> str | None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return None
|
||||
for fk in inspector.get_foreign_keys(table_name):
|
||||
cols = [str(col) for col in (fk.get("constrained_columns") or []) if col]
|
||||
if cols == [constrained_column]:
|
||||
referred = fk.get("referred_table")
|
||||
return str(referred) if referred else None
|
||||
return None
|
||||
|
||||
|
||||
def _repair_postgres_topic_foreign_keys() -> None:
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
targets = {
|
||||
"topic_topic": "topic_topic_bot_id_fkey",
|
||||
"topic_item": "topic_item_bot_id_fkey",
|
||||
}
|
||||
with engine.connect() as conn:
|
||||
changed = False
|
||||
for table_name, constraint_name in targets.items():
|
||||
if _topic_fk_target(table_name) == BOT_INSTANCE_TABLE:
|
||||
continue
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE {_quote_ident(table_name)} '
|
||||
f'DROP CONSTRAINT IF EXISTS {_quote_ident(constraint_name)}'
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE {_quote_ident(table_name)} '
|
||||
f'ADD CONSTRAINT {_quote_ident(constraint_name)} '
|
||||
f'FOREIGN KEY ({_quote_ident("bot_id")}) '
|
||||
f'REFERENCES {_quote_ident(BOT_INSTANCE_TABLE)}({_quote_ident("id")}) '
|
||||
f'ON DELETE CASCADE'
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _legacy_rows_missing_in_new(old_name: str, new_name: str) -> int:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
|
||||
return 0
|
||||
pk = inspector.get_pk_constraint(new_name) or {}
|
||||
pk_columns = [
|
||||
str(col)
|
||||
for col in (pk.get("constrained_columns") or [])
|
||||
if col
|
||||
]
|
||||
if not pk_columns:
|
||||
return _table_row_count(old_name)
|
||||
join_sql = " AND ".join(
|
||||
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
|
||||
for col in pk_columns
|
||||
)
|
||||
null_check_col = _quote_ident(pk_columns[0])
|
||||
with engine.connect() as conn:
|
||||
value = conn.execute(
|
||||
text(
|
||||
f'SELECT COUNT(*) FROM {_quote_ident(old_name)} o '
|
||||
f'LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} '
|
||||
f'WHERE n.{null_check_col} IS NULL'
|
||||
)
|
||||
).scalar()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _drop_legacy_tables() -> None:
|
||||
droppable = [
|
||||
old_name
|
||||
for old_name, new_name in LEGACY_TABLE_PAIRS
|
||||
if _legacy_rows_missing_in_new(old_name, new_name) <= 0
|
||||
]
|
||||
if not droppable:
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
for old_name in droppable:
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)} CASCADE'))
|
||||
else:
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)}'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_botinstance_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
|
|
@ -65,24 +292,24 @@ def _ensure_botinstance_columns() -> None:
|
|||
}
|
||||
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table("botinstance"):
|
||||
if not inspector.has_table(BOT_INSTANCE_TABLE):
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
existing = {
|
||||
str(row.get("name"))
|
||||
for row in inspect(conn).get_columns("botinstance")
|
||||
for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE botinstance ADD COLUMN {col} {ddl}"))
|
||||
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
if "enabled" in existing:
|
||||
if dialect == "sqlite":
|
||||
conn.execute(text("UPDATE botinstance SET enabled = 1 WHERE enabled IS NULL"))
|
||||
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL"))
|
||||
else:
|
||||
conn.execute(text("UPDATE botinstance SET enabled = TRUE WHERE enabled IS NULL"))
|
||||
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -113,7 +340,7 @@ def _drop_legacy_botinstance_columns() -> None:
|
|||
with engine.connect() as conn:
|
||||
existing = {
|
||||
str(col.get("name"))
|
||||
for col in inspect(conn).get_columns("botinstance")
|
||||
for col in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
|
||||
if col.get("name")
|
||||
}
|
||||
for col in legacy_columns:
|
||||
|
|
@ -121,11 +348,11 @@ def _drop_legacy_botinstance_columns() -> None:
|
|||
continue
|
||||
try:
|
||||
if engine.dialect.name == "mysql":
|
||||
conn.execute(text(f"ALTER TABLE botinstance DROP COLUMN `{col}`"))
|
||||
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`"))
|
||||
elif engine.dialect.name == "sqlite":
|
||||
conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN "{col}"'))
|
||||
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"'))
|
||||
else:
|
||||
conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN IF EXISTS "{col}"'))
|
||||
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN IF EXISTS "{col}"'))
|
||||
except Exception:
|
||||
# Keep startup resilient on mixed/legacy database engines.
|
||||
continue
|
||||
|
|
@ -141,12 +368,12 @@ def _ensure_botmessage_columns() -> None:
|
|||
"feedback_at": "DATETIME",
|
||||
}
|
||||
with engine.connect() as conn:
|
||||
existing_rows = conn.execute(text("PRAGMA table_info(botmessage)")).fetchall()
|
||||
existing_rows = conn.execute(text(f"PRAGMA table_info({BOT_MESSAGE_TABLE})")).fetchall()
|
||||
existing = {str(row[1]) for row in existing_rows}
|
||||
for col, ddl in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
conn.execute(text(f"ALTER TABLE botmessage ADD COLUMN {col} {ddl}"))
|
||||
conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -158,24 +385,97 @@ def _drop_legacy_skill_tables() -> None:
|
|||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_sys_setting_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"name": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "VARCHAR(200) NOT NULL DEFAULT ''",
|
||||
},
|
||||
"category": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'general'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'general'",
|
||||
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'",
|
||||
},
|
||||
"description": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"value_type": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'json'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'json'",
|
||||
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'",
|
||||
},
|
||||
"is_public": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 0",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
},
|
||||
"sort_order": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 100",
|
||||
"postgresql": "INTEGER NOT NULL DEFAULT 100",
|
||||
"mysql": "INTEGER NOT NULL DEFAULT 100",
|
||||
},
|
||||
}
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(SYS_SETTING_TABLE):
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
existing = {
|
||||
str(row.get("name"))
|
||||
for row in inspect(conn).get_columns(SYS_SETTING_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_bot_request_usage_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"message_id": {
|
||||
"sqlite": "INTEGER",
|
||||
"postgresql": "INTEGER",
|
||||
"mysql": "INTEGER",
|
||||
},
|
||||
"provider": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "VARCHAR(120)",
|
||||
},
|
||||
"model": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "VARCHAR(255)",
|
||||
},
|
||||
}
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(BOT_REQUEST_USAGE_TABLE):
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
existing = {
|
||||
str(row.get("name"))
|
||||
for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_topic_tables_sqlite() -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS topic_bot_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
topic_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -191,7 +491,7 @@ def _ensure_topic_tables_sqlite() -> None:
|
|||
view_schema_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
|
@ -212,13 +512,12 @@ def _ensure_topic_tables_sqlite() -> None:
|
|||
dedupe_key TEXT,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_bot_settings_bot_id ON topic_bot_settings(bot_id)"))
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic(bot_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)"))
|
||||
|
|
@ -237,23 +536,6 @@ def _ensure_topic_tables_sqlite() -> None:
|
|||
def _ensure_topic_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"topic_bot_settings": {
|
||||
"topic_enabled": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 1",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
},
|
||||
"created_at": {
|
||||
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
"updated_at": {
|
||||
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
"topic_topic": {
|
||||
"name": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
|
|
@ -360,7 +642,6 @@ def _ensure_topic_columns() -> None:
|
|||
|
||||
def _ensure_topic_indexes() -> None:
|
||||
required_indexes = [
|
||||
("uq_topic_bot_settings_bot_id", "topic_bot_settings", ["bot_id"], True),
|
||||
("uq_topic_topic_bot_topic_key", "topic_topic", ["bot_id", "topic_key"], True),
|
||||
("idx_topic_topic_bot_id", "topic_topic", ["bot_id"], False),
|
||||
("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False),
|
||||
|
|
@ -397,6 +678,17 @@ def _ensure_topic_indexes() -> None:
|
|||
conn.commit()
|
||||
|
||||
|
||||
def _drop_obsolete_topic_tables() -> None:
|
||||
with engine.connect() as conn:
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
|
||||
elif engine.dialect.name == "mysql":
|
||||
conn.execute(text("DROP TABLE IF EXISTS `topic_bot_settings`"))
|
||||
else:
|
||||
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _cleanup_legacy_default_topics() -> None:
|
||||
"""
|
||||
Remove legacy auto-created fallback topic rows from early topic-feed design.
|
||||
|
|
@ -452,7 +744,9 @@ def align_postgres_sequences() -> None:
|
|||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
sequence_targets = [
|
||||
("botmessage", "id"),
|
||||
(BOT_MESSAGE_TABLE, "id"),
|
||||
(BOT_REQUEST_USAGE_TABLE, "id"),
|
||||
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table_name, column_name in sequence_targets:
|
||||
|
|
@ -478,15 +772,27 @@ def align_postgres_sequences() -> None:
|
|||
|
||||
|
||||
def init_database() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_drop_legacy_skill_tables()
|
||||
_ensure_botinstance_columns()
|
||||
_drop_legacy_botinstance_columns()
|
||||
_ensure_botmessage_columns()
|
||||
_ensure_topic_tables_sqlite()
|
||||
_ensure_topic_columns()
|
||||
_cleanup_legacy_default_topics()
|
||||
align_postgres_sequences()
|
||||
lock_conn = _acquire_migration_lock()
|
||||
try:
|
||||
_rename_legacy_tables()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_migrate_legacy_table_rows()
|
||||
_drop_legacy_skill_tables()
|
||||
_ensure_sys_setting_columns()
|
||||
_ensure_bot_request_usage_columns()
|
||||
_ensure_botinstance_columns()
|
||||
_drop_legacy_botinstance_columns()
|
||||
_ensure_botmessage_columns()
|
||||
_ensure_topic_tables_sqlite()
|
||||
_repair_postgres_topic_foreign_keys()
|
||||
_ensure_topic_columns()
|
||||
_ensure_topic_indexes()
|
||||
_drop_obsolete_topic_tables()
|
||||
_cleanup_legacy_default_topics()
|
||||
_drop_legacy_tables()
|
||||
align_postgres_sequences()
|
||||
finally:
|
||||
_release_migration_lock(lock_conn)
|
||||
|
||||
|
||||
def get_session():
|
||||
|
|
|
|||
|
|
@ -619,6 +619,10 @@ class BotDockerManager:
|
|||
media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()]
|
||||
is_progress = bool(data.get("is_progress", False))
|
||||
is_tool = bool(data.get("is_tool", False))
|
||||
usage = data.get("usage") if isinstance(data.get("usage"), dict) else None
|
||||
request_id = str(data.get("request_id") or "").strip() or None
|
||||
provider = str(data.get("provider") or "").strip() or None
|
||||
model = str(data.get("model") or "").strip() or None
|
||||
|
||||
if event_type == "AGENT_STATE":
|
||||
payload = data.get("payload") or {}
|
||||
|
|
@ -628,11 +632,21 @@ class BotDockerManager:
|
|||
"type": "AGENT_STATE",
|
||||
"channel": "dashboard",
|
||||
"payload": {"state": state, "action_msg": action_msg},
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
if event_type == "ASSISTANT_MESSAGE":
|
||||
if content or media:
|
||||
return {"type": "ASSISTANT_MESSAGE", "channel": "dashboard", "text": content, "media": media}
|
||||
return {
|
||||
"type": "ASSISTANT_MESSAGE",
|
||||
"channel": "dashboard",
|
||||
"text": content,
|
||||
"media": media,
|
||||
"usage": usage,
|
||||
"request_id": request_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
return None
|
||||
|
||||
if event_type == "BUS_EVENT" or is_progress:
|
||||
|
|
@ -643,6 +657,10 @@ class BotDockerManager:
|
|||
"media": media,
|
||||
"is_progress": is_progress,
|
||||
"is_tool": is_tool,
|
||||
"usage": usage,
|
||||
"request_id": request_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
if content or media:
|
||||
|
|
@ -651,6 +669,10 @@ class BotDockerManager:
|
|||
"channel": "dashboard",
|
||||
"text": content,
|
||||
"media": media,
|
||||
"usage": usage,
|
||||
"request_id": request_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -178,8 +178,10 @@ DATABASE_POOL_SIZE: Final[int] = _env_int("DATABASE_POOL_SIZE", 20, 1, 200)
|
|||
DATABASE_MAX_OVERFLOW: Final[int] = _env_int("DATABASE_MAX_OVERFLOW", 40, 0, 200)
|
||||
DATABASE_POOL_TIMEOUT: Final[int] = _env_int("DATABASE_POOL_TIMEOUT", 30, 1, 300)
|
||||
DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30, 86400)
|
||||
UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048)
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = (
|
||||
DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
|
||||
DEFAULT_PAGE_SIZE: Final[int] = 10
|
||||
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
||||
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = (
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
|
|
@ -193,11 +195,7 @@ WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = (
|
|||
".odp",
|
||||
".wps",
|
||||
)
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = _env_extensions(
|
||||
"WORKSPACE_DOWNLOAD_EXTENSIONS",
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT,
|
||||
)
|
||||
STT_ENABLED: Final[bool] = _env_bool("STT_ENABLED", True)
|
||||
STT_ENABLED_DEFAULT: Final[bool] = True
|
||||
STT_MODEL: Final[str] = str(os.getenv("STT_MODEL") or "ggml-small-q8_0.bin").strip()
|
||||
_DEFAULT_STT_MODEL_DIR: Final[Path] = (Path(DATA_ROOT) / "model").resolve()
|
||||
_configured_stt_model_dir = _normalize_dir_path(os.getenv("STT_MODEL_DIR", str(_DEFAULT_STT_MODEL_DIR)))
|
||||
|
|
@ -206,23 +204,19 @@ if _configured_stt_model_dir and not Path(_configured_stt_model_dir).exists() an
|
|||
else:
|
||||
STT_MODEL_DIR: Final[str] = _configured_stt_model_dir
|
||||
STT_DEVICE: Final[str] = str(os.getenv("STT_DEVICE") or "cpu").strip().lower() or "cpu"
|
||||
STT_MAX_AUDIO_SECONDS: Final[int] = _env_int("STT_MAX_AUDIO_SECONDS", 20, 5, 600)
|
||||
STT_DEFAULT_LANGUAGE: Final[str] = str(os.getenv("STT_DEFAULT_LANGUAGE") or "zh").strip().lower() or "zh"
|
||||
STT_FORCE_SIMPLIFIED: Final[bool] = _env_bool("STT_FORCE_SIMPLIFIED", True)
|
||||
STT_AUDIO_PREPROCESS: Final[bool] = _env_bool("STT_AUDIO_PREPROCESS", True)
|
||||
STT_AUDIO_FILTER: Final[str] = str(
|
||||
os.getenv("STT_AUDIO_FILTER") or "highpass=f=120,lowpass=f=7600,afftdn=nf=-20"
|
||||
).strip()
|
||||
STT_INITIAL_PROMPT: Final[str] = str(
|
||||
os.getenv("STT_INITIAL_PROMPT")
|
||||
or "以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。"
|
||||
).strip()
|
||||
DEFAULT_STT_MAX_AUDIO_SECONDS: Final[int] = 20
|
||||
DEFAULT_STT_DEFAULT_LANGUAGE: Final[str] = "zh"
|
||||
DEFAULT_STT_FORCE_SIMPLIFIED: Final[bool] = True
|
||||
DEFAULT_STT_AUDIO_PREPROCESS: Final[bool] = True
|
||||
DEFAULT_STT_AUDIO_FILTER: Final[str] = "highpass=f=120,lowpass=f=7600,afftdn=nf=-20"
|
||||
DEFAULT_STT_INITIAL_PROMPT: Final[str] = (
|
||||
"以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。"
|
||||
)
|
||||
|
||||
REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False)
|
||||
REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
|
||||
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
|
||||
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
||||
CHAT_PULL_PAGE_SIZE: Final[int] = _env_int("CHAT_PULL_PAGE_SIZE", 60, 10, 500)
|
||||
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
|
||||
|
||||
TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
|
||||
|
|
|
|||
|
|
@ -8,17 +8,8 @@ import threading
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.settings import (
|
||||
STT_AUDIO_FILTER,
|
||||
STT_AUDIO_PREPROCESS,
|
||||
STT_DEVICE,
|
||||
STT_ENABLED,
|
||||
STT_FORCE_SIMPLIFIED,
|
||||
STT_INITIAL_PROMPT,
|
||||
STT_MAX_AUDIO_SECONDS,
|
||||
STT_MODEL,
|
||||
STT_MODEL_DIR,
|
||||
)
|
||||
from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR
|
||||
from services.platform_service import get_speech_runtime_settings
|
||||
|
||||
|
||||
class SpeechServiceError(RuntimeError):
|
||||
|
|
@ -76,7 +67,13 @@ class WhisperSpeechService:
|
|||
raise SpeechServiceError(
|
||||
f"STT model file not found under STT_MODEL_DIR: {candidate}"
|
||||
)
|
||||
return str(candidate.resolve())
|
||||
return str(candidate.resolve())
|
||||
|
||||
def reset_runtime(self) -> None:
|
||||
with self._model_lock:
|
||||
self._model = None
|
||||
self._model_source = ""
|
||||
self._backend = ""
|
||||
|
||||
def _load_model(self) -> Any:
|
||||
model_source = self._resolve_model_source()
|
||||
|
|
@ -103,8 +100,9 @@ class WhisperSpeechService:
|
|||
|
||||
@staticmethod
|
||||
def _preprocess_audio(file_path: str) -> str:
|
||||
settings = get_speech_runtime_settings()
|
||||
target = str(file_path or "").strip()
|
||||
if not STT_AUDIO_PREPROCESS or not target or not os.path.isfile(target):
|
||||
if not settings["audio_preprocess"] or not target or not os.path.isfile(target):
|
||||
return target
|
||||
if shutil.which("ffmpeg") is None:
|
||||
return target
|
||||
|
|
@ -122,7 +120,7 @@ class WhisperSpeechService:
|
|||
"-ar",
|
||||
"16000",
|
||||
]
|
||||
audio_filter = str(STT_AUDIO_FILTER or "").strip()
|
||||
audio_filter = str(settings["audio_filter"] or "").strip()
|
||||
if audio_filter:
|
||||
cmd.extend(["-af", audio_filter])
|
||||
cmd.extend(["-c:a", "pcm_s16le", tmp_path])
|
||||
|
|
@ -163,8 +161,9 @@ class WhisperSpeechService:
|
|||
|
||||
@staticmethod
|
||||
def _normalize_text(text: str) -> str:
|
||||
settings = get_speech_runtime_settings()
|
||||
content = str(text or "").strip()
|
||||
if not content or not STT_FORCE_SIMPLIFIED:
|
||||
if not content or not settings["force_simplified"]:
|
||||
return content
|
||||
try:
|
||||
from opencc_purepy import OpenCC # type: ignore
|
||||
|
|
@ -182,15 +181,16 @@ class WhisperSpeechService:
|
|||
return {key: value for key, value in kwargs.items() if key in available}
|
||||
|
||||
def transcribe_file(self, file_path: str, language: Optional[str] = None) -> Dict[str, Any]:
|
||||
if not STT_ENABLED:
|
||||
settings = get_speech_runtime_settings()
|
||||
if not settings["enabled"]:
|
||||
raise SpeechDisabledError("Speech-to-text is disabled")
|
||||
target = str(file_path or "").strip()
|
||||
if not target or not os.path.isfile(target):
|
||||
raise SpeechServiceError("Audio file not found")
|
||||
|
||||
duration_seconds = self._probe_audio_duration_seconds(target)
|
||||
if duration_seconds is not None and duration_seconds > float(STT_MAX_AUDIO_SECONDS) + 0.3:
|
||||
raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds")
|
||||
if duration_seconds is not None and duration_seconds > float(settings["max_audio_seconds"]) + 0.3:
|
||||
raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds")
|
||||
|
||||
prepared_target = self._preprocess_audio(target)
|
||||
try:
|
||||
|
|
@ -211,7 +211,7 @@ class WhisperSpeechService:
|
|||
}
|
||||
if normalized_lang:
|
||||
kwargs["language"] = normalized_lang
|
||||
initial_prompt = str(STT_INITIAL_PROMPT or "").strip()
|
||||
initial_prompt = str(settings["initial_prompt"] or "").strip()
|
||||
if initial_prompt:
|
||||
kwargs["initial_prompt"] = initial_prompt
|
||||
kwargs = self._filter_supported_transcribe_kwargs(model, kwargs)
|
||||
|
|
@ -232,8 +232,8 @@ class WhisperSpeechService:
|
|||
max_end = max(max_end, float(getattr(segment, "t1", 0.0) or 0.0) / 100.0)
|
||||
except Exception:
|
||||
pass
|
||||
if max_end > float(STT_MAX_AUDIO_SECONDS) + 0.3:
|
||||
raise SpeechDurationError(f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds")
|
||||
if max_end > float(settings["max_audio_seconds"]) + 0.3:
|
||||
raise SpeechDurationError(f"Audio duration exceeds {settings['max_audio_seconds']} seconds")
|
||||
|
||||
text = self._normalize_text(" ".join(texts).strip())
|
||||
if not text:
|
||||
|
|
@ -246,7 +246,7 @@ class WhisperSpeechService:
|
|||
"text": text,
|
||||
"language": detected_language or None,
|
||||
"duration_seconds": duration_seconds,
|
||||
"max_audio_seconds": STT_MAX_AUDIO_SECONDS,
|
||||
"max_audio_seconds": settings["max_audio_seconds"],
|
||||
"model": STT_MODEL,
|
||||
"device": STT_DEVICE,
|
||||
"backend": self._backend or "unknown",
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@
|
|||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_bot_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
topic_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_topic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
|
|
@ -24,7 +15,7 @@ CREATE TABLE IF NOT EXISTS topic_topic (
|
|||
view_schema_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_item (
|
||||
|
|
@ -40,12 +31,9 @@ CREATE TABLE IF NOT EXISTS topic_item (
|
|||
dedupe_key TEXT,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES botinstance(id)
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_bot_settings_bot_id
|
||||
ON topic_bot_settings(bot_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key
|
||||
ON topic_topic(bot_id, topic_key);
|
||||
|
||||
|
|
|
|||
298
backend/main.py
298
backend/main.py
|
|
@ -9,12 +9,12 @@ import tempfile
|
|||
import zipfile
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlmodel import Session, select
|
||||
|
||||
|
|
@ -30,7 +30,6 @@ from core.speech_service import (
|
|||
)
|
||||
from core.settings import (
|
||||
BOTS_WORKSPACE_ROOT,
|
||||
CHAT_PULL_PAGE_SIZE,
|
||||
DATA_ROOT,
|
||||
DATABASE_ECHO,
|
||||
DATABASE_ENGINE,
|
||||
|
|
@ -46,22 +45,28 @@ from core.settings import (
|
|||
REDIS_ENABLED,
|
||||
REDIS_PREFIX,
|
||||
REDIS_URL,
|
||||
STT_DEVICE,
|
||||
STT_DEFAULT_LANGUAGE,
|
||||
STT_ENABLED,
|
||||
STT_MAX_AUDIO_SECONDS,
|
||||
STT_MODEL,
|
||||
TOPIC_PRESET_TEMPLATES,
|
||||
TOPIC_PRESETS_TEMPLATES_FILE,
|
||||
UPLOAD_MAX_MB,
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS,
|
||||
load_agent_md_templates,
|
||||
load_topic_presets_template,
|
||||
)
|
||||
from models.bot import BotInstance, BotMessage, NanobotImage
|
||||
from models.topic import TopicBotSettings, TopicItem, TopicTopic
|
||||
from models.platform import BotActivityEvent, BotRequestUsage
|
||||
from models.topic import TopicItem, TopicTopic
|
||||
from api.platform_router import router as platform_router
|
||||
from api.topic_router import router as topic_router
|
||||
from services.topic_runtime import publish_runtime_topic_packet
|
||||
from services.platform_service import (
|
||||
bind_usage_message,
|
||||
create_usage_request,
|
||||
fail_latest_usage,
|
||||
finalize_usage_from_packet,
|
||||
get_chat_pull_page_size,
|
||||
get_platform_settings_snapshot,
|
||||
get_speech_runtime_settings,
|
||||
prune_expired_activity_events,
|
||||
record_activity_event,
|
||||
)
|
||||
|
||||
app = FastAPI(title="Dashboard Nanobot API")
|
||||
logger = logging.getLogger("dashboard.backend")
|
||||
|
|
@ -73,6 +78,7 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(topic_router)
|
||||
app.include_router(platform_router)
|
||||
|
||||
os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True)
|
||||
os.makedirs(DATA_ROOT, exist_ok=True)
|
||||
|
|
@ -80,6 +86,8 @@ os.makedirs(DATA_ROOT, exist_ok=True)
|
|||
docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
|
||||
speech_service = WhisperSpeechService()
|
||||
app.state.docker_manager = docker_manager
|
||||
app.state.speech_service = speech_service
|
||||
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
|
|
@ -182,6 +190,10 @@ class MessageFeedbackRequest(BaseModel):
|
|||
feedback: Optional[str] = None # up | down | null
|
||||
|
||||
|
||||
class WorkspaceFileUpdateRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class PanelLoginRequest(BaseModel):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
|
@ -261,6 +273,14 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
session.add(message_row)
|
||||
session.flush()
|
||||
persisted_message_id = message_row.id
|
||||
usage_row = finalize_usage_from_packet(
|
||||
session,
|
||||
bot_id,
|
||||
{
|
||||
**packet,
|
||||
"message_id": persisted_message_id,
|
||||
},
|
||||
)
|
||||
elif packet_type == "USER_COMMAND":
|
||||
text_msg = str(packet.get("text") or "").strip()
|
||||
media_list = _normalize_media_list(packet.get("media"), bot_id)
|
||||
|
|
@ -274,12 +294,19 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
session.add(message_row)
|
||||
session.flush()
|
||||
persisted_message_id = message_row.id
|
||||
bind_usage_message(
|
||||
session,
|
||||
bot_id,
|
||||
str(packet.get("request_id") or "").strip(),
|
||||
persisted_message_id,
|
||||
)
|
||||
elif packet_type == "BUS_EVENT":
|
||||
# Dashboard channel emits BUS_EVENT for both progress and final replies.
|
||||
# Persist only non-progress events to keep durable chat history clean.
|
||||
is_progress = bool(packet.get("is_progress"))
|
||||
detail_text = str(packet.get("content") or packet.get("text") or "").strip()
|
||||
if not is_progress:
|
||||
text_msg = str(packet.get("content") or packet.get("text") or "").strip()
|
||||
text_msg = detail_text
|
||||
media_list = _normalize_media_list(packet.get("media"), bot_id)
|
||||
if text_msg or media_list:
|
||||
bot.current_state = "IDLE"
|
||||
|
|
@ -294,6 +321,18 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
session.add(message_row)
|
||||
session.flush()
|
||||
persisted_message_id = message_row.id
|
||||
usage_row = finalize_usage_from_packet(
|
||||
session,
|
||||
bot_id,
|
||||
{
|
||||
"text": text_msg,
|
||||
"usage": packet.get("usage"),
|
||||
"request_id": packet.get("request_id"),
|
||||
"provider": packet.get("provider"),
|
||||
"model": packet.get("model"),
|
||||
"message_id": persisted_message_id,
|
||||
},
|
||||
)
|
||||
|
||||
bot.updated_at = datetime.utcnow()
|
||||
session.add(bot)
|
||||
|
|
@ -528,6 +567,9 @@ async def on_startup():
|
|||
init_database()
|
||||
cache.delete_prefix("")
|
||||
with Session(engine) as session:
|
||||
pruned_events = prune_expired_activity_events(session, force=True)
|
||||
if pruned_events > 0:
|
||||
session.commit()
|
||||
for bot in session.exec(select(BotInstance)).all():
|
||||
_migrate_bot_resources_store(bot.id)
|
||||
running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all()
|
||||
|
|
@ -554,6 +596,8 @@ def _provider_defaults(provider: str) -> tuple[str, str]:
|
|||
def get_system_defaults():
|
||||
md_templates = load_agent_md_templates()
|
||||
topic_presets = load_topic_presets_template()
|
||||
platform_settings = get_platform_settings_snapshot()
|
||||
speech_settings = get_speech_runtime_settings()
|
||||
return {
|
||||
"templates": {
|
||||
"soul_md": md_templates.get("soul_md") or DEFAULT_SOUL_MD,
|
||||
|
|
@ -563,21 +607,24 @@ def get_system_defaults():
|
|||
"identity_md": md_templates.get("identity_md") or DEFAULT_IDENTITY_MD,
|
||||
},
|
||||
"limits": {
|
||||
"upload_max_mb": UPLOAD_MAX_MB,
|
||||
"upload_max_mb": platform_settings.upload_max_mb,
|
||||
},
|
||||
"workspace": {
|
||||
"download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS),
|
||||
"download_extensions": list(platform_settings.workspace_download_extensions),
|
||||
"allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions),
|
||||
},
|
||||
"loading_page": platform_settings.loading_page.model_dump(),
|
||||
"chat": {
|
||||
"pull_page_size": CHAT_PULL_PAGE_SIZE,
|
||||
"pull_page_size": platform_settings.chat_pull_page_size,
|
||||
"page_size": platform_settings.page_size,
|
||||
},
|
||||
"topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES,
|
||||
"speech": {
|
||||
"enabled": STT_ENABLED,
|
||||
"model": STT_MODEL,
|
||||
"device": STT_DEVICE,
|
||||
"max_audio_seconds": STT_MAX_AUDIO_SECONDS,
|
||||
"default_language": STT_DEFAULT_LANGUAGE,
|
||||
"enabled": speech_settings["enabled"],
|
||||
"model": speech_settings["model"],
|
||||
"device": speech_settings["device"],
|
||||
"max_audio_seconds": speech_settings["max_audio_seconds"],
|
||||
"default_language": speech_settings["default_language"],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -590,6 +637,14 @@ def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
|||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _write_text_atomic(path: str, content: str) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(str(content or ""))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
@app.get("/api/system/templates")
|
||||
def get_system_templates():
|
||||
return {
|
||||
|
|
@ -1912,6 +1967,15 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
|
|||
},
|
||||
)
|
||||
session.refresh(bot)
|
||||
record_activity_event(
|
||||
session,
|
||||
normalized_bot_id,
|
||||
"bot_created",
|
||||
channel="system",
|
||||
detail=f"Bot {normalized_bot_id} created",
|
||||
metadata={"image_tag": payload.image_tag},
|
||||
)
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(normalized_bot_id)
|
||||
return _serialize_bot(bot)
|
||||
|
||||
|
|
@ -2149,6 +2213,7 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)):
|
|||
detail="Bot container failed shortly after startup. Check bot logs/config.",
|
||||
)
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {"status": "started"}
|
||||
|
|
@ -2165,6 +2230,7 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)):
|
|||
docker_manager.stop_bot(bot_id)
|
||||
bot.docker_status = "STOPPED"
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_stopped", channel="system", detail=f"Container stopped for {bot_id}")
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {"status": "stopped"}
|
||||
|
|
@ -2178,6 +2244,7 @@ def enable_bot(bot_id: str, session: Session = Depends(get_session)):
|
|||
|
||||
bot.enabled = True
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled")
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {"status": "enabled", "enabled": True}
|
||||
|
|
@ -2195,6 +2262,7 @@ def disable_bot(bot_id: str, session: Session = Depends(get_session)):
|
|||
if str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||
bot.current_state = "IDLE"
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_disabled", channel="system", detail=f"Bot {bot_id} disabled")
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {"status": "disabled", "enabled": False}
|
||||
|
|
@ -2212,6 +2280,7 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)):
|
|||
if str(bot.current_state or "").upper() not in {"ERROR"}:
|
||||
bot.current_state = "IDLE"
|
||||
session.add(bot)
|
||||
record_activity_event(session, bot_id, "bot_deactivated", channel="system", detail=f"Bot {bot_id} deactivated")
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {"status": "deactivated"}
|
||||
|
|
@ -2234,8 +2303,11 @@ def delete_bot(bot_id: str, delete_workspace: bool = True, session: Session = De
|
|||
topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all()
|
||||
for row in topics:
|
||||
session.delete(row)
|
||||
topic_settings = session.exec(select(TopicBotSettings).where(TopicBotSettings.bot_id == bot_id)).all()
|
||||
for row in topic_settings:
|
||||
usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all()
|
||||
for row in usage_rows:
|
||||
session.delete(row)
|
||||
activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
|
||||
for row in activity_rows:
|
||||
session.delete(row)
|
||||
|
||||
session.delete(bot)
|
||||
|
|
@ -2374,7 +2446,7 @@ async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), sessio
|
|||
if not filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
|
||||
|
||||
max_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
|
||||
tmp_zip_path: Optional[str] = None
|
||||
total_size = 0
|
||||
try:
|
||||
|
|
@ -2553,6 +2625,8 @@ def update_bot_channel(
|
|||
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
|
||||
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
existing_type = str(row.get("channel_type") or "").strip().lower()
|
||||
|
|
@ -2640,6 +2714,7 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
runtime_snapshot = _read_bot_runtime_snapshot(bot)
|
||||
|
||||
attachments = _normalize_media_list(payload.attachments, bot_id)
|
||||
command = str(payload.command or "").strip()
|
||||
|
|
@ -2706,6 +2781,30 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend
|
|||
"Reply language must follow USER.md. If not specified, use the same language as the user input."
|
||||
)
|
||||
|
||||
request_id = create_usage_request(
|
||||
session,
|
||||
bot_id,
|
||||
display_command,
|
||||
attachments=checked_attachments,
|
||||
channel="dashboard",
|
||||
metadata={"attachment_count": len(checked_attachments)},
|
||||
provider=str(runtime_snapshot.get("llm_provider") or "").strip() or None,
|
||||
model=str(runtime_snapshot.get("llm_model") or "").strip() or None,
|
||||
)
|
||||
record_activity_event(
|
||||
session,
|
||||
bot_id,
|
||||
"command_submitted",
|
||||
request_id=request_id,
|
||||
channel="dashboard",
|
||||
detail="command submitted",
|
||||
metadata={
|
||||
"attachment_count": len(checked_attachments),
|
||||
"has_text": bool(command),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
outbound_user_packet: Optional[Dict[str, Any]] = None
|
||||
if display_command or checked_attachments:
|
||||
outbound_user_packet = {
|
||||
|
|
@ -2713,6 +2812,7 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend
|
|||
"channel": "dashboard",
|
||||
"text": display_command,
|
||||
"media": checked_attachments,
|
||||
"request_id": request_id,
|
||||
}
|
||||
_persist_runtime_packet(bot_id, outbound_user_packet)
|
||||
|
||||
|
|
@ -2726,6 +2826,16 @@ def send_command(bot_id: str, payload: CommandRequest, session: Session = Depend
|
|||
success = docker_manager.send_command(bot_id, delivery_command, media=delivery_media)
|
||||
if not success:
|
||||
detail = docker_manager.get_last_delivery_error(bot_id)
|
||||
fail_latest_usage(session, bot_id, detail or "command delivery failed")
|
||||
record_activity_event(
|
||||
session,
|
||||
bot_id,
|
||||
"command_failed",
|
||||
request_id=request_id,
|
||||
channel="dashboard",
|
||||
detail=(detail or "command delivery failed")[:400],
|
||||
)
|
||||
session.commit()
|
||||
if loop and loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast(
|
||||
|
|
@ -2784,7 +2894,7 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(
|
|||
@app.get("/api/bots/{bot_id}/messages/page")
|
||||
def list_bot_messages_page(
|
||||
bot_id: str,
|
||||
limit: int = CHAT_PULL_PAGE_SIZE,
|
||||
limit: Optional[int] = None,
|
||||
before_id: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
|
|
@ -2792,7 +2902,8 @@ def list_bot_messages_page(
|
|||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
configured_limit = get_chat_pull_page_size()
|
||||
safe_limit = max(1, min(int(limit if limit is not None else configured_limit), 500))
|
||||
safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None
|
||||
cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
|
||||
cached = cache.get_json(cache_key)
|
||||
|
|
@ -2896,6 +3007,14 @@ def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
|
|||
bot.current_state = "IDLE"
|
||||
bot.updated_at = datetime.utcnow()
|
||||
session.add(bot)
|
||||
record_activity_event(
|
||||
session,
|
||||
bot_id,
|
||||
"history_cleared",
|
||||
channel="system",
|
||||
detail=f"Cleared {deleted} stored messages",
|
||||
metadata={"deleted_messages": deleted, "cleared_sessions": cleared_sessions},
|
||||
)
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
_invalidate_bot_messages_cache(bot_id)
|
||||
|
|
@ -2992,17 +3111,56 @@ def read_workspace_file(
|
|||
body = raw[:safe_max] if truncated else raw
|
||||
text_body = body.decode("utf-8", errors="replace")
|
||||
rel_path = os.path.relpath(target, root).replace("\\", "/")
|
||||
is_markdown = ext in {".md", ".markdown"}
|
||||
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"path": rel_path,
|
||||
"size": os.path.getsize(target),
|
||||
"is_markdown": rel_path.lower().endswith(".md"),
|
||||
"is_markdown": is_markdown,
|
||||
"truncated": truncated,
|
||||
"content": text_body,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/bots/{bot_id}/workspace/file")
|
||||
def update_workspace_file(
|
||||
bot_id: str,
|
||||
path: str,
|
||||
payload: WorkspaceFileUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
root, target = _resolve_workspace_path(bot_id, path)
|
||||
if not os.path.isfile(target):
|
||||
raise HTTPException(status_code=404, detail="workspace file not found")
|
||||
|
||||
ext = os.path.splitext(target)[1].lower()
|
||||
if ext not in {".md", ".markdown"}:
|
||||
raise HTTPException(status_code=400, detail=f"editing is only supported for markdown files: {ext or '(none)'}")
|
||||
|
||||
content = str(payload.content or "")
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) > 2_000_000:
|
||||
raise HTTPException(status_code=413, detail="markdown file too large to save")
|
||||
if "\x00" in content:
|
||||
raise HTTPException(status_code=400, detail="markdown content contains invalid null bytes")
|
||||
|
||||
_write_text_atomic(target, content)
|
||||
rel_path = os.path.relpath(target, root).replace("\\", "/")
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"path": rel_path,
|
||||
"size": os.path.getsize(target),
|
||||
"is_markdown": True,
|
||||
"truncated": False,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024):
|
||||
with open(target, "rb") as fh:
|
||||
fh.seek(start)
|
||||
|
|
@ -3055,7 +3213,24 @@ def _build_ranged_workspace_response(target: str, media_type: str, range_header:
|
|||
)
|
||||
|
||||
|
||||
def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Request, session: Session):
|
||||
def _build_workspace_raw_url(bot_id: str, path: str, public: bool) -> str:
|
||||
normalized = "/".join(part for part in str(path or "").strip().split("/") if part)
|
||||
if not normalized:
|
||||
return ""
|
||||
prefix = "/public" if public else "/api"
|
||||
return f"{prefix}/bots/{quote(bot_id, safe='')}/workspace/raw/{quote(normalized, safe='/')}"
|
||||
|
||||
|
||||
def _serve_workspace_file(
|
||||
bot_id: str,
|
||||
path: str,
|
||||
download: bool,
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
public: bool = False,
|
||||
redirect_html_to_raw: bool = False,
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
|
@ -3065,6 +3240,10 @@ def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Reque
|
|||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
media_type, _ = mimetypes.guess_type(target)
|
||||
if redirect_html_to_raw and not download and str(media_type or "").startswith("text/html"):
|
||||
raw_url = _build_workspace_raw_url(bot_id, path, public=public)
|
||||
if raw_url:
|
||||
return RedirectResponse(url=raw_url, status_code=307)
|
||||
range_header = request.headers.get("range", "")
|
||||
if range_header and not download:
|
||||
return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header)
|
||||
|
|
@ -3144,7 +3323,15 @@ def download_workspace_file(
|
|||
request: Request = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
|
||||
return _serve_workspace_file(
|
||||
bot_id=bot_id,
|
||||
path=path,
|
||||
download=download,
|
||||
request=request,
|
||||
session=session,
|
||||
public=False,
|
||||
redirect_html_to_raw=True,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/public/bots/{bot_id}/workspace/download")
|
||||
|
|
@ -3155,7 +3342,15 @@ def public_download_workspace_file(
|
|||
request: Request = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
|
||||
return _serve_workspace_file(
|
||||
bot_id=bot_id,
|
||||
path=path,
|
||||
download=download,
|
||||
request=request,
|
||||
session=session,
|
||||
public=True,
|
||||
redirect_html_to_raw=True,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/workspace/raw/{path:path}")
|
||||
|
|
@ -3166,7 +3361,15 @@ def raw_workspace_file(
|
|||
request: Request = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
|
||||
return _serve_workspace_file(
|
||||
bot_id=bot_id,
|
||||
path=path,
|
||||
download=download,
|
||||
request=request,
|
||||
session=session,
|
||||
public=False,
|
||||
redirect_html_to_raw=False,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/public/bots/{bot_id}/workspace/raw/{path:path}")
|
||||
|
|
@ -3177,7 +3380,15 @@ def public_raw_workspace_file(
|
|||
request: Request = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
|
||||
return _serve_workspace_file(
|
||||
bot_id=bot_id,
|
||||
path=path,
|
||||
download=download,
|
||||
request=request,
|
||||
session=session,
|
||||
public=True,
|
||||
redirect_html_to_raw=False,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/bots/{bot_id}/workspace/upload")
|
||||
|
|
@ -3192,7 +3403,9 @@ async def upload_workspace_files(
|
|||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="no files uploaded")
|
||||
max_bytes = UPLOAD_MAX_MB * 1024 * 1024
|
||||
platform_settings = get_platform_settings_snapshot()
|
||||
max_bytes = platform_settings.upload_max_mb * 1024 * 1024
|
||||
allowed_extensions = set(platform_settings.allowed_attachment_extensions)
|
||||
|
||||
root, upload_dir = _resolve_workspace_path(bot_id, path or "uploads")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
|
@ -3207,6 +3420,12 @@ async def upload_workspace_files(
|
|||
name = re.sub(r"[^\w.\-()+@ ]+", "_", name)
|
||||
if not name:
|
||||
name = "upload.bin"
|
||||
ext = str(os.path.splitext(name)[1] or "").strip().lower()
|
||||
if allowed_extensions and ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File '{name}' extension is not allowed. Allowed: {', '.join(sorted(allowed_extensions))}",
|
||||
)
|
||||
|
||||
abs_path = os.path.join(safe_dir_real, name)
|
||||
if os.path.exists(abs_path):
|
||||
|
|
@ -3262,7 +3481,8 @@ async def transcribe_bot_speech(
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
if not STT_ENABLED:
|
||||
speech_settings = get_speech_runtime_settings()
|
||||
if not speech_settings["enabled"]:
|
||||
raise HTTPException(status_code=400, detail="Speech recognition is disabled")
|
||||
if not file:
|
||||
raise HTTPException(status_code=400, detail="no audio file uploaded")
|
||||
|
|
@ -3286,7 +3506,7 @@ async def transcribe_bot_speech(
|
|||
if not tmp_path or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) <= 0:
|
||||
raise HTTPException(status_code=400, detail="audio payload is empty")
|
||||
|
||||
resolved_language = str(language or "").strip() or STT_DEFAULT_LANGUAGE
|
||||
resolved_language = str(language or "").strip() or speech_settings["default_language"]
|
||||
result = await asyncio.to_thread(speech_service.transcribe_file, tmp_path, resolved_language)
|
||||
text = str(result.get("text") or "").strip()
|
||||
if not text:
|
||||
|
|
@ -3295,9 +3515,9 @@ async def transcribe_bot_speech(
|
|||
"bot_id": bot_id,
|
||||
"text": text,
|
||||
"duration_seconds": result.get("duration_seconds"),
|
||||
"max_audio_seconds": STT_MAX_AUDIO_SECONDS,
|
||||
"model": STT_MODEL,
|
||||
"device": STT_DEVICE,
|
||||
"max_audio_seconds": speech_settings["max_audio_seconds"],
|
||||
"model": speech_settings["model"],
|
||||
"device": speech_settings["device"],
|
||||
"language": result.get("language") or resolved_language,
|
||||
}
|
||||
except SpeechDisabledError as exc:
|
||||
|
|
@ -3315,9 +3535,9 @@ async def transcribe_bot_speech(
|
|||
bot_id,
|
||||
safe_name,
|
||||
language,
|
||||
STT_MAX_AUDIO_SECONDS,
|
||||
speech_settings["max_audio_seconds"],
|
||||
)
|
||||
raise HTTPException(status_code=413, detail=f"Audio duration exceeds {STT_MAX_AUDIO_SECONDS} seconds")
|
||||
raise HTTPException(status_code=413, detail=f"Audio duration exceeds {speech_settings['max_audio_seconds']} seconds")
|
||||
except SpeechServiceError as exc:
|
||||
logger.exception(
|
||||
"speech transcribe failed bot_id=%s file=%s language=%s",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from sqlmodel import Field, SQLModel
|
|||
from datetime import datetime
|
||||
|
||||
class BotInstance(SQLModel, table=True):
|
||||
__tablename__ = "bot_instance"
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
name: str
|
||||
enabled: bool = Field(default=True, index=True)
|
||||
|
|
@ -16,8 +18,10 @@ class BotInstance(SQLModel, table=True):
|
|||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class BotMessage(SQLModel, table=True):
|
||||
__tablename__ = "bot_message"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
bot_id: str = Field(foreign_key="botinstance.id", index=True)
|
||||
bot_id: str = Field(foreign_key="bot_instance.id", index=True)
|
||||
role: str = Field(index=True) # user | assistant | system
|
||||
text: str
|
||||
media_json: Optional[str] = Field(default=None) # JSON string list of workspace-relative file paths
|
||||
|
|
@ -26,6 +30,8 @@ class BotMessage(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
|
||||
class NanobotImage(SQLModel, table=True):
|
||||
__tablename__ = "bot_image"
|
||||
|
||||
tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4
|
||||
image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID
|
||||
version: str # e.g., 0.1.4
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -5,19 +5,6 @@ from sqlalchemy import Index, UniqueConstraint
|
|||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class TopicBotSettings(SQLModel, table=True):
|
||||
__tablename__ = "topic_bot_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("bot_id", name="uq_topic_bot_settings_bot_id"),
|
||||
)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
bot_id: str = Field(foreign_key="botinstance.id", index=True)
|
||||
topic_enabled: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class TopicTopic(SQLModel, table=True):
|
||||
__tablename__ = "topic_topic"
|
||||
__table_args__ = (
|
||||
|
|
@ -26,7 +13,7 @@ class TopicTopic(SQLModel, table=True):
|
|||
)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
bot_id: str = Field(foreign_key="botinstance.id", index=True)
|
||||
bot_id: str = Field(foreign_key="bot_instance.id", index=True)
|
||||
topic_key: str = Field(index=True)
|
||||
name: str = Field(default="")
|
||||
description: str = Field(default="")
|
||||
|
|
@ -46,7 +33,7 @@ class TopicItem(SQLModel, table=True):
|
|||
)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
bot_id: str = Field(foreign_key="botinstance.id", index=True)
|
||||
bot_id: str = Field(foreign_key="bot_instance.id", index=True)
|
||||
topic_key: str = Field(index=True)
|
||||
title: str = Field(default="")
|
||||
content: str = Field(default="")
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,24 +1,56 @@
|
|||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from typing import Any
|
||||
import json
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
class DashboardChannel(BaseChannel):
|
||||
"""
|
||||
专门为管理面板设计的渠道。
|
||||
它充当机器人内部总线 (Bus) 与宿主机面板之间的桥梁。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Any, bus: Any, host: str = "0.0.0.0", port: int = 9000):
|
||||
super().__init__(config, bus)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.runner = None
|
||||
|
||||
name = "dashboard"
|
||||
display_name = "Dashboard"
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": False,
|
||||
"host": "0.0.0.0",
|
||||
"port": 9000,
|
||||
"allow_from": ["*"],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _coerce_config(cls, config: Any) -> Any:
|
||||
if config is None:
|
||||
return SimpleNamespace(**cls.default_config())
|
||||
if isinstance(config, dict):
|
||||
merged = cls.default_config()
|
||||
merged.update(config)
|
||||
if "allowFrom" in config and "allow_from" not in config:
|
||||
merged["allow_from"] = config.get("allowFrom")
|
||||
return SimpleNamespace(**merged)
|
||||
return config
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Any,
|
||||
bus: MessageBus,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
):
|
||||
config_obj = self._coerce_config(config)
|
||||
super().__init__(config_obj, bus)
|
||||
self.host = host if host is not None else getattr(config_obj, "host", "0.0.0.0")
|
||||
self.port = port if port is not None else getattr(config_obj, "port", 9000)
|
||||
self.runner: web.AppRunner | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 Dashboard HTTP 服务"""
|
||||
|
|
@ -30,14 +62,15 @@ class DashboardChannel(BaseChannel):
|
|||
site = web.TCPSite(self.runner, self.host, self.port)
|
||||
|
||||
await site.start()
|
||||
self._is_running = True
|
||||
self._running = True
|
||||
logger.info(f"🚀 Dashboard Channel 代理已上线,监听端口: {self.port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止服务"""
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
self._is_running = False
|
||||
self.runner = None
|
||||
self._running = False
|
||||
logger.info("Dashboard Channel 已下线")
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
|
|
@ -49,8 +82,9 @@ class DashboardChannel(BaseChannel):
|
|||
return
|
||||
|
||||
# 核心:从元数据识别消息类型(进度更新 vs 最终回复)
|
||||
is_progress = message.metadata.get("_progress", False)
|
||||
is_tool_hint = message.metadata.get("_tool_hint", False)
|
||||
metadata = message.metadata or {}
|
||||
is_progress = metadata.get("_progress", False)
|
||||
is_tool_hint = metadata.get("_tool_hint", False)
|
||||
|
||||
payload = {
|
||||
"type": "BUS_EVENT",
|
||||
|
|
@ -60,6 +94,18 @@ class DashboardChannel(BaseChannel):
|
|||
"content": message.content,
|
||||
"media": media,
|
||||
}
|
||||
usage = metadata.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
payload["usage"] = usage
|
||||
request_id = str(metadata.get("request_id") or "").strip()
|
||||
if request_id:
|
||||
payload["request_id"] = request_id
|
||||
provider = str(metadata.get("provider") or "").strip()
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
model = str(metadata.get("model") or "").strip()
|
||||
if model:
|
||||
payload["model"] = model
|
||||
|
||||
# 使用 JSON 格式输出,方便面板后端精准解析,告别正则
|
||||
print(f"\n__DASHBOARD_DATA_START__{json.dumps(payload, ensure_ascii=False)}__DASHBOARD_DATA_END__\n", flush=True)
|
||||
|
|
@ -79,14 +125,13 @@ class DashboardChannel(BaseChannel):
|
|||
# 调试日志:打印收到的原始消息长度和前 20 个字符,确保中文未乱码
|
||||
logger.info(f"📥 [Dashboard Channel] 收到指令 (len={len(user_message)}): {user_message[:20]}...")
|
||||
|
||||
# 将消息塞入总线
|
||||
await self.bus.publish_inbound(InboundMessage(
|
||||
channel="dashboard",
|
||||
sender_id="user",
|
||||
chat_id="direct",
|
||||
# 统一走基类入口,兼容不同核心的会话与权限逻辑。
|
||||
await self._handle_message(
|
||||
sender_id="user",
|
||||
chat_id="direct",
|
||||
content=user_message,
|
||||
media=media,
|
||||
))
|
||||
)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,79 +1,97 @@
|
|||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ChevronDown, ChevronUp, MoonStar, SunMedium, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react';
|
||||
import { useAppStore } from './store/appStore';
|
||||
import { useBotsSync } from './hooks/useBotsSync';
|
||||
import { APP_ENDPOINTS } from './config/env';
|
||||
import { ImageFactoryModule } from './modules/images/ImageFactoryModule';
|
||||
import { BotWizardModule } from './modules/onboarding/BotWizardModule';
|
||||
import { BotDashboardModule } from './modules/dashboard/BotDashboardModule';
|
||||
import { pickLocale } from './i18n';
|
||||
import { appZhCn } from './i18n/app.zh-cn';
|
||||
import { appEn } from './i18n/app.en';
|
||||
import { LucentIconButton } from './components/lucent/LucentIconButton';
|
||||
import { LucentTooltip } from './components/lucent/LucentTooltip';
|
||||
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
|
||||
import { BotHomePage } from './modules/bot-home/BotHomePage';
|
||||
import { PlatformDashboardPage } from './modules/platform/PlatformDashboardPage';
|
||||
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
|
||||
import './App.css';
|
||||
|
||||
function getSingleBotPasswordKey(botId: string) {
|
||||
return `nanobot-bot-page-password:${String(botId || '').trim()}`;
|
||||
}
|
||||
|
||||
function AuthenticatedApp({
|
||||
forcedBotId,
|
||||
compactMode,
|
||||
}: {
|
||||
forcedBotId?: string;
|
||||
compactMode: boolean;
|
||||
}) {
|
||||
const defaultLoadingPage = {
|
||||
title: 'Dashboard Nanobot',
|
||||
subtitle: '平台正在准备管理面板',
|
||||
description: '请稍候,正在加载 Bot 平台数据。',
|
||||
};
|
||||
|
||||
function AuthenticatedApp() {
|
||||
const route = useAppRoute();
|
||||
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
|
||||
const [showImageFactory, setShowImageFactory] = useState(false);
|
||||
const [showCreateWizard, setShowCreateWizard] = useState(false);
|
||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||
const [viewportCompact, setViewportCompact] = useState(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(max-width: 980px)').matches;
|
||||
});
|
||||
const [headerCollapsed, setHeaderCollapsed] = useState(false);
|
||||
const [singleBotPassword, setSingleBotPassword] = useState('');
|
||||
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
|
||||
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
|
||||
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
|
||||
useBotsSync(forcedBotId);
|
||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||
const isCompactShell = compactMode;
|
||||
const [headerCollapsed, setHeaderCollapsed] = useState(isCompactShell);
|
||||
const forced = String(forcedBotId || '').trim();
|
||||
const forcedBot = forced ? activeBots[forced] : undefined;
|
||||
const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked);
|
||||
|
||||
const forcedBotId = route.kind === 'bot' ? route.botId : '';
|
||||
useBotsSync(forcedBotId || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forced) {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const media = window.matchMedia('(max-width: 980px)');
|
||||
const apply = () => setViewportCompact(media.matches);
|
||||
apply();
|
||||
media.addEventListener('change', apply);
|
||||
return () => media.removeEventListener('change', apply);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderCollapsed(readCompactModeFromUrl() || viewportCompact);
|
||||
}, [viewportCompact, route.kind, forcedBotId]);
|
||||
|
||||
const compactMode = readCompactModeFromUrl() || viewportCompact;
|
||||
const isCompactShell = compactMode;
|
||||
const hideHeader = route.kind === 'dashboard' && compactMode;
|
||||
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
|
||||
const fromPanelOpen = typeof window !== 'undefined' && (new URLSearchParams(window.location.search).get('from_panel') || '').trim() === '1';
|
||||
const shouldPromptSingleBotPassword = Boolean(
|
||||
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked && !fromPanelOpen,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.kind !== 'bot') {
|
||||
document.title = t.title;
|
||||
return;
|
||||
}
|
||||
const botName = String(forcedBot?.name || '').trim();
|
||||
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`;
|
||||
}, [forced, forcedBot?.name, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderCollapsed(isCompactShell);
|
||||
}, [isCompactShell, forcedBotId]);
|
||||
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
|
||||
}, [forcedBot?.name, forcedBotId, route.kind, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
setSingleBotUnlocked(false);
|
||||
setSingleBotPassword('');
|
||||
setSingleBotPasswordError('');
|
||||
}, [forced]);
|
||||
}, [forcedBotId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
||||
const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : '';
|
||||
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
|
||||
const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forcedBotId)) || '' : '';
|
||||
if (!stored) return;
|
||||
let alive = true;
|
||||
const boot = async () => {
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: stored });
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
|
||||
if (!alive) return;
|
||||
setSingleBotUnlocked(true);
|
||||
setSingleBotPassword('');
|
||||
setSingleBotPasswordError('');
|
||||
} catch {
|
||||
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
|
||||
if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
|
||||
if (!alive) return;
|
||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
|
||||
}
|
||||
|
|
@ -82,27 +100,26 @@ function AuthenticatedApp({
|
|||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]);
|
||||
}, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
|
||||
|
||||
const unlockSingleBot = async () => {
|
||||
const entered = String(singleBotPassword || '').trim();
|
||||
if (!entered) {
|
||||
if (!entered || route.kind !== 'bot' || !forcedBotId) {
|
||||
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
|
||||
return;
|
||||
}
|
||||
if (!forced) return;
|
||||
setSingleBotSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: entered });
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered);
|
||||
window.sessionStorage.setItem(getSingleBotPasswordKey(forcedBotId), entered);
|
||||
}
|
||||
setSingleBotPasswordError('');
|
||||
setSingleBotUnlocked(true);
|
||||
setSingleBotPassword('');
|
||||
} catch {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
|
||||
window.sessionStorage.removeItem(getSingleBotPasswordKey(forcedBotId));
|
||||
}
|
||||
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
|
||||
} finally {
|
||||
|
|
@ -112,145 +129,90 @@ function AuthenticatedApp({
|
|||
|
||||
return (
|
||||
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
|
||||
<div className="app-frame">
|
||||
<header
|
||||
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
|
||||
onClick={() => {
|
||||
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<div className="row-between app-header-top">
|
||||
<div className="app-title">
|
||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
||||
<div className="app-title-main">
|
||||
<h1>{t.title}</h1>
|
||||
{isCompactShell ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-header-toggle-inline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setHeaderCollapsed((v) => !v);
|
||||
}}
|
||||
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
||||
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
||||
>
|
||||
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
|
||||
{!hideHeader ? (
|
||||
<header
|
||||
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
|
||||
onClick={() => {
|
||||
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<div className="row-between app-header-top">
|
||||
<div className="app-title">
|
||||
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
|
||||
<div className="app-title-main">
|
||||
<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 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-header-toggle-inline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setHeaderCollapsed((value) => !value);
|
||||
}}
|
||||
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
||||
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
|
||||
>
|
||||
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{!headerCollapsed ? (
|
||||
<div className="global-switches">
|
||||
<div className="switch-compact">
|
||||
<LucentTooltip content={t.dark}>
|
||||
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
|
||||
<MoonStar size={14} />
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
<LucentTooltip content={t.light}>
|
||||
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
|
||||
<SunMedium size={14} />
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
</div>
|
||||
|
||||
<div className="switch-compact">
|
||||
<LucentTooltip content={t.zh}>
|
||||
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
|
||||
<span>ZH</span>
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
<LucentTooltip content={t.en}>
|
||||
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
|
||||
<span>EN</span>
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{!headerCollapsed ? (
|
||||
<div className="global-switches">
|
||||
<div className="switch-compact">
|
||||
<LucentTooltip content={t.dark}>
|
||||
<button
|
||||
className={`switch-btn ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
aria-label={t.dark}
|
||||
>
|
||||
<MoonStar size={14} />
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
<LucentTooltip content={t.light}>
|
||||
<button
|
||||
className={`switch-btn ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('light')}
|
||||
aria-label={t.light}
|
||||
>
|
||||
<SunMedium size={14} />
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
</div>
|
||||
|
||||
<div className="switch-compact">
|
||||
<LucentTooltip content={t.zh}>
|
||||
<button
|
||||
className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`}
|
||||
onClick={() => setLocale('zh')}
|
||||
aria-label={t.zh}
|
||||
>
|
||||
<span>ZH</span>
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
<LucentTooltip content={t.en}>
|
||||
<button
|
||||
className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLocale('en')}
|
||||
aria-label={t.en}
|
||||
>
|
||||
<span>EN</span>
|
||||
</button>
|
||||
</LucentTooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<main className="main-stage">
|
||||
<BotDashboardModule
|
||||
onOpenCreateWizard={() => setShowCreateWizard(true)}
|
||||
onOpenImageFactory={() => setShowImageFactory(true)}
|
||||
forcedBotId={forcedBotId || undefined}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{route.kind === 'dashboard' ? (
|
||||
<PlatformDashboardPage compactMode={compactMode} />
|
||||
) : (
|
||||
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
|
||||
)}
|
||||
</main>
|
||||
</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 ? (
|
||||
<div className="modal-mask app-modal-mask">
|
||||
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
|
||||
<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>
|
||||
<div className="app-login-form">
|
||||
<input
|
||||
|
|
@ -279,53 +241,17 @@ function AuthenticatedApp({
|
|||
);
|
||||
}
|
||||
|
||||
function PanelLoginGate({
|
||||
children,
|
||||
}: {
|
||||
children: (props: { forcedBotId?: string; compactMode: boolean }) => ReactElement;
|
||||
}) {
|
||||
function PanelLoginGate({ children }: { children: ReactElement }) {
|
||||
const route = useAppRoute();
|
||||
const { theme, locale } = useAppStore();
|
||||
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
|
||||
const urlView = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const pathMatch = window.location.pathname.match(/^\/bot\/([^/?#]+)/i);
|
||||
let forcedBotIdFromPath = '';
|
||||
if (pathMatch?.[1]) {
|
||||
try {
|
||||
forcedBotIdFromPath = decodeURIComponent(pathMatch[1]).trim();
|
||||
} catch {
|
||||
forcedBotIdFromPath = String(pathMatch[1]).trim();
|
||||
}
|
||||
}
|
||||
const forcedBotIdFromQuery =
|
||||
(params.get('botId') || params.get('bot_id') || params.get('id') || '').trim();
|
||||
const forcedBotId = forcedBotIdFromPath || forcedBotIdFromQuery;
|
||||
const compactRaw = (params.get('compact') || params.get('h5') || params.get('mobile') || '').trim().toLowerCase();
|
||||
const compactByFlag = ['1', 'true', 'yes', 'on'].includes(compactRaw);
|
||||
const compactMode = compactByFlag;
|
||||
return { forcedBotId, compactMode };
|
||||
}, []);
|
||||
const [viewportCompact, setViewportCompact] = useState(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia('(max-width: 980px)').matches;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const media = window.matchMedia('(max-width: 980px)');
|
||||
const apply = () => setViewportCompact(media.matches);
|
||||
apply();
|
||||
media.addEventListener('change', apply);
|
||||
return () => media.removeEventListener('change', apply);
|
||||
}, []);
|
||||
const compactMode = urlView.compactMode || viewportCompact;
|
||||
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [required, setRequired] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim());
|
||||
const bypassPanelGate = route.kind === 'bot';
|
||||
|
||||
useEffect(() => {
|
||||
if (bypassPanelGate) {
|
||||
|
|
@ -440,11 +366,15 @@ function PanelLoginGate({
|
|||
);
|
||||
}
|
||||
|
||||
return children({ forcedBotId: urlView.forcedBotId || undefined, compactMode });
|
||||
return children;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <PanelLoginGate>{(urlView) => <AuthenticatedApp {...urlView} />}</PanelLoginGate>;
|
||||
return (
|
||||
<PanelLoginGate>
|
||||
<AuthenticatedApp />
|
||||
</PanelLoginGate>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const dashboardEn = {
|
|||
uploadFail: 'File upload failed.',
|
||||
attachmentAdded: (count: number) => `${count} attachment(s) added.`,
|
||||
uploadTooLarge: (files: string, limitMb: number) => `These files exceed the upload limit (${limitMb}MB): ${files}`,
|
||||
uploadTypeNotAllowed: (files: string, allowed: string) => `These files are not allowed to upload: ${files}. Allowed extensions: ${allowed}`,
|
||||
attachmentMessage: '[attachment message]',
|
||||
removeAttachment: 'Remove attachment',
|
||||
copyPrompt: 'Copy prompt',
|
||||
|
|
@ -234,7 +235,6 @@ export const dashboardEn = {
|
|||
previewTitle: 'Click to preview',
|
||||
fileNotPreviewable: 'File type not previewable',
|
||||
baseConfig: 'Base Configuration',
|
||||
baseConfigSub: 'Edit name, access password, and resource limits',
|
||||
accessPassword: 'Access Password',
|
||||
accessPasswordPlaceholder: 'Leave blank to disable password',
|
||||
botIdReadonly: 'Bot ID (Read-only)',
|
||||
|
|
@ -254,7 +254,12 @@ export const dashboardEn = {
|
|||
agentFiles: 'Agent Files',
|
||||
saveFiles: 'Save Files',
|
||||
filePreview: 'File Preview',
|
||||
editFile: 'Edit Markdown',
|
||||
previewMode: 'Preview Mode',
|
||||
fileTruncated: 'Large file: preview is truncated.',
|
||||
fileEditDisabled: 'Truncated previews cannot be edited directly.',
|
||||
fileSaved: 'Markdown saved.',
|
||||
fileSaveFail: 'Failed to save markdown.',
|
||||
download: 'Download',
|
||||
copyAddress: 'Copy URL',
|
||||
urlCopied: 'URL copied.',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const dashboardZhCn = {
|
|||
uploadFail: '文件上传失败。',
|
||||
attachmentAdded: (count: number) => `已添加 ${count} 个附件。`,
|
||||
uploadTooLarge: (files: string, limitMb: number) => `以下文件超过上传上限 ${limitMb}MB:${files}`,
|
||||
uploadTypeNotAllowed: (files: string, allowed: string) => `以下文件类型不允许上传:${files}。允许的后缀:${allowed}`,
|
||||
attachmentMessage: '[附件消息]',
|
||||
removeAttachment: '移除附件',
|
||||
copyPrompt: '复制指令',
|
||||
|
|
@ -234,7 +235,6 @@ export const dashboardZhCn = {
|
|||
previewTitle: '点击打开预览',
|
||||
fileNotPreviewable: '该文件类型不可预览',
|
||||
baseConfig: '基础配置',
|
||||
baseConfigSub: '可修改名称、访问密码与资源配额',
|
||||
accessPassword: '访问密码',
|
||||
accessPasswordPlaceholder: '留空表示不设置访问密码',
|
||||
botIdReadonly: 'Bot ID(只读)',
|
||||
|
|
@ -254,7 +254,12 @@ export const dashboardZhCn = {
|
|||
agentFiles: '代理文件配置',
|
||||
saveFiles: '保存文件',
|
||||
filePreview: '文件预览',
|
||||
editFile: '编辑 Markdown',
|
||||
previewMode: '预览模式',
|
||||
fileTruncated: '文件较大,当前内容为截断预览。',
|
||||
fileEditDisabled: '当前为截断预览,不能直接编辑保存。',
|
||||
fileSaved: 'Markdown 已保存。',
|
||||
fileSaveFail: 'Markdown 保存失败。',
|
||||
download: '下载',
|
||||
copyAddress: '复制地址',
|
||||
urlCopied: '地址已复制。',
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -2874,6 +2874,7 @@
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 340px;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
|
|
@ -2883,6 +2884,7 @@
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-path-wrap {
|
||||
|
|
@ -2899,6 +2901,9 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-refresh-icon-btn {
|
||||
|
|
@ -2933,6 +2938,15 @@
|
|||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.workspace-auto-switch-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-auto-switch input {
|
||||
|
|
@ -2991,6 +3005,7 @@
|
|||
background: var(--panel);
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-entry {
|
||||
|
|
@ -3010,10 +3025,14 @@
|
|||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
margin-bottom: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.workspace-entry .workspace-entry-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -3023,6 +3042,11 @@
|
|||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 1px;
|
||||
flex: 0 0 auto;
|
||||
max-width: 84px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-entry.dir {
|
||||
|
|
@ -3087,6 +3111,17 @@
|
|||
word-break: break-all;
|
||||
}
|
||||
|
||||
.workspace-entry-info-path {
|
||||
min-width: 0;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.workspace-entry-info-path .workspace-path-segments {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-hint {
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
|
|
@ -3098,6 +3133,8 @@
|
|||
|
||||
.modal-preview {
|
||||
width: min(1080px, 95vw);
|
||||
height: min(860px, 92vh);
|
||||
max-height: 92vh;
|
||||
}
|
||||
|
||||
.modal-preview-fullscreen {
|
||||
|
|
@ -3141,7 +3178,29 @@
|
|||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.workspace-path-segments {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
row-gap: 2px;
|
||||
column-gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-path-segment {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.workspace-path-separator {
|
||||
opacity: 0.6;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-preview-copy-name {
|
||||
|
|
@ -3181,14 +3240,20 @@
|
|||
}
|
||||
|
||||
.workspace-preview-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
|
||||
min-height: 52vh;
|
||||
max-height: 72vh;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.workspace-preview-body.is-editing {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-preview-body.media {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -3218,6 +3283,28 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-preview-editor {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
max-height: none;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
resize: vertical;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
line-height: 1.68;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.workspace-preview-editor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.workspace-preview-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import { APP_ENDPOINTS } from '../../config/env';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { ChatMessage } from '../../types/bot';
|
||||
|
|
@ -34,7 +34,6 @@ type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'sl
|
|||
type RuntimeViewMode = 'visual' | 'topic';
|
||||
type CompactPanelTab = 'chat' | 'runtime';
|
||||
type QuotedReply = { id?: number; text: string; ts: number };
|
||||
const BOT_LIST_PAGE_SIZE = 8;
|
||||
|
||||
interface WorkspaceNode {
|
||||
name: string;
|
||||
|
|
@ -271,9 +270,11 @@ interface SystemDefaultsResponse {
|
|||
upload_max_mb?: number;
|
||||
};
|
||||
workspace?: {
|
||||
allowed_attachment_extensions?: unknown;
|
||||
download_extensions?: unknown;
|
||||
};
|
||||
chat?: {
|
||||
page_size?: number;
|
||||
pull_page_size?: number;
|
||||
};
|
||||
topic_presets?: unknown;
|
||||
|
|
@ -558,6 +559,19 @@ function parseWorkspaceDownloadExtensions(
|
|||
return rows;
|
||||
}
|
||||
|
||||
function parseAllowedAttachmentExtensions(raw: unknown): string[] {
|
||||
if (raw === null || raw === undefined) return [];
|
||||
if (Array.isArray(raw) && raw.length === 0) return [];
|
||||
if (typeof raw === 'string' && raw.trim() === '') return [];
|
||||
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
|
||||
const rows: string[] = [];
|
||||
source.forEach((item) => {
|
||||
const ext = normalizeWorkspaceExtension(item);
|
||||
if (ext && !rows.includes(ext)) rows.push(ext);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
|
||||
const normalized = String(path || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
|
@ -660,6 +674,80 @@ function parseWorkspaceLink(href: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
|
||||
const path = String(pathRaw || '');
|
||||
if (!path) return ['-'];
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const hasLeadingSlash = normalized.startsWith('/');
|
||||
const parts = normalized.split('/').filter((part) => part.length > 0);
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (hasLeadingSlash) {
|
||||
nodes.push(<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 {
|
||||
if (!source) return source;
|
||||
const protectedLinks: string[] = [];
|
||||
|
|
@ -970,6 +1058,9 @@ export function BotDashboardModule({
|
|||
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
|
||||
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
|
||||
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
|
||||
const [workspacePreviewEditing, setWorkspacePreviewEditing] = useState(false);
|
||||
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
|
||||
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
|
||||
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
|
||||
const [workspaceQuery, setWorkspaceQuery] = useState('');
|
||||
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
|
||||
|
|
@ -1046,6 +1137,8 @@ export function BotDashboardModule({
|
|||
sendToolHints: false,
|
||||
});
|
||||
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
||||
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
|
||||
const [botListPageSize, setBotListPageSize] = useState(10);
|
||||
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
||||
const [chatHasMore, setChatHasMore] = useState(false);
|
||||
const [chatLoadingMore, setChatLoadingMore] = useState(false);
|
||||
|
|
@ -1142,10 +1235,31 @@ export function BotDashboardModule({
|
|||
const base = `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/raw/${normalized}`;
|
||||
return forceDownload ? `${base}?download=1` : base;
|
||||
};
|
||||
const buildWorkspacePreviewHref = (filePath: string) => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
if (!normalized) return '';
|
||||
return isHtmlPath(normalized)
|
||||
? buildWorkspaceRawHref(normalized, false)
|
||||
: buildWorkspaceDownloadHref(normalized, false);
|
||||
};
|
||||
const closeWorkspacePreview = () => {
|
||||
setWorkspacePreview(null);
|
||||
setWorkspacePreviewFullscreen(false);
|
||||
setWorkspacePreviewEditing(false);
|
||||
setWorkspacePreviewSaving(false);
|
||||
setWorkspacePreviewDraft('');
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!workspacePreview) {
|
||||
setWorkspacePreviewEditing(false);
|
||||
setWorkspacePreviewSaving(false);
|
||||
setWorkspacePreviewDraft('');
|
||||
return;
|
||||
}
|
||||
setWorkspacePreviewEditing(false);
|
||||
setWorkspacePreviewSaving(false);
|
||||
setWorkspacePreviewDraft(workspacePreview.content || '');
|
||||
}, [workspacePreview?.path, workspacePreview?.content]);
|
||||
const triggerWorkspaceFileDownload = (filePath: string) => {
|
||||
if (!selectedBotId) return;
|
||||
const normalized = String(filePath || '').trim();
|
||||
|
|
@ -1162,7 +1276,7 @@ export function BotDashboardModule({
|
|||
const copyWorkspacePreviewUrl = async (filePath: string) => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
if (!selectedBotId || !normalized) return;
|
||||
const hrefRaw = buildWorkspaceRawHref(normalized, false);
|
||||
const hrefRaw = buildWorkspacePreviewHref(normalized);
|
||||
const href = (() => {
|
||||
try {
|
||||
return new URL(hrefRaw, window.location.origin).href;
|
||||
|
|
@ -1239,28 +1353,28 @@ export function BotDashboardModule({
|
|||
}
|
||||
}
|
||||
};
|
||||
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string): string => {
|
||||
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
|
||||
const src = String(srcRaw || '').trim();
|
||||
if (!src || !selectedBotId) return src;
|
||||
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
|
||||
if (resolvedWorkspacePath) {
|
||||
return buildWorkspacePreviewHref(resolvedWorkspacePath);
|
||||
}
|
||||
const lower = src.toLowerCase();
|
||||
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||
const workspacePathFromLink = parseWorkspaceLink(src);
|
||||
if (workspacePathFromLink) {
|
||||
return buildWorkspaceDownloadHref(workspacePathFromLink, false);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
if (src.startsWith('/root/.nanobot/workspace/')) {
|
||||
const normalized = normalizeDashboardAttachmentPath(src);
|
||||
if (normalized) return buildWorkspaceDownloadHref(normalized, false);
|
||||
return src;
|
||||
}
|
||||
const workspacePathFromLink = parseWorkspaceLink(src);
|
||||
if (workspacePathFromLink) {
|
||||
return buildWorkspaceDownloadHref(workspacePathFromLink, false);
|
||||
}
|
||||
return src;
|
||||
}, [selectedBotId]);
|
||||
const transformWorkspacePreviewMarkdownUrl = (url: string, key: string): string => {
|
||||
if (!workspacePreview?.isMarkdown || !selectedBotId) return url;
|
||||
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(url, workspacePreview.path);
|
||||
if (!resolvedWorkspacePath) return url;
|
||||
if (key === 'href') {
|
||||
return buildWorkspaceLink(resolvedWorkspacePath);
|
||||
}
|
||||
return buildWorkspacePreviewHref(resolvedWorkspacePath);
|
||||
};
|
||||
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
|
||||
const source = String(text || '');
|
||||
if (!source) return [source];
|
||||
|
|
@ -1448,7 +1562,6 @@ export function BotDashboardModule({
|
|||
[activeBots],
|
||||
);
|
||||
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
|
||||
const singleBotHomeMode = hasForcedBot;
|
||||
const compactListFirstMode = compactMode && !hasForcedBot;
|
||||
const isCompactListPage = compactListFirstMode && !selectedBotId;
|
||||
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
|
||||
|
|
@ -1462,12 +1575,12 @@ export function BotDashboardModule({
|
|||
return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
|
||||
});
|
||||
}, [bots, normalizedBotListQuery]);
|
||||
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE));
|
||||
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize));
|
||||
const pagedBots = useMemo(() => {
|
||||
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
|
||||
const start = (page - 1) * BOT_LIST_PAGE_SIZE;
|
||||
return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE);
|
||||
}, [filteredBots, botListPage, botListTotalPages]);
|
||||
const start = (page - 1) * botListPageSize;
|
||||
return filteredBots.slice(start, start + botListPageSize);
|
||||
}, [filteredBots, botListPage, botListTotalPages, botListPageSize]);
|
||||
const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
|
||||
const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
|
||||
const messages = selectedBot?.messages || [];
|
||||
|
|
@ -1771,7 +1884,7 @@ export function BotDashboardModule({
|
|||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{decorateWorkspacePathsForMarkdown(displayText)}
|
||||
|
|
@ -2040,6 +2153,13 @@ export function BotDashboardModule({
|
|||
if (Number.isFinite(configured) && configured > 0) {
|
||||
setUploadMaxMb(Math.max(1, Math.floor(configured)));
|
||||
}
|
||||
const configuredPageSize = Number(res.data?.chat?.page_size);
|
||||
if (Number.isFinite(configuredPageSize) && configuredPageSize > 0) {
|
||||
setBotListPageSize(Math.max(1, Math.min(100, Math.floor(configuredPageSize))));
|
||||
}
|
||||
setAllowedAttachmentExtensions(
|
||||
parseAllowedAttachmentExtensions(res.data?.workspace?.allowed_attachment_extensions),
|
||||
);
|
||||
setWorkspaceDownloadExtensions(
|
||||
parseWorkspaceDownloadExtensions(
|
||||
res.data?.workspace?.download_extensions,
|
||||
|
|
@ -2285,6 +2405,39 @@ export function BotDashboardModule({
|
|||
setWorkspaceFileLoading(false);
|
||||
}
|
||||
};
|
||||
const saveWorkspacePreviewMarkdown = async () => {
|
||||
if (!selectedBotId || !workspacePreview?.isMarkdown) return;
|
||||
if (workspacePreview.truncated) {
|
||||
notify(t.fileEditDisabled, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
setWorkspacePreviewSaving(true);
|
||||
try {
|
||||
const res = await axios.put<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 = '') => {
|
||||
if (!botId) return;
|
||||
|
|
@ -3099,11 +3252,16 @@ export function BotDashboardModule({
|
|||
};
|
||||
|
||||
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) => {
|
||||
if (!selectedBot) return;
|
||||
if (!selectedBot || channel.locked || isDashboardChannel(channel)) return;
|
||||
setIsSavingChannel(true);
|
||||
try {
|
||||
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
|
||||
|
|
@ -3150,7 +3308,7 @@ export function BotDashboardModule({
|
|||
};
|
||||
|
||||
const removeChannel = async (channel: BotChannel) => {
|
||||
if (!selectedBot || channel.channel_type === 'dashboard') return;
|
||||
if (!selectedBot || channel.locked || channel.channel_type === 'dashboard') return;
|
||||
const ok = await confirm({
|
||||
title: t.channels,
|
||||
message: t.channelDeleteConfirm(channel.channel_type),
|
||||
|
|
@ -4138,11 +4296,44 @@ export function BotDashboardModule({
|
|||
const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
|
||||
const files = Array.from(event.target.files);
|
||||
const maxBytes = uploadMaxMb * 1024 * 1024;
|
||||
let effectiveUploadMaxMb = uploadMaxMb;
|
||||
let effectiveAllowedAttachmentExtensions = [...allowedAttachmentExtensions];
|
||||
try {
|
||||
const res = await axios.get<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);
|
||||
if (tooLarge.length > 0) {
|
||||
const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||
notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' });
|
||||
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
|
@ -4887,7 +5078,7 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="ops-bot-list-pagination">
|
||||
<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))}
|
||||
disabled={botListPage <= 1}
|
||||
tooltip={t.paginationPrev}
|
||||
|
|
@ -4895,9 +5086,9 @@ export function BotDashboardModule({
|
|||
>
|
||||
<ChevronLeft size={14} />
|
||||
</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
|
||||
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))}
|
||||
disabled={botListPage >= botListTotalPages}
|
||||
tooltip={t.paginationNext}
|
||||
|
|
@ -5083,6 +5274,7 @@ export function BotDashboardModule({
|
|||
ref={filePickerRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
|
||||
onChange={onPickAttachments}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
|
@ -5127,7 +5319,7 @@ export function BotDashboardModule({
|
|||
) : null}
|
||||
<button
|
||||
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
|
||||
disabled={!canChat || isVoiceTranscribing || (!speechEnabled && !isVoiceRecording)}
|
||||
disabled={!canChat || isVoiceTranscribing}
|
||||
onClick={onVoiceInput}
|
||||
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
|
||||
title={
|
||||
|
|
@ -5215,17 +5407,15 @@ export function BotDashboardModule({
|
|||
<div className="row-between ops-runtime-head">
|
||||
<h2 style={{ fontSize: 18 }}>{t.runtime}</h2>
|
||||
<div className="ops-panel-tools" ref={runtimeMenuRef}>
|
||||
{!singleBotHomeMode ? (
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
|
||||
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
|
||||
tooltip={t.restart}
|
||||
aria-label={t.restart}
|
||||
>
|
||||
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
|
||||
</LucentIconButton>
|
||||
) : null}
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
|
||||
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
|
||||
tooltip={t.restart}
|
||||
aria-label={t.restart}
|
||||
>
|
||||
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setRuntimeMenuOpen((v) => !v)}
|
||||
|
|
@ -5239,130 +5429,128 @@ export function BotDashboardModule({
|
|||
</LucentIconButton>
|
||||
{runtimeMenuOpen ? (
|
||||
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
|
||||
{!singleBotHomeMode ? (
|
||||
<>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setProviderTestResult('');
|
||||
setShowBaseModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<span>{t.base}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setShowParamModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
<span>{t.params}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openChannelModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<Waypoints size={14} />
|
||||
<span>{t.channels}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openTopicModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<MessageSquareText size={14} />
|
||||
<span>{t.topic}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
void loadBotEnvParams(selectedBot.id);
|
||||
setShowEnvParamsModal(true);
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<span>{t.envParams}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
void loadBotSkills(selectedBot.id);
|
||||
setShowSkillsModal(true);
|
||||
}}
|
||||
>
|
||||
<Hammer size={14} />
|
||||
<span>{t.skills}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openMcpModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<Boxes size={14} />
|
||||
<span>{t.mcp}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (selectedBot) void loadCronJobs(selectedBot.id);
|
||||
setShowCronModal(true);
|
||||
}}
|
||||
>
|
||||
<Clock3 size={14} />
|
||||
<span>{t.cronViewer}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setShowAgentModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{t.agent}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setProviderTestResult('');
|
||||
setShowBaseModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<span>{t.base}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setShowParamModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
<span>{t.params}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openChannelModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<Waypoints size={14} />
|
||||
<span>{t.channels}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openTopicModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<MessageSquareText size={14} />
|
||||
<span>{t.topic}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
void loadBotEnvParams(selectedBot.id);
|
||||
setShowEnvParamsModal(true);
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
<span>{t.envParams}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
void loadBotSkills(selectedBot.id);
|
||||
setShowSkillsModal(true);
|
||||
}}
|
||||
>
|
||||
<Hammer size={14} />
|
||||
<span>{t.skills}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (!selectedBot) return;
|
||||
openMcpModal(selectedBot.id);
|
||||
}}
|
||||
>
|
||||
<Boxes size={14} />
|
||||
<span>{t.mcp}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
if (selectedBot) void loadCronJobs(selectedBot.id);
|
||||
setShowCronModal(true);
|
||||
}}
|
||||
>
|
||||
<Clock3 size={14} />
|
||||
<span>{t.cronViewer}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setRuntimeMenuOpen(false);
|
||||
void (async () => {
|
||||
const detail = await ensureSelectedBotDetail();
|
||||
applyEditFormFromBot(detail);
|
||||
setShowAgentModal(true);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{t.agent}</span>
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className="ops-more-item"
|
||||
role="menuitem"
|
||||
|
|
@ -5620,7 +5808,6 @@ export function BotDashboardModule({
|
|||
<div className="modal-title-row modal-title-with-close">
|
||||
<div className="modal-title-main">
|
||||
<h3>{t.baseConfig}</h3>
|
||||
<span className="modal-sub">{t.baseConfigSub}</span>
|
||||
</div>
|
||||
<div className="modal-title-actions">
|
||||
<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">
|
||||
<h3>{t.filePreview}</h3>
|
||||
<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
|
||||
className="workspace-preview-copy-name"
|
||||
onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)}
|
||||
|
|
@ -7022,6 +7211,22 @@ export function BotDashboardModule({
|
|||
</span>
|
||||
</div>
|
||||
<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
|
||||
className="btn btn-secondary btn-sm icon-btn"
|
||||
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
|
||||
|
|
@ -7040,7 +7245,9 @@ export function BotDashboardModule({
|
|||
</LucentIconButton>
|
||||
</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 ? (
|
||||
<img
|
||||
className="workspace-preview-image"
|
||||
|
|
@ -7067,12 +7274,20 @@ export function BotDashboardModule({
|
|||
src={buildWorkspaceRawHref(workspacePreview.path, false)}
|
||||
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 ? (
|
||||
<div className="workspace-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={markdownComponents}
|
||||
urlTransform={transformWorkspacePreviewMarkdownUrl}
|
||||
>
|
||||
{workspacePreview.content}
|
||||
</ReactMarkdown>
|
||||
|
|
@ -7087,6 +7302,28 @@ export function BotDashboardModule({
|
|||
<div className="row-between">
|
||||
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
|
||||
<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 ? (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -7122,8 +7359,14 @@ export function BotDashboardModule({
|
|||
</div>
|
||||
<div className="workspace-entry-info-row">
|
||||
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
|
||||
<span className="workspace-entry-info-value mono">
|
||||
{`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<div className="workspace-entry-info-row">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue