From 3cb25ba7951f936a345272f0e45d0f067c05c1aa Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Mon, 2 Mar 2026 03:44:06 +0800 Subject: [PATCH] v0.1.2 --- backend/core/config_manager.py | 37 -- backend/core/database.py | 64 ++-- backend/main.py | 329 ++++++++++++++---- backend/models/bot.py | 20 -- frontend/src/hooks/useBotsSync.ts | 32 +- .../modules/dashboard/BotDashboardModule.css | 41 +++ .../modules/dashboard/BotDashboardModule.tsx | 225 +++++++++++- .../modules/onboarding/BotWizardModule.tsx | 1 + 8 files changed, 590 insertions(+), 159 deletions(-) diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index 12ecbd6..18906c4 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -1,4 +1,3 @@ -import json import os from typing import Any, Dict, List @@ -50,41 +49,6 @@ class BotConfigManager: "sendToolHints": bool(bot_data.get("send_tool_hints", False)), } - tools_cfg: Dict[str, Any] = { - "web": { - "search": { - "apiKey": "", - "maxResults": 5, - } - } - } - - raw_tools_cfg = bot_data.get("tools_config_json") - parsed_tools_cfg: Dict[str, Any] = {} - if isinstance(raw_tools_cfg, str) and raw_tools_cfg.strip(): - try: - loaded = json.loads(raw_tools_cfg) - if isinstance(loaded, dict): - parsed_tools_cfg = loaded - except Exception: - parsed_tools_cfg = {} - elif isinstance(raw_tools_cfg, dict): - parsed_tools_cfg = raw_tools_cfg - - web_cfg = parsed_tools_cfg.get("web") if isinstance(parsed_tools_cfg, dict) else None - if isinstance(web_cfg, dict): - search_cfg = web_cfg.get("search") - if isinstance(search_cfg, dict): - api_key = str(search_cfg.get("apiKey") or search_cfg.get("api_key") or "").strip() - max_results_raw = search_cfg.get("maxResults", search_cfg.get("max_results", 5)) - try: - max_results = int(max_results_raw) - except Exception: - max_results = 5 - max_results = max(1, min(max_results, 10)) - tools_cfg["web"]["search"]["apiKey"] = api_key - tools_cfg["web"]["search"]["maxResults"] = max_results - config_data: Dict[str, Any] = { "agents": { "defaults": { @@ -98,7 +62,6 @@ class BotConfigManager: provider_name: provider_cfg, }, "channels": channels_cfg, - "tools": tools_cfg, } for channel in channels: diff --git a/backend/core/database.py b/backend/core/database.py index cac6880..88b9f70 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -10,23 +10,12 @@ engine = create_engine(DATABASE_URL, echo=DATABASE_ECHO) def _ensure_botinstance_columns() -> None: + if engine.dialect.name != "sqlite": + return required_columns = { "current_state": "TEXT DEFAULT 'IDLE'", "last_action": "TEXT", - "soul_md": "TEXT", - "agents_md": "TEXT", - "user_md": "TEXT", - "tools_md": "TEXT", - "tools_config_json": "TEXT", - "identity_md": "TEXT", - "api_base": "TEXT", - "temperature": "REAL DEFAULT 0.2", - "top_p": "REAL DEFAULT 1.0", - "max_tokens": "INTEGER DEFAULT 8192", - "presence_penalty": "REAL DEFAULT 0.0", - "frequency_penalty": "REAL DEFAULT 0.0", - "send_progress": "BOOLEAN DEFAULT 0", - "send_tool_hints": "BOOLEAN DEFAULT 0", + "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'", } with engine.connect() as conn: existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() @@ -38,7 +27,45 @@ def _ensure_botinstance_columns() -> None: conn.commit() +def _drop_legacy_botinstance_columns() -> None: + if engine.dialect.name != "sqlite": + return + legacy_columns = [ + "avatar_model", + "avatar_skin", + "system_prompt", + "soul_md", + "agents_md", + "user_md", + "tools_md", + "tools_config_json", + "identity_md", + "llm_provider", + "llm_model", + "api_key", + "api_base", + "temperature", + "top_p", + "max_tokens", + "presence_penalty", + "frequency_penalty", + "send_progress", + "send_tool_hints", + "bot_env_json", + ] + with engine.connect() as conn: + existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() + existing = {str(row[1]) for row in existing_rows} + for col in legacy_columns: + if col not in existing: + continue + conn.execute(text(f'ALTER TABLE botinstance DROP COLUMN "{col}"')) + conn.commit() + + def _ensure_botmessage_columns() -> None: + if engine.dialect.name != "sqlite": + return required_columns = { "media_json": "TEXT", } @@ -60,18 +87,11 @@ def _drop_legacy_skill_tables() -> None: conn.commit() -def _drop_legacy_channel_tables() -> None: - """Drop deprecated channel route table (channel config source moved to config.json).""" - with engine.connect() as conn: - conn.execute(text("DROP TABLE IF EXISTS channelroute")) - conn.commit() - - def init_database() -> None: SQLModel.metadata.create_all(engine) _drop_legacy_skill_tables() - _drop_legacy_channel_tables() _ensure_botinstance_columns() + _drop_legacy_botinstance_columns() _ensure_botmessage_columns() diff --git a/backend/main.py b/backend/main.py index 265cdaf..f0dc256 100644 --- a/backend/main.py +++ b/backend/main.py @@ -128,6 +128,13 @@ class CommandRequest(BaseModel): attachments: Optional[List[str]] = None +def _normalize_packet_channel(packet: Dict[str, Any]) -> str: + raw = str(packet.get("channel") or packet.get("source") or "").strip().lower() + if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}: + return "dashboard" + return raw + + def _normalize_media_item(bot_id: str, value: Any) -> str: raw = str(value or "").strip().replace("\\", "/") if not raw: @@ -159,7 +166,7 @@ def _persist_runtime_packet(bot_id: str, packet: Dict[str, Any]): packet_type = str(packet.get("type", "")).upper() if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: return - source_channel = str(packet.get("channel") or "").strip().lower() + source_channel = _normalize_packet_channel(packet) if source_channel != "dashboard": return with Session(engine) as session: @@ -341,6 +348,20 @@ def _normalize_channel_extra(raw: Any) -> Dict[str, Any]: return raw +def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]: + if not isinstance(channels_cfg, dict): + return False, False + send_progress = channels_cfg.get("sendProgress") + send_tool_hints = channels_cfg.get("sendToolHints") + dashboard_cfg = channels_cfg.get("dashboard") + if isinstance(dashboard_cfg, dict): + if send_progress is None and "sendProgress" in dashboard_cfg: + send_progress = dashboard_cfg.get("sendProgress") + if send_tool_hints is None and "sendToolHints" in dashboard_cfg: + send_tool_hints = dashboard_cfg.get("sendToolHints") + return bool(send_progress), bool(send_tool_hints) + + def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]: ctype = str(ctype or "").strip().lower() enabled = bool(cfg.get("enabled", True)) @@ -472,8 +493,7 @@ def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]: if not isinstance(channels_cfg, dict): channels_cfg = {} - send_progress = bool(channels_cfg.get("sendProgress", bot.send_progress)) - send_tool_hints = bool(channels_cfg.get("sendToolHints", bot.send_tool_hints)) + send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg) rows: List[Dict[str, Any]] = [ { "id": "dashboard", @@ -492,7 +512,7 @@ def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]: ] for ctype, cfg in channels_cfg.items(): - if ctype in {"sendProgress", "sendToolHints"}: + if ctype in {"sendProgress", "sendToolHints", "dashboard"}: continue if not isinstance(cfg, dict): continue @@ -595,23 +615,161 @@ def _parse_env_params(raw: Any) -> Dict[str, str]: return _normalize_env_params(raw) +def _safe_float(raw: Any, default: float) -> float: + try: + return float(raw) + except Exception: + return default + + +def _safe_int(raw: Any, default: int) -> int: + try: + return int(raw) + except Exception: + return default + + +def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: + path = os.path.join(_workspace_root(bot_id), filename) + if not os.path.isfile(path): + return default_value + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + return default_value + + +def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: + config_data = _read_bot_config(bot.id) + + provider_name = "" + provider_cfg: Dict[str, Any] = {} + providers_cfg = config_data.get("providers") + if isinstance(providers_cfg, dict): + for p_name, p_cfg in providers_cfg.items(): + provider_name = str(p_name or "").strip() + if isinstance(p_cfg, dict): + provider_cfg = p_cfg + break + + agents_defaults: Dict[str, Any] = {} + agents_cfg = config_data.get("agents") + if isinstance(agents_cfg, dict): + defaults = agents_cfg.get("defaults") + if isinstance(defaults, dict): + agents_defaults = defaults + + channels_cfg = config_data.get("channels") + send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg) + + tools_cfg = _normalize_tools_config(config_data.get("tools")) + llm_provider = provider_name or "dashscope" + llm_model = str(agents_defaults.get("model") or "") + api_key = str(provider_cfg.get("apiKey") or "").strip() + api_base = str(provider_cfg.get("apiBase") or "").strip() + + soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) + + return { + "llm_provider": llm_provider, + "llm_model": llm_model, + "api_key": api_key, + "api_base": api_base, + "temperature": _safe_float(agents_defaults.get("temperature"), 0.2), + "top_p": _safe_float(agents_defaults.get("topP"), 1.0), + "max_tokens": _safe_int(agents_defaults.get("maxTokens"), 8192), + "send_progress": send_progress, + "send_tool_hints": send_tool_hints, + "soul_md": soul_md, + "agents_md": _read_workspace_md(bot.id, "AGENTS.md", DEFAULT_AGENTS_MD), + "user_md": _read_workspace_md(bot.id, "USER.md", DEFAULT_USER_MD), + "tools_md": _read_workspace_md(bot.id, "TOOLS.md", DEFAULT_TOOLS_MD), + "identity_md": _read_workspace_md(bot.id, "IDENTITY.md", DEFAULT_IDENTITY_MD), + "system_prompt": soul_md, + "tools_config_json": json.dumps(tools_cfg, ensure_ascii=False), + "tools_config": tools_cfg, + } + + +def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: + runtime = _read_bot_runtime_snapshot(bot) + return { + "id": bot.id, + "name": bot.name, + "avatar_model": "base", + "avatar_skin": "blue_suit", + "image_tag": bot.image_tag, + "llm_provider": runtime.get("llm_provider") or "", + "llm_model": runtime.get("llm_model") or "", + "system_prompt": runtime.get("system_prompt") or "", + "api_base": runtime.get("api_base") or "", + "temperature": _safe_float(runtime.get("temperature"), 0.2), + "top_p": _safe_float(runtime.get("top_p"), 1.0), + "max_tokens": _safe_int(runtime.get("max_tokens"), 8192), + "send_progress": bool(runtime.get("send_progress")), + "send_tool_hints": bool(runtime.get("send_tool_hints")), + "soul_md": runtime.get("soul_md") or "", + "agents_md": runtime.get("agents_md") or "", + "user_md": runtime.get("user_md") or "", + "tools_md": runtime.get("tools_md") or "", + "identity_md": runtime.get("identity_md") or "", + "workspace_dir": bot.workspace_dir, + "docker_status": bot.docker_status, + "current_state": bot.current_state, + "last_action": bot.last_action, + "created_at": bot.created_at, + "updated_at": bot.updated_at, + } + + def _sync_workspace_channels( session: Session, bot_id: str, channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, ) -> None: bot = session.get(BotInstance, bot_id) if not bot: return + snapshot = _read_bot_runtime_snapshot(bot) + bot_data: Dict[str, Any] = { + "name": bot.name, + "system_prompt": snapshot.get("system_prompt") or DEFAULT_SOUL_MD, + "soul_md": snapshot.get("soul_md") or DEFAULT_SOUL_MD, + "agents_md": snapshot.get("agents_md") or DEFAULT_AGENTS_MD, + "user_md": snapshot.get("user_md") or DEFAULT_USER_MD, + "tools_md": snapshot.get("tools_md") or DEFAULT_TOOLS_MD, + "identity_md": snapshot.get("identity_md") or DEFAULT_IDENTITY_MD, + "llm_provider": snapshot.get("llm_provider") or "dashscope", + "llm_model": snapshot.get("llm_model") or "", + "api_key": snapshot.get("api_key") or "", + "api_base": snapshot.get("api_base") or "", + "temperature": _safe_float(snapshot.get("temperature"), 0.2), + "top_p": _safe_float(snapshot.get("top_p"), 1.0), + "max_tokens": _safe_int(snapshot.get("max_tokens"), 8192), + "send_progress": bool(snapshot.get("send_progress")), + "send_tool_hints": bool(snapshot.get("send_tool_hints")), + "tools_config_json": json.dumps(_normalize_tools_config(snapshot.get("tools_config")), ensure_ascii=False), + } + if isinstance(runtime_overrides, dict): + for key, value in runtime_overrides.items(): + bot_data[key] = value + if "tools_config" in bot_data: + bot_data["tools_config_json"] = json.dumps(_normalize_tools_config(bot_data.get("tools_config")), ensure_ascii=False) + + send_progress = bool(bot_data.get("send_progress", False)) + send_tool_hints = bool(bot_data.get("send_tool_hints", False)) + if isinstance(global_delivery_override, dict): + if "sendProgress" in global_delivery_override: + send_progress = bool(global_delivery_override.get("sendProgress")) + if "sendToolHints" in global_delivery_override: + send_tool_hints = bool(global_delivery_override.get("sendToolHints")) + channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot) - bot_data = bot.model_dump() - for row in channels_data: - if str(row.get("channel_type") or "").strip().lower() != "dashboard": - continue - extra = _normalize_channel_extra(row.get("extra_config")) - bot_data["send_progress"] = bool(extra.get("sendProgress", bot.send_progress)) - bot_data["send_tool_hints"] = bool(extra.get("sendToolHints", bot.send_tool_hints)) - break + bot_data["send_progress"] = send_progress + bot_data["send_tool_hints"] = send_tool_hints normalized_channels: List[Dict[str, Any]] = [] for row in channels_data: ctype = str(row.get("channel_type") or "").strip().lower() @@ -979,7 +1137,7 @@ async def test_provider(payload: dict): } -@app.post("/api/bots", response_model=BotInstance) +@app.post("/api/bots") def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)): image_row = session.get(NanobotImage, payload.image_tag) if not image_row: @@ -992,22 +1150,6 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session bot = BotInstance( id=payload.id, name=payload.name, - system_prompt=payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, - soul_md=payload.soul_md or DEFAULT_SOUL_MD, - agents_md=payload.agents_md or DEFAULT_AGENTS_MD, - user_md=payload.user_md or DEFAULT_USER_MD, - tools_md=payload.tools_md or DEFAULT_TOOLS_MD, - tools_config_json=json.dumps(_normalize_tools_config(payload.tools_config), ensure_ascii=False), - identity_md=payload.identity_md or DEFAULT_IDENTITY_MD, - llm_provider=payload.llm_provider, - llm_model=payload.llm_model, - api_key=payload.api_key, - api_base=payload.api_base, - temperature=payload.temperature, - top_p=payload.top_p, - max_tokens=payload.max_tokens, - send_progress=bool(payload.send_progress) if payload.send_progress is not None else False, - send_tool_hints=bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, image_tag=payload.image_tag, workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), ) @@ -1016,16 +1158,44 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session session.commit() session.refresh(bot) _write_env_store(payload.id, _normalize_env_params(payload.env_params)) - _sync_workspace_channels(session, payload.id, channels_override=_normalize_initial_channels(payload.id, payload.channels)) - return bot + _sync_workspace_channels( + session, + payload.id, + channels_override=_normalize_initial_channels(payload.id, payload.channels), + global_delivery_override={ + "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, + "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, + }, + runtime_overrides={ + "llm_provider": payload.llm_provider, + "llm_model": payload.llm_model, + "api_key": payload.api_key, + "api_base": payload.api_base or "", + "temperature": payload.temperature, + "top_p": payload.top_p, + "max_tokens": payload.max_tokens, + "system_prompt": payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, + "soul_md": payload.soul_md or payload.system_prompt or DEFAULT_SOUL_MD, + "agents_md": payload.agents_md or DEFAULT_AGENTS_MD, + "user_md": payload.user_md or DEFAULT_USER_MD, + "tools_md": payload.tools_md or DEFAULT_TOOLS_MD, + "identity_md": payload.identity_md or DEFAULT_IDENTITY_MD, + "tools_config_json": json.dumps(_normalize_tools_config(payload.tools_config), ensure_ascii=False), + "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, + "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, + }, + ) + session.refresh(bot) + return _serialize_bot(bot) -@app.get("/api/bots", response_model=List[BotInstance]) +@app.get("/api/bots") def list_bots(session: Session = Depends(get_session)): - return session.exec(select(BotInstance)).all() + bots = session.exec(select(BotInstance)).all() + return [_serialize_bot(bot) for bot in bots] -@app.put("/api/bots/{bot_id}", response_model=BotInstance) +@app.put("/api/bots/{bot_id}") def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) if not bot: @@ -1043,21 +1213,64 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend if not docker_manager.has_image(image_tag): raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}") + env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None + runtime_overrides: Dict[str, Any] = {} tools_config = update_data.pop("tools_config", None) if isinstance(update_data, dict) else None if tools_config is not None: - bot.tools_config_json = json.dumps(_normalize_tools_config(tools_config), ensure_ascii=False) - env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None + runtime_overrides["tools_config_json"] = json.dumps(_normalize_tools_config(tools_config), ensure_ascii=False) + runtime_fields = { + "llm_provider", + "llm_model", + "api_key", + "api_base", + "temperature", + "top_p", + "max_tokens", + "soul_md", + "agents_md", + "user_md", + "tools_md", + "identity_md", + "send_progress", + "send_tool_hints", + "system_prompt", + } + for field in runtime_fields: + if field in update_data: + runtime_overrides[field] = update_data.pop(field) + + if "system_prompt" in runtime_overrides and "soul_md" not in runtime_overrides: + runtime_overrides["soul_md"] = runtime_overrides["system_prompt"] + if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides: + runtime_overrides["system_prompt"] = runtime_overrides["soul_md"] + + db_fields = {"name", "image_tag"} for key, value in update_data.items(): - setattr(bot, key, value) + if key in db_fields: + setattr(bot, key, value) session.add(bot) session.commit() session.refresh(bot) if env_params is not None: _write_env_store(bot_id, _normalize_env_params(env_params)) - _sync_workspace_channels(session, bot_id) - return bot + global_delivery_override: Optional[Dict[str, Any]] = None + if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides: + global_delivery_override = {} + if "send_progress" in runtime_overrides: + global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress")) + if "send_tool_hints" in runtime_overrides: + global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints")) + + _sync_workspace_channels( + session, + bot_id, + runtime_overrides=runtime_overrides if runtime_overrides else None, + global_delivery_override=global_delivery_override, + ) + session.refresh(bot) + return _serialize_bot(bot) @app.post("/api/bots/{bot_id}/start") @@ -1155,7 +1368,9 @@ def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)): raise HTTPException(status_code=404, detail="Bot not found") return { "bot_id": bot_id, - "tools_config": _parse_tools_config(bot.tools_config_json), + "tools_config": {}, + "managed_by_dashboard": False, + "hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.", } @@ -1164,19 +1379,10 @@ def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, s bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") - - normalized = _normalize_tools_config(payload.tools_config) - bot.tools_config_json = json.dumps(normalized, ensure_ascii=False) - session.add(bot) - session.commit() - session.refresh(bot) - - _sync_workspace_channels(session, bot_id) - return { - "status": "updated", - "bot_id": bot_id, - "tools_config": normalized, - } + raise HTTPException( + status_code=400, + detail="Tools config is no longer managed by dashboard. Please set required env vars manually.", + ) @app.get("/api/bots/{bot_id}/env-params") @@ -1391,19 +1597,20 @@ def update_bot_channel( row["id"] = new_type row["locked"] = new_type == "dashboard" - if new_type == "dashboard": - extra = _normalize_channel_extra(row.get("extra_config")) - bot.send_progress = bool(extra.get("sendProgress", bot.send_progress)) - bot.send_tool_hints = bool(extra.get("sendToolHints", bot.send_tool_hints)) - session.add(bot) - config_data = _read_bot_config(bot_id) channels_cfg = config_data.get("channels") if not isinstance(channels_cfg, dict): channels_cfg = {} config_data["channels"] = channels_cfg - channels_cfg["sendProgress"] = bool(bot.send_progress) - channels_cfg["sendToolHints"] = bool(bot.send_tool_hints) + current_send_progress, current_send_tool_hints = _read_global_delivery_flags(channels_cfg) + if new_type == "dashboard": + extra = _normalize_channel_extra(row.get("extra_config")) + channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress)) + channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints)) + else: + channels_cfg["sendProgress"] = current_send_progress + channels_cfg["sendToolHints"] = current_send_tool_hints + channels_cfg.pop("dashboard", None) if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type: channels_cfg.pop(existing_type, None) if new_type != "dashboard": diff --git a/backend/models/bot.py b/backend/models/bot.py index 401c358..0e3b58e 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -5,26 +5,6 @@ from datetime import datetime class BotInstance(SQLModel, table=True): id: str = Field(primary_key=True) name: str - avatar_model: str = Field(default="base") - avatar_skin: Optional[str] = Field(default="blue_suit") - system_prompt: Optional[str] = Field(default=None) - soul_md: Optional[str] = Field(default=None) - agents_md: Optional[str] = Field(default=None) - user_md: Optional[str] = Field(default=None) - tools_md: Optional[str] = Field(default=None) - tools_config_json: Optional[str] = Field(default=None) # JSON string for runtime tools config - identity_md: Optional[str] = Field(default=None) - llm_provider: str - llm_model: str - api_key: str # Encrypted - api_base: Optional[str] = Field(default=None) - temperature: float = Field(default=0.2) - top_p: float = Field(default=1.0) - max_tokens: int = Field(default=8192) - presence_penalty: float = Field(default=0.0) - frequency_penalty: float = Field(default=0.0) - send_progress: bool = Field(default=False) - send_tool_hints: bool = Field(default=False) workspace_dir: str = Field(unique=True) docker_status: str = Field(default="STOPPED", index=True) current_state: Optional[str] = Field(default="IDLE") diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index f5a967b..2d78871 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -23,12 +23,19 @@ function normalizeMedia(raw: unknown): string[] { return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0); } +function normalizeChannelName(raw: unknown): string { + const channel = String(raw || '').trim().toLowerCase(); + if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard'; + return channel; +} + export function useBotsSync() { const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const socketsRef = useRef>({}); const heartbeatsRef = useRef>({}); const lastUserEchoRef = useRef>({}); const lastAssistantRef = useRef>({}); + const lastProgressRef = useRef>({}); const hydratedMessagesRef = useRef>({}); const isZh = useAppStore((s) => s.locale === 'zh'); const locale = useAppStore((s) => s.locale); @@ -134,7 +141,7 @@ export function useBotsSync() { } catch { return; } - const sourceChannel = String(data?.channel || '').trim().toLowerCase(); + const sourceChannel = normalizeChannelName(data?.channel || data?.source); const isDashboardChannel = sourceChannel === 'dashboard'; const payload = data?.payload && typeof data.payload === 'object' ? data.payload : {}; if (data.type === 'AGENT_STATE') { @@ -173,15 +180,20 @@ export function useBotsSync() { const progressText = summarizeProgressText(content, isZh); updateBotState(bot.id, state, progressText); addBotEvent(bot.id, { state, text: progressText || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); - if (isDashboardChannel && isTool) { - const chatText = - (isZh ? '调用工具:' : 'Tool: ') + (progressText || (isZh ? '处理中...' : 'processing...')); - addBotMessage(bot.id, { - role: 'assistant', - text: chatText, - ts: Date.now(), - kind: 'progress', - }); + if (isDashboardChannel) { + const fullProgress = content || progressText || (isZh ? '处理中...' : 'Processing...'); + const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress; + const now = Date.now(); + const prev = lastProgressRef.current[bot.id]; + if (!prev || prev.text !== chatText || now - prev.ts > 1200) { + addBotMessage(bot.id, { + role: 'assistant', + text: chatText, + ts: now, + kind: 'progress', + }); + lastProgressRef.current[bot.id] = { text: chatText, ts: now }; + } } return; } diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index d348b6a..54c41b9 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -375,6 +375,37 @@ color: var(--text); } +.ops-chat-text.is-collapsed { + max-height: 220px; + overflow: hidden; + position: relative; +} + +.ops-chat-text.is-collapsed::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 42px; + pointer-events: none; + background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--panel-soft) 88%, var(--panel) 12%)); +} + +.ops-chat-more-btn { + position: relative; + z-index: 1; + margin-top: 8px; + border: 1px solid color-mix(in oklab, var(--brand) 56%, var(--line) 44%); + background: color-mix(in oklab, var(--panel) 72%, var(--brand-soft) 28%); + color: var(--text); + border-radius: 999px; + font-size: 12px; + font-weight: 700; + padding: 4px 10px; + cursor: pointer; +} + .ops-chat-text > *:first-child { margin-top: 0; } @@ -1340,6 +1371,16 @@ margin: 0; } +.workspace-preview-image { + display: block; + max-width: 100%; + max-height: 70vh; + width: auto; + height: auto; + object-fit: contain; + margin: 0 auto; +} + .workspace-markdown { padding: 12px 14px; color: var(--text); diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index b17f266..93a5843 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -62,6 +62,7 @@ interface WorkspacePreviewState { truncated: boolean; ext: string; isMarkdown: boolean; + isImage: boolean; } interface WorkspaceUploadResponse { @@ -205,13 +206,57 @@ function normalizeRuntimeState(s?: string) { function isPreviewableWorkspaceFile(node: WorkspaceNode) { if (node.type !== 'file') return false; const ext = (node.ext || '').toLowerCase(); - return ['.md', '.json', '.log', '.txt', '.csv', '.pdf'].includes(ext); + return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp'].includes(ext); } function isPdfPath(path: string) { return String(path || '').trim().toLowerCase().endsWith('.pdf'); } +function isImagePath(path: string) { + const normalized = String(path || '').trim().toLowerCase(); + return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp'); +} + +function isPreviewableWorkspacePath(path: string) { + const normalized = String(path || '').trim().toLowerCase(); + return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp'].some((ext) => + normalized.endsWith(ext), + ); +} + +const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/'; + +function buildWorkspaceLink(path: string) { + return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`; +} + +function parseWorkspaceLink(href: string): string | null { + const link = String(href || '').trim(); + if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null; + const encoded = link.slice(WORKSPACE_LINK_PREFIX.length); + try { + const decoded = decodeURIComponent(encoded || '').trim(); + return decoded || null; + } catch { + return null; + } +} + +function decorateWorkspacePathsForMarkdown(text: string) { + const source = String(text || ''); + const normalizedExistingLinks = source.replace( + /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\s]+)\)/g, + '[$1]($2)', + ); + const workspacePathPattern = /\/root\/\.nanobot\/workspace\/[^\s<>"'`)\]},。!?;:、]+/g; + return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => { + const normalized = normalizeDashboardAttachmentPath(fullPath); + if (!normalized) return fullPath; + return `[${fullPath}](${buildWorkspaceLink(normalized)})`; + }); +} + function normalizeAttachmentPaths(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw @@ -293,6 +338,7 @@ export function BotDashboardModule({ setBotMessages, } = useAppStore(); const { notify, confirm } = useLucentPrompt(); + const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable'; const [selectedBotId, setSelectedBotId] = useState(''); const [command, setCommand] = useState(''); const [isSaving, setIsSaving] = useState(false); @@ -344,11 +390,109 @@ export function BotDashboardModule({ const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [compactPanelTab, setCompactPanelTab] = useState('chat'); const [isCompactMobile, setIsCompactMobile] = useState(false); + const [expandedProgressByKey, setExpandedProgressByKey] = useState>({}); const runtimeMenuRef = useRef(null); + const openWorkspacePathFromChat = (path: string) => { + const normalized = String(path || '').trim(); + if (!normalized) return; + if (!isPreviewableWorkspacePath(normalized)) { + notify(fileNotPreviewableLabel, { tone: 'warning' }); + return; + } + void openWorkspaceFilePreview(normalized); + }; + const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { + const source = String(text || ''); + if (!source) return [source]; + const pattern = + /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\]},。!?;:、]+|https:\/\/workspace\.local\/open\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let lastIndex = 0; + let matchIndex = 0; + let match = pattern.exec(source); + while (match) { + if (match.index > lastIndex) { + nodes.push(source.slice(lastIndex, match.index)); + } + + const raw = match[0]; + const markdownPath = match[1] ? String(match[1]) : ''; + const markdownHref = match[2] ? String(match[2]) : ''; + let normalizedPath = ''; + let displayText = raw; + + if (markdownPath && markdownHref) { + normalizedPath = normalizeDashboardAttachmentPath(markdownPath); + displayText = markdownPath; + } else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) { + normalizedPath = String(parseWorkspaceLink(raw) || '').trim(); + displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw; + } else if (raw.startsWith('/root/.nanobot/workspace/')) { + normalizedPath = normalizeDashboardAttachmentPath(raw); + displayText = raw; + } + + if (normalizedPath) { + nodes.push( + { + event.preventDefault(); + event.stopPropagation(); + openWorkspacePathFromChat(normalizedPath); + }} + > + {displayText} + , + ); + } else { + nodes.push(raw); + } + + lastIndex = match.index + raw.length; + matchIndex += 1; + match = pattern.exec(source); + } + + if (lastIndex < source.length) { + nodes.push(source.slice(lastIndex)); + } + return nodes; + }; + const renderWorkspaceAwareChildren = (children: ReactNode, keyPrefix: string): ReactNode => { + const list = Array.isArray(children) ? children : [children]; + const mapped = list.flatMap((child, idx) => { + if (typeof child === 'string') { + return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`); + } + return [child]; + }); + return mapped; + }; const markdownComponents = useMemo( () => ({ a: ({ href, children, ...props }: AnchorHTMLAttributes) => { const link = String(href || '').trim(); + const workspacePath = parseWorkspaceLink(link); + if (workspacePath) { + return ( + { + event.preventDefault(); + if (!isPreviewableWorkspacePath(workspacePath)) { + notify(fileNotPreviewableLabel, { tone: 'warning' }); + return; + } + void openWorkspaceFilePreview(workspacePath); + }} + {...props} + > + {children} + + ); + } if (isExternalHttpLink(link)) { return ( @@ -362,8 +506,17 @@ export function BotDashboardModule({ ); }, + p: ({ children, ...props }: { children?: ReactNode }) => ( +

