main
mula.liu 2026-03-02 03:44:06 +08:00
parent 93e9547a7f
commit 3cb25ba795
8 changed files with 590 additions and 159 deletions

View File

@ -1,4 +1,3 @@
import json
import os import os
from typing import Any, Dict, List from typing import Any, Dict, List
@ -50,41 +49,6 @@ class BotConfigManager:
"sendToolHints": bool(bot_data.get("send_tool_hints", False)), "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] = { config_data: Dict[str, Any] = {
"agents": { "agents": {
"defaults": { "defaults": {
@ -98,7 +62,6 @@ class BotConfigManager:
provider_name: provider_cfg, provider_name: provider_cfg,
}, },
"channels": channels_cfg, "channels": channels_cfg,
"tools": tools_cfg,
} }
for channel in channels: for channel in channels:

View File

@ -10,23 +10,12 @@ engine = create_engine(DATABASE_URL, echo=DATABASE_ECHO)
def _ensure_botinstance_columns() -> None: def _ensure_botinstance_columns() -> None:
if engine.dialect.name != "sqlite":
return
required_columns = { required_columns = {
"current_state": "TEXT DEFAULT 'IDLE'", "current_state": "TEXT DEFAULT 'IDLE'",
"last_action": "TEXT", "last_action": "TEXT",
"soul_md": "TEXT", "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'",
"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",
} }
with engine.connect() as conn: with engine.connect() as conn:
existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall()
@ -38,7 +27,45 @@ def _ensure_botinstance_columns() -> None:
conn.commit() 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: def _ensure_botmessage_columns() -> None:
if engine.dialect.name != "sqlite":
return
required_columns = { required_columns = {
"media_json": "TEXT", "media_json": "TEXT",
} }
@ -60,18 +87,11 @@ def _drop_legacy_skill_tables() -> None:
conn.commit() 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: def init_database() -> None:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_drop_legacy_skill_tables() _drop_legacy_skill_tables()
_drop_legacy_channel_tables()
_ensure_botinstance_columns() _ensure_botinstance_columns()
_drop_legacy_botinstance_columns()
_ensure_botmessage_columns() _ensure_botmessage_columns()

View File

