From ad2af1e71f0701c404d603c1d89270ee910772f4 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 24 Apr 2026 11:07:52 +0800 Subject: [PATCH] v0.1.5 --- .env.full.example | 5 ++ .env.prod.example | 5 ++ backend/.env.example | 4 + backend/core/docker_instance.py | 7 +- backend/core/docker_manager.py | 63 ++++++++++++++- backend/core/settings.py | 1 + backend/models/bot.py | 4 +- backend/providers/bot_workspace_provider.py | 13 +++ backend/services/bot_service.py | 15 ++++ backend/services/provider_service.py | 80 +++++++++++++------ backend/services/workspace_service.py | 14 ++-- bot-images/Dashboard.Dockerfile | 4 +- docker-compose.full.yml | 7 +- docker-compose.prod.yml | 7 +- frontend/src/hooks/useBotsSync.ts | 65 ++++++++++++--- frontend/src/i18n/channels.en.ts | 2 + frontend/src/i18n/channels.zh-cn.ts | 2 + frontend/src/i18n/image-factory.en.ts | 2 +- frontend/src/i18n/image-factory.zh-cn.ts | 2 +- .../DashboardChannelConfigModal.tsx | 22 +++++ .../DashboardConversationMessages.tsx | 25 +++--- frontend/src/modules/dashboard/constants.ts | 2 +- frontend/src/modules/dashboard/types.ts | 2 +- .../components/BotWizardChannelModal.tsx | 22 +++++ frontend/src/modules/onboarding/types.ts | 4 +- .../components/PlatformBotRuntimeSection.tsx | 2 +- scripts/sql/create-tables.sql | 5 +- 27 files changed, 308 insertions(+), 78 deletions(-) diff --git a/.env.full.example b/.env.full.example index 6a35993..ee59f58 100644 --- a/.env.full.example +++ b/.env.full.example @@ -5,6 +5,11 @@ 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. +DOCKER_NETWORK_NAME=dashboard-nanobot-network +DOCKER_NETWORK_SUBNET=172.20.0.0/16 + # Optional custom image tags BACKEND_IMAGE_TAG=latest FRONTEND_IMAGE_TAG=latest diff --git a/.env.prod.example b/.env.prod.example index cd5b619..f1f8376 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -5,6 +5,11 @@ 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. +DOCKER_NETWORK_NAME=dashboard-nanobot-network +DOCKER_NETWORK_SUBNET=172.20.0.0/16 + # Optional custom image tags BACKEND_IMAGE_TAG=latest FRONTEND_IMAGE_TAG=latest diff --git a/backend/.env.example b/backend/.env.example index 1ebbc66..6c1affe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,10 @@ # Runtime paths DATA_ROOT=../data BOTS_WORKSPACE_ROOT=../workspace/bots +# Optional: when backend itself runs inside docker-compose and bot containers +# should join that same user-defined network, set the network name here. +# Leave empty for local development to use Docker's default bridge network. +DOCKER_NETWORK_NAME= # Database # PostgreSQL is required: diff --git a/backend/core/docker_instance.py b/backend/core/docker_instance.py index 5d817c4..9c7aade 100644 --- a/backend/core/docker_instance.py +++ b/backend/core/docker_instance.py @@ -1,4 +1,7 @@ from core.docker_manager import BotDockerManager -from core.settings import BOTS_WORKSPACE_ROOT +from core.settings import BOTS_WORKSPACE_ROOT, DOCKER_NETWORK_NAME -docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT) +docker_manager = BotDockerManager( + host_data_root=BOTS_WORKSPACE_ROOT, + network_name=DOCKER_NETWORK_NAME, +) diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index a4cb6c6..60b69c5 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -11,7 +11,12 @@ import docker class BotDockerManager: - def __init__(self, host_data_root: str, base_image: str = "nanobot-base:v0.1.4"): + def __init__( + self, + host_data_root: str, + base_image: str = "nanobot-base", + network_name: str = "", + ): try: self.client = docker.from_env(timeout=6) self.client.version() @@ -22,6 +27,7 @@ class BotDockerManager: self.host_data_root = host_data_root self.base_image = base_image + self.network_name = str(network_name or "").strip() self.active_monitors = {} self._last_delivery_error: Dict[str, str] = {} self._storage_limit_supported: Optional[bool] = None @@ -132,6 +138,48 @@ class BotDockerManager: except Exception as e: print(f"[DockerManager] failed to cleanup container {container_name}: {e}") + def _resolve_container_network(self) -> str: + if not self.client or not self.network_name: + return "bridge" + try: + self.client.networks.get(self.network_name) + return self.network_name + except docker.errors.NotFound: + print(f"[DockerManager] network '{self.network_name}' not found; falling back to bridge") + except Exception as e: + print(f"[DockerManager] failed to inspect network '{self.network_name}': {e}; falling back to bridge") + return "bridge" + + @staticmethod + def _container_uses_network(container: Any, network_name: str) -> bool: + attrs = getattr(container, "attrs", {}) or {} + network_settings = attrs.get("NetworkSettings") or {} + networks = network_settings.get("Networks") or {} + if network_name in networks: + return True + if network_name == "bridge" and not networks and str(network_settings.get("IPAddress") or "").strip(): + return True + return False + + @staticmethod + def _get_container_network_ip(container: Any, preferred_network: str = "") -> str: + attrs = getattr(container, "attrs", {}) or {} + network_settings = attrs.get("NetworkSettings") or {} + networks = network_settings.get("Networks") or {} + + if preferred_network: + preferred = networks.get(preferred_network) or {} + preferred_ip = str(preferred.get("IPAddress") or "").strip() + if preferred_ip: + return preferred_ip + + for network in networks.values(): + ip_address = str((network or {}).get("IPAddress") or "").strip() + if ip_address: + return ip_address + + return str(network_settings.get("IPAddress") or "").strip() + def _run_container_with_storage_fallback( self, bot_id: str, @@ -191,6 +239,7 @@ class BotDockerManager: container_name = f"worker_{bot_id}" os.makedirs(bot_workspace, exist_ok=True) cpu, memory, storage = self._normalize_resource_limits(cpu_cores, memory_mb, storage_gb) + target_network = self._resolve_container_network() base_kwargs = { "image": image, "name": container_name, @@ -201,7 +250,7 @@ class BotDockerManager: "volumes": { bot_workspace: {"bind": "/root/.nanobot", "mode": "rw"}, }, - "network_mode": "bridge", + "network": target_network, } if memory > 0: base_kwargs["mem_limit"] = f"{memory}m" @@ -212,10 +261,15 @@ class BotDockerManager: try: container = self.client.containers.get(container_name) container.reload() - if container.status == "running": + if container.status == "running" and self._container_uses_network(container, target_network): if on_state_change: self.ensure_monitor(bot_id, on_state_change) return True + if container.status == "running": + print( + f"[DockerManager] recreating {container_name} to switch network " + f"from current attachment to '{target_network}'" + ) container.remove(force=True) except docker.errors.NotFound: pass @@ -595,7 +649,8 @@ class BotDockerManager: container_name = f"worker_{bot_id}" payload = {"message": command, "media": media or []} container = self.client.containers.get(container_name) - ip_address = container.attrs["NetworkSettings"]["IPAddress"] or "127.0.0.1" + container.reload() + ip_address = self._get_container_network_ip(container, preferred_network=self.network_name) or "127.0.0.1" target_url = f"http://{ip_address}:9000/chat" with httpx.Client(timeout=4.0) as client: diff --git a/backend/core/settings.py b/backend/core/settings.py index 5269c8d..c8b5e53 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -242,6 +242,7 @@ CORS_ALLOWED_ORIGINS: Final[tuple[str, ...]] = _env_origins( APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip() APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535) APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False) +DOCKER_NETWORK_NAME: Final[str] = str(os.getenv("DOCKER_NETWORK_NAME") or "").strip() AGENT_MD_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "agent_md_templates.json" TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "topic_presets.json" diff --git a/backend/models/bot.py b/backend/models/bot.py index d7a16ac..f898b00 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -13,7 +13,7 @@ class BotInstance(SQLModel, table=True): docker_status: str = Field(default="STOPPED", index=True) current_state: Optional[str] = Field(default="IDLE") last_action: Optional[str] = Field(default=None) - image_tag: str = Field(default="nanobot-base:v0.1.4") # 记录该机器人使用的镜像版本 + image_tag: str = Field(default="nanobot-base") # 记录该机器人使用的镜像版本 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) @@ -32,7 +32,7 @@ class BotMessage(SQLModel, table=True): class NanobotImage(SQLModel, table=True): __tablename__ = "bot_image" - tag: str = Field(primary_key=True) # e.g., nanobot-base:v0.1.4 + tag: str = Field(primary_key=True) # e.g., nanobot-base image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID version: str # e.g., 0.1.4 status: str = Field(default="READY") # READY, BUILDING, ERROR diff --git a/backend/providers/bot_workspace_provider.py b/backend/providers/bot_workspace_provider.py index 0e090b0..e04c97d 100644 --- a/backend/providers/bot_workspace_provider.py +++ b/backend/providers/bot_workspace_provider.py @@ -199,6 +199,19 @@ class BotWorkspaceProvider: } continue + if channel_type == "wecom": + wecom_cfg: Dict[str, Any] = { + "enabled": enabled, + "botId": external_app_id, + "secret": app_secret, + "allowFrom": _normalize_allow_from(extra.get("allowFrom")), + } + welcome_message = str(extra.get("welcomeMessage") or "").strip() + if welcome_message: + wecom_cfg["welcomeMessage"] = welcome_message + channels_cfg["wecom"] = wecom_cfg + continue + if channel_type == "weixin": weixin_cfg: Dict[str, Any] = { "enabled": enabled, diff --git a/backend/services/bot_service.py b/backend/services/bot_service.py index ce30471..27e3357 100644 --- a/backend/services/bot_service.py +++ b/backend/services/bot_service.py @@ -107,6 +107,13 @@ def channel_config_to_api(bot_id: str, channel_type: str, cfg: Dict[str, Any]) - external_app_id = str(cfg.get("appId") or "") app_secret = str(cfg.get("secret") or "") extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} + 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 ""), + } elif ctype == "weixin": app_secret = "" extra = { @@ -229,6 +236,14 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]: "secret": app_secret, "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } + if ctype == "wecom": + return { + "enabled": enabled, + "botId": external_app_id, + "secret": app_secret, + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), + "welcomeMessage": str(extra.get("welcomeMessage") or ""), + } if ctype == "weixin": return { "enabled": enabled, diff --git a/backend/services/provider_service.py b/backend/services/provider_service.py index b02b194..d12f3d3 100644 --- a/backend/services/provider_service.py +++ b/backend/services/provider_service.py @@ -25,6 +25,10 @@ def get_provider_defaults(provider: str) -> tuple[str, str]: return normalized, "" +def _is_dashscope_coding_plan_base(api_base: str) -> bool: + return "coding.dashscope.aliyuncs.com" in str(api_base or "").strip().lower() + + async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]: provider = str(payload.get("provider") or "").strip() api_key = str(payload.get("api_key") or "").strip() @@ -43,37 +47,67 @@ async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]: headers = {"Authorization": f"Bearer {api_key}"} timeout = httpx.Timeout(20.0, connect=10.0) - url = f"{base}/models" + models_url = f"{base}/models" try: async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get(url, headers=headers) - if response.status_code >= 400: - return { - "ok": False, - "provider": normalized_provider, - "status_code": response.status_code, - "detail": response.text[:500], - } - data = response.json() - models_raw = data.get("data", []) if isinstance(data, dict) else [] - model_ids: List[str] = [ - str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id") - ] + response = await client.get(models_url, headers=headers) + if response.status_code < 400: + data = response.json() + models_raw = data.get("data", []) if isinstance(data, dict) else [] + model_ids: List[str] = [ + str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id") + ] + if model_ids or not (_is_dashscope_coding_plan_base(base) and model): + return { + "ok": True, + "provider": normalized_provider, + "endpoint": models_url, + "models_preview": model_ids[:8], + "model_hint": ( + "model_found" + if model and any(model in item for item in model_ids) + else ("model_not_listed" if model else "") + ), + } + + if _is_dashscope_coding_plan_base(base) and model: + completions_url = f"{base}/chat/completions" + completion_response = await client.post( + completions_url, + headers=headers, + json={ + "model": model, + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 1, + "temperature": 0, + }, + ) + if completion_response.status_code < 400: + return { + "ok": True, + "provider": normalized_provider, + "endpoint": completions_url, + "models_preview": [model], + "model_hint": "model_found", + } + + return { + "ok": False, + "provider": normalized_provider, + "status_code": completion_response.status_code, + "detail": completion_response.text[:500], + } + return { - "ok": True, + "ok": False, "provider": normalized_provider, - "endpoint": url, - "models_preview": model_ids[:8], - "model_hint": ( - "model_found" - if model and any(model in item for item in model_ids) - else ("model_not_listed" if model else "") - ), + "status_code": response.status_code, + "detail": response.text[:500], } except Exception as exc: return { "ok": False, "provider": normalized_provider, - "endpoint": url, + "endpoint": models_url, "detail": str(exc), } diff --git a/backend/services/workspace_service.py b/backend/services/workspace_service.py index 79296de..98caa53 100644 --- a/backend/services/workspace_service.py +++ b/backend/services/workspace_service.py @@ -31,6 +31,10 @@ TEXT_PREVIEW_EXTENSIONS = { MARKDOWN_EXTENSIONS = {".md", ".markdown"} + +def _is_hidden_workspace_name(name: str) -> bool: + return str(name or "").startswith(".") + def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]: root = get_bot_workspace_root(bot_id) rel = (rel_path or "").strip().replace("\\", "/") @@ -59,7 +63,7 @@ def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, An return rows for name in names: - if name in {".DS_Store"}: + if _is_hidden_workspace_name(name): continue abs_path = os.path.join(path, name) rel_path = os.path.relpath(abs_path, root).replace("\\", "/") @@ -90,7 +94,7 @@ def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]: rows: List[Dict[str, Any]] = [] names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) for name in names: - if name in {".DS_Store"}: + if _is_hidden_workspace_name(name): continue abs_path = os.path.join(path, name) rel_path = os.path.relpath(abs_path, root).replace("\\", "/") @@ -111,12 +115,12 @@ def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]: def _list_workspace_dir_recursive(path: str, root: str) -> List[Dict[str, Any]]: rows: List[Dict[str, Any]] = [] for walk_root, dirnames, filenames in os.walk(path): + dirnames[:] = [name for name in dirnames if not _is_hidden_workspace_name(name)] + filenames = [name for name in filenames if not _is_hidden_workspace_name(name)] dirnames.sort(key=lambda v: v.lower()) filenames.sort(key=lambda v: v.lower()) for name in dirnames: - if name in {".DS_Store"}: - continue abs_path = os.path.join(walk_root, name) rel_path = os.path.relpath(abs_path, root).replace("\\", "/") stat = os.stat(abs_path) @@ -133,8 +137,6 @@ def _list_workspace_dir_recursive(path: str, root: str) -> List[Dict[str, Any]]: ) for name in filenames: - if name in {".DS_Store"}: - continue abs_path = os.path.join(walk_root, name) rel_path = os.path.relpath(abs_path, root).replace("\\", "/") stat = os.stat(abs_path) diff --git a/bot-images/Dashboard.Dockerfile b/bot-images/Dashboard.Dockerfile index 4fe17d0..31d8b0e 100644 --- a/bot-images/Dashboard.Dockerfile +++ b/bot-images/Dashboard.Dockerfile @@ -23,8 +23,8 @@ WORKDIR /app # 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去 COPY . /app -# 4. 安装 nanobot -RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ . +# 4. 安装 nanobot(包含 WeCom 渠道依赖) +RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ ".[wecom]" WORKDIR /root # 官方 gateway 模式,现在它会自动加载您的 DashboardChannel diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 6b53117..0320831 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -75,6 +75,7 @@ services: DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} DATA_ROOT: /app/data BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} + DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB} REDIS_ENABLED: ${REDIS_ENABLED:-true} REDIS_URL: redis://redis:6379/${REDIS_DB:-8} @@ -146,4 +147,8 @@ services: networks: default: - name: dashboard-nanobot-network + name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} + driver: bridge + ipam: + config: + - subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c95fc7f..75fc37f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,6 +21,7 @@ services: DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} DATA_ROOT: /app/data BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} + DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} DATABASE_URL: ${DATABASE_URL:-} REDIS_ENABLED: ${REDIS_ENABLED:-false} REDIS_URL: ${REDIS_URL:-} @@ -91,4 +92,8 @@ services: networks: default: - name: dashboard-nanobot-network + name: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network} + driver: bridge + ipam: + config: + - subnet: ${DOCKER_NETWORK_SUBNET:-172.20.0.0/16} diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 0872b2e..685157c 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -55,6 +55,11 @@ interface MonitorWsMessage { is_tool?: unknown; } +interface ProgressMessageText { + detailText: string; + summaryText: string; +} + function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { const s = (v || '').toUpperCase(); if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s; @@ -103,7 +108,7 @@ function isLikelyEchoOfUserInput(progressText: string, userText: string): boolea return false; } -function extractToolCallProgressHint(raw: string, isZh: boolean): string | null { +function extractToolCallProgressPreview(raw: string): string | null { const text = String(raw || '').replace(/<\/?tool_call>/gi, '').trim(); if (!text) return null; const hasToolCallSignal = @@ -117,8 +122,36 @@ function extractToolCallProgressHint(raw: string, isZh: boolean): string | null const queryMatch = text.match(/"query"\s*:\s*"([^"]+)"/); const pathMatch = text.match(/"path"\s*:\s*"([^"]+)"/); const target = String(queryMatch?.[1] || pathMatch?.[1] || '').trim(); - const callLabel = target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName; - return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`; + return target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName; +} + +function buildProgressMessageText(raw: string, isZh: boolean, isTool: boolean): ProgressMessageText { + const normalized = normalizeAssistantMessageText(raw); + const fallback = isZh ? '处理中...' : 'Processing...'; + + if (!isTool) { + const detailText = normalized || fallback; + return { + detailText, + summaryText: summarizeProgressText(detailText, isZh), + }; + } + + const title = isZh ? '工具调用' : 'Tool Call'; + const preview = extractToolCallProgressPreview(normalized); + const fallbackSummary = normalized ? summarizeProgressText(normalized, isZh) : ''; + const summaryText = preview + ? `${title} · ${preview}` + : (fallbackSummary ? `${title} · ${fallbackSummary}` : title); + + if (!normalized || (!preview && fallbackSummary === normalized)) { + return { detailText: summaryText, summaryText }; + } + + return { + detailText: `${summaryText}\n\n${normalized}`, + summaryText, + }; } export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean = true) { @@ -311,7 +344,10 @@ export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean const messageRaw = String(payload.action_msg || payload.msg || data.action_msg || data.msg || ''); const normalizedState = normalizeState(state); const fullMessage = normalizeAssistantMessageText(messageRaw); - const message = fullMessage || summarizeProgressText(messageRaw, isZh) || t.stateUpdated; + const toolProgress = normalizedState === 'TOOL_CALL' + ? buildProgressMessageText(fullMessage || messageRaw, isZh, true) + : null; + const message = toolProgress?.summaryText || fullMessage || summarizeProgressText(messageRaw, isZh) || t.stateUpdated; updateBotState(bot.id, state, message); addBotEvent(bot.id, { state: normalizedState, @@ -320,7 +356,7 @@ export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean channel: sourceChannel || undefined, }); if (isDashboardChannel && fullMessage && normalizedState === 'TOOL_CALL') { - const chatText = `${isZh ? '工具调用' : 'Tool Call'}\n${fullMessage}`; + const chatText = toolProgress?.detailText || fullMessage; const now = Date.now(); const prev = lastProgressRef.current[bot.id]; if (!prev || prev.text !== chatText || now - prev.ts > 1200) { @@ -353,20 +389,23 @@ export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean if (data.type === 'BUS_EVENT') { const content = normalizeAssistantMessageText(String(data.content || payload.content || '')); const isProgress = Boolean(data.is_progress); - const toolHintFromText = extractToolCallProgressHint(content, isZh); - const isTool = Boolean(data.is_tool) || Boolean(toolHintFromText); + const isTool = Boolean(data.is_tool) || Boolean(extractToolCallProgressPreview(content)); if (isProgress) { const state = normalizeBusState(isTool); - const progressText = summarizeProgressText(content, isZh); - const fullProgress = toolHintFromText || content || progressText || (isZh ? '处理中...' : 'Processing...'); - updateBotState(bot.id, state, fullProgress); - addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); + const progressMessage = buildProgressMessageText(content, isZh, isTool); + updateBotState(bot.id, state, progressMessage.summaryText || t.progress); + addBotEvent(bot.id, { + state, + text: progressMessage.summaryText || t.progress, + ts: Date.now(), + channel: sourceChannel || undefined, + }); if (isDashboardChannel) { const lastUserText = lastUserEchoRef.current[bot.id]?.text || ''; - if (!isTool && isLikelyEchoOfUserInput(fullProgress, lastUserText)) { + if (!isTool && isLikelyEchoOfUserInput(progressMessage.detailText, lastUserText)) { return; } - const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress; + const chatText = progressMessage.detailText; const now = Date.now(); const prev = lastProgressRef.current[bot.id]; if (!prev || prev.text !== chatText || now - prev.ts > 1200) { diff --git a/frontend/src/i18n/channels.en.ts b/frontend/src/i18n/channels.en.ts index 595290f..f062bc2 100644 --- a/frontend/src/i18n/channels.en.ts +++ b/frontend/src/i18n/channels.en.ts @@ -25,6 +25,8 @@ export const channelsEn = { fieldPrimary: 'Primary Field', fieldSecret: 'Secret Field', fieldPort: 'Internal Port', + botId: 'Bot ID', + secret: 'Secret', appId: 'App ID', appSecret: 'App Secret', clientId: 'Client ID', diff --git a/frontend/src/i18n/channels.zh-cn.ts b/frontend/src/i18n/channels.zh-cn.ts index f477203..a8a5d34 100644 --- a/frontend/src/i18n/channels.zh-cn.ts +++ b/frontend/src/i18n/channels.zh-cn.ts @@ -25,6 +25,8 @@ export const channelsZhCn = { fieldPrimary: '主字段', fieldSecret: '密钥字段', fieldPort: '内部端口', + botId: 'Bot ID', + secret: 'Secret', appId: 'App ID', appSecret: 'App Secret', clientId: 'Client ID', diff --git a/frontend/src/i18n/image-factory.en.ts b/frontend/src/i18n/image-factory.en.ts index df8c0cb..e4f7f44 100644 --- a/frontend/src/i18n/image-factory.en.ts +++ b/frontend/src/i18n/image-factory.en.ts @@ -16,7 +16,7 @@ export const imageFactoryEn = { noRegistered: 'No registered image in DB.', dockerTitle: 'Docker Local Images', dockerDesc: 'System no longer scans engines. Register from docker images only.', - dockerTip: 'Build manually then register: docker build -f Dashboard.Dockerfile.manual -t nanobot-base:v0.1.4 .', + dockerTip: 'Build manually then register: docker build -f Dashboard.Dockerfile.manual -t nanobot-base .', update: 'Update', register: 'Register', noDocker: 'No local nanobot-base:* image found.', diff --git a/frontend/src/i18n/image-factory.zh-cn.ts b/frontend/src/i18n/image-factory.zh-cn.ts index 257a9da..7a9793d 100644 --- a/frontend/src/i18n/image-factory.zh-cn.ts +++ b/frontend/src/i18n/image-factory.zh-cn.ts @@ -16,7 +16,7 @@ export const imageFactoryZhCn = { noRegistered: '数据库暂无登记镜像。', dockerTitle: '可登记镜像(Docker 本地)', dockerDesc: '系统不再扫描 engines,仅从 docker images 获取并手工登记。', - dockerTip: '建议手工构建后再登记: docker build -f Dashboard.Dockerfile.manual -t nanobot-base:v0.1.4 .', + dockerTip: '建议手工构建后再登记: docker build -f Dashboard.Dockerfile.manual -t nanobot-base .', update: '更新登记', register: '加入数据库', noDocker: '本地没有 nanobot-base:* 镜像,请先手工构建。', diff --git a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx index 596da06..704467b 100644 --- a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx @@ -133,6 +133,28 @@ function ChannelFieldsEditor({ return null; } + if (ctype === 'wecom') { + return ( + <> + onPatch({ external_app_id: e.target.value })} + autoComplete="off" + /> + onPatch({ app_secret: e.target.value })} + autoComplete="new-password" + toggleLabels={passwordToggleLabels} + /> + + ); + } + if (ctype === 'email') { const extra = channel.extra_config || {}; return ( diff --git a/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx b/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx index 07d7550..025064b 100644 --- a/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx +++ b/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx @@ -4,7 +4,7 @@ import type { Components } from 'react-markdown'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import nanobotLogo from '../../../assets/nanobot-logo.png'; -import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../../../shared/text/messageText'; +import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText'; import { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown'; import { workspaceFileAction } from '../../../shared/workspace/utils'; import type { ChatMessage } from '../../../types/bot'; @@ -75,13 +75,6 @@ interface DashboardConversationMessageRowProps { onCopyAssistantReply: (text: string) => Promise | void; } -function shouldCollapseProgress(text: string) { - const normalized = String(text || '').trim(); - if (!normalized) return false; - const lines = normalized.split('\n').length; - return lines > 6 || normalized.length > 520; -} - function getConversationItemKey(item: ChatMessage, idx: number) { const messageId = Number(item.id); if (Number.isFinite(messageId) && messageId > 0) { @@ -122,15 +115,15 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress'; const isUserBubble = item.role === 'user'; const fullText = String(item.text || ''); - const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; - const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim(); - const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText)); + const normalizedProgressText = isProgressBubble ? normalizeAssistantMessageText(fullText) : ''; + const progressLineCount = isProgressBubble ? normalizedProgressText.split('\n').length : 0; + const progressCollapsible = isProgressBubble && progressLineCount > 5; const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : ''; const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0; const userCollapsible = isUserBubble && userLineCount > 5; const collapsible = isProgressBubble ? progressCollapsible : userCollapsible; const expanded = isProgressBubble ? expandedProgress : expandedUser; - const displayText = isProgressBubble && !expanded ? summaryText : fullText; + const collapsedClass = collapsible && !expanded ? ((isUserBubble || isProgressBubble) ? 'is-collapsed-user' : 'is-collapsed') : ''; return (
-
+
{item.text ? ( item.role === 'user' ? ( <> @@ -212,15 +205,15 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
{normalizeAssistantMessageText(item.quoted_reply)}
) : null} -
{normalizeUserMessageText(displayText)}
+
{normalizeUserMessageText(fullText)}
) : ( {normalizeAssistantMessageText(displayText)}
} + fallback={
{normalizeAssistantMessageText(fullText)}
} > ) diff --git a/frontend/src/modules/dashboard/constants.ts b/frontend/src/modules/dashboard/constants.ts index fdd8c66..8bcf22d 100644 --- a/frontend/src/modules/dashboard/constants.ts +++ b/frontend/src/modules/dashboard/constants.ts @@ -1,5 +1,5 @@ import type { ChannelType } from './types'; -export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack', 'email']; +export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'wecom', 'dingtalk', 'telegram', 'slack', 'email']; export const RUNTIME_STALE_MS = 45000; export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']); diff --git a/frontend/src/modules/dashboard/types.ts b/frontend/src/modules/dashboard/types.ts index cbe717b..789ce69 100644 --- a/frontend/src/modules/dashboard/types.ts +++ b/frontend/src/modules/dashboard/types.ts @@ -9,7 +9,7 @@ export interface BotDashboardModuleProps { } export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; -export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack' | 'email'; +export type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'weixin' | 'wecom' | 'dingtalk' | 'telegram' | 'slack' | 'email'; export type RuntimeViewMode = 'visual' | 'topic'; export type CompactPanelTab = 'chat' | 'runtime'; export type QuotedReply = { id?: number; text: string; ts: number }; diff --git a/frontend/src/modules/onboarding/components/BotWizardChannelModal.tsx b/frontend/src/modules/onboarding/components/BotWizardChannelModal.tsx index cfa24ee..bb3fcc0 100644 --- a/frontend/src/modules/onboarding/components/BotWizardChannelModal.tsx +++ b/frontend/src/modules/onboarding/components/BotWizardChannelModal.tsx @@ -110,6 +110,28 @@ function renderChannelFields({ return null; } + if (channel.channel_type === 'wecom') { + return ( + <> + onUpdateChannel(idx, { external_app_id: e.target.value })} + autoComplete="off" + /> + onUpdateChannel(idx, { app_secret: e.target.value })} + autoComplete="new-password" + toggleLabels={passwordToggleLabels} + /> + + ); + } + return null; } diff --git a/frontend/src/modules/onboarding/types.ts b/frontend/src/modules/onboarding/types.ts index c248e3b..6ca5a86 100644 --- a/frontend/src/modules/onboarding/types.ts +++ b/frontend/src/modules/onboarding/types.ts @@ -1,8 +1,8 @@ export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; -export type ChannelType = 'feishu' | 'qq' | 'weixin' | 'dingtalk' | 'telegram' | 'slack'; +export type ChannelType = 'feishu' | 'qq' | 'weixin' | 'wecom' | 'dingtalk' | 'telegram' | 'slack'; export const EMPTY_CHANNEL_PICKER = '__none__'; -export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'dingtalk', 'telegram', 'slack']; +export const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'weixin', 'wecom', 'dingtalk', 'telegram', 'slack']; export interface WizardChannelConfig { channel_type: ChannelType; diff --git a/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx b/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx index a638d09..8e59407 100644 --- a/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx +++ b/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx @@ -293,7 +293,7 @@ export function PlatformBotRuntimeSection({
- {isZh ? '直接按页读取容器日志,按时间倒序展示;无日志时回退到最近运行事件' : 'Reading container logs page by page in reverse chronological order; falls back to runtime events if logs are unavailable'} + {isZh ? '获取容器日志' : 'Reading container logs'}
diff --git a/scripts/sql/create-tables.sql b/scripts/sql/create-tables.sql index 401f90a..d03f73d 100644 --- a/scripts/sql/create-tables.sql +++ b/scripts/sql/create-tables.sql @@ -11,11 +11,14 @@ CREATE TABLE IF NOT EXISTS bot_instance ( docker_status TEXT NOT NULL DEFAULT 'STOPPED', current_state TEXT DEFAULT 'IDLE', last_action TEXT, - image_tag TEXT NOT NULL DEFAULT 'nanobot-base:v0.1.4', + image_tag TEXT NOT NULL DEFAULT 'nanobot-base', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +ALTER TABLE bot_instance + ALTER COLUMN image_tag SET DEFAULT 'nanobot-base'; + CREATE TABLE IF NOT EXISTS bot_image ( tag TEXT PRIMARY KEY, image_id TEXT,