Compare commits
3 Commits
codex/offl
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
617e0b2a2a | |
|
|
1c9f66c816 | |
|
|
ec974e6694 |
|
|
@ -5,8 +5,8 @@ NGINX_PORT=8080
|
|||
# Only workspace root still needs an absolute host path.
|
||||
HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots
|
||||
|
||||
# Fixed Docker bridge subnet for the compose network.
|
||||
# Change this if it conflicts with your host LAN / VPN / intranet routing.
|
||||
# Shared Docker network for dashboard and bot containers.
|
||||
# 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_SUBNET=172.20.0.0/16
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ NGINX_PORT=8082
|
|||
# Only workspace root still needs an absolute host path.
|
||||
HOST_BOTS_WORKSPACE_ROOT=/dep/dashboard-nanobot/workspace/bots
|
||||
|
||||
# Fixed Docker bridge subnet for the compose network.
|
||||
# Change this if it conflicts with your host LAN / VPN / intranet routing.
|
||||
# Shared Docker network for dashboard and bot containers.
|
||||
# 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_SUBNET=172.20.0.0/16
|
||||
|
||||
|
|
@ -48,8 +48,6 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
|||
|
||||
# Panel access protection (deployment secret, not stored in sys_setting)
|
||||
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).
|
||||
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ graph TD
|
|||
- 复制 `.env.prod.example` 为 `.env.prod`(位于项目根目录)
|
||||
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
||||
- `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`
|
||||
- 如启用本地语音识别,请将 Whisper `.bin` 模型文件放到宿主机项目根目录的 `data/model/`
|
||||
|
|
@ -122,7 +123,7 @@ graph TD
|
|||
- 如需基础镜像加速,覆盖 `PYTHON_BASE_IMAGE` / `NODE_BASE_IMAGE` / `NGINX_BASE_IMAGE`
|
||||
2. 启动服务
|
||||
- `./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. 访问
|
||||
- `http://<host>:${NGINX_PORT}`(默认 `8080`)
|
||||
|
||||
|
|
@ -130,6 +131,7 @@ graph TD
|
|||
|
||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||
- `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 不可达时,通用缓存健康检查会显示 `degraded`;面板登录认证会自动回退到数据库登录态,不再因为缓存不可达直接报错。
|
||||
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
||||
|
|
@ -160,6 +162,7 @@ graph TD
|
|||
1. 准备部署变量
|
||||
- 复制 `.env.full.example` 为 `.env.full`
|
||||
- `data/` 会自动映射到宿主机项目根目录下的 `./data`
|
||||
- `DOCKER_NETWORK_NAME` 表示 Dashboard 与 Bot 共用的 Docker network;`deploy-full.sh` 会优先复用现有 network,不存在时按 `DOCKER_NETWORK_SUBNET` 自动创建
|
||||
- 必填修改:
|
||||
- `HOST_BOTS_WORKSPACE_ROOT`
|
||||
- `POSTGRES_SUPERPASSWORD`
|
||||
|
|
@ -174,6 +177,7 @@ graph TD
|
|||
### 初始化说明
|
||||
|
||||
- `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` 负责:
|
||||
- 等待 PostgreSQL 就绪
|
||||
- 创建或更新业务账号
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ REDIS_DEFAULT_TTL=60
|
|||
|
||||
# Optional panel-level access password for all backend API/WS calls.
|
||||
PANEL_ACCESS_PASSWORD=
|
||||
WORKSPACE_PREVIEW_SIGNING_SECRET=
|
||||
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600
|
||||
|
||||
# Explicit CORS allowlist for browser credential requests.
|
||||
# For local development, the backend defaults to common Vite dev origins.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from sqlmodel import Session
|
|||
from core.database import get_session
|
||||
from models.bot import BotInstance
|
||||
from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest
|
||||
from services.platform_system_settings_service import get_workspace_preview_token_ttl_seconds
|
||||
from services.workspace_service import (
|
||||
create_workspace_html_preview_url,
|
||||
get_workspace_tree_data,
|
||||
|
|
@ -77,10 +78,13 @@ def create_workspace_preview_url(
|
|||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
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(
|
||||
bot_id=bot_id,
|
||||
path=payload.path,
|
||||
ttl_seconds=payload.ttl_seconds,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
|
||||
@router.get("/api/bots/{bot_id}/workspace/download")
|
||||
|
|
|
|||
|
|
@ -42,19 +42,6 @@ REQUIRED_TABLES = (
|
|||
"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:
|
||||
inspector = inspect(engine)
|
||||
missing = [table_name for table_name in REQUIRED_TABLES if not inspector.has_table(table_name)]
|
||||
|
|
@ -65,30 +52,14 @@ def _validate_required_tables() -> None:
|
|||
"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:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
_validate_required_tables()
|
||||
_validate_required_sys_settings()
|
||||
from services.platform_system_settings_service import validate_required_system_settings
|
||||
|
||||
with Session(engine) as session:
|
||||
validate_required_system_settings(session)
|
||||
|
||||
|
||||
def get_session():
|
||||
|
|
|
|||
|
|
@ -189,15 +189,6 @@ DEFAULT_PAGE_SIZE: Final[int] = 10
|
|||
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_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(
|
||||
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
|
||||
).strip() or "Asia/Shanghai"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ def _normalize_extra_config(raw: Any) -> Dict[str, Any]:
|
|||
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:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp_path = f"{path}.tmp"
|
||||
|
|
@ -85,8 +97,8 @@ class BotWorkspaceProvider:
|
|||
temperature = float(bot_data.get("temperature"))
|
||||
top_p = float(bot_data.get("top_p"))
|
||||
max_tokens = int(bot_data.get("max_tokens"))
|
||||
send_progress = bool(bot_data.get("send_progress"))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints"))
|
||||
send_progress = bool(bot_data.get("send_progress", True))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints", True))
|
||||
|
||||
bot_root = os.path.join(self.host_data_root, bot_id)
|
||||
dot_nanobot_dir = os.path.join(bot_root, ".nanobot")
|
||||
|
|
@ -120,13 +132,15 @@ class BotWorkspaceProvider:
|
|||
provider_name: provider_cfg,
|
||||
},
|
||||
"channels": {
|
||||
"sendProgress": send_progress,
|
||||
"sendToolHints": send_tool_hints,
|
||||
"sendProgress": True,
|
||||
"sendToolHints": True,
|
||||
"dashboard": {
|
||||
"enabled": True,
|
||||
"host": "0.0.0.0",
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"],
|
||||
"sendProgress": send_progress,
|
||||
"sendToolHints": send_tool_hints,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -143,6 +157,7 @@ class BotWorkspaceProvider:
|
|||
if not channel_type or channel_type == "dashboard":
|
||||
continue
|
||||
extra = _normalize_extra_config(channel.get("extra_config"))
|
||||
delivery_flags = _channel_delivery_flags(extra)
|
||||
enabled = bool(channel.get("is_active"))
|
||||
external_app_id = str(channel.get("external_app_id") or "").strip()
|
||||
app_secret = str(channel.get("app_secret") or "").strip()
|
||||
|
|
@ -154,6 +169,7 @@ class BotWorkspaceProvider:
|
|||
"proxy": str(extra.get("proxy") or "").strip(),
|
||||
"replyToMessage": bool(extra.get("replyToMessage")),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -165,6 +181,7 @@ class BotWorkspaceProvider:
|
|||
"encryptKey": str(extra.get("encryptKey") or "").strip(),
|
||||
"verificationToken": str(extra.get("verificationToken") or "").strip(),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -174,6 +191,7 @@ class BotWorkspaceProvider:
|
|||
"clientId": external_app_id,
|
||||
"clientSecret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -187,6 +205,7 @@ class BotWorkspaceProvider:
|
|||
"groupPolicy": str(extra.get("groupPolicy") or "mention"),
|
||||
"groupAllowFrom": extra.get("groupAllowFrom") if isinstance(extra.get("groupAllowFrom"), list) else [],
|
||||
"reactEmoji": str(extra.get("reactEmoji") or "eyes"),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -196,6 +215,7 @@ class BotWorkspaceProvider:
|
|||
"appId": external_app_id,
|
||||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -205,6 +225,7 @@ class BotWorkspaceProvider:
|
|||
"botId": external_app_id,
|
||||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
welcome_message = str(extra.get("welcomeMessage") or "").strip()
|
||||
if welcome_message:
|
||||
|
|
@ -216,6 +237,7 @@ class BotWorkspaceProvider:
|
|||
weixin_cfg: Dict[str, Any] = {
|
||||
"enabled": enabled,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
route_tag = str(extra.get("routeTag") or "").strip()
|
||||
if route_tag:
|
||||
|
|
@ -258,6 +280,7 @@ class BotWorkspaceProvider:
|
|||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom")),
|
||||
**delivery_flags,
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -266,6 +289,7 @@ class BotWorkspaceProvider:
|
|||
"appId": external_app_id,
|
||||
"appSecret": app_secret,
|
||||
**extra,
|
||||
**delivery_flags,
|
||||
}
|
||||
|
||||
_write_json_atomic(os.path.join(dot_nanobot_dir, "config.json"), config_data)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from services.bot_service import (
|
|||
channel_api_to_config,
|
||||
list_bot_channels_from_config,
|
||||
normalize_channel_extra,
|
||||
read_global_delivery_flags,
|
||||
sync_bot_workspace_channels,
|
||||
)
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -311,11 +312,38 @@ def update_bot_channel_config(
|
|||
|
||||
rows = list_bot_channels_from_config(bot)
|
||||
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)
|
||||
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
|
||||
if "channel_type" in update_data and update_data["channel_type"] is not None:
|
||||
new_type = str(update_data["channel_type"]).strip().lower()
|
||||
|
|
@ -344,15 +372,6 @@ def update_bot_channel_config(
|
|||
row["locked"] = new_type == "dashboard"
|
||||
|
||||
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:
|
||||
channels_cfg.pop(existing_type, None)
|
||||
if new_type != "dashboard":
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
|||
normalized_bot_id,
|
||||
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
|
||||
global_delivery_override={
|
||||
"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 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 True,
|
||||
},
|
||||
runtime_overrides={
|
||||
"llm_provider": llm_provider,
|
||||
|
|
@ -154,8 +154,8 @@ def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[st
|
|||
"user_md": payload.user_md,
|
||||
"tools_md": payload.tools_md,
|
||||
"identity_md": payload.identity_md,
|
||||
"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 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 True,
|
||||
},
|
||||
)
|
||||
record_activity_event(
|
||||
|
|
|
|||
|
|
@ -62,8 +62,16 @@ def _normalize_allow_from(raw: Any) -> List[str]:
|
|||
|
||||
def read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
|
||||
if not isinstance(channels_cfg, dict):
|
||||
return False, False
|
||||
return bool(channels_cfg.get("sendProgress")), bool(channels_cfg.get("sendToolHints"))
|
||||
return True, True
|
||||
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]:
|
||||
|
|
@ -81,17 +89,19 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
|||
"encryptKey": cfg.get("encryptKey", ""),
|
||||
"verificationToken": cfg.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
elif ctype == "dingtalk":
|
||||
external_app_id = str(cfg.get("clientId") 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":
|
||||
app_secret = str(cfg.get("token") or "")
|
||||
extra = {
|
||||
"proxy": cfg.get("proxy", ""),
|
||||
"replyToMessage": bool(cfg.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
elif ctype == "slack":
|
||||
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"),
|
||||
"groupAllowFrom": cfg.get("groupAllowFrom", []),
|
||||
"reactEmoji": cfg.get("reactEmoji", "eyes"),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
elif ctype == "qq":
|
||||
external_app_id = str(cfg.get("appId") 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":
|
||||
external_app_id = str(cfg.get("botId") or "")
|
||||
app_secret = str(cfg.get("secret") or "")
|
||||
extra = {
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
"welcomeMessage": str(cfg.get("welcomeMessage") or ""),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
elif ctype == "weixin":
|
||||
app_secret = ""
|
||||
extra = {
|
||||
"hasSavedState": (Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json").is_file(),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
elif ctype == "email":
|
||||
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),
|
||||
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
**_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg),
|
||||
}
|
||||
else:
|
||||
external_app_id = str(
|
||||
|
|
@ -170,8 +184,11 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) -
|
|||
"secret",
|
||||
"token",
|
||||
"appToken",
|
||||
"sendProgress",
|
||||
"sendToolHints",
|
||||
}
|
||||
}
|
||||
extra.update(_delivery_extra({"sendProgress": True, "sendToolHints": True}, cfg))
|
||||
|
||||
return {
|
||||
"id": ctype,
|
||||
|
|
@ -202,6 +219,8 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"encryptKey": extra.get("encryptKey", ""),
|
||||
"verificationToken": extra.get("verificationToken", ""),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "dingtalk":
|
||||
return {
|
||||
|
|
@ -209,6 +228,8 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"clientId": external_app_id,
|
||||
"clientSecret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "telegram":
|
||||
return {
|
||||
|
|
@ -217,6 +238,8 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"proxy": extra.get("proxy", ""),
|
||||
"replyToMessage": bool(extra.get("replyToMessage", False)),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "slack":
|
||||
return {
|
||||
|
|
@ -228,6 +251,8 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"groupPolicy": extra.get("groupPolicy", "mention"),
|
||||
"groupAllowFrom": extra.get("groupAllowFrom", []),
|
||||
"reactEmoji": extra.get("reactEmoji", "eyes"),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "qq":
|
||||
return {
|
||||
|
|
@ -235,6 +260,8 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"appId": external_app_id,
|
||||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "wecom":
|
||||
return {
|
||||
|
|
@ -243,11 +270,15 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"welcomeMessage": str(extra.get("welcomeMessage") or ""),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "weixin":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"token": app_secret,
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
if ctype == "email":
|
||||
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)),
|
||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
"sendProgress": bool(extra.get("sendProgress", True)),
|
||||
"sendToolHints": bool(extra.get("sendToolHints", True)),
|
||||
}
|
||||
merged = dict(extra)
|
||||
merged.update(
|
||||
|
|
@ -290,7 +323,8 @@ def list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
|||
channels_cfg = config_data.get("channels")
|
||||
if not isinstance(channels_cfg, dict):
|
||||
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]] = [
|
||||
{
|
||||
"id": "dashboard",
|
||||
|
|
@ -300,17 +334,19 @@ def list_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]:
|
|||
"app_secret": "",
|
||||
"internal_port": 9000,
|
||||
"is_active": True,
|
||||
"extra_config": {
|
||||
"sendProgress": send_progress,
|
||||
"sendToolHints": send_tool_hints,
|
||||
},
|
||||
"extra_config": dashboard_extra,
|
||||
"locked": True,
|
||||
}
|
||||
]
|
||||
for ctype, cfg in channels_cfg.items():
|
||||
if ctype in {"sendProgress", "sendToolHints", "dashboard"} or not isinstance(cfg, dict):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -513,8 +549,8 @@ def sync_bot_workspace_channels(
|
|||
bot_data.get("storage_gb"),
|
||||
)
|
||||
bot_data.update(resources)
|
||||
send_progress = bool(bot_data.get("send_progress", False))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints", False))
|
||||
send_progress = bool(bot_data.get("send_progress", True))
|
||||
send_tool_hints = bool(bot_data.get("send_tool_hints", True))
|
||||
if isinstance(global_delivery_override, dict):
|
||||
if "sendProgress" in global_delivery_override:
|
||||
send_progress = bool(global_delivery_override.get("sendProgress"))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ from schemas.platform import SystemSettingItem
|
|||
|
||||
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
|
||||
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
|
||||
DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600
|
||||
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
|
||||
WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY = "workspace_preview_token_ttl_seconds"
|
||||
SETTING_KEYS = (
|
||||
"page_size",
|
||||
"chat_pull_page_size",
|
||||
|
|
@ -30,7 +32,7 @@ SETTING_KEYS = (
|
|||
"workspace_download_extensions",
|
||||
"speech_enabled",
|
||||
)
|
||||
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY}
|
||||
PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY, WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}
|
||||
SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"page_size": {
|
||||
"name": "分页大小",
|
||||
|
|
@ -95,6 +97,15 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"is_public": False,
|
||||
"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": {
|
||||
"name": "语音识别开关",
|
||||
"category": "speech",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from services.platform_settings_core import (
|
|||
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
|
||||
PROTECTED_SETTING_KEYS,
|
||||
SYSTEM_SETTING_DEFINITIONS,
|
||||
WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY,
|
||||
_normalize_setting_key,
|
||||
_read_setting_value,
|
||||
_setting_item_from_row,
|
||||
|
|
@ -30,15 +31,19 @@ def _prune_deprecated_system_settings(session: Session) -> None:
|
|||
session.commit()
|
||||
|
||||
|
||||
def validate_required_system_settings(session: Session) -> None:
|
||||
_prune_deprecated_system_settings(session)
|
||||
def _missing_required_system_settings(session: Session) -> List[str]:
|
||||
stmt = select(PlatformSetting.key).where(PlatformSetting.key.in_(REQUIRED_SYSTEM_SETTING_KEYS))
|
||||
present = {
|
||||
str(key or "").strip()
|
||||
for key in session.exec(stmt).all()
|
||||
if str(key or "").strip()
|
||||
}
|
||||
missing = [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present]
|
||||
return [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:
|
||||
raise RuntimeError(
|
||||
"Database seed data is not initialized. "
|
||||
|
|
@ -118,3 +123,22 @@ def get_activity_event_retention_days(session: Session) -> int:
|
|||
"Fix the row manually or reapply scripts/sql/init-data.sql."
|
||||
) from exc
|
||||
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,9 +5,11 @@ import json
|
|||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.settings import WORKSPACE_PREVIEW_SIGNING_SECRET, WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS
|
||||
from core.settings import DATABASE_URL
|
||||
|
||||
HTML_PREVIEW_EXTENSIONS = {".html", ".htm"}
|
||||
DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600
|
||||
_WORKSPACE_PREVIEW_SIGNING_KEY = DATABASE_URL
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
|
|
@ -31,7 +33,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]:
|
||||
normalized_bot_id = str(bot_id or "").strip()
|
||||
normalized_path = normalize_workspace_preview_path(path)
|
||||
ttl = max(60, min(int(ttl_seconds or WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS), 86400))
|
||||
ttl = max(60, int(ttl_seconds or DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS))
|
||||
expires_at = int(time.time()) + ttl
|
||||
payload = {
|
||||
"bot_id": normalized_bot_id,
|
||||
|
|
@ -42,7 +44,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")
|
||||
body = _b64url_encode(payload_json)
|
||||
signature = hmac.new(
|
||||
WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"),
|
||||
_WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"),
|
||||
body.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
|
@ -59,7 +61,7 @@ def resolve_workspace_preview_token(token: str) -> Optional[Dict[str, Any]]:
|
|||
return None
|
||||
body, signature_raw = raw_token.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"),
|
||||
_WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"),
|
||||
body.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
RUN python -m venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md ./
|
||||
COPY pyproject.toml hatch_build.py README.md LICENSE THIRD_PARTY_NOTICES.md ./
|
||||
|
||||
# 3. 先安装第三方依赖。该层只依赖 pyproject.toml,源码改动不会触发整套依赖重装。
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ services:
|
|||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
APP_HOST: 0.0.0.0
|
||||
APP_PORT: 8000
|
||||
APP_PORT: 8002
|
||||
APP_RELOAD: "false"
|
||||
DATABASE_ECHO: "false"
|
||||
DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20}
|
||||
|
|
@ -101,9 +101,9 @@ services:
|
|||
- ./data:/app/data
|
||||
- ${HOST_BOTS_WORKSPACE_ROOT}:${HOST_BOTS_WORKSPACE_ROOT}
|
||||
expose:
|
||||
- "8000"
|
||||
- "8002"
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
@ -150,7 +150,4 @@ services:
|
|||
networks:
|
||||
default:
|
||||
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16}
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -95,7 +95,4 @@ services:
|
|||
networks:
|
||||
default:
|
||||
name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16}
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -53,6 +53,40 @@ function isChannelConfigured(channel: BotChannel): boolean {
|
|||
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({
|
||||
channel,
|
||||
labels,
|
||||
|
|
@ -302,23 +336,18 @@ function ChannelFieldsEditor({
|
|||
interface ChannelConfigModalProps {
|
||||
open: boolean;
|
||||
channels: BotChannel[];
|
||||
globalDelivery: { sendProgress: boolean; sendToolHints: boolean };
|
||||
expandedChannelByKey: Record<string, boolean>;
|
||||
newChannelDraft: BotChannel;
|
||||
addableChannelTypes: ChannelType[];
|
||||
newChannelPanelOpen: boolean;
|
||||
channelCreateMenuOpen: boolean;
|
||||
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
|
||||
isSavingGlobalDelivery: boolean;
|
||||
isSavingChannel: boolean;
|
||||
weixinLoginStatus: WeixinLoginStatus | null;
|
||||
hasSelectedBot: boolean;
|
||||
isZh: boolean;
|
||||
labels: ChannelConfigLabels;
|
||||
passwordToggleLabels: PasswordToggleLabels;
|
||||
onClose: () => void;
|
||||
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
||||
onSaveGlobalDelivery: () => Promise<void> | void;
|
||||
getChannelUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
|
||||
isDashboardChannel: (channel: BotChannel) => boolean;
|
||||
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
|
||||
|
|
@ -337,23 +366,18 @@ interface ChannelConfigModalProps {
|
|||
export function ChannelConfigModal({
|
||||
open,
|
||||
channels,
|
||||
globalDelivery,
|
||||
expandedChannelByKey,
|
||||
newChannelDraft,
|
||||
addableChannelTypes,
|
||||
newChannelPanelOpen,
|
||||
channelCreateMenuOpen,
|
||||
channelCreateMenuRef,
|
||||
isSavingGlobalDelivery,
|
||||
isSavingChannel,
|
||||
weixinLoginStatus,
|
||||
hasSelectedBot,
|
||||
isZh,
|
||||
labels,
|
||||
passwordToggleLabels,
|
||||
onClose,
|
||||
onUpdateGlobalDeliveryFlag,
|
||||
onSaveGlobalDelivery,
|
||||
getChannelUiKey,
|
||||
isDashboardChannel,
|
||||
onUpdateChannelLocal,
|
||||
|
|
@ -410,7 +434,7 @@ export function ChannelConfigModal({
|
|||
<DrawerShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={labels.wizardSectionTitle}
|
||||
title={labels.openManager}
|
||||
size="extend"
|
||||
closeLabel={labels.close}
|
||||
bodyClassName="ops-config-drawer-body"
|
||||
|
|
@ -442,50 +466,18 @@ export function ChannelConfigModal({
|
|||
)}
|
||||
>
|
||||
<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">
|
||||
{channels.filter((channel) => !isDashboardChannel(channel)).length === 0 ? (
|
||||
{channels.length === 0 ? (
|
||||
<div className="ops-empty-inline">{labels.channelEmpty}</div>
|
||||
) : (
|
||||
channels.map((channel, idx) => {
|
||||
if (isDashboardChannel(channel)) return null;
|
||||
const dashboardChannel = isDashboardChannel(channel);
|
||||
const uiKey = getChannelUiKey(channel, idx);
|
||||
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
|
||||
const summary = [
|
||||
String(channel.channel_type || '').toUpperCase(),
|
||||
channel.is_active ? labels.enabled : labels.disabled,
|
||||
isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending,
|
||||
dashboardChannel ? labels.dashboardLocked : (isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending),
|
||||
].join(' · ');
|
||||
return (
|
||||
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
|
||||
|
|
@ -495,24 +487,28 @@ export function ChannelConfigModal({
|
|||
<div className="ops-config-collapsed-meta">{summary}</div>
|
||||
</div>
|
||||
<div className="ops-config-card-actions">
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.is_active}
|
||||
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{labels.enabled}
|
||||
</label>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
disabled={isSavingChannel}
|
||||
onClick={() => void onRemoveChannel(channel)}
|
||||
tooltip={labels.remove}
|
||||
aria-label={labels.remove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</LucentIconButton>
|
||||
{!dashboardChannel ? (
|
||||
<>
|
||||
<label className="field-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.is_active}
|
||||
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{labels.enabled}
|
||||
</label>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm wizard-icon-btn"
|
||||
disabled={isSavingChannel}
|
||||
onClick={() => void onRemoveChannel(channel)}
|
||||
tooltip={labels.remove}
|
||||
aria-label={labels.remove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</LucentIconButton>
|
||||
</>
|
||||
) : null}
|
||||
<LucentIconButton
|
||||
className="ops-plain-icon-btn"
|
||||
onClick={() => onToggleExpandedChannel(uiKey)}
|
||||
|
|
@ -526,16 +522,23 @@ export function ChannelConfigModal({
|
|||
{expanded ? (
|
||||
<>
|
||||
<div className="ops-topic-grid">
|
||||
<ChannelFieldsEditor
|
||||
{dashboardChannel ? null : (
|
||||
<ChannelFieldsEditor
|
||||
channel={channel}
|
||||
labels={labels}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
||||
/>
|
||||
)}
|
||||
<ChannelDeliveryFields
|
||||
channel={channel}
|
||||
labels={labels}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
||||
/>
|
||||
</div>
|
||||
{renderWeixinLoginBlock(channel)}
|
||||
<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)}>
|
||||
<Save size={14} />
|
||||
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
||||
|
|
@ -592,6 +595,11 @@ export function ChannelConfigModal({
|
|||
passwordToggleLabels={passwordToggleLabels}
|
||||
onPatch={onUpdateNewChannelDraft}
|
||||
/>
|
||||
<ChannelDeliveryFields
|
||||
channel={newChannelDraft}
|
||||
labels={labels}
|
||||
onPatch={onUpdateNewChannelDraft}
|
||||
/>
|
||||
</div>
|
||||
<div className="row-between ops-config-footer">
|
||||
<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 type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react';
|
||||
import { useRef, useState, type ChangeEventHandler, type DragEvent, type DragEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
||||
|
||||
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
||||
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
||||
|
|
@ -26,6 +26,7 @@ interface DashboardChatComposerProps {
|
|||
filePickerRef: RefObject<HTMLInputElement | null>;
|
||||
allowedAttachmentExtensions: string[];
|
||||
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
||||
onDropAttachments: (files: FileList) => Promise<void> | void;
|
||||
controlCommandPanelOpen: boolean;
|
||||
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleControlCommandPanel: () => void;
|
||||
|
|
@ -125,6 +126,7 @@ export function DashboardChatComposer({
|
|||
filePickerRef,
|
||||
allowedAttachmentExtensions,
|
||||
onPickAttachments,
|
||||
onDropAttachments,
|
||||
controlCommandPanelOpen,
|
||||
controlCommandPanelRef,
|
||||
onToggleControlCommandPanel,
|
||||
|
|
@ -159,6 +161,46 @@ export function DashboardChatComposer({
|
|||
}: DashboardChatComposerProps) {
|
||||
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -209,7 +251,13 @@ export function DashboardChatComposer({
|
|||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ops-composer">
|
||||
<div
|
||||
className={`ops-composer ${isDraggingAttachments ? 'is-dragging-attachments' : ''}`}
|
||||
onDragEnter={handleAttachmentDragEnter}
|
||||
onDragOver={handleAttachmentDragOver}
|
||||
onDragLeave={handleAttachmentDragLeave}
|
||||
onDrop={handleAttachmentDrop}
|
||||
>
|
||||
<input
|
||||
ref={filePickerRef}
|
||||
type="file"
|
||||
|
|
@ -219,6 +267,11 @@ export function DashboardChatComposer({
|
|||
className="ops-hidden-file-input"
|
||||
/>
|
||||
<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-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@
|
|||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
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 {
|
||||
|
|
@ -57,6 +63,20 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ interface DashboardChatPanelProps {
|
|||
filePickerRef: RefObject<HTMLInputElement | null>;
|
||||
allowedAttachmentExtensions: string[];
|
||||
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
||||
onDropAttachments: (files: FileList) => Promise<void> | void;
|
||||
controlCommandPanelOpen: boolean;
|
||||
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleControlCommandPanel: () => void;
|
||||
|
|
@ -242,6 +243,7 @@ export function DashboardChatPanel({
|
|||
filePickerRef,
|
||||
allowedAttachmentExtensions,
|
||||
onPickAttachments,
|
||||
onDropAttachments,
|
||||
controlCommandPanelOpen,
|
||||
controlCommandPanelRef,
|
||||
onToggleControlCommandPanel,
|
||||
|
|
@ -370,6 +372,7 @@ export function DashboardChatPanel({
|
|||
filePickerRef={filePickerRef}
|
||||
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
||||
onPickAttachments={onPickAttachments}
|
||||
onDropAttachments={onDropAttachments}
|
||||
controlCommandPanelOpen={controlCommandPanelOpen}
|
||||
controlCommandPanelRef={controlCommandPanelRef}
|
||||
onToggleControlCommandPanel={onToggleControlCommandPanel}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,6 @@ export interface ChannelManagerLabels {
|
|||
channels: string;
|
||||
}
|
||||
|
||||
export interface GlobalDeliveryState {
|
||||
sendProgress: boolean;
|
||||
sendToolHints: boolean;
|
||||
}
|
||||
|
||||
interface ApiErrorDetail {
|
||||
detail?: string;
|
||||
}
|
||||
|
|
@ -58,12 +53,9 @@ function resolveApiErrorMessage(error: unknown, fallback: string): string {
|
|||
|
||||
interface ChannelManagerDeps extends PromptApi {
|
||||
selectedBotId: string;
|
||||
selectedBotDockerStatus: string;
|
||||
t: ChannelManagerLabels;
|
||||
currentGlobalDelivery: GlobalDeliveryState;
|
||||
addableChannelTypes: ChannelType[];
|
||||
currentNewChannelDraft: BotChannel;
|
||||
refresh: () => Promise<void>;
|
||||
setShowChannelModal: (value: boolean) => void;
|
||||
setChannels: (value: BotChannel[] | ((prev: BotChannel[]) => BotChannel[])) => void;
|
||||
setExpandedChannelByKey: (
|
||||
|
|
@ -73,20 +65,13 @@ interface ChannelManagerDeps extends PromptApi {
|
|||
setNewChannelPanelOpen: (value: boolean) => void;
|
||||
setNewChannelDraft: (value: BotChannel | ((prev: BotChannel) => BotChannel)) => void;
|
||||
setIsSavingChannel: (value: boolean) => void;
|
||||
setGlobalDelivery: (
|
||||
value: GlobalDeliveryState | ((prev: GlobalDeliveryState) => GlobalDeliveryState)
|
||||
) => void;
|
||||
setIsSavingGlobalDelivery: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function createChannelManager({
|
||||
selectedBotId,
|
||||
selectedBotDockerStatus,
|
||||
t,
|
||||
currentGlobalDelivery,
|
||||
addableChannelTypes,
|
||||
currentNewChannelDraft,
|
||||
refresh,
|
||||
notify,
|
||||
confirm,
|
||||
setShowChannelModal,
|
||||
|
|
@ -96,11 +81,11 @@ export function createChannelManager({
|
|||
setNewChannelPanelOpen,
|
||||
setNewChannelDraft,
|
||||
setIsSavingChannel,
|
||||
setGlobalDelivery,
|
||||
setIsSavingGlobalDelivery,
|
||||
}: ChannelManagerDeps) {
|
||||
const createEmptyChannelExtra = (channelType: ChannelType): Record<string, unknown> =>
|
||||
channelType === 'weixin' ? {} : {};
|
||||
const createEmptyChannelExtra = (): Record<string, unknown> => ({
|
||||
sendProgress: true,
|
||||
sendToolHints: true,
|
||||
});
|
||||
|
||||
const createEmptyChannelDraft = (channelType: ChannelType = 'feishu'): BotChannel => ({
|
||||
id: 'draft-channel',
|
||||
|
|
@ -110,7 +95,7 @@ export function createChannelManager({
|
|||
app_secret: '',
|
||||
internal_port: 8080,
|
||||
is_active: true,
|
||||
extra_config: createEmptyChannelExtra(channelType),
|
||||
extra_config: createEmptyChannelExtra(),
|
||||
});
|
||||
|
||||
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 type = String(channelType || '').toLowerCase();
|
||||
if (type === 'dashboard') return extra || {};
|
||||
if (type === 'weixin') return {};
|
||||
const next = { ...(extra || {}) };
|
||||
delete next.sendProgress;
|
||||
delete next.sendToolHints;
|
||||
next.sendProgress = Boolean(next.sendProgress);
|
||||
next.sendToolHints = Boolean(next.sendToolHints);
|
||||
return next;
|
||||
};
|
||||
|
||||
|
|
@ -143,12 +127,10 @@ export function createChannelManager({
|
|||
setChannels(rows);
|
||||
setExpandedChannelByKey((prev) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
rows
|
||||
.filter((channel) => !isDashboardChannel(channel))
|
||||
.forEach((channel, index) => {
|
||||
const key = channelDraftUiKey(channel, index);
|
||||
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
|
||||
});
|
||||
rows.forEach((channel, index) => {
|
||||
const key = channelDraftUiKey(channel, index);
|
||||
next[key] = typeof prev[key] === 'boolean' ? prev[key] : index === 0;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
|
@ -173,14 +155,15 @@ export function createChannelManager({
|
|||
const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
|
||||
setChannels((prev) =>
|
||||
prev.map((channel, channelIndex) => {
|
||||
if (channelIndex !== index || channel.locked) return channel;
|
||||
if (channelIndex !== index) return channel;
|
||||
return { ...channel, ...patch };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const saveChannel = async (channel: BotChannel) => {
|
||||
if (!selectedBotId || channel.locked || isDashboardChannel(channel)) return;
|
||||
if (!selectedBotId) return;
|
||||
const dashboardChannel = isDashboardChannel(channel);
|
||||
setIsSavingChannel(true);
|
||||
try {
|
||||
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,
|
||||
app_secret: channel.app_secret,
|
||||
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 || {}),
|
||||
});
|
||||
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 {
|
||||
createEmptyChannelDraft,
|
||||
channelDraftUiKey,
|
||||
|
|
@ -285,7 +242,5 @@ export function createChannelManager({
|
|||
saveChannel,
|
||||
addChannel,
|
||||
removeChannel,
|
||||
updateGlobalDeliveryFlag,
|
||||
saveGlobalDelivery,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,6 @@ export function useBotDashboardModule({
|
|||
notify,
|
||||
onPickSkillZip,
|
||||
passwordToggleLabels,
|
||||
refresh,
|
||||
reloginWeixin,
|
||||
removeBotSkill,
|
||||
resetSupportState,
|
||||
|
|
@ -351,6 +350,7 @@ export function useBotDashboardModule({
|
|||
resolveWorkspaceMediaSrc,
|
||||
saveWorkspacePreviewMarkdown,
|
||||
setPendingAttachments,
|
||||
uploadAttachmentFiles,
|
||||
setWorkspaceAutoRefresh,
|
||||
setWorkspacePreviewDraft,
|
||||
setWorkspacePreviewFullscreen,
|
||||
|
|
@ -665,6 +665,7 @@ export function useBotDashboardModule({
|
|||
removeStagedSubmission,
|
||||
pendingAttachments,
|
||||
setPendingAttachments,
|
||||
uploadAttachmentFiles,
|
||||
attachmentUploadPercent,
|
||||
isUploadingAttachments,
|
||||
filePickerRef,
|
||||
|
|
|
|||
|
|
@ -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 { ChannelLabels } from '../localeTypes';
|
||||
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';
|
||||
|
||||
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
|
@ -29,30 +29,14 @@ interface UseDashboardChannelConfigOptions {
|
|||
loadWeixinLoginStatus: (botId: string, silent?: boolean) => Promise<void>;
|
||||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
passwordToggleLabels: { show: string; hide: string };
|
||||
refresh: () => Promise<void>;
|
||||
reloginWeixin: () => Promise<void>;
|
||||
selectedBot?: Pick<BotState, 'id' | 'docker_status' | 'send_progress' | 'send_tool_hints'> | null;
|
||||
selectedBot?: Pick<BotState, 'id'> | null;
|
||||
selectedBotId: string;
|
||||
t: ChannelManagerLabels & { cancel: string; close: string };
|
||||
lc: ChannelLabels;
|
||||
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({
|
||||
closeRuntimeMenu,
|
||||
confirm,
|
||||
|
|
@ -60,7 +44,6 @@ export function useDashboardChannelConfig({
|
|||
loadWeixinLoginStatus,
|
||||
notify,
|
||||
passwordToggleLabels,
|
||||
refresh,
|
||||
reloginWeixin,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
|
|
@ -82,31 +65,15 @@ export function useDashboardChannelConfig({
|
|||
app_secret: '',
|
||||
internal_port: 8080,
|
||||
is_active: true,
|
||||
extra_config: {},
|
||||
extra_config: { sendProgress: true, sendToolHints: true },
|
||||
});
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
|
||||
const [globalDeliveryDraftByBot, setGlobalDeliveryDraftByBot] = useState<Record<string, GlobalDeliveryState>>({});
|
||||
|
||||
const addableChannelTypes = useMemo(() => {
|
||||
const exists = new Set(channels.map((channel) => String(channel.channel_type).toLowerCase()));
|
||||
return optionalChannelTypes.filter((type) => !exists.has(type));
|
||||
}, [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 {
|
||||
resetNewChannelDraft,
|
||||
channelDraftUiKey,
|
||||
|
|
@ -117,16 +84,11 @@ export function useDashboardChannelConfig({
|
|||
saveChannel,
|
||||
addChannel,
|
||||
removeChannel,
|
||||
updateGlobalDeliveryFlag,
|
||||
saveGlobalDelivery,
|
||||
} = createChannelManager({
|
||||
selectedBotId,
|
||||
selectedBotDockerStatus: selectedBot?.docker_status || '',
|
||||
t,
|
||||
currentGlobalDelivery: globalDelivery,
|
||||
addableChannelTypes,
|
||||
currentNewChannelDraft: newChannelDraft,
|
||||
refresh,
|
||||
notify,
|
||||
confirm,
|
||||
setShowChannelModal,
|
||||
|
|
@ -136,8 +98,6 @@ export function useDashboardChannelConfig({
|
|||
setNewChannelPanelOpen,
|
||||
setNewChannelDraft,
|
||||
setIsSavingChannel,
|
||||
setGlobalDelivery,
|
||||
setIsSavingGlobalDelivery,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -172,23 +132,19 @@ export function useDashboardChannelConfig({
|
|||
setNewChannelPanelOpen(false);
|
||||
setChannelCreateMenuOpen(false);
|
||||
resetNewChannelDraft();
|
||||
setGlobalDeliveryDraftByBot({});
|
||||
}, [resetNewChannelDraft]);
|
||||
|
||||
const channelConfigModalProps = {
|
||||
open: showChannelModal,
|
||||
channels,
|
||||
globalDelivery,
|
||||
expandedChannelByKey,
|
||||
newChannelDraft,
|
||||
addableChannelTypes,
|
||||
newChannelPanelOpen,
|
||||
channelCreateMenuOpen,
|
||||
channelCreateMenuRef,
|
||||
isSavingGlobalDelivery,
|
||||
isSavingChannel,
|
||||
weixinLoginStatus,
|
||||
hasSelectedBot: Boolean(selectedBot),
|
||||
isZh,
|
||||
labels: { ...lc, cancel: t.cancel, close: t.close },
|
||||
passwordToggleLabels,
|
||||
|
|
@ -198,8 +154,6 @@ export function useDashboardChannelConfig({
|
|||
setNewChannelPanelOpen(false);
|
||||
resetNewChannelDraft();
|
||||
},
|
||||
onUpdateGlobalDeliveryFlag: updateGlobalDeliveryFlag,
|
||||
onSaveGlobalDelivery: saveGlobalDelivery,
|
||||
getChannelUiKey: channelDraftUiKey,
|
||||
isDashboardChannel,
|
||||
onUpdateChannelLocal: updateChannelLocal,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ interface UseDashboardConfigPanelsOptions {
|
|||
notify: (message: string, options?: NotifyOptions) => void;
|
||||
onPickSkillZip: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
passwordToggleLabels: { show: string; hide: string };
|
||||
refresh: () => Promise<void>;
|
||||
reloginWeixin: () => Promise<void>;
|
||||
removeBotSkill: (skill: WorkspaceSkillOption) => Promise<void>;
|
||||
resetSupportState: () => void;
|
||||
|
|
@ -92,7 +91,6 @@ export function useDashboardConfigPanels({
|
|||
notify,
|
||||
onPickSkillZip,
|
||||
passwordToggleLabels,
|
||||
refresh,
|
||||
reloginWeixin,
|
||||
removeBotSkill,
|
||||
resetSupportState,
|
||||
|
|
@ -159,7 +157,6 @@ export function useDashboardConfigPanels({
|
|||
loadWeixinLoginStatus,
|
||||
notify,
|
||||
passwordToggleLabels,
|
||||
refresh,
|
||||
reloginWeixin,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export function useBotDashboardViewProps({
|
|||
filePickerRef: dashboard.filePickerRef,
|
||||
allowedAttachmentExtensions: dashboard.allowedAttachmentExtensions,
|
||||
onPickAttachments: dashboard.onPickAttachments,
|
||||
onDropAttachments: dashboard.uploadAttachmentFiles,
|
||||
controlCommandPanelOpen: dashboard.controlCommandPanelOpen,
|
||||
controlCommandPanelRef: dashboard.controlCommandPanelRef,
|
||||
onToggleControlCommandPanel: () => {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ export function BotWizardModule({ onCreated, onGoDashboard, onClose, showHeader
|
|||
testProvider,
|
||||
testResult,
|
||||
updateChannel,
|
||||
updateGlobalDeliveryFlag,
|
||||
upsertEnvParam,
|
||||
} = useBotWizard({
|
||||
ui,
|
||||
|
|
@ -207,12 +206,9 @@ export function BotWizardModule({ onCreated, onGoDashboard, onClose, showHeader
|
|||
lc={lc}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
channels={form.channels}
|
||||
sendProgress={Boolean(form.send_progress)}
|
||||
sendToolHints={Boolean(form.send_tool_hints)}
|
||||
addableChannelTypes={addableChannelTypes}
|
||||
newChannelType={newChannelType}
|
||||
onClose={() => setShowChannelModal(false)}
|
||||
onUpdateGlobalDeliveryFlag={updateGlobalDeliveryFlag}
|
||||
onUpdateChannel={updateChannel}
|
||||
onRemoveChannel={removeChannel}
|
||||
onSetNewChannelType={setNewChannelType}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,9 @@ interface BotWizardChannelModalProps {
|
|||
lc: OnboardingChannelLabels;
|
||||
passwordToggleLabels: { show: string; hide: string };
|
||||
channels: WizardChannelConfig[];
|
||||
sendProgress: boolean;
|
||||
sendToolHints: boolean;
|
||||
addableChannelTypes: ChannelType[];
|
||||
newChannelType: ChannelType | '';
|
||||
onClose: () => void;
|
||||
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
||||
onUpdateChannel: (index: number, patch: Partial<WizardChannelConfig>) => void;
|
||||
onRemoveChannel: (index: number) => void;
|
||||
onSetNewChannelType: (value: ChannelType | '') => void;
|
||||
|
|
@ -135,17 +132,50 @@ function renderChannelFields({
|
|||
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({
|
||||
open,
|
||||
lc,
|
||||
passwordToggleLabels,
|
||||
channels,
|
||||
sendProgress,
|
||||
sendToolHints,
|
||||
addableChannelTypes,
|
||||
newChannelType,
|
||||
onClose,
|
||||
onUpdateGlobalDeliveryFlag,
|
||||
onUpdateChannel,
|
||||
onRemoveChannel,
|
||||
onSetNewChannelType,
|
||||
|
|
@ -157,19 +187,6 @@ export function BotWizardChannelModal({
|
|||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<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">
|
||||
{channels.map((channel, idx) => (
|
||||
<div key={`${channel.channel_type}-${idx}`} className="card bot-wizard-channel-card bot-wizard-channel-compact">
|
||||
|
|
@ -187,6 +204,7 @@ export function BotWizardChannelModal({
|
|||
</div>
|
||||
|
||||
{renderChannelFields({ channel, idx, lc, passwordToggleLabels, onUpdateChannel })}
|
||||
{renderChannelDeliveryFields({ channel, idx, lc, onUpdateChannel })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ function clampStorageGb(value: number) {
|
|||
|
||||
function sanitizeChannelExtra(extra: Record<string, unknown>) {
|
||||
const next = { ...(extra || {}) };
|
||||
delete next.sendProgress;
|
||||
delete next.sendToolHints;
|
||||
next.sendProgress = Boolean(next.sendProgress);
|
||||
next.sendToolHints = Boolean(next.sendToolHints);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
|
@ -349,8 +349,6 @@ export function useBotWizard({
|
|||
user_md: form.user_md,
|
||||
tools_md: form.tools_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) => ({
|
||||
channel_type: channel.channel_type,
|
||||
is_active: channel.is_active,
|
||||
|
|
@ -426,7 +424,7 @@ export function useBotWizard({
|
|||
|
||||
const addChannel = () => {
|
||||
if (!newChannelType || !addableChannelTypes.includes(newChannelType)) return;
|
||||
const initialExtraConfig = {};
|
||||
const initialExtraConfig = { sendProgress: true, sendToolHints: true };
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
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 normalized = String(key || '').trim().toUpperCase();
|
||||
if (!normalized) return;
|
||||
|
|
@ -547,7 +538,6 @@ export function useBotWizard({
|
|||
testProvider,
|
||||
testResult,
|
||||
updateChannel,
|
||||
updateGlobalDeliveryFlag,
|
||||
upsertEnvParam,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ export function useBotWorkspace({
|
|||
pendingAttachments,
|
||||
resetPendingAttachments,
|
||||
setPendingAttachments,
|
||||
uploadAttachmentFiles,
|
||||
} = useWorkspaceAttachments({
|
||||
selectedBotId,
|
||||
workspaceCurrentPath,
|
||||
|
|
@ -365,6 +366,7 @@ export function useBotWorkspace({
|
|||
resolveWorkspaceMediaSrc,
|
||||
saveWorkspacePreviewMarkdown,
|
||||
setPendingAttachments,
|
||||
uploadAttachmentFiles,
|
||||
setWorkspaceAutoRefresh,
|
||||
setWorkspacePreviewDraft,
|
||||
setWorkspacePreviewFullscreen,
|
||||
|
|
|
|||
|
|
@ -84,9 +84,10 @@ export function useWorkspaceAttachments({
|
|||
setAttachmentUploadPercent(null);
|
||||
}, [selectedBotId]);
|
||||
|
||||
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedBotId || !event.target.files || event.target.files.length === 0) return;
|
||||
const files = Array.from(event.target.files);
|
||||
const uploadAttachmentFiles = useCallback(async (inputFiles: File[] | FileList) => {
|
||||
if (!selectedBotId || isUploadingAttachments) return;
|
||||
const files = Array.from(inputFiles);
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
const latestAttachmentPolicy = await refreshAttachmentPolicy();
|
||||
const effectiveUploadMaxMb = latestAttachmentPolicy.uploadMaxMb;
|
||||
|
|
@ -103,7 +104,6 @@ export function useWorkspaceAttachments({
|
|||
if (disallowed.length > 0) {
|
||||
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,6 @@ export function useWorkspaceAttachments({
|
|||
if (tooLarge.length > 0) {
|
||||
const names = tooLarge.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -178,9 +177,26 @@ export function useWorkspaceAttachments({
|
|||
} finally {
|
||||
setIsUploadingAttachments(false);
|
||||
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 = '';
|
||||
}
|
||||
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, setPendingAttachments, t, workspaceCurrentPath]);
|
||||
}, [uploadAttachmentFiles]);
|
||||
|
||||
return {
|
||||
attachmentUploadPercent,
|
||||
|
|
@ -189,5 +205,6 @@ export function useWorkspaceAttachments({
|
|||
pendingAttachments,
|
||||
resetPendingAttachments,
|
||||
setPendingAttachments,
|
||||
uploadAttachmentFiles,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,23 @@ load_env_var() {
|
|||
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() {
|
||||
local container_name="$1"
|
||||
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_PASSWORD
|
||||
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 POSTGRES_SUPERUSER
|
||||
|
|
@ -138,6 +157,7 @@ mkdir -p \
|
|||
"$HOST_BOTS_WORKSPACE_ROOT"
|
||||
|
||||
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"
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d postgres redis
|
||||
|
|
|
|||
|
|
@ -80,6 +80,23 @@ load_env_var() {
|
|||
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
|
||||
echo "[WARNING] Missing env file: $ENV_FILE"
|
||||
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 DATABASE_URL
|
||||
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_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"
|
||||
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
|
||||
|
||||
echo "[deploy] service status"
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ 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"]'
|
||||
WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS_SEED_JSON="3600"
|
||||
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
SPEECH_ENABLED_JSON="true"
|
||||
else
|
||||
|
|
@ -175,6 +176,7 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
|||
-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 workspace_preview_token_ttl_seconds_json="$WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS_SEED_JSON" \
|
||||
-v speech_enabled_json="$SPEECH_ENABLED_JSON" \
|
||||
-v activity_event_retention_days_json="$ACTIVITY_EVENT_RETENTION_DAYS_JSON" \
|
||||
-U "$POSTGRES_SUPERUSER" \
|
||||
|
|
|
|||
|
|
@ -1,4 +1,44 @@
|
|||
\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;
|
||||
|
||||
|
|
@ -22,6 +62,7 @@ VALUES
|
|||
('upload_max_mb', '上传大小限制', 'upload', '单文件上传大小限制,单位 MB。', 'integer', :'upload_max_mb_json', FALSE, 20, NOW(), NOW()),
|
||||
('allowed_attachment_extensions', '允许附件后缀', 'upload', '允许上传的附件后缀列表,留空表示不限制。', 'json', :'allowed_attachment_extensions_json', FALSE, 20, NOW(), NOW()),
|
||||
('workspace_download_extensions', '工作区下载后缀', 'workspace', '命中后缀的工作区文件默认走下载模式。', 'json', :'workspace_download_extensions_json', FALSE, 30, NOW(), NOW()),
|
||||
('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()),
|
||||
('activity_event_retention_days', '活动事件保留天数', 'maintenance', 'bot_activity_event 运维事件的保留天数,超期记录会自动清理。', 'integer', :'activity_event_retention_days_json', FALSE, 34, NOW(), NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
|
|
@ -34,8 +75,6 @@ SET
|
|||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW();
|
||||
|
||||
DELETE FROM sys_setting WHERE key = 'command_auto_unlock_seconds';
|
||||
|
||||
INSERT INTO skill_market_item (
|
||||
skill_key,
|
||||
display_name,
|
||||
|
|
|
|||
Loading…
Reference in New Issue