@ -128,6 +128,13 @@ class CommandRequest(BaseModel):
attachments: Optional[List[str]] = None 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: def _normalize_media_item(bot_id: str, value: Any) -> str:
raw = str(value or "").strip().replace("\\", "/") raw = str(value or "").strip().replace("\\", "/")
if not raw: 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() packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
return return
source_channel = str(packet.get("channel") or "").strip().lower() source_channel = _normalize_packet_channel(packet)
if source_channel != "dashboard": if source_channel != "dashboard":
return return
with Session(engine) as session: with Session(engine) as session:
@ -341,6 +348,20 @@ def _normalize_channel_extra(raw: Any) -> Dict[str, Any]:
return raw 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]: def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(ctype or "").strip().lower() ctype = str(ctype or "").strip().lower()
enabled = bool(cfg.get("enabled", True)) 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): if not isinstance(channels_cfg, dict):
channels_cfg = {} channels_cfg = {}
send_progress = bool(channels_cfg.get("sendProgress", bot.send_progress)) send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg)
send_tool_hints = bool(channels_cfg.get("sendToolHints", bot.send_tool_hints))
rows: List[Dict[str, Any]] = [ rows: List[Dict[str, Any]] = [
{ {
"id": "dashboard", "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(): for ctype, cfg in channels_cfg.items():
if ctype in {"sendProgress", "sendToolHints"}: if ctype in {"sendProgress", "sendToolHints", "dashboard"}:
continue continue
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
continue continue
@ -595,23 +615,161 @@ def _parse_env_params(raw: Any) -> Dict[str, str]:
return _normalize_env_params(raw) 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( def _sync_workspace_channels(
session: Session, session: Session,
bot_id: str, bot_id: str,
channels_override: Optional[List[Dict[str, Any]]] = None, channels_override: Optional[List[Dict[str, Any]]] = None,
global_delivery_override: Optional[Dict[str, Any]] = None,
runtime_overrides: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
return 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) channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot)
bot_data = bot.model_dump() bot_data["send_progress"] = send_progress
for row in channels_data: bot_data["send_tool_hints"] = send_tool_hints
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
normalized_channels: List[Dict[str, Any]] = [] normalized_channels: List[Dict[str, Any]] = []
for row in channels_data: for row in channels_data:
ctype = str(row.get("channel_type") or "").strip().lower() 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)): def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)):
image_row = session.get(NanobotImage, payload.image_tag) image_row = session.get(NanobotImage, payload.image_tag)
if not image_row: if not image_row:
@ -992,22 +1150,6 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
bot = BotInstance( bot = BotInstance(
id=payload.id, id=payload.id,
name=payload.name, 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, image_tag=payload.image_tag,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), 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.commit()
session.refresh(bot) session.refresh(bot)
_write_env_store(payload.id, _normalize_env_params(payload.env_params)) _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)) _sync_workspace_channels(
return bot 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)): 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)): def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id) bot = session.get(BotInstance, bot_id)
if not bot: 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): if not docker_manager.has_image(image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {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 tools_config = update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
if tools_config is not None: if tools_config is not None:
bot.tools_config_json = json.dumps(_normalize_tools_config(tools_config), ensure_ascii=False) runtime_overrides["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_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(): for key, value in update_data.items():
setattr(bot, key, value) if key in db_fields:
setattr(bot, key, value)
session.add(bot) session.add(bot)
session.commit() session.commit()
session.refresh(bot) session.refresh(bot)
if env_params is not None: if env_params is not None:
_write_env_store(bot_id, _normalize_env_params(env_params)) _write_env_store(bot_id, _normalize_env_params(env_params))
_sync_workspace_channels(session, bot_id) global_delivery_override: Optional[Dict[str, Any]] = None
return bot 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") @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") raise HTTPException(status_code=404, detail="Bot not found")
return { return {
"bot_id": bot_id, "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) bot = session.get(BotInstance, bot_id)
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") raise HTTPException(status_code=404, detail="Bot not found")
raise HTTPException(
normalized = _normalize_tools_config(payload.tools_config) status_code=400,
bot.tools_config_json = json.dumps(normalized, ensure_ascii=False) detail="Tools config is no longer managed by dashboard. Please set required env vars manually.",
session.add(bot) )
session.commit()
session.refresh(bot)
_sync_workspace_channels(session, bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"tools_config": normalized,
}
@app.get("/api/bots/{bot_id}/env-params") @app.get("/api/bots/{bot_id}/env-params")
@ -1391,19 +1597,20 @@ def update_bot_channel(
row["id"] = new_type row["id"] = new_type
row["locked"] = new_type == "dashboard" 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) config_data = _read_bot_config(bot_id)
channels_cfg = config_data.get("channels") channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict): if not isinstance(channels_cfg, dict):
channels_cfg = {} channels_cfg = {}
config_data["channels"] = channels_cfg config_data["channels"] = channels_cfg
channels_cfg["sendProgress"] = bool(bot.send_progress) current_send_progress, current_send_tool_hints = _read_global_delivery_flags(channels_cfg)
channels_cfg["sendToolHints"] = bool(bot.send_tool_hints) 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: if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
channels_cfg.pop(existing_type, None) channels_cfg.pop(existing_type, None)
if new_type != "dashboard": if new_type != "dashboard":

View File

@ -5,26 +5,6 @@ from datetime import datetime
class BotInstance(SQLModel, table=True): class BotInstance(SQLModel, table=True):
id: str = Field(primary_key=True) id: str = Field(primary_key=True)
name: str 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) workspace_dir: str = Field(unique=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")

View File

@ -23,12 +23,19 @@ function normalizeMedia(raw: unknown): string[] {
return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0); 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() { export function useBotsSync() {
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore(); const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
const socketsRef = useRef<Record<string, WebSocket>>({}); const socketsRef = useRef<Record<string, WebSocket>>({});
const heartbeatsRef = useRef<Record<string, number>>({}); const heartbeatsRef = useRef<Record<string, number>>({});
const lastUserEchoRef = useRef<Record<string, { text: string; ts: number }>>({}); const lastUserEchoRef = useRef<Record<string, { text: string; ts: number }>>({});
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({}); const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
const lastProgressRef = useRef<Record<string, { text: string; ts: number }>>({});
const hydratedMessagesRef = useRef<Record<string, boolean>>({}); const hydratedMessagesRef = useRef<Record<string, boolean>>({});
const isZh = useAppStore((s) => s.locale === 'zh'); const isZh = useAppStore((s) => s.locale === 'zh');
const locale = useAppStore((s) => s.locale); const locale = useAppStore((s) => s.locale);
@ -134,7 +141,7 @@ export function useBotsSync() {
} catch { } catch {
return; return;
} }
const sourceChannel = String(data?.channel || '').trim().toLowerCase(); const sourceChannel = normalizeChannelName(data?.channel || data?.source);
const isDashboardChannel = sourceChannel === 'dashboard'; const isDashboardChannel = sourceChannel === 'dashboard';
const payload = data?.payload && typeof data.payload === 'object' ? data.payload : {}; const payload = data?.payload && typeof data.payload === 'object' ? data.payload : {};
if (data.type === 'AGENT_STATE') { if (data.type === 'AGENT_STATE') {
@ -173,15 +180,20 @@ export function useBotsSync() {
const progressText = summarizeProgressText(content, isZh); const progressText = summarizeProgressText(content, isZh);
updateBotState(bot.id, state, progressText); updateBotState(bot.id, state, progressText);
addBotEvent(bot.id, { state, text: progressText || t.progress, ts: Date.now(), channel: sourceChannel || undefined }); addBotEvent(bot.id, { state, text: progressText || t.progress, ts: Date.now(), channel: sourceChannel || undefined });
if (isDashboardChannel && isTool) { if (isDashboardChannel) {
const chatText = const fullProgress = content || progressText || (isZh ? '处理中...' : 'Processing...');
(isZh ? '调用工具:' : 'Tool: ') + (progressText || (isZh ? '处理中...' : 'processing...')); const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress;
addBotMessage(bot.id, { const now = Date.now();
role: 'assistant', const prev = lastProgressRef.current[bot.id];
text: chatText, if (!prev || prev.text !== chatText || now - prev.ts > 1200) {
ts: Date.now(), addBotMessage(bot.id, {
kind: 'progress', role: 'assistant',
}); text: chatText,
ts: now,
kind: 'progress',
});
lastProgressRef.current[bot.id] = { text: chatText, ts: now };
}
} }
return; return;
} }

View File

@ -375,6 +375,37 @@
color: var(--text); 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 { .ops-chat-text > *:first-child {
margin-top: 0; margin-top: 0;
} }
@ -1340,6 +1371,16 @@
margin: 0; 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 { .workspace-markdown {
padding: 12px 14px; padding: 12px 14px;
color: var(--text); color: var(--text);

View File

@ -62,6 +62,7 @@ interface WorkspacePreviewState {
truncated: boolean; truncated: boolean;
ext: string; ext: string;
isMarkdown: boolean; isMarkdown: boolean;
isImage: boolean;
} }
interface WorkspaceUploadResponse { interface WorkspaceUploadResponse {
@ -205,13 +206,57 @@ function normalizeRuntimeState(s?: string) {
function isPreviewableWorkspaceFile(node: WorkspaceNode) { function isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false; if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase(); 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) { function isPdfPath(path: string) {
return String(path || '').trim().toLowerCase().endsWith('.pdf'); 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[] { function normalizeAttachmentPaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw return raw
@ -293,6 +338,7 @@ export function BotDashboardModule({
setBotMessages, setBotMessages,
} = useAppStore(); } = useAppStore();
const { notify, confirm } = useLucentPrompt(); const { notify, confirm } = useLucentPrompt();
const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
const [selectedBotId, setSelectedBotId] = useState(''); const [selectedBotId, setSelectedBotId] = useState('');
const [command, setCommand] = useState(''); const [command, setCommand] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -344,11 +390,109 @@ export function BotDashboardModule({
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat'); const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
const [isCompactMobile, setIsCompactMobile] = useState(false); const [isCompactMobile, setIsCompactMobile] = useState(false);
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
const runtimeMenuRef = useRef<HTMLDivElement | null>(null); const runtimeMenuRef = useRef<HTMLDivElement | null>(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(
<a
key={`${keyPrefix}-ws-${matchIndex}`}
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openWorkspacePathFromChat(normalizedPath);
}}
>
{displayText}
</a>,
);
} 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( const markdownComponents = useMemo(
() => ({ () => ({
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const link = String(href || '').trim(); const link = String(href || '').trim();
const workspacePath = parseWorkspaceLink(link);
if (workspacePath) {
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
if (!isPreviewableWorkspacePath(workspacePath)) {
notify(fileNotPreviewableLabel, { tone: 'warning' });
return;
}
void openWorkspaceFilePreview(workspacePath);
}}
{...props}
>
{children}
</a>
);
}
if (isExternalHttpLink(link)) { if (isExternalHttpLink(link)) {
return ( return (
<a href={link} target="_blank" rel="noopener noreferrer" {...props}> <a href={link} target="_blank" rel="noopener noreferrer" {...props}>
@ -362,8 +506,17 @@ export function BotDashboardModule({
</a> </a>
); );
}, },
p: ({ children, ...props }: { children?: ReactNode }) => (
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
),
li: ({ children, ...props }: { children?: ReactNode }) => (
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
),
code: ({ children, ...props }: { children?: ReactNode }) => (
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
),
}), }),
[], [fileNotPreviewableLabel, notify, selectedBotId],
); );
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
@ -460,10 +613,23 @@ export function BotDashboardModule({
if (eventText && eventText !== t.processing) return eventText; if (eventText && eventText !== t.processing) return eventText;
return '-'; return '-';
}, [selectedBot, latestEvent, isZh, t.processing]); }, [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( const conversationNodes = useMemo(
() => () =>
conversation.map((item, idx) => ( conversation.map((item, idx) => {
<div key={`${item.ts}-${idx}`} className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}> 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 (
<div key={itemKey} className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}> <div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
{item.role !== 'user' && ( {item.role !== 'user' && (
<div className="ops-avatar bot" title="Nanobot"> <div className="ops-avatar bot" title="Nanobot">
@ -476,16 +642,29 @@ export function BotDashboardModule({
<span>{item.role === 'user' ? t.you : 'Nanobot'}</span> <span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
<span className="mono">{formatClock(item.ts)}</span> <span className="mono">{formatClock(item.ts)}</span>
</div> </div>
<div className="ops-chat-text"> <div className={`ops-chat-text ${collapsible && !expanded ? 'is-collapsed' : ''}`}>
{item.text ? ( {item.text ? (
item.role === 'user' ? ( item.role === 'user' ? (
<div className="whitespace-pre-wrap">{normalizeUserMessageText(item.text)}</div> <div className="whitespace-pre-wrap">{normalizeUserMessageText(item.text)}</div>
) : ( ) : (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{item.text} {decorateWorkspacePathsForMarkdown(item.text)}
</ReactMarkdown> </ReactMarkdown>
) )
) : null} ) : null}
{collapsible ? (
<button
className="ops-chat-more-btn"
onClick={() =>
setExpandedProgressByKey((prev) => ({
...prev,
[itemKey]: !prev[itemKey],
}))
}
>
{expanded ? (isZh ? '收起' : 'Less') : (isZh ? '更多' : 'More')}
</button>
) : null}
{(item.attachments || []).length > 0 ? ( {(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments"> <div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => { {(item.attachments || []).map((rawPath) => {
@ -518,8 +697,8 @@ export function BotDashboardModule({
)} )}
</div> </div>
</div> </div>
)), )}),
[conversation, selectedBotId, t.user, t.you], [conversation, expandedProgressByKey, isZh, selectedBotId, t.user, t.you],
); );
useEffect(() => { useEffect(() => {
@ -555,6 +734,10 @@ export function BotDashboardModule({
setRuntimeMenuOpen(false); setRuntimeMenuOpen(false);
}, [selectedBotId]); }, [selectedBotId]);
useEffect(() => {
setExpandedProgressByKey({});
}, [selectedBotId]);
useEffect(() => { useEffect(() => {
if (!compactMode) { if (!compactMode) {
setIsCompactMobile(false); setIsCompactMobile(false);
@ -616,6 +799,18 @@ export function BotDashboardModule({
window.open(href, '_blank', 'noopener,noreferrer'); window.open(href, '_blank', 'noopener,noreferrer');
return; 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); setWorkspaceFileLoading(true);
try { try {
const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, { const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
@ -637,6 +832,7 @@ export function BotDashboardModule({
truncated: Boolean(res.data.truncated), truncated: Boolean(res.data.truncated),
ext: textExt ? `.${textExt}` : '', ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
isImage: false,
}); });
} catch (error: any) { } catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail; const msg = error?.response?.data?.detail || t.fileReadFail;
@ -908,6 +1104,10 @@ export function BotDashboardModule({
send_progress: Boolean(globalDelivery.sendProgress), send_progress: Boolean(globalDelivery.sendProgress),
send_tool_hints: Boolean(globalDelivery.sendToolHints), 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(); await refresh();
notify(t.channelSaved, { tone: 'success' }); notify(t.channelSaved, { tone: 'success' });
} catch (error: any) { } catch (error: any) {
@ -2123,6 +2323,7 @@ export function BotDashboardModule({
const key = String(envDraftKey || '').trim().toUpperCase(); const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return; if (!key) return;
upsertEnvParam(key, envDraftValue); upsertEnvParam(key, envDraftValue);
setEnvDraftKey('');
setEnvDraftValue(''); setEnvDraftValue('');
}} }}
title={t.addEnvParam} title={t.addEnvParam}
@ -2245,7 +2446,13 @@ export function BotDashboardModule({
<span className="modal-sub mono">{workspacePreview.path}</span> <span className="modal-sub mono">{workspacePreview.path}</span>
</div> </div>
<div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''}`}> <div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''}`}>
{workspacePreview.isMarkdown ? ( {workspacePreview.isImage ? (
<img
className="workspace-preview-image"
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/>
) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown"> <div className="workspace-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{workspacePreview.content} {workspacePreview.content}

View File

@ -823,6 +823,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const key = String(envDraftKey || '').trim().toUpperCase(); const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return; if (!key) return;
upsertEnvParam(key, envDraftValue); upsertEnvParam(key, envDraftValue);
setEnvDraftKey('');
setEnvDraftValue(''); setEnvDraftValue('');
}} }}
title={ui.addEnvParam} title={ui.addEnvParam}