v0.1.4-p4
parent
0167a9bc8a
commit
9699b4e7c9
|
|
@ -23,7 +23,7 @@ Dashboard Nanobot 是面向 `nanobot` 的控制平面项目,提供镜像管理
|
|||
graph TD
|
||||
User((User)) --> Frontend[Frontend Control Plane]
|
||||
Frontend --> API[FastAPI Backend]
|
||||
API --> DB[(SQLite)]
|
||||
API --> DB[(PostgreSQL)]
|
||||
API --> Docker[Docker Daemon]
|
||||
|
||||
Docker --> BotA[Bot Container A]
|
||||
|
|
@ -63,7 +63,7 @@ graph TD
|
|||
- 示例文件:`backend/.env.example`
|
||||
- 本地配置:`backend/.env`
|
||||
- 关键项:
|
||||
- `DATABASE_URL`:数据库连接串(三选一:SQLite / PostgreSQL / MySQL)
|
||||
- `DATABASE_URL`:数据库连接串(建议使用 PostgreSQL)
|
||||
- `DATABASE_ECHO`:SQL 日志输出开关
|
||||
- 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串)
|
||||
- `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@ DATA_ROOT=../data
|
|||
BOTS_WORKSPACE_ROOT=../workspace/bots
|
||||
|
||||
# Database
|
||||
# SQLite (recommended): leave DATABASE_URL unset, backend will use:
|
||||
# sqlite:///{DATA_ROOT}/nanobot_dashboard.db
|
||||
# DATABASE_URL=sqlite:///../data/nanobot_dashboard.db
|
||||
# PostgreSQL example:
|
||||
# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
|
||||
# PostgreSQL is required:
|
||||
DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
|
||||
# MySQL example:
|
||||
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
|
||||
# Show SQL statements in backend logs (debug only).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from schemas.bot import MessageFeedbackRequest
|
|||
from services.chat_history_service import (
|
||||
clear_bot_messages_payload,
|
||||
clear_dashboard_direct_session_payload,
|
||||
delete_bot_message_payload,
|
||||
list_bot_messages_by_date_payload,
|
||||
list_bot_messages_page_payload,
|
||||
list_bot_messages_payload,
|
||||
|
|
@ -59,6 +60,24 @@ def update_bot_message_feedback(
|
|||
return update_bot_message_feedback_payload(session, bot_id, message_id, payload.feedback)
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/messages/{message_id}")
|
||||
def delete_bot_message(
|
||||
bot_id: str,
|
||||
message_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return delete_bot_message_payload(session, bot_id, message_id)
|
||||
|
||||
|
||||
@router.post("/api/bots/{bot_id}/messages/{message_id}/delete")
|
||||
def delete_bot_message_post(
|
||||
bot_id: str,
|
||||
message_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return delete_bot_message_payload(session, bot_id, message_id)
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/messages")
|
||||
def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
|
||||
return clear_bot_messages_payload(session, bot_id)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from sqlmodel import SQLModel, Session, create_engine
|
|||
|
||||
from core.settings import (
|
||||
DATABASE_ECHO,
|
||||
DATABASE_ENGINE,
|
||||
DATABASE_MAX_OVERFLOW,
|
||||
DATABASE_POOL_RECYCLE,
|
||||
DATABASE_POOL_SIZE,
|
||||
|
|
@ -19,19 +18,12 @@ from models import topic as _topic_models # noqa: F401
|
|||
|
||||
_engine_kwargs = {
|
||||
"echo": DATABASE_ECHO,
|
||||
}
|
||||
if DATABASE_ENGINE == "sqlite":
|
||||
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
||||
else:
|
||||
_engine_kwargs.update(
|
||||
{
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": DATABASE_POOL_SIZE,
|
||||
"max_overflow": DATABASE_MAX_OVERFLOW,
|
||||
"pool_timeout": DATABASE_POOL_TIMEOUT,
|
||||
"pool_recycle": DATABASE_POOL_RECYCLE,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
engine = create_engine(DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
|
|
@ -42,60 +34,17 @@ BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
|||
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
||||
SYS_SETTING_TABLE = "sys_setting"
|
||||
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
||||
MYSQL_MIGRATION_LOCK_NAME = "dashboard_nanobot_schema_migration"
|
||||
LEGACY_TABLE_PAIRS = [
|
||||
("botinstance", BOT_INSTANCE_TABLE),
|
||||
("botmessage", BOT_MESSAGE_TABLE),
|
||||
("nanobotimage", BOT_IMAGE_TABLE),
|
||||
("platformsetting", SYS_SETTING_TABLE),
|
||||
("botrequestusage", BOT_REQUEST_USAGE_TABLE),
|
||||
("botactivityevent", BOT_ACTIVITY_EVENT_TABLE),
|
||||
]
|
||||
|
||||
|
||||
def _quote_ident(name: str) -> str:
|
||||
if engine.dialect.name == "mysql":
|
||||
return f"`{str(name).replace('`', '``')}`"
|
||||
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
|
||||
|
||||
|
||||
def _rename_table_if_needed(old_name: str, new_name: str) -> None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or inspector.has_table(new_name):
|
||||
return
|
||||
dialect = engine.dialect.name
|
||||
with engine.connect() as conn:
|
||||
if dialect == "mysql":
|
||||
conn.execute(text(f"RENAME TABLE `{old_name}` TO `{new_name}`"))
|
||||
else:
|
||||
conn.execute(text(f'ALTER TABLE "{old_name}" RENAME TO "{new_name}"'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _rename_legacy_tables() -> None:
|
||||
_rename_table_if_needed("botinstance", BOT_INSTANCE_TABLE)
|
||||
_rename_table_if_needed("botmessage", BOT_MESSAGE_TABLE)
|
||||
_rename_table_if_needed("nanobotimage", BOT_IMAGE_TABLE)
|
||||
_rename_table_if_needed("platformsetting", SYS_SETTING_TABLE)
|
||||
_rename_table_if_needed("botrequestusage", BOT_REQUEST_USAGE_TABLE)
|
||||
_rename_table_if_needed("botactivityevent", BOT_ACTIVITY_EVENT_TABLE)
|
||||
|
||||
|
||||
def _acquire_migration_lock():
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn = engine.connect()
|
||||
conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
||||
return conn
|
||||
if engine.dialect.name == "mysql":
|
||||
conn = engine.connect()
|
||||
acquired = conn.execute(
|
||||
text("SELECT GET_LOCK(:name, :timeout)"),
|
||||
{"name": MYSQL_MIGRATION_LOCK_NAME, "timeout": 120},
|
||||
).scalar()
|
||||
if int(acquired or 0) != 1:
|
||||
conn.close()
|
||||
raise RuntimeError("Failed to acquire schema migration lock")
|
||||
return conn
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -105,191 +54,17 @@ def _release_migration_lock(lock_conn) -> None:
|
|||
try:
|
||||
if engine.dialect.name == "postgresql":
|
||||
lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
||||
elif engine.dialect.name == "mysql":
|
||||
lock_conn.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": MYSQL_MIGRATION_LOCK_NAME})
|
||||
finally:
|
||||
lock_conn.close()
|
||||
|
||||
|
||||
def _table_row_count(table_name: str) -> int:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return 0
|
||||
with engine.connect() as conn:
|
||||
value = conn.execute(text(f"SELECT COUNT(*) FROM {_quote_ident(table_name)}")).scalar()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _copy_legacy_table_rows(old_name: str, new_name: str) -> None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
|
||||
return
|
||||
if _table_row_count(old_name) <= 0:
|
||||
return
|
||||
|
||||
old_columns = {
|
||||
str(row.get("name"))
|
||||
for row in inspector.get_columns(old_name)
|
||||
if row.get("name")
|
||||
}
|
||||
new_columns = [
|
||||
str(row.get("name"))
|
||||
for row in inspector.get_columns(new_name)
|
||||
if row.get("name")
|
||||
]
|
||||
shared_columns = [col for col in new_columns if col in old_columns]
|
||||
if not shared_columns:
|
||||
return
|
||||
pk = inspector.get_pk_constraint(new_name) or {}
|
||||
pk_columns = [
|
||||
str(col)
|
||||
for col in (pk.get("constrained_columns") or [])
|
||||
if col and col in shared_columns and col in old_columns
|
||||
]
|
||||
if not pk_columns:
|
||||
return
|
||||
|
||||
columns_sql = ", ".join(_quote_ident(col) for col in shared_columns)
|
||||
join_sql = " AND ".join(
|
||||
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
|
||||
for col in pk_columns
|
||||
)
|
||||
null_check_col = _quote_ident(pk_columns[0])
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f"INSERT INTO {_quote_ident(new_name)} ({columns_sql}) "
|
||||
f"SELECT {', '.join(f'o.{_quote_ident(col)}' for col in shared_columns)} "
|
||||
f"FROM {_quote_ident(old_name)} o "
|
||||
f"LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} "
|
||||
f"WHERE n.{null_check_col} IS NULL"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_legacy_table_rows() -> None:
|
||||
for old_name, new_name in LEGACY_TABLE_PAIRS:
|
||||
_copy_legacy_table_rows(old_name, new_name)
|
||||
|
||||
|
||||
def _topic_fk_target(table_name: str, constrained_column: str = "bot_id") -> str | None:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return None
|
||||
for fk in inspector.get_foreign_keys(table_name):
|
||||
cols = [str(col) for col in (fk.get("constrained_columns") or []) if col]
|
||||
if cols == [constrained_column]:
|
||||
referred = fk.get("referred_table")
|
||||
return str(referred) if referred else None
|
||||
return None
|
||||
|
||||
|
||||
def _repair_postgres_topic_foreign_keys() -> None:
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
targets = {
|
||||
"topic_topic": "topic_topic_bot_id_fkey",
|
||||
"topic_item": "topic_item_bot_id_fkey",
|
||||
}
|
||||
with engine.connect() as conn:
|
||||
changed = False
|
||||
for table_name, constraint_name in targets.items():
|
||||
if _topic_fk_target(table_name) == BOT_INSTANCE_TABLE:
|
||||
continue
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE {_quote_ident(table_name)} '
|
||||
f'DROP CONSTRAINT IF EXISTS {_quote_ident(constraint_name)}'
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE {_quote_ident(table_name)} '
|
||||
f'ADD CONSTRAINT {_quote_ident(constraint_name)} '
|
||||
f'FOREIGN KEY ({_quote_ident("bot_id")}) '
|
||||
f'REFERENCES {_quote_ident(BOT_INSTANCE_TABLE)}({_quote_ident("id")}) '
|
||||
f'ON DELETE CASCADE'
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _legacy_rows_missing_in_new(old_name: str, new_name: str) -> int:
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
|
||||
return 0
|
||||
pk = inspector.get_pk_constraint(new_name) or {}
|
||||
pk_columns = [
|
||||
str(col)
|
||||
for col in (pk.get("constrained_columns") or [])
|
||||
if col
|
||||
]
|
||||
if not pk_columns:
|
||||
return _table_row_count(old_name)
|
||||
join_sql = " AND ".join(
|
||||
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
|
||||
for col in pk_columns
|
||||
)
|
||||
null_check_col = _quote_ident(pk_columns[0])
|
||||
with engine.connect() as conn:
|
||||
value = conn.execute(
|
||||
text(
|
||||
f'SELECT COUNT(*) FROM {_quote_ident(old_name)} o '
|
||||
f'LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} '
|
||||
f'WHERE n.{null_check_col} IS NULL'
|
||||
)
|
||||
).scalar()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _drop_legacy_tables() -> None:
|
||||
droppable = [
|
||||
old_name
|
||||
for old_name, new_name in LEGACY_TABLE_PAIRS
|
||||
if _legacy_rows_missing_in_new(old_name, new_name) <= 0
|
||||
]
|
||||
if not droppable:
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
for old_name in droppable:
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)} CASCADE'))
|
||||
else:
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)}'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_botinstance_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"current_state": {
|
||||
"sqlite": "TEXT DEFAULT 'IDLE'",
|
||||
"postgresql": "TEXT DEFAULT 'IDLE'",
|
||||
"mysql": "VARCHAR(64) DEFAULT 'IDLE'",
|
||||
},
|
||||
"last_action": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"image_tag": {
|
||||
"sqlite": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
|
||||
"postgresql": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
|
||||
"mysql": "VARCHAR(255) DEFAULT 'nanobot-base:v0.1.4'",
|
||||
},
|
||||
"access_password": {
|
||||
"sqlite": "TEXT DEFAULT ''",
|
||||
"postgresql": "TEXT DEFAULT ''",
|
||||
"mysql": "VARCHAR(255) DEFAULT ''",
|
||||
},
|
||||
"enabled": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 1",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
},
|
||||
"current_state": "TEXT DEFAULT 'IDLE'",
|
||||
"last_action": "TEXT",
|
||||
"image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
|
||||
"access_password": "TEXT DEFAULT ''",
|
||||
"enabled": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
}
|
||||
|
||||
inspector = inspect(engine)
|
||||
|
|
@ -301,124 +76,24 @@ def _ensure_botinstance_columns() -> None:
|
|||
for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
for col, ddl in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
|
||||
if "enabled" in existing:
|
||||
if dialect == "sqlite":
|
||||
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL"))
|
||||
else:
|
||||
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _drop_legacy_botinstance_columns() -> None:
|
||||
legacy_columns = [
|
||||
"avatar_model",
|
||||
"avatar_skin",
|
||||
"system_prompt",
|
||||
"soul_md",
|
||||
"agents_md",
|
||||
"user_md",
|
||||
"tools_md",
|
||||
"tools_config_json",
|
||||
"identity_md",
|
||||
"llm_provider",
|
||||
"llm_model",
|
||||
"api_key",
|
||||
"api_base",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"send_progress",
|
||||
"send_tool_hints",
|
||||
"bot_env_json",
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
existing = {
|
||||
str(col.get("name"))
|
||||
for col in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
|
||||
if col.get("name")
|
||||
}
|
||||
for col in legacy_columns:
|
||||
if col not in existing:
|
||||
continue
|
||||
try:
|
||||
if engine.dialect.name == "mysql":
|
||||
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`"))
|
||||
elif engine.dialect.name == "sqlite":
|
||||
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"'))
|
||||
else:
|
||||
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN IF EXISTS "{col}"'))
|
||||
except Exception:
|
||||
# Keep startup resilient on mixed/legacy database engines.
|
||||
continue
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_botmessage_columns() -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
return
|
||||
required_columns = {
|
||||
"media_json": "TEXT",
|
||||
"feedback": "TEXT",
|
||||
"feedback_at": "DATETIME",
|
||||
}
|
||||
with engine.connect() as conn:
|
||||
existing_rows = conn.execute(text(f"PRAGMA table_info({BOT_MESSAGE_TABLE})")).fetchall()
|
||||
existing = {str(row[1]) for row in existing_rows}
|
||||
for col, ddl in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _drop_legacy_skill_tables() -> None:
|
||||
"""Drop deprecated skill registry tables (moved to workspace filesystem mode)."""
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS botskillmapping"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS skillregistry"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_sys_setting_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"name": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "VARCHAR(200) NOT NULL DEFAULT ''",
|
||||
},
|
||||
"category": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'general'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'general'",
|
||||
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'",
|
||||
},
|
||||
"description": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"value_type": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'json'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'json'",
|
||||
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'",
|
||||
},
|
||||
"is_public": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 0",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
},
|
||||
"sort_order": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 100",
|
||||
"postgresql": "INTEGER NOT NULL DEFAULT 100",
|
||||
"mysql": "INTEGER NOT NULL DEFAULT 100",
|
||||
},
|
||||
"name": "TEXT NOT NULL DEFAULT ''",
|
||||
"category": "TEXT NOT NULL DEFAULT 'general'",
|
||||
"description": "TEXT NOT NULL DEFAULT ''",
|
||||
"value_type": "TEXT NOT NULL DEFAULT 'json'",
|
||||
"is_public": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"sort_order": "INTEGER NOT NULL DEFAULT 100",
|
||||
}
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(SYS_SETTING_TABLE):
|
||||
|
|
@ -429,32 +104,18 @@ def _ensure_sys_setting_columns() -> None:
|
|||
for row in inspect(conn).get_columns(SYS_SETTING_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
for col, ddl in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_bot_request_usage_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"message_id": {
|
||||
"sqlite": "INTEGER",
|
||||
"postgresql": "INTEGER",
|
||||
"mysql": "INTEGER",
|
||||
},
|
||||
"provider": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "VARCHAR(120)",
|
||||
},
|
||||
"model": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "VARCHAR(255)",
|
||||
},
|
||||
"message_id": "INTEGER",
|
||||
"provider": "TEXT",
|
||||
"model": "TEXT",
|
||||
}
|
||||
inspector = inspect(engine)
|
||||
if not inspector.has_table(BOT_REQUEST_USAGE_TABLE):
|
||||
|
|
@ -465,161 +126,34 @@ def _ensure_bot_request_usage_columns() -> None:
|
|||
for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in required_columns.items():
|
||||
for col, ddl in required_columns.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_topic_tables_sqlite() -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
return
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS topic_topic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
topic_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_default_fallback INTEGER NOT NULL DEFAULT 0,
|
||||
routing_json TEXT NOT NULL DEFAULT '{}',
|
||||
view_schema_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS topic_item (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_id TEXT NOT NULL,
|
||||
topic_key TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
tags_json TEXT,
|
||||
view_json TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'mcp',
|
||||
dedupe_key TEXT,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic(bot_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback ON topic_topic(bot_id, is_default_fallback)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id ON topic_item(bot_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key ON topic_item(topic_key)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_level ON topic_item(level)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_source ON topic_item(source)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_is_read ON topic_item(is_read)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_created_at ON topic_item(created_at)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at ON topic_item(bot_id, topic_key, created_at)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe ON topic_item(bot_id, dedupe_key)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_topic_columns() -> None:
|
||||
dialect = engine.dialect.name
|
||||
required_columns = {
|
||||
"topic_topic": {
|
||||
"name": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
},
|
||||
"description": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"is_active": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 1",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
},
|
||||
"is_default_fallback": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 0",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
},
|
||||
"routing_json": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"view_schema_json": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"created_at": {
|
||||
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
"updated_at": {
|
||||
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
"name": "TEXT NOT NULL DEFAULT ''",
|
||||
"description": "TEXT NOT NULL DEFAULT ''",
|
||||
"is_active": "BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
"is_default_fallback": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"routing_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"view_schema_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"updated_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
"topic_item": {
|
||||
"title": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT ''",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT ''",
|
||||
"mysql": "VARCHAR(2000) NOT NULL DEFAULT ''",
|
||||
},
|
||||
"level": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'info'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'info'",
|
||||
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'info'",
|
||||
},
|
||||
"tags_json": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"view_json": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "LONGTEXT",
|
||||
},
|
||||
"source": {
|
||||
"sqlite": "TEXT NOT NULL DEFAULT 'mcp'",
|
||||
"postgresql": "TEXT NOT NULL DEFAULT 'mcp'",
|
||||
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'mcp'",
|
||||
},
|
||||
"dedupe_key": {
|
||||
"sqlite": "TEXT",
|
||||
"postgresql": "TEXT",
|
||||
"mysql": "VARCHAR(200)",
|
||||
},
|
||||
"is_read": {
|
||||
"sqlite": "INTEGER NOT NULL DEFAULT 0",
|
||||
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
},
|
||||
"created_at": {
|
||||
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
"title": "TEXT NOT NULL DEFAULT ''",
|
||||
"level": "TEXT NOT NULL DEFAULT 'info'",
|
||||
"tags_json": "TEXT",
|
||||
"view_json": "TEXT",
|
||||
"source": "TEXT NOT NULL DEFAULT 'mcp'",
|
||||
"dedupe_key": "TEXT",
|
||||
"is_read": "BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -633,114 +167,13 @@ def _ensure_topic_columns() -> None:
|
|||
for row in inspector.get_columns(table_name)
|
||||
if row.get("name")
|
||||
}
|
||||
for col, ddl_map in cols.items():
|
||||
for col, ddl in cols.items():
|
||||
if col in existing:
|
||||
continue
|
||||
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
|
||||
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _ensure_topic_indexes() -> None:
|
||||
required_indexes = [
|
||||
("uq_topic_topic_bot_topic_key", "topic_topic", ["bot_id", "topic_key"], True),
|
||||
("idx_topic_topic_bot_id", "topic_topic", ["bot_id"], False),
|
||||
("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False),
|
||||
("idx_topic_topic_bot_fallback", "topic_topic", ["bot_id", "is_default_fallback"], False),
|
||||
("idx_topic_item_bot_id", "topic_item", ["bot_id"], False),
|
||||
("idx_topic_item_topic_key", "topic_item", ["topic_key"], False),
|
||||
("idx_topic_item_level", "topic_item", ["level"], False),
|
||||
("idx_topic_item_source", "topic_item", ["source"], False),
|
||||
("idx_topic_item_is_read", "topic_item", ["is_read"], False),
|
||||
("idx_topic_item_created_at", "topic_item", ["created_at"], False),
|
||||
("idx_topic_item_bot_topic_created_at", "topic_item", ["bot_id", "topic_key", "created_at"], False),
|
||||
("idx_topic_item_bot_dedupe", "topic_item", ["bot_id", "dedupe_key"], False),
|
||||
]
|
||||
inspector = inspect(engine)
|
||||
with engine.connect() as conn:
|
||||
for name, table_name, columns, unique in required_indexes:
|
||||
if not inspector.has_table(table_name):
|
||||
continue
|
||||
existing = {
|
||||
str(item.get("name"))
|
||||
for item in inspector.get_indexes(table_name)
|
||||
if item.get("name")
|
||||
}
|
||||
existing.update(
|
||||
str(item.get("name"))
|
||||
for item in inspector.get_unique_constraints(table_name)
|
||||
if item.get("name")
|
||||
)
|
||||
if name in existing:
|
||||
continue
|
||||
unique_sql = "UNIQUE " if unique else ""
|
||||
cols_sql = ", ".join(columns)
|
||||
conn.execute(text(f"CREATE {unique_sql}INDEX {name} ON {table_name} ({cols_sql})"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _drop_obsolete_topic_tables() -> None:
|
||||
with engine.connect() as conn:
|
||||
if engine.dialect.name == "postgresql":
|
||||
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
|
||||
elif engine.dialect.name == "mysql":
|
||||
conn.execute(text("DROP TABLE IF EXISTS `topic_bot_settings`"))
|
||||
else:
|
||||
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _cleanup_legacy_default_topics() -> None:
|
||||
"""
|
||||
Remove legacy auto-created fallback topic rows from early topic-feed design.
|
||||
|
||||
Historical rows look like:
|
||||
- topic_key = inbox
|
||||
- name = Inbox
|
||||
- description = Default topic for uncategorized items
|
||||
- routing_json contains "Fallback topic"
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
legacy_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT bot_id, topic_key
|
||||
FROM topic_topic
|
||||
WHERE lower(coalesce(topic_key, '')) = 'inbox'
|
||||
AND lower(coalesce(name, '')) = 'inbox'
|
||||
AND lower(coalesce(description, '')) = 'default topic for uncategorized items'
|
||||
AND lower(coalesce(routing_json, '')) LIKE '%fallback topic%'
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
if not legacy_rows:
|
||||
return
|
||||
for row in legacy_rows:
|
||||
bot_id = str(row[0] or "").strip()
|
||||
topic_key = str(row[1] or "").strip().lower()
|
||||
if not bot_id or not topic_key:
|
||||
continue
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM topic_item
|
||||
WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key
|
||||
"""
|
||||
),
|
||||
{"bot_id": bot_id, "topic_key": topic_key},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM topic_topic
|
||||
WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key
|
||||
"""
|
||||
),
|
||||
{"bot_id": bot_id, "topic_key": topic_key},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def align_postgres_sequences() -> None:
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
|
|
@ -777,22 +210,11 @@ def align_postgres_sequences() -> None:
|
|||
def init_database() -> None:
|
||||
lock_conn = _acquire_migration_lock()
|
||||
try:
|
||||
_rename_legacy_tables()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_migrate_legacy_table_rows()
|
||||
_drop_legacy_skill_tables()
|
||||
_ensure_sys_setting_columns()
|
||||
_ensure_bot_request_usage_columns()
|
||||
_ensure_botinstance_columns()
|
||||
_drop_legacy_botinstance_columns()
|
||||
_ensure_botmessage_columns()
|
||||
_ensure_topic_tables_sqlite()
|
||||
_repair_postgres_topic_foreign_keys()
|
||||
_ensure_topic_columns()
|
||||
_ensure_topic_indexes()
|
||||
_drop_obsolete_topic_tables()
|
||||
_cleanup_legacy_default_topics()
|
||||
_drop_legacy_tables()
|
||||
align_postgres_sequences()
|
||||
finally:
|
||||
_release_migration_lock(lock_conn)
|
||||
|
|
|
|||
|
|
@ -143,9 +143,10 @@ def _mask_database_url(url: str) -> str:
|
|||
|
||||
|
||||
_db_env = str(os.getenv("DATABASE_URL") or "").strip()
|
||||
DATABASE_URL: Final[str] = _normalize_database_url(
|
||||
_db_env if _db_env else f"sqlite:///{Path(DATA_ROOT) / 'nanobot_dashboard.db'}"
|
||||
)
|
||||
if not _db_env:
|
||||
raise RuntimeError("DATABASE_URL is not set in environment. PostgreSQL is required.")
|
||||
|
||||
DATABASE_URL: Final[str] = _normalize_database_url(_db_env)
|
||||
DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL)
|
||||
DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL)
|
||||
DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True)
|
||||
|
|
@ -198,6 +199,10 @@ REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot")
|
|||
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
|
||||
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
|
||||
|
||||
APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip()
|
||||
APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
|
||||
APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False)
|
||||
|
||||
TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
|
||||
AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json"
|
||||
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
from app_factory import create_app
|
||||
from core.settings import APP_HOST, APP_PORT, APP_RELOAD
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
if APP_RELOAD:
|
||||
# Use import string to support hot-reloading
|
||||
uvicorn.run("main:app", host=APP_HOST, port=APP_PORT, reload=True)
|
||||
else:
|
||||
# Use app object for faster/direct startup
|
||||
uvicorn.run(app, host=APP_HOST, port=APP_PORT)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ def _invalidate_bot_detail_cache(bot_id: str) -> None:
|
|||
cache.delete(_cache_key_bots_list(), _cache_key_bot_detail(bot_id))
|
||||
|
||||
def _invalidate_bot_messages_cache(bot_id: str) -> None:
|
||||
cache.delete_prefix(f"bot:messages:{bot_id}:")
|
||||
cache.delete_prefix(f"bot:messages:list:v2:{bot_id}:")
|
||||
cache.delete_prefix(f"bot:messages:page:v2:{bot_id}:")
|
||||
|
||||
def _invalidate_images_cache() -> None:
|
||||
cache.delete(_cache_key_images())
|
||||
|
|
|
|||
|
|
@ -275,6 +275,37 @@ def update_bot_message_feedback_payload(
|
|||
}
|
||||
|
||||
|
||||
def delete_bot_message_payload(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
message_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
_get_bot_or_404(session, bot_id)
|
||||
row = session.get(BotMessage, message_id)
|
||||
if not row or row.bot_id != bot_id:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
deleted_role = str(row.role or "").strip() or "assistant"
|
||||
session.delete(row)
|
||||
record_activity_event(
|
||||
session,
|
||||
bot_id,
|
||||
"message_deleted",
|
||||
channel="dashboard",
|
||||
detail=f"Deleted {deleted_role} message #{message_id}",
|
||||
metadata={"message_id": message_id, "role": deleted_role},
|
||||
)
|
||||
session.commit()
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
_invalidate_bot_messages_cache(bot_id)
|
||||
return {
|
||||
"status": "deleted",
|
||||
"bot_id": bot_id,
|
||||
"message_id": message_id,
|
||||
"role": deleted_role,
|
||||
}
|
||||
|
||||
|
||||
def clear_bot_messages_payload(session: Session, bot_id: str) -> Dict[str, Any]:
|
||||
bot = _get_bot_or_404(session, bot_id)
|
||||
rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()
|
||||
|
|
|
|||
|
|
@ -101,3 +101,31 @@ def list_activity_events(
|
|||
).model_dump()
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def get_bot_activity_stats(session: Session) -> List[Dict[str, Any]]:
|
||||
from sqlalchemy import and_, func
|
||||
from models.bot import BotInstance
|
||||
|
||||
stmt = (
|
||||
select(BotInstance.id, BotInstance.name, func.count(BotActivityEvent.id).label("count"))
|
||||
.select_from(BotInstance)
|
||||
.join(
|
||||
BotActivityEvent,
|
||||
and_(
|
||||
BotActivityEvent.bot_id == BotInstance.id,
|
||||
BotActivityEvent.request_id.is_not(None),
|
||||
func.length(func.trim(BotActivityEvent.request_id)) > 0,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.where(BotInstance.enabled.is_(True))
|
||||
.group_by(BotInstance.id, BotInstance.name)
|
||||
.order_by(func.count(BotActivityEvent.id).desc(), BotInstance.name.asc(), BotInstance.id.asc())
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
|
||||
return [
|
||||
{"bot_id": row[0], "name": row[1] or row[0], "count": row[2]}
|
||||
for row in results
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ from sqlmodel import Session, select
|
|||
from core.utils import _calc_dir_size_bytes
|
||||
from models.bot import BotInstance, NanobotImage
|
||||
from services.bot_storage_service import _read_bot_resources, _workspace_root
|
||||
from services.platform_activity_service import list_activity_events, prune_expired_activity_events
|
||||
from services.platform_activity_service import (
|
||||
get_bot_activity_stats,
|
||||
list_activity_events,
|
||||
prune_expired_activity_events,
|
||||
)
|
||||
from services.platform_settings_service import get_platform_settings
|
||||
from services.platform_usage_service import list_usage
|
||||
|
||||
|
|
@ -63,6 +67,7 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
|
|||
|
||||
usage = list_usage(session, limit=20)
|
||||
events = list_activity_events(session, limit=20)
|
||||
activity_stats = get_bot_activity_stats(session)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
|
|
@ -101,4 +106,5 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
|
|||
"settings": settings.model_dump(),
|
||||
"usage": usage,
|
||||
"events": events,
|
||||
"activity_stats": activity_stats,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -21,6 +22,7 @@ logger = logging.getLogger("dashboard.backend")
|
|||
|
||||
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_AGENT_LOOP_READY_MARKER = "Agent loop started"
|
||||
_LAST_ACTION_CONTROL_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]")
|
||||
|
||||
|
||||
def set_main_loop(loop: Optional[asyncio.AbstractEventLoop]) -> None:
|
||||
|
|
@ -73,6 +75,17 @@ def _normalize_media_list(raw: Any, bot_id: str) -> List[str]:
|
|||
return rows
|
||||
|
||||
|
||||
def _normalize_last_action_text(value: Any) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
return ""
|
||||
text = _LAST_ACTION_CONTROL_RE.sub("", text)
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = "\n".join(line.rstrip() for line in text.split("\n"))
|
||||
text = re.sub(r"\n{4,}", "\n\n\n", text).strip()
|
||||
return text[:4000]
|
||||
|
||||
|
||||
def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
|
||||
packet_type = str(packet.get("type", "")).upper()
|
||||
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
|
||||
|
|
@ -91,18 +104,18 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
if packet_type == "AGENT_STATE":
|
||||
payload = packet.get("payload") or {}
|
||||
state = str(payload.get("state") or "").strip()
|
||||
action = str(payload.get("action_msg") or payload.get("msg") or "").strip()
|
||||
action = _normalize_last_action_text(payload.get("action_msg") or payload.get("msg") or "")
|
||||
if state:
|
||||
bot.current_state = state
|
||||
if action:
|
||||
bot.last_action = action[:4000]
|
||||
bot.last_action = action
|
||||
elif packet_type == "ASSISTANT_MESSAGE":
|
||||
bot.current_state = "IDLE"
|
||||
text_msg = str(packet.get("text") or "").strip()
|
||||
media_list = _normalize_media_list(packet.get("media"), bot_id)
|
||||
if text_msg or media_list:
|
||||
if text_msg:
|
||||
bot.last_action = " ".join(text_msg.split())[:4000]
|
||||
bot.last_action = _normalize_last_action_text(text_msg)
|
||||
message_row = BotMessage(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
|
|
@ -148,7 +161,7 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
|
|||
if text_msg or media_list:
|
||||
bot.current_state = "IDLE"
|
||||
if text_msg:
|
||||
bot.last_action = " ".join(text_msg.split())[:4000]
|
||||
bot.last_action = _normalize_last_action_text(text_msg)
|
||||
message_row = BotMessage(
|
||||
bot_id=bot_id,
|
||||
role="assistant",
|
||||
|
|
|
|||
|
|
@ -1,36 +1,42 @@
|
|||
# Dashboard Nanobot 数据库设计文档(当前实现)
|
||||
# Dashboard Nanobot 数据库设计文档
|
||||
|
||||
数据库默认使用 SQLite:`data/nanobot_dashboard.db`。
|
||||
数据库默认使用 PostgreSQL(推荐使用 psycopg3 驱动)。
|
||||
|
||||
## 1. ERD
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
BOTINSTANCE ||--o{ BOTMESSAGE : "messages"
|
||||
NANOBOTIMAGE ||--o{ BOTINSTANCE : "referenced by"
|
||||
bot_instance ||--o{ bot_message : "messages"
|
||||
bot_instance ||--o{ bot_request_usage : "usage"
|
||||
bot_instance ||--o{ bot_activity_event : "events"
|
||||
bot_image ||--o{ bot_instance : "referenced by"
|
||||
|
||||
BOTINSTANCE {
|
||||
bot_instance {
|
||||
string id PK
|
||||
string name
|
||||
boolean enabled
|
||||
string access_password
|
||||
string workspace_dir UK
|
||||
string docker_status
|
||||
string image_tag
|
||||
string current_state
|
||||
text last_action
|
||||
string last_action
|
||||
string image_tag
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
|
||||
BOTMESSAGE {
|
||||
bot_message {
|
||||
int id PK
|
||||
string bot_id FK
|
||||
string role
|
||||
text text
|
||||
text media_json
|
||||
string feedback
|
||||
datetime feedback_at
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
NANOBOTIMAGE {
|
||||
bot_image {
|
||||
string tag PK
|
||||
string image_id
|
||||
string version
|
||||
|
|
@ -38,48 +44,81 @@ erDiagram
|
|||
string source_dir
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
bot_request_usage {
|
||||
int id PK
|
||||
string bot_id FK
|
||||
string request_id
|
||||
string channel
|
||||
string status
|
||||
string provider
|
||||
string model
|
||||
int input_tokens
|
||||
int output_tokens
|
||||
int total_tokens
|
||||
datetime started_at
|
||||
datetime completed_at
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
bot_activity_event {
|
||||
int id PK
|
||||
string bot_id FK
|
||||
string request_id
|
||||
string event_type
|
||||
string channel
|
||||
string detail
|
||||
text metadata_json
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
sys_setting {
|
||||
string key PK
|
||||
string name
|
||||
string category
|
||||
string description
|
||||
string value_type
|
||||
text value_json
|
||||
boolean is_public
|
||||
int sort_order
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
- 数据库只保留运行索引和历史消息。
|
||||
- Bot 参数(模型、渠道、资源配额、5 个 MD 文件)统一持久化在:
|
||||
- 数据库保留运行索引、历史消息、用量统计与运维事件。
|
||||
- Bot 核心配置(渠道、资源配额、5 个 MD 文件)统一持久化在文件系统:
|
||||
- `.nanobot/config.json`
|
||||
- `.nanobot/workspace/*.md`
|
||||
- `.nanobot/env.json`
|
||||
- `channelroute` 已废弃,不再使用数据库存储渠道。
|
||||
|
||||
## 3. 表说明
|
||||
|
||||
### 3.1 `botinstance`
|
||||
### 3.1 `bot_instance`
|
||||
存储 Bot 基础索引与运行态。
|
||||
|
||||
仅存基础索引与运行态:
|
||||
### 3.2 `bot_message`
|
||||
Dashboard 渠道对话历史(用于会话回放与反馈)。
|
||||
|
||||
- 标识与展示:`id`、`name`
|
||||
- 容器与镜像:`docker_status`、`image_tag`
|
||||
- 运行状态:`current_state`、`last_action`
|
||||
- 路径与时间:`workspace_dir`、`created_at`、`updated_at`
|
||||
### 3.3 `bot_image`
|
||||
基础镜像登记表。
|
||||
|
||||
### 3.2 `botmessage`
|
||||
### 3.4 `bot_request_usage`
|
||||
模型调用用量详细记录。
|
||||
|
||||
Dashboard 渠道对话历史(用于会话回放):
|
||||
### 3.5 `bot_activity_event`
|
||||
运维事件记录(如容器启动/停止、指令提交、系统告警等)。
|
||||
|
||||
- `role`: `user | assistant`
|
||||
- `text`: 文本内容
|
||||
- `media_json`: 附件相对路径 JSON
|
||||
### 3.6 `sys_setting`
|
||||
平台全局参数设置。
|
||||
|
||||
### 3.3 `nanobotimage`
|
||||
## 4. 初始化与迁移策略
|
||||
|
||||
基础镜像登记表(手动注册):
|
||||
服务启动时(`backend/core/database.py`):
|
||||
|
||||
- `tag`: 如 `nanobot-base:v0.1.4`
|
||||
- `status`: `READY | UNKNOWN | ERROR`
|
||||
- `source_dir`: 来源标识(通常 `manual`)
|
||||
|
||||
## 4. 迁移策略
|
||||
|
||||
服务启动时:
|
||||
|
||||
1. `SQLModel.metadata.create_all(engine)`
|
||||
2. 清理废弃表:`DROP TABLE IF EXISTS channelroute`
|
||||
3. 对 `botinstance` 做列对齐,删除历史遗留配置列(保留当前最小字段集)
|
||||
1. 使用 PostgreSQL Advisory Lock 确保多节点部署时的单实例初始化。
|
||||
2. `SQLModel.metadata.create_all(engine)` 自动创建缺失表。
|
||||
3. 执行列对齐检查,确保旧表结构平滑升级。
|
||||
4. 自动对齐 PostgreSQL Sequences 以防 ID 冲突。
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export const dashboardEn = {
|
|||
copyReply: 'Copy reply',
|
||||
copyReplyDone: 'Reply copied.',
|
||||
copyReplyFail: 'Failed to copy reply.',
|
||||
deleteMessage: 'Delete message',
|
||||
deleteMessageConfirm: (role: string) => `Delete this ${role} message?`,
|
||||
deleteMessageDone: 'Message deleted.',
|
||||
deleteMessageFail: 'Failed to delete message.',
|
||||
deleteMessagePending: 'Message is not synced yet. Please retry in a moment.',
|
||||
quoteReply: 'Quote reply',
|
||||
quotedReplyLabel: 'Quoted reply',
|
||||
clearQuote: 'Clear quote',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export const dashboardZhCn = {
|
|||
copyReply: '复制回复',
|
||||
copyReplyDone: '回复已复制。',
|
||||
copyReplyFail: '复制回复失败。',
|
||||
deleteMessage: '删除消息',
|
||||
deleteMessageConfirm: (role: string) => `确认删除这条${role}消息?`,
|
||||
deleteMessageDone: '消息已删除。',
|
||||
deleteMessageFail: '删除消息失败。',
|
||||
deleteMessagePending: '消息尚未同步,暂不可删除。',
|
||||
quoteReply: '引用回复',
|
||||
quotedReplyLabel: '已引用回复',
|
||||
clearQuote: '取消引用',
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export function BotDashboardModule({
|
|||
controlCommandsShow: dashboard.t.controlCommandsShow,
|
||||
copyPrompt: dashboard.t.copyPrompt,
|
||||
copyReply: dashboard.t.copyReply,
|
||||
deleteMessage: dashboard.t.deleteMessage,
|
||||
disabledPlaceholder: dashboard.t.disabledPlaceholder,
|
||||
download: dashboard.t.download,
|
||||
editPrompt: dashboard.t.editPrompt,
|
||||
|
|
@ -147,6 +148,7 @@ export function BotDashboardModule({
|
|||
onChatScroll: dashboard.onChatScroll,
|
||||
expandedProgressByKey: dashboard.expandedProgressByKey,
|
||||
expandedUserByKey: dashboard.expandedUserByKey,
|
||||
deletingMessageIdMap: dashboard.deletingMessageIdMap,
|
||||
feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId,
|
||||
markdownComponents: dashboard.markdownComponents,
|
||||
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
|
||||
|
|
@ -154,6 +156,7 @@ export function BotDashboardModule({
|
|||
onToggleUserExpand: dashboard.toggleUserExpanded,
|
||||
onEditUserPrompt: dashboard.editUserPrompt,
|
||||
onCopyUserPrompt: dashboard.copyUserPrompt,
|
||||
onDeleteConversationMessage: dashboard.deleteConversationMessage,
|
||||
onOpenWorkspacePath: dashboard.openWorkspacePathFromChat,
|
||||
onSubmitAssistantFeedback: dashboard.submitAssistantFeedback,
|
||||
onQuoteAssistantReply: dashboard.quoteAssistantReply,
|
||||
|
|
@ -161,6 +164,7 @@ export function BotDashboardModule({
|
|||
isThinking: dashboard.isThinking,
|
||||
canChat: dashboard.canChat,
|
||||
isChatEnabled: dashboard.isChatEnabled,
|
||||
speechEnabled: dashboard.speechEnabled,
|
||||
selectedBotEnabled: dashboard.selectedBotEnabled,
|
||||
selectedBotControlState: dashboard.selectedBotControlState,
|
||||
quotedReply: dashboard.quotedReply,
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@
|
|||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%), color-mix(in oklab, var(--panel-soft) 94%, transparent 6%));
|
||||
padding: 10px 10px 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 9px 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
|
|
@ -179,31 +179,33 @@
|
|||
}
|
||||
|
||||
.ops-bot-name {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--title);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.ops-bot-id,
|
||||
.ops-bot-meta {
|
||||
margin-top: 2px;
|
||||
margin-top: 1px;
|
||||
color: var(--subtitle);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ops-bot-actions {
|
||||
margin-top: 10px;
|
||||
margin-top: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ops-bot-actions-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ops-bot-enable-switch {
|
||||
|
|
@ -285,10 +287,10 @@
|
|||
}
|
||||
|
||||
.ops-bot-icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -296,21 +298,21 @@
|
|||
}
|
||||
|
||||
.ops-bot-icon-btn svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke-width: 2.1;
|
||||
}
|
||||
|
||||
.ops-bot-top-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ops-bot-name-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ops-bot-lock {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface DashboardChatPanelLabels {
|
|||
controlCommandsShow: string;
|
||||
copyPrompt: string;
|
||||
copyReply: string;
|
||||
deleteMessage: string;
|
||||
disabledPlaceholder: string;
|
||||
download: string;
|
||||
editPrompt: string;
|
||||
|
|
@ -52,6 +53,7 @@ interface DashboardChatPanelProps {
|
|||
onChatScroll: () => void;
|
||||
expandedProgressByKey: Record<string, boolean>;
|
||||
expandedUserByKey: Record<string, boolean>;
|
||||
deletingMessageIdMap: Record<number, boolean>;
|
||||
feedbackSavingByMessageId: Record<number, boolean>;
|
||||
markdownComponents: Components;
|
||||
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||
|
|
@ -59,6 +61,7 @@ interface DashboardChatPanelProps {
|
|||
onToggleUserExpand: (key: string) => void;
|
||||
onEditUserPrompt: (text: string) => void;
|
||||
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
||||
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
||||
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
||||
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
||||
onQuoteAssistantReply: (message: ChatMessage) => void;
|
||||
|
|
@ -66,6 +69,7 @@ interface DashboardChatPanelProps {
|
|||
isThinking: boolean;
|
||||
canChat: boolean;
|
||||
isChatEnabled: boolean;
|
||||
speechEnabled: boolean;
|
||||
selectedBotEnabled: boolean;
|
||||
selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling';
|
||||
quotedReply: { text: string } | null;
|
||||
|
|
@ -103,8 +107,8 @@ interface DashboardChatPanelProps {
|
|||
isVoiceTranscribing: boolean;
|
||||
isCompactMobile: boolean;
|
||||
voiceCountdown: number;
|
||||
onVoiceInput: () => void;
|
||||
onTriggerPickAttachments: () => void;
|
||||
onVoiceInput: () => Promise<void> | void;
|
||||
onTriggerPickAttachments: () => Promise<void> | void;
|
||||
showInterruptSubmitAction: boolean;
|
||||
onSubmitAction: () => Promise<void> | void;
|
||||
}
|
||||
|
|
@ -117,6 +121,7 @@ export function DashboardChatPanel({
|
|||
onChatScroll,
|
||||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
deletingMessageIdMap,
|
||||
feedbackSavingByMessageId,
|
||||
markdownComponents,
|
||||
workspaceDownloadExtensionSet,
|
||||
|
|
@ -124,6 +129,7 @@ export function DashboardChatPanel({
|
|||
onToggleUserExpand,
|
||||
onEditUserPrompt,
|
||||
onCopyUserPrompt,
|
||||
onDeleteConversationMessage,
|
||||
onOpenWorkspacePath,
|
||||
onSubmitAssistantFeedback,
|
||||
onQuoteAssistantReply,
|
||||
|
|
@ -131,6 +137,7 @@ export function DashboardChatPanel({
|
|||
isThinking,
|
||||
canChat,
|
||||
isChatEnabled,
|
||||
speechEnabled,
|
||||
selectedBotEnabled,
|
||||
selectedBotControlState,
|
||||
quotedReply,
|
||||
|
|
@ -188,6 +195,7 @@ export function DashboardChatPanel({
|
|||
badReply: labels.badReply,
|
||||
copyPrompt: labels.copyPrompt,
|
||||
copyReply: labels.copyReply,
|
||||
deleteMessage: labels.deleteMessage,
|
||||
download: labels.download,
|
||||
editPrompt: labels.editPrompt,
|
||||
fileNotPreviewable: labels.fileNotPreviewable,
|
||||
|
|
@ -200,6 +208,7 @@ export function DashboardChatPanel({
|
|||
}}
|
||||
expandedProgressByKey={expandedProgressByKey}
|
||||
expandedUserByKey={expandedUserByKey}
|
||||
deletingMessageIdMap={deletingMessageIdMap}
|
||||
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
||||
markdownComponents={markdownComponents}
|
||||
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
||||
|
|
@ -207,6 +216,7 @@ export function DashboardChatPanel({
|
|||
onToggleUserExpand={onToggleUserExpand}
|
||||
onEditUserPrompt={onEditUserPrompt}
|
||||
onCopyUserPrompt={onCopyUserPrompt}
|
||||
onDeleteConversationMessage={onDeleteConversationMessage}
|
||||
onOpenWorkspacePath={onOpenWorkspacePath}
|
||||
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
||||
onQuoteAssistantReply={onQuoteAssistantReply}
|
||||
|
|
@ -453,8 +463,8 @@ export function DashboardChatPanel({
|
|||
) : null}
|
||||
<button
|
||||
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
|
||||
disabled={!canChat || isVoiceTranscribing}
|
||||
onClick={onVoiceInput}
|
||||
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
|
||||
onClick={() => void onVoiceInput()}
|
||||
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
||||
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
||||
>
|
||||
|
|
@ -469,7 +479,7 @@ export function DashboardChatPanel({
|
|||
<LucentIconButton
|
||||
className="ops-composer-inline-btn"
|
||||
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
|
||||
onClick={onTriggerPickAttachments}
|
||||
onClick={() => void onTriggerPickAttachments()}
|
||||
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
||||
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -72,12 +72,14 @@
|
|||
pointer-events: none;
|
||||
transform: translateX(6px) scale(0.95);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease, width 0.18s ease, margin-right 0.18s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ops-chat-row.is-user:hover .ops-chat-hover-actions-user,
|
||||
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
|
||||
width: 54px;
|
||||
margin-right: 6px;
|
||||
width: 84px;
|
||||
margin-right: 8px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0) scale(1);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, UserRound } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
|
@ -17,6 +17,7 @@ interface DashboardConversationLabels {
|
|||
badReply: string;
|
||||
copyPrompt: string;
|
||||
copyReply: string;
|
||||
deleteMessage: string;
|
||||
download: string;
|
||||
editPrompt: string;
|
||||
fileNotPreviewable: string;
|
||||
|
|
@ -34,6 +35,7 @@ interface DashboardConversationMessagesProps {
|
|||
labels: DashboardConversationLabels;
|
||||
expandedProgressByKey: Record<string, boolean>;
|
||||
expandedUserByKey: Record<string, boolean>;
|
||||
deletingMessageIdMap: Record<number, boolean>;
|
||||
feedbackSavingByMessageId: Record<number, boolean>;
|
||||
markdownComponents: Components;
|
||||
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||
|
|
@ -41,6 +43,7 @@ interface DashboardConversationMessagesProps {
|
|||
onToggleUserExpand: (key: string) => void;
|
||||
onEditUserPrompt: (text: string) => void;
|
||||
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
||||
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
||||
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
||||
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
||||
onQuoteAssistantReply: (message: ChatMessage) => void;
|
||||
|
|
@ -54,12 +57,21 @@ function shouldCollapseProgress(text: string) {
|
|||
return lines > 6 || normalized.length > 520;
|
||||
}
|
||||
|
||||
function getConversationItemKey(item: ChatMessage, idx: number) {
|
||||
const messageId = Number(item.id);
|
||||
if (Number.isFinite(messageId) && messageId > 0) {
|
||||
return `id:${messageId}`;
|
||||
}
|
||||
return `temp:${item.role}:${item.kind || 'final'}:${item.ts}:${idx}`;
|
||||
}
|
||||
|
||||
export function DashboardConversationMessages({
|
||||
conversation,
|
||||
isZh,
|
||||
labels,
|
||||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
deletingMessageIdMap,
|
||||
feedbackSavingByMessageId,
|
||||
markdownComponents,
|
||||
workspaceDownloadExtensionSet,
|
||||
|
|
@ -67,6 +79,7 @@ export function DashboardConversationMessages({
|
|||
onToggleUserExpand,
|
||||
onEditUserPrompt,
|
||||
onCopyUserPrompt,
|
||||
onDeleteConversationMessage,
|
||||
onOpenWorkspacePath,
|
||||
onSubmitAssistantFeedback,
|
||||
onQuoteAssistantReply,
|
||||
|
|
@ -75,7 +88,7 @@ export function DashboardConversationMessages({
|
|||
return (
|
||||
<>
|
||||
{conversation.map((item, idx) => {
|
||||
const itemKey = `${item.id || item.ts}-${idx}`;
|
||||
const itemKey = getConversationItemKey(item, idx);
|
||||
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
|
||||
const isUserBubble = item.role === 'user';
|
||||
const fullText = String(item.text || '');
|
||||
|
|
@ -91,6 +104,7 @@ export function DashboardConversationMessages({
|
|||
const currentDayKey = new Date(item.ts).toDateString();
|
||||
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
|
||||
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
|
||||
const isDeleting = Boolean(item.id && deletingMessageIdMap[item.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -127,6 +141,15 @@ export function DashboardConversationMessages({
|
|||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -241,6 +264,15 @@ export function DashboardConversationMessages({
|
|||
>
|
||||
<Copy size={13} />
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="ops-chat-inline-action"
|
||||
onClick={() => void onDeleteConversationMessage(item)}
|
||||
disabled={isDeleting}
|
||||
tooltip={labels.deleteMessage}
|
||||
aria-label={labels.deleteMessage}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ export function useBotDashboardModule({
|
|||
workspaceDownloadExtensions,
|
||||
} = useDashboardSystemDefaults({
|
||||
setBotListPageSize,
|
||||
setChatPullPageSize,
|
||||
setCommandAutoUnlockSeconds,
|
||||
});
|
||||
const {
|
||||
botListMenuOpen,
|
||||
|
|
@ -425,6 +427,8 @@ export function useBotDashboardModule({
|
|||
composerTextareaRef,
|
||||
copyAssistantReply,
|
||||
copyUserPrompt,
|
||||
deleteConversationMessage,
|
||||
deletingMessageIdMap,
|
||||
editUserPrompt,
|
||||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
|
|
@ -469,6 +473,7 @@ export function useBotDashboardModule({
|
|||
setBotMessages,
|
||||
setBotMessageFeedback,
|
||||
notify,
|
||||
confirm,
|
||||
t,
|
||||
isZh,
|
||||
});
|
||||
|
|
@ -537,8 +542,6 @@ export function useBotDashboardModule({
|
|||
selectedBotId,
|
||||
setBotListMenuOpen,
|
||||
setChatDatePickerOpen,
|
||||
setChatPullPageSize,
|
||||
setCommandAutoUnlockSeconds,
|
||||
setPendingAttachments,
|
||||
setShowRuntimeActionModal,
|
||||
setRuntimeMenuOpen,
|
||||
|
|
@ -652,6 +655,7 @@ export function useBotDashboardModule({
|
|||
onChatScroll,
|
||||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
deletingMessageIdMap,
|
||||
feedbackSavingByMessageId,
|
||||
markdownComponents,
|
||||
workspaceDownloadExtensionSet,
|
||||
|
|
@ -659,12 +663,14 @@ export function useBotDashboardModule({
|
|||
toggleUserExpanded,
|
||||
editUserPrompt,
|
||||
copyUserPrompt,
|
||||
deleteConversationMessage,
|
||||
submitAssistantFeedback,
|
||||
quoteAssistantReply,
|
||||
copyAssistantReply,
|
||||
isThinking,
|
||||
canChat,
|
||||
isChatEnabled,
|
||||
speechEnabled,
|
||||
selectedBotEnabled,
|
||||
selectedBotControlState,
|
||||
quotedReply,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import axios from 'axios';
|
|||
|
||||
import { APP_ENDPOINTS } from '../../../config/env';
|
||||
import type { ChatMessage } from '../../../types/bot';
|
||||
import { normalizeAssistantMessageText } from '../messageParser';
|
||||
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
||||
import type { BotMessagesByDateResponse } from '../types';
|
||||
import {
|
||||
formatConversationDate,
|
||||
|
|
@ -19,6 +19,14 @@ interface NotifyOptions {
|
|||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
tone?: PromptTone;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
interface UseDashboardChatHistoryOptions {
|
||||
selectedBotId: string;
|
||||
messages: ChatMessage[];
|
||||
|
|
@ -29,6 +37,7 @@ interface UseDashboardChatHistoryOptions {
|
|||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
t: any;
|
||||
isZh: boolean;
|
||||
}
|
||||
|
|
@ -43,6 +52,7 @@ export function useDashboardChatHistory({
|
|||
setBotMessages,
|
||||
setBotMessageFeedback,
|
||||
notify,
|
||||
confirm,
|
||||
t,
|
||||
isZh,
|
||||
}: UseDashboardChatHistoryOptions) {
|
||||
|
|
@ -56,6 +66,7 @@ export function useDashboardChatHistory({
|
|||
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
||||
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
|
||||
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
|
||||
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
|
||||
|
||||
const chatScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
|
@ -81,6 +92,7 @@ export function useDashboardChatHistory({
|
|||
setExpandedProgressByKey({});
|
||||
setExpandedUserByKey({});
|
||||
setFeedbackSavingByMessageId({});
|
||||
setDeletingMessageIdMap({});
|
||||
setChatDatePickerOpen(false);
|
||||
setChatDatePanelPosition(null);
|
||||
setChatJumpAnchorId(null);
|
||||
|
|
@ -406,6 +418,153 @@ export function useDashboardChatHistory({
|
|||
}
|
||||
};
|
||||
|
||||
const resolveMessageIdFromLatest = useCallback(async (message: ChatMessage) => {
|
||||
if (!selectedBotId) return null;
|
||||
const latest = await fetchBotMessages(selectedBotId);
|
||||
const normalizedTargetText = message.role === 'user'
|
||||
? normalizeUserMessageText(message.text)
|
||||
: normalizeAssistantMessageText(message.text);
|
||||
const targetAttachments = JSON.stringify(message.attachments || []);
|
||||
const matched = latest
|
||||
.filter((row) => row.role === message.role && row.id)
|
||||
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
|
||||
.filter(({ message: row, diff }) => {
|
||||
const normalizedRowText = row.role === 'user'
|
||||
? normalizeUserMessageText(row.text)
|
||||
: normalizeAssistantMessageText(row.text);
|
||||
return normalizedRowText === normalizedTargetText
|
||||
&& JSON.stringify(row.attachments || []) === targetAttachments
|
||||
&& diff <= 10 * 60 * 1000;
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff)[0]?.message;
|
||||
return matched?.id || null;
|
||||
}, [fetchBotMessages, selectedBotId]);
|
||||
|
||||
const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => {
|
||||
if (!selectedBotId) return;
|
||||
const originalMessageId = Number(message.id);
|
||||
const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0;
|
||||
const idsToRemove = new Set<number>([deletedMessageId]);
|
||||
if (hasOriginalId) {
|
||||
idsToRemove.add(originalMessageId);
|
||||
}
|
||||
|
||||
const scrollBox = chatScrollRef.current;
|
||||
const prevTop = scrollBox?.scrollTop ?? null;
|
||||
const normalizedTargetText = message.role === 'user'
|
||||
? normalizeUserMessageText(message.text)
|
||||
: normalizeAssistantMessageText(message.text);
|
||||
const targetAttachments = JSON.stringify(message.attachments || []);
|
||||
|
||||
const nextMessages = messages.filter((row) => {
|
||||
const rowId = Number(row.id);
|
||||
if (Number.isFinite(rowId) && rowId > 0) {
|
||||
return !idsToRemove.has(rowId);
|
||||
}
|
||||
if (hasOriginalId || row.role !== message.role) {
|
||||
return true;
|
||||
}
|
||||
const normalizedRowText = row.role === 'user'
|
||||
? normalizeUserMessageText(row.text)
|
||||
: normalizeAssistantMessageText(row.text);
|
||||
return !(
|
||||
normalizedRowText === normalizedTargetText
|
||||
&& JSON.stringify(row.attachments || []) === targetAttachments
|
||||
&& Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000
|
||||
);
|
||||
});
|
||||
|
||||
setBotMessages(selectedBotId, nextMessages);
|
||||
|
||||
if (prevTop === null || chatAutoFollowRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const box = chatScrollRef.current;
|
||||
if (!box) return;
|
||||
const maxTop = Math.max(0, box.scrollHeight - box.clientHeight);
|
||||
box.scrollTop = Math.min(prevTop, maxTop);
|
||||
});
|
||||
}, [messages, selectedBotId, setBotMessages]);
|
||||
|
||||
const deleteConversationMessageOnServer = useCallback(async (messageId: number) => {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`);
|
||||
}, [selectedBotId]);
|
||||
|
||||
const deleteConversationMessage = useCallback(async (message: ChatMessage) => {
|
||||
if (!selectedBotId) {
|
||||
notify(t.deleteMessagePending, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
let targetMessageId = Number(message.id);
|
||||
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
|
||||
targetMessageId = Number(await resolveMessageIdFromLatest(message));
|
||||
}
|
||||
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
|
||||
notify(t.deleteMessagePending, { tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (deletingMessageIdMap[targetMessageId]) return;
|
||||
|
||||
const roleLabel = message.role === 'user' ? t.you : 'Nanobot';
|
||||
const ok = await confirm({
|
||||
title: t.deleteMessage,
|
||||
message: t.deleteMessageConfirm(roleLabel),
|
||||
tone: 'warning',
|
||||
confirmLabel: t.delete,
|
||||
cancelLabel: t.cancel,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true }));
|
||||
try {
|
||||
await deleteConversationMessageOnServer(targetMessageId);
|
||||
removeConversationMessageLocally(message, targetMessageId);
|
||||
notify(t.deleteMessageDone, { tone: 'success' });
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/delete`);
|
||||
removeConversationMessageLocally(message, targetMessageId);
|
||||
notify(t.deleteMessageDone, { tone: 'success' });
|
||||
return;
|
||||
} catch {
|
||||
// continue to secondary re-match fallback below
|
||||
}
|
||||
}
|
||||
if (error?.response?.status === 404) {
|
||||
const refreshedMessageId = Number(await resolveMessageIdFromLatest(message));
|
||||
if (Number.isFinite(refreshedMessageId) && refreshedMessageId > 0 && refreshedMessageId !== targetMessageId) {
|
||||
try {
|
||||
await deleteConversationMessageOnServer(refreshedMessageId);
|
||||
removeConversationMessageLocally(message, refreshedMessageId);
|
||||
notify(t.deleteMessageDone, { tone: 'success' });
|
||||
return;
|
||||
} catch (retryError: any) {
|
||||
if (retryError?.response?.status === 404) {
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${refreshedMessageId}/delete`);
|
||||
removeConversationMessageLocally(message, refreshedMessageId);
|
||||
notify(t.deleteMessageDone, { tone: 'success' });
|
||||
return;
|
||||
} catch (postRetryError: any) {
|
||||
notify(postRetryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify(retryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
notify(error?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
|
||||
} finally {
|
||||
setDeletingMessageIdMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[targetMessageId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]);
|
||||
|
||||
const toggleProgressExpanded = (key: string) => {
|
||||
setExpandedProgressByKey((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -430,6 +589,8 @@ export function useDashboardChatHistory({
|
|||
expandedProgressByKey,
|
||||
expandedUserByKey,
|
||||
feedbackSavingByMessageId,
|
||||
deletingMessageIdMap,
|
||||
deleteConversationMessage,
|
||||
jumpConversationToDate,
|
||||
loadInitialChatPage,
|
||||
onChatScroll,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ interface NotifyOptions {
|
|||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
tone?: PromptTone;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
interface UseDashboardConversationOptions {
|
||||
selectedBotId: string;
|
||||
selectedBot?: { id: string; messages?: ChatMessage[] } | null;
|
||||
|
|
@ -29,6 +37,7 @@ interface UseDashboardConversationOptions {
|
|||
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
|
||||
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
t: any;
|
||||
isZh: boolean;
|
||||
}
|
||||
|
|
@ -44,6 +53,7 @@ export function useDashboardConversation(options: UseDashboardConversationOption
|
|||
setBotMessages: options.setBotMessages,
|
||||
setBotMessageFeedback: options.setBotMessageFeedback,
|
||||
notify: options.notify,
|
||||
confirm: options.confirm,
|
||||
t: options.t,
|
||||
isZh: options.isZh,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
|
||||
import { fetchDashboardSystemDefaults } from '../api/system';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
interface NotifyOptions {
|
||||
|
|
@ -34,8 +32,6 @@ interface UseDashboardLifecycleOptions {
|
|||
selectedBotId: string;
|
||||
setBotListMenuOpen: (value: boolean) => void;
|
||||
setChatDatePickerOpen: (value: boolean) => void;
|
||||
setChatPullPageSize: Dispatch<SetStateAction<number>>;
|
||||
setCommandAutoUnlockSeconds: Dispatch<SetStateAction<number>>;
|
||||
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
|
||||
setShowRuntimeActionModal: (value: boolean) => void;
|
||||
setRuntimeMenuOpen: (value: boolean) => void;
|
||||
|
|
@ -70,8 +66,6 @@ export function useDashboardLifecycle({
|
|||
selectedBotId,
|
||||
setBotListMenuOpen,
|
||||
setChatDatePickerOpen,
|
||||
setChatPullPageSize,
|
||||
setCommandAutoUnlockSeconds,
|
||||
setPendingAttachments,
|
||||
setShowRuntimeActionModal,
|
||||
setRuntimeMenuOpen,
|
||||
|
|
@ -118,30 +112,6 @@ export function useDashboardLifecycle({
|
|||
hideWorkspaceHoverCard();
|
||||
}, [hideWorkspaceHoverCard, selectedBotId, setShowRuntimeActionModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const loadChatDefaults = async () => {
|
||||
try {
|
||||
const data = await fetchDashboardSystemDefaults();
|
||||
if (!alive) return;
|
||||
const pullPageSize = Number(data?.chat?.pull_page_size);
|
||||
if (Number.isFinite(pullPageSize) && pullPageSize > 0) {
|
||||
setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize))));
|
||||
}
|
||||
const autoUnlockSeconds = Number(data?.chat?.command_auto_unlock_seconds);
|
||||
if (Number.isFinite(autoUnlockSeconds) && autoUnlockSeconds > 0) {
|
||||
setCommandAutoUnlockSeconds(Math.max(1, Math.min(600, Math.floor(autoUnlockSeconds))));
|
||||
}
|
||||
} catch {
|
||||
// Keep current chat defaults when system defaults are unavailable.
|
||||
}
|
||||
};
|
||||
void loadChatDefaults();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [setChatPullPageSize, setCommandAutoUnlockSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBotId) {
|
||||
resetWorkspaceState();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { parseAllowedAttachmentExtensions, parseTopicPresets, parseWorkspaceDown
|
|||
|
||||
interface UseDashboardSystemDefaultsOptions {
|
||||
setBotListPageSize: Dispatch<SetStateAction<number>>;
|
||||
setChatPullPageSize?: Dispatch<SetStateAction<number>>;
|
||||
setCommandAutoUnlockSeconds?: Dispatch<SetStateAction<number>>;
|
||||
setVoiceCountdown?: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
|
|
@ -22,8 +24,22 @@ function resolveVoiceMaxSeconds(raw: unknown) {
|
|||
return Math.max(5, Math.floor(configured));
|
||||
}
|
||||
|
||||
function resolveChatPullPageSize(raw: unknown) {
|
||||
const configured = Number(raw);
|
||||
if (!Number.isFinite(configured) || configured <= 0) return 60;
|
||||
return Math.max(10, Math.min(500, Math.floor(configured)));
|
||||
}
|
||||
|
||||
function resolveCommandAutoUnlockSeconds(raw: unknown) {
|
||||
const configured = Number(raw);
|
||||
if (!Number.isFinite(configured) || configured <= 0) return 10;
|
||||
return Math.max(1, Math.min(600, Math.floor(configured)));
|
||||
}
|
||||
|
||||
export function useDashboardSystemDefaults({
|
||||
setBotListPageSize,
|
||||
setChatPullPageSize,
|
||||
setCommandAutoUnlockSeconds,
|
||||
setVoiceCountdown,
|
||||
}: UseDashboardSystemDefaultsOptions) {
|
||||
const [botListPageSizeReady, setBotListPageSizeReady] = useState(false);
|
||||
|
|
@ -45,7 +61,8 @@ export function useDashboardSystemDefaults({
|
|||
setBotListPageSize((prev) =>
|
||||
normalizePlatformPageSize(data?.chat?.page_size, normalizePlatformPageSize(prev, 10)),
|
||||
);
|
||||
|
||||
setChatPullPageSize?.(resolveChatPullPageSize(data?.chat?.pull_page_size));
|
||||
setCommandAutoUnlockSeconds?.(resolveCommandAutoUnlockSeconds(data?.chat?.command_auto_unlock_seconds));
|
||||
setAllowedAttachmentExtensions(
|
||||
parseAllowedAttachmentExtensions(data?.workspace?.allowed_attachment_extensions),
|
||||
);
|
||||
|
|
@ -64,7 +81,7 @@ export function useDashboardSystemDefaults({
|
|||
setVoiceMaxSeconds(nextVoiceMaxSeconds);
|
||||
setVoiceCountdown?.(nextVoiceMaxSeconds);
|
||||
}
|
||||
}, [setBotListPageSize, setVoiceCountdown]);
|
||||
}, [setBotListPageSize, setChatPullPageSize, setCommandAutoUnlockSeconds, setVoiceCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
|
@ -94,19 +111,25 @@ export function useDashboardSystemDefaults({
|
|||
const nextAllowedAttachmentExtensions = parseAllowedAttachmentExtensions(
|
||||
data?.workspace?.allowed_attachment_extensions,
|
||||
);
|
||||
const nextWorkspaceDownloadExtensions = parseWorkspaceDownloadExtensions(
|
||||
data?.workspace?.download_extensions,
|
||||
);
|
||||
setUploadMaxMb(nextUploadMaxMb);
|
||||
setAllowedAttachmentExtensions(nextAllowedAttachmentExtensions);
|
||||
setWorkspaceDownloadExtensions(nextWorkspaceDownloadExtensions);
|
||||
return {
|
||||
uploadMaxMb: nextUploadMaxMb,
|
||||
allowedAttachmentExtensions: nextAllowedAttachmentExtensions,
|
||||
workspaceDownloadExtensions: nextWorkspaceDownloadExtensions,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
uploadMaxMb,
|
||||
allowedAttachmentExtensions,
|
||||
workspaceDownloadExtensions,
|
||||
};
|
||||
}
|
||||
}, [allowedAttachmentExtensions, uploadMaxMb]);
|
||||
}, [allowedAttachmentExtensions, uploadMaxMb, workspaceDownloadExtensions]);
|
||||
|
||||
return {
|
||||
allowedAttachmentExtensions,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface NotifyOptions {
|
|||
interface AttachmentPolicySnapshot {
|
||||
uploadMaxMb: number;
|
||||
allowedAttachmentExtensions: string[];
|
||||
workspaceDownloadExtensions?: string[];
|
||||
}
|
||||
|
||||
interface UseDashboardWorkspaceOptions {
|
||||
|
|
@ -85,10 +86,26 @@ export function useDashboardWorkspace({
|
|||
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
|
||||
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
|
||||
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
|
||||
const [workspaceDownloadExtensionList, setWorkspaceDownloadExtensionList] = useState<string[]>(
|
||||
() => parseWorkspaceDownloadExtensions(workspaceDownloadExtensions),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextList = parseWorkspaceDownloadExtensions(workspaceDownloadExtensions);
|
||||
setWorkspaceDownloadExtensionList((current) => {
|
||||
if (
|
||||
current.length === nextList.length &&
|
||||
current.every((item, index) => item === nextList[index])
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return nextList;
|
||||
});
|
||||
}, [workspaceDownloadExtensions]);
|
||||
|
||||
const workspaceDownloadExtensionSet = useMemo(
|
||||
() => new Set(parseWorkspaceDownloadExtensions(workspaceDownloadExtensions)),
|
||||
[workspaceDownloadExtensions],
|
||||
() => new Set(workspaceDownloadExtensionList),
|
||||
[workspaceDownloadExtensionList],
|
||||
);
|
||||
const workspaceFiles = useMemo(
|
||||
() => workspaceEntries.filter((entry) => entry.type === 'file' && isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet)),
|
||||
|
|
|
|||
|
|
@ -64,6 +64,33 @@ export function normalizeAssistantMessageText(input: string) {
|
|||
return text;
|
||||
}
|
||||
|
||||
export function repairCollapsedMarkdown(input: string) {
|
||||
const normalized = normalizeAssistantMessageText(input);
|
||||
if (!normalized) return '';
|
||||
if (normalized.includes('\n')) return normalized;
|
||||
|
||||
let text = normalized;
|
||||
|
||||
text = text
|
||||
.replace(/\s+(#{1,6}\s+)/g, '\n\n$1')
|
||||
.replace(/\s+(```[A-Za-z0-9_-]*)\s+/g, '\n\n$1\n')
|
||||
.replace(/\s+```/g, '\n```\n')
|
||||
.replace(/\s+(---|\*\*\*|___)\s+/g, '\n\n$1\n\n');
|
||||
|
||||
if (/\|/.test(text) && /\|\s*:?-{3,}/.test(text)) {
|
||||
text = text
|
||||
.replace(/\s*(\|[^|\n]*\|\s*:?-{3,}(?:\s*\|\s*:?-{3,})+\s*\|)\s*/g, '\n$1\n')
|
||||
.replace(/\|\s+\|/g, '|\n|');
|
||||
}
|
||||
|
||||
text = text
|
||||
.replace(/([^\n])\s-\s(?=\S)/g, '$1\n- ')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function summarizeProgressText(input: string, isZh: boolean) {
|
||||
const raw = normalizeAssistantMessageText(input);
|
||||
if (!raw) return isZh ? '处理中...' : 'Processing...';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import '../../components/skill-market/SkillMarketShared.css';
|
||||
import { PlatformSummaryCards } from './components/PlatformSummaryCards';
|
||||
import { PlatformUsageAnalyticsSection } from './components/PlatformUsageAnalyticsSection';
|
||||
import { PlatformBotActivityAnalyticsSection } from './components/PlatformBotActivityAnalyticsSection';
|
||||
import { usePlatformDashboard } from './hooks/usePlatformDashboard';
|
||||
import './PlatformDashboardPage.css';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboa
|
|||
overviewResources={dashboard.overviewResources}
|
||||
/>
|
||||
|
||||
<div className="platform-analytics-grid">
|
||||
<PlatformUsageAnalyticsSection
|
||||
isZh={dashboard.isZh}
|
||||
usageAnalytics={dashboard.usageAnalytics}
|
||||
|
|
@ -31,6 +33,13 @@ export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboa
|
|||
usageLoading={dashboard.usageLoading}
|
||||
usageSummary={dashboard.usageSummary}
|
||||
/>
|
||||
|
||||
<PlatformBotActivityAnalyticsSection
|
||||
isZh={dashboard.isZh}
|
||||
activityStats={dashboard.activityStats}
|
||||
loading={dashboard.loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ interface PlatformBotManagementPageProps {
|
|||
compactMode: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
|
||||
|
||||
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
|
||||
const dashboard = usePlatformDashboard({ compactMode });
|
||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||
const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -65,7 +68,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
|||
isZh={dashboard.isZh}
|
||||
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
|
||||
selectedBotInfo={dashboard.selectedBotInfo}
|
||||
workspaceDownloadExtensions={dashboard.overview?.settings?.workspace_download_extensions || []}
|
||||
workspaceDownloadExtensions={workspaceDownloadExtensions}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -99,7 +102,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
|
|||
isZh={dashboard.isZh}
|
||||
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
|
||||
selectedBotInfo={dashboard.selectedBotInfo}
|
||||
workspaceDownloadExtensions={dashboard.overview?.settings?.workspace_download_extensions || []}
|
||||
workspaceDownloadExtensions={workspaceDownloadExtensions}
|
||||
/>
|
||||
</>
|
||||
</PlatformCompactBotSheet>
|
||||
|
|
|
|||
|
|
@ -675,14 +675,23 @@
|
|||
|
||||
.platform-selected-bot-last-body {
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
max-height: min(58vh, 680px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
|
||||
background: color-mix(in oklab, var(--panel-soft) 76%, transparent);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.platform-last-action-markdown {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.platform-last-action-markdown p,
|
||||
.platform-last-action-markdown li,
|
||||
.platform-last-action-markdown blockquote {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.platform-last-action-btn {
|
||||
|
|
@ -1149,6 +1158,9 @@
|
|||
|
||||
.platform-last-action-modal {
|
||||
width: min(760px, 92vw);
|
||||
max-height: min(82vh, 920px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.platform-resource-summary-grid {
|
||||
|
|
@ -1283,6 +1295,23 @@
|
|||
padding-right: 0;
|
||||
}
|
||||
|
||||
.platform-analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.platform-analytics-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.platform-analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-settings-page-workspace,
|
||||
.platform-image-page-workspace {
|
||||
padding-right: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import type { BotActivityStatsItem } from '../types';
|
||||
|
||||
interface PlatformBotActivityAnalyticsSectionProps {
|
||||
isZh: boolean;
|
||||
activityStats: BotActivityStatsItem[] | undefined;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function PlatformBotActivityAnalyticsSection({
|
||||
isZh,
|
||||
activityStats,
|
||||
loading,
|
||||
}: PlatformBotActivityAnalyticsSectionProps) {
|
||||
const totalActivity = activityStats?.reduce((acc, item) => acc + item.count, 0) || 0;
|
||||
const displayStats = (activityStats || []).slice(0, 10);
|
||||
const maxCount = Math.max(...(displayStats.map(item => item.count) || [0]), 1);
|
||||
const tickCount = 4;
|
||||
const yAxisTicks = Array.from({ length: tickCount + 1 }, (_, index) => Math.round((maxCount / tickCount) * index));
|
||||
|
||||
return (
|
||||
<section className="panel stack platform-analytics-panel">
|
||||
<div className="platform-model-analytics-head">
|
||||
<div>
|
||||
<h2>{isZh ? 'Bot 活跃度分析' : 'Bot Activity Analytics'}</h2>
|
||||
<div className="platform-model-analytics-subtitle">
|
||||
{isZh ? '基于 bot_activity_event 中 request_id 非空记录的活跃度统计' : 'Activity statistics based on non-empty request_id rows in bot_activity_event'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-model-analytics-total">
|
||||
<strong>{totalActivity}</strong>
|
||||
<span>{isZh ? '总请求数' : 'Total Requests'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !activityStats ? (
|
||||
<div className="ops-empty-inline">{isZh ? '正在加载活跃度统计...' : 'Loading activity analytics...'}</div>
|
||||
) : null}
|
||||
|
||||
{!loading && (!activityStats || activityStats.length === 0) ? (
|
||||
<div className="ops-empty-inline">{isZh ? '暂无活跃度数据。' : 'No activity data.'}</div>
|
||||
) : null}
|
||||
|
||||
{activityStats && activityStats.length > 0 ? (() => {
|
||||
const barStep = 72;
|
||||
const barWidth = 40;
|
||||
const chartWidth = Math.max(680, displayStats.length * barStep + 120);
|
||||
const chartHeight = 320;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 78;
|
||||
const paddingLeft = 52;
|
||||
const paddingRight = 24;
|
||||
const innerWidth = chartWidth - paddingLeft - paddingRight;
|
||||
const innerHeight = chartHeight - paddingTop - paddingBottom;
|
||||
const startX = paddingLeft + Math.max((innerWidth - displayStats.length * barStep) / 2, 0) + (barStep - barWidth) / 2;
|
||||
|
||||
return (
|
||||
<div className="platform-model-chart" style={{ marginTop: '16px' }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
role="img"
|
||||
aria-label={isZh ? '启用 Bot 请求数柱状图' : 'Enabled bot request count bar chart'}
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
>
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = paddingTop + innerHeight - (tick / maxCount) * innerHeight;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={paddingLeft} y1={y} x2={chartWidth - paddingRight} y2={y} className="platform-model-chart-grid" />
|
||||
<text x={paddingLeft - 10} y={y + 4} textAnchor="end" className="platform-model-chart-axis-label">
|
||||
{tick}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{displayStats.map((item, index) => {
|
||||
const x = startX + index * barStep;
|
||||
const normalizedCount = Number(item.count || 0);
|
||||
const columnHeight = (normalizedCount / maxCount) * innerHeight;
|
||||
const y = paddingTop + innerHeight - columnHeight;
|
||||
return (
|
||||
<g key={item.bot_id}>
|
||||
<rect
|
||||
x={x}
|
||||
y={paddingTop}
|
||||
width={barWidth}
|
||||
height={innerHeight}
|
||||
rx={12}
|
||||
style={{ fill: 'rgba(128, 128, 128, 0.08)' }}
|
||||
/>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={columnHeight}
|
||||
rx={12}
|
||||
style={{ fill: 'url(#activityGradient)' }}
|
||||
/>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={Math.max(y - 8, paddingTop + 14)}
|
||||
textAnchor="middle"
|
||||
style={{ fontSize: '12px', fill: 'var(--title)', fontWeight: 700 }}
|
||||
>
|
||||
{normalizedCount}
|
||||
</text>
|
||||
<text
|
||||
x={x + barWidth / 2}
|
||||
y={chartHeight - 18}
|
||||
textAnchor="end"
|
||||
className="platform-model-chart-axis-label"
|
||||
transform={`rotate(-28 ${x + barWidth / 2} ${chartHeight - 18})`}
|
||||
>
|
||||
{item.name.length > 12 ? item.name.slice(0, 12) : item.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<defs>
|
||||
<linearGradient id="activityGradient" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" style={{ stopColor: '#6c5ac3', stopOpacity: 1 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#7f90ff', stopOpacity: 1 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
{(activityStats?.length || 0) > 10 && (
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', color: 'var(--muted)', textAlign: 'center' }}>
|
||||
{isZh ? `仅显示前 10 个活跃 Bot (共 ${activityStats.length} 个)` : `Showing top 10 active bots (total ${activityStats.length})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -166,6 +166,7 @@ export function PlatformBotRuntimeSection({
|
|||
refreshAttachmentPolicy: async () => ({
|
||||
uploadMaxMb: 0,
|
||||
allowedAttachmentExtensions: [],
|
||||
workspaceDownloadExtensions,
|
||||
}),
|
||||
notify,
|
||||
t: dashboardT,
|
||||
|
|
@ -180,7 +181,10 @@ export function PlatformBotRuntimeSection({
|
|||
}
|
||||
resetWorkspaceState();
|
||||
void loadWorkspaceTree(selectedBotInfo.id, '');
|
||||
}, [loadWorkspaceTree, resetWorkspaceState, selectedBotInfo?.id]);
|
||||
// Re-run only when the selected bot changes; loadWorkspaceTree is recreated
|
||||
// by workspace policy updates and would otherwise cause an initialization loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetWorkspaceState, selectedBotInfo?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setDockerLogsPage(1);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { ImageFactoryModule } from '../../images/ImageFactoryModule';
|
||||
import type { BotState } from '../../../types/bot';
|
||||
import { MARKDOWN_SANITIZE_SCHEMA } from '../../dashboard/constants';
|
||||
import { repairCollapsedMarkdown } from '../../dashboard/messageParser';
|
||||
import {
|
||||
createWorkspaceMarkdownComponents,
|
||||
decorateWorkspacePathsForMarkdown,
|
||||
} from '../../dashboard/shared/workspaceMarkdown';
|
||||
import type { PlatformBotResourceSnapshot } from '../types';
|
||||
import { formatPlatformBytes, formatPlatformPercent } from '../utils';
|
||||
import '../../dashboard/components/WorkspaceOverlay.css';
|
||||
|
||||
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
|
||||
|
||||
interface PlatformCompactBotSheetProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -93,6 +106,8 @@ export function PlatformLastActionModal({
|
|||
}: PlatformLastActionModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const content = repairCollapsedMarkdown(lastAction || (isZh ? '暂无最近执行内容。' : 'No recent execution yet.'));
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="modal-card platform-last-action-modal" onClick={(event) => event.stopPropagation()}>
|
||||
|
|
@ -107,7 +122,17 @@ export function PlatformLastActionModal({
|
|||
</LucentIconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-selected-bot-last-body">{lastAction || (isZh ? '暂无最近执行内容。' : 'No recent execution yet.')}</div>
|
||||
<div className="platform-selected-bot-last-body">
|
||||
<div className="workspace-markdown platform-last-action-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||
components={lastActionMarkdownComponents}
|
||||
>
|
||||
{decorateWorkspacePathsForMarkdown(content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ export function PlatformUsageAnalyticsSection({
|
|||
usageSummary,
|
||||
}: PlatformUsageAnalyticsSectionProps) {
|
||||
return (
|
||||
<section className="panel stack">
|
||||
<section className="panel stack platform-analytics-panel">
|
||||
<div className="platform-model-analytics-head">
|
||||
<div>
|
||||
<h2>{isZh ? '模型数据分析' : 'Model Analytics'}</h2>
|
||||
<h2>{isZh ? '模型调用分析' : 'Model Call Analytics'}</h2>
|
||||
<div className="platform-model-analytics-subtitle">
|
||||
{isZh
|
||||
? `最近 ${usageAnalytics?.window_days || 7} 天 · 调用次数趋势`
|
||||
|
|
@ -47,10 +47,10 @@ export function PlatformUsageAnalyticsSection({
|
|||
|
||||
{usageAnalytics && usageAnalytics.total_requests > 0 && usageAnalyticsSeries.length > 0 ? (() => {
|
||||
const chartWidth = 1120;
|
||||
const chartHeight = 248;
|
||||
const chartHeight = 300;
|
||||
const paddingTop = 18;
|
||||
const paddingRight = 20;
|
||||
const paddingBottom = 30;
|
||||
const paddingBottom = 36;
|
||||
const paddingLeft = 44;
|
||||
const innerWidth = chartWidth - paddingLeft - paddingRight;
|
||||
const innerHeight = chartHeight - paddingTop - paddingBottom;
|
||||
|
|
|
|||
|
|
@ -254,6 +254,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
const overviewBots = overview?.summary.bots;
|
||||
const overviewImages = overview?.summary.images;
|
||||
const overviewResources = overview?.summary.resources;
|
||||
const activityStats = overview?.activity_stats;
|
||||
const usageSummary = usageData?.summary || overview?.usage.summary;
|
||||
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
|
||||
|
||||
|
|
@ -494,6 +495,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
|
|||
storagePercent,
|
||||
toggleBot,
|
||||
usageAnalytics,
|
||||
activityStats,
|
||||
usageAnalyticsMax,
|
||||
usageAnalyticsSeries,
|
||||
usageAnalyticsTicks,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ export interface BotSkillMarketItem extends SkillMarketItem {
|
|||
install_error?: string | null;
|
||||
}
|
||||
|
||||
export interface BotActivityStatsItem {
|
||||
bot_id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PlatformOverviewResponse {
|
||||
summary: {
|
||||
bots: {
|
||||
|
|
@ -165,6 +171,7 @@ export interface PlatformOverviewResponse {
|
|||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}>;
|
||||
activity_stats?: BotActivityStatsItem[];
|
||||
}
|
||||
|
||||
export interface PlatformBotResourceSnapshot {
|
||||
|
|
|
|||
Loading…
Reference in New Issue