| 1 | """Ask-user tool: send structured question cards to the WebUI.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import uuid |
| 6 | from contextvars import ContextVar |
| 7 | from typing import Any, Awaitable, Callable |
| 8 | |
| 9 | from nanobot.agent.tools.base import Tool, tool_parameters |
| 10 | from nanobot.agent.tools.schema import ( |
| 11 | ArraySchema, |
| 12 | BooleanSchema, |
| 13 | ObjectSchema, |
| 14 | StringSchema, |
| 15 | tool_parameters_schema, |
| 16 | ) |
| 17 | from nanobot.bus.events import OutboundMessage |
| 18 | |
| 19 | REFERENCE_IMAGE_EDIT_OPTION = "我想修改/增删参考图" |
| 20 | STORY_CONFIRM_OPTION = "可以,按这个来" |
| 21 | STORY_REVISE_OPTION = "需要修改" |
| 22 | _REFERENCE_IMAGE_EDIT_ALIASES = frozenset( |
| 23 | { |
| 24 | REFERENCE_IMAGE_EDIT_OPTION, |
| 25 | "我要修改/增删参考图", |
| 26 | } |
| 27 | ) |
| 28 | _STORY_DIRECTION_CARD_IDS = frozenset({"confirm_story", "story_direction"}) |
| 29 | _STORY_DIRECTION_OPTION_MARKERS = frozenset({STORY_CONFIRM_OPTION, STORY_REVISE_OPTION}) |
| 30 | _STORY_DIRECTION_QUESTION_MARKERS = ( |
| 31 | "故事方向", |
| 32 | "什么样的故事", |
| 33 | "什么故事", |
| 34 | "想拍什么", |
| 35 | "故事题材", |
| 36 | ) |
| 37 | _NOT_STORY_DIRECTION_QUESTION_MARKERS = ( |
| 38 | "上传首帧", |
| 39 | "上传参考图", |
| 40 | "还需上传", |
| 41 | "确认使用此参考图", |
| 42 | "完成参考图修改", |
| 43 | "几个镜头", |
| 44 | "多少镜头", |
| 45 | ) |
| 46 | _UPLOAD_GATE_OPTIONS = frozenset({"需要上传,已上传完毕", "不上传"}) |
| 47 | |
| 48 | _QUESTION_SCHEMA = ObjectSchema( |
| 49 | { |
| 50 | "id": StringSchema("Stable id for this card (auto-generated if omitted)"), |
| 51 | "question": StringSchema("Question prompt shown above the option chips"), |
| 52 | "options": ArraySchema( |
| 53 | StringSchema("Option label"), |
| 54 | description="Tap choices for the user", |
| 55 | min_items=1, |
| 56 | ), |
| 57 | "allow_custom": BooleanSchema( |
| 58 | description="Whether the user may type a custom answer", |
| 59 | default=False, |
| 60 | ), |
| 61 | }, |
| 62 | required=["question", "options"], |
| 63 | ) |
| 64 | |
| 65 | PersistCallback = Callable[ |
| 66 | [str, str, str, str, list[dict[str, Any]], str, str], |
| 67 | Awaitable[None], |
| 68 | ] |
| 69 | |
| 70 | |
| 71 | def is_story_confirm_option(label: str) -> bool: |
| 72 | return (label or "").strip() == STORY_CONFIRM_OPTION |
| 73 | |
| 74 | |
| 75 | def is_reference_image_edit_option(label: str) -> bool: |
| 76 | return (label or "").strip() in _REFERENCE_IMAGE_EDIT_ALIASES |
| 77 | |
| 78 | |
| 79 | def _option_labels(options: list[Any]) -> list[str]: |
| 80 | labels: list[str] = [] |
| 81 | for option in options: |
| 82 | if isinstance(option, dict): |
| 83 | labels.append(str(option.get("label") or "").strip()) |
| 84 | else: |
| 85 | labels.append(str(option).strip()) |
| 86 | return [label for label in labels if label] |
| 87 | |
| 88 | |
| 89 | def is_story_direction_card( |
| 90 | *, |
| 91 | card_id: str, |
| 92 | question: str, |
| 93 | option_labels: list[str], |
| 94 | ) -> bool: |
| 95 | """True for confirm-story / story-premise cards that must offer reference-image edit.""" |
| 96 | if any(marker in question for marker in _NOT_STORY_DIRECTION_QUESTION_MARKERS): |
| 97 | return False |
| 98 | if _UPLOAD_GATE_OPTIONS.intersection(option_labels): |
| 99 | return False |
| 100 | if card_id in _STORY_DIRECTION_CARD_IDS: |
| 101 | return True |
| 102 | if any(marker in question for marker in _STORY_DIRECTION_QUESTION_MARKERS): |
| 103 | return True |
| 104 | return bool(_STORY_DIRECTION_OPTION_MARKERS.intersection(option_labels)) |
| 105 | |
| 106 | |
| 107 | def ensure_reference_image_edit_option(card: dict[str, Any]) -> dict[str, Any]: |
| 108 | """Append the canonical edit-reference option when a story-direction card omitted it.""" |
| 109 | options = card.get("options") |
| 110 | if not isinstance(options, list): |
| 111 | return card |
| 112 | labels = _option_labels(options) |
| 113 | if any(label in _REFERENCE_IMAGE_EDIT_ALIASES for label in labels): |
| 114 | return card |
| 115 | if not is_story_direction_card( |
| 116 | card_id=str(card.get("id") or ""), |
| 117 | question=str(card.get("question") or ""), |
| 118 | option_labels=labels, |
| 119 | ): |
| 120 | return card |
| 121 | card["options"] = [*options, {"label": REFERENCE_IMAGE_EDIT_OPTION}] |
| 122 | return card |
| 123 | |
| 124 | |
| 125 | def normalize_question_cards(raw_questions: list[Any]) -> list[dict[str, Any]] | str: |
| 126 | """Normalize and validate question payloads for WebUI + session storage.""" |
| 127 | if not isinstance(raw_questions, list) or not raw_questions: |
| 128 | return "Error: questions must be a non-empty list" |
| 129 | |
| 130 | normalized: list[dict[str, Any]] = [] |
| 131 | for index, item in enumerate(raw_questions): |
| 132 | if not isinstance(item, dict): |
| 133 | return f"Error: questions[{index}] must be an object" |
| 134 | |
| 135 | question = item.get("question") |
| 136 | if not isinstance(question, str) or not question.strip(): |
| 137 | return f"Error: questions[{index}].question must be a non-empty string" |
| 138 | |
| 139 | options_raw = item.get("options") |
| 140 | if not isinstance(options_raw, list) or not options_raw: |
| 141 | return f"Error: questions[{index}].options must be a non-empty list" |
| 142 | |
| 143 | options: list[dict[str, str]] = [] |
| 144 | for opt_index, opt in enumerate(options_raw): |
| 145 | if isinstance(opt, str) and opt.strip(): |
| 146 | options.append({"label": opt.strip()}) |
| 147 | continue |
| 148 | if isinstance(opt, dict) and isinstance(opt.get("label"), str) and opt["label"].strip(): |
| 149 | options.append({"label": opt["label"].strip()}) |
| 150 | continue |
| 151 | return f"Error: questions[{index}].options[{opt_index}] must be a string or {{label}}" |
| 152 | |
| 153 | card_id = item.get("id") |
| 154 | if not isinstance(card_id, str) or not card_id.strip(): |
| 155 | card_id = f"q-{index}" |
| 156 | |
| 157 | allow_custom = item.get("allow_custom", item.get("allowCustom", False)) is True |
| 158 | |
| 159 | normalized.append( |
| 160 | ensure_reference_image_edit_option( |
| 161 | { |
| 162 | "id": card_id.strip(), |
| 163 | "question": question.strip(), |
| 164 | "options": options, |
| 165 | "allow_custom": allow_custom, |
| 166 | "status": "pending", |
| 167 | "answered": None, |
| 168 | } |
| 169 | ) |
| 170 | ) |
| 171 | |
| 172 | return normalized |
| 173 | |
| 174 | |
| 175 | @tool_parameters( |
| 176 | tool_parameters_schema( |
| 177 | content=StringSchema( |
| 178 | "Intro text shown above the cards. Do not repeat option labels here." |
| 179 | ), |
| 180 | questions=ArraySchema( |
| 181 | _QUESTION_SCHEMA, |
| 182 | description="Structured question cards for the user to tap", |
| 183 | min_items=1, |
| 184 | ), |
| 185 | required=["content", "questions"], |
| 186 | ) |
| 187 | ) |
| 188 | class AskUserTool(Tool): |
| 189 | """Present structured multiple-choice cards in the WebUI.""" |
| 190 | |
| 191 | def __init__( |
| 192 | self, |
| 193 | send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, |
| 194 | persist_callback: PersistCallback | None = None, |
| 195 | default_channel: str = "", |
| 196 | default_chat_id: str = "", |
| 197 | default_session_key: str = "", |
| 198 | ): |
| 199 | self._send_callback = send_callback |
| 200 | self._persist_callback = persist_callback |
| 201 | self._default_channel: ContextVar[str] = ContextVar( |
| 202 | "ask_user_default_channel", default=default_channel |
| 203 | ) |
| 204 | self._default_chat_id: ContextVar[str] = ContextVar( |
| 205 | "ask_user_default_chat_id", default=default_chat_id |
| 206 | ) |
| 207 | self._default_session_key: ContextVar[str] = ContextVar( |
| 208 | "ask_user_default_session_key", default=default_session_key |
| 209 | ) |
| 210 | self._tool_call_id: ContextVar[str] = ContextVar("ask_user_tool_call_id", default="") |
| 211 | self._sent_in_turn_var: ContextVar[bool] = ContextVar( |
| 212 | "ask_user_sent_in_turn", default=False |
| 213 | ) |
| 214 | |
| 215 | def set_context( |
| 216 | self, |
| 217 | channel: str, |
| 218 | chat_id: str, |
| 219 | *, |
| 220 | session_key: str | None = None, |
| 221 | ) -> None: |
| 222 | self._default_channel.set(channel) |
| 223 | self._default_chat_id.set(chat_id) |
| 224 | if session_key: |
| 225 | self._default_session_key.set(session_key) |
| 226 | |
| 227 | def set_tool_call_id(self, tool_call_id: str) -> None: |
| 228 | self._tool_call_id.set(tool_call_id.strip()) |
| 229 | |
| 230 | def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: |
| 231 | self._send_callback = callback |
| 232 | |
| 233 | def set_persist_callback(self, callback: PersistCallback) -> None: |
| 234 | self._persist_callback = callback |
| 235 | |
| 236 | def start_turn(self) -> None: |
| 237 | self._sent_in_turn = False |
| 238 | |
| 239 | @property |
| 240 | def _sent_in_turn(self) -> bool: |
| 241 | return self._sent_in_turn_var.get() |
| 242 | |
| 243 | @_sent_in_turn.setter |
| 244 | def _sent_in_turn(self, value: bool) -> None: |
| 245 | self._sent_in_turn_var.set(value) |
| 246 | |
| 247 | @property |
| 248 | def name(self) -> str: |
| 249 | return "ask_user" |
| 250 | |
| 251 | @property |
| 252 | def description(self) -> str: |
| 253 | return ( |
| 254 | "Ask the user one or more multiple-choice questions using interactive " |
| 255 | "cards in the WebUI. Use this instead of listing options as plain text. " |
| 256 | "Put the intro in `content` and each question in `questions` with " |
| 257 | "`options` labels. The user's tap is persisted and survives page refresh." |
| 258 | ) |
| 259 | |
| 260 | async def execute( |
| 261 | self, |
| 262 | content: str, |
| 263 | questions: list[Any] | None = None, |
| 264 | channel: str | None = None, |
| 265 | chat_id: str | None = None, |
| 266 | **kwargs: Any, |
| 267 | ) -> str: |
| 268 | from nanobot.utils.helpers import strip_think |
| 269 | |
| 270 | content = strip_think(content or "") |
| 271 | if not content.strip(): |
| 272 | return "Error: content must be a non-empty string" |
| 273 | |
| 274 | normalized = normalize_question_cards(questions or []) |
| 275 | if isinstance(normalized, str): |
| 276 | return normalized |
| 277 | |
| 278 | channel = channel or self._default_channel.get() |
| 279 | chat_id = chat_id or self._default_chat_id.get() |
| 280 | session_key = self._default_session_key.get() |
| 281 | |
| 282 | if not channel or not chat_id: |
| 283 | return "Error: No target channel/chat specified" |
| 284 | if not self._send_callback: |
| 285 | return "Error: ask_user delivery not configured" |
| 286 | |
| 287 | batch_id = str(uuid.uuid4()) |
| 288 | metadata: dict[str, Any] = { |
| 289 | "questions": normalized, |
| 290 | "question_batch_id": batch_id, |
| 291 | } |
| 292 | |
| 293 | try: |
| 294 | if self._persist_callback and session_key: |
| 295 | tool_call_id = self._tool_call_id.get().strip() |
| 296 | if not tool_call_id: |
| 297 | tool_call_id = f"call_{batch_id}" |
| 298 | await self._persist_callback( |
| 299 | session_key, |
| 300 | tool_call_id, |
| 301 | batch_id, |
| 302 | content.strip(), |
| 303 | normalized, |
| 304 | channel, |
| 305 | chat_id, |
| 306 | ) |
| 307 | await self._send_callback( |
| 308 | OutboundMessage( |
| 309 | channel=channel, |
| 310 | chat_id=chat_id, |
| 311 | content="", |
| 312 | metadata=metadata, |
| 313 | ) |
| 314 | ) |
| 315 | if channel == self._default_channel.get() and chat_id == self._default_chat_id.get(): |
| 316 | self._sent_in_turn = True |
| 317 | return ( |
| 318 | f"Question cards sent to {channel}:{chat_id} " |
| 319 | f"({len(normalized)} card(s), batch={batch_id})" |
| 320 | ) |
| 321 | except Exception as e: |
| 322 | return f"Error sending question cards: {e}" |
| 323 | |
| 324 |