v0.1.5
parent
02a4000416
commit
ad2af1e71f
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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:* 镜像,请先手工构建。',
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue