修改了部署脚本

main
mula.liu 2026-05-11 18:31:41 +08:00
parent b8e958da13
commit ec974e6694
19 changed files with 279 additions and 248 deletions

View File

@ -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
# Fixed Docker bridge subnet for the compose network. # Shared Docker network for dashboard and bot containers.
# Change this if it conflicts with your host LAN / VPN / intranet routing. # deploy-full.sh will reuse it when it already exists, or create it with the subnet below.
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

View File

@ -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
# Fixed Docker bridge subnet for the compose network. # Shared Docker network for dashboard and bot containers.
# Change this if it conflicts with your host LAN / VPN / intranet routing. # deploy-prod.sh will reuse it when it already exists, or create it with the subnet below.
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

View File

@ -112,6 +112,7 @@ 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/`
@ -122,7 +123,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 --env-file .env.prod -f docker-compose.prod.yml up -d --build` - 如需手动执行 `docker compose`,请先自行创建 `DOCKER_NETWORK_NAME` 指向的 Docker network
3. 访问 3. 访问
- `http://<host>:${NGINX_PORT}`(默认 `8080` - `http://<host>:${NGINX_PORT}`(默认 `8080`
@ -130,6 +131,7 @@ 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`
@ -160,6 +162,7 @@ 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`
@ -174,6 +177,7 @@ 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 就绪
- 创建或更新业务账号 - 创建或更新业务账号

View File

@ -57,6 +57,18 @@ 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"
@ -85,8 +97,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")) send_progress = bool(bot_data.get("send_progress", True))
send_tool_hints = bool(bot_data.get("send_tool_hints")) send_tool_hints = bool(bot_data.get("send_tool_hints", True))
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")
@ -120,13 +132,15 @@ class BotWorkspaceProvider:
provider_name: provider_cfg, provider_name: provider_cfg,
}, },
"channels": { "channels": {
"sendProgress": send_progress, "sendProgress": True,
"sendToolHints": send_tool_hints, "sendToolHints": True,
"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,
}, },
}, },
} }
@ -143,6 +157,7 @@ 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()
@ -154,6 +169,7 @@ 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
@ -165,6 +181,7 @@ 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
@ -174,6 +191,7 @@ 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
@ -187,6 +205,7 @@ 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
@ -196,6 +215,7 @@ 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
@ -205,6 +225,7 @@ 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:
@ -216,6 +237,7 @@ 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:
@ -258,6 +280,7 @@ 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
@ -266,6 +289,7 @@ 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)

View File

