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