v0.1.4-p4

main
mula.liu 2026-04-02 12:14:08 +08:00
parent 0167a9bc8a
commit 9699b4e7c9
36 changed files with 813 additions and 757 deletions

View File

@ -23,7 +23,7 @@ Dashboard Nanobot 是面向 `nanobot` 的控制平面项目,提供镜像管理
graph TD graph TD
User((User)) --> Frontend[Frontend Control Plane] User((User)) --> Frontend[Frontend Control Plane]
Frontend --> API[FastAPI Backend] Frontend --> API[FastAPI Backend]
API --> DB[(SQLite)] API --> DB[(PostgreSQL)]
API --> Docker[Docker Daemon] API --> Docker[Docker Daemon]
Docker --> BotA[Bot Container A] Docker --> BotA[Bot Container A]
@ -63,7 +63,7 @@ graph TD
- 示例文件:`backend/.env.example` - 示例文件:`backend/.env.example`
- 本地配置:`backend/.env` - 本地配置:`backend/.env`
- 关键项: - 关键项:
- `DATABASE_URL`:数据库连接串(三选一SQLite / PostgreSQL / MySQL - `DATABASE_URL`:数据库连接串(建议使用 PostgreSQL
- `DATABASE_ECHO`SQL 日志输出开关 - `DATABASE_ECHO`SQL 日志输出开关
- 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串) - 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串)
- `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录 - `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录

View File

@ -3,11 +3,8 @@ DATA_ROOT=../data
BOTS_WORKSPACE_ROOT=../workspace/bots BOTS_WORKSPACE_ROOT=../workspace/bots
# Database # Database
# SQLite (recommended): leave DATABASE_URL unset, backend will use: # PostgreSQL is required:
# sqlite:///{DATA_ROOT}/nanobot_dashboard.db DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
# DATABASE_URL=sqlite:///../data/nanobot_dashboard.db
# PostgreSQL example:
# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
# MySQL example: # MySQL example:
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
# Show SQL statements in backend logs (debug only). # Show SQL statements in backend logs (debug only).

View File