@ -18,7 +18,6 @@ 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 (
@ -66,6 +65,8 @@ 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
@ -311,11 +312,38 @@ 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()
@ -344,15 +372,6 @@ 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":

View File

@ -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 False, "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else True,
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else True,
}, },
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 False, "send_progress": bool(payload.send_progress) if payload.send_progress is not None else True,
"send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else True,
}, },
) )
record_activity_event( record_activity_event(

View File

@ -62,8 +62,16 @@ 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 False, False return True, True
return bool(channels_cfg.get("sendProgress")), bool(channels_cfg.get("sendToolHints")) return bool(channels_cfg.get("sendProgress", True)), bool(channels_cfg.get("sendToolHints", True))
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]:
@ -81,17 +89,19 @@ 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", []))} extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), **_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg)}
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 "")
@ -102,22 +112,25 @@ 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", []))} extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), **_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg)}
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 = {
@ -141,6 +154,7 @@ 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(
@ -170,8 +184,11 @@ 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,
@ -202,6 +219,8 @@ 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 {
@ -209,6 +228,8 @@ 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 {
@ -217,6 +238,8 @@ 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 {
@ -228,6 +251,8 @@ 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 {
@ -235,6 +260,8 @@ 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 {
@ -243,11 +270,15 @@ 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 {
@ -272,6 +303,8 @@ 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(
@ -290,7 +323,8 @@ 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 = {}
send_progress, send_tool_hints = read_global_delivery_flags(channels_cfg) dashboard_cfg = channels_cfg.get("dashboard")
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",
@ -300,17 +334,19 @@ 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": { "extra_config": dashboard_extra,
"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
rows.append(channel_config_to_api(bot.id, ctype, cfg)) row = 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
@ -513,8 +549,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", False)) send_progress = bool(bot_data.get("send_progress", True))
send_tool_hints = bool(bot_data.get("send_tool_hints", False)) send_tool_hints = bool(bot_data.get("send_tool_hints", True))
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"))

View File

@ -66,7 +66,7 @@ services:
environment: environment:
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
APP_HOST: 0.0.0.0 APP_HOST: 0.0.0.0
APP_PORT: 8000 APP_PORT: 8002
APP_RELOAD: "false" APP_RELOAD: "false"
DATABASE_ECHO: "false" DATABASE_ECHO: "false"
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20} DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20}
@ -101,9 +101,9 @@ services:
- ./data:/app/data - ./data:/app/data
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT} - ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT}
expose: expose:
- "8000" - "8002"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).read()"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/api/health', timeout=3).read()"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -150,7 +150,4 @@ services:
networks: networks:
default: default:
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
driver: bridge external: true
ipam:
config:
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16}

View File

@ -95,7 +95,4 @@ services:
networks: networks:
default: default:
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
driver: bridge external: true
ipam:
config:
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16}

View File

@ -53,6 +53,40 @@ 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,
@ -302,23 +336,18 @@ 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;
@ -337,23 +366,18 @@ 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,
@ -410,7 +434,7 @@ export function ChannelConfigModal({
<DrawerShell <DrawerShell
open={open} open={open}
onClose={onClose} onClose={onClose}
title={labels.wizardSectionTitle} title={labels.openManager}
size="extend" size="extend"
closeLabel={labels.close} closeLabel={labels.close}
bodyClassName="ops-config-drawer-body" bodyClassName="ops-config-drawer-body"
@ -442,50 +466,18 @@ 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.filter((channel) => !isDashboardChannel(channel)).length === 0 ? ( {channels.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) => {
if (isDashboardChannel(channel)) return null; const dashboardChannel = isDashboardChannel(channel);
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,
isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending, dashboardChannel ? labels.dashboardLocked : (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">
@ -499,6 +491,7 @@ export function ChannelConfigModal({
<input <input
type="checkbox" type="checkbox"
checked={channel.is_active} checked={channel.is_active}
disabled={dashboardChannel}
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })} onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }} style={{ marginRight: 6 }}
/> />
@ -506,7 +499,7 @@ export function ChannelConfigModal({
</label> </label>
<LucentIconButton <LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn" className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingChannel} disabled={isSavingChannel || dashboardChannel}
onClick={() => void onRemoveChannel(channel)} onClick={() => void onRemoveChannel(channel)}
tooltip={labels.remove} tooltip={labels.remove}
aria-label={labels.remove} aria-label={labels.remove}
@ -526,16 +519,23 @@ export function ChannelConfigModal({
{expanded ? ( {expanded ? (
<> <>
<div className="ops-topic-grid"> <div className="ops-topic-grid">
{dashboardChannel ? null : (
<ChannelFieldsEditor <ChannelFieldsEditor
channel={channel} channel={channel}
labels={labels} labels={labels}
passwordToggleLabels={passwordToggleLabels} passwordToggleLabels={passwordToggleLabels}
onPatch={(patch) => onUpdateChannelLocal(idx, patch)} onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
/> />
)}
<ChannelDeliveryFields
channel={channel}
labels={labels}
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">{labels.customChannel}</span> <span className="field-label">{dashboardChannel ? labels.defaultChannel : 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>
@ -592,6 +592,11 @@ 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>

View File

@ -34,11 +34,6 @@ export interface ChannelManagerLabels {
channels: string; channels: string;
} }
export interface GlobalDeliveryState {
sendProgress: boolean;
sendToolHints: boolean;
}
interface ApiErrorDetail { interface ApiErrorDetail {
detail?: string; detail?: string;
} }
@ -58,12 +53,9 @@ 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: (
@ -73,20 +65,13 @@ 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,
@ -96,11 +81,11 @@ export function createChannelManager({
setNewChannelPanelOpen, setNewChannelPanelOpen,
setNewChannelDraft, setNewChannelDraft,
setIsSavingChannel, setIsSavingChannel,
setGlobalDelivery,
setIsSavingGlobalDelivery,
}: ChannelManagerDeps) { }: ChannelManagerDeps) {
const createEmptyChannelExtra = (channelType: ChannelType): Record<string, unknown> => const createEmptyChannelExtra = (): Record<string, unknown> => ({
channelType === 'weixin' ? {} : {}; sendProgress: true,
sendToolHints: true,
});
const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({ const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({
id: 'draft-channel', id: 'draft-channel',
@ -110,7 +95,7 @@ export function createChannelManager({
app_secret: '', app_secret: '',
internal_port: 8080, internal_port: 8080,
is_active: true, is_active: true,
extra_config: createEmptyChannelExtra(channelType), extra_config: createEmptyChannelExtra(),
}); });
const channelDraftUiKey = (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => { const channelDraftUiKey = (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => {
@ -129,10 +114,9 @@ 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 || {}) };
delete next.sendProgress; next.sendProgress = Boolean(next.sendProgress);
delete next.sendToolHints; next.sendToolHints = Boolean(next.sendToolHints);
return next; return next;
}; };
@ -143,9 +127,7 @@ export function createChannelManager({
setChannels(rows); setChannels(rows);
setExpandedChannelByKey((prev) => { setExpandedChannelByKey((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
rows rows.forEach((channel, index) => {
.filter((channel) => !isDashboardChannel(channel))
.forEach((channel, index) => {
const key = channelDraftUiKey(channel, index); const key = channelDraftUiKey(channel, index);
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0; next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
}); });
@ -173,14 +155,15 @@ 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 || channel.locked) return channel; if (channelIndex !== index) return channel;
return { ...channel, ...patch }; return { ...channel, ...patch };
}), }),
); );
}; };
const saveChannel = async (channel: BotChannel) => { const saveChannel = async (channel: BotChannel) => {
if (!selectedBotId || channel.locked || isDashboardChannel(channel)) return; if (!selectedBotId) 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}`, {
@ -188,7 +171,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: channel.is_active, is_active: dashboardChannel ? true : 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);
@ -247,32 +230,6 @@ 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,
@ -285,7 +242,5 @@ export function createChannelManager({
saveChannel, saveChannel,
addChannel, addChannel,
removeChannel, removeChannel,
updateGlobalDeliveryFlag,
saveGlobalDelivery,
}; };
} }

View File

@ -283,7 +283,6 @@ export function useBotDashboardModule({
notify, notify,
onPickSkillZip, onPickSkillZip,
passwordToggleLabels, passwordToggleLabels,
refresh,
reloginWeixin, reloginWeixin,
removeBotSkill, removeBotSkill,
resetSupportState, resetSupportState,

View File

@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } 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, type GlobalDeliveryState } from '../config-managers/channelManager'; import { createChannelManager, type ChannelManagerLabels } 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,30 +29,14 @@ 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' | 'docker_status' | 'send_progress' | 'send_tool_hints'> | null; selectedBot?: Pick<BotState, 'id'> | 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,
@ -60,7 +44,6 @@ export function useDashboardChannelConfig({
loadWeixinLoginStatus, loadWeixinLoginStatus,
notify, notify,
passwordToggleLabels, passwordToggleLabels,
refresh,
reloginWeixin, reloginWeixin,
selectedBot, selectedBot,
selectedBotId, selectedBotId,
@ -82,31 +65,15 @@ export function useDashboardChannelConfig({
app_secret: '', app_secret: '',
internal_port: 8080, internal_port: 8080,
is_active: true, is_active: true,
extra_config: {}, extra_config: { sendProgress: true, sendToolHints: true },
}); });
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,
@ -117,16 +84,11 @@ 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,
@ -136,8 +98,6 @@ export function useDashboardChannelConfig({
setNewChannelPanelOpen, setNewChannelPanelOpen,
setNewChannelDraft, setNewChannelDraft,
setIsSavingChannel, setIsSavingChannel,
setGlobalDelivery,
setIsSavingGlobalDelivery,
}); });
useEffect(() => { useEffect(() => {
@ -172,23 +132,19 @@ 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,
@ -198,8 +154,6 @@ export function useDashboardChannelConfig({
setNewChannelPanelOpen(false); setNewChannelPanelOpen(false);
resetNewChannelDraft(); resetNewChannelDraft();
}, },
onUpdateGlobalDeliveryFlag: updateGlobalDeliveryFlag,
onSaveGlobalDelivery: saveGlobalDelivery,
getChannelUiKey: channelDraftUiKey, getChannelUiKey: channelDraftUiKey,
isDashboardChannel, isDashboardChannel,
onUpdateChannelLocal: updateChannelLocal, onUpdateChannelLocal: updateChannelLocal,

View File

@ -50,7 +50,6 @@ 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;
@ -92,7 +91,6 @@ export function useDashboardConfigPanels({
notify, notify,
onPickSkillZip, onPickSkillZip,
passwordToggleLabels, passwordToggleLabels,
refresh,
reloginWeixin, reloginWeixin,
removeBotSkill, removeBotSkill,
resetSupportState, resetSupportState,
@ -159,7 +157,6 @@ export function useDashboardConfigPanels({
loadWeixinLoginStatus, loadWeixinLoginStatus,
notify, notify,
passwordToggleLabels, passwordToggleLabels,
refresh,
reloginWeixin, reloginWeixin,
selectedBot, selectedBot,
selectedBotId, selectedBotId,

View File

@ -83,7 +83,6 @@ export function BotWizardModule({ onCreated, onGoDashboard, onClose, showHeader
testProvider, testProvider,
testResult, testResult,
updateChannel, updateChannel,
updateGlobalDeliveryFlag,
upsertEnvParam, upsertEnvParam,
} = useBotWizard({ } = useBotWizard({
ui, ui,
@ -207,12 +206,9 @@ 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}

View File

@ -12,12 +12,9 @@ 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;
@ -135,17 +132,50 @@ 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,
@ -157,19 +187,6 @@ 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">
@ -187,6 +204,7 @@ 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>

View File

@ -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 || {}) };
delete next.sendProgress; next.sendProgress = Boolean(next.sendProgress);
delete next.sendToolHints; next.sendToolHints = Boolean(next.sendToolHints);
return next; return next;
} }
@ -349,8 +349,6 @@ 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,
@ -426,7 +424,7 @@ export function useBotWizard({
const addChannel = () => { const addChannel = () => {
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return; if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
const initialExtraConfig = {}; const initialExtraConfig = { sendProgress: true, sendToolHints: true };
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
channels: [ channels: [
@ -458,13 +456,6 @@ 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;
@ -547,7 +538,6 @@ export function useBotWizard({
testProvider, testProvider,
testResult, testResult,
updateChannel, updateChannel,
updateGlobalDeliveryFlag,
upsertEnvParam, upsertEnvParam,
}; };
} }

View File

@ -81,6 +81,23 @@ 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"
@ -119,6 +136,8 @@ 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
@ -138,6 +157,7 @@ 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

View File

@ -80,6 +80,23 @@ 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 ..."
@ -97,6 +114,8 @@ 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
@ -121,6 +140,7 @@ 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"