From a58d9fd357c778930869f50d2ca3e6dad95773c2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 25 Apr 2026 15:46:47 +0000 Subject: [PATCH] feat(webui): render ask_user choices Made-with: Cursor --- nanobot/agent/tools/ask.py | 4 +- nanobot/channels/websocket.py | 16 ++- tests/agent/test_ask_user.py | 34 ++++++ tests/channels/test_websocket_channel.py | 5 +- webui/src/components/thread/AskUserPrompt.tsx | 108 ++++++++++++++++++ webui/src/components/thread/ThreadShell.tsx | 23 ++++ webui/src/hooks/useNanobotStream.ts | 4 +- webui/src/lib/types.ts | 5 + webui/src/tests/thread-shell.test.tsx | 58 +++++++++- webui/src/tests/useNanobotStream.test.tsx | 23 ++++ 10 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 webui/src/components/thread/AskUserPrompt.tsx diff --git a/nanobot/agent/tools/ask.py b/nanobot/agent/tools/ask.py index c2aa8e0e8..db8c83a84 100644 --- a/nanobot/agent/tools/ask.py +++ b/nanobot/agent/tools/ask.py @@ -6,7 +6,7 @@ from typing import Any from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema -BUTTON_CHANNELS = frozenset({"telegram"}) +STRUCTURED_BUTTON_CHANNELS = frozenset({"telegram", "websocket"}) class AskUserInterrupt(BaseException): @@ -130,7 +130,7 @@ def ask_user_outbound( ) -> tuple[str | None, list[list[str]]]: if not options: return content, [] - if channel in BUTTON_CHANNELS: + if channel in STRUCTURED_BUTTON_CHANNELS: return content, [options] option_text = "\n".join(f"{index}. {option}" for index, option in enumerate(options, 1)) return f"{content}\n\n{option_text}" if content else option_text, [] diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index c76371e98..ff923d810 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -54,6 +54,14 @@ def _normalize_config_path(path: str) -> str: return _strip_trailing_slash(path) +def _append_buttons_as_text(text: str, buttons: list[list[str]]) -> str: + labels = [label for row in buttons for label in row if label] + if not labels: + return text + fallback = "\n".join(f"{index}. {label}" for index, label in enumerate(labels, 1)) + return f"{text}\n\n{fallback}" if text else fallback + + class WebSocketConfig(Base): """WebSocket server channel configuration. @@ -1146,11 +1154,17 @@ class WebSocketChannel(BaseChannel): if not conns: logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) return + text = msg.content + if msg.buttons: + text = _append_buttons_as_text(text, msg.buttons) payload: dict[str, Any] = { "event": "message", "chat_id": msg.chat_id, - "text": msg.content, + "text": text, } + if msg.buttons: + payload["buttons"] = msg.buttons + payload["button_prompt"] = msg.content if msg.media: payload["media"] = msg.media urls: list[dict[str, str]] = [] diff --git a/tests/agent/test_ask_user.py b/tests/agent/test_ask_user.py index 4d5b5be93..a192ee4a6 100644 --- a/tests/agent/test_ask_user.py +++ b/tests/agent/test_ask_user.py @@ -205,3 +205,37 @@ async def test_ask_user_keeps_buttons_for_telegram(tmp_path): assert response is not None assert response.content == "Install the optional package?" assert response.buttons == [["Install", "Skip"]] + + +@pytest.mark.asyncio +async def test_ask_user_keeps_buttons_for_websocket(tmp_path): + async def chat_with_retry(**kwargs): + return LLMResponse( + content="", + finish_reason="tool_calls", + tool_calls=[ + ToolCallRequest( + id="call_ask", + name="ask_user", + arguments={ + "question": "Install the optional package?", + "options": ["Install", "Skip"], + }, + ) + ], + ) + + loop = AgentLoop( + bus=MessageBus(), + provider=_make_provider(chat_with_retry), + workspace=tmp_path, + model="test-model", + ) + + response = await loop._process_message( + InboundMessage(channel="websocket", sender_id="user", chat_id="123", content="set it up") + ) + + assert response is not None + assert response.content == "Install the optional package?" + assert response.buttons == [["Install", "Skip"]] diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index c92c88ba8..a1d459b94 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -178,6 +178,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: content="hello", reply_to="m1", media=["/tmp/a.png"], + buttons=[["Yes", "No"]], ) await channel.send(msg) @@ -185,9 +186,11 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: payload = json.loads(mock_ws.send.call_args[0][0]) assert payload["event"] == "message" assert payload["chat_id"] == "chat-1" - assert payload["text"] == "hello" + assert payload["text"] == "hello\n\n1. Yes\n2. No" + assert payload["button_prompt"] == "hello" assert payload["reply_to"] == "m1" assert payload["media"] == ["/tmp/a.png"] + assert payload["buttons"] == [["Yes", "No"]] @pytest.mark.asyncio diff --git a/webui/src/components/thread/AskUserPrompt.tsx b/webui/src/components/thread/AskUserPrompt.tsx new file mode 100644 index 000000000..3ab20f5e8 --- /dev/null +++ b/webui/src/components/thread/AskUserPrompt.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MessageSquareText } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface AskUserPromptProps { + question: string; + buttons: string[][]; + onAnswer: (answer: string) => void; +} + +export function AskUserPrompt({ + question, + buttons, + onAnswer, +}: AskUserPromptProps) { + const [customOpen, setCustomOpen] = useState(false); + const [custom, setCustom] = useState(""); + const inputRef = useRef(null); + const options = buttons.flat().filter(Boolean); + + useEffect(() => { + if (customOpen) { + inputRef.current?.focus(); + } + }, [customOpen]); + + const submitCustom = useCallback(() => { + const answer = custom.trim(); + if (!answer) return; + onAnswer(answer); + setCustom(""); + setCustomOpen(false); + }, [custom, onAnswer]); + + if (options.length === 0) return null; + + return ( +
+
+
+ +
+

+ {question} +

+
+ +
+ {options.map((option) => ( + + ))} + +
+ + {customOpen ? ( +
+