main
mula.liu 2026-04-24 11:07:52 +08:00
parent 02a4000416
commit ad2af1e71f
27 changed files with 308 additions and 78 deletions

View File

@ -5,6 +5,11 @@ NGINX_PORT=8080
# Only workspace root still needs an absolute host path. # Only workspace root still needs an absolute host path.
HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots HOST_BOTS_WORKSPACE_ROOT=/opt/dashboard-nanobot/workspace/bots
# Fixed Docker bridge subnet for the compose network.
# 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 # Optional custom image tags
BACKEND_IMAGE_TAG=latest BACKEND_IMAGE_TAG=latest
FRONTEND_IMAGE_TAG=latest FRONTEND_IMAGE_TAG=latest

View File

@ -5,6 +5,11 @@ NGINX_PORT=8082
# Only workspace root still needs an absolute host path. # Only workspace root still needs an absolute host path.
HOST_BOTS_WORKSPACE_ROOT=/dep/dashboard-nanobot/workspace/bots HOST_BOTS_WORKSPACE_ROOT=/dep/dashboard-nanobot/workspace/bots
# Fixed Docker bridge subnet for the compose network.
# 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 # Optional custom image tags
BACKEND_IMAGE_TAG=latest BACKEND_IMAGE_TAG=latest
FRONTEND_IMAGE_TAG=latest FRONTEND_IMAGE_TAG=latest

View File

@ -1,6 +1,10 @@
# Runtime paths # Runtime paths
DATA_ROOT=../data DATA_ROOT=../data
BOTS_WORKSPACE_ROOT=../workspace/bots 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 # Database
# PostgreSQL is required: # PostgreSQL is required:

View File

@ -1,4 +1,7 @@
from core.docker_manager import BotDockerManager 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,
)

View File

@ -11,7 +11,12 @@ import docker
class BotDockerManager: 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: try:
self.client = docker.from_env(timeout=6) self.client = docker.from_env(timeout=6)
self.client.version() self.client.version()
@ -22,6 +27,7 @@ class BotDockerManager:
self.host_data_root = host_data_root self.host_data_root = host_data_root
self.base_image = base_image self.base_image = base_image
self.network_name = str(network_name or "").strip()
self.active_monitors = {} self.active_monitors = {}
self._last_delivery_error: Dict[str, str] = {} self._last_delivery_error: Dict[str, str] = {}
self._storage_limit_supported: Optional[bool] = None self._storage_limit_supported: Optional[bool] = None
@ -132,6 +138,48 @@ class BotDockerManager:
except Exception as e: except Exception as e:
print(f"[DockerManager] failed to cleanup container {container_name}: {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( def _run_container_with_storage_fallback(
self, self,
bot_id: str, bot_id: str,
@ -191,6 +239,7 @@ class BotDockerManager:
container_name = f"worker_{bot_id}" container_name = f"worker_{bot_id}"
os.makedirs(bot_workspace, exist_ok=True) os.makedirs(bot_workspace, exist_ok=True)
cpu, memory, storage = self._normalize_resource_limits(cpu_cores, memory_mb, storage_gb) cpu, memory, storage = self._normalize_resource_limits(cpu_cores, memory_mb, storage_gb)
target_network = self._resolve_container_network()
base_kwargs = { base_kwargs = {
"image": image, "image": image,
"name": container_name, "name": container_name,
@ -201,7 +250,7 @@ class BotDockerManager:
"volumes": { "volumes": {
bot_workspace: {"bind": "/root/.nanobot", "mode": "rw"}, bot_workspace: {"bind": "/root/.nanobot", "mode": "rw"},
}, },
"network_mode": "bridge", "network": target_network,
} }
if memory > 0: if memory > 0:
base_kwargs["mem_limit"] = f"{memory}m" base_kwargs["mem_limit"] = f"{memory}m"
@ -212,10 +261,15 @@ class BotDockerManager:
try: try:
container = self.client.containers.get(container_name) container = self.client.containers.get(container_name)
container.reload() container.reload()
if container.status == "running": if container.status == "running" and self._container_uses_network(container, target_network):
if on_state_change: if on_state_change:
self.ensure_monitor(bot_id, on_state_change) self.ensure_monitor(bot_id, on_state_change)
return True 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) container.remove(force=True)
except docker.errors.NotFound: except docker.errors.NotFound:
pass pass
@ -595,7 +649,8 @@ class BotDockerManager:
container_name = f"worker_{bot_id}" container_name = f"worker_{bot_id}"
payload = {"message": command, "media": media or []} payload = {"message": command, "media": media or []}
container = self.client.containers.get(container_name) 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" target_url = f"http://{ip_address}:9000/chat"
with httpx.Client(timeout=4.0) as client: with httpx.Client(timeout=4.0) as client:

View File

@ -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_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_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535)
APP_RELOAD: Final[bool] = _env_bool("APP_RELOAD", False) 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" AGENT_MD_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "agent_md_templates.json"
TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "topic_presets.json" TOPIC_PRESETS_TEMPLATES_FILE: Final[Path] = RUNTIME_TEMPLATES_ROOT / "topic_presets.json"

View File

@ -13,7 +13,7 @@ class BotInstance(SQLModel, table=True):
docker_status: str = Field(default="STOPPED", index=True) docker_status: str = Field(default="STOPPED", index=True)
current_state: Optional[str] = Field(default="IDLE") current_state: Optional[str] = Field(default="IDLE")
last_action: Optional[str] = Field(default=None) 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) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_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): class NanobotImage(SQLModel, table=True):
__tablename__ = "bot_image" __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 image_id: Optional[str] = Field(default=None) # Docker 内部的 Image ID
version: str # e.g., 0.1.4 version: str # e.g., 0.1.4
status: str = Field(default="READY") # READY, BUILDING, ERROR status: str = Field(default="READY") # READY, BUILDING, ERROR

View File

@ -199,6 +199,19 @@ class BotWorkspaceProvider:
} }
continue 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": if channel_type == "weixin":
weixin_cfg: Dict[str, Any] = { weixin_cfg: Dict[str, Any] = {
"enabled": enabled, "enabled": enabled,

View File

@ -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 "") external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "") app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
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": elif ctype == "weixin":
app_secret = "" app_secret = ""
extra = { extra = {
@ -229,6 +236,14 @@ def channel_api_to_config(row: Dict[str, Any]) -> Dict[str, Any]:
"secret": app_secret, "secret": app_secret,
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
} }
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": if ctype == "weixin":
return { return {
"enabled": enabled, "enabled": enabled,

View File

@ -25,6 +25,10 @@ def get_provider_defaults(provider: str) -> tuple[str, str]:
return normalized, "" 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]: async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]:
provider = str(payload.get("provider") or "").strip() provider = str(payload.get("provider") or "").strip()
api_key = str(payload.get("api_key") or "").strip() api_key = str(payload.get("api_key") or "").strip()
@ -43,26 +47,21 @@ async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]:
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
timeout = httpx.Timeout(20.0, connect=10.0) timeout = httpx.Timeout(20.0, connect=10.0)
url = f"{base}/models" models_url = f"{base}/models"
try: try:
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=headers) response = await client.get(models_url, headers=headers)
if response.status_code >= 400: if response.status_code < 400:
return {
"ok": False,
"provider": normalized_provider,
"status_code": response.status_code,
"detail": response.text[:500],
}
data = response.json() data = response.json()
models_raw = data.get("data", []) if isinstance(data, dict) else [] models_raw = data.get("data", []) if isinstance(data, dict) else []
model_ids: List[str] = [ model_ids: List[str] = [
str(item["id"]) for item in models_raw[:20] if isinstance(item, dict) and item.get("id") 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 { return {
"ok": True, "ok": True,
"provider": normalized_provider, "provider": normalized_provider,
"endpoint": url, "endpoint": models_url,
"models_preview": model_ids[:8], "models_preview": model_ids[:8],
"model_hint": ( "model_hint": (
"model_found" "model_found"
@ -70,10 +69,45 @@ async def test_provider_connection(payload: Dict[str, Any]) -> Dict[str, Any]:
else ("model_not_listed" if model else "") 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": False,
"provider": normalized_provider,
"status_code": response.status_code,
"detail": response.text[:500],
}
except Exception as exc: except Exception as exc:
return { return {
"ok": False, "ok": False,
"provider": normalized_provider, "provider": normalized_provider,
"endpoint": url, "endpoint": models_url,
"detail": str(exc), "detail": str(exc),
} }

View File

@ -31,6 +31,10 @@ TEXT_PREVIEW_EXTENSIONS = {
MARKDOWN_EXTENSIONS = {".md", ".markdown"} 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]: def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]:
root = get_bot_workspace_root(bot_id) root = get_bot_workspace_root(bot_id)
rel = (rel_path or "").strip().replace("\\", "/") 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 return rows
for name in names: for name in names:
if name in {".DS_Store"}: if _is_hidden_workspace_name(name):
continue continue
abs_path = os.path.join(path, name) abs_path = os.path.join(path, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/") 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]] = [] rows: List[Dict[str, Any]] = []
names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower()))
for name in names: for name in names:
if name in {".DS_Store"}: if _is_hidden_workspace_name(name):
continue continue
abs_path = os.path.join(path, name) abs_path = os.path.join(path, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/") 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]]: def _list_workspace_dir_recursive(path: str, root: str) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
for walk_root, dirnames, filenames in os.walk(path): 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()) dirnames.sort(key=lambda v: v.lower())
filenames.sort(key=lambda v: v.lower()) filenames.sort(key=lambda v: v.lower())
for name in dirnames: for name in dirnames:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(walk_root, name) abs_path = os.path.join(walk_root, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/") rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path) 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: for name in filenames:
if name in {".DS_Store"}:
continue
abs_path = os.path.join(walk_root, name) abs_path = os.path.join(walk_root, name)
rel_path = os.path.relpath(abs_path, root).replace("\\", "/") rel_path = os.path.relpath(abs_path, root).replace("\\", "/")
stat = os.stat(abs_path) stat = os.stat(abs_path)

View File

@ -23,8 +23,8 @@ WORKDIR /app
# 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去 # 这一步会把您修改好的 nanobot/channels/dashboard.py 一起拷进去
COPY . /app COPY . /app
# 4. 安装 nanobot # 4. 安装 nanobot(包含 WeCom 渠道依赖)
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ . RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ ".[wecom]"
WORKDIR /root WORKDIR /root
# 官方 gateway 模式,现在它会自动加载您的 DashboardChannel # 官方 gateway 模式,现在它会自动加载您的 DashboardChannel

View File

@ -75,6 +75,7 @@ services:
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
DATA_ROOT: /app/data DATA_ROOT: /app/data
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} 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} DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB}
REDIS_ENABLED: ${REDIS_ENABLED:-true} REDIS_ENABLED: ${REDIS_ENABLED:-true}
REDIS_URL: redis://redis:6379/${REDIS_DB:-8} REDIS_URL: redis://redis:6379/${REDIS_DB:-8}
@ -146,4 +147,8 @@ services:
networks: networks:
default: 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}

