v0.1.5
parent
02a4000416
commit
ad2af1e71f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,26 +47,21 @@ 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],
|
||||
}
|
||||
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": url,
|
||||
"endpoint": models_url,
|
||||
"models_preview": model_ids[:8],
|
||||
"model_hint": (
|
||||
"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 "")
|
||||
),
|
||||
}
|
||||
|
||||
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:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": normalized_provider,
|
||||
"endpoint": url,
|
||||
"endpoint": models_url,
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const channelsZhCn = {
|
|||
fieldPrimary: '主字段',
|
||||
fieldSecret: '密钥字段',
|
||||
fieldPort: '内部端口',
|
||||
botId: 'Bot ID',
|
||||
secret: 'Secret',
|
||||
appId: 'App ID',
|
||||
appSecret: 'App Secret',
|
||||
clientId: 'Client ID',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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:* 镜像,请先手工构建。',
|
||||
|
|
|
|||
|
|
@ -133,6 +133,28 @@ function ChannelFieldsEditor({
|
|||
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') {
|
||||
const extra = channel.extra_config || {};
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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> | 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 (
|
||||
<div
|
||||
|
|
@ -202,7 +195,7 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
|
||||
<div className={`ops-chat-text ${collapsedClass}`}>
|
||||
{item.text ? (
|
||||
item.role === 'user' ? (
|
||||
<>
|
||||
|
|
@ -212,15 +205,15 @@ const DashboardConversationMessageRow = memo(function DashboardConversationMessa
|
|||
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
|
||||
<div className="whitespace-pre-wrap">{normalizeUserMessageText(fullText)}</div>
|
||||
</>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(displayText)}</div>}
|
||||
fallback={<div className="whitespace-pre-wrap">{normalizeAssistantMessageText(fullText)}</div>}
|
||||
>
|
||||
<LazyMarkdownRenderer
|
||||
components={markdownComponents}
|
||||
content={decorateWorkspacePathsForMarkdown(displayText)}
|
||||
content={decorateWorkspacePathsForMarkdown(fullText)}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -110,6 +110,28 @@ function renderChannelFields({
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ export function PlatformBotRuntimeSection({
|
|||
<div className="platform-bot-runtime-card-head">
|
||||
<div>
|
||||
<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 className="platform-bot-runtime-card-actions">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue