两种模式的部署文件
parent
e8932bec17
commit
7971182478
|
|
@ -22,13 +22,11 @@ PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
# Frontend package registry mirror (used by yarn, recommended in CN)
|
# Frontend package registry mirror (used by yarn, recommended in CN)
|
||||||
NPM_REGISTRY=https://registry.npmmirror.com
|
NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
|
|
||||||
# Database (choose one: SQLite / PostgreSQL / MySQL)
|
# Database for deploy-prod.sh
|
||||||
# SQLite example:
|
# This mode now supports external PostgreSQL only.
|
||||||
# DATABASE_URL=sqlite:////app/data/nanobot_dashboard.db
|
# Before running deploy-prod.sh, initialize the target database explicitly with:
|
||||||
# PostgreSQL example:
|
# scripts/sql/create-tables.sql
|
||||||
# DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
|
# scripts/sql/init-data.sql
|
||||||
# MySQL example:
|
|
||||||
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
|
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/dashboard
|
DATABASE_URL=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/dashboard
|
||||||
DATABASE_POOL_SIZE=20
|
DATABASE_POOL_SIZE=20
|
||||||
DATABASE_MAX_OVERFLOW=40
|
DATABASE_MAX_OVERFLOW=40
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -59,8 +59,8 @@ graph TD
|
||||||
|
|
||||||
## 默认资源
|
## 默认资源
|
||||||
|
|
||||||
- 项目根目录 `data/templates/` 保存默认模板资源,会在初始化时同步到运行时数据目录。
|
- 项目根目录 `data/templates/` 保存默认模板资源,后端运行时直接读取这里的文件,不再在启动阶段做复制或兜底回填。
|
||||||
- 项目根目录 `data/skills/` 保存默认 skill 包,会在数据库初始化阶段自动注册到 `skill_market_item`。
|
- 项目根目录 `data/skills/` 保存默认 skill 包,数据库初始化阶段会把这些默认 skill 注册到 `skill_market_item`。
|
||||||
- `data/model/` 不包含语音识别模型文件;模型需要用户自行下载放入该目录或 `STT_MODEL_DIR` 指向的目录。
|
- `data/model/` 不包含语音识别模型文件;模型需要用户自行下载放入该目录或 `STT_MODEL_DIR` 指向的目录。
|
||||||
- 如果语音模型缺失,后端启动时会打印明确告警,但不会阻断服务启动。
|
- 如果语音模型缺失,后端启动时会打印明确告警,但不会阻断服务启动。
|
||||||
|
|
||||||
|
|
@ -110,6 +110,7 @@ graph TD
|
||||||
1. 准备部署变量
|
1. 准备部署变量
|
||||||
- 复制 `.env.prod.example` 为 `.env.prod`(位于项目根目录)
|
- 复制 `.env.prod.example` 为 `.env.prod`(位于项目根目录)
|
||||||
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
||||||
|
- `deploy-prod.sh` 现在要求使用外部 PostgreSQL,且目标库必须提前执行 `scripts/sql/create-tables.sql` 与 `scripts/sql/init-data.sql`
|
||||||
- 只需要配置绝对路径:
|
- 只需要配置绝对路径:
|
||||||
- `HOST_BOTS_WORKSPACE_ROOT`
|
- `HOST_BOTS_WORKSPACE_ROOT`
|
||||||
- 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到宿主机项目根目录的 `data/model/`
|
- 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到宿主机项目根目录的 `data/model/`
|
||||||
|
|
@ -127,6 +128,7 @@ graph TD
|
||||||
### 关键说明
|
### 关键说明
|
||||||
|
|
||||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||||
|
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
||||||
- 上传大小使用单一参数 `UPLOAD_MAX_MB` 控制(后端校验 + Nginx 限制)。
|
- 上传大小使用单一参数 `UPLOAD_MAX_MB` 控制(后端校验 + Nginx 限制)。
|
||||||
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
||||||
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
||||||
|
|
@ -145,6 +147,8 @@ graph TD
|
||||||
- `scripts/deploy-full.sh`
|
- `scripts/deploy-full.sh`
|
||||||
- `scripts/init-full-db.sh`
|
- `scripts/init-full-db.sh`
|
||||||
- `scripts/stop-full.sh`
|
- `scripts/stop-full.sh`
|
||||||
|
- `scripts/sql/create-tables.sql`
|
||||||
|
- `scripts/sql/init-data.sql`
|
||||||
- `scripts/sql/init-postgres-bootstrap.sql`
|
- `scripts/sql/init-postgres-bootstrap.sql`
|
||||||
- `scripts/sql/init-postgres-app.sql`
|
- `scripts/sql/init-postgres-app.sql`
|
||||||
|
|
||||||
|
|
@ -172,7 +176,9 @@ graph TD
|
||||||
- 创建或更新业务账号
|
- 创建或更新业务账号
|
||||||
- 创建业务库并授权
|
- 创建业务库并授权
|
||||||
- 修正 `public` schema 权限
|
- 修正 `public` schema 权限
|
||||||
- Dashboard 业务表本身仍由后端启动时自动执行 `SQLModel.metadata.create_all(...)` 与补列/索引对齐。
|
- 执行 `scripts/sql/create-tables.sql` 创建业务表
|
||||||
|
- 执行 `scripts/sql/init-data.sql` 初始化 `sys_setting` 与默认 skill 市场数据
|
||||||
|
- 后端启动时只做初始化完整性校验,不再自动补表、补列、补数据或迁移旧结构;缺库表、缺 `sys_setting`、缺模板文件都会直接报错。
|
||||||
|
|
||||||
### 停止
|
### 停止
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ from core.docker_instance import docker_manager
|
||||||
from core.speech_service import inspect_speech_model_status
|
from core.speech_service import inspect_speech_model_status
|
||||||
from core.settings import DATABASE_URL_DISPLAY, REDIS_ENABLED
|
from core.settings import DATABASE_URL_DISPLAY, REDIS_ENABLED
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
from services.bot_storage_service import _migrate_bot_resources_store
|
from services.default_assets_service import validate_runtime_data_assets
|
||||||
from services.default_assets_service import ensure_default_skill_market_items, ensure_runtime_data_assets
|
|
||||||
from services.platform_service import get_speech_runtime_settings, prune_expired_activity_events
|
from services.platform_service import get_speech_runtime_settings, prune_expired_activity_events
|
||||||
from services.runtime_service import docker_callback, set_main_loop
|
from services.runtime_service import docker_callback, set_main_loop
|
||||||
|
|
||||||
|
|
@ -31,24 +30,13 @@ def register_app_runtime(app: FastAPI) -> None:
|
||||||
current_loop = asyncio.get_running_loop()
|
current_loop = asyncio.get_running_loop()
|
||||||
app.state.main_loop = current_loop
|
app.state.main_loop = current_loop
|
||||||
set_main_loop(current_loop)
|
set_main_loop(current_loop)
|
||||||
asset_report = ensure_runtime_data_assets()
|
validate_runtime_data_assets()
|
||||||
if asset_report["templates_initialized"] or asset_report["skills_synchronized"]:
|
print("[init] data 目录校验通过")
|
||||||
print(
|
|
||||||
"[init] 默认资源已同步 "
|
|
||||||
f"(templates={asset_report['templates_initialized']}, skills={asset_report['skills_synchronized']})"
|
|
||||||
)
|
|
||||||
init_database()
|
init_database()
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
skill_report = ensure_default_skill_market_items(session)
|
|
||||||
if skill_report["created"] or skill_report["updated"]:
|
|
||||||
print(
|
|
||||||
"[init] 默认 skills 已入库 "
|
|
||||||
f"(created={len(skill_report['created'])}, updated={len(skill_report['updated'])})"
|
|
||||||
)
|
|
||||||
prune_expired_activity_events(session, force=True)
|
prune_expired_activity_events(session, force=True)
|
||||||
bots = session.exec(select(BotInstance)).all()
|
bots = session.exec(select(BotInstance)).all()
|
||||||
for bot in bots:
|
for bot in bots:
|
||||||
_migrate_bot_resources_store(bot.id)
|
|
||||||
docker_manager.ensure_monitor(bot.id, docker_callback)
|
docker_manager.ensure_monitor(bot.id, docker_callback)
|
||||||
speech_settings = get_speech_runtime_settings()
|
speech_settings = get_speech_runtime_settings()
|
||||||
model_status = inspect_speech_model_status()
|
model_status = inspect_speech_model_status()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
from sqlmodel import Session, create_engine
|
||||||
|
|
||||||
from core.settings import (
|
from core.settings import (
|
||||||
DATABASE_ECHO,
|
DATABASE_ECHO,
|
||||||
|
|
@ -10,13 +10,6 @@ from core.settings import (
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure table models are registered in SQLModel metadata before create_all.
|
|
||||||
from models import auth as _auth_models # noqa: F401
|
|
||||||
from models import bot as _bot_models # noqa: F401
|
|
||||||
from models import platform as _platform_models # noqa: F401
|
|
||||||
from models import skill as _skill_models # noqa: F401
|
|
||||||
from models import topic as _topic_models # noqa: F401
|
|
||||||
|
|
||||||
_engine_kwargs = {
|
_engine_kwargs = {
|
||||||
"echo": DATABASE_ECHO,
|
"echo": DATABASE_ECHO,
|
||||||
"pool_pre_ping": True,
|
"pool_pre_ping": True,
|
||||||
|
|
@ -34,270 +27,69 @@ BOT_IMAGE_TABLE = "bot_image"
|
||||||
BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
BOT_REQUEST_USAGE_TABLE = "bot_request_usage"
|
||||||
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event"
|
||||||
SYS_LOGIN_LOG_TABLE = "sys_login_log"
|
SYS_LOGIN_LOG_TABLE = "sys_login_log"
|
||||||
LEGACY_AUTH_LOGIN_LOG_TABLE = "auth_login_log"
|
|
||||||
SYS_SETTING_TABLE = "sys_setting"
|
SYS_SETTING_TABLE = "sys_setting"
|
||||||
POSTGRES_MIGRATION_LOCK_KEY = 2026031801
|
REQUIRED_TABLES = (
|
||||||
|
BOT_INSTANCE_TABLE,
|
||||||
|
BOT_MESSAGE_TABLE,
|
||||||
def _quote_ident(name: str) -> str:
|
BOT_IMAGE_TABLE,
|
||||||
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
|
BOT_REQUEST_USAGE_TABLE,
|
||||||
|
BOT_ACTIVITY_EVENT_TABLE,
|
||||||
|
SYS_LOGIN_LOG_TABLE,
|
||||||
def _acquire_migration_lock():
|
SYS_SETTING_TABLE,
|
||||||
if engine.dialect.name == "postgresql":
|
"skill_market_item",
|
||||||
conn = engine.connect()
|
"bot_skill_install",
|
||||||
conn.execute(text("SELECT pg_advisory_lock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
"topic_topic",
|
||||||
return conn
|
"topic_item",
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _release_migration_lock(lock_conn) -> None:
|
|
||||||
if lock_conn is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if engine.dialect.name == "postgresql":
|
|
||||||
lock_conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": POSTGRES_MIGRATION_LOCK_KEY})
|
|
||||||
finally:
|
|
||||||
lock_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
with engine.connect() as conn:
|
|
||||||
conn.execute(text(f"ALTER TABLE {_quote_ident(old_name)} RENAME TO {_quote_ident(new_name)}"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def _ensure_botinstance_columns() -> None:
|
|
||||||
required_columns = {
|
|
||||||
"current_state": "TEXT DEFAULT 'IDLE'",
|
|
||||||
"last_action": "TEXT",
|
|
||||||
"image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
|
|
||||||
"access_password": "TEXT DEFAULT ''",
|
|
||||||
"enabled": "BOOLEAN NOT NULL DEFAULT TRUE",
|
|
||||||
}
|
|
||||||
|
|
||||||
inspector = inspect(engine)
|
|
||||||
if not inspector.has_table(BOT_INSTANCE_TABLE):
|
|
||||||
return
|
|
||||||
with engine.connect() as conn:
|
|
||||||
existing = {
|
|
||||||
str(row.get("name"))
|
|
||||||
for row in inspect(conn).get_columns(BOT_INSTANCE_TABLE)
|
|
||||||
if row.get("name")
|
|
||||||
}
|
|
||||||
for col, ddl in required_columns.items():
|
|
||||||
if col in existing:
|
|
||||||
continue
|
|
||||||
conn.execute(text(f"ALTER TABLE {BOT_INSTANCE_TABLE} ADD COLUMN {col} {ddl}"))
|
|
||||||
|
|
||||||
if "enabled" in existing:
|
|
||||||
conn.execute(text(f"UPDATE {BOT_INSTANCE_TABLE} SET enabled = TRUE WHERE enabled IS NULL"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sys_setting_columns() -> None:
|
|
||||||
required_columns = {
|
|
||||||
"name": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"category": "TEXT NOT NULL DEFAULT 'general'",
|
|
||||||
"description": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"value_type": "TEXT NOT NULL DEFAULT 'json'",
|
|
||||||
"is_public": "BOOLEAN NOT NULL DEFAULT FALSE",
|
|
||||||
"sort_order": "INTEGER NOT NULL DEFAULT 100",
|
|
||||||
}
|
|
||||||
inspector = inspect(engine)
|
|
||||||
if not inspector.has_table(SYS_SETTING_TABLE):
|
|
||||||
return
|
|
||||||
with engine.connect() as conn:
|
|
||||||
existing = {
|
|
||||||
str(row.get("name"))
|
|
||||||
for row in inspect(conn).get_columns(SYS_SETTING_TABLE)
|
|
||||||
if row.get("name")
|
|
||||||
}
|
|
||||||
for col, ddl in required_columns.items():
|
|
||||||
if col in existing:
|
|
||||||
continue
|
|
||||||
conn.execute(text(f"ALTER TABLE {SYS_SETTING_TABLE} ADD COLUMN {col} {ddl}"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_bot_request_usage_columns() -> None:
|
|
||||||
required_columns = {
|
|
||||||
"message_id": "INTEGER",
|
|
||||||
"provider": "TEXT",
|
|
||||||
"model": "TEXT",
|
|
||||||
}
|
|
||||||
inspector = inspect(engine)
|
|
||||||
if not inspector.has_table(BOT_REQUEST_USAGE_TABLE):
|
|
||||||
return
|
|
||||||
with engine.connect() as conn:
|
|
||||||
existing = {
|
|
||||||
str(row.get("name"))
|
|
||||||
for row in inspect(conn).get_columns(BOT_REQUEST_USAGE_TABLE)
|
|
||||||
if row.get("name")
|
|
||||||
}
|
|
||||||
for col, ddl in required_columns.items():
|
|
||||||
if col in existing:
|
|
||||||
continue
|
|
||||||
conn.execute(text(f"ALTER TABLE {BOT_REQUEST_USAGE_TABLE} ADD COLUMN {col} {ddl}"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_auth_login_log_table() -> None:
|
|
||||||
_rename_table_if_needed(LEGACY_AUTH_LOGIN_LOG_TABLE, SYS_LOGIN_LOG_TABLE)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_auth_login_log_columns() -> None:
|
|
||||||
required_columns = {
|
|
||||||
"auth_type": "TEXT NOT NULL DEFAULT 'bot'",
|
|
||||||
"token_hash": "TEXT",
|
|
||||||
"auth_source": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"revoke_reason": "TEXT",
|
|
||||||
"device_info": "TEXT",
|
|
||||||
}
|
|
||||||
inspector = inspect(engine)
|
|
||||||
if not inspector.has_table(SYS_LOGIN_LOG_TABLE):
|
|
||||||
return
|
|
||||||
with engine.connect() as conn:
|
|
||||||
existing = {
|
|
||||||
str(row.get("name"))
|
|
||||||
for row in inspect(conn).get_columns(SYS_LOGIN_LOG_TABLE)
|
|
||||||
if row.get("name")
|
|
||||||
}
|
|
||||||
for col, ddl in required_columns.items():
|
|
||||||
if col in existing:
|
|
||||||
continue
|
|
||||||
conn.execute(text(f"ALTER TABLE {SYS_LOGIN_LOG_TABLE} ADD COLUMN {col} {ddl}"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_topic_columns() -> None:
|
|
||||||
required_columns = {
|
|
||||||
"topic_topic": {
|
|
||||||
"name": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"description": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"is_active": "BOOLEAN NOT NULL DEFAULT TRUE",
|
|
||||||
"is_default_fallback": "BOOLEAN NOT NULL DEFAULT FALSE",
|
|
||||||
"routing_json": "TEXT NOT NULL DEFAULT '{}'",
|
|
||||||
"view_schema_json": "TEXT NOT NULL DEFAULT '{}'",
|
|
||||||
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
||||||
"updated_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
||||||
},
|
|
||||||
"topic_item": {
|
|
||||||
"title": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"level": "TEXT NOT NULL DEFAULT 'info'",
|
|
||||||
"tags_json": "TEXT",
|
|
||||||
"view_json": "TEXT",
|
|
||||||
"source": "TEXT NOT NULL DEFAULT 'mcp'",
|
|
||||||
"dedupe_key": "TEXT",
|
|
||||||
"is_read": "BOOLEAN NOT NULL DEFAULT FALSE",
|
|
||||||
"created_at": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
inspector = inspect(engine)
|
|
||||||
with engine.connect() as conn:
|
|
||||||
for table_name, cols in required_columns.items():
|
|
||||||
if not inspector.has_table(table_name):
|
|
||||||
continue
|
|
||||||
existing = {
|
|
||||||
str(row.get("name"))
|
|
||||||
for row in inspector.get_columns(table_name)
|
|
||||||
if row.get("name")
|
|
||||||
}
|
|
||||||
for col, ddl in cols.items():
|
|
||||||
if col in existing:
|
|
||||||
continue
|
|
||||||
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {col} {ddl}"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_platform_indexes() -> None:
|
|
||||||
inspector = inspect(engine)
|
|
||||||
with engine.connect() as conn:
|
|
||||||
if inspector.has_table(BOT_ACTIVITY_EVENT_TABLE):
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
f"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_bot_id_request_present
|
|
||||||
ON {BOT_ACTIVITY_EVENT_TABLE} (bot_id)
|
|
||||||
WHERE request_id IS NOT NULL AND request_id <> ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Fall back silently when the current database dialect does not support partial indexes.
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
f"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_bot_id
|
|
||||||
ON {BOT_ACTIVITY_EVENT_TABLE} (bot_id)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if inspector.has_table(BOT_REQUEST_USAGE_TABLE):
|
REQUIRED_SYS_SETTING_KEYS = (
|
||||||
conn.execute(
|
"page_size",
|
||||||
text(
|
"chat_pull_page_size",
|
||||||
f"""
|
"command_auto_unlock_seconds",
|
||||||
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_started_at_bot_id
|
"auth_token_ttl_hours",
|
||||||
ON {BOT_REQUEST_USAGE_TABLE} (started_at, bot_id)
|
"auth_token_max_active",
|
||||||
"""
|
"upload_max_mb",
|
||||||
|
"allowed_attachment_extensions",
|
||||||
|
"workspace_download_extensions",
|
||||||
|
"speech_enabled",
|
||||||
|
"activity_event_retention_days",
|
||||||
)
|
)
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def align_postgres_sequences() -> None:
|
def _validate_required_tables() -> None:
|
||||||
if engine.dialect.name != "postgresql":
|
inspector = inspect(engine)
|
||||||
return
|
missing = [table_name for table_name in REQUIRED_TABLES if not inspector.has_table(table_name)]
|
||||||
sequence_targets = [
|
if missing:
|
||||||
(SYS_LOGIN_LOG_TABLE, "id"),
|
raise RuntimeError(
|
||||||
(BOT_MESSAGE_TABLE, "id"),
|
"Database schema is not initialized. "
|
||||||
(BOT_REQUEST_USAGE_TABLE, "id"),
|
f"Missing tables: {', '.join(missing)}. "
|
||||||
(BOT_ACTIVITY_EVENT_TABLE, "id"),
|
"Run scripts/init-full-db.sh or apply scripts/sql/create-tables.sql before starting the backend."
|
||||||
("skill_market_item", "id"),
|
)
|
||||||
("bot_skill_install", "id"),
|
|
||||||
]
|
|
||||||
|
def _validate_required_sys_settings() -> None:
|
||||||
|
placeholders = ", ".join(f":k{i}" for i, _ in enumerate(REQUIRED_SYS_SETTING_KEYS))
|
||||||
|
params = {f"k{i}": key for i, key in enumerate(REQUIRED_SYS_SETTING_KEYS)}
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
for table_name, column_name in sequence_targets:
|
rows = conn.execute(
|
||||||
seq_name = conn.execute(
|
text(f'SELECT key FROM "{SYS_SETTING_TABLE}" WHERE key IN ({placeholders})'),
|
||||||
text("SELECT pg_get_serial_sequence(:table_name, :column_name)"),
|
params,
|
||||||
{"table_name": table_name, "column_name": column_name},
|
).scalars().all()
|
||||||
).scalar()
|
present = {str(row or "").strip() for row in rows if str(row or "").strip()}
|
||||||
if not seq_name:
|
missing = [key for key in REQUIRED_SYS_SETTING_KEYS if key not in present]
|
||||||
continue
|
if missing:
|
||||||
max_id = conn.execute(
|
raise RuntimeError(
|
||||||
text(f'SELECT COALESCE(MAX("{column_name}"), 0) FROM "{table_name}"')
|
"Database seed data is not initialized. "
|
||||||
).scalar()
|
f"Missing sys_setting keys: {', '.join(missing)}. "
|
||||||
max_id = int(max_id or 0)
|
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
||||||
conn.execute(
|
|
||||||
text("SELECT setval(:seq_name, :next_value, :is_called)"),
|
|
||||||
{
|
|
||||||
"seq_name": seq_name,
|
|
||||||
"next_value": max_id if max_id > 0 else 1,
|
|
||||||
"is_called": max_id > 0,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def init_database() -> None:
|
def init_database() -> None:
|
||||||
lock_conn = _acquire_migration_lock()
|
with engine.connect() as conn:
|
||||||
try:
|
conn.execute(text("SELECT 1"))
|
||||||
_migrate_auth_login_log_table()
|
_validate_required_tables()
|
||||||
SQLModel.metadata.create_all(engine)
|
_validate_required_sys_settings()
|
||||||
_ensure_auth_login_log_columns()
|
|
||||||
_ensure_sys_setting_columns()
|
|
||||||
_ensure_bot_request_usage_columns()
|
|
||||||
_ensure_botinstance_columns()
|
|
||||||
_ensure_topic_columns()
|
|
||||||
_ensure_platform_indexes()
|
|
||||||
align_postgres_sequences()
|
|
||||||
finally:
|
|
||||||
_release_migration_lock(lock_conn)
|
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
|
|
|
||||||
|
|
@ -117,11 +117,8 @@ DATA_ROOT: Final[str] = _normalize_dir_path(os.getenv("DATA_ROOT", str(PROJECT_R
|
||||||
BOTS_WORKSPACE_ROOT: Final[str] = _normalize_dir_path(
|
BOTS_WORKSPACE_ROOT: Final[str] = _normalize_dir_path(
|
||||||
os.getenv("BOTS_WORKSPACE_ROOT", str(PROJECT_ROOT / "workspace" / "bots"))
|
os.getenv("BOTS_WORKSPACE_ROOT", str(PROJECT_ROOT / "workspace" / "bots"))
|
||||||
)
|
)
|
||||||
BUNDLED_DATA_ROOT: Final[Path] = (PROJECT_ROOT / "data").resolve()
|
|
||||||
RUNTIME_DATA_ROOT: Final[Path] = Path(DATA_ROOT).resolve()
|
RUNTIME_DATA_ROOT: Final[Path] = Path(DATA_ROOT).resolve()
|
||||||
BUNDLED_TEMPLATES_ROOT: Final[Path] = (BUNDLED_DATA_ROOT / "templates").resolve()
|
|
||||||
RUNTIME_TEMPLATES_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "templates").resolve()
|
RUNTIME_TEMPLATES_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "templates").resolve()
|
||||||
BUNDLED_SKILLS_ROOT: Final[Path] = (BUNDLED_DATA_ROOT / "skills").resolve()
|
|
||||||
RUNTIME_SKILLS_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "skills").resolve()
|
RUNTIME_SKILLS_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "skills").resolve()
|
||||||
RUNTIME_MODEL_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "model").resolve()
|
RUNTIME_MODEL_ROOT: Final[Path] = (RUNTIME_DATA_ROOT / "model").resolve()
|
||||||
|
|
||||||
|
|
@ -249,5 +246,3 @@ APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False)
|
||||||
|
|
||||||
AGENT_MD_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "agent_md_templates.json"
|
AGENT_MD_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "agent_md_templates.json"
|
||||||
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "topic_presets.json"
|
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "topic_presets.json"
|
||||||
BUNDLED_AGENT_MD_TEMPLATES_FILE: Final[Path] = BUNDLED_TEMPLATES_ROOT / "agent_md_templates.json"
|
|
||||||
BUNDLED_TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = BUNDLED_TEMPLATES_ROOT / "topic_presets.json"
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
-- Topic subsystem schema (SQLite)
|
|
||||||
-- Apply manually before/after backend deployment if needed.
|
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_topic_topic_bot_topic_key
|
|
||||||
ON topic_topic(bot_id, topic_key);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id
|
|
||||||
ON topic_topic(bot_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key
|
|
||||||
ON topic_topic(topic_key);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback
|
|
||||||
ON topic_topic(bot_id, is_default_fallback);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id
|
|
||||||
ON topic_item(bot_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key
|
|
||||||
ON topic_item(topic_key);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_level
|
|
||||||
ON topic_item(level);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_source
|
|
||||||
ON topic_item(source);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_is_read
|
|
||||||
ON topic_item(is_read);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_created_at
|
|
||||||
ON topic_item(created_at);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at
|
|
||||||
ON topic_item(bot_id, topic_key, created_at);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe
|
|
||||||
ON topic_item(bot_id, dedupe_key);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
@ -29,7 +29,6 @@ __all__ = [
|
||||||
"_bot_data_root",
|
"_bot_data_root",
|
||||||
"_clear_bot_dashboard_direct_session",
|
"_clear_bot_dashboard_direct_session",
|
||||||
"_clear_bot_sessions",
|
"_clear_bot_sessions",
|
||||||
"_migrate_bot_resources_store",
|
|
||||||
"_normalize_env_params",
|
"_normalize_env_params",
|
||||||
"_normalize_resource_limits",
|
"_normalize_resource_limits",
|
||||||
"_read_bot_config",
|
"_read_bot_config",
|
||||||
|
|
@ -186,30 +185,21 @@ def write_bot_resource_limits(bot_id: str, cpu_cores: Any, memory_mb: Any, stora
|
||||||
|
|
||||||
|
|
||||||
def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
cpu_raw: Any = None
|
_ = config_data
|
||||||
memory_raw: Any = None
|
path = _resources_json_path(bot_id)
|
||||||
storage_raw: Any = None
|
if not os.path.isfile(path):
|
||||||
|
raise RuntimeError(
|
||||||
data = _read_json_object(_resources_json_path(bot_id))
|
f"Missing bot resource file: {path}. "
|
||||||
if data:
|
"Please restore it or recreate the bot configuration; runtime compatibility fallback has been removed."
|
||||||
cpu_raw = data.get("cpuCores", data.get("cpu_cores"))
|
)
|
||||||
memory_raw = data.get("memoryMB", data.get("memory_mb"))
|
data = _read_json_object(path)
|
||||||
storage_raw = data.get("storageGB", data.get("storage_gb"))
|
if not data:
|
||||||
|
raise RuntimeError(f"Invalid bot resource file: {path}.")
|
||||||
if cpu_raw is None or memory_raw is None or storage_raw is None:
|
return _normalize_resource_limits(
|
||||||
cfg = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id)
|
data.get("cpuCores", data.get("cpu_cores")),
|
||||||
runtime_cfg = cfg.get("runtime")
|
data.get("memoryMB", data.get("memory_mb")),
|
||||||
if isinstance(runtime_cfg, dict):
|
data.get("storageGB", data.get("storage_gb")),
|
||||||
resources_raw = runtime_cfg.get("resources")
|
)
|
||||||
if isinstance(resources_raw, dict):
|
|
||||||
if cpu_raw is None:
|
|
||||||
cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores"))
|
|
||||||
if memory_raw is None:
|
|
||||||
memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb"))
|
|
||||||
if storage_raw is None:
|
|
||||||
storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb"))
|
|
||||||
|
|
||||||
return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw)
|
|
||||||
|
|
||||||
|
|
||||||
def get_bot_resource_limits(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def get_bot_resource_limits(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
|
@ -230,31 +220,6 @@ def get_bot_workspace_snapshot(bot_id: str, config_data: Optional[Dict[str, Any]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _migrate_bot_resources_store(bot_id: str) -> None:
|
|
||||||
config_data = _read_bot_config(bot_id)
|
|
||||||
runtime_cfg = config_data.get("runtime")
|
|
||||||
resources_raw: Dict[str, Any] = {}
|
|
||||||
if isinstance(runtime_cfg, dict):
|
|
||||||
legacy_raw = runtime_cfg.get("resources")
|
|
||||||
if isinstance(legacy_raw, dict):
|
|
||||||
resources_raw = legacy_raw
|
|
||||||
|
|
||||||
path = _resources_json_path(bot_id)
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
_write_bot_resources(
|
|
||||||
bot_id,
|
|
||||||
resources_raw.get("cpuCores", resources_raw.get("cpu_cores")),
|
|
||||||
resources_raw.get("memoryMB", resources_raw.get("memory_mb")),
|
|
||||||
resources_raw.get("storageGB", resources_raw.get("storage_gb")),
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg:
|
|
||||||
runtime_cfg.pop("resources", None)
|
|
||||||
if not runtime_cfg:
|
|
||||||
config_data.pop("runtime", None)
|
|
||||||
_write_bot_config(bot_id, config_data)
|
|
||||||
|
|
||||||
|
|
||||||
def _env_store_path(bot_id: str) -> str:
|
def _env_store_path(bot_id: str) -> str:
|
||||||
return os.path.join(_bot_data_root(bot_id), "env.json")
|
return os.path.join(_bot_data_root(bot_id), "env.json")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,206 +1,42 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Dict
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from core.settings import (
|
from core.settings import (
|
||||||
AGENT_MD_TEMPLATES_FILE,
|
AGENT_MD_TEMPLATES_FILE,
|
||||||
BUNDLED_AGENT_MD_TEMPLATES_FILE,
|
|
||||||
BUNDLED_SKILLS_ROOT,
|
|
||||||
BUNDLED_TOPIC_PRESETS_TEMPLATES_FILE,
|
|
||||||
DATA_ROOT,
|
DATA_ROOT,
|
||||||
RUNTIME_MODEL_ROOT,
|
|
||||||
RUNTIME_SKILLS_ROOT,
|
RUNTIME_SKILLS_ROOT,
|
||||||
RUNTIME_TEMPLATES_ROOT,
|
RUNTIME_TEMPLATES_ROOT,
|
||||||
TOPIC_PRESETS_TEMPLATES_FILE,
|
TOPIC_PRESETS_TEMPLATES_FILE,
|
||||||
)
|
)
|
||||||
from core.utils import (
|
|
||||||
_is_ignored_skill_zip_top_level,
|
|
||||||
_is_valid_top_level_skill_name,
|
def _require_dir(path: Path, *, label: str) -> str:
|
||||||
_read_description_from_text,
|
resolved = path.resolve()
|
||||||
_sanitize_skill_market_key,
|
if not resolved.exists() or not resolved.is_dir():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Missing required {label} directory: {resolved}. "
|
||||||
|
"Please mount the project-root data directory to /app/data before starting the backend."
|
||||||
)
|
)
|
||||||
from models.skill import SkillMarketItem
|
return str(resolved)
|
||||||
|
|
||||||
|
|
||||||
def _copy_if_missing(src: Path, dst: Path) -> bool:
|
def _require_file(path: Path, *, label: str) -> str:
|
||||||
if not src.exists() or not src.is_file():
|
resolved = path.resolve()
|
||||||
return False
|
if not resolved.exists() or not resolved.is_file():
|
||||||
if src.resolve() == dst.resolve() if dst.exists() else False:
|
raise RuntimeError(
|
||||||
return False
|
f"Missing required {label} file: {resolved}. "
|
||||||
if dst.exists():
|
"Please restore the tracked files under data/templates before starting the backend."
|
||||||
return False
|
)
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
return str(resolved)
|
||||||
shutil.copy2(src, dst)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _copy_if_different(src: Path, dst: Path) -> bool:
|
def validate_runtime_data_assets() -> Dict[str, str]:
|
||||||
if not src.exists() or not src.is_file():
|
|
||||||
return False
|
|
||||||
if src.resolve() == dst.resolve() if dst.exists() else False:
|
|
||||||
return False
|
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if dst.exists():
|
|
||||||
try:
|
|
||||||
if src.stat().st_size == dst.stat().st_size and src.read_bytes() == dst.read_bytes():
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
shutil.copy2(src, dst)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_bundled_skill_packages() -> List[Path]:
|
|
||||||
if not BUNDLED_SKILLS_ROOT.exists() or not BUNDLED_SKILLS_ROOT.is_dir():
|
|
||||||
return []
|
|
||||||
return sorted(path for path in BUNDLED_SKILLS_ROOT.iterdir() if path.is_file() and path.suffix.lower() == ".zip")
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_runtime_data_assets() -> Dict[str, int]:
|
|
||||||
Path(DATA_ROOT).mkdir(parents=True, exist_ok=True)
|
|
||||||
RUNTIME_TEMPLATES_ROOT.mkdir(parents=True, exist_ok=True)
|
|
||||||
RUNTIME_SKILLS_ROOT.mkdir(parents=True, exist_ok=True)
|
|
||||||
RUNTIME_MODEL_ROOT.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
templates_initialized = 0
|
|
||||||
skills_synchronized = 0
|
|
||||||
|
|
||||||
if _copy_if_missing(BUNDLED_AGENT_MD_TEMPLATES_FILE, AGENT_MD_TEMPLATES_FILE):
|
|
||||||
templates_initialized += 1
|
|
||||||
if _copy_if_missing(BUNDLED_TOPIC_PRESETS_TEMPLATES_FILE, TOPIC_PRESETS_TEMPLATES_FILE):
|
|
||||||
templates_initialized += 1
|
|
||||||
|
|
||||||
for src in _iter_bundled_skill_packages():
|
|
||||||
if _copy_if_different(src, RUNTIME_SKILLS_ROOT / src.name):
|
|
||||||
skills_synchronized += 1
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"templates_initialized": templates_initialized,
|
"data_root": _require_dir(Path(DATA_ROOT), label="data"),
|
||||||
"skills_synchronized": skills_synchronized,
|
"templates_root": _require_dir(RUNTIME_TEMPLATES_ROOT, label="templates"),
|
||||||
|
"skills_root": _require_dir(RUNTIME_SKILLS_ROOT, label="skills"),
|
||||||
|
"agent_md_templates_file": _require_file(AGENT_MD_TEMPLATES_FILE, label="agent templates"),
|
||||||
|
"topic_presets_file": _require_file(TOPIC_PRESETS_TEMPLATES_FILE, label="topic presets"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_skill_zip_summary(zip_path: Path) -> Dict[str, Any]:
|
|
||||||
entry_names: List[str] = []
|
|
||||||
description = ""
|
|
||||||
with zipfile.ZipFile(zip_path) as archive:
|
|
||||||
members = archive.infolist()
|
|
||||||
file_members = [member for member in members if not member.is_dir()]
|
|
||||||
for member in file_members:
|
|
||||||
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
||||||
if not raw_name:
|
|
||||||
continue
|
|
||||||
first = raw_name.split("/", 1)[0].strip()
|
|
||||||
if _is_ignored_skill_zip_top_level(first):
|
|
||||||
continue
|
|
||||||
if _is_valid_top_level_skill_name(first) and first not in entry_names:
|
|
||||||
entry_names.append(first)
|
|
||||||
|
|
||||||
candidates = sorted(
|
|
||||||
[
|
|
||||||
str(member.filename or "").replace("\\", "/").lstrip("/")
|
|
||||||
for member in file_members
|
|
||||||
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
||||||
in {"skill.md", "readme.md"}
|
|
||||||
],
|
|
||||||
key=lambda value: (value.count("/"), value.lower()),
|
|
||||||
)
|
|
||||||
for candidate in candidates:
|
|
||||||
try:
|
|
||||||
with archive.open(candidate, "r") as file:
|
|
||||||
preview = file.read(4096).decode("utf-8", errors="ignore")
|
|
||||||
description = _read_description_from_text(preview)
|
|
||||||
if description:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return {
|
|
||||||
"entry_names": entry_names,
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _default_display_name(stem: str) -> str:
|
|
||||||
chunks = [chunk for chunk in re.split(r"[-_]+", str(stem or "").strip()) if chunk]
|
|
||||||
if not chunks:
|
|
||||||
return "Skill"
|
|
||||||
return " ".join(chunk.upper() if chunk.isupper() else chunk.capitalize() for chunk in chunks)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_unique_skill_key(existing_keys: set[str], preferred_key: str) -> str:
|
|
||||||
base_key = _sanitize_skill_market_key(preferred_key) or "skill"
|
|
||||||
candidate = base_key
|
|
||||||
counter = 2
|
|
||||||
while candidate in existing_keys:
|
|
||||||
candidate = f"{base_key}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
existing_keys.add(candidate)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_skill_market_items(session: Session) -> Dict[str, List[str]]:
|
|
||||||
report: Dict[str, List[str]] = {"created": [], "updated": []}
|
|
||||||
default_packages = _iter_bundled_skill_packages()
|
|
||||||
if not default_packages:
|
|
||||||
return report
|
|
||||||
|
|
||||||
rows = session.exec(select(SkillMarketItem)).all()
|
|
||||||
existing_by_zip = {str(row.zip_filename or "").strip(): row for row in rows if str(row.zip_filename or "").strip()}
|
|
||||||
existing_keys = {str(row.skill_key or "").strip() for row in rows if str(row.skill_key or "").strip()}
|
|
||||||
|
|
||||||
for bundled_path in default_packages:
|
|
||||||
runtime_path = RUNTIME_SKILLS_ROOT / bundled_path.name
|
|
||||||
source_path = runtime_path if runtime_path.exists() else bundled_path
|
|
||||||
try:
|
|
||||||
summary = _extract_skill_zip_summary(source_path)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
zip_filename = bundled_path.name
|
|
||||||
entry_names_json = json.dumps(summary["entry_names"], ensure_ascii=False)
|
|
||||||
display_name = _default_display_name((summary["entry_names"] or [bundled_path.stem])[0])
|
|
||||||
zip_size_bytes = int(source_path.stat().st_size) if source_path.exists() else 0
|
|
||||||
row = existing_by_zip.get(zip_filename)
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
row = SkillMarketItem(
|
|
||||||
skill_key=_resolve_unique_skill_key(existing_keys, bundled_path.stem),
|
|
||||||
display_name=display_name,
|
|
||||||
description=str(summary["description"] or "").strip(),
|
|
||||||
zip_filename=zip_filename,
|
|
||||||
zip_size_bytes=zip_size_bytes,
|
|
||||||
entry_names_json=entry_names_json,
|
|
||||||
)
|
|
||||||
session.add(row)
|
|
||||||
existing_by_zip[zip_filename] = row
|
|
||||||
report["created"].append(zip_filename)
|
|
||||||
continue
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if int(row.zip_size_bytes or 0) != zip_size_bytes:
|
|
||||||
row.zip_size_bytes = zip_size_bytes
|
|
||||||
changed = True
|
|
||||||
if str(row.entry_names_json or "") != entry_names_json:
|
|
||||||
row.entry_names_json = entry_names_json
|
|
||||||
changed = True
|
|
||||||
if not str(row.display_name or "").strip():
|
|
||||||
row.display_name = display_name
|
|
||||||
changed = True
|
|
||||||
if not str(row.description or "").strip() and str(summary["description"] or "").strip():
|
|
||||||
row.description = str(summary["description"] or "").strip()
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
report["updated"].append(zip_filename)
|
|
||||||
|
|
||||||
if report["created"] or report["updated"]:
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return report
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ from services.platform_settings_service import (
|
||||||
SETTING_KEYS,
|
SETTING_KEYS,
|
||||||
SYSTEM_SETTING_DEFINITIONS,
|
SYSTEM_SETTING_DEFINITIONS,
|
||||||
create_or_update_system_setting,
|
create_or_update_system_setting,
|
||||||
default_platform_settings,
|
|
||||||
delete_system_setting,
|
delete_system_setting,
|
||||||
ensure_default_system_settings,
|
|
||||||
get_activity_event_retention_days,
|
get_activity_event_retention_days,
|
||||||
get_auth_token_max_active,
|
get_auth_token_max_active,
|
||||||
get_auth_token_ttl_hours,
|
get_auth_token_ttl_hours,
|
||||||
|
|
@ -29,6 +27,7 @@ from services.platform_settings_service import (
|
||||||
get_workspace_download_extensions,
|
get_workspace_download_extensions,
|
||||||
list_system_settings,
|
list_system_settings,
|
||||||
save_platform_settings,
|
save_platform_settings,
|
||||||
|
validate_required_system_settings,
|
||||||
)
|
)
|
||||||
from services.platform_usage_service import (
|
from services.platform_usage_service import (
|
||||||
bind_usage_message,
|
bind_usage_message,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
@ -34,19 +33,6 @@ SETTING_KEYS = (
|
||||||
"speech_enabled",
|
"speech_enabled",
|
||||||
)
|
)
|
||||||
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
|
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
|
||||||
DEPRECATED_SETTING_KEYS = {
|
|
||||||
"loading_page",
|
|
||||||
"speech_max_audio_seconds",
|
|
||||||
"speech_default_language",
|
|
||||||
"speech_force_simplified",
|
|
||||||
"speech_audio_preprocess",
|
|
||||||
"speech_audio_filter",
|
|
||||||
"speech_initial_prompt",
|
|
||||||
"sys_auth_token_ttl_days",
|
|
||||||
"auth_token_ttl_days",
|
|
||||||
"panel_session_ttl_days",
|
|
||||||
"bot_session_ttl_days",
|
|
||||||
}
|
|
||||||
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"page_size": {
|
"page_size": {
|
||||||
"name": "分页大小",
|
"name": "分页大小",
|
||||||
|
|
@ -144,15 +130,6 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
return datetime.utcnow()
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_activity_event_retention_days(raw: Any) -> int:
|
|
||||||
try:
|
|
||||||
value = int(raw)
|
|
||||||
except Exception:
|
|
||||||
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
|
||||||
return max(1, min(3650, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_extension(raw: Any) -> str:
|
def _normalize_extension(raw: Any) -> str:
|
||||||
text = str(raw or "").strip().lower()
|
text = str(raw or "").strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
|
|
@ -177,77 +154,6 @@ def _normalize_extension_list(rows: Any) -> List[str]:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
value = int(str(raw).strip())
|
|
||||||
except Exception:
|
|
||||||
value = default
|
|
||||||
return max(min_value, min(max_value, value))
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_env_bool(name: str, default: bool) -> bool:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return default
|
|
||||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_env_extensions(name: str, default: List[str]) -> List[str]:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None:
|
|
||||||
return list(default)
|
|
||||||
source = re.split(r"[,;\s]+", str(raw))
|
|
||||||
normalized: List[str] = []
|
|
||||||
for item in source:
|
|
||||||
ext = _normalize_extension(item)
|
|
||||||
if ext and ext not in normalized:
|
|
||||||
normalized.append(ext)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_platform_setting_values() -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100),
|
|
||||||
"chat_pull_page_size": _legacy_env_int(
|
|
||||||
"CHAT_PULL_PAGE_SIZE",
|
|
||||||
DEFAULT_CHAT_PULL_PAGE_SIZE,
|
|
||||||
10,
|
|
||||||
500,
|
|
||||||
),
|
|
||||||
"command_auto_unlock_seconds": _legacy_env_int(
|
|
||||||
"COMMAND_AUTO_UNLOCK_SECONDS",
|
|
||||||
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
|
|
||||||
1,
|
|
||||||
600,
|
|
||||||
),
|
|
||||||
"auth_token_ttl_hours": _legacy_env_int(
|
|
||||||
"AUTH_TOKEN_TTL_HOURS",
|
|
||||||
DEFAULT_AUTH_TOKEN_TTL_HOURS,
|
|
||||||
1,
|
|
||||||
720,
|
|
||||||
),
|
|
||||||
"auth_token_max_active": _legacy_env_int(
|
|
||||||
"AUTH_TOKEN_MAX_ACTIVE",
|
|
||||||
DEFAULT_AUTH_TOKEN_MAX_ACTIVE,
|
|
||||||
1,
|
|
||||||
20,
|
|
||||||
),
|
|
||||||
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
|
|
||||||
"allowed_attachment_extensions": _legacy_env_extensions(
|
|
||||||
"ALLOWED_ATTACHMENT_EXTENSIONS",
|
|
||||||
list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS),
|
|
||||||
),
|
|
||||||
"workspace_download_extensions": _legacy_env_extensions(
|
|
||||||
"WORKSPACE_DOWNLOAD_EXTENSIONS",
|
|
||||||
list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS),
|
|
||||||
),
|
|
||||||
"speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_setting_key(raw: Any) -> str:
|
def _normalize_setting_key(raw: Any) -> str:
|
||||||
text = str(raw or "").strip()
|
text = str(raw or "").strip()
|
||||||
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower()
|
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower()
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from services.platform_settings_core import (
|
||||||
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS,
|
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS,
|
||||||
SETTING_KEYS,
|
SETTING_KEYS,
|
||||||
SYSTEM_SETTING_DEFINITIONS,
|
SYSTEM_SETTING_DEFINITIONS,
|
||||||
_bootstrap_platform_setting_values,
|
|
||||||
_normalize_extension_list,
|
_normalize_extension_list,
|
||||||
_read_setting_value,
|
_read_setting_value,
|
||||||
_upsert_setting_row,
|
_upsert_setting_row,
|
||||||
|
|
@ -29,57 +28,42 @@ from services.platform_settings_core import (
|
||||||
from services.platform_system_settings_service import (
|
from services.platform_system_settings_service import (
|
||||||
create_or_update_system_setting,
|
create_or_update_system_setting,
|
||||||
delete_system_setting,
|
delete_system_setting,
|
||||||
ensure_default_system_settings,
|
|
||||||
get_activity_event_retention_days,
|
get_activity_event_retention_days,
|
||||||
list_system_settings,
|
list_system_settings,
|
||||||
)
|
validate_required_system_settings,
|
||||||
|
|
||||||
|
|
||||||
def default_platform_settings() -> PlatformSettingsPayload:
|
|
||||||
bootstrap = _bootstrap_platform_setting_values()
|
|
||||||
return PlatformSettingsPayload(
|
|
||||||
page_size=int(bootstrap["page_size"]),
|
|
||||||
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
|
|
||||||
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
|
|
||||||
auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]),
|
|
||||||
auth_token_max_active=int(bootstrap["auth_token_max_active"]),
|
|
||||||
upload_max_mb=int(bootstrap["upload_max_mb"]),
|
|
||||||
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
|
|
||||||
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
|
|
||||||
speech_enabled=bool(bootstrap["speech_enabled"]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_platform_settings(session: Session) -> PlatformSettingsPayload:
|
def get_platform_settings(session: Session) -> PlatformSettingsPayload:
|
||||||
defaults = default_platform_settings()
|
validate_required_system_settings(session)
|
||||||
ensure_default_system_settings(session)
|
|
||||||
rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all()
|
rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all()
|
||||||
data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows}
|
data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows}
|
||||||
|
missing = [key for key in SETTING_KEYS if key not in data]
|
||||||
merged = defaults.model_dump()
|
if missing:
|
||||||
merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"])))
|
raise RuntimeError(
|
||||||
merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"])))
|
"Database seed data is not initialized. "
|
||||||
merged["command_auto_unlock_seconds"] = max(
|
f"Missing sys_setting keys: {', '.join(missing)}. "
|
||||||
1,
|
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
||||||
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])),
|
|
||||||
)
|
)
|
||||||
merged["auth_token_ttl_hours"] = max(
|
try:
|
||||||
1,
|
return PlatformSettingsPayload.model_validate(
|
||||||
min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])),
|
{
|
||||||
|
"page_size": max(1, min(100, int(data["page_size"]))),
|
||||||
|
"chat_pull_page_size": max(10, min(500, int(data["chat_pull_page_size"]))),
|
||||||
|
"command_auto_unlock_seconds": max(1, min(600, int(data["command_auto_unlock_seconds"]))),
|
||||||
|
"auth_token_ttl_hours": max(1, min(720, int(data["auth_token_ttl_hours"]))),
|
||||||
|
"auth_token_max_active": max(1, min(20, int(data["auth_token_max_active"]))),
|
||||||
|
"upload_max_mb": int(data["upload_max_mb"]),
|
||||||
|
"allowed_attachment_extensions": _normalize_extension_list(data["allowed_attachment_extensions"]),
|
||||||
|
"workspace_download_extensions": _normalize_extension_list(data["workspace_download_extensions"]),
|
||||||
|
"speech_enabled": bool(data["speech_enabled"]),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
merged["auth_token_max_active"] = max(
|
except Exception as exc:
|
||||||
1,
|
raise RuntimeError(
|
||||||
min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])),
|
"sys_setting contains invalid platform configuration values. "
|
||||||
)
|
"Fix the rows manually or reapply scripts/sql/init-data.sql."
|
||||||
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
|
) from exc
|
||||||
merged["allowed_attachment_extensions"] = _normalize_extension_list(
|
|
||||||
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
|
|
||||||
)
|
|
||||||
merged["workspace_download_extensions"] = _normalize_extension_list(
|
|
||||||
data.get("workspace_download_extensions", merged["workspace_download_extensions"])
|
|
||||||
)
|
|
||||||
merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"]))
|
|
||||||
return PlatformSettingsPayload.model_validate(merged)
|
|
||||||
|
|
||||||
|
|
||||||
def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload:
|
def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
@ -7,128 +6,35 @@ from models.platform import PlatformSetting
|
||||||
from schemas.platform import SystemSettingPayload
|
from schemas.platform import SystemSettingPayload
|
||||||
from services.platform_settings_core import (
|
from services.platform_settings_core import (
|
||||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||||
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
|
|
||||||
DEPRECATED_SETTING_KEYS,
|
|
||||||
PROTECTED_SETTING_KEYS,
|
PROTECTED_SETTING_KEYS,
|
||||||
SETTING_KEYS,
|
|
||||||
SYSTEM_SETTING_DEFINITIONS,
|
SYSTEM_SETTING_DEFINITIONS,
|
||||||
_bootstrap_platform_setting_values,
|
|
||||||
_normalize_activity_event_retention_days,
|
|
||||||
_normalize_setting_key,
|
_normalize_setting_key,
|
||||||
_read_setting_value,
|
_read_setting_value,
|
||||||
_setting_item_from_row,
|
_setting_item_from_row,
|
||||||
_upsert_setting_row,
|
_upsert_setting_row,
|
||||||
_utcnow,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REQUIRED_SYSTEM_SETTING_KEYS = tuple(SYSTEM_SETTING_DEFINITIONS.keys())
|
||||||
def _coerce_auth_ttl_hours_from_legacy(value: Any) -> int:
|
|
||||||
try:
|
|
||||||
normalized = int(value)
|
|
||||||
except Exception:
|
|
||||||
normalized = 0
|
|
||||||
return max(1, min(720, normalized * 24))
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_system_settings(session: Session) -> None:
|
def validate_required_system_settings(session: Session) -> None:
|
||||||
bootstrap_values = _bootstrap_platform_setting_values()
|
stmt = select(PlatformSetting.key).where(PlatformSetting.key.in_(REQUIRED_SYSTEM_SETTING_KEYS))
|
||||||
legacy_row = session.get(PlatformSetting, "global")
|
present = {
|
||||||
if legacy_row is not None:
|
str(key or "").strip()
|
||||||
try:
|
for key in session.exec(stmt).all()
|
||||||
legacy_data = _read_setting_value(legacy_row)
|
if str(key or "").strip()
|
||||||
except Exception:
|
}
|
||||||
legacy_data = {}
|
missing = [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present]
|
||||||
if isinstance(legacy_data, dict):
|
if missing:
|
||||||
for key in SETTING_KEYS:
|
raise RuntimeError(
|
||||||
meta = SYSTEM_SETTING_DEFINITIONS[key]
|
"Database seed data is not initialized. "
|
||||||
_upsert_setting_row(
|
f"Missing sys_setting keys: {', '.join(missing)}. "
|
||||||
session,
|
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
||||||
key,
|
|
||||||
name=str(meta["name"]),
|
|
||||||
category=str(meta["category"]),
|
|
||||||
description=str(meta["description"]),
|
|
||||||
value_type=str(meta["value_type"]),
|
|
||||||
value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])),
|
|
||||||
is_public=bool(meta["is_public"]),
|
|
||||||
sort_order=int(meta["sort_order"]),
|
|
||||||
)
|
)
|
||||||
session.delete(legacy_row)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
legacy_auth_ttl_hours = None
|
|
||||||
dirty = False
|
|
||||||
for key in DEPRECATED_SETTING_KEYS:
|
|
||||||
legacy_row = session.get(PlatformSetting, key)
|
|
||||||
if legacy_row is not None:
|
|
||||||
if key in {"sys_auth_token_ttl_days", "auth_token_ttl_days"} and legacy_auth_ttl_hours is None:
|
|
||||||
try:
|
|
||||||
legacy_auth_ttl_hours = _coerce_auth_ttl_hours_from_legacy(_read_setting_value(legacy_row))
|
|
||||||
except Exception:
|
|
||||||
legacy_auth_ttl_hours = None
|
|
||||||
session.delete(legacy_row)
|
|
||||||
dirty = True
|
|
||||||
|
|
||||||
for key, meta in SYSTEM_SETTING_DEFINITIONS.items():
|
|
||||||
row = session.get(PlatformSetting, key)
|
|
||||||
default_value = bootstrap_values.get(key, meta["value"])
|
|
||||||
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
|
|
||||||
default_value = legacy_auth_ttl_hours
|
|
||||||
if row is None:
|
|
||||||
_upsert_setting_row(
|
|
||||||
session,
|
|
||||||
key,
|
|
||||||
name=str(meta["name"]),
|
|
||||||
category=str(meta["category"]),
|
|
||||||
description=str(meta["description"]),
|
|
||||||
value_type=str(meta["value_type"]),
|
|
||||||
value=default_value,
|
|
||||||
is_public=bool(meta["is_public"]),
|
|
||||||
sort_order=int(meta["sort_order"]),
|
|
||||||
)
|
|
||||||
dirty = True
|
|
||||||
continue
|
|
||||||
changed = False
|
|
||||||
if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None:
|
|
||||||
try:
|
|
||||||
current_value = int(_read_setting_value(row))
|
|
||||||
except Exception:
|
|
||||||
current_value = int(meta["value"])
|
|
||||||
if current_value == int(meta["value"]) and legacy_auth_ttl_hours != current_value:
|
|
||||||
row.value_type = str(meta["value_type"])
|
|
||||||
row.value_json = json.dumps(legacy_auth_ttl_hours, ensure_ascii=False)
|
|
||||||
changed = True
|
|
||||||
for field in ("name", "category", "description", "value_type"):
|
|
||||||
value = str(meta[field])
|
|
||||||
if key in PROTECTED_SETTING_KEYS:
|
|
||||||
if getattr(row, field) != value:
|
|
||||||
setattr(row, field, value)
|
|
||||||
changed = True
|
|
||||||
elif not getattr(row, field):
|
|
||||||
setattr(row, field, value)
|
|
||||||
changed = True
|
|
||||||
if key in PROTECTED_SETTING_KEYS:
|
|
||||||
if int(getattr(row, "sort_order", 100) or 100) != int(meta["sort_order"]):
|
|
||||||
row.sort_order = int(meta["sort_order"])
|
|
||||||
changed = True
|
|
||||||
if bool(getattr(row, "is_public", False)) != bool(meta["is_public"]):
|
|
||||||
row.is_public = bool(meta["is_public"])
|
|
||||||
changed = True
|
|
||||||
elif getattr(row, "sort_order", None) is None:
|
|
||||||
row.sort_order = int(meta["sort_order"])
|
|
||||||
changed = True
|
|
||||||
if key not in PROTECTED_SETTING_KEYS and getattr(row, "is_public", None) is None:
|
|
||||||
row.is_public = bool(meta["is_public"])
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
row.updated_at = _utcnow()
|
|
||||||
session.add(row)
|
|
||||||
dirty = True
|
|
||||||
if dirty:
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]:
|
def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]:
|
||||||
ensure_default_system_settings(session)
|
validate_required_system_settings(session)
|
||||||
stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc())
|
stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc())
|
||||||
rows = session.exec(stmt).all()
|
rows = session.exec(stmt).all()
|
||||||
keyword = str(search or "").strip().lower()
|
keyword = str(search or "").strip().lower()
|
||||||
|
|
@ -146,7 +52,6 @@ def list_system_settings(session: Session, search: str = "") -> List[Dict[str, A
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]:
|
def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]:
|
||||||
ensure_default_system_settings(session)
|
|
||||||
normalized_key = _normalize_setting_key(payload.key)
|
normalized_key = _normalize_setting_key(payload.key)
|
||||||
definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {})
|
definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {})
|
||||||
row = _upsert_setting_row(
|
row = _upsert_setting_row(
|
||||||
|
|
@ -181,11 +86,19 @@ def delete_system_setting(session: Session, key: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def get_activity_event_retention_days(session: Session) -> int:
|
def get_activity_event_retention_days(session: Session) -> int:
|
||||||
|
validate_required_system_settings(session)
|
||||||
row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY)
|
row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY)
|
||||||
if row is None:
|
if row is None:
|
||||||
return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
raise RuntimeError(
|
||||||
|
"Database seed data is not initialized. "
|
||||||
|
f"Missing sys_setting key: {ACTIVITY_EVENT_RETENTION_SETTING_KEY}. "
|
||||||
|
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
value = _read_setting_value(row)
|
value = int(_read_setting_value(row))
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS
|
raise RuntimeError(
|
||||||
return _normalize_activity_event_retention_days(value)
|
f"sys_setting value is invalid for key: {ACTIVITY_EVENT_RETENTION_SETTING_KEY}. "
|
||||||
|
"Fix the row manually or reapply scripts/sql/init-data.sql."
|
||||||
|
) from exc
|
||||||
|
return max(1, min(3650, value))
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,30 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from core.settings import (
|
from core.settings import AGENT_MD_TEMPLATES_FILE, TOPIC_PRESETS_TEMPLATES_FILE
|
||||||
AGENT_MD_TEMPLATES_FILE,
|
|
||||||
BUNDLED_AGENT_MD_TEMPLATES_FILE,
|
|
||||||
BUNDLED_TOPIC_PRESETS_TEMPLATES_FILE,
|
|
||||||
TOPIC_PRESETS_TEMPLATES_FILE,
|
|
||||||
)
|
|
||||||
|
|
||||||
TEMPLATE_KEYS = ("agents_md", "soul_md", "user_md", "tools_md", "identity_md")
|
TEMPLATE_KEYS = ("agents_md", "soul_md", "user_md", "tools_md", "identity_md")
|
||||||
|
|
||||||
|
|
||||||
def _load_json_object(path: str, fallback_path: str = "") -> Dict[str, Any]:
|
def _load_json_object(path: Path, *, label: str) -> Dict[str, Any]:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
for candidate in [path, fallback_path]:
|
target = Path(path).resolve()
|
||||||
candidate = str(candidate or "").strip()
|
if not target.is_file():
|
||||||
if not candidate:
|
raise RuntimeError(
|
||||||
continue
|
f"Missing required {label} file: {target}. "
|
||||||
|
"Please restore the tracked files under data/templates before starting the backend."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with open(candidate, "r", encoding="utf-8") as file:
|
with target.open("r", encoding="utf-8") as file:
|
||||||
data = json.load(file)
|
data = json.load(file)
|
||||||
if isinstance(data, dict):
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Invalid JSON in {label} file: {target}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise RuntimeError(f"{label} file must contain a JSON object: {target}")
|
||||||
return data
|
return data
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_md_text(value: Any) -> str:
|
def _normalize_md_text(value: Any) -> str:
|
||||||
|
|
@ -45,12 +43,12 @@ def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
||||||
|
|
||||||
|
|
||||||
def get_agent_md_templates() -> Dict[str, str]:
|
def get_agent_md_templates() -> Dict[str, str]:
|
||||||
raw = _load_json_object(str(AGENT_MD_TEMPLATES_FILE), str(BUNDLED_AGENT_MD_TEMPLATES_FILE))
|
raw = _load_json_object(AGENT_MD_TEMPLATES_FILE, label="agent templates")
|
||||||
return {key: _normalize_md_text(raw.get(key)) for key in TEMPLATE_KEYS}
|
return {key: _normalize_md_text(raw.get(key)) for key in TEMPLATE_KEYS}
|
||||||
|
|
||||||
|
|
||||||
def get_topic_presets() -> Dict[str, Any]:
|
def get_topic_presets() -> Dict[str, Any]:
|
||||||
raw = _load_json_object(str(TOPIC_PRESETS_TEMPLATES_FILE), str(BUNDLED_TOPIC_PRESETS_TEMPLATES_FILE))
|
raw = _load_json_object(TOPIC_PRESETS_TEMPLATES_FILE, label="topic presets")
|
||||||
presets = raw.get("presets")
|
presets = raw.get("presets")
|
||||||
if not isinstance(presets, list):
|
if not isinstance(presets, list):
|
||||||
return {"presets": []}
|
return {"presets": []}
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,9 @@ Dashboard 渠道对话历史(用于会话回放与反馈)。
|
||||||
|
|
||||||
## 4. 初始化与迁移策略
|
## 4. 初始化与迁移策略
|
||||||
|
|
||||||
服务启动时(`backend/core/database.py`):
|
数据库初始化改为离线显式执行:
|
||||||
|
|
||||||
1. 使用 PostgreSQL Advisory Lock 确保多节点部署时的单实例初始化。
|
1. `scripts/sql/create-tables.sql` 负责创建业务表和索引。
|
||||||
2. `SQLModel.metadata.create_all(engine)` 自动创建缺失表。
|
2. `scripts/sql/init-data.sql` 负责初始化 `sys_setting` 和默认 `skill_market_item` 数据。
|
||||||
3. 执行列对齐检查,确保旧表结构平滑升级。
|
3. `scripts/init-full-db.sh` 在完整部署场景下会按顺序执行 PostgreSQL 引导 SQL、建表 SQL、初始化数据 SQL。
|
||||||
4. 自动对齐 PostgreSQL Sequences 以防 ID 冲突。
|
4. 后端启动时(`backend/core/database.py`)只校验必需表和核心 `sys_setting` 是否已经存在;若缺失则直接中止启动,不再做运行时迁移或结构修复。
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
ENV_FILE="${1:-$ROOT_DIR/.env.full}"
|
ENV_FILE="${1:-$ROOT_DIR/.env.full}"
|
||||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
||||||
DATA_DIR="$ROOT_DIR/data"
|
DATA_DIR="$ROOT_DIR/data"
|
||||||
|
INIT_DB_SCRIPT="$ROOT_DIR/scripts/init-full-db.sh"
|
||||||
|
AGENT_TEMPLATES_FILE="$DATA_DIR/templates/agent_md_templates.json"
|
||||||
|
TOPIC_PRESETS_FILE="$DATA_DIR/templates/topic_presets.json"
|
||||||
|
SKILLS_DIR="$DATA_DIR/skills"
|
||||||
|
|
||||||
require_file() {
|
require_file() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
|
|
@ -19,6 +23,19 @@ require_file() {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="$2"
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing directory: $path"
|
||||||
|
if [[ -n "$hint" ]]; then
|
||||||
|
echo "$hint"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
require_env() {
|
require_env() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
if [[ -n "${!name:-}" ]]; then
|
if [[ -n "${!name:-}" ]]; then
|
||||||
|
|
@ -89,6 +106,10 @@ wait_for_health() {
|
||||||
|
|
||||||
require_file "$ENV_FILE" "Create it from: $ROOT_DIR/.env.full.example"
|
require_file "$ENV_FILE" "Create it from: $ROOT_DIR/.env.full.example"
|
||||||
require_file "$COMPOSE_FILE" ""
|
require_file "$COMPOSE_FILE" ""
|
||||||
|
require_file "$INIT_DB_SCRIPT" ""
|
||||||
|
require_file "$AGENT_TEMPLATES_FILE" "Expected tracked template file under project-root data/templates/"
|
||||||
|
require_file "$TOPIC_PRESETS_FILE" "Expected tracked template file under project-root data/templates/"
|
||||||
|
require_dir "$SKILLS_DIR" "Expected tracked skills directory under project-root data/skills/"
|
||||||
|
|
||||||
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
||||||
load_env_var POSTGRES_SUPERUSER postgres
|
load_env_var POSTGRES_SUPERUSER postgres
|
||||||
|
|
@ -125,7 +146,7 @@ wait_for_health "dashboard-nanobot-postgres" 120
|
||||||
wait_for_health "dashboard-nanobot-redis" 60
|
wait_for_health "dashboard-nanobot-redis" 60
|
||||||
|
|
||||||
echo "[deploy-full] initializing application database"
|
echo "[deploy-full] initializing application database"
|
||||||
"$ROOT_DIR/scripts/init-full-db.sh" "$ENV_FILE"
|
"$INIT_DB_SCRIPT" "$ENV_FILE"
|
||||||
|
|
||||||
echo "[deploy-full] starting backend and nginx"
|
echo "[deploy-full] starting backend and nginx"
|
||||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build backend nginx
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build backend nginx
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,81 @@ set -euo pipefail
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
ENV_FILE="${1:-$ROOT_DIR/.env.prod}"
|
ENV_FILE="${1:-$ROOT_DIR/.env.prod}"
|
||||||
DATA_DIR="$ROOT_DIR/data"
|
DATA_DIR="$ROOT_DIR/data"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.prod.yml"
|
||||||
|
AGENT_TEMPLATES_FILE="$DATA_DIR/templates/agent_md_templates.json"
|
||||||
|
TOPIC_PRESETS_FILE="$DATA_DIR/templates/topic_presets.json"
|
||||||
|
SKILLS_DIR="$DATA_DIR/skills"
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="$2"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing file: $path"
|
||||||
|
if [[ -n "$hint" ]]; then
|
||||||
|
echo "$hint"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="$2"
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing directory: $path"
|
||||||
|
if [[ -n "$hint" ]]; then
|
||||||
|
echo "$hint"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ -n "${!name:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing required env: $name"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local line=""
|
||||||
|
local value=""
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%$'\r'}"
|
||||||
|
[[ -z "${line//[[:space:]]/}" ]] && continue
|
||||||
|
[[ "${line#\#}" != "$line" ]] && continue
|
||||||
|
[[ "${line#export }" != "$line" ]] && line="${line#export }"
|
||||||
|
[[ "$line" == "$key="* ]] || continue
|
||||||
|
value="${line#*=}"
|
||||||
|
if [[ "$value" =~ ^\"(.*)\"$ ]]; then
|
||||||
|
value="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$value" =~ ^\'(.*)\'$ ]]; then
|
||||||
|
value="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
printf '%s' "$value"
|
||||||
|
return 0
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_var() {
|
||||||
|
local name="$1"
|
||||||
|
local default_value="${2:-}"
|
||||||
|
local value=""
|
||||||
|
|
||||||
|
value="$(read_env_value "$name" || true)"
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
value="$default_value"
|
||||||
|
fi
|
||||||
|
printf -v "$name" '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
echo "Missing env file: $ENV_FILE"
|
echo "Missing env file: $ENV_FILE"
|
||||||
|
|
@ -11,12 +86,41 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
require_file "$COMPOSE_FILE" ""
|
||||||
|
require_file "$AGENT_TEMPLATES_FILE" "Expected tracked template file under project-root data/templates/"
|
||||||
|
require_file "$TOPIC_PRESETS_FILE" "Expected tracked template file under project-root data/templates/"
|
||||||
|
require_dir "$SKILLS_DIR" "Expected tracked skills directory under project-root data/skills/"
|
||||||
|
|
||||||
|
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
load_env_var DATABASE_URL
|
||||||
|
load_env_var NGINX_PORT 8080
|
||||||
|
load_env_var REDIS_ENABLED false
|
||||||
|
load_env_var REDIS_URL
|
||||||
|
|
||||||
|
require_env HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
require_env DATABASE_URL
|
||||||
|
require_env NGINX_PORT
|
||||||
|
|
||||||
|
if [[ "$DATABASE_URL" != postgresql* ]]; then
|
||||||
|
echo "Unsupported DATABASE_URL for deploy-prod.sh: $DATABASE_URL"
|
||||||
|
echo "deploy-prod.sh now supports external PostgreSQL only."
|
||||||
|
echo "If you need one-click PostgreSQL + Redis deployment, use scripts/deploy-full.sh."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${REDIS_ENABLED,,}" =~ ^(1|true|yes|on)$ ]] && [[ -z "$REDIS_URL" ]]; then
|
||||||
|
echo "Missing required env: REDIS_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[deploy] using env: $ENV_FILE"
|
echo "[deploy] using env: $ENV_FILE"
|
||||||
mkdir -p "$DATA_DIR" "$DATA_DIR/model"
|
mkdir -p "$DATA_DIR" "$DATA_DIR/model" "$HOST_BOTS_WORKSPACE_ROOT"
|
||||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" config -q
|
|
||||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" up -d --build
|
echo "[deploy] expecting external PostgreSQL to be pre-initialized with scripts/sql/create-tables.sql and scripts/sql/init-data.sql"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
echo "[deploy] service status"
|
echo "[deploy] service status"
|
||||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.prod.yml" ps
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
echo "[deploy] done"
|
echo "[deploy] done"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ ENV_FILE="${1:-$ROOT_DIR/.env.full}"
|
||||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
||||||
BOOTSTRAP_SQL="$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql"
|
BOOTSTRAP_SQL="$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql"
|
||||||
APP_SQL="$ROOT_DIR/scripts/sql/init-postgres-app.sql"
|
APP_SQL="$ROOT_DIR/scripts/sql/init-postgres-app.sql"
|
||||||
|
SCHEMA_SQL="$ROOT_DIR/scripts/sql/create-tables.sql"
|
||||||
|
SEED_SQL="$ROOT_DIR/scripts/sql/init-data.sql"
|
||||||
|
|
||||||
require_file() {
|
require_file() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
|
|
@ -65,6 +67,31 @@ load_env_var() {
|
||||||
printf -v "$name" '%s' "$value"
|
printf -v "$name" '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
csv_to_json_array() {
|
||||||
|
local raw="$1"
|
||||||
|
local result="["
|
||||||
|
local first=1
|
||||||
|
local item=""
|
||||||
|
local old_ifs="$IFS"
|
||||||
|
|
||||||
|
IFS=','
|
||||||
|
for item in $raw; do
|
||||||
|
item="${item#"${item%%[![:space:]]*}"}"
|
||||||
|
item="${item%"${item##*[![:space:]]}"}"
|
||||||
|
[[ -z "$item" ]] && continue
|
||||||
|
item="${item//\\/\\\\}"
|
||||||
|
item="${item//\"/\\\"}"
|
||||||
|
if (( first == 0 )); then
|
||||||
|
result+=", "
|
||||||
|
fi
|
||||||
|
result+="\"$item\""
|
||||||
|
first=0
|
||||||
|
done
|
||||||
|
IFS="$old_ifs"
|
||||||
|
result+="]"
|
||||||
|
printf '%s' "$result"
|
||||||
|
}
|
||||||
|
|
||||||
wait_for_postgres() {
|
wait_for_postgres() {
|
||||||
local timeout_seconds="${1:-120}"
|
local timeout_seconds="${1:-120}"
|
||||||
local elapsed=0
|
local elapsed=0
|
||||||
|
|
@ -89,6 +116,8 @@ require_file "$ENV_FILE" "Create it from: $ROOT_DIR/.env.full.example"
|
||||||
require_file "$COMPOSE_FILE" ""
|
require_file "$COMPOSE_FILE" ""
|
||||||
require_file "$BOOTSTRAP_SQL" ""
|
require_file "$BOOTSTRAP_SQL" ""
|
||||||
require_file "$APP_SQL" ""
|
require_file "$APP_SQL" ""
|
||||||
|
require_file "$SCHEMA_SQL" ""
|
||||||
|
require_file "$SEED_SQL" ""
|
||||||
|
|
||||||
load_env_var POSTGRES_SUPERUSER postgres
|
load_env_var POSTGRES_SUPERUSER postgres
|
||||||
load_env_var POSTGRES_SUPERPASSWORD
|
load_env_var POSTGRES_SUPERPASSWORD
|
||||||
|
|
@ -96,6 +125,16 @@ load_env_var POSTGRES_BOOTSTRAP_DB postgres
|
||||||
load_env_var POSTGRES_APP_DB
|
load_env_var POSTGRES_APP_DB
|
||||||
load_env_var POSTGRES_APP_USER
|
load_env_var POSTGRES_APP_USER
|
||||||
load_env_var POSTGRES_APP_PASSWORD
|
load_env_var POSTGRES_APP_PASSWORD
|
||||||
|
load_env_var PAGE_SIZE 10
|
||||||
|
load_env_var CHAT_PULL_PAGE_SIZE 60
|
||||||
|
load_env_var COMMAND_AUTO_UNLOCK_SECONDS 10
|
||||||
|
load_env_var AUTH_TOKEN_TTL_HOURS 24
|
||||||
|
load_env_var AUTH_TOKEN_MAX_ACTIVE 2
|
||||||
|
load_env_var UPLOAD_MAX_MB 100
|
||||||
|
load_env_var ALLOWED_ATTACHMENT_EXTENSIONS
|
||||||
|
load_env_var WORKSPACE_DOWNLOAD_EXTENSIONS ".pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps"
|
||||||
|
load_env_var STT_ENABLED true
|
||||||
|
load_env_var ACTIVITY_EVENT_RETENTION_DAYS 7
|
||||||
|
|
||||||
require_env POSTGRES_SUPERUSER
|
require_env POSTGRES_SUPERUSER
|
||||||
require_env POSTGRES_SUPERPASSWORD
|
require_env POSTGRES_SUPERPASSWORD
|
||||||
|
|
@ -132,4 +171,49 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
-d "$POSTGRES_APP_DB" \
|
-d "$POSTGRES_APP_DB" \
|
||||||
-f - < "$APP_SQL"
|
-f - < "$APP_SQL"
|
||||||
|
|
||||||
|
echo "[init-full-db] applying application schema"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
-e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \
|
||||||
|
postgres \
|
||||||
|
psql \
|
||||||
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-U "$POSTGRES_SUPERUSER" \
|
||||||
|
-d "$POSTGRES_APP_DB" \
|
||||||
|
-f - < "$SCHEMA_SQL"
|
||||||
|
|
||||||
|
PAGE_SIZE_JSON="$PAGE_SIZE"
|
||||||
|
CHAT_PULL_PAGE_SIZE_JSON="$CHAT_PULL_PAGE_SIZE"
|
||||||
|
COMMAND_AUTO_UNLOCK_SECONDS_JSON="$COMMAND_AUTO_UNLOCK_SECONDS"
|
||||||
|
AUTH_TOKEN_TTL_HOURS_JSON="$AUTH_TOKEN_TTL_HOURS"
|
||||||
|
AUTH_TOKEN_MAX_ACTIVE_JSON="$AUTH_TOKEN_MAX_ACTIVE"
|
||||||
|
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="$(csv_to_json_array "$ALLOWED_ATTACHMENT_EXTENSIONS")"
|
||||||
|
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON="$(csv_to_json_array "$WORKSPACE_DOWNLOAD_EXTENSIONS")"
|
||||||
|
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
||||||
|
SPEECH_ENABLED_JSON="true"
|
||||||
|
else
|
||||||
|
SPEECH_ENABLED_JSON="false"
|
||||||
|
fi
|
||||||
|
ACTIVITY_EVENT_RETENTION_DAYS_JSON="$ACTIVITY_EVENT_RETENTION_DAYS"
|
||||||
|
|
||||||
|
echo "[init-full-db] applying initial data"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
-e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \
|
||||||
|
postgres \
|
||||||
|
psql \
|
||||||
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-v page_size_json="$PAGE_SIZE_JSON" \
|
||||||
|
-v chat_pull_page_size_json="$CHAT_PULL_PAGE_SIZE_JSON" \
|
||||||
|
-v command_auto_unlock_seconds_json="$COMMAND_AUTO_UNLOCK_SECONDS_JSON" \
|
||||||
|
-v auth_token_ttl_hours_json="$AUTH_TOKEN_TTL_HOURS_JSON" \
|
||||||
|
-v auth_token_max_active_json="$AUTH_TOKEN_MAX_ACTIVE_JSON" \
|
||||||
|
-v upload_max_mb_json="$UPLOAD_MAX_MB_JSON" \
|
||||||
|
-v allowed_attachment_extensions_json="$ALLOWED_ATTACHMENT_EXTENSIONS_JSON" \
|
||||||
|
-v workspace_download_extensions_json="$WORKSPACE_DOWNLOAD_EXTENSIONS_JSON" \
|
||||||
|
-v speech_enabled_json="$SPEECH_ENABLED_JSON" \
|
||||||
|
-v activity_event_retention_days_json="$ACTIVITY_EVENT_RETENTION_DAYS_JSON" \
|
||||||
|
-U "$POSTGRES_SUPERUSER" \
|
||||||
|
-d "$POSTGRES_APP_DB" \
|
||||||
|
-f - < "$SEED_SQL"
|
||||||
|
|
||||||
echo "[init-full-db] done"
|
echo "[init-full-db] done"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_instance (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
access_password TEXT NOT NULL DEFAULT '',
|
||||||
|
workspace_dir TEXT NOT NULL UNIQUE,
|
||||||
|
docker_status TEXT NOT NULL DEFAULT 'STOPPED',
|
||||||
|
current_state TEXT DEFAULT 'IDLE',
|
||||||
|
last_action TEXT,
|
||||||
|
image_tag TEXT NOT NULL DEFAULT 'nanobot-base:v0.1.4',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_image (
|
||||||
|
tag TEXT PRIMARY KEY,
|
||||||
|
image_id TEXT,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'READY',
|
||||||
|
source_dir TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_message (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
media_json TEXT,
|
||||||
|
feedback TEXT,
|
||||||
|
feedback_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
auth_type TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
subject_id TEXT NOT NULL,
|
||||||
|
bot_id TEXT,
|
||||||
|
auth_source TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP,
|
||||||
|
revoke_reason TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
device_info TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_setting (
|
||||||
|
key VARCHAR(120) PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
category VARCHAR(64) NOT NULL DEFAULT 'general',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
value_type VARCHAR(32) NOT NULL DEFAULT 'json',
|
||||||
|
value_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_request_usage (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
message_id INTEGER,
|
||||||
|
request_id VARCHAR(120) NOT NULL,
|
||||||
|
channel VARCHAR(64) NOT NULL DEFAULT 'dashboard',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||||
|
provider VARCHAR(120),
|
||||||
|
model VARCHAR(255),
|
||||||
|
token_source VARCHAR(32) NOT NULL DEFAULT 'estimated',
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_text_preview TEXT,
|
||||||
|
output_text_preview TEXT,
|
||||||
|
attachments_json TEXT,
|
||||||
|
error_text TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_activity_event (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
request_id VARCHAR(120),
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
channel VARCHAR(64) NOT NULL DEFAULT 'dashboard',
|
||||||
|
detail TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS skill_market_item (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
skill_key VARCHAR(120) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
zip_filename VARCHAR(255) NOT NULL,
|
||||||
|
zip_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
entry_names_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_skill_market_item_skill_key UNIQUE (skill_key),
|
||||||
|
CONSTRAINT uq_skill_market_item_zip_filename UNIQUE (zip_filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_skill_install (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
skill_market_item_id INTEGER NOT NULL REFERENCES skill_market_item(id),
|
||||||
|
installed_entries_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
source_zip_filename VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'INSTALLED',
|
||||||
|
last_error TEXT,
|
||||||
|
installed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_bot_skill_install_bot_market UNIQUE (bot_id, skill_market_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS topic_topic (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
topic_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_default_fallback BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
routing_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
view_schema_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_topic_topic_bot_topic_key UNIQUE (bot_id, topic_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS topic_item (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bot_instance(id),
|
||||||
|
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 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_instance_enabled ON bot_instance (enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_instance_docker_status ON bot_instance (docker_status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_message_bot_id ON bot_message (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_message_role ON bot_message (role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_message_feedback ON bot_message (feedback);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_message_created_at ON bot_message (created_at);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_login_log_token_hash ON sys_login_log (token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_auth_type ON sys_login_log (auth_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_subject_id ON sys_login_log (subject_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_bot_id ON sys_login_log (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_auth_source ON sys_login_log (auth_source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_created_at ON sys_login_log (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_expires_at ON sys_login_log (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_last_seen_at ON sys_login_log (last_seen_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_login_log_revoked_at ON sys_login_log (revoked_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_setting_category ON sys_setting (category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_setting_is_public ON sys_setting (is_public);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_setting_sort_order ON sys_setting (sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sys_setting_updated_at ON sys_setting (updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_bot_id ON bot_request_usage (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_message_id ON bot_request_usage (message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_request_id ON bot_request_usage (request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_channel ON bot_request_usage (channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_status ON bot_request_usage (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_started_at ON bot_request_usage (started_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_completed_at ON bot_request_usage (completed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_created_at ON bot_request_usage (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_updated_at ON bot_request_usage (updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_request_usage_started_at_bot_id ON bot_request_usage (started_at, bot_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_bot_id ON bot_activity_event (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_request_id ON bot_activity_event (request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_event_type ON bot_activity_event (event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_channel ON bot_activity_event (channel);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_created_at ON bot_activity_event (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_activity_event_bot_id_request_present
|
||||||
|
ON bot_activity_event (bot_id)
|
||||||
|
WHERE request_id IS NOT NULL AND request_id <> '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_skill_market_item_created_at ON skill_market_item (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_skill_market_item_updated_at ON skill_market_item (updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_skill_install_bot_id ON bot_skill_install (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_skill_install_skill_market_item_id ON bot_skill_install (skill_market_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_skill_install_status ON bot_skill_install (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_skill_install_installed_at ON bot_skill_install (installed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bot_skill_install_updated_at ON bot_skill_install (updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_id ON topic_topic (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_topic_topic_key ON topic_topic (topic_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_topic_created_at ON topic_topic (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_topic_updated_at ON topic_topic (updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_topic_bot_fallback ON topic_topic (bot_id, is_default_fallback);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_id ON topic_item (bot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_topic_key ON topic_item (topic_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_level ON topic_item (level);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_source ON topic_item (source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_is_read ON topic_item (is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_created_at ON topic_item (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_topic_created_at ON topic_item (bot_id, topic_key, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic_item_bot_dedupe ON topic_item (bot_id, dedupe_key);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO sys_setting (
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
value_type,
|
||||||
|
value_json,
|
||||||
|
is_public,
|
||||||
|
sort_order,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
('page_size', '分页大小', 'ui', '平台各类列表默认每页条数。', 'integer', :'page_size_json', TRUE, 5, NOW(), NOW()),
|
||||||
|
('chat_pull_page_size', '对话懒加载条数', 'chat', 'Bot 对话区向上懒加载时每次读取的消息条数。', 'integer', :'chat_pull_page_size_json', TRUE, 8, NOW(), NOW()),
|
||||||
|
('command_auto_unlock_seconds', '发送按钮自动恢复秒数', 'chat', '对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。', 'integer', :'command_auto_unlock_seconds_json', TRUE, 9, NOW(), NOW()),
|
||||||
|
('auth_token_ttl_hours', '认证 Token 过期小时数', 'auth', 'Panel 与 Bot 登录 Token 的统一有效时长,单位小时。', 'integer', :'auth_token_ttl_hours_json', FALSE, 10, NOW(), NOW()),
|
||||||
|
('auth_token_max_active', '认证 Token 最大并发数', 'auth', '同一主体允许同时活跃的 Token 数量,超过时自动撤销最旧 Token。', 'integer', :'auth_token_max_active_json', FALSE, 11, NOW(), NOW()),
|
||||||
|
('upload_max_mb', '上传大小限制', 'upload', '单文件上传大小限制,单位 MB。', 'integer', :'upload_max_mb_json', FALSE, 20, NOW(), NOW()),
|
||||||
|
('allowed_attachment_extensions', '允许附件后缀', 'upload', '允许上传的附件后缀列表,留空表示不限制。', 'json', :'allowed_attachment_extensions_json', FALSE, 20, NOW(), NOW()),
|
||||||
|
('workspace_download_extensions', '工作区下载后缀', 'workspace', '命中后缀的工作区文件默认走下载模式。', 'json', :'workspace_download_extensions_json', FALSE, 30, NOW(), NOW()),
|
||||||
|
('speech_enabled', '语音识别开关', 'speech', '控制 Bot 语音转写功能是否启用。', 'boolean', :'speech_enabled_json', TRUE, 32, NOW(), NOW()),
|
||||||
|
('activity_event_retention_days', '活动事件保留天数', 'maintenance', 'bot_activity_event 运维事件的保留天数,超期记录会自动清理。', 'integer', :'activity_event_retention_days_json', FALSE, 34, NOW(), NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
value_type = EXCLUDED.value_type,
|
||||||
|
is_public = EXCLUDED.is_public,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO skill_market_item (
|
||||||
|
skill_key,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
zip_filename,
|
||||||
|
zip_size_bytes,
|
||||||
|
entry_names_json,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
('nano-banana-pro', 'Nano Banana Pro', 'Generate and edit images with Nano Banana Pro.', 'nano-banana-pro.zip', 5317, '["nano-banana-pro"]', NOW(), NOW()),
|
||||||
|
('powerpoint-pptx', 'Powerpoint PPTX', 'Create and edit PowerPoint PPTX files.', 'powerpoint-pptx.zip', 3596, '["powerpoint-pptx"]', NOW(), NOW()),
|
||||||
|
('self-improving-agent', 'Self Improving Agent', 'Capture learnings, errors, and corrections for continuous improvement.', 'self-improving-agent.zip', 26866, '["self-improving-agent"]', NOW(), NOW()),
|
||||||
|
('stock-analysis', 'Stock Analysis', 'Analyze stocks and crypto with research and portfolio workflows.', 'stock-analysis.zip', 79536, '["stock-analysis"]', NOW(), NOW()),
|
||||||
|
('tavily-search', 'Tavily Search', 'AI-optimized web search skill powered by Tavily.', 'tavily-search.zip', 3459, '["tavily-search"]', NOW(), NOW()),
|
||||||
|
('writing', 'Writing', 'Adapt writing voice and improve clarity, structure, and style.', 'writing.zip', 8618, '["writing"]', NOW(), NOW())
|
||||||
|
ON CONFLICT (skill_key) DO UPDATE
|
||||||
|
SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
zip_filename = EXCLUDED.zip_filename,
|
||||||
|
zip_size_bytes = EXCLUDED.zip_size_bytes,
|
||||||
|
entry_names_json = EXCLUDED.entry_names_json,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Reference in New Issue