v0.1.4-p4
parent
0167a9bc8a
commit
9699b4e7c9
|
|
@ -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 工作目录
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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` 做列对齐,删除历史遗留配置列(保留当前最小字段集)
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: '取消引用',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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...';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { BotActivityStatsItem } from '../types';
|
||||||
|
|
||||||
|
interface PlatformBotActivityAnalyticsSectionProps {
|
||||||
|
isZh: boolean;
|
||||||
|
activityStats: BotActivityStatsItem[] | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformBotActivityAnalyticsSection({
|
||||||
|
isZh,
|
||||||
|
activityStats,
|
||||||
|
loading,
|
||||||
|
}: PlatformBotActivityAnalyticsSectionProps) {
|
||||||
|
const totalActivity = activityStats?.reduce((acc, item) => acc + item.count, 0) || 0;
|
||||||
|
const displayStats = (activityStats || []).slice(0, 10);
|
||||||
|
const maxCount = Math.max(...(displayStats.map(item => item.count) || [0]), 1);
|
||||||
|
const tickCount = 4;
|
||||||
|
const yAxisTicks = Array.from({ length: tickCount + 1 }, (_, index) => Math.round((maxCount / tickCount) * index));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel stack platform-analytics-panel">
|
||||||
|
<div className="platform-model-analytics-head">
|
||||||
|
<div>
|
||||||
|
<h2>{isZh ? 'Bot 活跃度分析' : 'Bot Activity Analytics'}</h2>
|
||||||
|
<div className="platform-model-analytics-subtitle">
|
||||||
|
{isZh ? '基于 bot_activity_event 中 request_id 非空记录的活跃度统计' : 'Activity statistics based on non-empty request_id rows in bot_activity_event'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="platform-model-analytics-total">
|
||||||
|
<strong>{totalActivity}</strong>
|
||||||
|
<span>{isZh ? '总请求数' : 'Total Requests'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !activityStats ? (
|
||||||
|
<div className="ops-empty-inline">{isZh ? '正在加载活跃度统计...' : 'Loading activity analytics...'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && (!activityStats || activityStats.length === 0) ? (
|
||||||
|
<div className="ops-empty-inline">{isZh ? '暂无活跃度数据。' : 'No activity data.'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activityStats && activityStats.length > 0 ? (() => {
|
||||||
|
const barStep = 72;
|
||||||
|
const barWidth = 40;
|
||||||
|
const chartWidth = Math.max(680, displayStats.length * barStep + 120);
|
||||||
|
const chartHeight = 320;
|
||||||
|
const paddingTop = 20;
|
||||||
|
const paddingBottom = 78;
|
||||||
|
const paddingLeft = 52;
|
||||||
|
const paddingRight = 24;
|
||||||
|
const innerWidth = chartWidth - paddingLeft - paddingRight;
|
||||||
|
const innerHeight = chartHeight - paddingTop - paddingBottom;
|
||||||
|
const startX = paddingLeft + Math.max((innerWidth - displayStats.length * barStep) / 2, 0) + (barStep - barWidth) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="platform-model-chart" style={{ marginTop: '16px' }}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={isZh ? '启用 Bot 请求数柱状图' : 'Enabled bot request count bar chart'}
|
||||||
|
style={{ width: '100%', height: 'auto' }}
|
||||||
|
>
|
||||||
|
{yAxisTicks.map((tick) => {
|
||||||
|
const y = paddingTop + innerHeight - (tick / maxCount) * innerHeight;
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={paddingLeft} y1={y} x2={chartWidth - paddingRight} y2={y} className="platform-model-chart-grid" />
|
||||||
|
<text x={paddingLeft - 10} y={y + 4} textAnchor="end" className="platform-model-chart-axis-label">
|
||||||
|
{tick}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{displayStats.map((item, index) => {
|
||||||
|
const x = startX + index * barStep;
|
||||||
|
const normalizedCount = Number(item.count || 0);
|
||||||
|
const columnHeight = (normalizedCount / maxCount) * innerHeight;
|
||||||
|
const y = paddingTop + innerHeight - columnHeight;
|
||||||
|
return (
|
||||||
|
<g key={item.bot_id}>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={paddingTop}
|
||||||
|
width={barWidth}
|
||||||
|
height={innerHeight}
|
||||||
|
rx={12}
|
||||||
|
style={{ fill: 'rgba(128, 128, 128, 0.08)' }}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={columnHeight}
|
||||||
|
rx={12}
|
||||||
|
style={{ fill: 'url(#activityGradient)' }}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + barWidth / 2}
|
||||||
|
y={Math.max(y - 8, paddingTop + 14)}
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ fontSize: '12px', fill: 'var(--title)', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{normalizedCount}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={x + barWidth / 2}
|
||||||
|
y={chartHeight - 18}
|
||||||
|
textAnchor="end"
|
||||||
|
className="platform-model-chart-axis-label"
|
||||||
|
transform={`rotate(-28 ${x + barWidth / 2} ${chartHeight - 18})`}
|
||||||
|
>
|
||||||
|
{item.name.length > 12 ? item.name.slice(0, 12) : item.name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="activityGradient" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
|
<stop offset="0%" style={{ stopColor: '#6c5ac3', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: '#7f90ff', stopOpacity: 1 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
{(activityStats?.length || 0) > 10 && (
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px', color: 'var(--muted)', textAlign: 'center' }}>
|
||||||
|
{isZh ? `仅显示前 10 个活跃 Bot (共 ${activityStats.length} 个)` : `Showing top 10 active bots (total ${activityStats.length})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -166,6 +166,7 @@ export function PlatformBotRuntimeSection({
|
||||||
refreshAttachmentPolicy: async () => ({
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue