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

View File

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

View File

@ -8,6 +8,7 @@ from schemas.bot import MessageFeedbackRequest
from services.chat_history_service import (
clear_bot_messages_payload,
clear_dashboard_direct_session_payload,
delete_bot_message_payload,
list_bot_messages_by_date_payload,
list_bot_messages_page_payload,
list_bot_messages_payload,
@ -59,6 +60,24 @@ def update_bot_message_feedback(
return update_bot_message_feedback_payload(session, bot_id, message_id, payload.feedback)
@router.delete("/api/bots/{bot_id}/messages/{message_id}")
def delete_bot_message(
bot_id: str,
message_id: int,
session: Session = Depends(get_session),
):
return delete_bot_message_payload(session, bot_id, message_id)
@router.post("/api/bots/{bot_id}/messages/{message_id}/delete")
def delete_bot_message_post(
bot_id: str,
message_id: int,
session: Session = Depends(get_session),
):
return delete_bot_message_payload(session, bot_id, message_id)
@router.delete("/api/bots/{bot_id}/messages")
def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)):
return clear_bot_messages_payload(session, bot_id)

View File

@ -3,7 +3,6 @@ from sqlmodel import SQLModel, Session, create_engine
from core.settings import (
DATABASE_ECHO,
DATABASE_ENGINE,
DATABASE_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
@ -19,19 +18,12 @@ from models import topic as _topic_models # noqa: F401
_engine_kwargs = {
"echo": DATABASE_ECHO,
}
if DATABASE_ENGINE == "sqlite":
_engine_kwargs["connect_args"] = {"check_same_thread": False}
else:
_engine_kwargs.update(
{
"pool_pre_ping": True,
"pool_size": DATABASE_POOL_SIZE,
"max_overflow": DATABASE_MAX_OVERFLOW,
"pool_timeout": DATABASE_POOL_TIMEOUT,
"pool_recycle": DATABASE_POOL_RECYCLE,
}
)
engine = create_engine(DATABASE_URL, **_engine_kwargs)
@ -42,60 +34,17 @@ BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
SYS_SETTING_TABLE = "sys_setting"
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
MYSQL_MIGRATION_LOCK_NAME = "dashboard_nanobot_schema_migration"
LEGACY_TABLE_PAIRS = [
("botinstance", BOT_INSTANCE_TABLE),
("botmessage", BOT_MESSAGE_TABLE),
("nanobotimage", BOT_IMAGE_TABLE),
("platformsetting", SYS_SETTING_TABLE),
("botrequestusage", BOT_REQUEST_USAGE_TABLE),
("botactivityevent", BOT_ACTIVITY_EVENT_TABLE),
]
def _quote_ident(name: str) -> str:
if engine.dialect.name == "mysql":
return f"`{str(name).replace('`', '``')}`"
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
def _rename_table_if_needed(old_name: str, new_name: str) -> None:
inspector = inspect(engine)
if not inspector.has_table(old_name) or inspector.has_table(new_name):
return
dialect = engine.dialect.name
with engine.connect() as conn:
if dialect == "mysql":
conn.execute(text(f"RENAME TABLE `{old_name}` TO `{new_name}`"))
else:
conn.execute(text(f'ALTER TABLE "{old_name}" RENAME TO "{new_name}"'))
conn.commit()
def _rename_legacy_tables() -> None:
_rename_table_if_needed("botinstance", BOT_INSTANCE_TABLE)
_rename_table_if_needed("botmessage", BOT_MESSAGE_TABLE)
_rename_table_if_needed("nanobotimage", BOT_IMAGE_TABLE)
_rename_table_if_needed("platformsetting", SYS_SETTING_TABLE)
_rename_table_if_needed("botrequestusage", BOT_REQUEST_USAGE_TABLE)
_rename_table_if_needed("botactivityevent", BOT_ACTIVITY_EVENT_TABLE)
def _acquire_migration_lock():
if engine.dialect.name == "postgresql":
conn = engine.connect()
conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
return conn
if engine.dialect.name == "mysql":
conn = engine.connect()
acquired = conn.execute(
text("SELECT GET_LOCK(:name, :timeout)"),
{"name": MYSQL_MIGRATION_LOCK_NAME, "timeout": 120},
).scalar()
if int(acquired or 0) != 1:
conn.close()
raise RuntimeError("Failed to acquire schema migration lock")
return conn
return None
@ -105,191 +54,17 @@ def _release_migration_lock(lock_conn) -> None:
try:
if engine.dialect.name == "postgresql":
lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
elif engine.dialect.name == "mysql":
lock_conn.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": MYSQL_MIGRATION_LOCK_NAME})
finally:
lock_conn.close()
def _table_row_count(table_name: str) -> int:
inspector = inspect(engine)
if not inspector.has_table(table_name):
return 0
with engine.connect() as conn:
value = conn.execute(text(f"SELECT COUNT(*) FROM {_quote_ident(table_name)}")).scalar()
return int(value or 0)
def _copy_legacy_table_rows(old_name: str, new_name: str) -> None:
inspector = inspect(engine)
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
return
if _table_row_count(old_name) <= 0:
return
old_columns = {
str(row.get("name"))
for row in inspector.get_columns(old_name)
if row.get("name")
}
new_columns = [
str(row.get("name"))
for row in inspector.get_columns(new_name)
if row.get("name")
]
shared_columns = [col for col in new_columns if col in old_columns]
if not shared_columns:
return
pk = inspector.get_pk_constraint(new_name) or {}
pk_columns = [
str(col)
for col in (pk.get("constrained_columns") or [])
if col and col in shared_columns and col in old_columns
]
if not pk_columns:
return
columns_sql = ", ".join(_quote_ident(col) for col in shared_columns)
join_sql = " AND ".join(
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
for col in pk_columns
)
null_check_col = _quote_ident(pk_columns[0])
with engine.connect() as conn:
conn.execute(
text(
f"INSERT INTO {_quote_ident(new_name)} ({columns_sql}) "
f"SELECT {', '.join(f'o.{_quote_ident(col)}' for col in shared_columns)} "
f"FROM {_quote_ident(old_name)} o "
f"LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} "
f"WHERE n.{null_check_col} IS NULL"
)
)
conn.commit()
def _migrate_legacy_table_rows() -> None:
for old_name, new_name in LEGACY_TABLE_PAIRS:
_copy_legacy_table_rows(old_name, new_name)
def _topic_fk_target(table_name: str, constrained_column: str = "bot_id") -> str | None:
inspector = inspect(engine)
if not inspector.has_table(table_name):
return None
for fk in inspector.get_foreign_keys(table_name):
cols = [str(col) for col in (fk.get("constrained_columns") or []) if col]
if cols == [constrained_column]:
referred = fk.get("referred_table")
return str(referred) if referred else None
return None
def _repair_postgres_topic_foreign_keys() -> None:
if engine.dialect.name != "postgresql":
return
targets = {
"topic_topic": "topic_topic_bot_id_fkey",
"topic_item": "topic_item_bot_id_fkey",
}
with engine.connect() as conn:
changed = False
for table_name, constraint_name in targets.items():
if _topic_fk_target(table_name) == BOT_INSTANCE_TABLE:
continue
conn.execute(
text(
f'ALTER TABLE {_quote_ident(table_name)} '
f'DROP CONSTRAINT IF EXISTS {_quote_ident(constraint_name)}'
)
)
conn.execute(
text(
f'ALTER TABLE {_quote_ident(table_name)} '
f'ADD CONSTRAINT {_quote_ident(constraint_name)} '
f'FOREIGN KEY ({_quote_ident("bot_id")}) '
f'REFERENCES {_quote_ident(BOT_INSTANCE_TABLE)}({_quote_ident("id")}) '
f'ON DELETE CASCADE'
)
)
changed = True
if changed:
conn.commit()
def _legacy_rows_missing_in_new(old_name: str, new_name: str) -> int:
inspector = inspect(engine)
if not inspector.has_table(old_name) or not inspector.has_table(new_name):
return 0
pk = inspector.get_pk_constraint(new_name) or {}
pk_columns = [
str(col)
for col in (pk.get("constrained_columns") or [])
if col
]
if not pk_columns:
return _table_row_count(old_name)
join_sql = " AND ".join(
f'n.{_quote_ident(col)} = o.{_quote_ident(col)}'
for col in pk_columns
)
null_check_col = _quote_ident(pk_columns[0])
with engine.connect() as conn:
value = conn.execute(
text(
f'SELECT COUNT(*) FROM {_quote_ident(old_name)} o '
f'LEFT JOIN {_quote_ident(new_name)} n ON {join_sql} '
f'WHERE n.{null_check_col} IS NULL'
)
).scalar()
return int(value or 0)
def _drop_legacy_tables() -> None:
droppable = [
old_name
for old_name, new_name in LEGACY_TABLE_PAIRS
if _legacy_rows_missing_in_new(old_name, new_name) <= 0
]
if not droppable:
return
with engine.connect() as conn:
for old_name in droppable:
if engine.dialect.name == "postgresql":
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)} CASCADE'))
else:
conn.execute(text(f'DROP TABLE IF EXISTS {_quote_ident(old_name)}'))
conn.commit()
def _ensure_botinstance_columns() -> None:
dialect = engine.dialect.name
required_columns = {
"current_state": {
"sqlite": "TEXT DEFAULT 'IDLE'",
"postgresql": "TEXT DEFAULT 'IDLE'",
"mysql": "VARCHAR(64) DEFAULT 'IDLE'",
},
"last_action": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "LONGTEXT",
},
"image_tag": {
"sqlite": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"postgresql": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"mysql": "VARCHAR(255) DEFAULT 'nanobot-base:v0.1.4'",
},
"access_password": {
"sqlite": "TEXT DEFAULT ''",
"postgresql": "TEXT DEFAULT ''",
"mysql": "VARCHAR(255) DEFAULT ''",
},
"enabled": {
"sqlite": "INTEGER NOT NULL DEFAULT 1",
"postgresql": "BOOLEAN NOT NULL DEFAULT TRUE",
"mysql": "BOOLEAN NOT NULL DEFAULT TRUE",
},
"current_state": "TEXT DEFAULT 'IDLE'",
"last_action": "TEXT",
"image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"access_password": "TEXT DEFAULT ''",
"enabled": "BOOLEAN NOT NULL DEFAULT TRUE",
}
inspector = inspect(engine)
@ -301,124 +76,24 @@ def _ensure_botinstance_columns() -> None:
for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
if row.get("name")
}
for col, ddl_map in required_columns.items():
for col, ddl in required_columns.items():
if col in existing:
continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}"))
if "enabled" in existing:
if dialect == "sqlite":
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = 1 WHERE enabled IS NULL"))
else:
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL"))
conn.commit()
def _drop_legacy_botinstance_columns() -> None:
legacy_columns = [
"avatar_model",
"avatar_skin",
"system_prompt",
"soul_md",
"agents_md",
"user_md",
"tools_md",
"tools_config_json",
"identity_md",
"llm_provider",
"llm_model",
"api_key",
"api_base",
"temperature",
"top_p",
"max_tokens",
"presence_penalty",
"frequency_penalty",
"send_progress",
"send_tool_hints",
"bot_env_json",
]
with engine.connect() as conn:
existing = {
str(col.get("name"))
for col in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
if col.get("name")
}
for col in legacy_columns:
if col not in existing:
continue
try:
if engine.dialect.name == "mysql":
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN `{col}`"))
elif engine.dialect.name == "sqlite":
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN "{col}"'))
else:
conn.execute(text(f'ALTER TABLE {BOT_INSTANCE_TABLE} DROP COLUMN IF EXISTS "{col}"'))
except Exception:
# Keep startup resilient on mixed/legacy database engines.
continue
conn.commit()
def _ensure_botmessage_columns() -> None:
if engine.dialect.name != "sqlite":
return
required_columns = {
"media_json": "TEXT",
"feedback": "TEXT",
"feedback_at": "DATETIME",
}
with engine.connect() as conn:
existing_rows = conn.execute(text(f"PRAGMA table_info({BOT_MESSAGE_TABLE})")).fetchall()
existing = {str(row[1]) for row in existing_rows}
for col, ddl in required_columns.items():
if col in existing:
continue
conn.execute(text(f"ALTER TABLE {BOT_MESSAGE_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit()
def _drop_legacy_skill_tables() -> None:
"""Drop deprecated skill registry tables (moved to workspace filesystem mode)."""
with engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS botskillmapping"))
conn.execute(text("DROP TABLE IF EXISTS skillregistry"))
conn.commit()
def _ensure_sys_setting_columns() -> None:
dialect = engine.dialect.name
required_columns = {
"name": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "VARCHAR(200) NOT NULL DEFAULT ''",
},
"category": {
"sqlite": "TEXT NOT NULL DEFAULT 'general'",
"postgresql": "TEXT NOT NULL DEFAULT 'general'",
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'general'",
},
"description": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "LONGTEXT",
},
"value_type": {
"sqlite": "TEXT NOT NULL DEFAULT 'json'",
"postgresql": "TEXT NOT NULL DEFAULT 'json'",
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'json'",
},
"is_public": {
"sqlite": "INTEGER NOT NULL DEFAULT 0",
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
},
"sort_order": {
"sqlite": "INTEGER NOT NULL DEFAULT 100",
"postgresql": "INTEGER NOT NULL DEFAULT 100",
"mysql": "INTEGER NOT NULL DEFAULT 100",
},
"name": "TEXT NOT NULL DEFAULT ''",
"category": "TEXT NOT NULL DEFAULT 'general'",
"description": "TEXT NOT NULL DEFAULT ''",
"value_type": "TEXT NOT NULL DEFAULT 'json'",
"is_public": "BOOLEAN NOT NULL DEFAULT FALSE",
"sort_order": "INTEGER NOT NULL DEFAULT 100",
}
inspector = inspect(engine)
if not inspector.has_table(SYS_SETTING_TABLE):
@ -429,32 +104,18 @@ def _ensure_sys_setting_columns() -> None:
for row in inspect(conn).get_columns(SYS_SETTING_TABLE)
if row.get("name")
}
for col, ddl_map in required_columns.items():
for col, ddl in required_columns.items():
if col in existing:
continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit()
def _ensure_bot_request_usage_columns() -> None:
dialect = engine.dialect.name
required_columns = {
"message_id": {
"sqlite": "INTEGER",
"postgresql": "INTEGER",
"mysql": "INTEGER",
},
"provider": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "VARCHAR(120)",
},
"model": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "VARCHAR(255)",
},
"message_id": "INTEGER",
"provider": "TEXT",
"model": "TEXT",
}
inspector = inspect(engine)
if not inspector.has_table(BOT_REQUEST_USAGE_TABLE):
@ -465,161 +126,34 @@ def _ensure_bot_request_usage_columns() -> None:
for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE)
if row.get("name")
}
for col, ddl_map in required_columns.items():
for col, ddl in required_columns.items():
if col in existing:
continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}"))
conn.commit()
def _ensure_topic_tables_sqlite() -> None:
if engine.dialect.name != "sqlite":
return
with engine.connect() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS topic_topic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL,
topic_key TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
is_default_fallback INTEGER NOT NULL DEFAULT 0,
routing_json TEXT NOT NULL DEFAULT '{}',
view_schema_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS topic_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL,
topic_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT 'info',
tags_json TEXT,
view_json TEXT,
source TEXT NOT NULL DEFAULT 'mcp',
dedupe_key TEXT,
is_read INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(bot_id) REFERENCES bot_instance(id)
)
"""
)
)
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key ON topic_topic(bot_id, topic_key)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic(bot_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic(topic_key)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback ON topic_topic(bot_id, is_default_fallback)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id ON topic_item(bot_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key ON topic_item(topic_key)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_level ON topic_item(level)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_source ON topic_item(source)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_is_read ON topic_item(is_read)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_created_at ON topic_item(created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at ON topic_item(bot_id, topic_key, created_at)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe ON topic_item(bot_id, dedupe_key)"))
conn.commit()
def _ensure_topic_columns() -> None:
dialect = engine.dialect.name
required_columns = {
"topic_topic": {
"name": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "VARCHAR(255) NOT NULL DEFAULT ''",
},
"description": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "LONGTEXT",
},
"is_active": {
"sqlite": "INTEGER NOT NULL DEFAULT 1",
"postgresql": "BOOLEAN NOT NULL DEFAULT TRUE",
"mysql": "BOOLEAN NOT NULL DEFAULT TRUE",
},
"is_default_fallback": {
"sqlite": "INTEGER NOT NULL DEFAULT 0",
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
},
"routing_json": {
"sqlite": "TEXT NOT NULL DEFAULT '{}'",
"postgresql": "TEXT NOT NULL DEFAULT '{}'",
"mysql": "LONGTEXT",
},
"view_schema_json": {
"sqlite": "TEXT NOT NULL DEFAULT '{}'",
"postgresql": "TEXT NOT NULL DEFAULT '{}'",
"mysql": "LONGTEXT",
},
"created_at": {
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
"updated_at": {
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
"name": "TEXT NOT NULL DEFAULT ''",
"description": "TEXT NOT NULL DEFAULT ''",
"is_active": "BOOLEAN NOT NULL DEFAULT TRUE",
"is_default_fallback": "BOOLEAN NOT NULL DEFAULT FALSE",
"routing_json": "TEXT NOT NULL DEFAULT '{}'",
"view_schema_json": "TEXT NOT NULL DEFAULT '{}'",
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"updated_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
"topic_item": {
"title": {
"sqlite": "TEXT NOT NULL DEFAULT ''",
"postgresql": "TEXT NOT NULL DEFAULT ''",
"mysql": "VARCHAR(2000) NOT NULL DEFAULT ''",
},
"level": {
"sqlite": "TEXT NOT NULL DEFAULT 'info'",
"postgresql": "TEXT NOT NULL DEFAULT 'info'",
"mysql": "VARCHAR(32) NOT NULL DEFAULT 'info'",
},
"tags_json": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "LONGTEXT",
},
"view_json": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "LONGTEXT",
},
"source": {
"sqlite": "TEXT NOT NULL DEFAULT 'mcp'",
"postgresql": "TEXT NOT NULL DEFAULT 'mcp'",
"mysql": "VARCHAR(64) NOT NULL DEFAULT 'mcp'",
},
"dedupe_key": {
"sqlite": "TEXT",
"postgresql": "TEXT",
"mysql": "VARCHAR(200)",
},
"is_read": {
"sqlite": "INTEGER NOT NULL DEFAULT 0",
"postgresql": "BOOLEAN NOT NULL DEFAULT FALSE",
"mysql": "BOOLEAN NOT NULL DEFAULT FALSE",
},
"created_at": {
"sqlite": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
"postgresql": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
"mysql": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
"title": "TEXT NOT NULL DEFAULT ''",
"level": "TEXT NOT NULL DEFAULT 'info'",
"tags_json": "TEXT",
"view_json": "TEXT",
"source": "TEXT NOT NULL DEFAULT 'mcp'",
"dedupe_key": "TEXT",
"is_read": "BOOLEAN NOT NULL DEFAULT FALSE",
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
}
@ -633,114 +167,13 @@ def _ensure_topic_columns() -> None:
for row in inspector.get_columns(table_name)
if row.get("name")
}
for col, ddl_map in cols.items():
for col, ddl in cols.items():
if col in existing:
continue
ddl = ddl_map.get(dialect) or ddl_map.get("sqlite")
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}"))
conn.commit()
def _ensure_topic_indexes() -> None:
required_indexes = [
("uq_topic_topic_bot_topic_key", "topic_topic", ["bot_id", "topic_key"], True),
("idx_topic_topic_bot_id", "topic_topic", ["bot_id"], False),
("idx_topic_topic_topic_key", "topic_topic", ["topic_key"], False),
("idx_topic_topic_bot_fallback", "topic_topic", ["bot_id", "is_default_fallback"], False),
("idx_topic_item_bot_id", "topic_item", ["bot_id"], False),
("idx_topic_item_topic_key", "topic_item", ["topic_key"], False),
("idx_topic_item_level", "topic_item", ["level"], False),
("idx_topic_item_source", "topic_item", ["source"], False),
("idx_topic_item_is_read", "topic_item", ["is_read"], False),
("idx_topic_item_created_at", "topic_item", ["created_at"], False),
("idx_topic_item_bot_topic_created_at", "topic_item", ["bot_id", "topic_key", "created_at"], False),
("idx_topic_item_bot_dedupe", "topic_item", ["bot_id", "dedupe_key"], False),
]
inspector = inspect(engine)
with engine.connect() as conn:
for name, table_name, columns, unique in required_indexes:
if not inspector.has_table(table_name):
continue
existing = {
str(item.get("name"))
for item in inspector.get_indexes(table_name)
if item.get("name")
}
existing.update(
str(item.get("name"))
for item in inspector.get_unique_constraints(table_name)
if item.get("name")
)
if name in existing:
continue
unique_sql = "UNIQUE " if unique else ""
cols_sql = ", ".join(columns)
conn.execute(text(f"CREATE {unique_sql}INDEX {name} ON {table_name} ({cols_sql})"))
conn.commit()
def _drop_obsolete_topic_tables() -> None:
with engine.connect() as conn:
if engine.dialect.name == "postgresql":
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
elif engine.dialect.name == "mysql":
conn.execute(text("DROP TABLE IF EXISTS `topic_bot_settings`"))
else:
conn.execute(text('DROP TABLE IF EXISTS "topic_bot_settings"'))
conn.commit()
def _cleanup_legacy_default_topics() -> None:
"""
Remove legacy auto-created fallback topic rows from early topic-feed design.
Historical rows look like:
- topic_key = inbox
- name = Inbox
- description = Default topic for uncategorized items
- routing_json contains "Fallback topic"
"""
with engine.connect() as conn:
legacy_rows = conn.execute(
text(
"""
SELECT bot_id, topic_key
FROM topic_topic
WHERE lower(coalesce(topic_key, '')) = 'inbox'
AND lower(coalesce(name, '')) = 'inbox'
AND lower(coalesce(description, '')) = 'default topic for uncategorized items'
AND lower(coalesce(routing_json, '')) LIKE '%fallback topic%'
"""
)
).fetchall()
if not legacy_rows:
return
for row in legacy_rows:
bot_id = str(row[0] or "").strip()
topic_key = str(row[1] or "").strip().lower()
if not bot_id or not topic_key:
continue
conn.execute(
text(
"""
DELETE FROM topic_item
WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key
"""
),
{"bot_id": bot_id, "topic_key": topic_key},
)
conn.execute(
text(
"""
DELETE FROM topic_topic
WHERE bot_id = :bot_id AND lower(coalesce(topic_key, '')) = :topic_key
"""
),
{"bot_id": bot_id, "topic_key": topic_key},
)
conn.commit()
def align_postgres_sequences() -> None:
if engine.dialect.name != "postgresql":
return
@ -777,22 +210,11 @@ def align_postgres_sequences() -> None:
def init_database() -> None:
lock_conn = _acquire_migration_lock()
try:
_rename_legacy_tables()
SQLModel.metadata.create_all(engine)
_migrate_legacy_table_rows()
_drop_legacy_skill_tables()
_ensure_sys_setting_columns()
_ensure_bot_request_usage_columns()
_ensure_botinstance_columns()
_drop_legacy_botinstance_columns()
_ensure_botmessage_columns()
_ensure_topic_tables_sqlite()
_repair_postgres_topic_foreign_keys()
_ensure_topic_columns()
_ensure_topic_indexes()
_drop_obsolete_topic_tables()
_cleanup_legacy_default_topics()
_drop_legacy_tables()
align_postgres_sequences()
finally:
_release_migration_lock(lock_conn)

View File

@ -143,9 +143,10 @@ def _mask_database_url(url: str) -> str:
_db_env = str(os.getenv("DATABASE_URL") or "").strip()
DATABASE_URL: Final[str] = _normalize_database_url(
_db_env if _db_env else f"sqlite:///{Path(DATA_ROOT) / 'nanobot_dashboard.db'}"
)
if not _db_env:
raise RuntimeError("DATABASE_URL is not set in environment. PostgreSQL is required.")
DATABASE_URL: Final[str] = _normalize_database_url(_db_env)
DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL)
DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL)
DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True)
@ -198,6 +199,10 @@ REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot")
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip()
APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False)
TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json"
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "topic_presets.json"

View File

@ -1,8 +1,14 @@
from app_factory import create_app
from core.settings import APP_HOST, APP_PORT, APP_RELOAD
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
if APP_RELOAD:
# Use import string to support hot-reloading
uvicorn.run("main:app", host=APP_HOST, port=APP_PORT, reload=True)
else:
# Use app object for faster/direct startup
uvicorn.run(app, host=APP_HOST, port=APP_PORT)

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))
def _invalidate_bot_messages_cache(bot_id: str) -> None:
cache.delete_prefix(f"bot:messages:{bot_id}:")
cache.delete_prefix(f"bot:messages:list:v2:{bot_id}:")
cache.delete_prefix(f"bot:messages:page:v2:{bot_id}:")
def _invalidate_images_cache() -> None:
cache.delete(_cache_key_images())

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]:
bot = _get_bot_or_404(session, bot_id)
rows = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()

View File

@ -101,3 +101,31 @@ def list_activity_events(
).model_dump()
)
return items
def get_bot_activity_stats(session: Session) -> List[Dict[str, Any]]:
from sqlalchemy import and_, func
from models.bot import BotInstance
stmt = (
select(BotInstance.id, BotInstance.name, func.count(BotActivityEvent.id).label("count"))
.select_from(BotInstance)
.join(
BotActivityEvent,
and_(
BotActivityEvent.bot_id == BotInstance.id,
BotActivityEvent.request_id.is_not(None),
func.length(func.trim(BotActivityEvent.request_id)) > 0,
),
isouter=True,
)
.where(BotInstance.enabled.is_(True))
.group_by(BotInstance.id, BotInstance.name)
.order_by(func.count(BotActivityEvent.id).desc(), BotInstance.name.asc(), BotInstance.id.asc())
)
results = session.exec(stmt).all()
return [
{"bot_id": row[0], "name": row[1] or row[0], "count": row[2]}
for row in results
]

View File

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

View File

@ -2,6 +2,7 @@ import asyncio
import json
import logging
import os
import re
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
@ -21,6 +22,7 @@ logger = logging.getLogger("dashboard.backend")
_main_loop: Optional[asyncio.AbstractEventLoop] = None
_AGENT_LOOP_READY_MARKER = "Agent loop started"
_LAST_ACTION_CONTROL_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]")
def set_main_loop(loop: Optional[asyncio.AbstractEventLoop]) -> None:
@ -73,6 +75,17 @@ def _normalize_media_list(raw: Any, bot_id: str) -> List[str]:
return rows
def _normalize_last_action_text(value: Any) -> str:
text = str(value or "")
if not text:
return ""
text = _LAST_ACTION_CONTROL_RE.sub("", text)
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = "\n".join(line.rstrip() for line in text.split("\n"))
text = re.sub(r"\n{4,}", "\n\n\n", text).strip()
return text[:4000]
def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
@ -91,18 +104,18 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
if packet_type == "AGENT_STATE":
payload = packet.get("payload") or {}
state = str(payload.get("state") or "").strip()
action = str(payload.get("action_msg") or payload.get("msg") or "").strip()
action = _normalize_last_action_text(payload.get("action_msg") or payload.get("msg") or "")
if state:
bot.current_state = state
if action:
bot.last_action = action[:4000]
bot.last_action = action
elif packet_type == "ASSISTANT_MESSAGE":
bot.current_state = "IDLE"
text_msg = str(packet.get("text") or "").strip()
media_list = _normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000]
bot.last_action = _normalize_last_action_text(text_msg)
message_row = BotMessage(
bot_id=bot_id,
role="assistant",
@ -148,7 +161,7 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]) -> Optional[int
if text_msg or media_list:
bot.current_state = "IDLE"
if text_msg:
bot.last_action = " ".join(text_msg.split())[:4000]
bot.last_action = _normalize_last_action_text(text_msg)
message_row = BotMessage(
bot_id=bot_id,
role="assistant",

View File

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

View File

@ -45,6 +45,11 @@ export const dashboardEn = {
copyReply: 'Copy reply',
copyReplyDone: 'Reply copied.',
copyReplyFail: 'Failed to copy reply.',
deleteMessage: 'Delete message',
deleteMessageConfirm: (role: string) => `Delete this ${role} message?`,
deleteMessageDone: 'Message deleted.',
deleteMessageFail: 'Failed to delete message.',
deleteMessagePending: 'Message is not synced yet. Please retry in a moment.',
quoteReply: 'Quote reply',
quotedReplyLabel: 'Quoted reply',
clearQuote: 'Clear quote',

View File

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

View File

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

View File

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

View File

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

View File

@ -72,12 +72,14 @@
pointer-events: none;
transform: translateX(6px) scale(0.95);
transition: opacity 0.18s ease, transform 0.18s ease, width 0.18s ease, margin-right 0.18s ease;
position: relative;
z-index: 2;
}
.ops-chat-row.is-user:hover .ops-chat-hover-actions-user,
.ops-chat-row.is-user:focus-within .ops-chat-hover-actions-user {
width: 54px;
margin-right: 6px;
width: 84px;
margin-right: 8px;
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);

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

View File

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

View File

@ -3,7 +3,7 @@ import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText } from '../messageParser';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import type { BotMessagesByDateResponse } from '../types';
import {
formatConversationDate,
@ -19,6 +19,14 @@ interface NotifyOptions {
durationMs?: number;
}
interface ConfirmOptions {
title: string;
message: string;
tone?: PromptTone;
confirmLabel?: string;
cancelLabel?: string;
}
interface UseDashboardChatHistoryOptions {
selectedBotId: string;
messages: ChatMessage[];
@ -29,6 +37,7 @@ interface UseDashboardChatHistoryOptions {
setBotMessages: (botId: string, messages: ChatMessage[]) => void;
setBotMessageFeedback: (botId: string, messageId: number, feedback: 'up' | 'down' | null) => void;
notify: (message: string, options?: NotifyOptions) => void;
confirm: (options: ConfirmOptions) => Promise<boolean>;
t: any;
isZh: boolean;
}
@ -43,6 +52,7 @@ export function useDashboardChatHistory({
setBotMessages,
setBotMessageFeedback,
notify,
confirm,
t,
isZh,
}: UseDashboardChatHistoryOptions) {
@ -56,6 +66,7 @@ export function useDashboardChatHistory({
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
const [deletingMessageIdMap, setDeletingMessageIdMap] = useState<Record<number, boolean>>({});
const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatDateTriggerRef = useRef<HTMLButtonElement | null>(null);
@ -81,6 +92,7 @@ export function useDashboardChatHistory({
setExpandedProgressByKey({});
setExpandedUserByKey({});
setFeedbackSavingByMessageId({});
setDeletingMessageIdMap({});
setChatDatePickerOpen(false);
setChatDatePanelPosition(null);
setChatJumpAnchorId(null);
@ -406,6 +418,153 @@ export function useDashboardChatHistory({
}
};
const resolveMessageIdFromLatest = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) return null;
const latest = await fetchBotMessages(selectedBotId);
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const matched = latest
.filter((row) => row.role === message.role && row.id)
.map((row) => ({ message: row, diff: Math.abs((row.ts || 0) - (message.ts || 0)) }))
.filter(({ message: row, diff }) => {
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& diff <= 10 * 60 * 1000;
})
.sort((a, b) => a.diff - b.diff)[0]?.message;
return matched?.id || null;
}, [fetchBotMessages, selectedBotId]);
const removeConversationMessageLocally = useCallback((message: ChatMessage, deletedMessageId: number) => {
if (!selectedBotId) return;
const originalMessageId = Number(message.id);
const hasOriginalId = Number.isFinite(originalMessageId) && originalMessageId > 0;
const idsToRemove = new Set<number>([deletedMessageId]);
if (hasOriginalId) {
idsToRemove.add(originalMessageId);
}
const scrollBox = chatScrollRef.current;
const prevTop = scrollBox?.scrollTop ?? null;
const normalizedTargetText = message.role === 'user'
? normalizeUserMessageText(message.text)
: normalizeAssistantMessageText(message.text);
const targetAttachments = JSON.stringify(message.attachments || []);
const nextMessages = messages.filter((row) => {
const rowId = Number(row.id);
if (Number.isFinite(rowId) && rowId > 0) {
return !idsToRemove.has(rowId);
}
if (hasOriginalId || row.role !== message.role) {
return true;
}
const normalizedRowText = row.role === 'user'
? normalizeUserMessageText(row.text)
: normalizeAssistantMessageText(row.text);
return !(
normalizedRowText === normalizedTargetText
&& JSON.stringify(row.attachments || []) === targetAttachments
&& Math.abs((row.ts || 0) - (message.ts || 0)) <= 1000
);
});
setBotMessages(selectedBotId, nextMessages);
if (prevTop === null || chatAutoFollowRef.current) return;
requestAnimationFrame(() => {
const box = chatScrollRef.current;
if (!box) return;
const maxTop = Math.max(0, box.scrollHeight - box.clientHeight);
box.scrollTop = Math.min(prevTop, maxTop);
});
}, [messages, selectedBotId, setBotMessages]);
const deleteConversationMessageOnServer = useCallback(async (messageId: number) => {
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${messageId}`);
}, [selectedBotId]);
const deleteConversationMessage = useCallback(async (message: ChatMessage) => {
if (!selectedBotId) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
let targetMessageId = Number(message.id);
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
targetMessageId = Number(await resolveMessageIdFromLatest(message));
}
if (!Number.isFinite(targetMessageId) || targetMessageId <= 0) {
notify(t.deleteMessagePending, { tone: 'warning' });
return;
}
if (deletingMessageIdMap[targetMessageId]) return;
const roleLabel = message.role === 'user' ? t.you : 'Nanobot';
const ok = await confirm({
title: t.deleteMessage,
message: t.deleteMessageConfirm(roleLabel),
tone: 'warning',
confirmLabel: t.delete,
cancelLabel: t.cancel,
});
if (!ok) return;
setDeletingMessageIdMap((prev) => ({ ...prev, [targetMessageId]: true }));
try {
await deleteConversationMessageOnServer(targetMessageId);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
} catch (error: any) {
if (error?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/delete`);
removeConversationMessageLocally(message, targetMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch {
// continue to secondary re-match fallback below
}
}
if (error?.response?.status === 404) {
const refreshedMessageId = Number(await resolveMessageIdFromLatest(message));
if (Number.isFinite(refreshedMessageId) && refreshedMessageId > 0 && refreshedMessageId !== targetMessageId) {
try {
await deleteConversationMessageOnServer(refreshedMessageId);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (retryError: any) {
if (retryError?.response?.status === 404) {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${refreshedMessageId}/delete`);
removeConversationMessageLocally(message, refreshedMessageId);
notify(t.deleteMessageDone, { tone: 'success' });
return;
} catch (postRetryError: any) {
notify(postRetryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
notify(retryError?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
return;
}
}
}
notify(error?.response?.data?.detail || t.deleteMessageFail, { tone: 'error' });
} finally {
setDeletingMessageIdMap((prev) => {
const next = { ...prev };
delete next[targetMessageId];
return next;
});
}
}, [confirm, deleteConversationMessageOnServer, deletingMessageIdMap, notify, removeConversationMessageLocally, resolveMessageIdFromLatest, selectedBotId, t]);
const toggleProgressExpanded = (key: string) => {
setExpandedProgressByKey((prev) => ({
...prev,
@ -430,6 +589,8 @@ export function useDashboardChatHistory({
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
deletingMessageIdMap,
deleteConversationMessage,
jumpConversationToDate,
loadInitialChatPage,
onChatScroll,

View File

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

View File

@ -1,7 +1,5 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { fetchDashboardSystemDefaults } from '../api/system';
type PromptTone = 'info' | 'success' | 'warning' | 'error';
interface NotifyOptions {
@ -34,8 +32,6 @@ interface UseDashboardLifecycleOptions {
selectedBotId: string;
setBotListMenuOpen: (value: boolean) => void;
setChatDatePickerOpen: (value: boolean) => void;
setChatPullPageSize: Dispatch<SetStateAction<number>>;
setCommandAutoUnlockSeconds: Dispatch<SetStateAction<number>>;
setPendingAttachments: Dispatch<SetStateAction<string[]>>;
setShowRuntimeActionModal: (value: boolean) => void;
setRuntimeMenuOpen: (value: boolean) => void;
@ -70,8 +66,6 @@ export function useDashboardLifecycle({
selectedBotId,
setBotListMenuOpen,
setChatDatePickerOpen,
setChatPullPageSize,
setCommandAutoUnlockSeconds,
setPendingAttachments,
setShowRuntimeActionModal,
setRuntimeMenuOpen,
@ -118,30 +112,6 @@ export function useDashboardLifecycle({
hideWorkspaceHoverCard();
}, [hideWorkspaceHoverCard, selectedBotId, setShowRuntimeActionModal]);
useEffect(() => {
let alive = true;
const loadChatDefaults = async () => {
try {
const data = await fetchDashboardSystemDefaults();
if (!alive) return;
const pullPageSize = Number(data?.chat?.pull_page_size);
if (Number.isFinite(pullPageSize) && pullPageSize > 0) {
setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize))));
}
const autoUnlockSeconds = Number(data?.chat?.command_auto_unlock_seconds);
if (Number.isFinite(autoUnlockSeconds) && autoUnlockSeconds > 0) {
setCommandAutoUnlockSeconds(Math.max(1, Math.min(600, Math.floor(autoUnlockSeconds))));
}
} catch {
// Keep current chat defaults when system defaults are unavailable.
}
};
void loadChatDefaults();
return () => {
alive = false;
};
}, [setChatPullPageSize, setCommandAutoUnlockSeconds]);
useEffect(() => {
if (!selectedBotId) {
resetWorkspaceState();

View File

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

View File

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

View File

@ -64,6 +64,33 @@ export function normalizeAssistantMessageText(input: string) {
return text;
}
export function repairCollapsedMarkdown(input: string) {
const normalized = normalizeAssistantMessageText(input);
if (!normalized) return '';
if (normalized.includes('\n')) return normalized;
let text = normalized;
text = text
.replace(/\s+(#{1,6}\s+)/g, '\n\n$1')
.replace(/\s+(```[A-Za-z0-9_-]*)\s+/g, '\n\n$1\n')
.replace(/\s+```/g, '\n```\n')
.replace(/\s+(---|\*\*\*|___)\s+/g, '\n\n$1\n\n');
if (/\|/.test(text) && /\|\s*:?-{3,}/.test(text)) {
text = text
.replace(/\s*(\|[^|\n]*\|\s*:?-{3,}(?:\s*\|\s*:?-{3,})+\s*\|)\s*/g, '\n$1\n')
.replace(/\|\s+\|/g, '|\n|');
}
text = text
.replace(/([^\n])\s-\s(?=\S)/g, '$1\n- ')
.replace(/\n{4,}/g, '\n\n\n')
.trim();
return text;
}
export function summarizeProgressText(input: string, isZh: boolean) {
const raw = normalizeAssistantMessageText(input);
if (!raw) return isZh ? '处理中...' : 'Processing...';

View File

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

View File

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

View File

@ -675,14 +675,23 @@
.platform-selected-bot-last-body {
margin-top: 10px;
padding: 14px;
max-height: min(58vh, 680px);
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
background: color-mix(in oklab, var(--panel-soft) 76%, transparent);
color: var(--muted);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
min-height: 0;
}
.platform-last-action-markdown {
padding: 14px 16px;
}
.platform-last-action-markdown p,
.platform-last-action-markdown li,
.platform-last-action-markdown blockquote {
color: var(--text);
}
.platform-last-action-btn {
@ -1149,6 +1158,9 @@
.platform-last-action-modal {
width: min(760px, 92vw);
max-height: min(82vh, 920px);
display: flex;
flex-direction: column;
}
.platform-resource-summary-grid {
@ -1283,6 +1295,23 @@
padding-right: 0;
}
.platform-analytics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: stretch;
}
.platform-analytics-panel {
height: 100%;
}
@media (max-width: 1160px) {
.platform-analytics-grid {
grid-template-columns: 1fr;
}
}
.platform-settings-page-workspace,
.platform-image-page-workspace {
padding-right: 0;

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 () => ({
uploadMaxMb: 0,
allowedAttachmentExtensions: [],
workspaceDownloadExtensions,
}),
notify,
t: dashboardT,
@ -180,7 +181,10 @@ export function PlatformBotRuntimeSection({
}
resetWorkspaceState();
void loadWorkspaceTree(selectedBotInfo.id, '');
}, [loadWorkspaceTree, resetWorkspaceState, selectedBotInfo?.id]);
// Re-run only when the selected bot changes; loadWorkspaceTree is recreated
// by workspace policy updates and would otherwise cause an initialization loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetWorkspaceState, selectedBotInfo?.id]);
useEffect(() => {
setDockerLogsPage(1);

View File

@ -1,11 +1,24 @@
import type { ReactNode } from 'react';
import { RefreshCw, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { ImageFactoryModule } from '../../images/ImageFactoryModule';
import type { BotState } from '../../../types/bot';
import { MARKDOWN_SANITIZE_SCHEMA } from '../../dashboard/constants';
import { repairCollapsedMarkdown } from '../../dashboard/messageParser';
import {
createWorkspaceMarkdownComponents,
decorateWorkspacePathsForMarkdown,
} from '../../dashboard/shared/workspaceMarkdown';
import type { PlatformBotResourceSnapshot } from '../types';
import { formatPlatformBytes, formatPlatformPercent } from '../utils';
import '../../dashboard/components/WorkspaceOverlay.css';
const lastActionMarkdownComponents = createWorkspaceMarkdownComponents(() => {});
interface PlatformCompactBotSheetProps {
children: ReactNode;
@ -93,6 +106,8 @@ export function PlatformLastActionModal({
}: PlatformLastActionModalProps) {
if (!open) return null;
const content = repairCollapsedMarkdown(lastAction || (isZh ? '暂无最近执行内容。' : 'No recent execution yet.'));
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card platform-last-action-modal" onClick={(event) => event.stopPropagation()}>
@ -107,7 +122,17 @@ export function PlatformLastActionModal({
</LucentIconButton>
</div>
</div>
<div className="platform-selected-bot-last-body">{lastAction || (isZh ? '暂无最近执行内容。' : 'No recent execution yet.')}</div>
<div className="platform-selected-bot-last-body">
<div className="workspace-markdown platform-last-action-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={lastActionMarkdownComponents}
>
{decorateWorkspacePathsForMarkdown(content)}
</ReactMarkdown>
</div>
</div>
</div>
</div>
);

View File

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

View File

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

View File

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