@ -8,6 +8,7 @@ from schemas.bot import MessageFeedbackRequest
from services.chat_history_service import ( from services.chat_history_service import (
clear_bot_messages_payload, clear_bot_messages_payload,
clear_dashboard_direct_session_payload, clear_dashboard_direct_session_payload,
delete_bot_message_payload,
list_bot_messages_by_date_payload, list_bot_messages_by_date_payload,
list_bot_messages_page_payload, list_bot_messages_page_payload,
list_bot_messages_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) 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") @router.delete("/api/bots/{bot_id}/messages")
def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
return clear_bot_messages_payload(session, bot_id) return clear_bot_messages_payload(session, bot_id)

View File

@ -3,7 +3,6 @@ from sqlmodel import SQLModel, Session, create_engine
from core.settings import ( from core.settings import (
DATABASE_ECHO, DATABASE_ECHO,
DATABASE_ENGINE,
DATABASE_MAX_OVERFLOW, DATABASE_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE, DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE, DATABASE_POOL_SIZE,
@ -19,19 +18,12 @@ from models import topic as _topic_models # noqa: F401
_engine_kwargs = { _engine_kwargs = {
"echo": DATABASE_ECHO, "echo": DATABASE_ECHO,
"pool_pre_ping": True,
"pool_size": DATABASE_POOL_SIZE,
"max_overflow": DATABASE_MAX_OVERFLOW,
"pool_timeout": DATABASE_POOL_TIMEOUT,
"pool_recycle": DATABASE_POOL_RECYCLE,
} }
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) 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" BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
SYS_SETTING_TABLE = "sys_setting" SYS_SETTING_TABLE = "sys_setting"
POSTGRES_MIGRATION_LOCK_KEY = 2026031801 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: 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)}"' 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(): def _acquire_migration_lock():
if engine.dialect.name == "postgresql": if engine.dialect.name == "postgresql":
conn = engine.connect() conn = engine.connect()
conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY}) conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
return conn 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 return None
@ -105,191 +54,17 @@ def _release_migration_lock(lock_conn) -> None:
try: try:
if engine.dialect.name == "postgresql": if engine.dialect.name == "postgresql":
lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY}) 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: finally:
lock_conn.close() lock_conn.close()
def _table_row_count(table_name: str) -> int:
inspector = inspect(engine)
if not inspector.has_table(table_name):
return 0
with engine.connect() as conn:
value = conn.execute(text(f"SELECT COUNT(*) FROM {_quote_ident(table_name)}")).scalar()
return int(value or 0)
def _copy_legacy_table_rows(old_name: str, new_name: str) -> None:
inspector = inspect(engine)
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
return
if _table_row_count(old_name) <= 0:
return
old_columns = {
str(row.get("name"))
for row in inspector.get_columns(old_name)
if row.get("name")
}
new_columns = [
str(row.get("name"))
for row in inspector.get_columns(new_name)
if row.get("name")
]
shared_columns = [col for col in new_columns if col in old_columns]
if not shared_columns:
return
pk = inspector.get_pk_constraint(new_name) or {}
pk_columns = [
str(col)
for col in (pk.get("constrained_columns") or [])
if col and col in shared_columns and col in old_columns
]
if not pk_columns:
return
columns_sql = ", ".join(_quote_ident(col) for col in shared_columns)
join_sql = " AND ".join(
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
for col in pk_columns
)
null_check_col = _quote_ident(pk_columns[0])
with engine.connect() as conn:
conn.execute(
text(
f"INSERT INTO {_quote_ident(new_name)} ({columns_sql}) "
f"SELECT {', '.join(f'o.{_quote_ident(col)}' for col in shared_columns)} "
f"FROM {_quote_ident(old_name)} o "
f"LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} "
f"WHERE n.{null_check_col} IS NULL"
)
)
conn.commit()
def _migrate_legacy_table_rows() -> None:
for old_name, new_name in LEGACY_TABLE_PAIRS:
_copy_legacy_table_rows(old_name, new_name)
def _topic_fk_target(table_name: str, constrained_column: str = "bot_id") -> str | None:
inspector = inspect(engine)
if not inspector.has_table(table_name):
return None
for fk in inspector.get_foreign_keys(table_name):
cols = [str(col) for col in (fk.get("constrained_columns") or []) if col]
if cols == [constrained_column]:
referred = fk.get("referred_table")
return str(referred) if referred else None
return None
def _repair_postgres_topic_foreign_keys() -> None:
if engine.dialect.name != "postgresql":
return
targets = {
"topic_topic": "topic_topic_bot_id_fkey",
"topic_item": "topic_item_bot_id_fkey",
}
with engine.connect() as conn:
changed = False
for table_name, constraint_name in targets.items():
if _topic_fk_target(table_name) == BOT_INSTANCE_TABLE:
continue
conn.execute(
text(
f'ALTER TABLE {_quote_ident(table_name)} '
f'DROP CONSTRAINT IF EXISTS {_quote_ident(constraint_name)}'
)
)
conn.execute(
text(
f'ALTER TABLE {_quote_ident(table_name)} '
f'ADD CONSTRAINT {_quote_ident(constraint_name)} '
f'FOREIGN KEY ({_quote_ident("bot_id")}) '
f'REFERENCES {_quote_ident(BOT_INSTANCE_TABLE)}({_quote_ident("id")}) '
f'ON DELETE CASCADE'
)
)
changed = True
if changed:
conn.commit()
def _legacy_rows_missing_in_new(old_name: str, new_name: str) -> int:
inspector = inspect(engine)
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
return 0
pk = inspector.get_pk_constraint(new_name) or {}
pk_columns = [
str(col)
for col in (pk.get("constrained_columns") or [])
if col
]
if not pk_columns:
return _table_row_count(old_name)
join_sql = " AND ".join(
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
for col in pk_columns
)
null_check_col = _quote_ident(pk_columns[0])
with engine.connect() as conn:
value = conn.execute(
text(
f'SELECT COUNT(*) FROM {_quote_ident(old_name)} o '
f'LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} '
f'WHERE n.{null_check_col} IS NULL'
)
).scalar()
return int(value or 0)
def _drop_legacy_tables() -> None:
droppable = [
old_name
for old_name, new_name in LEGACY_TABLE_PAIRS
if _legacy_rows_missing_in_new(old_name, new_name) <= 0
]
if not droppable:
return
with engine.connect() as conn:
for old_name in droppable:
if engine.dialect.name == "postgresql":
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)} CASCADE'))
else:
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)}'))
conn.commit()
def _ensure_botinstance_columns() -> None: def _ensure_botinstance_columns() -> None:
dialect = engine.dialect.name
required_columns = { required_columns = {
"current_state": { "current_state": "TEXT DEFAULT 'IDLE'",
"sqlite": "TEXT DEFAULT 'IDLE'", "last_action": "TEXT",
"postgresql": "TEXT DEFAULT 'IDLE'", "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"mysql": "VARCHAR(64) DEFAULT 'IDLE'", "access_password": "TEXT DEFAULT ''",
}, "enabled": "BOOLEAN NOT NULL DEFAULT TRUE",
"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",
},
} }
inspector = inspect(engine) inspector = inspect(engine)
@ -301,124 +76,24 @@ def _ensure_botinstance_columns() -> None:
for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE) for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
if row.get("name") if row.get("name")
} }
for col, ddl_map in required_columns.items():
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(): for col, ddl in required_columns.items():
if col in existing: if col in existing:
continue continue
conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}")) conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit()
if "enabled" in existing:
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL"))
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() conn.commit()
def _ensure_sys_setting_columns() -> None: def _ensure_sys_setting_columns() -> None:
dialect = engine.dialect.name
required_columns = { required_columns = {
"name": { "name": "TEXT NOT NULL DEFAULT ''",
"sqlite": "TEXT NOT NULL DEFAULT ''", "category": "TEXT NOT NULL DEFAULT 'general'",
"postgresql": "TEXT NOT NULL DEFAULT ''", "description": "TEXT NOT NULL DEFAULT ''",
"mysql": "VARCHAR(200) NOT NULL DEFAULT ''", "value_type": "TEXT NOT NULL DEFAULT 'json'",
}, "is_public": "BOOLEAN NOT NULL DEFAULT FALSE",
"category": { "sort_order": "INTEGER NOT NULL DEFAULT 100",
"sqlite": "TEXT NOT NULL DEFAULT 'general'",
"postgresql": "TEXT NOT NULL DEFAULT 'general'",
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'",
},
"description": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "LONGTEXT",
},
"value_type": {
"sqlite": "TEXT NOT NULL DEFAULT 'json'",
"postgresql": "TEXT NOT NULL DEFAULT 'json'",
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'",
},
"is_public": {
"sqlite": "INTEGER NOT NULL DEFAULT 0",
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
},
"sort_order": {
"sqlite": "INTEGER NOT NULL DEFAULT 100",
"postgresql": "INTEGER NOT NULL DEFAULT 100",
"mysql": "INTEGER NOT NULL DEFAULT 100",
},
} }
inspector = inspect(engine) inspector = inspect(engine)
if not inspector.has_table(SYS_SETTING_TABLE): 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) for row in inspect(conn).get_columns(SYS_SETTING_TABLE)
if row.get("name") if row.get("name")
} }
for col, ddl_map in required_columns.items(): for col, ddl in required_columns.items():
if col in existing: if col in existing:
continue 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.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit() conn.commit()
def _ensure_bot_request_usage_columns() -> None: def _ensure_bot_request_usage_columns() -> None:
dialect = engine.dialect.name
required_columns = { required_columns = {
"message_id": { "message_id": "INTEGER",
"sqlite": "INTEGER", "provider": "TEXT",
"postgresql": "INTEGER", "model": "TEXT",
"mysql": "INTEGER",
},
"provider": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "VARCHAR(120)",
},
"model": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "VARCHAR(255)",
},
} }
inspector = inspect(engine) inspector = inspect(engine)
if not inspector.has_table(BOT_REQUEST_USAGE_TABLE): 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) for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE)
if row.get("name") if row.get("name")
} }
for col, ddl_map in required_columns.items(): for col, ddl in required_columns.items():
if col in existing: if col in existing:
continue 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.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit() 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: def _ensure_topic_columns() -> None:
dialect = engine.dialect.name
required_columns = { required_columns = {
"topic_topic": { "topic_topic": {
"name": { "name": "TEXT NOT NULL DEFAULT ''",
"sqlite": "TEXT NOT NULL DEFAULT ''", "description": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''", "is_active": "BOOLEAN NOT NULL DEFAULT TRUE",
"mysql": "VARCHAR(255) NOT NULL DEFAULT ''", "is_default_fallback": "BOOLEAN NOT NULL DEFAULT FALSE",
}, "routing_json": "TEXT NOT NULL DEFAULT '{}'",
"description": { "view_schema_json": "TEXT NOT NULL DEFAULT '{}'",
"sqlite": "TEXT NOT NULL DEFAULT ''", "created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"postgresql": "TEXT NOT NULL DEFAULT ''", "updated_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"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",
},
}, },
"topic_item": { "topic_item": {
"title": { "title": "TEXT NOT NULL DEFAULT ''",
"sqlite": "TEXT NOT NULL DEFAULT ''", "level": "TEXT NOT NULL DEFAULT 'info'",
"postgresql": "TEXT NOT NULL DEFAULT ''", "tags_json": "TEXT",
"mysql": "VARCHAR(2000) NOT NULL DEFAULT ''", "view_json": "TEXT",
}, "source": "TEXT NOT NULL DEFAULT 'mcp'",
"level": { "dedupe_key": "TEXT",
"sqlite": "TEXT NOT NULL DEFAULT 'info'", "is_read": "BOOLEAN NOT NULL DEFAULT FALSE",
"postgresql": "TEXT NOT NULL DEFAULT 'info'", "created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"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",
},
}, },
} }
@ -633,114 +167,13 @@ def _ensure_topic_columns() -> None:
for row in inspector.get_columns(table_name) for row in inspector.get_columns(table_name)
if row.get("name") if row.get("name")
} }
for col, ddl_map in cols.items(): for col, ddl in cols.items():
if col in existing: if col in existing:
continue continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}")) conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}"))
conn.commit() 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: def align_postgres_sequences() -> None:
if engine.dialect.name != "postgresql": if engine.dialect.name != "postgresql":
return return
@ -777,22 +210,11 @@ def align_postgres_sequences() -> None:
def init_database() -> None: def init_database() -> None:
lock_conn = _acquire_migration_lock() lock_conn = _acquire_migration_lock()
try: try:
_rename_legacy_tables()
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_migrate_legacy_table_rows()
_drop_legacy_skill_tables()
_ensure_sys_setting_columns() _ensure_sys_setting_columns()
_ensure_bot_request_usage_columns() _ensure_bot_request_usage_columns()
_ensure_botinstance_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_columns()
_ensure_topic_indexes()
_drop_obsolete_topic_tables()
_cleanup_legacy_default_topics()
_drop_legacy_tables()
align_postgres_sequences() align_postgres_sequences()
finally: finally:
_release_migration_lock(lock_conn) _release_migration_lock(lock_conn)

View File

@ -143,9 +143,10 @@ def _mask_database_url(url: str) -> str:
_db_env = str(os.getenv("DATABASE_URL") or "").strip() _db_env = str(os.getenv("DATABASE_URL") or "").strip()
DATABASE_URL: Final[str] = _normalize_database_url( if not _db_env:
_db_env if _db_env else f"sqlite:///{Path(DATA_ROOT) / 'nanobot_dashboard.db'}" 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_ENGINE: Final[str] = _database_engine(DATABASE_URL)
DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL)
DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) 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) 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() 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() TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json" AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json"
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json" TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json"

View File

@ -1,8 +1,14 @@
from app_factory import create_app from app_factory import create_app
from core.settings import APP_HOST, APP_PORT, APP_RELOAD
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn 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)

View File

@ -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)) cache.delete(_cache_key_bots_list(), _cache_key_bot_detail(bot_id))
def _invalidate_bot_messages_cache(bot_id: str) -> None: 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: def _invalidate_images_cache() -> None:
cache.delete(_cache_key_images()) cache.delete(_cache_key_images())

View File

@ -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]: def clear_bot_messages_payload(session: Session, bot_id: str) -> Dict[str, Any]:
bot = _get_bot_or_404(session, bot_id) bot = _get_bot_or_404(session, bot_id)
rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all() rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()

View File

@ -101,3 +101,31 @@ def list_activity_events(
).model_dump() ).model_dump()
) )
return items 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
]

View File