View File

@ -21,6 +21,7 @@ services:
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
DATA_ROOT: /app/data DATA_ROOT: /app/data
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME:-dashboard-nanobot-network}
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
REDIS_ENABLED: ${REDIS_ENABLED:-false} REDIS_ENABLED: ${REDIS_ENABLED:-false}
REDIS_URL: ${REDIS_URL:-} REDIS_URL: ${REDIS_URL:-}
@ -91,4 +92,8 @@ services:
networks: networks:
default: 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}

View File

@ -55,6 +55,11 @@ interface MonitorWsMessage {
is_tool?: unknown; is_tool?: unknown;
} }
interface ProgressMessageText {
detailText: string;
summaryText: string;
}
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase(); const s = (v || '').toUpperCase();
if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s; 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; 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(); const text = String(raw || '').replace(/<\/?tool_call>/gi, '').trim();
if (!text) return null; if (!text) return null;
const hasToolCallSignal = const hasToolCallSignal =
@ -117,8 +122,36 @@ function extractToolCallProgressHint(raw: string, isZh: boolean): string | null
const queryMatch = text.match(/"query"\s*:\s*"([^"]+)"/); const queryMatch = text.match(/"query"\s*:\s*"([^"]+)"/);
const pathMatch = text.match(/"path"\s*:\s*"([^"]+)"/); const pathMatch = text.match(/"path"\s*:\s*"([^"]+)"/);
const target = String(queryMatch?.[1] || pathMatch?.[1] || '').trim(); const target = String(queryMatch?.[1] || pathMatch?.[1] || '').trim();
const callLabel = target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName; return target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName;
return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`; }
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) { 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 messageRaw = String(payload.action_msg || payload.msg || data.action_msg || data.msg || '');
const normalizedState = normalizeState(state); const normalizedState = normalizeState(state);
const fullMessage = normalizeAssistantMessageText(messageRaw); 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); updateBotState(bot.id, state, message);
addBotEvent(bot.id, { addBotEvent(bot.id, {
state: normalizedState, state: normalizedState,
@ -320,7 +356,7 @@ export function useBotsSync(forcedBotId?: string, enableMonitorSockets: boolean
channel: sourceChannel || undefined, channel: sourceChannel || undefined,
}); });
if (isDashboardChannel && fullMessage && normalizedState === 'TOOL_CALL') { if (isDashboardChannel && fullMessage && normalizedState === 'TOOL_CALL') {
const chatText = `${isZh ? '工具调用' : 'Tool Call'}\n${fullMessage}`; const chatText = toolProgress?.detailText || fullMessage;
const now = Date.now(); const now = Date.now();
const prev = lastProgressRef.current[bot.id]; const prev = lastProgressRef.current[bot.id];
if (!prev || prev.text !== chatText || now - prev.ts > 1200) { 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') { if (data.type === 'BUS_EVENT') {
const content = normalizeAssistantMessageText(String(data.content || payload.content || '')); const content = normalizeAssistantMessageText(String(data.content || payload.content || ''));
const isProgress = Boolean(data.is_progress); const isProgress = Boolean(data.is_progress);
const toolHintFromText = extractToolCallProgressHint(content, isZh); const isTool = Boolean(data.is_tool) || Boolean(extractToolCallProgressPreview(content));
const isTool = Boolean(data.is_tool) || Boolean(toolHintFromText);
if (isProgress) { if (isProgress) {
const state = normalizeBusState(isTool); const state = normalizeBusState(isTool);
const progressText = summarizeProgressText(content, isZh); const progressMessage = buildProgressMessageText(content, isZh, isTool);
const fullProgress = toolHintFromText || content || progressText || (isZh ? '处理中...' : 'Processing...'); updateBotState(bot.id, state, progressMessage.summaryText || t.progress);
updateBotState(bot.id, state, fullProgress); addBotEvent(bot.id, {
addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); state,
text: progressMessage.summaryText || t.progress,
ts: Date.now(),
channel: sourceChannel || undefined,
});
if (isDashboardChannel) { if (isDashboardChannel) {
const lastUserText = lastUserEchoRef.current[bot.id]?.text || ''; const lastUserText = lastUserEchoRef.current[bot.id]?.text || '';
if (!isTool && isLikelyEchoOfUserInput(fullProgress, lastUserText)) { if (!isTool && isLikelyEchoOfUserInput(progressMessage.detailText, lastUserText)) {
return; return;
} }
const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress; const chatText = progressMessage.detailText;
const now = Date.now(); const now = Date.now();
const prev = lastProgressRef.current[bot.id]; const prev = lastProgressRef.current[bot.id];
if (!prev || prev.text !== chatText || now - prev.ts > 1200) { if (!prev || prev.text !== chatText || now - prev.ts > 1200) {

View File

@ -25,6 +25,8 @@ export const channelsEn = {
fieldPrimary: 'Primary Field', fieldPrimary: 'Primary Field',
fieldSecret: 'Secret Field', fieldSecret: 'Secret Field',
fieldPort: 'Internal Port', fieldPort: 'Internal Port',
botId: 'Bot ID',
secret: 'Secret',
appId: 'App ID', appId: 'App ID',
appSecret: 'App Secret', appSecret: 'App Secret',
clientId: 'Client ID', clientId: 'Client ID',

View File

@ -25,6 +25,8 @@ export const channelsZhCn = {
fieldPrimary: '主字段', fieldPrimary: '主字段',
fieldSecret: '密钥字段', fieldSecret: '密钥字段',
fieldPort: '内部端口', fieldPort: '内部端口',
botId: 'Bot ID',
secret: 'Secret',
appId: 'App ID', appId: 'App ID',
appSecret: 'App Secret', appSecret: 'App Secret',
clientId: 'Client ID', clientId: 'Client ID',

View File

@ -16,7 +16,7 @@ export const imageFactoryEn = {
noRegistered: 'No registered image in DB.', noRegistered: 'No registered image in DB.',
dockerTitle: 'Docker Local Images', dockerTitle: 'Docker Local Images',
dockerDesc: 'System no longer scans engines. Register from docker images only.', 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', update: 'Update',
register: 'Register', register: 'Register',
noDocker: 'No local nanobot-base:* image found.', noDocker: 'No local nanobot-base:* image found.',

View File

@ -16,7 +16,7 @@ export const imageFactoryZhCn = {
noRegistered: '数据库暂无登记镜像。', noRegistered: '数据库暂无登记镜像。',
dockerTitle: '可登记镜像Docker 本地)', dockerTitle: '可登记镜像Docker 本地)',
dockerDesc: '系统不再扫描 engines仅从 docker images 获取并手工登记。', 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: '更新登记', update: '更新登记',
register: '加入数据库', register: '加入数据库',
noDocker: '本地没有 nanobot-base:* 镜像,请先手工构建。', noDocker: '本地没有 nanobot-base:* 镜像,请先手工构建。',

View File

@ -133,6 +133,28 @@ function ChannelFieldsEditor({
return null; return null;
} }
if (ctype === 'wecom') {
return (
<>
<input
className="input"
placeholder={labels.botId}
value={channel.external_app_id || ''}
onChange={(e) => onPatch({ external_app_id: e.target.value })}
autoComplete="off"
/>
<PasswordInput
className="input"
placeholder={labels.secret}
value={channel.app_secret || ''}
onChange={(e) => onPatch({ app_secret: e.target.value })}
autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/>
</>
);
}
if (ctype === 'email') { if (ctype === 'email') {
const extra = channel.extra_config || {}; const extra = channel.extra_config || {};
return ( return (

View File

@ -4,7 +4,7 @@ import type { Components } from 'react-markdown';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png'; 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 { decorateWorkspacePathsForMarkdown, normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown';
import { workspaceFileAction } from '../../../shared/workspace/utils'; import { workspaceFileAction } from '../../../shared/workspace/utils';
import type { ChatMessage } from '../../../types/bot'; import type { ChatMessage } from '../../../types/bot';
@ -75,13 +75,6 @@ interface DashboardConversationMessageRowProps {
onCopyAssistantReply: (text: string) => Promise<void> | void; onCopyAssistantReply: (text: string) => Promise<void> | 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) { function getConversationItemKey(item: ChatMessage, idx: number) {
const messageId = Number(item.id); const messageId = Number(item.id);
if (Number.isFinite(messageId) && messageId > 0) { 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 isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user'; const isUserBubble = item.role === 'user';
const fullText = String(item.text || ''); const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText; const normalizedProgressText = isProgressBubble ? normalizeAssistantMessageText(fullText) : '';
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim(); const progressLineCount = isProgressBubble ? normalizedProgressText.split('\n').length : 0;
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText)); const progressCollapsible = isProgressBubble && progressLineCount > 5;
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : ''; const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0; const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5; const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible; const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? expandedProgress : expandedUser; const expanded = isProgressBubble ? expandedProgress : expandedUser;
const displayText = isProgressBubble && !expanded ? summaryText : fullText; const collapsedClass = collapsible && !expanded ? ((isUserBubble || isProgressBubble) ? 'is-collapsed-user' : 'is-collapsed') : '';
return ( return (
<div <div
@ -202,7 +195,7 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
) : null} ) : null}
</div> </div>
</div> </div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}> <div className={`ops-chat-text ${collapsedClass}`}>
{item.text ? ( {item.text ? (
item.role === 'user' ? ( item.role === 'user' ? (
<> <>
@ -212,15 +205,15 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div> <div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div> </div>
) : null} ) : null}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div> <div className="whitespace-pre-wrap">{normalizeUserMessageText(fullText)}</div>
</> </>
) : ( ) : (
<Suspense <Suspense
fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(displayText)}</div>} fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(fullText)}</div>}
> >
<LazyMarkdownRenderer <LazyMarkdownRenderer
components={markdownComponents} components={markdownComponents}
content={decorateWorkspacePathsForMarkdown(displayText)} content={decorateWorkspacePathsForMarkdown(fullText)}
/> />
</Suspense> </Suspense>
) )

View File

@ -1,5 +1,5 @@
import type { ChannelType } from './types'; 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 RUNTIME_STALE_MS = 45000;
export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']); export const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);

View File

@ -9,7 +9,7 @@ export interface BotDashboardModuleProps {
} }
export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; 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 RuntimeViewMode = 'visual' | 'topic';
export type CompactPanelTab = 'chat' | 'runtime'; export type CompactPanelTab = 'chat' | 'runtime';
export type QuotedReply = { id?: number; text: string; ts: number }; export type QuotedReply = { id?: number; text: string; ts: number };

View File

@ -110,6 +110,28 @@ function renderChannelFields({
return null; return null;
} }
if (channel.channel_type === 'wecom') {
return (
<>
<input
className="input"
placeholder={lc.botId}
value={channel.external_app_id}
onChange={(e) => onUpdateChannel(idx, { external_app_id: e.target.value })}
autoComplete="off"
/>
<PasswordInput
className="input"
placeholder={lc.secret}
value={channel.app_secret}
onChange={(e) => onUpdateChannel(idx, { app_secret: e.target.value })}
autoComplete="new-password"
toggleLabels={passwordToggleLabels}
/>
</>
);
}
return null; return null;
} }

View File

@ -1,8 +1,8 @@
export type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; 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 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 { export interface WizardChannelConfig {
channel_type: ChannelType; channel_type: ChannelType;

View File

@ -293,7 +293,7 @@ export function PlatformBotRuntimeSection({
<div className="platform-bot-runtime-card-head"> <div className="platform-bot-runtime-card-head">
<div> <div>
<div className="platform-monitor-meta"> <div className="platform-monitor-meta">
{isZh ? '直接按页读取容器日志,按时间倒序展示;无日志时回退到最近运行事件' : 'Reading container logs page by page in reverse chronological order; falls back to runtime events if logs are unavailable'} {isZh ? '获取容器日志' : 'Reading container logs'}
</div> </div>
</div> </div>
<div className="platform-bot-runtime-card-actions"> <div className="platform-bot-runtime-card-actions">

View File

@ -11,11 +11,14 @@ CREATE TABLE IF NOT EXISTS bot_instance (
docker_status TEXT NOT NULL DEFAULT 'STOPPED', docker_status TEXT NOT NULL DEFAULT 'STOPPED',
current_state TEXT DEFAULT 'IDLE', current_state TEXT DEFAULT 'IDLE',
last_action TEXT, 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 ( CREATE TABLE IF NOT EXISTS bot_image (
tag TEXT PRIMARY KEY, tag TEXT PRIMARY KEY,
image_id TEXT, image_id TEXT,