{renderWorkspaceAwareChildren(children, 'md-p')}

+ ), + li: ({ children, ...props }: { children?: ReactNode }) => ( +
  • {renderWorkspaceAwareChildren(children, 'md-li')}
  • + ), + code: ({ children, ...props }: { children?: ReactNode }) => ( + {renderWorkspaceAwareChildren(children, 'md-code')} + ), }), - [], + [fileNotPreviewableLabel, notify, selectedBotId], ); const [editForm, setEditForm] = useState({ @@ -460,10 +613,23 @@ export function BotDashboardModule({ if (eventText && eventText !== t.processing) return eventText; return '-'; }, [selectedBot, latestEvent, isZh, t.processing]); + + const shouldCollapseProgress = (text: string) => { + const normalized = String(text || '').trim(); + if (!normalized) return false; + const lines = normalized.split('\n').length; + return lines > 6 || normalized.length > 520; + }; + const conversationNodes = useMemo( () => - conversation.map((item, idx) => ( -
    + conversation.map((item, idx) => { + const itemKey = `${item.ts}-${idx}`; + const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress'; + const collapsible = isProgressBubble && shouldCollapseProgress(item.text); + const expanded = Boolean(expandedProgressByKey[itemKey]); + return ( +
    {item.role !== 'user' && (
    @@ -476,16 +642,29 @@ export function BotDashboardModule({ {item.role === 'user' ? t.you : 'Nanobot'} {formatClock(item.ts)}
    -
    +
    {item.text ? ( item.role === 'user' ? (
    {normalizeUserMessageText(item.text)}
    ) : ( - {item.text} + {decorateWorkspacePathsForMarkdown(item.text)} ) ) : null} + {collapsible ? ( + + ) : null} {(item.attachments || []).length > 0 ? (
    {(item.attachments || []).map((rawPath) => { @@ -518,8 +697,8 @@ export function BotDashboardModule({ )}
    - )), - [conversation, selectedBotId, t.user, t.you], + )}), + [conversation, expandedProgressByKey, isZh, selectedBotId, t.user, t.you], ); useEffect(() => { @@ -555,6 +734,10 @@ export function BotDashboardModule({ setRuntimeMenuOpen(false); }, [selectedBotId]); + useEffect(() => { + setExpandedProgressByKey({}); + }, [selectedBotId]); + useEffect(() => { if (!compactMode) { setIsCompactMobile(false); @@ -616,6 +799,18 @@ export function BotDashboardModule({ window.open(href, '_blank', 'noopener,noreferrer'); return; } + if (isImagePath(normalizedPath)) { + const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase(); + setWorkspacePreview({ + path: normalizedPath, + content: '', + truncated: false, + ext: fileExt ? `.${fileExt}` : '', + isMarkdown: false, + isImage: true, + }); + return; + } setWorkspaceFileLoading(true); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, { @@ -637,6 +832,7 @@ export function BotDashboardModule({ truncated: Boolean(res.data.truncated), ext: textExt ? `.${textExt}` : '', isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), + isImage: false, }); } catch (error: any) { const msg = error?.response?.data?.detail || t.fileReadFail; @@ -908,6 +1104,10 @@ export function BotDashboardModule({ send_progress: Boolean(globalDelivery.sendProgress), send_tool_hints: Boolean(globalDelivery.sendToolHints), }); + if (selectedBot.docker_status === 'RUNNING') { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/stop`); + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/start`); + } await refresh(); notify(t.channelSaved, { tone: 'success' }); } catch (error: any) { @@ -2123,6 +2323,7 @@ export function BotDashboardModule({ const key = String(envDraftKey || '').trim().toUpperCase(); if (!key) return; upsertEnvParam(key, envDraftValue); + setEnvDraftKey(''); setEnvDraftValue(''); }} title={t.addEnvParam} @@ -2245,7 +2446,13 @@ export function BotDashboardModule({ {workspacePreview.path}
    - {workspacePreview.isMarkdown ? ( + {workspacePreview.isImage ? ( + {workspacePreview.path.split('/').pop() + ) : workspacePreview.isMarkdown ? (
    {workspacePreview.content} diff --git a/frontend/src/modules/onboarding/BotWizardModule.tsx b/frontend/src/modules/onboarding/BotWizardModule.tsx index 40115f9..13e4bdb 100644 --- a/frontend/src/modules/onboarding/BotWizardModule.tsx +++ b/frontend/src/modules/onboarding/BotWizardModule.tsx @@ -823,6 +823,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro const key = String(envDraftKey || '').trim().toUpperCase(); if (!key) return; upsertEnvParam(key, envDraftValue); + setEnvDraftKey(''); setEnvDraftValue(''); }} title={ui.addEnvParam}