@ -5,7 +5,11 @@ from sqlmodel import Session, select
from core.utils import _calc_dir_size_bytes from core.utils import _calc_dir_size_bytes
from models.bot import BotInstance, NanobotImage from models.bot import BotInstance, NanobotImage
from services.bot_storage_service import _read_bot_resources, _workspace_root 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_settings_service import get_platform_settings
from services.platform_usage_service import list_usage 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) usage = list_usage(session, limit=20)
events = list_activity_events(session, limit=20) events = list_activity_events(session, limit=20)
activity_stats = get_bot_activity_stats(session)
return { return {
"summary": { "summary": {
@ -101,4 +106,5 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
"settings": settings.model_dump(), "settings": settings.model_dump(),
"usage": usage, "usage": usage,
"events": events, "events": events,
"activity_stats": activity_stats,
} }

View File

@ -2,6 +2,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -21,6 +22,7 @@ logger = logging.getLogger("dashboard.backend")
_main_loop: Optional[asyncio.AbstractEventLoop] = None _main_loop: Optional[asyncio.AbstractEventLoop] = None
_AGENT_LOOP_READY_MARKER = "Agent loop started" _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: 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 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]: def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
packet_type = str(packet.get("type", "")).upper() packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: 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": if packet_type == "AGENT_STATE":
payload = packet.get("payload") or {} payload = packet.get("payload") or {}
state = str(payload.get("state") or "").strip() 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: if state:
bot.current_state = state bot.current_state = state
if action: if action:
bot.last_action = action[:4000] bot.last_action = action
elif packet_type == "ASSISTANT_MESSAGE": elif packet_type == "ASSISTANT_MESSAGE":
bot.current_state = "IDLE" bot.current_state = "IDLE"
text_msg = str(packet.get("text") or "").strip() text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id) media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list: if text_msg or media_list:
if text_msg: if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000] bot.last_action = _normalize_last_action_text(text_msg)
message_row = BotMessage( message_row = BotMessage(
bot_id=bot_id, bot_id=bot_id,
role="assistant", 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: if text_msg or media_list:
bot.current_state = "IDLE" bot.current_state = "IDLE"
if text_msg: if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000] bot.last_action = _normalize_last_action_text(text_msg)
message_row = BotMessage( message_row = BotMessage(
bot_id=bot_id, bot_id=bot_id,
role="assistant", role="assistant",

View File

@ -1,36 +1,42 @@
# Dashboard Nanobot 数据库设计文档(当前实现) # Dashboard Nanobot 数据库设计文档
数据库默认使用 SQLite`data/nanobot_dashboard.db` 数据库默认使用 PostgreSQL推荐使用 psycopg3 驱动)
## 1. ERD ## 1. ERD
```mermaid ```mermaid
erDiagram erDiagram
BOTINSTANCE ||--o{ BOTMESSAGE : "messages" bot_instance ||--o{ bot_message : "messages"
NANOBOTIMAGE ||--o{ BOTINSTANCE : "referenced by" 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 id PK
string name string name
boolean enabled
string access_password
string workspace_dir UK string workspace_dir UK
string docker_status string docker_status
string image_tag
string current_state string current_state
text last_action string last_action
string image_tag
datetime created_at datetime created_at
datetime updated_at datetime updated_at
} }
BOTMESSAGE { bot_message {
int id PK int id PK
string bot_id FK string bot_id FK
string role string role
text text text text
text media_json text media_json
string feedback
datetime feedback_at
datetime created_at datetime created_at
} }
NANOBOTIMAGE { bot_image {
string tag PK string tag PK
string image_id string image_id
string version string version
@ -38,48 +44,81 @@ erDiagram
string source_dir string source_dir
datetime created_at 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. 设计原则 ## 2. 设计原则
- 数据库只保留运行索引和历史消息。 - 数据库保留运行索引、历史消息、用量统计与运维事件
- Bot 参数模型、渠道、资源配额、5 个 MD 文件)统一持久化在: - Bot 核心配置(渠道、资源配额、5 个 MD 文件)统一持久化在文件系统
- `.nanobot/config.json` - `.nanobot/config.json`
- `.nanobot/workspace/*.md` - `.nanobot/workspace/*.md`
- `.nanobot/env.json` - `.nanobot/env.json`
- `channelroute` 已废弃,不再使用数据库存储渠道。
## 3. 表说明 ## 3. 表说明
### 3.1 `botinstance` ### 3.1 `bot_instance`
存储 Bot 基础索引与运行态。
仅存基础索引与运行态: ### 3.2 `bot_message`
Dashboard 渠道对话历史(用于会话回放与反馈)。
- 标识与展示:`id`、`name` ### 3.3 `bot_image`
- 容器与镜像:`docker_status`、`image_tag` 基础镜像登记表。
- 运行状态:`current_state`、`last_action`
- 路径与时间:`workspace_dir`、`created_at`、`updated_at`
### 3.2 `botmessage` ### 3.4 `bot_request_usage`
模型调用用量详细记录。
Dashboard 渠道对话历史(用于会话回放): ### 3.5 `bot_activity_event`
运维事件记录(如容器启动/停止、指令提交、系统告警等)。
- `role`: `user | assistant` ### 3.6 `sys_setting`
- `text`: 文本内容 平台全局参数设置。
- `media_json`: 附件相对路径 JSON
### 3.3 `nanobotimage` ## 4. 初始化与迁移策略
基础镜像登记表(手动注册): 服务启动时(`backend/core/database.py`
- `tag`: 如 `nanobot-base:v0.1.4` 1. 使用 PostgreSQL Advisory Lock 确保多节点部署时的单实例初始化。
- `status`: `READY | UNKNOWN | ERROR` 2. `SQLModel.metadata.create_all(engine)` 自动创建缺失表。
- `source_dir`: 来源标识(通常 `manual` 3. 执行列对齐检查,确保旧表结构平滑升级。
4. 自动对齐 PostgreSQL Sequences 以防 ID 冲突。
## 4. 迁移策略
服务启动时:
1. `SQLModel.metadata.create_all(engine)`
2. 清理废弃表:`DROP TABLE IF EXISTS channelroute`
3. 对 `botinstance` 做列对齐,删除历史遗留配置列(保留当前最小字段集)

View File

@ -45,6 +45,11 @@ export const dashboardEn = {
copyReply: 'Copy reply', copyReply: 'Copy reply',
copyReplyDone: 'Reply copied.', copyReplyDone: 'Reply copied.',
copyReplyFail: 'Failed to copy reply.', 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', quoteReply: 'Quote reply',
quotedReplyLabel: 'Quoted reply', quotedReplyLabel: 'Quoted reply',
clearQuote: 'Clear quote', clearQuote: 'Clear quote',

View File

@ -45,6 +45,11 @@ export const dashboardZhCn = {
copyReply: '复制回复', copyReply: '复制回复',
copyReplyDone: '回复已复制。', copyReplyDone: '回复已复制。',
copyReplyFail: '复制回复失败。', copyReplyFail: '复制回复失败。',
deleteMessage: '删除消息',
deleteMessageConfirm: (role: string) => `确认删除这条${role}消息?`,
deleteMessageDone: '消息已删除。',
deleteMessageFail: '删除消息失败。',
deleteMessagePending: '消息尚未同步,暂不可删除。',
quoteReply: '引用回复', quoteReply: '引用回复',
quotedReplyLabel: '已引用回复', quotedReplyLabel: '已引用回复',
clearQuote: '取消引用', clearQuote: '取消引用',

View File

@ -122,6 +122,7 @@ export function BotDashboardModule({
controlCommandsShow: dashboard.t.controlCommandsShow, controlCommandsShow: dashboard.t.controlCommandsShow,
copyPrompt: dashboard.t.copyPrompt, copyPrompt: dashboard.t.copyPrompt,
copyReply: dashboard.t.copyReply, copyReply: dashboard.t.copyReply,
deleteMessage: dashboard.t.deleteMessage,
disabledPlaceholder: dashboard.t.disabledPlaceholder, disabledPlaceholder: dashboard.t.disabledPlaceholder,
download: dashboard.t.download, download: dashboard.t.download,
editPrompt: dashboard.t.editPrompt, editPrompt: dashboard.t.editPrompt,
@ -147,6 +148,7 @@ export function BotDashboardModule({
onChatScroll: dashboard.onChatScroll, onChatScroll: dashboard.onChatScroll,
expandedProgressByKey: dashboard.expandedProgressByKey, expandedProgressByKey: dashboard.expandedProgressByKey,
expandedUserByKey: dashboard.expandedUserByKey, expandedUserByKey: dashboard.expandedUserByKey,
deletingMessageIdMap: dashboard.deletingMessageIdMap,
feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId, feedbackSavingByMessageId: dashboard.feedbackSavingByMessageId,
markdownComponents: dashboard.markdownComponents, markdownComponents: dashboard.markdownComponents,
workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet, workspaceDownloadExtensionSet: dashboard.workspaceDownloadExtensionSet,
@ -154,6 +156,7 @@ export function BotDashboardModule({
onToggleUserExpand: dashboard.toggleUserExpanded, onToggleUserExpand: dashboard.toggleUserExpanded,
onEditUserPrompt: dashboard.editUserPrompt, onEditUserPrompt: dashboard.editUserPrompt,
onCopyUserPrompt: dashboard.copyUserPrompt, onCopyUserPrompt: dashboard.copyUserPrompt,
onDeleteConversationMessage: dashboard.deleteConversationMessage,
onOpenWorkspacePath: dashboard.openWorkspacePathFromChat, onOpenWorkspacePath: dashboard.openWorkspacePathFromChat,
onSubmitAssistantFeedback: dashboard.submitAssistantFeedback, onSubmitAssistantFeedback: dashboard.submitAssistantFeedback,
onQuoteAssistantReply: dashboard.quoteAssistantReply, onQuoteAssistantReply: dashboard.quoteAssistantReply,
@ -161,6 +164,7 @@ export function BotDashboardModule({
isThinking: dashboard.isThinking, isThinking: dashboard.isThinking,
canChat: dashboard.canChat, canChat: dashboard.canChat,
isChatEnabled: dashboard.isChatEnabled, isChatEnabled: dashboard.isChatEnabled,
speechEnabled: dashboard.speechEnabled,
selectedBotEnabled: dashboard.selectedBotEnabled, selectedBotEnabled: dashboard.selectedBotEnabled,
selectedBotControlState: dashboard.selectedBotControlState, selectedBotControlState: dashboard.selectedBotControlState,
quotedReply: dashboard.quotedReply, quotedReply: dashboard.quotedReply,

View File

@ -124,8 +124,8 @@
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; 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%)); 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; padding: 8px 9px 8px 12px;
margin-bottom: 10px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
@ -179,31 +179,33 @@
} }
.ops-bot-name { .ops-bot-name {
font-size: 16px; font-size: 15px;
font-weight: 800; font-weight: 800;
color: var(--title); color: var(--title);
line-height: 1.15;
} }
.ops-bot-id, .ops-bot-id,
.ops-bot-meta { .ops-bot-meta {
margin-top: 2px; margin-top: 1px;
color: var(--subtitle); color: var(--subtitle);
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 1.25;
} }
.ops-bot-actions { .ops-bot-actions {
margin-top: 10px; margin-top: 7px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 6px; gap: 5px;
} }
.ops-bot-actions-main { .ops-bot-actions-main {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
} }
.ops-bot-enable-switch { .ops-bot-enable-switch {
@ -285,10 +287,10 @@
} }
.ops-bot-icon-btn { .ops-bot-icon-btn {
width: 36px; width: 32px;
height: 36px; height: 32px;
padding: 0; padding: 0;
border-radius: 10px; border-radius: 9px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -296,21 +298,21 @@
} }
.ops-bot-icon-btn svg { .ops-bot-icon-btn svg {
width: 17px; width: 15px;
height: 17px; height: 15px;
stroke-width: 2.1; stroke-width: 2.1;
} }
.ops-bot-top-actions { .ops-bot-top-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
} }
.ops-bot-name-row { .ops-bot-name-row {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
} }
.ops-bot-lock { .ops-bot-lock {

View File

@ -22,6 +22,7 @@ interface DashboardChatPanelLabels {
controlCommandsShow: string; controlCommandsShow: string;
copyPrompt: string; copyPrompt: string;
copyReply: string; copyReply: string;
deleteMessage: string;
disabledPlaceholder: string; disabledPlaceholder: string;
download: string; download: string;
editPrompt: string; editPrompt: string;
@ -52,6 +53,7 @@ interface DashboardChatPanelProps {
onChatScroll: () => void; onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>; expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>; expandedUserByKey: Record<string, boolean>;
deletingMessageIdMap: Record<number, boolean>;
feedbackSavingByMessageId: Record<number, boolean>; feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components; markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>; workspaceDownloadExtensionSet: ReadonlySet<string>;
@ -59,6 +61,7 @@ interface DashboardChatPanelProps {
onToggleUserExpand: (key: string) => void; onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void; onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void; onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void; onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void; onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void; onQuoteAssistantReply: (message: ChatMessage) => void;
@ -66,6 +69,7 @@ interface DashboardChatPanelProps {
isThinking: boolean; isThinking: boolean;
canChat: boolean; canChat: boolean;
isChatEnabled: boolean; isChatEnabled: boolean;
speechEnabled: boolean;
selectedBotEnabled: boolean; selectedBotEnabled: boolean;
selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling'; selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling';
quotedReply: { text: string } | null; quotedReply: { text: string } | null;
@ -103,8 +107,8 @@ interface DashboardChatPanelProps {
isVoiceTranscribing: boolean; isVoiceTranscribing: boolean;
isCompactMobile: boolean; isCompactMobile: boolean;
voiceCountdown: number; voiceCountdown: number;
onVoiceInput: () => void; onVoiceInput: () => Promise<void> | void;
onTriggerPickAttachments: () => void; onTriggerPickAttachments: () => Promise<void> | void;
showInterruptSubmitAction: boolean; showInterruptSubmitAction: boolean;
onSubmitAction: () => Promise<void> | void; onSubmitAction: () => Promise<void> | void;
} }
@ -117,6 +121,7 @@ export function DashboardChatPanel({
onChatScroll, onChatScroll,
expandedProgressByKey, expandedProgressByKey,
expandedUserByKey, expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId, feedbackSavingByMessageId,
markdownComponents, markdownComponents,
workspaceDownloadExtensionSet, workspaceDownloadExtensionSet,
@ -124,6 +129,7 @@ export function DashboardChatPanel({
onToggleUserExpand, onToggleUserExpand,
onEditUserPrompt, onEditUserPrompt,
onCopyUserPrompt, onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath, onOpenWorkspacePath,
onSubmitAssistantFeedback, onSubmitAssistantFeedback,
onQuoteAssistantReply, onQuoteAssistantReply,
@ -131,6 +137,7 @@ export function DashboardChatPanel({
isThinking, isThinking,
canChat, canChat,
isChatEnabled, isChatEnabled,
speechEnabled,
selectedBotEnabled, selectedBotEnabled,
selectedBotControlState, selectedBotControlState,
quotedReply, quotedReply,
@ -188,6 +195,7 @@ export function DashboardChatPanel({
badReply: labels.badReply, badReply: labels.badReply,
copyPrompt: labels.copyPrompt, copyPrompt: labels.copyPrompt,
copyReply: labels.copyReply, copyReply: labels.copyReply,
deleteMessage: labels.deleteMessage,
download: labels.download, download: labels.download,
editPrompt: labels.editPrompt, editPrompt: labels.editPrompt,
fileNotPreviewable: labels.fileNotPreviewable, fileNotPreviewable: labels.fileNotPreviewable,
@ -200,6 +208,7 @@ export function DashboardChatPanel({
}} }}
expandedProgressByKey={expandedProgressByKey} expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey} expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap}
feedbackSavingByMessageId={feedbackSavingByMessageId} feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents} markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
@ -207,6 +216,7 @@ export function DashboardChatPanel({
onToggleUserExpand={onToggleUserExpand} onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt} onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt} onCopyUserPrompt={onCopyUserPrompt}
onDeleteConversationMessage={onDeleteConversationMessage}
onOpenWorkspacePath={onOpenWorkspacePath} onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback} onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply} onQuoteAssistantReply={onQuoteAssistantReply}
@ -453,8 +463,8 @@ export function DashboardChatPanel({
) : null} ) : null}
<button <button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`} className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || isVoiceTranscribing} disabled={!canChat || !speechEnabled || isVoiceTranscribing}
onClick={onVoiceInput} onClick={() => void onVoiceInput()}
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart} aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart} title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
> >
@ -469,7 +479,7 @@ export function DashboardChatPanel({
<LucentIconButton <LucentIconButton
className="ops-composer-inline-btn" className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing} disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={onTriggerPickAttachments} onClick={() => void onTriggerPickAttachments()}
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile} tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile} aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
> >

View File

@ -72,12 +72,14 @@
pointer-events: none; pointer-events: none;
transform: translateX(6px) scale(0.95); transform: translateX(6px) scale(0.95);
transition: opacity 0.18s ease, transform 0.18s ease, width 0.18s ease, margin-right 0.18s ease; 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:hover .ops-chat-hover-actions-user,
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user { .ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
width: 54px; width: 84px;
margin-right: 6px; margin-right: 8px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
transform: translateX(0) scale(1); transform: translateX(0) scale(1);

View File

@ -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 ReactMarkdown, { type Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
@ -17,6 +17,7 @@ interface DashboardConversationLabels {
badReply: string; badReply: string;
copyPrompt: string; copyPrompt: string;
copyReply: string; copyReply: string;
deleteMessage: string;
download: string; download: string;
editPrompt: string; editPrompt: string;
fileNotPreviewable: string; fileNotPreviewable: string;
@ -34,6 +35,7 @@ interface DashboardConversationMessagesProps {
labels: DashboardConversationLabels; labels: DashboardConversationLabels;
expandedProgressByKey: Record<string, boolean>; expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>; expandedUserByKey: Record<string, boolean>;
deletingMessageIdMap: Record<number, boolean>;
feedbackSavingByMessageId: Record<number, boolean>; feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components; markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>; workspaceDownloadExtensionSet: ReadonlySet<string>;
@ -41,6 +43,7 @@ interface DashboardConversationMessagesProps {
onToggleUserExpand: (key: string) => void; onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void; onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void; onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void; onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void; onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void; onQuoteAssistantReply: (message: ChatMessage) => void;
@ -54,12 +57,21 @@ function shouldCollapseProgress(text: string) {
return lines > 6 || normalized.length > 520; 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({ export function DashboardConversationMessages({
conversation, conversation,
isZh, isZh,
labels, labels,
expandedProgressByKey, expandedProgressByKey,
expandedUserByKey, expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId, feedbackSavingByMessageId,
markdownComponents, markdownComponents,
workspaceDownloadExtensionSet, workspaceDownloadExtensionSet,
@ -67,6 +79,7 @@ export function DashboardConversationMessages({
onToggleUserExpand, onToggleUserExpand,
onEditUserPrompt, onEditUserPrompt,
onCopyUserPrompt, onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath, onOpenWorkspacePath,
onSubmitAssistantFeedback, onSubmitAssistantFeedback,
onQuoteAssistantReply, onQuoteAssistantReply,
@ -75,7 +88,7 @@ export function DashboardConversationMessages({
return ( return (
<> <>
{conversation.map((item, idx) => { {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 isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user'; const isUserBubble = item.role === 'user';
const fullText = String(item.text || ''); const fullText = String(item.text || '');
@ -91,6 +104,7 @@ export function DashboardConversationMessages({
const currentDayKey = new Date(item.ts).toDateString(); const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
const isDeleting = Boolean(item.id && deletingMessageIdMap[item.id]);
return ( return (
<div <div
@ -127,6 +141,15 @@ export function DashboardConversationMessages({
> >
<Copy size={13} /> <Copy size={13} />
</LucentIconButton> </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> </div>
) : null} ) : null}
@ -241,6 +264,15 @@ export function DashboardConversationMessages({
> >
<Copy size={13} /> <Copy size={13} />
</LucentIconButton> </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> </div>
) : null} ) : null}
</div> </div>

View File

@ -64,6 +64,8 @@ export function useBotDashboardModule({
workspaceDownloadExtensions, workspaceDownloadExtensions,
} = useDashboardSystemDefaults({ } = useDashboardSystemDefaults({
setBotListPageSize, setBotListPageSize,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
}); });
const { const {
botListMenuOpen, botListMenuOpen,
@ -425,6 +427,8 @@ export function useBotDashboardModule({
composerTextareaRef, composerTextareaRef,
copyAssistantReply, copyAssistantReply,
copyUserPrompt, copyUserPrompt,
deleteConversationMessage,
deletingMessageIdMap,
editUserPrompt, editUserPrompt,
expandedProgressByKey, expandedProgressByKey,
expandedUserByKey, expandedUserByKey,
@ -469,6 +473,7 @@ export function useBotDashboardModule({
setBotMessages, setBotMessages,
setBotMessageFeedback, setBotMessageFeedback,
notify, notify,
confirm,
t, t,
isZh, isZh,
}); });
@ -537,8 +542,6 @@ export function useBotDashboardModule({
selectedBotId, selectedBotId,
setBotListMenuOpen, setBotListMenuOpen,
setChatDatePickerOpen, setChatDatePickerOpen,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
setPendingAttachments, setPendingAttachments,
setShowRuntimeActionModal, setShowRuntimeActionModal,
setRuntimeMenuOpen, setRuntimeMenuOpen,
@ -652,6 +655,7 @@ export function useBotDashboardModule({
onChatScroll, onChatScroll,
expandedProgressByKey, expandedProgressByKey,
expandedUserByKey, expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId, feedbackSavingByMessageId,
markdownComponents, markdownComponents,
workspaceDownloadExtensionSet, workspaceDownloadExtensionSet,
@ -659,12 +663,14 @@ export function useBotDashboardModule({
toggleUserExpanded, toggleUserExpanded,
editUserPrompt, editUserPrompt,
copyUserPrompt, copyUserPrompt,
deleteConversationMessage,
submitAssistantFeedback, submitAssistantFeedback,
quoteAssistantReply, quoteAssistantReply,
copyAssistantReply, copyAssistantReply,
isThinking, isThinking,
canChat, canChat,
isChatEnabled, isChatEnabled,
speechEnabled,
selectedBotEnabled, selectedBotEnabled,
selectedBotControlState, selectedBotControlState,
quotedReply, quotedReply,

View File

@ -3,7 +3,7 @@ import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env'; import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText } from '../messageParser'; import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import type { BotMessagesByDateResponse } from '../types'; import type { BotMessagesByDateResponse } from '../types';
import { import {
formatConversationDate, formatConversationDate,
@ -19,6 +19,14 @@ interface NotifyOptions {
durationMs?: number; durationMs?: number;
} }
interface ConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}
interface UseDashboardChatHistoryOptions { interface UseDashboardChatHistoryOptions {
selectedBotId: string; selectedBotId: string;
messages: ChatMessage[]; messages: ChatMessage[];
@ -29,6 +37,7 @@ interface UseDashboardChatHistoryOptions {
setBotMessages: (botId: string, messages: ChatMessage[]) => void; setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void; setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: NotifyOptions) => void; notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
t: any; t: any;
isZh: boolean; isZh: boolean;
} }
@ -43,6 +52,7 @@ export function useDashboardChatHistory({
setBotMessages, setBotMessages,
setBotMessageFeedback, setBotMessageFeedback,
notify, notify,
confirm,
t, t,
isZh, isZh,
}: UseDashboardChatHistoryOptions) { }: UseDashboardChatHistoryOptions) {
@ -56,6 +66,7 @@ export function useDashboardChatHistory({
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({}); const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({}); const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({}); const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
const chatScrollRef = useRef<HTMLDivElement | null>(null); const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null); const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
@ -81,6 +92,7 @@ export function useDashboardChatHistory({
setExpandedProgressByKey({}); setExpandedProgressByKey({});
setExpandedUserByKey({}); setExpandedUserByKey({});
setFeedbackSavingByMessageId({}); setFeedbackSavingByMessageId({});
setDeletingMessageIdMap({});
setChatDatePickerOpen(false); setChatDatePickerOpen(false);
setChatDatePanelPosition(null); setChatDatePanelPosition(null);
setChatJumpAnchorId(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) => { const toggleProgressExpanded = (key: string) => {
setExpandedProgressByKey((prev) => ({ setExpandedProgressByKey((prev) => ({
...prev, ...prev,
@ -430,6 +589,8 @@ export function useDashboardChatHistory({
expandedProgressByKey, expandedProgressByKey,
expandedUserByKey, expandedUserByKey,
feedbackSavingByMessageId, feedbackSavingByMessageId,
deletingMessageIdMap,
deleteConversationMessage,
jumpConversationToDate, jumpConversationToDate,
loadInitialChatPage, loadInitialChatPage,
onChatScroll, onChatScroll,

View File

@ -12,6 +12,14 @@ interface NotifyOptions {
durationMs?: number; durationMs?: number;
} }
interface ConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}
interface UseDashboardConversationOptions { interface UseDashboardConversationOptions {
selectedBotId: string; selectedBotId: string;
selectedBot?: { id: string; messages?: ChatMessage[] } | null; selectedBot?: { id: string; messages?: ChatMessage[] } | null;
@ -29,6 +37,7 @@ interface UseDashboardConversationOptions {
setBotMessages: (botId: string, messages: ChatMessage[]) => void; setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void; setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: NotifyOptions) => void; notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
t: any; t: any;
isZh: boolean; isZh: boolean;
} }
@ -44,6 +53,7 @@ export function useDashboardConversation(options: UseDashboardConversationOption
setBotMessages: options.setBotMessages, setBotMessages: options.setBotMessages,
setBotMessageFeedback: options.setBotMessageFeedback, setBotMessageFeedback: options.setBotMessageFeedback,
notify: options.notify, notify: options.notify,
confirm: options.confirm,
t: options.t, t: options.t,
isZh: options.isZh, isZh: options.isZh,
}); });

View File

@ -1,7 +1,5 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { fetchDashboardSystemDefaults } from '../api/system';
type PromptTone = 'info' | 'success' | 'warning' | 'error'; type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions { interface NotifyOptions {
@ -34,8 +32,6 @@ interface UseDashboardLifecycleOptions {
selectedBotId: string; selectedBotId: string;
setBotListMenuOpen: (value: boolean) => void; setBotListMenuOpen: (value: boolean) => void;
setChatDatePickerOpen: (value: boolean) => void; setChatDatePickerOpen: (value: boolean) => void;
setChatPullPageSize: Dispatch<SetStateAction<number>>;
setCommandAutoUnlockSeconds: Dispatch<SetStateAction<number>>;
setPendingAttachments: Dispatch<SetStateAction<string[]>>; setPendingAttachments: Dispatch<SetStateAction<string[]>>;
setShowRuntimeActionModal: (value: boolean) => void; setShowRuntimeActionModal: (value: boolean) => void;
setRuntimeMenuOpen: (value: boolean) => void; setRuntimeMenuOpen: (value: boolean) => void;
@ -70,8 +66,6 @@ export function useDashboardLifecycle({
selectedBotId, selectedBotId,
setBotListMenuOpen, setBotListMenuOpen,
setChatDatePickerOpen, setChatDatePickerOpen,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
setPendingAttachments, setPendingAttachments,
setShowRuntimeActionModal, setShowRuntimeActionModal,
setRuntimeMenuOpen, setRuntimeMenuOpen,
@ -118,30 +112,6 @@ export function useDashboardLifecycle({
hideWorkspaceHoverCard(); hideWorkspaceHoverCard();
}, [hideWorkspaceHoverCard, selectedBotId, setShowRuntimeActionModal]); }, [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(() => { useEffect(() => {
if (!selectedBotId) { if (!selectedBotId) {
resetWorkspaceState(); resetWorkspaceState();

View File

@ -7,6 +7,8 @@ import { parseAllowedAttachmentExtensions, parseTopicPresets, parseWorkspaceDown
interface UseDashboardSystemDefaultsOptions { interface UseDashboardSystemDefaultsOptions {
setBotListPageSize: Dispatch<SetStateAction<number>>; setBotListPageSize: Dispatch<SetStateAction<number>>;
setChatPullPageSize?: Dispatch<SetStateAction<number>>;
setCommandAutoUnlockSeconds?: Dispatch<SetStateAction<number>>;
setVoiceCountdown?: Dispatch<SetStateAction<number>>; setVoiceCountdown?: Dispatch<SetStateAction<number>>;
} }
@ -22,8 +24,22 @@ function resolveVoiceMaxSeconds(raw: unknown) {
return Math.max(5, Math.floor(configured)); 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({ export function useDashboardSystemDefaults({
setBotListPageSize, setBotListPageSize,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
setVoiceCountdown, setVoiceCountdown,
}: UseDashboardSystemDefaultsOptions) { }: UseDashboardSystemDefaultsOptions) {
const [botListPageSizeReady, setBotListPageSizeReady] = useState(false); const [botListPageSizeReady, setBotListPageSizeReady] = useState(false);
@ -45,7 +61,8 @@ export function useDashboardSystemDefaults({
setBotListPageSize((prev) => setBotListPageSize((prev) =>
normalizePlatformPageSize(data?.chat?.page_size, normalizePlatformPageSize(prev, 10)), normalizePlatformPageSize(data?.chat?.page_size, normalizePlatformPageSize(prev, 10)),
); );
setChatPullPageSize?.(resolveChatPullPageSize(data?.chat?.pull_page_size));
setCommandAutoUnlockSeconds?.(resolveCommandAutoUnlockSeconds(data?.chat?.command_auto_unlock_seconds));
setAllowedAttachmentExtensions( setAllowedAttachmentExtensions(
parseAllowedAttachmentExtensions(data?.workspace?.allowed_attachment_extensions), parseAllowedAttachmentExtensions(data?.workspace?.allowed_attachment_extensions),
); );
@ -64,7 +81,7 @@ export function useDashboardSystemDefaults({
setVoiceMaxSeconds(nextVoiceMaxSeconds); setVoiceMaxSeconds(nextVoiceMaxSeconds);
setVoiceCountdown?.(nextVoiceMaxSeconds); setVoiceCountdown?.(nextVoiceMaxSeconds);
} }
}, [setBotListPageSize, setVoiceCountdown]); }, [setBotListPageSize, setChatPullPageSize, setCommandAutoUnlockSeconds, setVoiceCountdown]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@ -94,19 +111,25 @@ export function useDashboardSystemDefaults({
const nextAllowedAttachmentExtensions = parseAllowedAttachmentExtensions( const nextAllowedAttachmentExtensions = parseAllowedAttachmentExtensions(
data?.workspace?.allowed_attachment_extensions, data?.workspace?.allowed_attachment_extensions,
); );
const nextWorkspaceDownloadExtensions = parseWorkspaceDownloadExtensions(
data?.workspace?.download_extensions,
);
setUploadMaxMb(nextUploadMaxMb); setUploadMaxMb(nextUploadMaxMb);
setAllowedAttachmentExtensions(nextAllowedAttachmentExtensions); setAllowedAttachmentExtensions(nextAllowedAttachmentExtensions);
setWorkspaceDownloadExtensions(nextWorkspaceDownloadExtensions);
return { return {
uploadMaxMb: nextUploadMaxMb, uploadMaxMb: nextUploadMaxMb,
allowedAttachmentExtensions: nextAllowedAttachmentExtensions, allowedAttachmentExtensions: nextAllowedAttachmentExtensions,
workspaceDownloadExtensions: nextWorkspaceDownloadExtensions,
}; };
} catch { } catch {
return { return {
uploadMaxMb, uploadMaxMb,
allowedAttachmentExtensions, allowedAttachmentExtensions,
workspaceDownloadExtensions,
}; };
} }
}, [allowedAttachmentExtensions, uploadMaxMb]); }, [allowedAttachmentExtensions, uploadMaxMb, workspaceDownloadExtensions]);
return { return {
allowedAttachmentExtensions, allowedAttachmentExtensions,

View File

@ -43,6 +43,7 @@ interface NotifyOptions {
interface AttachmentPolicySnapshot { interface AttachmentPolicySnapshot {
uploadMaxMb: number; uploadMaxMb: number;
allowedAttachmentExtensions: string[]; allowedAttachmentExtensions: string[];
workspaceDownloadExtensions?: string[];
} }
interface UseDashboardWorkspaceOptions { interface UseDashboardWorkspaceOptions {
@ -85,10 +86,26 @@ export function useDashboardWorkspace({
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null); const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | 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( const workspaceDownloadExtensionSet = useMemo(
() => new Set(parseWorkspaceDownloadExtensions(workspaceDownloadExtensions)), () => new Set(workspaceDownloadExtensionList),
[workspaceDownloadExtensions], [workspaceDownloadExtensionList],
); );
const workspaceFiles = useMemo( const workspaceFiles = useMemo(
() => workspaceEntries.filter((entry) => entry.type === 'file' && isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet)), () => workspaceEntries.filter((entry) => entry.type === 'file' && isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet)),

View File

@ -64,6 +64,33 @@ export function normalizeAssistantMessageText(input: string) {
return text; 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) { export function summarizeProgressText(input: string, isZh: boolean) {
const raw = normalizeAssistantMessageText(input); const raw = normalizeAssistantMessageText(input);
if (!raw) return isZh ? '处理中...' : 'Processing...'; if (!raw) return isZh ? '处理中...' : 'Processing...';

View File

@ -1,6 +1,7 @@
import '../../components/skill-market/SkillMarketShared.css'; import '../../components/skill-market/SkillMarketShared.css';
import { PlatformSummaryCards } from './components/PlatformSummaryCards'; import { PlatformSummaryCards } from './components/PlatformSummaryCards';
import { PlatformUsageAnalyticsSection } from './components/PlatformUsageAnalyticsSection'; import { PlatformUsageAnalyticsSection } from './components/PlatformUsageAnalyticsSection';
import { PlatformBotActivityAnalyticsSection } from './components/PlatformBotActivityAnalyticsSection';
import { usePlatformDashboard } from './hooks/usePlatformDashboard'; import { usePlatformDashboard } from './hooks/usePlatformDashboard';
import './PlatformDashboardPage.css'; import './PlatformDashboardPage.css';
@ -22,15 +23,23 @@ export function PlatformAdminDashboardPage({ compactMode }: PlatformAdminDashboa
overviewResources={dashboard.overviewResources} overviewResources={dashboard.overviewResources}
/> />
<PlatformUsageAnalyticsSection <div className="platform-analytics-grid">
isZh={dashboard.isZh} <PlatformUsageAnalyticsSection
usageAnalytics={dashboard.usageAnalytics} isZh={dashboard.isZh}
usageAnalyticsMax={dashboard.usageAnalyticsMax} usageAnalytics={dashboard.usageAnalytics}
usageAnalyticsSeries={dashboard.usageAnalyticsSeries} usageAnalyticsMax={dashboard.usageAnalyticsMax}
usageAnalyticsTicks={dashboard.usageAnalyticsTicks} usageAnalyticsSeries={dashboard.usageAnalyticsSeries}
usageLoading={dashboard.usageLoading} usageAnalyticsTicks={dashboard.usageAnalyticsTicks}
usageSummary={dashboard.usageSummary} usageLoading={dashboard.usageLoading}
/> usageSummary={dashboard.usageSummary}
/>
<PlatformBotActivityAnalyticsSection
isZh={dashboard.isZh}
activityStats={dashboard.activityStats}
loading={dashboard.loading}
/>
</div>
</div> </div>
</section> </section>
); );

View File

@ -17,9 +17,12 @@ interface PlatformBotManagementPageProps {
compactMode: boolean; compactMode: boolean;
} }
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: string[] = [];
export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) { export function PlatformBotManagementPage({ compactMode }: PlatformBotManagementPageProps) {
const dashboard = usePlatformDashboard({ compactMode }); const dashboard = usePlatformDashboard({ compactMode });
const [showCreateBotModal, setShowCreateBotModal] = useState(false); const [showCreateBotModal, setShowCreateBotModal] = useState(false);
const workspaceDownloadExtensions = dashboard.overview?.settings?.workspace_download_extensions || EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS;
return ( return (
<> <>
@ -65,7 +68,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
isZh={dashboard.isZh} isZh={dashboard.isZh}
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10} pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
selectedBotInfo={dashboard.selectedBotInfo} selectedBotInfo={dashboard.selectedBotInfo}
workspaceDownloadExtensions={dashboard.overview?.settings?.workspace_download_extensions || []} workspaceDownloadExtensions={workspaceDownloadExtensions}
/> />
</div> </div>
</section> </section>
@ -99,7 +102,7 @@ export function PlatformBotManagementPage({ compactMode }: PlatformBotManagement
isZh={dashboard.isZh} isZh={dashboard.isZh}
pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10} pageSize={dashboard.overview?.settings?.page_size || dashboard.botListPageSize || 10}
selectedBotInfo={dashboard.selectedBotInfo} selectedBotInfo={dashboard.selectedBotInfo}
workspaceDownloadExtensions={dashboard.overview?.settings?.workspace_download_extensions || []} workspaceDownloadExtensions={workspaceDownloadExtensions}
/> />
</> </>
</PlatformCompactBotSheet> </PlatformCompactBotSheet>

View File

@ -675,14 +675,23 @@
.platform-selected-bot-last-body { .platform-selected-bot-last-body {
margin-top: 10px; margin-top: 10px;
padding: 14px; max-height: min(58vh, 680px);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 14px; border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent); border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
background: color-mix(in oklab, var(--panel-soft) 76%, transparent); background: color-mix(in oklab, var(--panel-soft) 76%, transparent);
color: var(--muted); min-height: 0;
line-height: 1.6; }
white-space: pre-wrap;
word-break: break-word; .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 { .platform-last-action-btn {
@ -1149,6 +1158,9 @@
.platform-last-action-modal { .platform-last-action-modal {
width: min(760px, 92vw); width: min(760px, 92vw);
max-height: min(82vh, 920px);
display: flex;
flex-direction: column;
} }
.platform-resource-summary-grid { .platform-resource-summary-grid {
@ -1283,6 +1295,23 @@
padding-right: 0; 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-settings-page-workspace,
.platform-image-page-workspace { .platform-image-page-workspace {
padding-right: 0; padding-right: 0;

View File

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

View File

@ -166,6 +166,7 @@ export function PlatformBotRuntimeSection({
refreshAttachmentPolicy: async () => ({ refreshAttachmentPolicy: async () => ({
uploadMaxMb: 0, uploadMaxMb: 0,
allowedAttachmentExtensions: [], allowedAttachmentExtensions: [],
workspaceDownloadExtensions,
}), }),
notify, notify,
t: dashboardT, t: dashboardT,
@ -180,7 +181,10 @@ export function PlatformBotRuntimeSection({
} }
resetWorkspaceState(); resetWorkspaceState();
void loadWorkspaceTree(selectedBotInfo.id, ''); 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(() => { useEffect(() => {
setDockerLogsPage(1); setDockerLogsPage(1);

View File

@ -1,11 +1,24 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { RefreshCw, X } from 'lucide-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 { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ImageFactoryModule } from '../../images/ImageFactoryModule'; import { ImageFactoryModule } from '../../images/ImageFactoryModule';
import type { BotState } from '../../../types/bot'; 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 type { PlatformBotResourceSnapshot } from '../types';
import { formatPlatformBytes, formatPlatformPercent } from '../utils'; import { formatPlatformBytes, formatPlatformPercent } from '../utils';
import '../../dashboard/components/WorkspaceOverlay.css';
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
interface PlatformCompactBotSheetProps { interface PlatformCompactBotSheetProps {
children: ReactNode; children: ReactNode;
@ -93,6 +106,8 @@ export function PlatformLastActionModal({
}: PlatformLastActionModalProps) { }: PlatformLastActionModalProps) {
if (!open) return null; if (!open) return null;
const content = repairCollapsedMarkdown(lastAction || (isZh ? '暂无最近执行内容。' : 'No recent execution yet.'));
return ( return (
<div className="modal-mask" onClick={onClose}> <div className="modal-mask" onClick={onClose}>
<div className="modal-card platform-last-action-modal" onClick={(event) => event.stopPropagation()}> <div className="modal-card platform-last-action-modal" onClick={(event) => event.stopPropagation()}>
@ -107,7 +122,17 @@ export function PlatformLastActionModal({
</LucentIconButton> </LucentIconButton>
</div> </div>
</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>
</div> </div>
); );

View File

@ -21,10 +21,10 @@ export function PlatformUsageAnalyticsSection({
usageSummary, usageSummary,
}: PlatformUsageAnalyticsSectionProps) { }: PlatformUsageAnalyticsSectionProps) {
return ( return (
<section className="panel stack"> <section className="panel stack platform-analytics-panel">
<div className="platform-model-analytics-head"> <div className="platform-model-analytics-head">
<div> <div>
<h2>{isZh ? '模型数据分析' : 'Model Analytics'}</h2> <h2>{isZh ? '模型调用分析' : 'Model Call Analytics'}</h2>
<div className="platform-model-analytics-subtitle"> <div className="platform-model-analytics-subtitle">
{isZh {isZh
? `最近 ${usageAnalytics?.window_days || 7} 天 · 调用次数趋势` ? `最近 ${usageAnalytics?.window_days || 7} 天 · 调用次数趋势`
@ -47,10 +47,10 @@ export function PlatformUsageAnalyticsSection({
{usageAnalytics && usageAnalytics.total_requests > 0 && usageAnalyticsSeries.length > 0 ? (() => { {usageAnalytics && usageAnalytics.total_requests > 0 && usageAnalyticsSeries.length > 0 ? (() => {
const chartWidth = 1120; const chartWidth = 1120;
const chartHeight = 248; const chartHeight = 300;
const paddingTop = 18; const paddingTop = 18;
const paddingRight = 20; const paddingRight = 20;
const paddingBottom = 30; const paddingBottom = 36;
const paddingLeft = 44; const paddingLeft = 44;
const innerWidth = chartWidth - paddingLeft - paddingRight; const innerWidth = chartWidth - paddingLeft - paddingRight;
const innerHeight = chartHeight - paddingTop - paddingBottom; const innerHeight = chartHeight - paddingTop - paddingBottom;

View File

@ -254,6 +254,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
const overviewBots = overview?.summary.bots; const overviewBots = overview?.summary.bots;
const overviewImages = overview?.summary.images; const overviewImages = overview?.summary.images;
const overviewResources = overview?.summary.resources; const overviewResources = overview?.summary.resources;
const activityStats = overview?.activity_stats;
const usageSummary = usageData?.summary || overview?.usage.summary; const usageSummary = usageData?.summary || overview?.usage.summary;
const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null; const usageAnalytics = usageData?.analytics || overview?.usage.analytics || null;
@ -494,6 +495,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
storagePercent, storagePercent,
toggleBot, toggleBot,
usageAnalytics, usageAnalytics,
activityStats,
usageAnalyticsMax, usageAnalyticsMax,
usageAnalyticsSeries, usageAnalyticsSeries,
usageAnalyticsTicks, usageAnalyticsTicks,

View File

@ -100,6 +100,12 @@ export interface BotSkillMarketItem extends SkillMarketItem {
install_error?: string | null; install_error?: string | null;
} }
export interface BotActivityStatsItem {
bot_id: string;
name: string;
count: number;
}
export interface PlatformOverviewResponse { export interface PlatformOverviewResponse {
summary: { summary: {
bots: { bots: {
@ -165,6 +171,7 @@ export interface PlatformOverviewResponse {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
created_at: string; created_at: string;
}>; }>;
activity_stats?: BotActivityStatsItem[];
} }
export interface PlatformBotResourceSnapshot { export interface PlatformBotResourceSnapshot {