Compare commits
7 Commits
main
...
codex/offl
| Author | SHA1 | Date |
|---|---|---|
|
|
6322171418 | |
|
|
abf851eded | |
|
|
7d96e81005 | |
|
|
f6cf5edc1c | |
|
|
88611c14a1 | |
|
|
73b9afa037 | |
|
|
8e6b4581cd |
|
|
@ -5,8 +5,8 @@ NGINX_PORT=8080
|
||||||
# Only workspace root still needs an absolute host path.
|
# Only workspace root still needs an absolute host path.
|
||||||
HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots
|
HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots
|
||||||
|
|
||||||
# Shared Docker network for dashboard and bot containers.
|
# Fixed Docker bridge subnet for the compose network.
|
||||||
# deploy-full.sh will reuse it when it already exists, or create it with the subnet below.
|
# Change this if it conflicts with your host LAN / VPN / intranet routing.
|
||||||
DOCKER_NETWORK_NAME=dashboard-nanobot-network
|
DOCKER_NETWORK_NAME=dashboard-nanobot-network
|
||||||
DOCKER_NETWORK_SUBNET=172.20.0.0/16
|
DOCKER_NETWORK_SUBNET=172.20.0.0/16
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ NGINX_PORT=8082
|
||||||
# Only workspace root still needs an absolute host path.
|
# Only workspace root still needs an absolute host path.
|
||||||
HOST_BOTS_WORKSPACE_ROOT=/dep/dashboard-nanobot/workspace/bots
|
HOST_BOTS_WORKSPACE_ROOT=/dep/dashboard-nanobot/workspace/bots
|
||||||
|
|
||||||
# Shared Docker network for dashboard and bot containers.
|
# Fixed Docker bridge subnet for the compose network.
|
||||||
# deploy-prod.sh will reuse it when it already exists, or create it with the subnet below.
|
# Change this if it conflicts with your host LAN / VPN / intranet routing.
|
||||||
DOCKER_NETWORK_NAME=dashboard-nanobot-network
|
DOCKER_NETWORK_NAME=dashboard-nanobot-network
|
||||||
DOCKER_NETWORK_SUBNET=172.20.0.0/16
|
DOCKER_NETWORK_SUBNET=172.20.0.0/16
|
||||||
|
|
||||||
|
|
@ -48,6 +48,8 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
||||||
|
|
||||||
# Panel access protection (deployment secret, not stored in sys_setting)
|
# Panel access protection (deployment secret, not stored in sys_setting)
|
||||||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||||
|
WORKSPACE_PREVIEW_SIGNING_SECRET=change_me_workspace_preview_signing_secret
|
||||||
|
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600
|
||||||
|
|
||||||
# Browser credential requests must use an explicit CORS allowlist (deployment security setting).
|
# Browser credential requests must use an explicit CORS allowlist (deployment security setting).
|
||||||
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ graph TD
|
||||||
- 复制 `.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`
|
- `deploy-prod.sh` 现在要求使用外部 PostgreSQL,且目标库必须提前执行 `scripts/sql/create-tables.sql` 与 `scripts/sql/init-data.sql`
|
||||||
- `DOCKER_NETWORK_NAME` 表示 Dashboard 与 Bot 共用的 Docker network;`deploy-prod.sh` 会优先复用现有 network,不存在时按 `DOCKER_NETWORK_SUBNET` 自动创建
|
|
||||||
- 只需要配置绝对路径:
|
- 只需要配置绝对路径:
|
||||||
- `HOST_BOTS_WORKSPACE_ROOT`
|
- `HOST_BOTS_WORKSPACE_ROOT`
|
||||||
- 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到宿主机项目根目录的 `data/model/`
|
- 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到宿主机项目根目录的 `data/model/`
|
||||||
|
|
@ -123,7 +122,7 @@ graph TD
|
||||||
- 如需基础镜像加速,覆盖 `PYTHON_BASE_IMAGE` / `NODE_BASE_IMAGE` / `NGINX_BASE_IMAGE`
|
- 如需基础镜像加速,覆盖 `PYTHON_BASE_IMAGE` / `NODE_BASE_IMAGE` / `NGINX_BASE_IMAGE`
|
||||||
2. 启动服务
|
2. 启动服务
|
||||||
- `./scripts/deploy-prod.sh`
|
- `./scripts/deploy-prod.sh`
|
||||||
- 如需手动执行 `docker compose`,请先自行创建 `DOCKER_NETWORK_NAME` 指向的 Docker network
|
- 或:`docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build`
|
||||||
3. 访问
|
3. 访问
|
||||||
- `http://<host>:${NGINX_PORT}`(默认 `8080`)
|
- `http://<host>:${NGINX_PORT}`(默认 `8080`)
|
||||||
|
|
||||||
|
|
@ -131,7 +130,6 @@ graph TD
|
||||||
|
|
||||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||||
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
||||||
- `deploy-prod.sh` 会确保 `DOCKER_NETWORK_NAME` 对应的共享 Docker network 存在,但停止服务时不会删除该 network。
|
|
||||||
- 如果启用 Redis,`REDIS_URL` 必须从 `backend` 容器内部可达;在 `docker-compose.prod.yml` 里使用 `127.0.0.1` 只会指向后端容器自己,不是宿主机。
|
- 如果启用 Redis,`REDIS_URL` 必须从 `backend` 容器内部可达;在 `docker-compose.prod.yml` 里使用 `127.0.0.1` 只会指向后端容器自己,不是宿主机。
|
||||||
- Redis 不可达时,通用缓存健康检查会显示 `degraded`;面板登录认证会自动回退到数据库登录态,不再因为缓存不可达直接报错。
|
- Redis 不可达时,通用缓存健康检查会显示 `degraded`;面板登录认证会自动回退到数据库登录态,不再因为缓存不可达直接报错。
|
||||||
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
||||||
|
|
@ -162,7 +160,6 @@ graph TD
|
||||||
1. 准备部署变量
|
1. 准备部署变量
|
||||||
- 复制 `.env.full.example` 为 `.env.full`
|
- 复制 `.env.full.example` 为 `.env.full`
|
||||||
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
||||||
- `DOCKER_NETWORK_NAME` 表示 Dashboard 与 Bot 共用的 Docker network;`deploy-full.sh` 会优先复用现有 network,不存在时按 `DOCKER_NETWORK_SUBNET` 自动创建
|
|
||||||
- 必填修改:
|
- 必填修改:
|
||||||
- `HOST_BOTS_WORKSPACE_ROOT`
|
- `HOST_BOTS_WORKSPACE_ROOT`
|
||||||
- `POSTGRES_SUPERPASSWORD`
|
- `POSTGRES_SUPERPASSWORD`
|
||||||
|
|
@ -177,7 +174,6 @@ graph TD
|
||||||
### 初始化说明
|
### 初始化说明
|
||||||
|
|
||||||
- `scripts/deploy-full.sh` 会先启动 `postgres` / `redis`,然后自动调用 `scripts/init-full-db.sh`。
|
- `scripts/deploy-full.sh` 会先启动 `postgres` / `redis`,然后自动调用 `scripts/init-full-db.sh`。
|
||||||
- `scripts/deploy-full.sh` 会确保 `DOCKER_NETWORK_NAME` 对应的共享 Docker network 存在,但停止服务时不会删除该 network。
|
|
||||||
- `scripts/init-full-db.sh` 负责:
|
- `scripts/init-full-db.sh` 负责:
|
||||||
- 等待 PostgreSQL 就绪
|
- 等待 PostgreSQL 就绪
|
||||||
- 创建或更新业务账号
|
- 创建或更新业务账号
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ REDIS_DEFAULT_TTL=60
|
||||||
|
|
||||||
# Optional panel-level access password for all backend API/WS calls.
|
# Optional panel-level access password for all backend API/WS calls.
|
||||||
PANEL_ACCESS_PASSWORD=
|
PANEL_ACCESS_PASSWORD=
|
||||||
|
WORKSPACE_PREVIEW_SIGNING_SECRET=
|
||||||
|
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600
|
||||||
|
|
||||||
# Explicit CORS allowlist for browser credential requests.
|
# Explicit CORS allowlist for browser credential requests.
|
||||||
# For local development, the backend defaults to common Vite dev origins.
|
# For local development, the backend defaults to common Vite dev origins.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from sqlmodel import Session
|
||||||
from core.database import get_session
|
from core.database import get_session
|
||||||
from models.bot import BotInstance
|
from models.bot import BotInstance
|
||||||
from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest
|
from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest
|
||||||
from services.platform_system_settings_service import get_workspace_preview_token_ttl_seconds
|
|
||||||
from services.workspace_service import (
|
from services.workspace_service import (
|
||||||
create_workspace_html_preview_url,
|
create_workspace_html_preview_url,
|
||||||
get_workspace_tree_data,
|
get_workspace_tree_data,
|
||||||
|
|
@ -78,13 +77,10 @@ def create_workspace_preview_url(
|
||||||
bot = session.get(BotInstance, bot_id)
|
bot = session.get(BotInstance, bot_id)
|
||||||
if not bot:
|
if not bot:
|
||||||
raise HTTPException(status_code=404, detail="Bot not found")
|
raise HTTPException(status_code=404, detail="Bot not found")
|
||||||
ttl_seconds = payload.ttl_seconds
|
|
||||||
if ttl_seconds is None:
|
|
||||||
ttl_seconds = get_workspace_preview_token_ttl_seconds(session)
|
|
||||||
return create_workspace_html_preview_url(
|
return create_workspace_html_preview_url(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
path=payload.path,
|
path=payload.path,
|
||||||
ttl_seconds=ttl_seconds,
|
ttl_seconds=payload.ttl_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/api/bots/{bot_id}/workspace/download")
|
@router.get("/api/bots/{bot_id}/workspace/download")
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ REQUIRED_TABLES = (
|
||||||
"topic_item",
|
"topic_item",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REQUIRED_SYS_SETTING_KEYS = (
|
||||||
|
"page_size",
|
||||||
|
"chat_pull_page_size",
|
||||||
|
"auth_token_ttl_hours",
|
||||||
|
"auth_token_max_active",
|
||||||
|
"upload_max_mb",
|
||||||
|
"allowed_attachment_extensions",
|
||||||
|
"workspace_download_extensions",
|
||||||
|
"speech_enabled",
|
||||||
|
"activity_event_retention_days",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_required_tables() -> None:
|
def _validate_required_tables() -> None:
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
missing = [table_name for table_name in REQUIRED_TABLES if not inspector.has_table(table_name)]
|
missing = [table_name for table_name in REQUIRED_TABLES if not inspector.has_table(table_name)]
|
||||||
|
|
@ -52,14 +65,30 @@ def _validate_required_tables() -> None:
|
||||||
"Run scripts/init-full-db.sh or apply scripts/sql/create-tables.sql before starting the backend."
|
"Run scripts/init-full-db.sh or apply scripts/sql/create-tables.sql before starting the backend."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(f'SELECT key FROM "{SYS_SETTING_TABLE}" WHERE key IN ({placeholders})'),
|
||||||
|
params,
|
||||||
|
).scalars().all()
|
||||||
|
present = {str(row or "").strip() for row in rows if str(row or "").strip()}
|
||||||
|
missing = [key for key in REQUIRED_SYS_SETTING_KEYS if key not in present]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Database seed data is not initialized. "
|
||||||
|
f"Missing sys_setting keys: {', '.join(missing)}. "
|
||||||
|
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_database() -> None:
|
def init_database() -> None:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(text("SELECT 1"))
|
conn.execute(text("SELECT 1"))
|
||||||
_validate_required_tables()
|
_validate_required_tables()
|
||||||
from services.platform_system_settings_service import validate_required_system_settings
|
_validate_required_sys_settings()
|
||||||
|
|
||||||
with Session(engine) as session:
|
|
||||||
validate_required_system_settings(session)
|
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,15 @@ DEFAULT_PAGE_SIZE: Final[int] = 10
|
||||||
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
||||||
DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720)
|
DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720)
|
||||||
DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
||||||
|
WORKSPACE_PREVIEW_SIGNING_SECRET: Final[str] = str(
|
||||||
|
os.getenv("WORKSPACE_PREVIEW_SIGNING_SECRET") or DATABASE_URL
|
||||||
|
).strip()
|
||||||
|
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: Final[int] = _env_int(
|
||||||
|
"WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS",
|
||||||
|
3600,
|
||||||
|
60,
|
||||||
|
86400,
|
||||||
|
)
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
||||||
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
|
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
|
||||||
).strip() or "Asia/Shanghai"
|
).strip() or "Asia/Shanghai"
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,6 @@ def _normalize_extra_config(raw: Any) -> Dict[str, Any]:
|
||||||
return dict(raw)
|
return dict(raw)
|
||||||
|
|
||||||
|
|
||||||
def _channel_delivery_flags(
|
|
||||||
extra: Dict[str, Any],
|
|
||||||
*,
|
|
||||||
default_send_progress: bool = True,
|
|
||||||
default_send_tool_hints: bool = True,
|
|
||||||
) -> Dict[str, bool]:
|
|
||||||
return {
|
|
||||||
"sendProgress": bool(extra.get("sendProgress", default_send_progress)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", default_send_tool_hints)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
tmp_path = f"{path}.tmp"
|
tmp_path = f"{path}.tmp"
|
||||||
|
|
@ -97,8 +85,8 @@ class BotWorkspaceProvider:
|
||||||
temperature = float(bot_data.get("temperature"))
|
temperature = float(bot_data.get("temperature"))
|
||||||
top_p = float(bot_data.get("top_p"))
|
top_p = float(bot_data.get("top_p"))
|
||||||
max_tokens = int(bot_data.get("max_tokens"))
|
max_tokens = int(bot_data.get("max_tokens"))
|
||||||
send_progress = bool(bot_data.get("send_progress", True))
|
send_progress = bool(bot_data.get("send_progress"))
|
||||||
send_tool_hints = bool(bot_data.get("send_tool_hints", True))
|
send_tool_hints = bool(bot_data.get("send_tool_hints"))
|
||||||
|
|
||||||
bot_root = os.path.join(self.host_data_root, bot_id)
|
bot_root = os.path.join(self.host_data_root, bot_id)
|
||||||
dot_nanobot_dir = os.path.join(bot_root, ".nanobot")
|
dot_nanobot_dir = os.path.join(bot_root, ".nanobot")
|
||||||
|
|
@ -132,15 +120,13 @@ class BotWorkspaceProvider:
|
||||||
provider_name: provider_cfg,
|
provider_name: provider_cfg,
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"sendProgress": True,
|
"sendProgress": send_progress,
|
||||||
"sendToolHints": True,
|
"sendToolHints": send_tool_hints,
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"allowFrom": ["*"],
|
"allowFrom": ["*"],
|
||||||
"sendProgress": send_progress,
|
|
||||||
"sendToolHints": send_tool_hints,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +143,6 @@ class BotWorkspaceProvider:
|
||||||
if not channel_type or channel_type == "dashboard":
|
if not channel_type or channel_type == "dashboard":
|
||||||
continue
|
continue
|
||||||
extra = _normalize_extra_config(channel.get("extra_config"))
|
extra = _normalize_extra_config(channel.get("extra_config"))
|
||||||
delivery_flags = _channel_delivery_flags(extra)
|
|
||||||
enabled = bool(channel.get("is_active"))
|
enabled = bool(channel.get("is_active"))
|
||||||
external_app_id = str(channel.get("external_app_id") or "").strip()
|
external_app_id = str(channel.get("external_app_id") or "").strip()
|
||||||
app_secret = str(channel.get("app_secret") or "").strip()
|
app_secret = str(channel.get("app_secret") or "").strip()
|
||||||
|
|
@ -169,7 +154,6 @@ class BotWorkspaceProvider:
|
||||||
"proxy": str(extra.get("proxy") or "").strip(),
|
"proxy": str(extra.get("proxy") or "").strip(),
|
||||||
"replyToMessage": bool(extra.get("replyToMessage")),
|
"replyToMessage": bool(extra.get("replyToMessage")),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -181,7 +165,6 @@ class BotWorkspaceProvider:
|
||||||
"encryptKey": str(extra.get("encryptKey") or "").strip(),
|
"encryptKey": str(extra.get("encryptKey") or "").strip(),
|
||||||
"verificationToken": str(extra.get("verificationToken") or "").strip(),
|
"verificationToken": str(extra.get("verificationToken") or "").strip(),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -191,7 +174,6 @@ class BotWorkspaceProvider:
|
||||||
"clientId": external_app_id,
|
"clientId": external_app_id,
|
||||||
"clientSecret": app_secret,
|
"clientSecret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -205,7 +187,6 @@ class BotWorkspaceProvider:
|
||||||
"groupPolicy": str(extra.get("groupPolicy") or "mention"),
|
"groupPolicy": str(extra.get("groupPolicy") or "mention"),
|
||||||
"groupAllowFrom": extra.get("groupAllowFrom") if isinstance(extra.get("groupAllowFrom"), list) else [],
|
"groupAllowFrom": extra.get("groupAllowFrom") if isinstance(extra.get("groupAllowFrom"), list) else [],
|
||||||
"reactEmoji": str(extra.get("reactEmoji") or "eyes"),
|
"reactEmoji": str(extra.get("reactEmoji") or "eyes"),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -215,7 +196,6 @@ class BotWorkspaceProvider:
|
||||||
"appId": external_app_id,
|
"appId": external_app_id,
|
||||||
"secret": app_secret,
|
"secret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -225,7 +205,6 @@ class BotWorkspaceProvider:
|
||||||
"botId": external_app_id,
|
"botId": external_app_id,
|
||||||
"secret": app_secret,
|
"secret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
welcome_message = str(extra.get("welcomeMessage") or "").strip()
|
welcome_message = str(extra.get("welcomeMessage") or "").strip()
|
||||||
if welcome_message:
|
if welcome_message:
|
||||||
|
|
@ -237,7 +216,6 @@ class BotWorkspaceProvider:
|
||||||
weixin_cfg: Dict[str, Any] = {
|
weixin_cfg: Dict[str, Any] = {
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
route_tag = str(extra.get("routeTag") or "").strip()
|
route_tag = str(extra.get("routeTag") or "").strip()
|
||||||
if route_tag:
|
if route_tag:
|
||||||
|
|
@ -280,7 +258,6 @@ class BotWorkspaceProvider:
|
||||||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -289,7 +266,6 @@ class BotWorkspaceProvider:
|
||||||
"appId": external_app_id,
|
"appId": external_app_id,
|
||||||
"appSecret": app_secret,
|
"appSecret": app_secret,
|
||||||
**extra,
|
**extra,
|
||||||
**delivery_flags,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_write_json_atomic(os.path.join(dot_nanobot_dir, "config.json"), config_data)
|
_write_json_atomic(os.path.join(dot_nanobot_dir, "config.json"), config_data)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from services.bot_service import (
|
||||||
channel_api_to_config,
|
channel_api_to_config,
|
||||||
list_bot_channels_from_config,
|
list_bot_channels_from_config,
|
||||||
normalize_channel_extra,
|
normalize_channel_extra,
|
||||||
|
read_global_delivery_flags,
|
||||||
sync_bot_workspace_channels,
|
sync_bot_workspace_channels,
|
||||||
)
|
)
|
||||||
from services.bot_mcp_service import (
|
from services.bot_mcp_service import (
|
||||||
|
|
@ -65,8 +66,6 @@ def _read_bot_channels_cfg(bot_id: str) -> tuple[Dict[str, Any], Dict[str, Any]]
|
||||||
if not isinstance(channels_cfg, dict):
|
if not isinstance(channels_cfg, dict):
|
||||||
channels_cfg = {}
|
channels_cfg = {}
|
||||||
config_data["channels"] = channels_cfg
|
config_data["channels"] = channels_cfg
|
||||||
channels_cfg["sendProgress"] = bool(channels_cfg.get("sendProgress", True))
|
|
||||||
channels_cfg["sendToolHints"] = bool(channels_cfg.get("sendToolHints", True))
|
|
||||||
return config_data, channels_cfg
|
return config_data, channels_cfg
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -312,38 +311,11 @@ def update_bot_channel_config(
|
||||||
|
|
||||||
rows = list_bot_channels_from_config(bot)
|
rows = list_bot_channels_from_config(bot)
|
||||||
row = _find_channel_row(rows, channel_id)
|
row = _find_channel_row(rows, channel_id)
|
||||||
|
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
|
||||||
|
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
existing_type = str(row.get("channel_type") or "").strip().lower()
|
existing_type = str(row.get("channel_type") or "").strip().lower()
|
||||||
if existing_type == "dashboard" or bool(row.get("locked")):
|
|
||||||
if "channel_type" in update_data and str(update_data.get("channel_type") or "").strip().lower() != "dashboard":
|
|
||||||
raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed")
|
|
||||||
if "is_active" in update_data and update_data["is_active"] is False:
|
|
||||||
raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
|
|
||||||
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
|
||||||
extra = normalize_channel_extra(update_data.get("extra_config", row.get("extra_config")))
|
|
||||||
dashboard_cfg = channels_cfg.get("dashboard")
|
|
||||||
if not isinstance(dashboard_cfg, dict):
|
|
||||||
dashboard_cfg = {}
|
|
||||||
dashboard_cfg.update(
|
|
||||||
{
|
|
||||||
"enabled": True,
|
|
||||||
"host": str(dashboard_cfg.get("host") or "0.0.0.0"),
|
|
||||||
"port": max(1, min(int(dashboard_cfg.get("port") or 9000), 65535)),
|
|
||||||
"allowFrom": dashboard_cfg.get("allowFrom") if isinstance(dashboard_cfg.get("allowFrom"), list) else ["*"],
|
|
||||||
"sendProgress": bool(extra.get("sendProgress", dashboard_cfg.get("sendProgress", channels_cfg.get("sendProgress", True)))),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", dashboard_cfg.get("sendToolHints", channels_cfg.get("sendToolHints", True)))),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
channels_cfg["dashboard"] = dashboard_cfg
|
|
||||||
row["is_active"] = True
|
|
||||||
row["extra_config"] = {
|
|
||||||
"sendProgress": bool(dashboard_cfg.get("sendProgress")),
|
|
||||||
"sendToolHints": bool(dashboard_cfg.get("sendToolHints")),
|
|
||||||
}
|
|
||||||
_write_bot_config_state(session, bot_id=bot_id, config_data=config_data, sync_workspace=True)
|
|
||||||
return row
|
|
||||||
|
|
||||||
new_type = existing_type
|
new_type = existing_type
|
||||||
if "channel_type" in update_data and update_data["channel_type"] is not None:
|
if "channel_type" in update_data and update_data["channel_type"] is not None:
|
||||||
new_type = str(update_data["channel_type"]).strip().lower()
|
new_type = str(update_data["channel_type"]).strip().lower()
|
||||||
|
|
@ -372,6 +344,15 @@ def update_bot_channel_config(
|
||||||
row["locked"] = new_type == "dashboard"
|
row["locked"] = new_type == "dashboard"
|
||||||
|
|
||||||
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
config_data, channels_cfg = _read_bot_channels_cfg(bot_id)
|
||||||
|
current_send_progress, current_send_tool_hints = read_global_delivery_flags(channels_cfg)
|
||||||
|
if new_type == "dashboard":
|
||||||
|
extra = normalize_channel_extra(row.get("extra_config"))
|
||||||
|
channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
|
||||||
|
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
|
||||||
|
else:
|
||||||
|
channels_cfg["sendProgress"] = current_send_progress
|
||||||
|
channels_cfg["sendToolHints"] = current_send_tool_hints
|
||||||
|
channels_cfg.pop("dashboard", None)
|
||||||
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
|
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
|
||||||
channels_cfg.pop(existing_type, None)
|
channels_cfg.pop(existing_type, None)
|
||||||
if new_type != "dashboard":
|
if new_type != "dashboard":
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,8 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
||||||
normalized_bot_id,
|
normalized_bot_id,
|
||||||
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
|
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
|
||||||
global_delivery_override={
|
global_delivery_override={
|
||||||
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else True,
|
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False,
|
||||||
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else True,
|
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
|
||||||
},
|
},
|
||||||
runtime_overrides={
|
runtime_overrides={
|
||||||
"llm_provider": llm_provider,
|
"llm_provider": llm_provider,
|
||||||
|
|
@ -154,8 +154,8 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
||||||
"user_md": payload.user_md,
|
"user_md": payload.user_md,
|
||||||
"tools_md": payload.tools_md,
|
"tools_md": payload.tools_md,
|
||||||
"identity_md": payload.identity_md,
|
"identity_md": payload.identity_md,
|
||||||
"send_progress": bool(payload.send_progress) if payload.send_progress is not None else True,
|
"send_progress": bool(payload.send_progress) if payload.send_progress is not None else False,
|
||||||
"send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else True,
|
"send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
record_activity_event(
|
record_activity_event(
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,8 @@ def _normalize_allow_from(raw: Any) -> List[str]:
|
||||||
|
|
||||||
def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
|
def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
|
||||||
if not isinstance(channels_cfg, dict):
|
if not isinstance(channels_cfg, dict):
|
||||||
return True, True
|
return False, False
|
||||||
return bool(channels_cfg.get("sendProgress", True)), bool(channels_cfg.get("sendToolHints", True))
|
return bool(channels_cfg.get("sendProgress")), bool(channels_cfg.get("sendToolHints"))
|
||||||
|
|
||||||
|
|
||||||
def _delivery_extra(channels_cfg: Any, cfg: Dict[str, Any]) -> Dict[str, bool]:
|
|
||||||
default_send_progress, default_send_tool_hints = read_global_delivery_flags(channels_cfg)
|
|
||||||
return {
|
|
||||||
"sendProgress": bool(cfg.get("sendProgress", default_send_progress)),
|
|
||||||
"sendToolHints": bool(cfg.get("sendToolHints", default_send_tool_hints)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -89,19 +81,17 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
||||||
"encryptKey": cfg.get("encryptKey", ""),
|
"encryptKey": cfg.get("encryptKey", ""),
|
||||||
"verificationToken": cfg.get("verificationToken", ""),
|
"verificationToken": cfg.get("verificationToken", ""),
|
||||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
elif ctype == "dingtalk":
|
elif ctype == "dingtalk":
|
||||||
external_app_id = str(cfg.get("clientId") or "")
|
external_app_id = str(cfg.get("clientId") or "")
|
||||||
app_secret = str(cfg.get("clientSecret") or "")
|
app_secret = str(cfg.get("clientSecret") or "")
|
||||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), **_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg)}
|
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||||
elif ctype == "telegram":
|
elif ctype == "telegram":
|
||||||
app_secret = str(cfg.get("token") or "")
|
app_secret = str(cfg.get("token") or "")
|
||||||
extra = {
|
extra = {
|
||||||
"proxy": cfg.get("proxy", ""),
|
"proxy": cfg.get("proxy", ""),
|
||||||
"replyToMessage": bool(cfg.get("replyToMessage", False)),
|
"replyToMessage": bool(cfg.get("replyToMessage", False)),
|
||||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
elif ctype == "slack":
|
elif ctype == "slack":
|
||||||
external_app_id = str(cfg.get("botToken") or "")
|
external_app_id = str(cfg.get("botToken") or "")
|
||||||
|
|
@ -112,25 +102,22 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
||||||
"groupPolicy": cfg.get("groupPolicy", "mention"),
|
"groupPolicy": cfg.get("groupPolicy", "mention"),
|
||||||
"groupAllowFrom": cfg.get("groupAllowFrom", []),
|
"groupAllowFrom": cfg.get("groupAllowFrom", []),
|
||||||
"reactEmoji": cfg.get("reactEmoji", "eyes"),
|
"reactEmoji": cfg.get("reactEmoji", "eyes"),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
elif ctype == "qq":
|
elif ctype == "qq":
|
||||||
external_app_id = str(cfg.get("appId") or "")
|
external_app_id = str(cfg.get("appId") or "")
|
||||||
app_secret = str(cfg.get("secret") or "")
|
app_secret = str(cfg.get("secret") or "")
|
||||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), **_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg)}
|
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||||
elif ctype == "wecom":
|
elif ctype == "wecom":
|
||||||
external_app_id = str(cfg.get("botId") or "")
|
external_app_id = str(cfg.get("botId") or "")
|
||||||
app_secret = str(cfg.get("secret") or "")
|
app_secret = str(cfg.get("secret") or "")
|
||||||
extra = {
|
extra = {
|
||||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||||
"welcomeMessage": str(cfg.get("welcomeMessage") or ""),
|
"welcomeMessage": str(cfg.get("welcomeMessage") or ""),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
elif ctype == "weixin":
|
elif ctype == "weixin":
|
||||||
app_secret = ""
|
app_secret = ""
|
||||||
extra = {
|
extra = {
|
||||||
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
|
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
elif ctype == "email":
|
elif ctype == "email":
|
||||||
extra = {
|
extra = {
|
||||||
|
|
@ -154,7 +141,6 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
||||||
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
|
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
|
||||||
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
||||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
external_app_id = str(
|
external_app_id = str(
|
||||||
|
|
@ -184,11 +170,8 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
||||||
"secret",
|
"secret",
|
||||||
"token",
|
"token",
|
||||||
"appToken",
|
"appToken",
|
||||||
"sendProgress",
|
|
||||||
"sendToolHints",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
extra.update(_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": ctype,
|
"id": ctype,
|
||||||
|
|
@ -219,8 +202,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"encryptKey": extra.get("encryptKey", ""),
|
"encryptKey": extra.get("encryptKey", ""),
|
||||||
"verificationToken": extra.get("verificationToken", ""),
|
"verificationToken": extra.get("verificationToken", ""),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "dingtalk":
|
if ctype == "dingtalk":
|
||||||
return {
|
return {
|
||||||
|
|
@ -228,8 +209,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"clientId": external_app_id,
|
"clientId": external_app_id,
|
||||||
"clientSecret": app_secret,
|
"clientSecret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "telegram":
|
if ctype == "telegram":
|
||||||
return {
|
return {
|
||||||
|
|
@ -238,8 +217,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"proxy": extra.get("proxy", ""),
|
"proxy": extra.get("proxy", ""),
|
||||||
"replyToMessage": bool(extra.get("replyToMessage", False)),
|
"replyToMessage": bool(extra.get("replyToMessage", False)),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "slack":
|
if ctype == "slack":
|
||||||
return {
|
return {
|
||||||
|
|
@ -251,8 +228,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"groupPolicy": extra.get("groupPolicy", "mention"),
|
"groupPolicy": extra.get("groupPolicy", "mention"),
|
||||||
"groupAllowFrom": extra.get("groupAllowFrom", []),
|
"groupAllowFrom": extra.get("groupAllowFrom", []),
|
||||||
"reactEmoji": extra.get("reactEmoji", "eyes"),
|
"reactEmoji": extra.get("reactEmoji", "eyes"),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "qq":
|
if ctype == "qq":
|
||||||
return {
|
return {
|
||||||
|
|
@ -260,8 +235,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"appId": external_app_id,
|
"appId": external_app_id,
|
||||||
"secret": app_secret,
|
"secret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "wecom":
|
if ctype == "wecom":
|
||||||
return {
|
return {
|
||||||
|
|
@ -270,15 +243,11 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"secret": app_secret,
|
"secret": app_secret,
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"welcomeMessage": str(extra.get("welcomeMessage") or ""),
|
"welcomeMessage": str(extra.get("welcomeMessage") or ""),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "weixin":
|
if ctype == "weixin":
|
||||||
return {
|
return {
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"token": app_secret,
|
"token": app_secret,
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
if ctype == "email":
|
if ctype == "email":
|
||||||
return {
|
return {
|
||||||
|
|
@ -303,8 +272,6 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
|
||||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
|
||||||
}
|
}
|
||||||
merged = dict(extra)
|
merged = dict(extra)
|
||||||
merged.update(
|
merged.update(
|
||||||
|
|
@ -323,8 +290,7 @@ def list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
||||||
channels_cfg = config_data.get("channels")
|
channels_cfg = config_data.get("channels")
|
||||||
if not isinstance(channels_cfg, dict):
|
if not isinstance(channels_cfg, dict):
|
||||||
channels_cfg = {}
|
channels_cfg = {}
|
||||||
dashboard_cfg = channels_cfg.get("dashboard")
|
send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg)
|
||||||
dashboard_extra = _delivery_extra(channels_cfg, dashboard_cfg if isinstance(dashboard_cfg, dict) else {})
|
|
||||||
rows: List[Dict[str, Any]] = [
|
rows: List[Dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"id": "dashboard",
|
"id": "dashboard",
|
||||||
|
|
@ -334,19 +300,17 @@ def list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
||||||
"app_secret": "",
|
"app_secret": "",
|
||||||
"internal_port": 9000,
|
"internal_port": 9000,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"extra_config": dashboard_extra,
|
"extra_config": {
|
||||||
|
"sendProgress": send_progress,
|
||||||
|
"sendToolHints": send_tool_hints,
|
||||||
|
},
|
||||||
"locked": True,
|
"locked": True,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for ctype, cfg in channels_cfg.items():
|
for ctype, cfg in channels_cfg.items():
|
||||||
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
|
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
|
||||||
continue
|
continue
|
||||||
row = channel_config_to_api(bot.id, ctype, cfg)
|
rows.append(channel_config_to_api(bot.id, ctype, cfg))
|
||||||
row["extra_config"] = {
|
|
||||||
**row.get("extra_config", {}),
|
|
||||||
**_delivery_extra(channels_cfg, cfg),
|
|
||||||
}
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -549,8 +513,8 @@ def sync_bot_workspace_channels(
|
||||||
bot_data.get("storage_gb"),
|
bot_data.get("storage_gb"),
|
||||||
)
|
)
|
||||||
bot_data.update(resources)
|
bot_data.update(resources)
|
||||||
send_progress = bool(bot_data.get("send_progress", True))
|
send_progress = bool(bot_data.get("send_progress", False))
|
||||||
send_tool_hints = bool(bot_data.get("send_tool_hints", True))
|
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
|
||||||
if isinstance(global_delivery_override, dict):
|
if isinstance(global_delivery_override, dict):
|
||||||
if "sendProgress" in global_delivery_override:
|
if "sendProgress" in global_delivery_override:
|
||||||
send_progress = bool(global_delivery_override.get("sendProgress"))
|
send_progress = bool(global_delivery_override.get("sendProgress"))
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ from schemas.platform import SystemSettingItem
|
||||||
|
|
||||||
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
|
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
|
||||||
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
|
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
|
||||||
DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600
|
|
||||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY = "workspace_preview_token_ttl_seconds"
|
|
||||||
SETTING_KEYS = (
|
SETTING_KEYS = (
|
||||||
"page_size",
|
"page_size",
|
||||||
"chat_pull_page_size",
|
"chat_pull_page_size",
|
||||||
|
|
@ -32,7 +30,7 @@ SETTING_KEYS = (
|
||||||
"workspace_download_extensions",
|
"workspace_download_extensions",
|
||||||
"speech_enabled",
|
"speech_enabled",
|
||||||
)
|
)
|
||||||
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY, WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}
|
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
|
||||||
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"page_size": {
|
"page_size": {
|
||||||
"name": "分页大小",
|
"name": "分页大小",
|
||||||
|
|
@ -97,15 +95,6 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"is_public": False,
|
"is_public": False,
|
||||||
"sort_order": 30,
|
"sort_order": 30,
|
||||||
},
|
},
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY: {
|
|
||||||
"name": "工作区预览 Token 过期秒数",
|
|
||||||
"category": "workspace",
|
|
||||||
"description": "HTML 预览地址中临时访问 Token 的默认有效时长,单位秒。",
|
|
||||||
"value_type": "integer",
|
|
||||||
"value": DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS,
|
|
||||||
"is_public": False,
|
|
||||||
"sort_order": 31,
|
|
||||||
},
|
|
||||||
"speech_enabled": {
|
"speech_enabled": {
|
||||||
"name": "语音识别开关",
|
"name": "语音识别开关",
|
||||||
"category": "speech",
|
"category": "speech",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from services.platform_settings_core import (
|
||||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||||
PROTECTED_SETTING_KEYS,
|
PROTECTED_SETTING_KEYS,
|
||||||
SYSTEM_SETTING_DEFINITIONS,
|
SYSTEM_SETTING_DEFINITIONS,
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY,
|
|
||||||
_normalize_setting_key,
|
_normalize_setting_key,
|
||||||
_read_setting_value,
|
_read_setting_value,
|
||||||
_setting_item_from_row,
|
_setting_item_from_row,
|
||||||
|
|
@ -31,19 +30,15 @@ def _prune_deprecated_system_settings(session: Session) -> None:
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _missing_required_system_settings(session: Session) -> List[str]:
|
def validate_required_system_settings(session: Session) -> None:
|
||||||
|
_prune_deprecated_system_settings(session)
|
||||||
stmt = select(PlatformSetting.key).where(PlatformSetting.key.in_(REQUIRED_SYSTEM_SETTING_KEYS))
|
stmt = select(PlatformSetting.key).where(PlatformSetting.key.in_(REQUIRED_SYSTEM_SETTING_KEYS))
|
||||||
present = {
|
present = {
|
||||||
str(key or "").strip()
|
str(key or "").strip()
|
||||||
for key in session.exec(stmt).all()
|
for key in session.exec(stmt).all()
|
||||||
if str(key or "").strip()
|
if str(key or "").strip()
|
||||||
}
|
}
|
||||||
return [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present]
|
missing = [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present]
|
||||||
|
|
||||||
|
|
||||||
def validate_required_system_settings(session: Session) -> None:
|
|
||||||
_prune_deprecated_system_settings(session)
|
|
||||||
missing = _missing_required_system_settings(session)
|
|
||||||
if missing:
|
if missing:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Database seed data is not initialized. "
|
"Database seed data is not initialized. "
|
||||||
|
|
@ -123,22 +118,3 @@ def get_activity_event_retention_days(session: Session) -> int:
|
||||||
"Fix the row manually or reapply scripts/sql/init-data.sql."
|
"Fix the row manually or reapply scripts/sql/init-data.sql."
|
||||||
) from exc
|
) from exc
|
||||||
return max(1, min(3650, value))
|
return max(1, min(3650, value))
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_preview_token_ttl_seconds(session: Session) -> int:
|
|
||||||
validate_required_system_settings(session)
|
|
||||||
row = session.get(PlatformSetting, WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY)
|
|
||||||
if row is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Database seed data is not initialized. "
|
|
||||||
f"Missing sys_setting key: {WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}. "
|
|
||||||
"Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
value = int(_read_setting_value(row))
|
|
||||||
except Exception as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"sys_setting value is invalid for key: {WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}. "
|
|
||||||
"Fix the row manually or reapply scripts/sql/init-data.sql."
|
|
||||||
) from exc
|
|
||||||
return max(60, value)
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,9 @@ import json
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from core.settings import DATABASE_URL
|
from core.settings import WORKSPACE_PREVIEW_SIGNING_SECRET, WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
HTML_PREVIEW_EXTENSIONS = {".html", ".htm"}
|
HTML_PREVIEW_EXTENSIONS = {".html", ".htm"}
|
||||||
DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600
|
|
||||||
_WORKSPACE_PREVIEW_SIGNING_KEY = DATABASE_URL
|
|
||||||
|
|
||||||
|
|
||||||
def _b64url_encode(raw: bytes) -> str:
|
def _b64url_encode(raw: bytes) -> str:
|
||||||
|
|
@ -33,7 +31,7 @@ def is_html_preview_path(path: str) -> bool:
|
||||||
def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
|
def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
|
||||||
normalized_bot_id = str(bot_id or "").strip()
|
normalized_bot_id = str(bot_id or "").strip()
|
||||||
normalized_path = normalize_workspace_preview_path(path)
|
normalized_path = normalize_workspace_preview_path(path)
|
||||||
ttl = max(60, int(ttl_seconds or DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS))
|
ttl = max(60, min(int(ttl_seconds or WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS), 86400))
|
||||||
expires_at = int(time.time()) + ttl
|
expires_at = int(time.time()) + ttl
|
||||||
payload = {
|
payload = {
|
||||||
"bot_id": normalized_bot_id,
|
"bot_id": normalized_bot_id,
|
||||||
|
|
@ -44,7 +42,7 @@ def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional
|
||||||
payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
body = _b64url_encode(payload_json)
|
body = _b64url_encode(payload_json)
|
||||||
signature = hmac.new(
|
signature = hmac.new(
|
||||||
_WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"),
|
WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"),
|
||||||
body.encode("ascii"),
|
body.encode("ascii"),
|
||||||
hashlib.sha256,
|
hashlib.sha256,
|
||||||
).digest()
|
).digest()
|
||||||
|
|
@ -61,7 +59,7 @@ def resolve_workspace_preview_token(token: str) -> Optional[Dict[str, Any]]:
|
||||||
return None
|
return None
|
||||||
body, signature_raw = raw_token.split(".", 1)
|
body, signature_raw = raw_token.split(".", 1)
|
||||||
expected_signature = hmac.new(
|
expected_signature = hmac.new(
|
||||||
_WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"),
|
WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"),
|
||||||
body.encode("ascii"),
|
body.encode("ascii"),
|
||||||
hashlib.sha256,
|
hashlib.sha256,
|
||||||
).digest()
|
).digest()
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN python -m venv /opt/venv
|
RUN python -m venv /opt/venv
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml hatch_build.py README.md LICENSE THIRD_PARTY_NOTICES.md ./
|
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md ./
|
||||||
|
|
||||||
# 3. 先安装第三方依赖。该层只依赖 pyproject.toml,源码改动不会触发整套依赖重装。
|
# 3. 先安装第三方依赖。该层只依赖 pyproject.toml,源码改动不会触发整套依赖重装。
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Bot Images 使用说明
|
||||||
|
|
||||||
|
这个目录用于构建 `nanobot-base` 相关镜像。
|
||||||
|
|
||||||
|
## 1. 直接构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bot-images/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后脚本会提示你选择:
|
||||||
|
|
||||||
|
- `1` 从远程拉取最新版本再构建
|
||||||
|
- `2` 从本地已有 `nanobot-base-*` 目录中选择一个构建
|
||||||
|
|
||||||
|
默认行为只构建 Docker 镜像,不导出压缩产物。
|
||||||
|
|
||||||
|
## 2. 构建后同时导出镜像产物
|
||||||
|
|
||||||
|
如果需要把构建好的镜像发给没有源码的客户,可以加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bot-images/build.sh --save-artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
执行完成后,会在你运行命令时的当前目录生成一个压缩包,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nanobot-base-v0.1.5.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
这个压缩包本质上是 `docker save | gzip` 的产物,可以直接拿到客户机器上导入。
|
||||||
|
|
||||||
|
## 3. 指定导出目录
|
||||||
|
|
||||||
|
如果不想导出到当前目录,可以指定目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bot-images/build.sh --artifact-dir /path/to/output
|
||||||
|
```
|
||||||
|
|
||||||
|
执行完成后,镜像压缩包会输出到指定目录。
|
||||||
|
|
||||||
|
## 4. 客户侧如何导入
|
||||||
|
|
||||||
|
客户机器上拿到压缩包后可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c nanobot-base-v0.1.5.tar.gz | docker load
|
||||||
|
```
|
||||||
|
|
||||||
|
导入完成后,可用下面命令确认:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker images | grep nanobot-base
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 说明
|
||||||
|
|
||||||
|
- `--artifact-dir` 会自动包含 `--save-artifact` 的效果。
|
||||||
|
- 如果只是本机构建测试,不需要加导出参数。
|
||||||
|
- 如果后续要给多个客户复用,推荐保留导出的 `.tar.gz`,这样不需要客户再拿源码构建。
|
||||||
|
|
@ -2,12 +2,71 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CALLER_DIR="$(pwd)"
|
||||||
|
SAVE_ARTIFACT=false
|
||||||
|
ARTIFACT_DIR=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [--save-artifact] [--artifact-dir PATH]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--save-artifact Build image and export a compressed docker image archive.
|
||||||
|
Default export location is the current shell directory.
|
||||||
|
--artifact-dir Custom directory for exported archive. Implies --save-artifact.
|
||||||
|
-h, --help Show this help message.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--save-artifact)
|
||||||
|
SAVE_ARTIFACT=true
|
||||||
|
;;
|
||||||
|
--artifact-dir)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "缺少 --artifact-dir 的路径参数。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SAVE_ARTIFACT=true
|
||||||
|
ARTIFACT_DIR="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知参数: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
export_image_artifact() {
|
||||||
|
local image_name=$1
|
||||||
|
local version=$2
|
||||||
|
local output_dir="${ARTIFACT_DIR:-$CALLER_DIR}"
|
||||||
|
local archive_name="nanobot-base-${version}.tar.gz"
|
||||||
|
local archive_path="${output_dir}/${archive_name}"
|
||||||
|
|
||||||
|
mkdir -p "${output_dir}"
|
||||||
|
|
||||||
|
echo ">> [额外步骤] 导出镜像产物到: ${archive_path}"
|
||||||
|
docker save "${image_name}" | gzip > "${archive_path}"
|
||||||
|
echo "✅ 已导出镜像产物: ${archive_path}"
|
||||||
|
}
|
||||||
|
|
||||||
# 执行拷贝和打包的核心函数
|
# 执行拷贝和打包的核心函数
|
||||||
build_image() {
|
build_image() {
|
||||||
local dir_name=$1
|
local dir_name=$1
|
||||||
local version=$2
|
local version=$2
|
||||||
local image_name="nanobot-base:${version}"
|
local image_name="nanobot-base:${version}"
|
||||||
|
local previous_dir="${PWD}"
|
||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "准备构建镜像: ${image_name}"
|
echo "准备构建镜像: ${image_name}"
|
||||||
|
|
@ -39,8 +98,16 @@ build_image() {
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "✅ 构建完成: ${image_name}"
|
echo "✅ 构建完成: ${image_name}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
|
cd "${previous_dir}"
|
||||||
|
|
||||||
|
if [ "${SAVE_ARTIFACT}" = "true" ]; then
|
||||||
|
export_image_artifact "${image_name}" "${version}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
echo "请选择操作模式:"
|
echo "请选择操作模式:"
|
||||||
echo "1) 从 Git 拉取最新代码并打包 (会覆盖已有同名目录)"
|
echo "1) 从 Git 拉取最新代码并打包 (会覆盖已有同名目录)"
|
||||||
echo "2) 扫描本地已有的目录并打包"
|
echo "2) 扫描本地已有的目录并打包"
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,163 @@
|
||||||
services:
|
# Dashboard Nanobot Docker 编排文件(Full 模式)
|
||||||
postgres:
|
# 说明:
|
||||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
# 1. 当前文件用于“前端 + 后端 + PostgreSQL + Redis”整套部署。
|
||||||
container_name: dashboard-nanobot-postgres
|
# 2. 数据库和 Redis 都在本 compose 中启动,适合客户没有外部中间件时使用。
|
||||||
restart: unless-stopped
|
# 3. 客户通常需要重点修改 `.env` 里的端口、数据库密码、工作目录路径。
|
||||||
environment:
|
# 4. 如果需要调整宿主机挂载路径,可以直接修改下方 `volumes`。
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
|
||||||
POSTGRES_USER: ${POSTGRES_SUPERUSER:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_SUPERPASSWORD:?POSTGRES_SUPERPASSWORD is required}
|
|
||||||
POSTGRES_DB: ${POSTGRES_BOOTSTRAP_DB:-postgres}
|
|
||||||
volumes:
|
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
|
||||||
expose:
|
|
||||||
- "5432"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 20s
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
redis:
|
services: # 定义当前 compose 里需要启动的服务
|
||||||
image: ${REDIS_IMAGE:-redis:7-alpine}
|
postgres: # PostgreSQL 数据库服务
|
||||||
container_name: dashboard-nanobot-redis
|
image: ${POSTGRES_IMAGE:-postgres:16-alpine} # PostgreSQL 镜像名
|
||||||
restart: unless-stopped
|
container_name: dashboard-nanobot-postgres # PostgreSQL 容器固定名称
|
||||||
environment:
|
restart: unless-stopped # 异常退出后自动拉起
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
environment: # PostgreSQL 启动参数
|
||||||
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
|
TZ: ${TZ:-Asia/Shanghai} # 容器时区
|
||||||
volumes:
|
POSTGRES_USER: ${POSTGRES_SUPERUSER:-postgres} # PostgreSQL 超级用户
|
||||||
- ./data/redis:/data
|
POSTGRES_PASSWORD: ${POSTGRES_SUPERPASSWORD:?POSTGRES_SUPERPASSWORD is required} # PostgreSQL 超级用户密码
|
||||||
expose:
|
POSTGRES_DB: ${POSTGRES_BOOTSTRAP_DB:-postgres} # 启动时默认数据库
|
||||||
- "6379"
|
volumes: # 数据库存储挂载
|
||||||
healthcheck:
|
- ./data/postgres:/var/lib/postgresql/data # 宿主机数据库目录映射到容器数据目录
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
expose: # 仅对内部网络开放
|
||||||
interval: 10s
|
- "5432" # PostgreSQL 默认端口
|
||||||
timeout: 5s
|
healthcheck: # PostgreSQL 健康检查
|
||||||
retries: 10
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] # 检测数据库是否就绪
|
||||||
start_period: 10s
|
interval: 10s # 每 10 秒检查一次
|
||||||
logging:
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
driver: json-file
|
retries: 10 # 连续失败 10 次判定不健康
|
||||||
options:
|
start_period: 20s # 启动后预留 20 秒缓冲时间
|
||||||
max-size: "20m"
|
logging: # 容器日志策略
|
||||||
max-file: "3"
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
|
options: # 日志滚动配置
|
||||||
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
|
|
||||||
backend:
|
redis: # Redis 缓存服务
|
||||||
build:
|
image: ${REDIS_IMAGE:-redis:7-alpine} # Redis 镜像名
|
||||||
context: .
|
container_name: dashboard-nanobot-redis # Redis 容器固定名称
|
||||||
dockerfile: backend/Dockerfile
|
restart: unless-stopped # 异常退出后自动拉起
|
||||||
args:
|
environment: # Redis 运行环境变量
|
||||||
PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim}
|
TZ: ${TZ:-Asia/Shanghai} # 容器时区
|
||||||
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple}
|
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"] # 开启 AOF 持久化并保留快照策略
|
||||||
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
|
volumes: # Redis 数据挂载
|
||||||
image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest}
|
- ./data/redis:/data # 宿主机 Redis 数据目录映射到容器
|
||||||
container_name: dashboard-nanobot-backend
|
expose: # 仅对内部网络开放
|
||||||
restart: unless-stopped
|
- "6379" # Redis 默认端口
|
||||||
depends_on:
|
healthcheck: # Redis 健康检查
|
||||||
postgres:
|
test: ["CMD", "redis-cli", "ping"] # 检测 Redis 是否响应
|
||||||
condition: service_healthy
|
interval: 10s # 每 10 秒检查一次
|
||||||
redis:
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
condition: service_healthy
|
retries: 10 # 连续失败 10 次判定不健康
|
||||||
environment:
|
start_period: 10s # 启动后预留 10 秒缓冲时间
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
logging: # 容器日志策略
|
||||||
APP_HOST: 0.0.0.0
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
APP_PORT: 8002
|
options: # 日志滚动配置
|
||||||
APP_RELOAD: "false"
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
DATABASE_ECHO: "false"
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20}
|
|
||||||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
|
||||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
|
||||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
|
||||||
DATA_ROOT: /app/data
|
|
||||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
|
||||||
DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB}
|
|
||||||
REDIS_ENABLED: ${REDIS_ENABLED:-true}
|
|
||||||
REDIS_URL: redis://redis:6379/${REDIS_DB:-8}
|
|
||||||
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
|
||||||
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
|
||||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
|
||||||
WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-}
|
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600}
|
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
|
||||||
STT_ENABLED: ${STT_ENABLED:-true}
|
|
||||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
|
||||||
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
|
||||||
STT_DEVICE: ${STT_DEVICE:-cpu}
|
|
||||||
STT_MAX_AUDIO_SECONDS: ${STT_MAX_AUDIO_SECONDS:-20}
|
|
||||||
STT_DEFAULT_LANGUAGE: ${STT_DEFAULT_LANGUAGE:-zh}
|
|
||||||
STT_FORCE_SIMPLIFIED: ${STT_FORCE_SIMPLIFIED:-true}
|
|
||||||
STT_AUDIO_PREPROCESS: ${STT_AUDIO_PREPROCESS:-true}
|
|
||||||
STT_AUDIO_FILTER: ${STT_AUDIO_FILTER:-highpass=f=120,lowpass=f=7600,afftdn=nf=-20}
|
|
||||||
STT_INITIAL_PROMPT: ${STT_INITIAL_PROMPT:-以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。}
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./data:/app/data
|
|
||||||
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT}
|
|
||||||
expose:
|
|
||||||
- "8002"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/api/health', timeout=3).read()"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
nginx:
|
backend: # 后端 API 服务
|
||||||
build:
|
build: # 保留构建信息,便于有源码时重新 build;离线部署通常直接用 image
|
||||||
context: ./frontend
|
context: . # 构建上下文为项目根目录
|
||||||
dockerfile: Dockerfile
|
dockerfile: backend/Dockerfile # 后端 Dockerfile 路径
|
||||||
args:
|
args: # 构建参数
|
||||||
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:22-alpine}
|
PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim} # Python 基础镜像
|
||||||
NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine}
|
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple} # pip 源地址
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
|
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-} # pip 信任主机
|
||||||
VITE_API_BASE: /api
|
image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest} # 后端运行镜像名
|
||||||
VITE_WS_BASE: /ws/monitor
|
container_name: dashboard-nanobot-backend # 后端容器固定名称
|
||||||
image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest}
|
restart: unless-stopped # 异常退出后自动拉起
|
||||||
container_name: dashboard-nanobot-nginx
|
depends_on: # 依赖中间件健康后再启动
|
||||||
restart: unless-stopped
|
postgres: # 依赖 PostgreSQL
|
||||||
environment:
|
condition: service_healthy # 要求 PostgreSQL 健康
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
redis: # 依赖 Redis
|
||||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
condition: service_healthy # 要求 Redis 健康
|
||||||
depends_on:
|
environment: # 后端环境变量
|
||||||
backend:
|
TZ: ${TZ:-Asia/Shanghai} # 容器时区
|
||||||
condition: service_healthy
|
APP_HOST: 0.0.0.0 # 服务监听地址,容器内通常保持 0.0.0.0
|
||||||
ports:
|
APP_PORT: 8000 # 服务监听端口,需与 expose 和健康检查保持一致
|
||||||
- "${NGINX_PORT}:80"
|
APP_RELOAD: "false" # 生产环境关闭热重载
|
||||||
healthcheck:
|
DATABASE_ECHO: "false" # 生产环境关闭 SQL 日志回显
|
||||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
|
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20} # 数据库连接池基础连接数
|
||||||
interval: 15s
|
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40} # 连接池额外可溢出连接数
|
||||||
timeout: 5s
|
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30} # 获取连接超时时间(秒)
|
||||||
retries: 5
|
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} # 连接回收周期(秒)
|
||||||
start_period: 10s
|
DATA_ROOT: /app/data # 容器内业务数据目录
|
||||||
logging:
|
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} # Bot 工作目录,容器内外路径保持一致
|
||||||
driver: json-file
|
DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} # 业务容器使用的 Docker 网络名称
|
||||||
options:
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB} # Full 模式固定连接内部 PostgreSQL
|
||||||
max-size: "20m"
|
REDIS_ENABLED: ${REDIS_ENABLED:-true} # Full 模式默认启用 Redis
|
||||||
max-file: "3"
|
REDIS_URL: redis://redis:6379/${REDIS_DB:-8} # Full 模式固定连接内部 Redis
|
||||||
|
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} # Redis key 前缀
|
||||||
|
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} # Redis 默认过期时间(秒)
|
||||||
|
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} # Bot 默认时区
|
||||||
|
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} # 面板访问密码
|
||||||
|
WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-} # 预览签名密钥
|
||||||
|
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600} # 预览令牌有效期(秒)
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} # 前端跨域白名单
|
||||||
|
STT_ENABLED: ${STT_ENABLED:-true} # 是否启用语音识别
|
||||||
|
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} # 语音识别模型文件名
|
||||||
|
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model} # 语音识别模型目录
|
||||||
|
STT_DEVICE: ${STT_DEVICE:-cpu} # 语音识别运行设备
|
||||||
|
STT_MAX_AUDIO_SECONDS: ${STT_MAX_AUDIO_SECONDS:-20} # 单次音频最大秒数
|
||||||
|
STT_DEFAULT_LANGUAGE: ${STT_DEFAULT_LANGUAGE:-zh} # 默认语音识别语言
|
||||||
|
STT_FORCE_SIMPLIFIED: ${STT_FORCE_SIMPLIFIED:-true} # 是否强制输出简体中文
|
||||||
|
STT_AUDIO_PREPROCESS: ${STT_AUDIO_PREPROCESS:-true} # 是否预处理音频
|
||||||
|
STT_AUDIO_FILTER: ${STT_AUDIO_FILTER:-highpass=f=120,lowpass=f=7600,afftdn=nf=-20} # 音频滤波参数
|
||||||
|
STT_INITIAL_PROMPT: ${STT_INITIAL_PROMPT:-以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。} # 语音识别初始提示词
|
||||||
|
volumes: # 宿主机与容器挂载关系
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # 必须保留,后端需要管理 Bot 容器
|
||||||
|
- ./data:/app/data # 项目数据目录,建议保留在交付目录下
|
||||||
|
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT} # Bot 工作目录挂载,路径通常由客户现场决定
|
||||||
|
expose: # 只暴露给内部网络,不直接发布到宿主机
|
||||||
|
- "8000" # 后端服务端口
|
||||||
|
healthcheck: # 健康检查,供 nginx 依赖判断使用
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).read()"] # 检测后端健康接口
|
||||||
|
interval: 15s # 每 15 秒检查一次
|
||||||
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
|
retries: 5 # 连续失败 5 次判定不健康
|
||||||
|
start_period: 20s # 启动后预留 20 秒缓冲时间
|
||||||
|
logging: # 容器日志策略
|
||||||
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
|
options: # 日志滚动配置
|
||||||
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
|
|
||||||
networks:
|
nginx: # 前端 Nginx 服务
|
||||||
default:
|
build: # 保留构建信息,离线部署通常直接使用导入镜像
|
||||||
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
context: ./frontend # 前端构建上下文
|
||||||
external: true
|
dockerfile: Dockerfile # 前端 Dockerfile 路径
|
||||||
|
args: # 前端构建参数
|
||||||
|
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:22-alpine} # Node 基础镜像
|
||||||
|
NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine} # Nginx 基础镜像
|
||||||
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/} # npm 源地址
|
||||||
|
VITE_API_BASE: /api # 前端 API 前缀
|
||||||
|
VITE_WS_BASE: /ws/monitor # 前端 WebSocket 前缀
|
||||||
|
image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest} # 前端运行镜像名
|
||||||
|
container_name: dashboard-nanobot-nginx # 前端容器固定名称
|
||||||
|
restart: unless-stopped # 异常退出后自动拉起
|
||||||
|
environment: # 前端环境变量
|
||||||
|
TZ: ${TZ:-Asia/Shanghai} # 容器时区
|
||||||
|
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} # 上传大小限制,传给 Nginx 配置使用
|
||||||
|
depends_on: # 依赖后端健康后再启动
|
||||||
|
backend: # 依赖后端容器
|
||||||
|
condition: service_healthy # 要求后端健康
|
||||||
|
ports: # 对外开放端口
|
||||||
|
- "${NGINX_PORT}:80" # 宿主机端口映射到容器 80 端口
|
||||||
|
healthcheck: # Nginx 健康检查
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] # 检测首页是否可访问
|
||||||
|
interval: 15s # 每 15 秒检查一次
|
||||||
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
|
retries: 5 # 连续失败 5 次判定不健康
|
||||||
|
start_period: 10s # 启动后预留 10 秒缓冲时间
|
||||||
|
logging: # 容器日志策略
|
||||||
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
|
options: # 日志滚动配置
|
||||||
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
|
|
||||||
|
networks: # 自定义网络配置
|
||||||
|
default: # 默认网络
|
||||||
|
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} # 网络名称,客户通常无需修改
|
||||||
|
driver: bridge # 使用 bridge 网络驱动
|
||||||
|
ipam: # IP 地址管理配置
|
||||||
|
config: # 网段配置列表
|
||||||
|
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16} # 自定义子网,现场冲突时可修改
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,108 @@
|
||||||
services:
|
# Dashboard Nanobot Docker 编排文件(Prod 模式)
|
||||||
backend:
|
# 说明:
|
||||||
build:
|
# 1. 当前文件用于“前端 + 后端”部署。
|
||||||
context: .
|
# 2. PostgreSQL 与 Redis 由客户现场外部提供,本文件不会启动这两个服务。
|
||||||
dockerfile: backend/Dockerfile
|
# 3. 客户通常需要重点修改 `.env` 里的端口、数据库连接串、Redis 连接串、工作目录路径。
|
||||||
args:
|
# 4. 如果需要调整宿主机挂载路径,可以直接修改下方 `volumes`。
|
||||||
PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim}
|
|
||||||
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple}
|
|
||||||
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
|
|
||||||
image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest}
|
|
||||||
container_name: dashboard-nanobot-backend
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
APP_HOST: 0.0.0.0
|
|
||||||
APP_PORT: 8002
|
|
||||||
APP_RELOAD: "false"
|
|
||||||
DATABASE_ECHO: "false"
|
|
||||||
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20}
|
|
||||||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
|
||||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
|
||||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
|
||||||
DATA_ROOT: /app/data
|
|
||||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
|
||||||
DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-}
|
|
||||||
REDIS_ENABLED: ${REDIS_ENABLED:-false}
|
|
||||||
REDIS_URL: ${REDIS_URL:-}
|
|
||||||
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
|
||||||
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
|
||||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
|
||||||
WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-}
|
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600}
|
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
|
||||||
STT_ENABLED: ${STT_ENABLED:-true}
|
|
||||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
|
||||||
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
|
||||||
STT_DEVICE: ${STT_DEVICE:-cpu}
|
|
||||||
STT_MAX_AUDIO_SECONDS: ${STT_MAX_AUDIO_SECONDS:-20}
|
|
||||||
STT_DEFAULT_LANGUAGE: ${STT_DEFAULT_LANGUAGE:-zh}
|
|
||||||
STT_FORCE_SIMPLIFIED: ${STT_FORCE_SIMPLIFIED:-true}
|
|
||||||
STT_AUDIO_PREPROCESS: ${STT_AUDIO_PREPROCESS:-true}
|
|
||||||
STT_AUDIO_FILTER: ${STT_AUDIO_FILTER:-highpass=f=120,lowpass=f=7600,afftdn=nf=-20}
|
|
||||||
STT_INITIAL_PROMPT: ${STT_INITIAL_PROMPT:-以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。}
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./data:/app/data
|
|
||||||
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT}
|
|
||||||
expose:
|
|
||||||
- "8002"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/api/health', timeout=3).read()"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
nginx:
|
services: # 定义当前 compose 里需要启动的服务
|
||||||
build:
|
backend: # 后端 API 服务
|
||||||
context: ./frontend
|
build: # 保留构建信息,便于有源码时重新 build;离线部署通常直接用 image
|
||||||
dockerfile: Dockerfile
|
context: . # 构建上下文为项目根目录
|
||||||
args:
|
dockerfile: backend/Dockerfile # 后端 Dockerfile 路径
|
||||||
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:22-alpine}
|
args: # 构建参数
|
||||||
NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine}
|
PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-python:3.12-slim} # Python 基础镜像
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
|
PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.org/simple} # pip 源地址
|
||||||
VITE_API_BASE: /api
|
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-} # pip 信任主机
|
||||||
VITE_WS_BASE: /ws/monitor
|
image: dashboard-nanobot/backend:${BACKEND_IMAGE_TAG:-latest} # 后端运行镜像名
|
||||||
image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest}
|
container_name: dashboard-nanobot-backend # 后端容器固定名称
|
||||||
container_name: dashboard-nanobot-nginx
|
restart: unless-stopped # 异常退出后自动拉起,手动停止时不自动启动
|
||||||
restart: unless-stopped
|
environment: # 后端环境变量
|
||||||
environment:
|
APP_HOST: 0.0.0.0 # 服务监听地址,容器内通常保持 0.0.0.0
|
||||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
APP_PORT: 8002 # 服务监听端口,需与 expose 和健康检查保持一致
|
||||||
depends_on:
|
APP_RELOAD: "false" # 生产环境关闭热重载
|
||||||
backend:
|
DATABASE_ECHO: "false" # 生产环境关闭 SQL 日志回显
|
||||||
condition: service_healthy
|
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20} # 数据库连接池基础连接数
|
||||||
ports:
|
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40} # 连接池额外可溢出连接数
|
||||||
- "${NGINX_PORT}:80"
|
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30} # 获取连接超时时间(秒)
|
||||||
healthcheck:
|
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} # 连接回收周期(秒)
|
||||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
|
DATA_ROOT: /app/data # 容器内业务数据目录
|
||||||
interval: 15s
|
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} # Bot 工作目录,容器内外路径保持一致
|
||||||
timeout: 5s
|
DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} # 业务容器使用的 Docker 网络名称
|
||||||
retries: 5
|
DATABASE_URL: ${DATABASE_URL:-} # 外部 PostgreSQL 连接串,Prod 模式必须配置
|
||||||
start_period: 10s
|
REDIS_ENABLED: ${REDIS_ENABLED:-false} # 是否启用 Redis
|
||||||
logging:
|
REDIS_URL: ${REDIS_URL:-} # 外部 Redis 连接串
|
||||||
driver: json-file
|
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} # Redis key 前缀
|
||||||
options:
|
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} # Redis 默认过期时间(秒)
|
||||||
max-size: "20m"
|
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai} # Bot 默认时区
|
||||||
max-file: "3"
|
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} # 面板访问密码
|
||||||
|
WORKSPACE_PREVIEW_SIGNING_SECRET: ${WORKSPACE_PREVIEW_SIGNING_SECRET:-} # 预览签名密钥
|
||||||
|
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: ${WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS:-3600} # 预览令牌有效期(秒)
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} # 前端跨域白名单
|
||||||
|
STT_ENABLED: ${STT_ENABLED:-true} # 是否启用语音识别
|
||||||
|
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} # 语音识别模型文件名
|
||||||
|
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model} # 语音识别模型目录
|
||||||
|
STT_DEVICE: ${STT_DEVICE:-cpu} # 语音识别运行设备
|
||||||
|
STT_MAX_AUDIO_SECONDS: ${STT_MAX_AUDIO_SECONDS:-20} # 单次音频最大秒数
|
||||||
|
STT_DEFAULT_LANGUAGE: ${STT_DEFAULT_LANGUAGE:-zh} # 默认语音识别语言
|
||||||
|
STT_FORCE_SIMPLIFIED: ${STT_FORCE_SIMPLIFIED:-true} # 是否强制输出简体中文
|
||||||
|
STT_AUDIO_PREPROCESS: ${STT_AUDIO_PREPROCESS:-true} # 是否预处理音频
|
||||||
|
STT_AUDIO_FILTER: ${STT_AUDIO_FILTER:-highpass=f=120,lowpass=f=7600,afftdn=nf=-20} # 音频滤波参数
|
||||||
|
STT_INITIAL_PROMPT: ${STT_INITIAL_PROMPT:-以下内容可能包含简体中文和英文术语。请优先输出简体中文,英文单词、缩写、品牌名和数字保持原文,不要翻译。} # 语音识别初始提示词
|
||||||
|
volumes: # 宿主机与容器挂载关系
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # 必须保留,后端需要管理 Bot 容器
|
||||||
|
- ./data:/app/data # 项目数据目录,建议保留在交付目录下
|
||||||
|
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT} # Bot 工作目录挂载,路径通常由客户现场决定
|
||||||
|
expose: # 只暴露给内部网络,不直接发布到宿主机
|
||||||
|
- "8002" # 后端服务端口
|
||||||
|
healthcheck: # 健康检查,供 nginx 依赖判断使用
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/api/health', timeout=3).read()"] # 检测后端健康接口
|
||||||
|
interval: 15s # 每 15 秒检查一次
|
||||||
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
|
retries: 5 # 连续失败 5 次判定不健康
|
||||||
|
start_period: 20s # 启动后预留 20 秒缓冲时间
|
||||||
|
logging: # 容器日志策略
|
||||||
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
|
options: # 日志滚动配置
|
||||||
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
|
|
||||||
networks:
|
nginx: # 前端 Nginx 服务
|
||||||
default:
|
build: # 保留构建信息,离线部署通常直接使用导入镜像
|
||||||
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
context: ./frontend # 前端构建上下文
|
||||||
external: true
|
dockerfile: Dockerfile # 前端 Dockerfile 路径
|
||||||
|
args: # 前端构建参数
|
||||||
|
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-node:22-alpine} # Node 基础镜像
|
||||||
|
NGINX_BASE_IMAGE: ${NGINX_BASE_IMAGE:-nginx:alpine} # Nginx 基础镜像
|
||||||
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/} # npm 源地址
|
||||||
|
VITE_API_BASE: /api # 前端 API 前缀
|
||||||
|
VITE_WS_BASE: /ws/monitor # 前端 WebSocket 前缀
|
||||||
|
image: dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG:-latest} # 前端运行镜像名
|
||||||
|
container_name: dashboard-nanobot-nginx # 前端容器固定名称
|
||||||
|
restart: unless-stopped # 异常退出后自动拉起
|
||||||
|
environment: # 前端环境变量
|
||||||
|
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} # 上传大小限制,传给 Nginx 配置使用
|
||||||
|
depends_on: # 依赖后端健康后再启动
|
||||||
|
backend: # 依赖后端容器
|
||||||
|
condition: service_healthy # 要求后端健康
|
||||||
|
ports: # 对外开放端口
|
||||||
|
- "${NGINX_PORT}:80" # 宿主机端口映射到容器 80 端口
|
||||||
|
healthcheck: # Nginx 健康检查
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] # 检测首页是否可访问
|
||||||
|
interval: 15s # 每 15 秒检查一次
|
||||||
|
timeout: 5s # 单次检查超时 5 秒
|
||||||
|
retries: 5 # 连续失败 5 次判定不健康
|
||||||
|
start_period: 10s # 启动后预留 10 秒缓冲时间
|
||||||
|
logging: # 容器日志策略
|
||||||
|
driver: json-file # 使用 Docker 默认 json-file 日志驱动
|
||||||
|
options: # 日志滚动配置
|
||||||
|
max-size: "20m" # 单个日志文件最大 20MB
|
||||||
|
max-file: "3" # 最多保留 3 个日志文件
|
||||||
|
|
||||||
|
networks: # 自定义网络配置
|
||||||
|
default: # 默认网络
|
||||||
|
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} # 网络名称,客户通常无需修改
|
||||||
|
driver: bridge # 使用 bridge 网络驱动
|
||||||
|
ipam: # IP 地址管理配置
|
||||||
|
config: # 网段配置列表
|
||||||
|
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16} # 自定义子网,现场冲突时可修改
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Whisper ggml 模型下载脚本
|
||||||
|
# 下载的模型将存放在 data/model 目录下
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 确保在项目根目录运行
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TARGET_DIR="${SCRIPT_DIR}/data/model"
|
||||||
|
|
||||||
|
# 创建目标目录
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
|
||||||
|
# Hugging Face 的 whisper.cpp 模型仓库地址
|
||||||
|
# 使用 hf-mirror 镜像加速国内下载
|
||||||
|
BASE_URL="https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "请选择要下载的 Whisper 语音识别模型:"
|
||||||
|
echo "1) tiny (最小,速度最快,约 40MB)"
|
||||||
|
echo "2) base (基础,速度快,约 74MB)"
|
||||||
|
echo "3) small (当前默认,平衡,约 240MB)"
|
||||||
|
echo "4) medium (推荐,效果更好,约 770MB)"
|
||||||
|
echo "5) large-v3 (最大,效果最好,约 1.5GB)"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
read -p "请输入对应的数字 [1-5,默认 4]: " choice
|
||||||
|
|
||||||
|
MODEL_NAME=""
|
||||||
|
case $choice in
|
||||||
|
1) MODEL_NAME="ggml-tiny-q8_0.bin" ;;
|
||||||
|
2) MODEL_NAME="ggml-base-q8_0.bin" ;;
|
||||||
|
3) MODEL_NAME="ggml-small-q8_0.bin" ;;
|
||||||
|
4|"") MODEL_NAME="ggml-medium-q8_0.bin" ;;
|
||||||
|
5) MODEL_NAME="ggml-large-v3-q8_0.bin" ;;
|
||||||
|
*)
|
||||||
|
echo "无效的选择,使用默认选项 4 (medium)"
|
||||||
|
MODEL_NAME="ggml-medium-q8_0.bin"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "准备下载模型: $MODEL_NAME"
|
||||||
|
echo "保存路径: $TARGET_DIR/$MODEL_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 下载文件
|
||||||
|
DOWNLOAD_URL="${BASE_URL}/${MODEL_NAME}"
|
||||||
|
|
||||||
|
if command -v wget >/dev/null 2>&1; then
|
||||||
|
echo "使用 wget 下载中..."
|
||||||
|
wget -c --show-progress "$DOWNLOAD_URL" -O "$TARGET_DIR/$MODEL_NAME"
|
||||||
|
elif command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "使用 curl 下载中..."
|
||||||
|
curl -L -C - -o "$TARGET_DIR/$MODEL_NAME" "$DOWNLOAD_URL"
|
||||||
|
else
|
||||||
|
echo "错误:找不到 wget 或 curl 命令,无法下载文件。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ 下载成功!"
|
||||||
|
echo "模型文件已保存到: $TARGET_DIR/$MODEL_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "请记得更新 .env.prod 或 backend/.env 文件中的配置:"
|
||||||
|
echo "STT_MODEL=$MODEL_NAME"
|
||||||
|
echo "修改后重启后端容器即可生效。"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ 下载失败,请检查网络连接或稍后重试。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const appZhCn = {
|
export const appZhCn = {
|
||||||
title: 'Nanobot 管理面板',
|
title: 'Unisbot 管理面板',
|
||||||
theme: '主题',
|
theme: '主题',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
dark: '深色',
|
dark: '深色',
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ export const dashboardEn = {
|
||||||
modelName: 'Model Name',
|
modelName: 'Model Name',
|
||||||
modelNamePlaceholder: 'e.g. qwen-plus',
|
modelNamePlaceholder: 'e.g. qwen-plus',
|
||||||
newApiKey: 'API Key',
|
newApiKey: 'API Key',
|
||||||
newApiKeyPlaceholder: 'Enter API Key',
|
newApiKeyPlaceholder: 'Enter API key',
|
||||||
testing: 'Testing...',
|
testing: 'Testing...',
|
||||||
testModelConnection: 'Test model connection',
|
testModelConnection: 'Test model connection',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import axios from 'axios'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx'
|
import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx'
|
||||||
import { setupBotAccessAuth } from './utils/botAccess.ts'
|
import { setupBotAccessAuth } from './utils/botAccess.ts'
|
||||||
import { setupPanelAccessAuth } from './utils/panelAccess.ts'
|
import { setupPanelAccessAuth } from './utils/panelAccess.ts'
|
||||||
|
|
||||||
|
axios.defaults.withCredentials = true
|
||||||
|
|
||||||
setupPanelAccessAuth();
|
setupPanelAccessAuth();
|
||||||
setupBotAccessAuth();
|
setupBotAccessAuth();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,40 +53,6 @@ function isChannelConfigured(channel: BotChannel): boolean {
|
||||||
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
|
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelDeliveryFields({
|
|
||||||
channel,
|
|
||||||
labels,
|
|
||||||
onPatch,
|
|
||||||
}: {
|
|
||||||
channel: BotChannel;
|
|
||||||
labels: Pick<ChannelConfigLabels, 'sendProgress' | 'sendToolHints'>;
|
|
||||||
onPatch: (patch: Partial<BotChannel>) => void;
|
|
||||||
}) {
|
|
||||||
const extra = channel.extra_config || {};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<label className="field-label ops-channel-delivery-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(extra.sendProgress)}
|
|
||||||
onChange={(e) => onPatch({ extra_config: { ...extra, sendProgress: e.target.checked } })}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
{labels.sendProgress}
|
|
||||||
</label>
|
|
||||||
<label className="field-label ops-channel-delivery-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(extra.sendToolHints)}
|
|
||||||
onChange={(e) => onPatch({ extra_config: { ...extra, sendToolHints: e.target.checked } })}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
{labels.sendToolHints}
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelFieldsEditor({
|
function ChannelFieldsEditor({
|
||||||
channel,
|
channel,
|
||||||
labels,
|
labels,
|
||||||
|
|
@ -336,18 +302,23 @@ function ChannelFieldsEditor({
|
||||||
interface ChannelConfigModalProps {
|
interface ChannelConfigModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
channels: BotChannel[];
|
channels: BotChannel[];
|
||||||
|
globalDelivery: { sendProgress: boolean; sendToolHints: boolean };
|
||||||
expandedChannelByKey: Record<string, boolean>;
|
expandedChannelByKey: Record<string, boolean>;
|
||||||
newChannelDraft: BotChannel;
|
newChannelDraft: BotChannel;
|
||||||
addableChannelTypes: ChannelType[];
|
addableChannelTypes: ChannelType[];
|
||||||
newChannelPanelOpen: boolean;
|
newChannelPanelOpen: boolean;
|
||||||
channelCreateMenuOpen: boolean;
|
channelCreateMenuOpen: boolean;
|
||||||
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
|
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
isSavingGlobalDelivery: boolean;
|
||||||
isSavingChannel: boolean;
|
isSavingChannel: boolean;
|
||||||
weixinLoginStatus: WeixinLoginStatus | null;
|
weixinLoginStatus: WeixinLoginStatus | null;
|
||||||
|
hasSelectedBot: boolean;
|
||||||
isZh: boolean;
|
isZh: boolean;
|
||||||
labels: ChannelConfigLabels;
|
labels: ChannelConfigLabels;
|
||||||
passwordToggleLabels: PasswordToggleLabels;
|
passwordToggleLabels: PasswordToggleLabels;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
||||||
|
onSaveGlobalDelivery: () => Promise<void> | void;
|
||||||
getChannelUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
|
getChannelUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
|
||||||
isDashboardChannel: (channel: BotChannel) => boolean;
|
isDashboardChannel: (channel: BotChannel) => boolean;
|
||||||
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
|
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
|
||||||
|
|
@ -366,18 +337,23 @@ interface ChannelConfigModalProps {
|
||||||
export function ChannelConfigModal({
|
export function ChannelConfigModal({
|
||||||
open,
|
open,
|
||||||
channels,
|
channels,
|
||||||
|
globalDelivery,
|
||||||
expandedChannelByKey,
|
expandedChannelByKey,
|
||||||
newChannelDraft,
|
newChannelDraft,
|
||||||
addableChannelTypes,
|
addableChannelTypes,
|
||||||
newChannelPanelOpen,
|
newChannelPanelOpen,
|
||||||
channelCreateMenuOpen,
|
channelCreateMenuOpen,
|
||||||
channelCreateMenuRef,
|
channelCreateMenuRef,
|
||||||
|
isSavingGlobalDelivery,
|
||||||
isSavingChannel,
|
isSavingChannel,
|
||||||
weixinLoginStatus,
|
weixinLoginStatus,
|
||||||
|
hasSelectedBot,
|
||||||
isZh,
|
isZh,
|
||||||
labels,
|
labels,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
onClose,
|
onClose,
|
||||||
|
onUpdateGlobalDeliveryFlag,
|
||||||
|
onSaveGlobalDelivery,
|
||||||
getChannelUiKey,
|
getChannelUiKey,
|
||||||
isDashboardChannel,
|
isDashboardChannel,
|
||||||
onUpdateChannelLocal,
|
onUpdateChannelLocal,
|
||||||
|
|
@ -434,7 +410,7 @@ export function ChannelConfigModal({
|
||||||
<DrawerShell
|
<DrawerShell
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={labels.openManager}
|
title={labels.wizardSectionTitle}
|
||||||
size="extend"
|
size="extend"
|
||||||
closeLabel={labels.close}
|
closeLabel={labels.close}
|
||||||
bodyClassName="ops-config-drawer-body"
|
bodyClassName="ops-config-drawer-body"
|
||||||
|
|
@ -466,18 +442,50 @@ export function ChannelConfigModal({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="ops-config-modal">
|
<div className="ops-config-modal">
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-mini-title">{labels.globalDeliveryTitle}</div>
|
||||||
|
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(globalDelivery.sendProgress)}
|
||||||
|
onChange={(e) => onUpdateGlobalDeliveryFlag('sendProgress', e.target.checked)}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.sendProgress}
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(globalDelivery.sendToolHints)}
|
||||||
|
onChange={(e) => onUpdateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
{labels.sendToolHints}
|
||||||
|
</label>
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-primary btn-sm icon-btn"
|
||||||
|
disabled={isSavingGlobalDelivery || !hasSelectedBot}
|
||||||
|
onClick={() => void onSaveGlobalDelivery()}
|
||||||
|
tooltip={labels.saveChannel}
|
||||||
|
aria-label={labels.saveChannel}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="wizard-channel-list ops-config-list-scroll">
|
<div className="wizard-channel-list ops-config-list-scroll">
|
||||||
{channels.length === 0 ? (
|
{channels.filter((channel) => !isDashboardChannel(channel)).length === 0 ? (
|
||||||
<div className="ops-empty-inline">{labels.channelEmpty}</div>
|
<div className="ops-empty-inline">{labels.channelEmpty}</div>
|
||||||
) : (
|
) : (
|
||||||
channels.map((channel, idx) => {
|
channels.map((channel, idx) => {
|
||||||
const dashboardChannel = isDashboardChannel(channel);
|
if (isDashboardChannel(channel)) return null;
|
||||||
const uiKey = getChannelUiKey(channel, idx);
|
const uiKey = getChannelUiKey(channel, idx);
|
||||||
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
|
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
|
||||||
const summary = [
|
const summary = [
|
||||||
String(channel.channel_type || '').toUpperCase(),
|
String(channel.channel_type || '').toUpperCase(),
|
||||||
channel.is_active ? labels.enabled : labels.disabled,
|
channel.is_active ? labels.enabled : labels.disabled,
|
||||||
dashboardChannel ? labels.dashboardLocked : (isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending),
|
isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending,
|
||||||
].join(' · ');
|
].join(' · ');
|
||||||
return (
|
return (
|
||||||
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
|
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
|
||||||
|
|
@ -487,28 +495,24 @@ export function ChannelConfigModal({
|
||||||
<div className="ops-config-collapsed-meta">{summary}</div>
|
<div className="ops-config-collapsed-meta">{summary}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-config-card-actions">
|
<div className="ops-config-card-actions">
|
||||||
{!dashboardChannel ? (
|
<label className="field-label">
|
||||||
<>
|
<input
|
||||||
<label className="field-label">
|
type="checkbox"
|
||||||
<input
|
checked={channel.is_active}
|
||||||
type="checkbox"
|
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
|
||||||
checked={channel.is_active}
|
style={{ marginRight: 6 }}
|
||||||
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
|
/>
|
||||||
style={{ marginRight: 6 }}
|
{labels.enabled}
|
||||||
/>
|
</label>
|
||||||
{labels.enabled}
|
<LucentIconButton
|
||||||
</label>
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||||
<LucentIconButton
|
disabled={isSavingChannel}
|
||||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
onClick={() => void onRemoveChannel(channel)}
|
||||||
disabled={isSavingChannel}
|
tooltip={labels.remove}
|
||||||
onClick={() => void onRemoveChannel(channel)}
|
aria-label={labels.remove}
|
||||||
tooltip={labels.remove}
|
>
|
||||||
aria-label={labels.remove}
|
<Trash2 size={14} />
|
||||||
>
|
</LucentIconButton>
|
||||||
<Trash2 size={14} />
|
|
||||||
</LucentIconButton>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<LucentIconButton
|
<LucentIconButton
|
||||||
className="ops-plain-icon-btn"
|
className="ops-plain-icon-btn"
|
||||||
onClick={() => onToggleExpandedChannel(uiKey)}
|
onClick={() => onToggleExpandedChannel(uiKey)}
|
||||||
|
|
@ -522,23 +526,16 @@ export function ChannelConfigModal({
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
<div className="ops-topic-grid">
|
<div className="ops-topic-grid">
|
||||||
{dashboardChannel ? null : (
|
<ChannelFieldsEditor
|
||||||
<ChannelFieldsEditor
|
|
||||||
channel={channel}
|
|
||||||
labels={labels}
|
|
||||||
passwordToggleLabels={passwordToggleLabels}
|
|
||||||
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ChannelDeliveryFields
|
|
||||||
channel={channel}
|
channel={channel}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{renderWeixinLoginBlock(channel)}
|
{renderWeixinLoginBlock(channel)}
|
||||||
<div className="row-between ops-config-footer">
|
<div className="row-between ops-config-footer">
|
||||||
<span className="field-label">{dashboardChannel ? labels.defaultChannel : labels.customChannel}</span>
|
<span className="field-label">{labels.customChannel}</span>
|
||||||
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onSaveChannel(channel)}>
|
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onSaveChannel(channel)}>
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
||||||
|
|
@ -595,11 +592,6 @@ export function ChannelConfigModal({
|
||||||
passwordToggleLabels={passwordToggleLabels}
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
onPatch={onUpdateNewChannelDraft}
|
onPatch={onUpdateNewChannelDraft}
|
||||||
/>
|
/>
|
||||||
<ChannelDeliveryFields
|
|
||||||
channel={newChannelDraft}
|
|
||||||
labels={labels}
|
|
||||||
onPatch={onUpdateNewChannelDraft}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="row-between ops-config-footer">
|
<div className="row-between ops-config-footer">
|
||||||
<span className="field-label">{labels.channelAddHint}</span>
|
<span className="field-label">{labels.channelAddHint}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square, X } from 'lucide-react';
|
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square, X } from 'lucide-react';
|
||||||
import { useRef, useState, type ChangeEventHandler, type DragEvent, type DragEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react';
|
||||||
|
|
||||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||||
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
||||||
|
|
@ -26,7 +26,6 @@ interface DashboardChatComposerProps {
|
||||||
filePickerRef: RefObject<HTMLInputElement | null>;
|
filePickerRef: RefObject<HTMLInputElement | null>;
|
||||||
allowedAttachmentExtensions: string[];
|
allowedAttachmentExtensions: string[];
|
||||||
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
||||||
onDropAttachments: (files: FileList) => Promise<void> | void;
|
|
||||||
controlCommandPanelOpen: boolean;
|
controlCommandPanelOpen: boolean;
|
||||||
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
||||||
onToggleControlCommandPanel: () => void;
|
onToggleControlCommandPanel: () => void;
|
||||||
|
|
@ -126,7 +125,6 @@ export function DashboardChatComposer({
|
||||||
filePickerRef,
|
filePickerRef,
|
||||||
allowedAttachmentExtensions,
|
allowedAttachmentExtensions,
|
||||||
onPickAttachments,
|
onPickAttachments,
|
||||||
onDropAttachments,
|
|
||||||
controlCommandPanelOpen,
|
controlCommandPanelOpen,
|
||||||
controlCommandPanelRef,
|
controlCommandPanelRef,
|
||||||
onToggleControlCommandPanel,
|
onToggleControlCommandPanel,
|
||||||
|
|
@ -161,46 +159,6 @@ export function DashboardChatComposer({
|
||||||
}: DashboardChatComposerProps) {
|
}: DashboardChatComposerProps) {
|
||||||
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
||||||
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
||||||
const [isDraggingAttachments, setIsDraggingAttachments] = useState(false);
|
|
||||||
const dragDepthRef = useRef(0);
|
|
||||||
const canDropAttachments = canChat && !isUploadingAttachments && !isVoiceRecording && !isVoiceTranscribing;
|
|
||||||
const hasDraggedFiles = (event: DragEvent<HTMLDivElement>) =>
|
|
||||||
Array.from(event.dataTransfer?.types || []).includes('Files');
|
|
||||||
|
|
||||||
const handleAttachmentDragEnter: DragEventHandler<HTMLDivElement> = (event) => {
|
|
||||||
if (!hasDraggedFiles(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!canDropAttachments) return;
|
|
||||||
dragDepthRef.current += 1;
|
|
||||||
setIsDraggingAttachments(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachmentDragOver: DragEventHandler<HTMLDivElement> = (event) => {
|
|
||||||
if (!hasDraggedFiles(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
event.dataTransfer.dropEffect = canDropAttachments ? 'copy' : 'none';
|
|
||||||
if (canDropAttachments) setIsDraggingAttachments(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachmentDragLeave: DragEventHandler<HTMLDivElement> = (event) => {
|
|
||||||
if (!hasDraggedFiles(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
||||||
if (dragDepthRef.current === 0) setIsDraggingAttachments(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachmentDrop: DragEventHandler<HTMLDivElement> = (event) => {
|
|
||||||
if (!hasDraggedFiles(event)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
dragDepthRef.current = 0;
|
|
||||||
setIsDraggingAttachments(false);
|
|
||||||
if (!canDropAttachments || event.dataTransfer.files.length === 0) return;
|
|
||||||
void onDropAttachments(event.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -251,13 +209,7 @@ export function DashboardChatComposer({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div className="ops-composer">
|
||||||
className={`ops-composer ${isDraggingAttachments ? 'is-dragging-attachments' : ''}`}
|
|
||||||
onDragEnter={handleAttachmentDragEnter}
|
|
||||||
onDragOver={handleAttachmentDragOver}
|
|
||||||
onDragLeave={handleAttachmentDragLeave}
|
|
||||||
onDrop={handleAttachmentDrop}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
ref={filePickerRef}
|
ref={filePickerRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -267,11 +219,6 @@ export function DashboardChatComposer({
|
||||||
className="ops-hidden-file-input"
|
className="ops-hidden-file-input"
|
||||||
/>
|
/>
|
||||||
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
||||||
{isDraggingAttachments ? (
|
|
||||||
<div className="ops-composer-drop-overlay" aria-hidden="true">
|
|
||||||
<Paperclip size={24} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
||||||
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,6 @@
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
transition: border-color 0.16s ease, background 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-composer.is-dragging-attachments {
|
|
||||||
border-color: color-mix(in oklab, var(--brand) 64%, var(--line) 36%);
|
|
||||||
background: color-mix(in oklab, var(--brand-soft) 18%, var(--panel-soft) 82%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-composer-shell {
|
.ops-composer-shell {
|
||||||
|
|
@ -63,20 +57,6 @@
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-composer-drop-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px dashed color-mix(in oklab, var(--brand) 62%, var(--line) 38%);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%);
|
|
||||||
color: var(--brand);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops-chat-top-context {
|
.ops-chat-top-context {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ interface DashboardChatPanelProps {
|
||||||
filePickerRef: RefObject<HTMLInputElement | null>;
|
filePickerRef: RefObject<HTMLInputElement | null>;
|
||||||
allowedAttachmentExtensions: string[];
|
allowedAttachmentExtensions: string[];
|
||||||
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
||||||
onDropAttachments: (files: FileList) => Promise<void> | void;
|
|
||||||
controlCommandPanelOpen: boolean;
|
controlCommandPanelOpen: boolean;
|
||||||
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
||||||
onToggleControlCommandPanel: () => void;
|
onToggleControlCommandPanel: () => void;
|
||||||
|
|
@ -243,7 +242,6 @@ export function DashboardChatPanel({
|
||||||
filePickerRef,
|
filePickerRef,
|
||||||
allowedAttachmentExtensions,
|
allowedAttachmentExtensions,
|
||||||
onPickAttachments,
|
onPickAttachments,
|
||||||
onDropAttachments,
|
|
||||||
controlCommandPanelOpen,
|
controlCommandPanelOpen,
|
||||||
controlCommandPanelRef,
|
controlCommandPanelRef,
|
||||||
onToggleControlCommandPanel,
|
onToggleControlCommandPanel,
|
||||||
|
|
@ -372,7 +370,6 @@ export function DashboardChatPanel({
|
||||||
filePickerRef={filePickerRef}
|
filePickerRef={filePickerRef}
|
||||||
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
||||||
onPickAttachments={onPickAttachments}
|
onPickAttachments={onPickAttachments}
|
||||||
onDropAttachments={onDropAttachments}
|
|
||||||
controlCommandPanelOpen={controlCommandPanelOpen}
|
controlCommandPanelOpen={controlCommandPanelOpen}
|
||||||
controlCommandPanelRef={controlCommandPanelRef}
|
controlCommandPanelRef={controlCommandPanelRef}
|
||||||
onToggleControlCommandPanel={onToggleControlCommandPanel}
|
onToggleControlCommandPanel={onToggleControlCommandPanel}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ export interface ChannelManagerLabels {
|
||||||
channels: string;
|
channels: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalDeliveryState {
|
||||||
|
sendProgress: boolean;
|
||||||
|
sendToolHints: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiErrorDetail {
|
interface ApiErrorDetail {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -53,9 +58,12 @@ function resolveApiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
|
||||||
interface ChannelManagerDeps extends PromptApi {
|
interface ChannelManagerDeps extends PromptApi {
|
||||||
selectedBotId: string;
|
selectedBotId: string;
|
||||||
|
selectedBotDockerStatus: string;
|
||||||
t: ChannelManagerLabels;
|
t: ChannelManagerLabels;
|
||||||
|
currentGlobalDelivery: GlobalDeliveryState;
|
||||||
addableChannelTypes: ChannelType[];
|
addableChannelTypes: ChannelType[];
|
||||||
currentNewChannelDraft: BotChannel;
|
currentNewChannelDraft: BotChannel;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
setShowChannelModal: (value: boolean) => void;
|
setShowChannelModal: (value: boolean) => void;
|
||||||
setChannels: (value: BotChannel[] | ((prev: BotChannel[]) => BotChannel[])) => void;
|
setChannels: (value: BotChannel[] | ((prev: BotChannel[]) => BotChannel[])) => void;
|
||||||
setExpandedChannelByKey: (
|
setExpandedChannelByKey: (
|
||||||
|
|
@ -65,13 +73,20 @@ interface ChannelManagerDeps extends PromptApi {
|
||||||
setNewChannelPanelOpen: (value: boolean) => void;
|
setNewChannelPanelOpen: (value: boolean) => void;
|
||||||
setNewChannelDraft: (value: BotChannel | ((prev: BotChannel) => BotChannel)) => void;
|
setNewChannelDraft: (value: BotChannel | ((prev: BotChannel) => BotChannel)) => void;
|
||||||
setIsSavingChannel: (value: boolean) => void;
|
setIsSavingChannel: (value: boolean) => void;
|
||||||
|
setGlobalDelivery: (
|
||||||
|
value: GlobalDeliveryState | ((prev: GlobalDeliveryState) => GlobalDeliveryState)
|
||||||
|
) => void;
|
||||||
|
setIsSavingGlobalDelivery: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChannelManager({
|
export function createChannelManager({
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
|
selectedBotDockerStatus,
|
||||||
t,
|
t,
|
||||||
|
currentGlobalDelivery,
|
||||||
addableChannelTypes,
|
addableChannelTypes,
|
||||||
currentNewChannelDraft,
|
currentNewChannelDraft,
|
||||||
|
refresh,
|
||||||
notify,
|
notify,
|
||||||
confirm,
|
confirm,
|
||||||
setShowChannelModal,
|
setShowChannelModal,
|
||||||
|
|
@ -81,11 +96,11 @@ export function createChannelManager({
|
||||||
setNewChannelPanelOpen,
|
setNewChannelPanelOpen,
|
||||||
setNewChannelDraft,
|
setNewChannelDraft,
|
||||||
setIsSavingChannel,
|
setIsSavingChannel,
|
||||||
|
setGlobalDelivery,
|
||||||
|
setIsSavingGlobalDelivery,
|
||||||
}: ChannelManagerDeps) {
|
}: ChannelManagerDeps) {
|
||||||
const createEmptyChannelExtra = (): Record<string, unknown> => ({
|
const createEmptyChannelExtra = (channelType: ChannelType): Record<string, unknown> =>
|
||||||
sendProgress: true,
|
channelType === 'weixin' ? {} : {};
|
||||||
sendToolHints: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({
|
const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({
|
||||||
id: 'draft-channel',
|
id: 'draft-channel',
|
||||||
|
|
@ -95,7 +110,7 @@ export function createChannelManager({
|
||||||
app_secret: '',
|
app_secret: '',
|
||||||
internal_port: 8080,
|
internal_port: 8080,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
extra_config: createEmptyChannelExtra(),
|
extra_config: createEmptyChannelExtra(channelType),
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelDraftUiKey = (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => {
|
const channelDraftUiKey = (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => {
|
||||||
|
|
@ -114,9 +129,10 @@ export function createChannelManager({
|
||||||
const sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
|
const sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
|
||||||
const type = String(channelType || '').toLowerCase();
|
const type = String(channelType || '').toLowerCase();
|
||||||
if (type === 'dashboard') return extra || {};
|
if (type === 'dashboard') return extra || {};
|
||||||
|
if (type === 'weixin') return {};
|
||||||
const next = { ...(extra || {}) };
|
const next = { ...(extra || {}) };
|
||||||
next.sendProgress = Boolean(next.sendProgress);
|
delete next.sendProgress;
|
||||||
next.sendToolHints = Boolean(next.sendToolHints);
|
delete next.sendToolHints;
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -127,10 +143,12 @@ export function createChannelManager({
|
||||||
setChannels(rows);
|
setChannels(rows);
|
||||||
setExpandedChannelByKey((prev) => {
|
setExpandedChannelByKey((prev) => {
|
||||||
const next: Record<string, boolean> = {};
|
const next: Record<string, boolean> = {};
|
||||||
rows.forEach((channel, index) => {
|
rows
|
||||||
const key = channelDraftUiKey(channel, index);
|
.filter((channel) => !isDashboardChannel(channel))
|
||||||
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
|
.forEach((channel, index) => {
|
||||||
});
|
const key = channelDraftUiKey(channel, index);
|
||||||
|
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
|
||||||
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -155,15 +173,14 @@ export function createChannelManager({
|
||||||
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
|
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
|
||||||
setChannels((prev) =>
|
setChannels((prev) =>
|
||||||
prev.map((channel, channelIndex) => {
|
prev.map((channel, channelIndex) => {
|
||||||
if (channelIndex !== index) return channel;
|
if (channelIndex !== index || channel.locked) return channel;
|
||||||
return { ...channel, ...patch };
|
return { ...channel, ...patch };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveChannel = async (channel: BotChannel) => {
|
const saveChannel = async (channel: BotChannel) => {
|
||||||
if (!selectedBotId) return;
|
if (!selectedBotId || channel.locked || isDashboardChannel(channel)) return;
|
||||||
const dashboardChannel = isDashboardChannel(channel);
|
|
||||||
setIsSavingChannel(true);
|
setIsSavingChannel(true);
|
||||||
try {
|
try {
|
||||||
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels/${channel.id}`, {
|
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/channels/${channel.id}`, {
|
||||||
|
|
@ -171,7 +188,7 @@ export function createChannelManager({
|
||||||
external_app_id: channel.external_app_id,
|
external_app_id: channel.external_app_id,
|
||||||
app_secret: channel.app_secret,
|
app_secret: channel.app_secret,
|
||||||
internal_port: Number(channel.internal_port),
|
internal_port: Number(channel.internal_port),
|
||||||
is_active: dashboardChannel ? true : channel.is_active,
|
is_active: channel.is_active,
|
||||||
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
|
extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
|
||||||
});
|
});
|
||||||
await loadChannels(selectedBotId);
|
await loadChannels(selectedBotId);
|
||||||
|
|
@ -230,6 +247,32 @@ export function createChannelManager({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
|
||||||
|
setGlobalDelivery((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGlobalDelivery = async () => {
|
||||||
|
if (!selectedBotId) return;
|
||||||
|
setIsSavingGlobalDelivery(true);
|
||||||
|
try {
|
||||||
|
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`, {
|
||||||
|
send_progress: Boolean(currentGlobalDelivery.sendProgress),
|
||||||
|
send_tool_hints: Boolean(currentGlobalDelivery.sendToolHints),
|
||||||
|
});
|
||||||
|
if (selectedBotDockerStatus === 'RUNNING') {
|
||||||
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/stop`);
|
||||||
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/start`);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
notify(t.channelSaved, { tone: 'success' });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = resolveApiErrorMessage(error, t.channelSaveFail);
|
||||||
|
notify(message, { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsSavingGlobalDelivery(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createEmptyChannelDraft,
|
createEmptyChannelDraft,
|
||||||
channelDraftUiKey,
|
channelDraftUiKey,
|
||||||
|
|
@ -242,5 +285,7 @@ export function createChannelManager({
|
||||||
saveChannel,
|
saveChannel,
|
||||||
addChannel,
|
addChannel,
|
||||||
removeChannel,
|
removeChannel,
|
||||||
|
updateGlobalDeliveryFlag,
|
||||||
|
saveGlobalDelivery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@ export function useBotDashboardModule({
|
||||||
notify,
|
notify,
|
||||||
onPickSkillZip,
|
onPickSkillZip,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
|
refresh,
|
||||||
reloginWeixin,
|
reloginWeixin,
|
||||||
removeBotSkill,
|
removeBotSkill,
|
||||||
resetSupportState,
|
resetSupportState,
|
||||||
|
|
@ -350,7 +351,6 @@ export function useBotDashboardModule({
|
||||||
resolveWorkspaceMediaSrc,
|
resolveWorkspaceMediaSrc,
|
||||||
saveWorkspacePreviewMarkdown,
|
saveWorkspacePreviewMarkdown,
|
||||||
setPendingAttachments,
|
setPendingAttachments,
|
||||||
uploadAttachmentFiles,
|
|
||||||
setWorkspaceAutoRefresh,
|
setWorkspaceAutoRefresh,
|
||||||
setWorkspacePreviewDraft,
|
setWorkspacePreviewDraft,
|
||||||
setWorkspacePreviewFullscreen,
|
setWorkspacePreviewFullscreen,
|
||||||
|
|
@ -665,7 +665,6 @@ export function useBotDashboardModule({
|
||||||
removeStagedSubmission,
|
removeStagedSubmission,
|
||||||
pendingAttachments,
|
pendingAttachments,
|
||||||
setPendingAttachments,
|
setPendingAttachments,
|
||||||
uploadAttachmentFiles,
|
|
||||||
attachmentUploadPercent,
|
attachmentUploadPercent,
|
||||||
isUploadingAttachments,
|
isUploadingAttachments,
|
||||||
filePickerRef,
|
filePickerRef,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { BotState } from '../../../types/bot';
|
import type { BotState } from '../../../types/bot';
|
||||||
import type { ChannelLabels } from '../localeTypes';
|
import type { ChannelLabels } from '../localeTypes';
|
||||||
import { optionalChannelTypes } from '../constants';
|
import { optionalChannelTypes } from '../constants';
|
||||||
import { createChannelManager, type ChannelManagerLabels } from '../config-managers/channelManager';
|
import { createChannelManager, type ChannelManagerLabels, type GlobalDeliveryState } from '../config-managers/channelManager';
|
||||||
import type { BotChannel, WeixinLoginStatus } from '../types';
|
import type { BotChannel, WeixinLoginStatus } from '../types';
|
||||||
|
|
||||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
@ -29,14 +29,30 @@ interface UseDashboardChannelConfigOptions {
|
||||||
loadWeixinLoginStatus: (botId: string, silent?: boolean) => Promise<void>;
|
loadWeixinLoginStatus: (botId: string, silent?: boolean) => Promise<void>;
|
||||||
notify: (message: string, options?: NotifyOptions) => void;
|
notify: (message: string, options?: NotifyOptions) => void;
|
||||||
passwordToggleLabels: { show: string; hide: string };
|
passwordToggleLabels: { show: string; hide: string };
|
||||||
|
refresh: () => Promise<void>;
|
||||||
reloginWeixin: () => Promise<void>;
|
reloginWeixin: () => Promise<void>;
|
||||||
selectedBot?: Pick<BotState, 'id'> | null;
|
selectedBot?: Pick<BotState, 'id' | 'docker_status' | 'send_progress' | 'send_tool_hints'> | null;
|
||||||
selectedBotId: string;
|
selectedBotId: string;
|
||||||
t: ChannelManagerLabels & { cancel: string; close: string };
|
t: ChannelManagerLabels & { cancel: string; close: string };
|
||||||
lc: ChannelLabels;
|
lc: ChannelLabels;
|
||||||
weixinLoginStatus: WeixinLoginStatus | null;
|
weixinLoginStatus: WeixinLoginStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_GLOBAL_DELIVERY: GlobalDeliveryState = {
|
||||||
|
sendProgress: false,
|
||||||
|
sendToolHints: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readBotGlobalDelivery(
|
||||||
|
bot?: Pick<BotState, 'send_progress' | 'send_tool_hints'> | null,
|
||||||
|
): GlobalDeliveryState {
|
||||||
|
if (!bot) return EMPTY_GLOBAL_DELIVERY;
|
||||||
|
return {
|
||||||
|
sendProgress: Boolean(bot.send_progress),
|
||||||
|
sendToolHints: Boolean(bot.send_tool_hints),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useDashboardChannelConfig({
|
export function useDashboardChannelConfig({
|
||||||
closeRuntimeMenu,
|
closeRuntimeMenu,
|
||||||
confirm,
|
confirm,
|
||||||
|
|
@ -44,6 +60,7 @@ export function useDashboardChannelConfig({
|
||||||
loadWeixinLoginStatus,
|
loadWeixinLoginStatus,
|
||||||
notify,
|
notify,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
|
refresh,
|
||||||
reloginWeixin,
|
reloginWeixin,
|
||||||
selectedBot,
|
selectedBot,
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
|
|
@ -65,15 +82,31 @@ export function useDashboardChannelConfig({
|
||||||
app_secret: '',
|
app_secret: '',
|
||||||
internal_port: 8080,
|
internal_port: 8080,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
extra_config: { sendProgress: true, sendToolHints: true },
|
extra_config: {},
|
||||||
});
|
});
|
||||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||||
|
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
||||||
|
const [globalDeliveryDraftByBot, setGlobalDeliveryDraftByBot] = useState<Record<string, GlobalDeliveryState>>({});
|
||||||
|
|
||||||
const addableChannelTypes = useMemo(() => {
|
const addableChannelTypes = useMemo(() => {
|
||||||
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
|
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
|
||||||
return optionalChannelTypes.filter((type) => !exists.has(type));
|
return optionalChannelTypes.filter((type) => !exists.has(type));
|
||||||
}, [channels]);
|
}, [channels]);
|
||||||
|
|
||||||
|
const globalDelivery = useMemo(() => {
|
||||||
|
if (!selectedBotId || !selectedBot) return EMPTY_GLOBAL_DELIVERY;
|
||||||
|
return globalDeliveryDraftByBot[selectedBotId] ?? readBotGlobalDelivery(selectedBot);
|
||||||
|
}, [globalDeliveryDraftByBot, selectedBot, selectedBotId]);
|
||||||
|
|
||||||
|
const setGlobalDelivery = useCallback((value: SetStateAction<GlobalDeliveryState>) => {
|
||||||
|
if (!selectedBotId) return;
|
||||||
|
setGlobalDeliveryDraftByBot((prev) => {
|
||||||
|
const currentValue = prev[selectedBotId] ?? readBotGlobalDelivery(selectedBot);
|
||||||
|
const nextValue = typeof value === 'function' ? value(currentValue) : value;
|
||||||
|
return { ...prev, [selectedBotId]: nextValue };
|
||||||
|
});
|
||||||
|
}, [selectedBot, selectedBotId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
resetNewChannelDraft,
|
resetNewChannelDraft,
|
||||||
channelDraftUiKey,
|
channelDraftUiKey,
|
||||||
|
|
@ -84,11 +117,16 @@ export function useDashboardChannelConfig({
|
||||||
saveChannel,
|
saveChannel,
|
||||||
addChannel,
|
addChannel,
|
||||||
removeChannel,
|
removeChannel,
|
||||||
|
updateGlobalDeliveryFlag,
|
||||||
|
saveGlobalDelivery,
|
||||||
} = createChannelManager({
|
} = createChannelManager({
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
|
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
||||||
t,
|
t,
|
||||||
|
currentGlobalDelivery: globalDelivery,
|
||||||
addableChannelTypes,
|
addableChannelTypes,
|
||||||
currentNewChannelDraft: newChannelDraft,
|
currentNewChannelDraft: newChannelDraft,
|
||||||
|
refresh,
|
||||||
notify,
|
notify,
|
||||||
confirm,
|
confirm,
|
||||||
setShowChannelModal,
|
setShowChannelModal,
|
||||||
|
|
@ -98,6 +136,8 @@ export function useDashboardChannelConfig({
|
||||||
setNewChannelPanelOpen,
|
setNewChannelPanelOpen,
|
||||||
setNewChannelDraft,
|
setNewChannelDraft,
|
||||||
setIsSavingChannel,
|
setIsSavingChannel,
|
||||||
|
setGlobalDelivery,
|
||||||
|
setIsSavingGlobalDelivery,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -132,19 +172,23 @@ export function useDashboardChannelConfig({
|
||||||
setNewChannelPanelOpen(false);
|
setNewChannelPanelOpen(false);
|
||||||
setChannelCreateMenuOpen(false);
|
setChannelCreateMenuOpen(false);
|
||||||
resetNewChannelDraft();
|
resetNewChannelDraft();
|
||||||
|
setGlobalDeliveryDraftByBot({});
|
||||||
}, [resetNewChannelDraft]);
|
}, [resetNewChannelDraft]);
|
||||||
|
|
||||||
const channelConfigModalProps = {
|
const channelConfigModalProps = {
|
||||||
open: showChannelModal,
|
open: showChannelModal,
|
||||||
channels,
|
channels,
|
||||||
|
globalDelivery,
|
||||||
expandedChannelByKey,
|
expandedChannelByKey,
|
||||||
newChannelDraft,
|
newChannelDraft,
|
||||||
addableChannelTypes,
|
addableChannelTypes,
|
||||||
newChannelPanelOpen,
|
newChannelPanelOpen,
|
||||||
channelCreateMenuOpen,
|
channelCreateMenuOpen,
|
||||||
channelCreateMenuRef,
|
channelCreateMenuRef,
|
||||||
|
isSavingGlobalDelivery,
|
||||||
isSavingChannel,
|
isSavingChannel,
|
||||||
weixinLoginStatus,
|
weixinLoginStatus,
|
||||||
|
hasSelectedBot: Boolean(selectedBot),
|
||||||
isZh,
|
isZh,
|
||||||
labels: { ...lc, cancel: t.cancel, close: t.close },
|
labels: { ...lc, cancel: t.cancel, close: t.close },
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
|
|
@ -154,6 +198,8 @@ export function useDashboardChannelConfig({
|
||||||
setNewChannelPanelOpen(false);
|
setNewChannelPanelOpen(false);
|
||||||
resetNewChannelDraft();
|
resetNewChannelDraft();
|
||||||
},
|
},
|
||||||
|
onUpdateGlobalDeliveryFlag: updateGlobalDeliveryFlag,
|
||||||
|
onSaveGlobalDelivery: saveGlobalDelivery,
|
||||||
getChannelUiKey: channelDraftUiKey,
|
getChannelUiKey: channelDraftUiKey,
|
||||||
isDashboardChannel,
|
isDashboardChannel,
|
||||||
onUpdateChannelLocal: updateChannelLocal,
|
onUpdateChannelLocal: updateChannelLocal,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ interface UseDashboardConfigPanelsOptions {
|
||||||
notify: (message: string, options?: NotifyOptions) => void;
|
notify: (message: string, options?: NotifyOptions) => void;
|
||||||
onPickSkillZip: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
onPickSkillZip: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
passwordToggleLabels: { show: string; hide: string };
|
passwordToggleLabels: { show: string; hide: string };
|
||||||
|
refresh: () => Promise<void>;
|
||||||
reloginWeixin: () => Promise<void>;
|
reloginWeixin: () => Promise<void>;
|
||||||
removeBotSkill: (skill: WorkspaceSkillOption) => Promise<void>;
|
removeBotSkill: (skill: WorkspaceSkillOption) => Promise<void>;
|
||||||
resetSupportState: () => void;
|
resetSupportState: () => void;
|
||||||
|
|
@ -91,6 +92,7 @@ export function useDashboardConfigPanels({
|
||||||
notify,
|
notify,
|
||||||
onPickSkillZip,
|
onPickSkillZip,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
|
refresh,
|
||||||
reloginWeixin,
|
reloginWeixin,
|
||||||
removeBotSkill,
|
removeBotSkill,
|
||||||
resetSupportState,
|
resetSupportState,
|
||||||
|
|
@ -157,6 +159,7 @@ export function useDashboardConfigPanels({
|
||||||
loadWeixinLoginStatus,
|
loadWeixinLoginStatus,
|
||||||
notify,
|
notify,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
|
refresh,
|
||||||
reloginWeixin,
|
reloginWeixin,
|
||||||
selectedBot,
|
selectedBot,
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,6 @@ export function useBotDashboardViewProps({
|
||||||
filePickerRef: dashboard.filePickerRef,
|
filePickerRef: dashboard.filePickerRef,
|
||||||
allowedAttachmentExtensions: dashboard.allowedAttachmentExtensions,
|
allowedAttachmentExtensions: dashboard.allowedAttachmentExtensions,
|
||||||
onPickAttachments: dashboard.onPickAttachments,
|
onPickAttachments: dashboard.onPickAttachments,
|
||||||
onDropAttachments: dashboard.uploadAttachmentFiles,
|
|
||||||
controlCommandPanelOpen: dashboard.controlCommandPanelOpen,
|
controlCommandPanelOpen: dashboard.controlCommandPanelOpen,
|
||||||
controlCommandPanelRef: dashboard.controlCommandPanelRef,
|
controlCommandPanelRef: dashboard.controlCommandPanelRef,
|
||||||
onToggleControlCommandPanel: () => {
|
onToggleControlCommandPanel: () => {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export function BotWizardModule({ onCreated, onGoDashboard, onClose, showHeader
|
||||||
testProvider,
|
testProvider,
|
||||||
testResult,
|
testResult,
|
||||||
updateChannel,
|
updateChannel,
|
||||||
|
updateGlobalDeliveryFlag,
|
||||||
upsertEnvParam,
|
upsertEnvParam,
|
||||||
} = useBotWizard({
|
} = useBotWizard({
|
||||||
ui,
|
ui,
|
||||||
|
|
@ -206,9 +207,12 @@ export function BotWizardModule({ onCreated, onGoDashboard, onClose, showHeader
|
||||||
lc={lc}
|
lc={lc}
|
||||||
passwordToggleLabels={passwordToggleLabels}
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
channels={form.channels}
|
channels={form.channels}
|
||||||
|
sendProgress={Boolean(form.send_progress)}
|
||||||
|
sendToolHints={Boolean(form.send_tool_hints)}
|
||||||
addableChannelTypes={addableChannelTypes}
|
addableChannelTypes={addableChannelTypes}
|
||||||
newChannelType={newChannelType}
|
newChannelType={newChannelType}
|
||||||
onClose={() => setShowChannelModal(false)}
|
onClose={() => setShowChannelModal(false)}
|
||||||
|
onUpdateGlobalDeliveryFlag={updateGlobalDeliveryFlag}
|
||||||
onUpdateChannel={updateChannel}
|
onUpdateChannel={updateChannel}
|
||||||
onRemoveChannel={removeChannel}
|
onRemoveChannel={removeChannel}
|
||||||
onSetNewChannelType={setNewChannelType}
|
onSetNewChannelType={setNewChannelType}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ interface BotWizardChannelModalProps {
|
||||||
lc: OnboardingChannelLabels;
|
lc: OnboardingChannelLabels;
|
||||||
passwordToggleLabels: { show: string; hide: string };
|
passwordToggleLabels: { show: string; hide: string };
|
||||||
channels: WizardChannelConfig[];
|
channels: WizardChannelConfig[];
|
||||||
|
sendProgress: boolean;
|
||||||
|
sendToolHints: boolean;
|
||||||
addableChannelTypes: ChannelType[];
|
addableChannelTypes: ChannelType[];
|
||||||
newChannelType: ChannelType | '';
|
newChannelType: ChannelType | '';
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
||||||
onUpdateChannel: (index: number, patch: Partial<WizardChannelConfig>) => void;
|
onUpdateChannel: (index: number, patch: Partial<WizardChannelConfig>) => void;
|
||||||
onRemoveChannel: (index: number) => void;
|
onRemoveChannel: (index: number) => void;
|
||||||
onSetNewChannelType: (value: ChannelType | '') => void;
|
onSetNewChannelType: (value: ChannelType | '') => void;
|
||||||
|
|
@ -132,50 +135,17 @@ function renderChannelFields({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChannelDeliveryFields({
|
|
||||||
channel,
|
|
||||||
idx,
|
|
||||||
lc,
|
|
||||||
onUpdateChannel,
|
|
||||||
}: {
|
|
||||||
channel: WizardChannelConfig;
|
|
||||||
idx: number;
|
|
||||||
lc: Pick<OnboardingChannelLabels, 'sendProgress' | 'sendToolHints'>;
|
|
||||||
onUpdateChannel: (index: number, patch: Partial<WizardChannelConfig>) => void;
|
|
||||||
}) {
|
|
||||||
const extra = channel.extra_config || {};
|
|
||||||
return (
|
|
||||||
<div className="bot-wizard-switches">
|
|
||||||
<label className="field-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(extra.sendProgress)}
|
|
||||||
onChange={(e) => onUpdateChannel(idx, { extra_config: { ...extra, sendProgress: e.target.checked } })}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
{lc.sendProgress}
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(extra.sendToolHints)}
|
|
||||||
onChange={(e) => onUpdateChannel(idx, { extra_config: { ...extra, sendToolHints: e.target.checked } })}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
{lc.sendToolHints}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BotWizardChannelModal({
|
export function BotWizardChannelModal({
|
||||||
open,
|
open,
|
||||||
lc,
|
lc,
|
||||||
passwordToggleLabels,
|
passwordToggleLabels,
|
||||||
channels,
|
channels,
|
||||||
|
sendProgress,
|
||||||
|
sendToolHints,
|
||||||
addableChannelTypes,
|
addableChannelTypes,
|
||||||
newChannelType,
|
newChannelType,
|
||||||
onClose,
|
onClose,
|
||||||
|
onUpdateGlobalDeliveryFlag,
|
||||||
onUpdateChannel,
|
onUpdateChannel,
|
||||||
onRemoveChannel,
|
onRemoveChannel,
|
||||||
onSetNewChannelType,
|
onSetNewChannelType,
|
||||||
|
|
@ -187,6 +157,19 @@ export function BotWizardChannelModal({
|
||||||
<div className="modal-mask" onClick={onClose}>
|
<div className="modal-mask" onClick={onClose}>
|
||||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>{lc.wizardSectionTitle}</h3>
|
<h3>{lc.wizardSectionTitle}</h3>
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-mini-title">{lc.globalDeliveryTitle}</div>
|
||||||
|
<div className="bot-wizard-switches" style={{ marginTop: 8 }}>
|
||||||
|
<label className="field-label">
|
||||||
|
<input type="checkbox" checked={sendProgress} onChange={(e) => onUpdateGlobalDeliveryFlag('sendProgress', e.target.checked)} style={{ marginRight: 6 }} />
|
||||||
|
{lc.sendProgress}
|
||||||
|
</label>
|
||||||
|
<label className="field-label">
|
||||||
|
<input type="checkbox" checked={sendToolHints} onChange={(e) => onUpdateGlobalDeliveryFlag('sendToolHints', e.target.checked)} style={{ marginRight: 6 }} />
|
||||||
|
{lc.sendToolHints}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bot-wizard-channel-list">
|
<div className="bot-wizard-channel-list">
|
||||||
{channels.map((channel, idx) => (
|
{channels.map((channel, idx) => (
|
||||||
<div key={`${channel.channel_type}-${idx}`} className="card bot-wizard-channel-card bot-wizard-channel-compact">
|
<div key={`${channel.channel_type}-${idx}`} className="card bot-wizard-channel-card bot-wizard-channel-compact">
|
||||||
|
|
@ -204,7 +187,6 @@ export function BotWizardChannelModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderChannelFields({ channel, idx, lc, passwordToggleLabels, onUpdateChannel })}
|
{renderChannelFields({ channel, idx, lc, passwordToggleLabels, onUpdateChannel })}
|
||||||
{renderChannelDeliveryFields({ channel, idx, lc, onUpdateChannel })}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ function clampStorageGb(value: number) {
|
||||||
|
|
||||||
function sanitizeChannelExtra(extra: Record<string, unknown>) {
|
function sanitizeChannelExtra(extra: Record<string, unknown>) {
|
||||||
const next = { ...(extra || {}) };
|
const next = { ...(extra || {}) };
|
||||||
next.sendProgress = Boolean(next.sendProgress);
|
delete next.sendProgress;
|
||||||
next.sendToolHints = Boolean(next.sendToolHints);
|
delete next.sendToolHints;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +349,8 @@ export function useBotWizard({
|
||||||
user_md: form.user_md,
|
user_md: form.user_md,
|
||||||
tools_md: form.tools_md,
|
tools_md: form.tools_md,
|
||||||
identity_md: form.identity_md,
|
identity_md: form.identity_md,
|
||||||
|
send_progress: Boolean(form.send_progress),
|
||||||
|
send_tool_hints: Boolean(form.send_tool_hints),
|
||||||
channels: form.channels.map((channel) => ({
|
channels: form.channels.map((channel) => ({
|
||||||
channel_type: channel.channel_type,
|
channel_type: channel.channel_type,
|
||||||
is_active: channel.is_active,
|
is_active: channel.is_active,
|
||||||
|
|
@ -424,7 +426,7 @@ export function useBotWizard({
|
||||||
|
|
||||||
const addChannel = () => {
|
const addChannel = () => {
|
||||||
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||||
const initialExtraConfig = { sendProgress: true, sendToolHints: true };
|
const initialExtraConfig = {};
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
channels: [
|
channels: [
|
||||||
|
|
@ -456,6 +458,13 @@ export function useBotWizard({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
if (key === 'sendProgress') return { ...prev, send_progress: value };
|
||||||
|
return { ...prev, send_tool_hints: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const upsertEnvParam = (key: string, value: string) => {
|
const upsertEnvParam = (key: string, value: string) => {
|
||||||
const normalized = String(key || '').trim().toUpperCase();
|
const normalized = String(key || '').trim().toUpperCase();
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
|
|
@ -538,6 +547,7 @@ export function useBotWizard({
|
||||||
testProvider,
|
testProvider,
|
||||||
testResult,
|
testResult,
|
||||||
updateChannel,
|
updateChannel,
|
||||||
|
updateGlobalDeliveryFlag,
|
||||||
upsertEnvParam,
|
upsertEnvParam,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,6 @@ export function useBotWorkspace({
|
||||||
pendingAttachments,
|
pendingAttachments,
|
||||||
resetPendingAttachments,
|
resetPendingAttachments,
|
||||||
setPendingAttachments,
|
setPendingAttachments,
|
||||||
uploadAttachmentFiles,
|
|
||||||
} = useWorkspaceAttachments({
|
} = useWorkspaceAttachments({
|
||||||
selectedBotId,
|
selectedBotId,
|
||||||
workspaceCurrentPath,
|
workspaceCurrentPath,
|
||||||
|
|
@ -366,7 +365,6 @@ export function useBotWorkspace({
|
||||||
resolveWorkspaceMediaSrc,
|
resolveWorkspaceMediaSrc,
|
||||||
saveWorkspacePreviewMarkdown,
|
saveWorkspacePreviewMarkdown,
|
||||||
setPendingAttachments,
|
setPendingAttachments,
|
||||||
uploadAttachmentFiles,
|
|
||||||
setWorkspaceAutoRefresh,
|
setWorkspaceAutoRefresh,
|
||||||
setWorkspacePreviewDraft,
|
setWorkspacePreviewDraft,
|
||||||
setWorkspacePreviewFullscreen,
|
setWorkspacePreviewFullscreen,
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,9 @@ export function useWorkspaceAttachments({
|
||||||
setAttachmentUploadPercent(null);
|
setAttachmentUploadPercent(null);
|
||||||
}, [selectedBotId]);
|
}, [selectedBotId]);
|
||||||
|
|
||||||
const uploadAttachmentFiles = useCallback(async (inputFiles: File[] | FileList) => {
|
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!selectedBotId || isUploadingAttachments) return;
|
if (!selectedBotId || !event.target.files || event.target.files.length === 0) return;
|
||||||
const files = Array.from(inputFiles);
|
const files = Array.from(event.target.files);
|
||||||
if (files.length === 0) return;
|
|
||||||
try {
|
try {
|
||||||
const latestAttachmentPolicy = await refreshAttachmentPolicy();
|
const latestAttachmentPolicy = await refreshAttachmentPolicy();
|
||||||
const effectiveUploadMaxMb = latestAttachmentPolicy.uploadMaxMb;
|
const effectiveUploadMaxMb = latestAttachmentPolicy.uploadMaxMb;
|
||||||
|
|
@ -104,6 +103,7 @@ export function useWorkspaceAttachments({
|
||||||
if (disallowed.length > 0) {
|
if (disallowed.length > 0) {
|
||||||
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||||
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
|
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
|
||||||
|
event.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +114,7 @@ export function useWorkspaceAttachments({
|
||||||
if (tooLarge.length > 0) {
|
if (tooLarge.length > 0) {
|
||||||
const names = tooLarge.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
const names = tooLarge.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||||
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
|
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
|
||||||
|
event.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,26 +178,9 @@ export function useWorkspaceAttachments({
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingAttachments(false);
|
setIsUploadingAttachments(false);
|
||||||
setAttachmentUploadPercent(null);
|
setAttachmentUploadPercent(null);
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isUploadingAttachments,
|
|
||||||
loadWorkspaceTree,
|
|
||||||
notify,
|
|
||||||
refreshAttachmentPolicy,
|
|
||||||
selectedBotId,
|
|
||||||
setPendingAttachments,
|
|
||||||
t,
|
|
||||||
workspaceCurrentPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
try {
|
|
||||||
if (!event.target.files || event.target.files.length === 0) return;
|
|
||||||
await uploadAttachmentFiles(event.target.files);
|
|
||||||
} finally {
|
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
}, [uploadAttachmentFiles]);
|
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, setPendingAttachments, t, workspaceCurrentPath]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachmentUploadPercent,
|
attachmentUploadPercent,
|
||||||
|
|
@ -205,6 +189,5 @@ export function useWorkspaceAttachments({
|
||||||
pendingAttachments,
|
pendingAttachments,
|
||||||
resetPendingAttachments,
|
resetPendingAttachments,
|
||||||
setPendingAttachments,
|
setPendingAttachments,
|
||||||
uploadAttachmentFiles,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Offline 打包与部署说明
|
||||||
|
|
||||||
|
这套离线方案只放在 `offline/` 目录,不改项目原有部署脚本。
|
||||||
|
|
||||||
|
## 仓库内文件
|
||||||
|
|
||||||
|
- `offline/export-offline-bundle.sh`
|
||||||
|
- `offline/deploy-prod-offline.sh`
|
||||||
|
- `offline/deploy-full-offline.sh`
|
||||||
|
- `offline/init-full-db-offline.sh`
|
||||||
|
- `bot-images/build.sh`
|
||||||
|
- `bot-images/README.md`
|
||||||
|
|
||||||
|
## 1. 如何打包
|
||||||
|
|
||||||
|
完整模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./offline/export-offline-bundle.sh --mode full
|
||||||
|
```
|
||||||
|
|
||||||
|
生产模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./offline/export-offline-bundle.sh --mode prod
|
||||||
|
```
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./offline/export-offline-bundle.sh --mode full --env-file .env.full --output-dir offline-dist
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `full` 会导出 `backend`、`nginx`、`postgres`、`redis` 镜像。
|
||||||
|
- `prod` 只导出 `backend`、`nginx` 镜像,数据库和 Redis 由客户自己提供。
|
||||||
|
- 导出产物默认放在 `offline-dist/`。
|
||||||
|
|
||||||
|
## 2. 打包后会生成什么
|
||||||
|
|
||||||
|
每次导出会得到一个目录和一个压缩包,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
offline-dist/dashboard-nanobot-full-offline-YYYYMMDD_HHMMSS/
|
||||||
|
offline-dist/dashboard-nanobot-full-offline-YYYYMMDD_HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
产物根目录主要包含:
|
||||||
|
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `.env`
|
||||||
|
- `import-images.sh`
|
||||||
|
- `init-db.sh`
|
||||||
|
- `start.sh`
|
||||||
|
- `stop.sh`
|
||||||
|
- `README.txt`
|
||||||
|
|
||||||
|
其中导出的 `docker-compose.yml` 会带中文注释,方便客户直接按注释修改端口、挂载路径、数据库与 Redis 配置。
|
||||||
|
|
||||||
|
此外还会带上:
|
||||||
|
|
||||||
|
- `offline/` 内部离线脚本
|
||||||
|
- `sql/` 数据库初始化 SQL
|
||||||
|
- `data/templates/`
|
||||||
|
- `data/skills/`
|
||||||
|
- `data/model/`
|
||||||
|
|
||||||
|
也就是说,发给客户的是一套部署产物,不需要把源码仓库一起发过去。
|
||||||
|
|
||||||
|
## 3. 客户如何部署
|
||||||
|
|
||||||
|
客户拿到压缩包后按下面做:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf dashboard-nanobot-full-offline-YYYYMMDD_HHMMSS.tar.gz
|
||||||
|
cd dashboard-nanobot-full-offline-YYYYMMDD_HHMMSS
|
||||||
|
./import-images.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你还另外给了客户 `nanobot-base-v0.1.5.tar.gz` 这类 Bot 基础镜像包,也需要先导入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c nanobot-base-v0.1.5.tar.gz | docker load
|
||||||
|
```
|
||||||
|
|
||||||
|
然后修改:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- 如果要改挂载路径,再改 `docker-compose.yml`
|
||||||
|
|
||||||
|
再初始化数据库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./init-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
最后启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
停止:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 客户主要改哪些配置
|
||||||
|
|
||||||
|
`.env` 里通常需要改:
|
||||||
|
|
||||||
|
- `PUBLIC_HOST`
|
||||||
|
- `NGINX_PORT`
|
||||||
|
- `HOST_BOTS_WORKSPACE_ROOT`
|
||||||
|
- `DOCKER_NETWORK_SUBNET`
|
||||||
|
- `PANEL_ACCESS_PASSWORD`
|
||||||
|
|
||||||
|
`prod` 模式额外常改:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `REDIS_ENABLED`
|
||||||
|
- `REDIS_URL`
|
||||||
|
|
||||||
|
`full` 模式额外常改:
|
||||||
|
|
||||||
|
- `POSTGRES_SUPERPASSWORD`
|
||||||
|
- `POSTGRES_APP_PASSWORD`
|
||||||
|
|
||||||
|
## 5. 挂载路径怎么改
|
||||||
|
|
||||||
|
默认挂载关系在导出产物里的 `docker-compose.yml`:
|
||||||
|
|
||||||
|
- `./data:/app/data`
|
||||||
|
- `${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT}`
|
||||||
|
- `/var/run/docker.sock:/var/run/docker.sock`
|
||||||
|
|
||||||
|
如果客户现场要换宿主机路径,直接改产物里的:
|
||||||
|
|
||||||
|
- `.env` 中的 `HOST_BOTS_WORKSPACE_ROOT`
|
||||||
|
- `docker-compose.yml` 中的 volume 挂载项
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径
|
||||||
|
- 保留 `/var/run/docker.sock:/var/run/docker.sock`
|
||||||
|
- `./data` 最好保留在产物目录下,方便整体交付
|
||||||
|
|
||||||
|
## 6. 数据库说明
|
||||||
|
|
||||||
|
`prod` 模式:
|
||||||
|
|
||||||
|
- 客户需要提前准备 PostgreSQL
|
||||||
|
- 推荐直接执行:
|
||||||
|
- `./init-db.sh`
|
||||||
|
- 这个脚本会自动使用 `.env` 里的 `DATABASE_URL` 执行 `sql/create-tables.sql` 和 `sql/init-data.sql`
|
||||||
|
- 如果客户想手工执行,也可以执行:
|
||||||
|
- `sql/create-tables.sql`
|
||||||
|
- `sql/init-data.sql`
|
||||||
|
|
||||||
|
`full` 模式:
|
||||||
|
|
||||||
|
- 产物里包含 PostgreSQL 和 Redis 镜像
|
||||||
|
- `./init-db.sh` 可以手工初始化数据库
|
||||||
|
- `start.sh` 启动完整栈时也会自动初始化数据库
|
||||||
|
|
||||||
|
## 7. Bot 基础镜像说明
|
||||||
|
|
||||||
|
- 离线部署包里的 `./import-images.sh` 只负责导入当前离线包自带的业务镜像。
|
||||||
|
- 如果客户现场还需要运行依赖 `nanobot-base` 的 Bot 容器,则还需要额外导入单独提供的 `nanobot-base-*.tar.gz`。
|
||||||
|
- 导入命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c nanobot-base-v0.1.5.tar.gz | docker load
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果没有导入这类镜像,Dashboard 主服务可以启动,但相关 Bot 运行时容器可能会因为缺少基础镜像而启动失败。
|
||||||
|
|
||||||
|
## 8. 看哪份文档
|
||||||
|
|
||||||
|
- 研发/打包同学看本文件:`offline/README.md`
|
||||||
|
- 客户部署时看导出产物根目录里的:`README.txt`
|
||||||
|
- 如果要单独构建和导出 `nanobot-base` 镜像,看:`bot-images/README.md`
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${1:-$ROOT_DIR/.env}"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||||
|
DATA_DIR="$ROOT_DIR/data"
|
||||||
|
INIT_DB_SCRIPT="$ROOT_DIR/offline/init-full-db-offline.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"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" && -f "$ROOT_DIR/.env.full" ]]; then
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.full"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" && -f "$ROOT_DIR/docker-compose.full.yml" ]]; then
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing file: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing directory: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "${!name:-}" ]] || { 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local container_name="$1"
|
||||||
|
local timeout_seconds="$2"
|
||||||
|
local elapsed=0
|
||||||
|
local status=""
|
||||||
|
|
||||||
|
while (( elapsed < timeout_seconds )); do
|
||||||
|
status="$(
|
||||||
|
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true
|
||||||
|
)"
|
||||||
|
if [[ "$status" == "healthy" || "$status" == "running" ]]; then
|
||||||
|
echo "[deploy-full-offline] $container_name is $status"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] timed out waiting for $container_name (last status: ${status:-unknown})"
|
||||||
|
docker logs --tail 80 "$container_name" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file "$ENV_FILE" "Expected bundle config file such as .env"
|
||||||
|
require_file "$COMPOSE_FILE"
|
||||||
|
require_file "$INIT_DB_SCRIPT"
|
||||||
|
require_file "$AGENT_TEMPLATES_FILE"
|
||||||
|
require_file "$TOPIC_PRESETS_FILE"
|
||||||
|
require_dir "$SKILLS_DIR"
|
||||||
|
|
||||||
|
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
load_env_var POSTGRES_SUPERUSER postgres
|
||||||
|
load_env_var POSTGRES_SUPERPASSWORD
|
||||||
|
load_env_var POSTGRES_BOOTSTRAP_DB postgres
|
||||||
|
load_env_var POSTGRES_APP_DB
|
||||||
|
load_env_var POSTGRES_APP_USER
|
||||||
|
load_env_var POSTGRES_APP_PASSWORD
|
||||||
|
load_env_var NGINX_PORT 8080
|
||||||
|
load_env_var PUBLIC_HOST ""
|
||||||
|
|
||||||
|
require_env HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
require_env POSTGRES_SUPERUSER
|
||||||
|
require_env POSTGRES_SUPERPASSWORD
|
||||||
|
require_env POSTGRES_BOOTSTRAP_DB
|
||||||
|
require_env POSTGRES_APP_DB
|
||||||
|
require_env POSTGRES_APP_USER
|
||||||
|
require_env POSTGRES_APP_PASSWORD
|
||||||
|
require_env NGINX_PORT
|
||||||
|
|
||||||
|
case "$HOST_BOTS_WORKSPACE_ROOT" in
|
||||||
|
/*) ;;
|
||||||
|
*)
|
||||||
|
echo "HOST_BOTS_WORKSPACE_ROOT must be an absolute host path: $HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -S /var/run/docker.sock ]]; then
|
||||||
|
echo "Missing required mount source: /var/run/docker.sock"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] using env: $ENV_FILE"
|
||||||
|
mkdir -p \
|
||||||
|
"$DATA_DIR" \
|
||||||
|
"$DATA_DIR/postgres" \
|
||||||
|
"$DATA_DIR/redis" \
|
||||||
|
"$DATA_DIR/model" \
|
||||||
|
"$HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] starting postgres and redis"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres redis
|
||||||
|
|
||||||
|
wait_for_health "dashboard-nanobot-postgres" 120
|
||||||
|
wait_for_health "dashboard-nanobot-redis" 60
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] initializing application database"
|
||||||
|
"$INIT_DB_SCRIPT" "$ENV_FILE"
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] starting backend and nginx"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d backend nginx
|
||||||
|
|
||||||
|
wait_for_health "dashboard-nanobot-backend" 180
|
||||||
|
wait_for_health "dashboard-nanobot-nginx" 120
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] service status"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
|
if [[ -n "$PUBLIC_HOST" ]]; then
|
||||||
|
echo "[deploy-full-offline] open: http://${PUBLIC_HOST}:${NGINX_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy-full-offline] done"
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${1:-$ROOT_DIR/.env}"
|
||||||
|
DATA_DIR="$ROOT_DIR/data"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.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"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" && -f "$ROOT_DIR/.env.prod" ]]; then
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.prod"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" && -f "$ROOT_DIR/docker-compose.prod.yml" ]]; then
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.prod.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing file: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing directory: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "${!name:-}" ]] || { 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
local value="${1:-}"
|
||||||
|
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
[[ "$value" =~ ^(1|true|yes|on)$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file "$ENV_FILE" "Expected bundle config file such as .env"
|
||||||
|
require_file "$COMPOSE_FILE"
|
||||||
|
require_file "$AGENT_TEMPLATES_FILE"
|
||||||
|
require_file "$TOPIC_PRESETS_FILE"
|
||||||
|
require_dir "$SKILLS_DIR"
|
||||||
|
|
||||||
|
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
|
||||||
|
load_env_var PUBLIC_HOST ""
|
||||||
|
|
||||||
|
require_env HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
require_env DATABASE_URL
|
||||||
|
require_env NGINX_PORT
|
||||||
|
|
||||||
|
case "$HOST_BOTS_WORKSPACE_ROOT" in
|
||||||
|
/*) ;;
|
||||||
|
*)
|
||||||
|
echo "HOST_BOTS_WORKSPACE_ROOT must be an absolute host path: $HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -S /var/run/docker.sock ]]; then
|
||||||
|
echo "Missing required mount source: /var/run/docker.sock"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DATABASE_URL" != postgresql* ]]; then
|
||||||
|
echo "Unsupported DATABASE_URL for deploy-prod-offline.sh: $DATABASE_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_truthy "$REDIS_ENABLED" && [[ -z "$REDIS_URL" ]]; then
|
||||||
|
echo "Missing required env: REDIS_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy-prod-offline] using env: $ENV_FILE"
|
||||||
|
mkdir -p "$DATA_DIR" "$DATA_DIR/model" "$HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
|
||||||
|
echo "[deploy-prod-offline] expecting external PostgreSQL to be pre-initialized with sql/create-tables.sql and sql/init-data.sql, or by running ./init-db.sh"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo "[deploy-prod-offline] service status"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
|
if [[ -n "$PUBLIC_HOST" ]]; then
|
||||||
|
echo "[deploy-prod-offline] open: http://${PUBLIC_HOST}:${NGINX_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy-prod-offline] done"
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MODE="full"
|
||||||
|
ENV_FILE=""
|
||||||
|
OUTPUT_DIR="$ROOT_DIR/offline-dist"
|
||||||
|
VERSION="$(date +"%Y%m%d_%H%M%S")"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [--mode full|prod] [--env-file path] [--output-dir path]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--mode Export deployment bundle for full or prod mode. Default: full
|
||||||
|
--env-file Compose env file used for image tags and build args.
|
||||||
|
--output-dir Output directory for offline bundles. Default: offline-dist
|
||||||
|
-h, --help Show this help message.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
[[ $# -ge 2 ]] || { echo "Missing value for --mode"; exit 1; }
|
||||||
|
MODE="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--env-file)
|
||||||
|
[[ $# -ge 2 ]] || { echo "Missing value for --env-file"; exit 1; }
|
||||||
|
ENV_FILE="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--output-dir)
|
||||||
|
[[ $# -ge 2 ]] || { echo "Missing value for --output-dir"; exit 1; }
|
||||||
|
OUTPUT_DIR="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected argument: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -f "$path" ]] || { echo "Missing file: $path"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -d "$path" ]] || { echo "Missing directory: $path"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local env_path="$1"
|
||||||
|
local key="$2"
|
||||||
|
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_path"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local default_value="${2:-}"
|
||||||
|
local value=""
|
||||||
|
|
||||||
|
value="$(read_env_value "$ENV_FILE" "$key" || true)"
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
value="$default_value"
|
||||||
|
fi
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_image_available() {
|
||||||
|
local image_ref="$1"
|
||||||
|
if docker image inspect "$image_ref" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "[export] local image not found, pulling: $image_ref"
|
||||||
|
docker pull "$image_ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_into_bundle() {
|
||||||
|
local src="$1"
|
||||||
|
local dst="$BUNDLE_DIR/$1"
|
||||||
|
mkdir -p "$(dirname "$dst")"
|
||||||
|
if [[ -d "$ROOT_DIR/$src" ]]; then
|
||||||
|
cp -R "$ROOT_DIR/$src" "$dst"
|
||||||
|
else
|
||||||
|
cp "$ROOT_DIR/$src" "$dst"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_bundle_compose() {
|
||||||
|
cp "$ROOT_DIR/docker-compose.$MODE.yml" "$BUNDLE_DIR/docker-compose.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_sql_bundle() {
|
||||||
|
mkdir -p "$BUNDLE_DIR/sql"
|
||||||
|
cp "$ROOT_DIR/scripts/sql/create-tables.sql" "$BUNDLE_DIR/sql/"
|
||||||
|
cp "$ROOT_DIR/scripts/sql/init-data.sql" "$BUNDLE_DIR/sql/"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
cp "$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql" "$BUNDLE_DIR/sql/"
|
||||||
|
cp "$ROOT_DIR/scripts/sql/init-postgres-app.sql" "$BUNDLE_DIR/sql/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_env_file() {
|
||||||
|
local file="$1"
|
||||||
|
local key="$2"
|
||||||
|
local value="$3"
|
||||||
|
local tmp_file=""
|
||||||
|
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { updated = 0 }
|
||||||
|
{
|
||||||
|
if ($0 ~ "^[[:space:]]*#") {
|
||||||
|
print
|
||||||
|
next
|
||||||
|
}
|
||||||
|
if ($0 ~ "^" key "=") {
|
||||||
|
print key "=" value
|
||||||
|
updated = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
print
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (!updated) {
|
||||||
|
print key "=" value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$file" > "$tmp_file"
|
||||||
|
mv "$tmp_file" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_bundle_env() {
|
||||||
|
local target="$BUNDLE_DIR/${ROOT_ENV_FILE}"
|
||||||
|
|
||||||
|
cp "$ROOT_DIR/.env.$MODE.example" "$target"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Offline bundle helper field."
|
||||||
|
echo "# Used only for README / start script output."
|
||||||
|
echo "PUBLIC_HOST=127.0.0.1"
|
||||||
|
} >> "$target"
|
||||||
|
|
||||||
|
upsert_env_file "$target" "BACKEND_IMAGE_TAG" "$BACKEND_IMAGE_TAG"
|
||||||
|
upsert_env_file "$target" "FRONTEND_IMAGE_TAG" "$FRONTEND_IMAGE_TAG"
|
||||||
|
upsert_env_file "$target" "NGINX_PORT" "$(load_env_value NGINX_PORT 8080)"
|
||||||
|
upsert_env_file "$target" "HOST_BOTS_WORKSPACE_ROOT" "$(load_env_value HOST_BOTS_WORKSPACE_ROOT /opt/dashboard-nanobot/workspace/bots)"
|
||||||
|
upsert_env_file "$target" "DOCKER_NETWORK_NAME" "$(load_env_value DOCKER_NETWORK_NAME dashboard-nanobot-network)"
|
||||||
|
upsert_env_file "$target" "DOCKER_NETWORK_SUBNET" "$(load_env_value DOCKER_NETWORK_SUBNET 172.20.0.0/16)"
|
||||||
|
upsert_env_file "$target" "PANEL_ACCESS_PASSWORD" "$(load_env_value PANEL_ACCESS_PASSWORD change_me_panel_password)"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "prod" ]]; then
|
||||||
|
upsert_env_file "$target" "DATABASE_URL" "$(load_env_value DATABASE_URL postgresql+psycopg://postgres:change_me_db_password@127.0.0.1:5432/nanobot)"
|
||||||
|
upsert_env_file "$target" "REDIS_ENABLED" "$(load_env_value REDIS_ENABLED true)"
|
||||||
|
upsert_env_file "$target" "REDIS_URL" "$(load_env_value REDIS_URL redis://127.0.0.1:6379/8)"
|
||||||
|
else
|
||||||
|
upsert_env_file "$target" "POSTGRES_IMAGE" "$POSTGRES_IMAGE"
|
||||||
|
upsert_env_file "$target" "REDIS_IMAGE" "$REDIS_IMAGE"
|
||||||
|
upsert_env_file "$target" "POSTGRES_SUPERPASSWORD" "$(load_env_value POSTGRES_SUPERPASSWORD change_me_pg_super_password)"
|
||||||
|
upsert_env_file "$target" "POSTGRES_APP_PASSWORD" "$(load_env_value POSTGRES_APP_PASSWORD change_me_nanobot_password)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_root_helper_scripts() {
|
||||||
|
cat > "$BUNDLE_DIR/import-images.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||||
|
IMAGE_ARCHIVE="\$SCRIPT_DIR/${IMAGE_ARCHIVE}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$IMAGE_ARCHIVE" ]]; then
|
||||||
|
echo "Missing image archive: \$IMAGE_ARCHIVE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[import-images] loading images from ${IMAGE_ARCHIVE}"
|
||||||
|
gunzip -c "\$IMAGE_ARCHIVE" | docker load
|
||||||
|
echo "[import-images] done"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$BUNDLE_DIR/init-db.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||||
|
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ENV_FILE" ]]; then
|
||||||
|
echo "Missing env file: \$ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MODE}" == "prod" ]]; then
|
||||||
|
if [[ ! -f "\$SCRIPT_DIR/offline/init-prod-db-offline.sh" ]]; then
|
||||||
|
echo "Missing script: \$SCRIPT_DIR/offline/init-prod-db-offline.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"\$SCRIPT_DIR/offline/init-prod-db-offline.sh" "\$ENV_FILE"
|
||||||
|
else
|
||||||
|
if [[ ! -f "\$SCRIPT_DIR/offline/init-full-db-offline.sh" ]]; then
|
||||||
|
echo "Missing script: \$SCRIPT_DIR/offline/init-full-db-offline.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"\$SCRIPT_DIR/offline/init-full-db-offline.sh" "\$ENV_FILE"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$BUNDLE_DIR/start.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||||
|
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ENV_FILE" ]]; then
|
||||||
|
echo "Missing env file: \$ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"\$SCRIPT_DIR/offline/deploy-${MODE}-offline.sh" "\$ENV_FILE"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$BUNDLE_DIR/stop.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||||
|
ENV_FILE="\$SCRIPT_DIR/${ROOT_ENV_FILE}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ENV_FILE" ]]; then
|
||||||
|
echo "Missing env file: \$ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose --env-file "\$ENV_FILE" -f "\$SCRIPT_DIR/docker-compose.yml" down
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x \
|
||||||
|
"$BUNDLE_DIR/import-images.sh" \
|
||||||
|
"$BUNDLE_DIR/init-db.sh" \
|
||||||
|
"$BUNDLE_DIR/start.sh" \
|
||||||
|
"$BUNDLE_DIR/stop.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_bundle_readme() {
|
||||||
|
cat > "$BUNDLE_DIR/README.txt" <<EOF
|
||||||
|
Dashboard Nanobot Offline Bundle
|
||||||
|
Mode: ${MODE}
|
||||||
|
Version: ${VERSION}
|
||||||
|
|
||||||
|
This directory is ready to send directly to the customer.
|
||||||
|
|
||||||
|
Included:
|
||||||
|
- ${IMAGE_ARCHIVE}
|
||||||
|
- ${ROOT_ENV_FILE}
|
||||||
|
- docker-compose.yml
|
||||||
|
- import-images.sh
|
||||||
|
- init-db.sh
|
||||||
|
- start.sh
|
||||||
|
- stop.sh
|
||||||
|
- offline/
|
||||||
|
- sql/
|
||||||
|
- data/templates/
|
||||||
|
- data/skills/
|
||||||
|
- data/model/
|
||||||
|
|
||||||
|
Customer Quick Start:
|
||||||
|
1. Extract this bundle on the target server.
|
||||||
|
2. Import images:
|
||||||
|
./import-images.sh
|
||||||
|
3. If a separate bot base image archive was also provided, import it before startup:
|
||||||
|
gunzip -c nanobot-base-<version>.tar.gz | docker load
|
||||||
|
4. Edit config:
|
||||||
|
${ROOT_ENV_FILE}
|
||||||
|
5. Initialize database:
|
||||||
|
./init-db.sh
|
||||||
|
6. Start service:
|
||||||
|
./start.sh
|
||||||
|
7. Stop service:
|
||||||
|
./stop.sh
|
||||||
|
|
||||||
|
Fields customer usually needs to edit:
|
||||||
|
- PUBLIC_HOST
|
||||||
|
- NGINX_PORT
|
||||||
|
- HOST_BOTS_WORKSPACE_ROOT
|
||||||
|
- DOCKER_NETWORK_SUBNET
|
||||||
|
- PANEL_ACCESS_PASSWORD
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$MODE" == "prod" ]]; then
|
||||||
|
cat >> "$BUNDLE_DIR/README.txt" <<EOF
|
||||||
|
- DATABASE_URL
|
||||||
|
- REDIS_ENABLED
|
||||||
|
- REDIS_URL
|
||||||
|
|
||||||
|
Prod mode note:
|
||||||
|
- Customer must prepare external PostgreSQL in advance.
|
||||||
|
- Customer can run ./init-db.sh to initialize external PostgreSQL automatically.
|
||||||
|
- If they prefer manual import, run sql/create-tables.sql and sql/init-data.sql before startup.
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
cat >> "$BUNDLE_DIR/README.txt" <<EOF
|
||||||
|
- POSTGRES_SUPERPASSWORD
|
||||||
|
- POSTGRES_APP_PASSWORD
|
||||||
|
|
||||||
|
Full mode note:
|
||||||
|
- PostgreSQL and Redis are already included in this bundle.
|
||||||
|
- ./init-db.sh can initialize the application database manually if needed.
|
||||||
|
- start.sh will also trigger database initialization during full startup flow.
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$BUNDLE_DIR/README.txt" <<EOF
|
||||||
|
|
||||||
|
Access URL:
|
||||||
|
- http://<PUBLIC_HOST>:<NGINX_PORT>
|
||||||
|
|
||||||
|
Mounts used by this deployment:
|
||||||
|
- ./data -> /app/data
|
||||||
|
- HOST_BOTS_WORKSPACE_ROOT -> same path inside backend container
|
||||||
|
- /var/run/docker.sock -> /var/run/docker.sock
|
||||||
|
|
||||||
|
Mount note:
|
||||||
|
- Customer can edit docker-compose.yml directly if they want to change host mount paths.
|
||||||
|
- If customer also received a separate nanobot-base image archive and does not import it, Bot-related runtime containers may fail to start.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
full|prod)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported mode: $MODE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -z "$ENV_FILE" ]]; then
|
||||||
|
if [[ -f "$ROOT_DIR/.env.$MODE" ]]; then
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.$MODE"
|
||||||
|
else
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.$MODE.example"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.$MODE.yml"
|
||||||
|
BUNDLE_NAME="dashboard-nanobot-${MODE}-offline-${VERSION}"
|
||||||
|
BUNDLE_DIR="$OUTPUT_DIR/$BUNDLE_NAME"
|
||||||
|
ARCHIVE_FILE="$OUTPUT_DIR/${BUNDLE_NAME}.tar.gz"
|
||||||
|
IMAGE_ARCHIVE="docker-images-${MODE}.tar.gz"
|
||||||
|
ROOT_ENV_FILE=".env"
|
||||||
|
|
||||||
|
require_file "$ENV_FILE"
|
||||||
|
require_file "$COMPOSE_FILE"
|
||||||
|
require_file "$ROOT_DIR/offline/deploy-${MODE}-offline.sh"
|
||||||
|
require_dir "$ROOT_DIR/data/templates"
|
||||||
|
require_dir "$ROOT_DIR/data/skills"
|
||||||
|
require_dir "$ROOT_DIR/data/model"
|
||||||
|
|
||||||
|
BACKEND_IMAGE_TAG="$(load_env_value BACKEND_IMAGE_TAG latest)"
|
||||||
|
FRONTEND_IMAGE_TAG="$(load_env_value FRONTEND_IMAGE_TAG latest)"
|
||||||
|
BACKEND_IMAGE="dashboard-nanobot/backend:${BACKEND_IMAGE_TAG}"
|
||||||
|
FRONTEND_IMAGE="dashboard-nanobot/nginx:${FRONTEND_IMAGE_TAG}"
|
||||||
|
IMAGE_REFS=("$BACKEND_IMAGE" "$FRONTEND_IMAGE")
|
||||||
|
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
POSTGRES_IMAGE="$(load_env_value POSTGRES_IMAGE postgres:16-alpine)"
|
||||||
|
REDIS_IMAGE="$(load_env_value REDIS_IMAGE redis:7-alpine)"
|
||||||
|
IMAGE_REFS+=("$POSTGRES_IMAGE" "$REDIS_IMAGE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
rm -rf "$BUNDLE_DIR"
|
||||||
|
mkdir -p "$BUNDLE_DIR"
|
||||||
|
|
||||||
|
echo "=== Export Dashboard Nanobot Offline Bundle ==="
|
||||||
|
echo "[export] mode: $MODE"
|
||||||
|
echo "[export] env file: $ENV_FILE"
|
||||||
|
echo "[export] bundle dir: $BUNDLE_DIR"
|
||||||
|
|
||||||
|
echo "[1/5] validating compose file"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
||||||
|
|
||||||
|
echo "[2/5] building backend and nginx images"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build backend nginx
|
||||||
|
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
echo "[3/5] ensuring dependency images are available"
|
||||||
|
ensure_image_available "$POSTGRES_IMAGE"
|
||||||
|
ensure_image_available "$REDIS_IMAGE"
|
||||||
|
else
|
||||||
|
echo "[3/5] prod mode uses external PostgreSQL/Redis"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[4/5] exporting docker images"
|
||||||
|
docker save "${IMAGE_REFS[@]}" | gzip > "$BUNDLE_DIR/$IMAGE_ARCHIVE"
|
||||||
|
|
||||||
|
echo "[5/5] collecting deployment files"
|
||||||
|
copy_into_bundle "offline/deploy-$MODE-offline.sh"
|
||||||
|
copy_into_bundle "data/templates"
|
||||||
|
copy_into_bundle "data/skills"
|
||||||
|
copy_into_bundle "data/model"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "prod" ]]; then
|
||||||
|
copy_into_bundle "offline/init-prod-db-offline.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "full" ]]; then
|
||||||
|
copy_into_bundle "offline/init-full-db-offline.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
copy_sql_bundle
|
||||||
|
write_bundle_compose
|
||||||
|
prepare_bundle_env
|
||||||
|
|
||||||
|
write_root_helper_scripts
|
||||||
|
write_bundle_readme
|
||||||
|
|
||||||
|
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" "$BUNDLE_NAME"
|
||||||
|
|
||||||
|
echo "[done] archive: $ARCHIVE_FILE"
|
||||||
|
echo "[done] images:"
|
||||||
|
printf ' - %s\n' "${IMAGE_REFS[@]}"
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${1:-$ROOT_DIR/.env}"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||||
|
BOOTSTRAP_SQL="$ROOT_DIR/sql/init-postgres-bootstrap.sql"
|
||||||
|
APP_SQL="$ROOT_DIR/sql/init-postgres-app.sql"
|
||||||
|
SCHEMA_SQL="$ROOT_DIR/sql/create-tables.sql"
|
||||||
|
SEED_SQL="$ROOT_DIR/sql/init-data.sql"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" && -f "$ROOT_DIR/.env.full" ]]; then
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.full"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" && -f "$ROOT_DIR/docker-compose.full.yml" ]]; then
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.full.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$BOOTSTRAP_SQL" && -f "$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql" ]]; then
|
||||||
|
BOOTSTRAP_SQL="$ROOT_DIR/scripts/sql/init-postgres-bootstrap.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$APP_SQL" && -f "$ROOT_DIR/scripts/sql/init-postgres-app.sql" ]]; then
|
||||||
|
APP_SQL="$ROOT_DIR/scripts/sql/init-postgres-app.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SCHEMA_SQL" && -f "$ROOT_DIR/scripts/sql/create-tables.sql" ]]; then
|
||||||
|
SCHEMA_SQL="$ROOT_DIR/scripts/sql/create-tables.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SEED_SQL" && -f "$ROOT_DIR/scripts/sql/init-data.sql" ]]; then
|
||||||
|
SEED_SQL="$ROOT_DIR/scripts/sql/init-data.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing file: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "${!name:-}" ]] || { 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
local value="${1:-}"
|
||||||
|
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
[[ "$value" =~ ^(1|true|yes|on)$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_postgres() {
|
||||||
|
local timeout_seconds="${1:-120}"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
while (( elapsed < timeout_seconds )); do
|
||||||
|
if docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
-e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \
|
||||||
|
postgres \
|
||||||
|
pg_isready -U "$POSTGRES_SUPERUSER" -d "$POSTGRES_BOOTSTRAP_DB" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
elapsed=$((elapsed + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[init-full-db-offline] timed out waiting for postgres"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" logs --tail 100 postgres || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file "$ENV_FILE" "Expected bundle config file such as .env"
|
||||||
|
require_file "$COMPOSE_FILE"
|
||||||
|
require_file "$BOOTSTRAP_SQL"
|
||||||
|
require_file "$APP_SQL"
|
||||||
|
require_file "$SCHEMA_SQL"
|
||||||
|
require_file "$SEED_SQL"
|
||||||
|
|
||||||
|
load_env_var POSTGRES_SUPERUSER postgres
|
||||||
|
load_env_var POSTGRES_SUPERPASSWORD
|
||||||
|
load_env_var POSTGRES_BOOTSTRAP_DB postgres
|
||||||
|
load_env_var POSTGRES_APP_DB
|
||||||
|
load_env_var POSTGRES_APP_USER
|
||||||
|
load_env_var POSTGRES_APP_PASSWORD
|
||||||
|
load_env_var UPLOAD_MAX_MB 100
|
||||||
|
load_env_var STT_ENABLED true
|
||||||
|
|
||||||
|
require_env POSTGRES_SUPERUSER
|
||||||
|
require_env POSTGRES_SUPERPASSWORD
|
||||||
|
require_env POSTGRES_BOOTSTRAP_DB
|
||||||
|
require_env POSTGRES_APP_DB
|
||||||
|
require_env POSTGRES_APP_USER
|
||||||
|
require_env POSTGRES_APP_PASSWORD
|
||||||
|
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres >/dev/null
|
||||||
|
|
||||||
|
wait_for_postgres 120
|
||||||
|
|
||||||
|
echo "[init-full-db-offline] ensuring role/database exist"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
-e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \
|
||||||
|
postgres \
|
||||||
|
psql \
|
||||||
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-v app_db="$POSTGRES_APP_DB" \
|
||||||
|
-v app_user="$POSTGRES_APP_USER" \
|
||||||
|
-v app_password="$POSTGRES_APP_PASSWORD" \
|
||||||
|
-U "$POSTGRES_SUPERUSER" \
|
||||||
|
-d "$POSTGRES_BOOTSTRAP_DB" \
|
||||||
|
-f - < "$BOOTSTRAP_SQL"
|
||||||
|
|
||||||
|
echo "[init-full-db-offline] ensuring schema privileges in $POSTGRES_APP_DB"
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
-e PGPASSWORD="$POSTGRES_SUPERPASSWORD" \
|
||||||
|
postgres \
|
||||||
|
psql \
|
||||||
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-v app_user="$POSTGRES_APP_USER" \
|
||||||
|
-U "$POSTGRES_SUPERUSER" \
|
||||||
|
-d "$POSTGRES_APP_DB" \
|
||||||
|
-f - < "$APP_SQL"
|
||||||
|
|
||||||
|
echo "[init-full-db-offline] 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="10"
|
||||||
|
CHAT_PULL_PAGE_SIZE_JSON="60"
|
||||||
|
AUTH_TOKEN_TTL_HOURS_JSON="24"
|
||||||
|
AUTH_TOKEN_MAX_ACTIVE_JSON="2"
|
||||||
|
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
||||||
|
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
||||||
|
if is_truthy "$STT_ENABLED"; then
|
||||||
|
SPEECH_ENABLED_JSON="true"
|
||||||
|
else
|
||||||
|
SPEECH_ENABLED_JSON="false"
|
||||||
|
fi
|
||||||
|
ACTIVITY_EVENT_RETENTION_DAYS_JSON="7"
|
||||||
|
|
||||||
|
echo "[init-full-db-offline] 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 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-offline] done"
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${1:-$ROOT_DIR/.env}"
|
||||||
|
SCHEMA_SQL="$ROOT_DIR/sql/create-tables.sql"
|
||||||
|
SEED_SQL="$ROOT_DIR/sql/init-data.sql"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" && -f "$ROOT_DIR/.env.prod" ]]; then
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.prod"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local hint="${2:-}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Missing file: $path"
|
||||||
|
[[ -n "$hint" ]] && echo "$hint"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "${!name:-}" ]] || { 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
local value="${1:-}"
|
||||||
|
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
[[ "$value" =~ ^(1|true|yes|on)$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file "$ENV_FILE" "Expected bundle config file such as .env"
|
||||||
|
require_file "$SCHEMA_SQL"
|
||||||
|
require_file "$SEED_SQL"
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "Missing command: psql"
|
||||||
|
echo "Please install PostgreSQL client tools on the target host, then rerun ./init-db.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
load_env_var DATABASE_URL
|
||||||
|
load_env_var UPLOAD_MAX_MB 100
|
||||||
|
load_env_var STT_ENABLED true
|
||||||
|
|
||||||
|
require_env DATABASE_URL
|
||||||
|
|
||||||
|
PAGE_SIZE_JSON="10"
|
||||||
|
CHAT_PULL_PAGE_SIZE_JSON="60"
|
||||||
|
AUTH_TOKEN_TTL_HOURS_JSON="24"
|
||||||
|
AUTH_TOKEN_MAX_ACTIVE_JSON="2"
|
||||||
|
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
||||||
|
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
||||||
|
if is_truthy "$STT_ENABLED"; then
|
||||||
|
SPEECH_ENABLED_JSON="true"
|
||||||
|
else
|
||||||
|
SPEECH_ENABLED_JSON="false"
|
||||||
|
fi
|
||||||
|
ACTIVITY_EVENT_RETENTION_DAYS_JSON="7"
|
||||||
|
|
||||||
|
echo "[init-prod-db-offline] applying schema with DATABASE_URL"
|
||||||
|
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$SCHEMA_SQL"
|
||||||
|
|
||||||
|
echo "[init-prod-db-offline] applying initial data with DATABASE_URL"
|
||||||
|
psql \
|
||||||
|
"$DATABASE_URL" \
|
||||||
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-v page_size_json="$PAGE_SIZE_JSON" \
|
||||||
|
-v chat_pull_page_size_json="$CHAT_PULL_PAGE_SIZE_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" \
|
||||||
|
-f "$SEED_SQL"
|
||||||
|
|
||||||
|
echo "[init-prod-db-offline] done"
|
||||||
|
|
@ -81,23 +81,6 @@ load_env_var() {
|
||||||
printf -v "$name" '%s' "$value"
|
printf -v "$name" '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_docker_network() {
|
|
||||||
local network_name="$1"
|
|
||||||
local network_subnet="${2:-}"
|
|
||||||
|
|
||||||
if docker network inspect "$network_name" >/dev/null 2>&1; then
|
|
||||||
echo "[deploy-full] reusing docker network: $network_name"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[deploy-full] creating docker network: $network_name"
|
|
||||||
if [[ -n "$network_subnet" ]]; then
|
|
||||||
docker network create --driver bridge --subnet "$network_subnet" "$network_name" >/dev/null
|
|
||||||
else
|
|
||||||
docker network create --driver bridge "$network_name" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_health() {
|
wait_for_health() {
|
||||||
local container_name="$1"
|
local container_name="$1"
|
||||||
local timeout_seconds="$2"
|
local timeout_seconds="$2"
|
||||||
|
|
@ -136,8 +119,6 @@ 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 NGINX_PORT 8080
|
load_env_var NGINX_PORT 8080
|
||||||
load_env_var DOCKER_NETWORK_NAME dashboard-nanobot-network
|
|
||||||
load_env_var DOCKER_NETWORK_SUBNET 172.20.0.0/16
|
|
||||||
|
|
||||||
require_env HOST_BOTS_WORKSPACE_ROOT
|
require_env HOST_BOTS_WORKSPACE_ROOT
|
||||||
require_env POSTGRES_SUPERUSER
|
require_env POSTGRES_SUPERUSER
|
||||||
|
|
@ -157,7 +138,6 @@ mkdir -p \
|
||||||
"$HOST_BOTS_WORKSPACE_ROOT"
|
"$HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
|
||||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config -q
|
||||||
ensure_docker_network "$DOCKER_NETWORK_NAME" "$DOCKER_NETWORK_SUBNET"
|
|
||||||
|
|
||||||
echo "[deploy-full] starting postgres and redis"
|
echo "[deploy-full] starting postgres and redis"
|
||||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres redis
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres redis
|
||||||
|
|
|
||||||
|
|
@ -80,23 +80,6 @@ load_env_var() {
|
||||||
printf -v "$name" '%s' "$value"
|
printf -v "$name" '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_docker_network() {
|
|
||||||
local network_name="$1"
|
|
||||||
local network_subnet="${2:-}"
|
|
||||||
|
|
||||||
if docker network inspect "$network_name" >/dev/null 2>&1; then
|
|
||||||
echo "[deploy] reusing docker network: $network_name"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[deploy] creating docker network: $network_name"
|
|
||||||
if [[ -n "$network_subnet" ]]; then
|
|
||||||
docker network create --driver bridge --subnet "$network_subnet" "$network_name" >/dev/null
|
|
||||||
else
|
|
||||||
docker network create --driver bridge "$network_name" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
echo "[WARNING] Missing env file: $ENV_FILE"
|
echo "[WARNING] Missing env file: $ENV_FILE"
|
||||||
echo "[WARNING] Creating it from: $ROOT_DIR/.env.prod.example ..."
|
echo "[WARNING] Creating it from: $ROOT_DIR/.env.prod.example ..."
|
||||||
|
|
@ -114,8 +97,6 @@ require_dir "$SKILLS_DIR" "Expected tracked skills directory under project-root
|
||||||
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
load_env_var HOST_BOTS_WORKSPACE_ROOT
|
||||||
load_env_var DATABASE_URL
|
load_env_var DATABASE_URL
|
||||||
load_env_var NGINX_PORT 8080
|
load_env_var NGINX_PORT 8080
|
||||||
load_env_var DOCKER_NETWORK_NAME dashboard-nanobot-network
|
|
||||||
load_env_var DOCKER_NETWORK_SUBNET 172.20.0.0/16
|
|
||||||
load_env_var REDIS_ENABLED false
|
load_env_var REDIS_ENABLED false
|
||||||
load_env_var REDIS_URL
|
load_env_var REDIS_URL
|
||||||
|
|
||||||
|
|
@ -140,7 +121,6 @@ mkdir -p "$DATA_DIR" "$DATA_DIR/model" "$HOST_BOTS_WORKSPACE_ROOT"
|
||||||
|
|
||||||
echo "[deploy] expecting external PostgreSQL to be pre-initialized with scripts/sql/create-tables.sql and scripts/sql/init-data.sql"
|
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" config -q
|
||||||
ensure_docker_network "$DOCKER_NETWORK_NAME" "$DOCKER_NETWORK_SUBNET"
|
|
||||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
echo "[deploy] service status"
|
echo "[deploy] service status"
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,6 @@ AUTH_TOKEN_MAX_ACTIVE_JSON="2"
|
||||||
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
||||||
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
||||||
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS_SEED_JSON="3600"
|
|
||||||
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
||||||
SPEECH_ENABLED_JSON="true"
|
SPEECH_ENABLED_JSON="true"
|
||||||
else
|
else
|
||||||
|
|
@ -176,7 +175,6 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
-v upload_max_mb_json="$UPLOAD_MAX_MB_JSON" \
|
-v upload_max_mb_json="$UPLOAD_MAX_MB_JSON" \
|
||||||
-v allowed_attachment_extensions_json="$ALLOWED_ATTACHMENT_EXTENSIONS_JSON" \
|
-v allowed_attachment_extensions_json="$ALLOWED_ATTACHMENT_EXTENSIONS_JSON" \
|
||||||
-v workspace_download_extensions_json="$WORKSPACE_DOWNLOAD_EXTENSIONS_JSON" \
|
-v workspace_download_extensions_json="$WORKSPACE_DOWNLOAD_EXTENSIONS_JSON" \
|
||||||
-v workspace_preview_token_ttl_seconds_json="$WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS_SEED_JSON" \
|
|
||||||
-v speech_enabled_json="$SPEECH_ENABLED_JSON" \
|
-v speech_enabled_json="$SPEECH_ENABLED_JSON" \
|
||||||
-v activity_event_retention_days_json="$ACTIVITY_EVENT_RETENTION_DAYS_JSON" \
|
-v activity_event_retention_days_json="$ACTIVITY_EVENT_RETENTION_DAYS_JSON" \
|
||||||
-U "$POSTGRES_SUPERUSER" \
|
-U "$POSTGRES_SUPERUSER" \
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,4 @@
|
||||||
\set ON_ERROR_STOP on
|
\set ON_ERROR_STOP on
|
||||||
\if :{?page_size_json}
|
|
||||||
\else
|
|
||||||
\set page_size_json 10
|
|
||||||
\endif
|
|
||||||
\if :{?chat_pull_page_size_json}
|
|
||||||
\else
|
|
||||||
\set chat_pull_page_size_json 60
|
|
||||||
\endif
|
|
||||||
\if :{?auth_token_ttl_hours_json}
|
|
||||||
\else
|
|
||||||
\set auth_token_ttl_hours_json 24
|
|
||||||
\endif
|
|
||||||
\if :{?auth_token_max_active_json}
|
|
||||||
\else
|
|
||||||
\set auth_token_max_active_json 2
|
|
||||||
\endif
|
|
||||||
\if :{?upload_max_mb_json}
|
|
||||||
\else
|
|
||||||
\set upload_max_mb_json 100
|
|
||||||
\endif
|
|
||||||
\if :{?allowed_attachment_extensions_json}
|
|
||||||
\else
|
|
||||||
\set allowed_attachment_extensions_json []
|
|
||||||
\endif
|
|
||||||
\if :{?workspace_download_extensions_json}
|
|
||||||
\else
|
|
||||||
\set workspace_download_extensions_json '[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
|
||||||
\endif
|
|
||||||
\if :{?workspace_preview_token_ttl_seconds_json}
|
|
||||||
\else
|
|
||||||
\set workspace_preview_token_ttl_seconds_json 3600
|
|
||||||
\endif
|
|
||||||
\if :{?speech_enabled_json}
|
|
||||||
\else
|
|
||||||
\set speech_enabled_json true
|
|
||||||
\endif
|
|
||||||
\if :{?activity_event_retention_days_json}
|
|
||||||
\else
|
|
||||||
\set activity_event_retention_days_json 7
|
|
||||||
\endif
|
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
|
@ -62,7 +22,6 @@ VALUES
|
||||||
('upload_max_mb', '上传大小限制', 'upload', '单文件上传大小限制,单位 MB。', 'integer', :'upload_max_mb_json', FALSE, 20, 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()),
|
('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()),
|
('workspace_download_extensions', '工作区下载后缀', 'workspace', '命中后缀的工作区文件默认走下载模式。', 'json', :'workspace_download_extensions_json', FALSE, 30, NOW(), NOW()),
|
||||||
('workspace_preview_token_ttl_seconds', '工作区预览 Token 过期秒数', 'workspace', 'HTML 预览地址中临时访问 Token 的默认有效时长,单位秒。', 'integer', :'workspace_preview_token_ttl_seconds_json', FALSE, 31, NOW(), NOW()),
|
|
||||||
('speech_enabled', '语音识别开关', 'speech', '控制 Bot 语音转写功能是否启用。', 'boolean', :'speech_enabled_json', TRUE, 32, 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())
|
('activity_event_retention_days', '活动事件保留天数', 'maintenance', 'bot_activity_event 运维事件的保留天数,超期记录会自动清理。', 'integer', :'activity_event_retention_days_json', FALSE, 34, NOW(), NOW())
|
||||||
ON CONFLICT (key) DO UPDATE
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
|
@ -75,6 +34,8 @@ SET
|
||||||
sort_order = EXCLUDED.sort_order,
|
sort_order = EXCLUDED.sort_order,
|
||||||
updated_at = NOW();
|
updated_at = NOW();
|
||||||
|
|
||||||
|
DELETE FROM sys_setting WHERE key = 'command_auto_unlock_seconds';
|
||||||
|
|
||||||
INSERT INTO skill_market_item (
|
INSERT INTO skill_market_item (
|
||||||
skill_key,
|
skill_key,
|
||||||
display_name,
|
display_name,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue