| """Gradio adapter layer — wires :mod:`core.chat` into Gradio events. |
| |
| All pure conversation logic lives in :mod:`core.chat`. This module only deals |
| with Gradio types (``gr.update``, ``gr.Warning``) and yields the tuples that |
| the Blocks event handlers expect. |
| |
| Architecture |
| ------------ |
| The chat surface is a custom HTML container (``<div id="hy-chat">``) owned |
| and mutated by ``static/chat.js``. The server ships **deltas**, never full |
| snapshots, via two hidden bridge components: |
| |
| * ``chat_delta`` (``gr.HTML``, ``elem_id="hy-chat-delta"``): a single |
| ``<div>`` whose textContent is the latest JSON payload of the form |
| ``{"seq": N, "ops": [...]}``. The browser observes mutations on this |
| element, parses, and applies each op to the chat container. |
| * ``busy_marker`` (``gr.HTML``, ``elem_id="hy-busy-marker"``): drives the |
| Send button's enabled state. ``HY_BUSY_HTML`` while the model is |
| generating, ``HY_IDLE_HTML`` when it's done. |
| |
| Delta wire payload is just the new characters, and the client only |
| re-renders the trailing in-progress paragraph; everything above it is |
| frozen DOM that never changes again. |
| |
| Send-button busy-state ownership |
| -------------------------------- |
| The Send button's busy state is **owned entirely by the browser** — there |
| is NO server-side ``gr.update(interactive=...)`` toggling. See |
| ``static/app.js`` for the MutationObserver-based gate. |
| |
| The terminal IDLE-marker frame is shipped *separately* from any |
| chat-content frame so that even when WebSocket is back-pressured the |
| tiny marker frame still slips through and the UI unblocks promptly. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import itertools |
| import json |
| import logging |
| from typing import Iterator |
|
|
| import gradio as gr |
|
|
| from core.chat import ( |
| ChatState, |
| build_api_kwargs, |
| finalize_response, |
| flush_tool_results, |
| init_state, |
| record_tool_result, |
| reset_state_in_place, |
| stream_response, |
| ) |
| from display import ( |
| format_tool_call_prompt, |
| format_tool_calls_for_display, |
| preprocess_latex, |
| ) |
| from i18n import t |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| HY_BUSY_HTML = '<span data-hy-busy="1" data-hy-streaming="1" hidden aria-hidden="true"></span>' |
| HY_IDLE_HTML = '<span data-hy-busy="0" hidden aria-hidden="true"></span>' |
|
|
| |
| |
| |
| HY_CHAT_INITIAL_HTML = '<div id="hy-chat" class="hy-chat" role="log" aria-live="polite"></div>' |
|
|
| |
| |
| |
| |
| _seq_counter = itertools.count(1) |
|
|
|
|
| def _next_seq() -> int: |
| return next(_seq_counter) |
|
|
|
|
| |
| |
| |
| _bubble_counter = itertools.count(1) |
|
|
|
|
| def _next_bubble_id() -> str: |
| return f"a-{next(_bubble_counter)}" |
|
|
|
|
| def _delta(ops: list[dict], epoch: int | None = None) -> str: |
| """Encode a list of ops as a delta payload string. |
| |
| The payload is shipped through ``gr.HTML``'s value, which the front |
| end renders into the DOM via ``innerHTML``. Plain ``json.dumps`` |
| output is valid HTML text 99 % of the time, but any user- or |
| model-supplied ``<`` would be parsed as the opening of an HTML tag |
| and silently truncate the payload (also a stored-XSS sink). We |
| pre-escape the three HTML-meta characters into their JSON |
| ``\\uNNNN`` form — the browser's ``JSON.parse`` reverses the escapes |
| so semantics are preserved end-to-end. |
| |
| ``epoch`` tags the payload with the session's chat-epoch id so the |
| client can drop stale deltas that arrive after a "+" / new-chat |
| reset. The reset payload itself carries the NEW epoch — the client |
| adopts it, then ignores any later payload that still carries the |
| old one. |
| """ |
| payload: dict = {"seq": _next_seq(), "ops": ops} |
| if epoch is not None: |
| payload["epoch"] = epoch |
| raw = json.dumps(payload, ensure_ascii=False) |
| return ( |
| raw.replace("<", "\\u003c") |
| .replace(">", "\\u003e") |
| .replace("&", "\\u0026") |
| ) |
|
|
|
|
| |
| __all__ = [ |
| "init_state", |
| "send_message", |
| "new_chat", |
| "submit_tool_result", |
| "api_chat", |
| "HY_BUSY_HTML", |
| "HY_IDLE_HTML", |
| "HY_CHAT_INITIAL_HTML", |
| ] |
|
|
|
|
| def _ensure_state(state) -> ChatState: |
| """Normalize ``state`` into a fresh ``ChatState`` dict. |
| |
| ``gr.State(init_state)`` treats ``init_state`` as a per-session factory in |
| the browser, but stateless ``gradio_client`` calls hand the factory itself |
| to the handler. Accept callable/None and fall back to a fresh state so the |
| API works without breaking the in-browser per-session semantics. |
| """ |
| if state is None or callable(state): |
| return init_state() |
| return state |
|
|
|
|
| def _validate_user_message(message: str, functions_json_str: str) -> bool: |
| if not message or not message.strip(): |
| gr.Warning(t("warn.empty_msg")) |
| return False |
| if functions_json_str and functions_json_str.strip(): |
| try: |
| json.loads(functions_json_str) |
| except json.JSONDecodeError as e: |
| gr.Warning(t("warn.invalid_fn_json", err=str(e))) |
| return False |
| return True |
|
|
|
|
| def _run_streaming_turn( |
| state: ChatState, |
| bubble_id: str, |
| epoch: int, |
| system_prompt: str, |
| think_level: str, |
| temperature: float, |
| temperature_use_default: bool, |
| max_tokens: int, |
| top_p: float, |
| preserved_thinking: bool, |
| preserved_thinking_use_default: bool, |
| functions_json_str: str, |
| ) -> Iterator[tuple[list[dict], bool, str]]: |
| """Stream a single turn. Yields ``(ops, show_tool, tool_md)``. |
| |
| ``ops`` is the list of bubble-scoped ops (each carrying ``id=bubble_id``) |
| to ship on this frame. ``show_tool`` / ``tool_md`` carry the tool-area |
| UI state for the caller to translate into ``gr.update(...)`` outputs. |
| |
| ``epoch`` is the chat-epoch captured by the caller at turn start. |
| We compare it against the live ``state["epoch"]`` between yields; |
| if they diverge the user clicked "+", so we stop yielding and let |
| the caller exit without finalising the assistant turn. |
| """ |
| |
| |
| |
| |
| resolved_temperature: float | None = ( |
| None if temperature_use_default else float(temperature) |
| ) |
| |
| |
| resolved_preserved_thinking: bool | None = ( |
| None if preserved_thinking_use_default else bool(preserved_thinking) |
| ) |
| kwargs = build_api_kwargs( |
| state, system_prompt, functions_json_str, |
| think_level, resolved_temperature, max_tokens, top_p, |
| resolved_preserved_thinking, |
| ) |
| if logger.isEnabledFor(logging.DEBUG): |
| |
| |
| loggable = {k: v for k, v in kwargs.items() if k != "messages"} |
| logger.debug("streaming turn kwargs: %r", loggable) |
|
|
| def _cancelled() -> bool: |
| return state.get("epoch") != epoch |
|
|
| last_ac, last_rc, last_tca = "", "", [] |
| seen_first_content = False |
| for ops, ac, rc, tca, _rid in stream_response(kwargs, is_cancelled=_cancelled): |
| if _cancelled(): |
| return |
| last_ac, last_rc, last_tca = ac, rc, tca |
| if not ops: |
| |
| |
| |
| yield [], False, "" |
| continue |
| |
| |
| |
| out_ops: list[dict] = [] |
| for op in ops: |
| if op["type"] == "content_delta": |
| |
| |
| |
| delta_text = preprocess_latex(op["delta"]) |
| wrapped = { |
| "type": "content_delta", |
| "id": bubble_id, |
| "delta": delta_text, |
| } |
| if not seen_first_content: |
| wrapped["thinking_done"] = True |
| seen_first_content = True |
| out_ops.append(wrapped) |
| elif op["type"] == "reasoning_delta": |
| out_ops.append({ |
| "type": "reasoning_delta", |
| "id": bubble_id, |
| "delta": op["delta"], |
| }) |
| elif op["type"] == "tool_calls": |
| |
| |
| |
| |
| |
| continue |
| if out_ops: |
| yield out_ops, False, "" |
|
|
| |
| if _cancelled(): |
| return |
| has_tools, pending = finalize_response( |
| state, last_ac, last_rc, last_tca, |
| ) |
| end_ops: list[dict] = [{"type": "assistant_end", "id": bubble_id}] |
| if has_tools: |
| |
| |
| |
| tc_markdown = format_tool_calls_for_display(pending) |
| end_ops.append({ |
| "type": "tool_call", |
| "id": bubble_id, |
| "markdown": tc_markdown, |
| }) |
| tool_md = format_tool_call_prompt(pending[0], 1, len(pending)) |
| yield end_ops, True, tool_md |
| else: |
| yield end_ops, False, "" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def send_message( |
| message, state, |
| system_prompt, think_level, |
| temperature, temperature_use_default, |
| max_tokens, top_p, |
| preserved_thinking, preserved_thinking_use_default, |
| functions_json_str, |
| ): |
| state = _ensure_state(state) |
|
|
| if not _validate_user_message(message, functions_json_str): |
| |
| |
| yield ( |
| gr.update(), state, gr.update(), |
| gr.update(), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
| return |
|
|
| message = message.strip() |
| state["messages"].append({"role": "user", "content": message}) |
| bubble_id = _next_bubble_id() |
| |
| |
| |
| epoch = state["epoch"] |
|
|
| def _cancelled() -> bool: |
| return state.get("epoch") != epoch |
|
|
| |
| |
| yield ( |
| _delta([ |
| {"type": "user", "text": message}, |
| {"type": "assistant_begin", "id": bubble_id}, |
| ], epoch=epoch), |
| state, "", |
| gr.update(visible=False), gr.update(), gr.update(), |
| HY_BUSY_HTML, |
| ) |
|
|
| try: |
| for ops, show_tool, tool_md in _run_streaming_turn( |
| state, bubble_id, epoch, |
| system_prompt, think_level, |
| temperature, temperature_use_default, |
| max_tokens, top_p, |
| preserved_thinking, preserved_thinking_use_default, |
| functions_json_str, |
| ): |
| if _cancelled(): |
| return |
| |
| |
| payload = _delta(ops, epoch=epoch) if ops else _delta([], epoch=epoch) |
| if show_tool: |
| yield ( |
| payload, state, gr.update(), |
| gr.update(visible=True), gr.update(value=tool_md), gr.update(value=""), |
| gr.update(), |
| ) |
| else: |
| yield ( |
| payload, state, gr.update(), |
| gr.update(visible=False), gr.update(), gr.update(), |
| gr.update(), |
| ) |
|
|
| except Exception as e: |
| if _cancelled(): |
| |
| |
| |
| return |
| logger.exception("send_message failed: %s", e) |
| if state["messages"] and state["messages"][-1].get("role") == "user": |
| state["messages"].pop() |
| gr.Warning(t("warn.request_failed")) |
| |
| |
| yield ( |
| _delta([{"type": "remove_bubble", "id": bubble_id}], epoch=epoch), |
| state, gr.update(value=message), |
| gr.update(visible=False), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
| return |
|
|
| if _cancelled(): |
| return |
|
|
| |
| |
| |
| |
| |
| yield ( |
| gr.update(), state, gr.update(), |
| gr.update(), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def api_chat( |
| message: str, |
| system_prompt: str = "", |
| history: list | None = None, |
| think_level: str = "high", |
| temperature: float | None = None, |
| max_tokens: int = 0, |
| top_p: float = 0, |
| preserved_thinking: bool | None = None, |
| functions_json_str: str = "", |
| ) -> Iterator[tuple[str, str, list, list]]: |
| """Stateless chat endpoint for ``gradio_client`` callers. |
| |
| Args: |
| message: The new user turn. |
| system_prompt: Optional system prompt prepended to every request. |
| history: Prior conversation as a list of OpenAI-style messages |
| (``[{"role": "user"|"assistant"|"tool", "content": ...}, ...]``). |
| Pass ``None`` or ``[]`` to start a fresh conversation. The caller |
| owns the history; pass the returned ``updated_history`` back on |
| the next call to continue multi-turn. |
| think_level: One of ``"no_think" | "low" | "high"``. |
| temperature: Sampling temperature. Pass ``None`` (the default) to omit |
| the field from the API request and let the server apply its own |
| default; pass any float (including ``0`` for greedy decoding) to |
| send it literally. |
| max_tokens, top_p: Sampling knobs. Pass ``0`` (the default) to omit |
| the field from the API request and let the server apply its own |
| default. |
| preserved_thinking: Whether to preserve the model's chain-of-thought. |
| Pass ``None`` (the default) to omit the field and let the server |
| apply its own default; pass ``True`` / ``False`` to send it |
| literally (via ``extra_body``). |
| functions_json_str: JSON string of tool definitions (OpenAI tools |
| schema). Empty string disables function calling. |
| |
| Yields: |
| ``(content, reasoning_content, tool_calls, updated_history)`` |
| cumulative on every yield. The final yield is the complete reply. |
| |
| * ``content``: assistant visible text. |
| * ``reasoning_content``: chain-of-thought (when ``think_level`` |
| requests it). May be empty for ``no_think``. |
| * ``tool_calls``: list of OpenAI-style tool-call dicts the model |
| requested. Empty when no function was called. The caller is |
| responsible for executing each call and appending the |
| corresponding ``{"role": "tool", "tool_call_id": ..., "content": |
| ...}`` messages to ``history`` on the next ``api_chat`` call. |
| * ``updated_history``: full message list including the new user |
| turn and the assistant reply (with ``tool_calls`` attached when |
| present). Suitable for round-tripping into the next call. |
| |
| Raises: |
| ValueError: when ``message`` is empty or ``functions_json_str`` is |
| not valid JSON. |
| """ |
| if not message or not message.strip(): |
| raise ValueError("message must be a non-empty string") |
| if functions_json_str and functions_json_str.strip(): |
| try: |
| json.loads(functions_json_str) |
| except json.JSONDecodeError as e: |
| raise ValueError(f"functions_json_str is not valid JSON: {e}") from e |
|
|
| state = init_state() |
| if history: |
| |
| state["messages"] = [dict(m) for m in history] |
| state["messages"].append({"role": "user", "content": message.strip()}) |
|
|
| kwargs = build_api_kwargs( |
| state, system_prompt, functions_json_str, |
| think_level, temperature, max_tokens, top_p, |
| preserved_thinking, |
| ) |
|
|
| last_ac, last_rc = "", "" |
| last_tca: list[dict] = [] |
| yielded = False |
| for _ops, ac, rc, tca, _rid in stream_response(kwargs): |
| last_ac, last_rc, last_tca = ac, rc, tca |
| |
| |
| in_flight = list(state["messages"]) |
| in_flight.append({ |
| "role": "assistant", |
| "content": ac or "", |
| **({"reasoning_content": rc} if rc else {}), |
| **({"tool_calls": list(tca)} if tca else {}), |
| }) |
| yield ac or "", rc or "", list(tca), in_flight |
| yielded = True |
|
|
| finalize_response(state, last_ac, last_rc, last_tca) |
| final_history = list(state["messages"]) |
|
|
| |
| |
| |
| if not yielded: |
| yield "", "", [], final_history |
| else: |
| yield last_ac or "", last_rc or "", list(last_tca), final_history |
|
|
|
|
| def new_chat(state): |
| """Reset the conversation. |
| |
| Critical: when there's an in-flight stream we MUTATE the existing |
| state dict in place (bumping its epoch) instead of returning a brand |
| new dict. The streaming generator holds a reference to this same |
| dict and polls ``state["epoch"]`` between yields — the in-place |
| bump is what tells it to abandon the rest of its turn. Returning a |
| fresh dict would leave the generator pointed at a stale dict it |
| would happily keep streaming into. |
| |
| We also push ``HY_IDLE_HTML`` to the busy marker so the Send button |
| unlocks immediately. Without this the cancelled generator never |
| yields its terminal IDLE frame, so the button would stay stuck |
| until the soft watchdog in static/app.js force-clears it (~4s). |
| """ |
| if not state or not isinstance(state, dict) or not state.get("messages"): |
| gr.Info(t("info.new_chat")) |
| return gr.update(), state, gr.update() |
| new_epoch = reset_state_in_place(state) |
| return ( |
| _delta([{"type": "reset"}], epoch=new_epoch), |
| state, |
| HY_IDLE_HTML, |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| def submit_tool_result( |
| result_text, state, |
| system_prompt, think_level, |
| temperature, temperature_use_default, |
| max_tokens, top_p, |
| preserved_thinking, preserved_thinking_use_default, |
| functions_json_str, |
| ): |
| state = _ensure_state(state) |
| pending = state.get("pending_tool_calls", []) |
| if not pending: |
| yield ( |
| gr.update(), state, |
| gr.update(visible=False), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
| return |
|
|
| record_tool_result(state, pending[0], result_text) |
| state["pending_tool_calls"] = pending[1:] |
|
|
| if state["pending_tool_calls"]: |
| |
| |
| remaining = state["pending_tool_calls"] |
| submitted = state.get("submitted_tool_results", []) |
| total = len(remaining) + len(submitted) |
| current_idx = len(submitted) + 1 |
| tool_info_md = format_tool_call_prompt(remaining[0], current_idx, total) |
| yield ( |
| gr.update(), state, |
| gr.update(visible=True), gr.update(value=tool_info_md), gr.update(value=""), |
| HY_IDLE_HTML, |
| ) |
| return |
|
|
| flush_tool_results(state) |
| bubble_id = _next_bubble_id() |
| epoch = state["epoch"] |
|
|
| def _cancelled() -> bool: |
| return state.get("epoch") != epoch |
|
|
| |
| |
| yield ( |
| _delta([{"type": "assistant_begin", "id": bubble_id}], epoch=epoch), |
| state, |
| gr.update(visible=False), gr.update(), gr.update(), |
| HY_BUSY_HTML, |
| ) |
|
|
| try: |
| for ops, show_tool, tool_md in _run_streaming_turn( |
| state, bubble_id, epoch, |
| system_prompt, think_level, |
| temperature, temperature_use_default, |
| max_tokens, top_p, |
| preserved_thinking, preserved_thinking_use_default, |
| functions_json_str, |
| ): |
| if _cancelled(): |
| return |
| payload = _delta(ops, epoch=epoch) if ops else _delta([], epoch=epoch) |
| if show_tool: |
| yield ( |
| payload, state, |
| gr.update(visible=True), gr.update(value=tool_md), gr.update(value=""), |
| gr.update(), |
| ) |
| else: |
| yield ( |
| payload, state, |
| gr.update(visible=False), gr.update(), gr.update(), |
| gr.update(), |
| ) |
|
|
| except Exception as e: |
| if _cancelled(): |
| return |
| logger.exception("submit_tool_result failed: %s", e) |
| gr.Warning(t("warn.request_failed")) |
| yield ( |
| _delta([{"type": "remove_bubble", "id": bubble_id}], epoch=epoch), |
| state, |
| gr.update(visible=False), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
| return |
|
|
| if _cancelled(): |
| return |
|
|
| yield ( |
| gr.update(), state, |
| gr.update(), gr.update(), gr.update(), |
| HY_IDLE_HTML, |
| ) |
|
|