gradio server

#1
by akhaliq HF Staff - opened
Files changed (5) hide show
  1. README.md +4 -4
  2. config.py +17 -32
  3. requirements.txt +1 -0
  4. server.py +473 -0
  5. static/index.html +1344 -0
README.md CHANGED
@@ -1,11 +1,11 @@
1
  ---
2
  title: Hy3
3
  emoji: ⚡
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
- app_file: app.py
9
  pinned: false
10
  short_description: Hy3 multi-turn streaming chat with function calling
11
  ---
 
1
  ---
2
  title: Hy3
3
  emoji: ⚡
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 6.19.0
8
+ app_file: server.py
9
  pinned: false
10
  short_description: Hy3 multi-turn streaming chat with function calling
11
  ---
config.py CHANGED
@@ -1,9 +1,11 @@
1
- """Runtime configuration loaded from environment variables.
 
 
 
 
2
 
3
  Required:
4
- HY_API_KEY API key for the upstream OpenAI-compatible endpoint.
5
- HY_BASE_URL OpenAI-compatible base URL of the upstream endpoint.
6
- HY_MODEL Model name passed to chat.completions.create.
7
 
8
  Optional:
9
  HY_LOG_LEVEL One of DEBUG / INFO / WARNING / ERROR (default: WARNING).
@@ -17,9 +19,7 @@ import os
17
  from openai import OpenAI
18
 
19
 
20
- API_KEY = os.environ.get("HY_API_KEY", "").strip()
21
- BASE_URL = os.environ.get("HY_BASE_URL", "").strip()
22
- MODEL = os.environ.get("HY_MODEL", "").strip()
23
  LOG_LEVEL = os.environ.get("HY_LOG_LEVEL", "WARNING").upper()
24
 
25
 
@@ -37,34 +37,19 @@ if not logging.getLogger().handlers:
37
  logger = logging.getLogger("Hy3")
38
 
39
 
40
- # Surface missing required env vars loudly at import time. We don't
41
- # raise — that would prevent the Gradio server from even starting up
42
- # (and HuggingFace Spaces would just show a blank build-failed page),
43
- # making it harder to debug. Instead: warn now, and the per-request
44
- # code paths will still fail cleanly with the upstream API's own
45
- # error message.
46
- _missing = [
47
- name
48
- for name, val in (
49
- ("HY_API_KEY", API_KEY),
50
- ("HY_BASE_URL", BASE_URL),
51
- ("HY_MODEL", MODEL),
52
- )
53
- if not val
54
- ]
55
- if _missing:
56
  logger.warning(
57
- "Required env var(s) not set: %s. "
58
- "The app will boot but every chat request will fail until they are "
59
- "configured (e.g. as HuggingFace Space secrets / variables).",
60
- ", ".join(_missing),
61
  )
62
 
63
 
64
- # ``base_url=""`` would be passed through to httpx and produce undefined
65
- # request URLs. ``None`` makes the OpenAI SDK fall back to its default
66
- # (api.openai.com), which is at least a well-defined behaviour.
67
  client = OpenAI(
68
- api_key=API_KEY or "missing-api-key",
69
- base_url=BASE_URL or None,
70
  )
 
1
+ """Runtime configuration for the upstream OpenAI-compatible endpoint.
2
+
3
+ The model is served via the HuggingFace inference router. The only secret
4
+ we need is ``HF_TOKEN`` (a HuggingFace access token); the base URL and model
5
+ name are fixed.
6
 
7
  Required:
8
+ HF_TOKEN HuggingFace access token (read access to the model).
 
 
9
 
10
  Optional:
11
  HY_LOG_LEVEL One of DEBUG / INFO / WARNING / ERROR (default: WARNING).
 
19
  from openai import OpenAI
20
 
21
 
22
+ MODEL = "tencent/Hy3:deepinfra"
 
 
23
  LOG_LEVEL = os.environ.get("HY_LOG_LEVEL", "WARNING").upper()
24
 
25
 
 
37
  logger = logging.getLogger("Hy3")
38
 
39
 
40
+ # Surface a missing HF_TOKEN loudly at import time. We don't raise — that
41
+ # would prevent the Gradio server from even starting up (and HuggingFace
42
+ # Spaces would just show a blank build-failed page), making it harder to
43
+ # debug. Instead: warn now, and the per-request code paths will still fail
44
+ # cleanly with the upstream API's own 401 error message.
45
+ if not os.environ.get("HF_TOKEN", "").strip():
 
 
 
 
 
 
 
 
 
 
46
  logger.warning(
47
+ "HF_TOKEN is not set. The app will boot but every chat request will "
48
+ "fail until it is configured (e.g. as a HuggingFace Space secret)."
 
 
49
  )
50
 
51
 
 
 
 
52
  client = OpenAI(
53
+ base_url="https://router.huggingface.co/v1",
54
+ api_key=os.environ["HF_TOKEN"],
55
  )
requirements.txt CHANGED
@@ -1 +1,2 @@
 
1
  openai>=1.66.0
 
1
+ gradio>=6.19.0
2
  openai>=1.66.0
server.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tencent-branded Hy3 chat demo, served from a :class:`gradio.Server`.
2
+
3
+ Goal: ``only the UI and the transport change`` — the chat behaviour is
4
+ identical to the previous ``gr.Blocks`` app (``app.py`` + ``chat.py``).
5
+ We reuse the exact same incremental **delta-op** streaming protocol the
6
+ old app shipped through its hidden ``#hy-chat-delta`` ``gr.HTML`` bridge:
7
+ the same op types (``user``, ``assistant_begin``, ``reasoning_delta``,
8
+ ``content_delta``, ``assistant_end``, ``tool_call``, ``remove_bubble``,
9
+ ``reset``), the same ``seq`` de-dup, the same ``epoch`` staleness guard,
10
+ the same ``preprocess_latex`` on content deltas, the same tool-call
11
+ markdown from ``display.format_tool_calls_for_display``, and — critically
12
+ — the same ``is_cancelled`` epoch mechanism so clicking "+" mid-stream
13
+ abandons the upstream API call server-side instead of billing tokens for
14
+ an answer nobody will see.
15
+
16
+ The only real change vs. the Blocks app is the transport: instead of a
17
+ ``gr.HTML`` component whose value we mutate, we stream the same JSON
18
+ delta payload through ``@app.api`` SSE frames, and the frontend consumes
19
+ them with the same renderer (ported from ``static/chat.js``).
20
+
21
+ Why not reuse ``chat.send_message`` directly? It yields the
22
+ 7-tuple-of-``gr.update`` shape the Blocks event handlers expect, plus
23
+ ``gr.Warning`` toasts — all Gradio-coupled. The *pure* turn logic it
24
+ wraps (``_run_streaming_turn``, ``_delta``, the op assembly) is already
25
+ Gradio-free, so we call that directly and serialise its op-lists to
26
+ JSON ourselves.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import threading
34
+ from pathlib import Path
35
+ from typing import Any, Iterator
36
+
37
+ from fastapi.responses import HTMLResponse
38
+ from fastapi.staticfiles import StaticFiles
39
+
40
+ from gradio import Server
41
+
42
+ import chat as _chat # for _delta / _next_bubble_id (pure helpers reused below)
43
+ from chat import _run_streaming_turn
44
+ from core.chat import (
45
+ ChatState,
46
+ finalize_response,
47
+ flush_tool_results,
48
+ init_state,
49
+ record_tool_result,
50
+ reset_state_in_place,
51
+ )
52
+ from display import format_tool_call_prompt
53
+ from i18n import t
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ _STATIC_DIR = Path(__file__).parent / "static"
58
+ _INDEX_HTML = _STATIC_DIR / "index.html"
59
+
60
+ CHAT_CONCURRENCY = 64
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Per-session state (identical in spirit to the Blocks app's gr.State)
64
+ # ---------------------------------------------------------------------------
65
+ # Each browser tab holds a random session id (see static/index.html). We keep
66
+ # an in-process ChatState per id so multi-turn + tool-calling work across the
67
+ # stateless HTTP boundary. The lock guards the *non-streaming* bookkeeping
68
+ # (record/flush tool results, new_chat reset); streaming itself does NOT hold
69
+ # the lock (see /chat) so a new_chat can interrupt an in-flight stream.
70
+
71
+ _SESSIONS: dict[str, dict] = {}
72
+ _SESSIONS_LOCK = threading.Lock()
73
+
74
+
75
+ def _get_session(session_id: str) -> dict:
76
+ with _SESSIONS_LOCK:
77
+ sess = _SESSIONS.get(session_id)
78
+ if sess is None:
79
+ sess = {"state": init_state(), "lock": threading.Lock()}
80
+ _SESSIONS[session_id] = sess
81
+ return sess
82
+
83
+
84
+ def _resolve_state(session_id: Any) -> ChatState:
85
+ return _get_session(session_id or "")["state"]
86
+
87
+
88
+ # Reuse chat.py's HTML-safe JSON delta encoder + bubble-id counter so the
89
+ # wire format is byte-identical to the Blocks app's #hy-chat-delta payload.
90
+ _delta = _chat._delta
91
+ _next_bubble_id = _chat._next_bubble_id
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Server + routes
96
+ # ---------------------------------------------------------------------------
97
+ app = Server(title="Hy3 · Tencent")
98
+
99
+
100
+ @app.get("/", response_class=HTMLResponse)
101
+ async def homepage() -> HTMLResponse:
102
+ """Serve the custom Tencent-branded frontend."""
103
+ return HTMLResponse(_INDEX_HTML.read_text(encoding="utf-8"))
104
+
105
+
106
+ # Static asset passthrough (logo, vendored libs the frontend pulls from the
107
+ # same origin instead of a CDN, etc.).
108
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="hy-static")
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Validation — mirror chat._validate_user_message but return the error string
113
+ # instead of emitting a gr.Warning toast.
114
+ # ---------------------------------------------------------------------------
115
+ def _validate_message(message: str, functions_json_str: str) -> str | None:
116
+ """Return None if valid, else a localized error string."""
117
+ if not message or not message.strip():
118
+ return t("warn.empty_msg")
119
+ if functions_json_str and functions_json_str.strip():
120
+ try:
121
+ json.loads(functions_json_str)
122
+ except json.JSONDecodeError as e:
123
+ return t("warn.invalid_fn_json", err=str(e))
124
+ return None
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # A streaming turn = a sequence of JSON delta payloads (the SAME wire format
129
+ # the old app pushed through #hy-chat-delta). Each yielded string is a single
130
+ # {"seq","ops"[,"epoch"]} document the frontend applies with applyDelta.
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _frame(ops: list[dict], epoch: int) -> str:
134
+ """Encode a frame's ops as an epoch-tagged delta payload."""
135
+ return _delta(ops, epoch=epoch)
136
+
137
+
138
+ def _stream_turn(
139
+ state: ChatState,
140
+ bubble_id: str,
141
+ epoch: int,
142
+ system_prompt: str,
143
+ think_level: str,
144
+ temperature: float | None,
145
+ max_tokens: int,
146
+ top_p: float,
147
+ preserved_thinking: bool | None,
148
+ functions_json_str: str,
149
+ ) -> Iterator[str]:
150
+ """Run one assistant turn, yielding delta payloads.
151
+
152
+ This is the pure core of the Blocks app's ``_run_streaming_turn`` +
153
+ ``send_message`` streaming section, translated to JSON frames. It:
154
+
155
+ * collapses the temperature/preserved_thinking "use default" tristate,
156
+ * runs ``preprocess_latex`` on every content delta (so bare
157
+ ``\\begin{equation}`` and negation macros render correctly),
158
+ * suppresses tool-call snapshots mid-stream and materialises them as a
159
+ ``tool_call`` op at end-of-turn (keeping wire payloads tiny),
160
+ * yields heartbeats (empty-ops frames) so the connection stays alive,
161
+ * bails the moment ``state["epoch"]`` diverges (new_chat clicked) so
162
+ the upstream API stream is abandoned server-side.
163
+ """
164
+ # The Blocks UI collapses (slider, "use default" checkbox) into a tristate
165
+ # BEFORE calling build_api_kwargs. The Server frontend sends the tristate
166
+ # directly (null = use default), so we only need to pass it through.
167
+ temperature_use_default = temperature is None
168
+ preserved_thinking_use_default = preserved_thinking is None
169
+
170
+ show_tool = False
171
+ tool_md = ""
172
+ for ops, this_show_tool, this_tool_md in _run_streaming_turn(
173
+ state, bubble_id, epoch,
174
+ system_prompt, think_level,
175
+ # _run_streaming_turn takes the raw slider value + use-default flag;
176
+ # reconstruct them from the tristate the frontend sent.
177
+ float(temperature) if temperature is not None else 0.9,
178
+ temperature_use_default,
179
+ max_tokens, top_p,
180
+ bool(preserved_thinking) if preserved_thinking is not None else False,
181
+ preserved_thinking_use_default,
182
+ functions_json_str,
183
+ ):
184
+ show_tool = show_tool or this_show_tool
185
+ if this_tool_md:
186
+ tool_md = this_tool_md
187
+ yield _frame(ops, epoch), this_show_tool, this_tool_md
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # /chat — the streaming chat endpoint. Mirrors chat.send_message's flow.
192
+ # ---------------------------------------------------------------------------
193
+ @app.api(
194
+ name="chat",
195
+ concurrency_limit=CHAT_CONCURRENCY,
196
+ concurrency_id="chat",
197
+ stream_every=0.06,
198
+ )
199
+ def chat(
200
+ message: str,
201
+ session_id: str = "",
202
+ system_prompt: str = "",
203
+ think_level: str = "high",
204
+ temperature: float | None = None,
205
+ max_tokens: int = 0,
206
+ top_p: float = 0,
207
+ preserved_thinking: bool | None = None,
208
+ functions_json_str: str = "",
209
+ ) -> Iterator[tuple]:
210
+ """Stream a chat turn as delta-op frames.
211
+
212
+ Yields ``(delta_json, tool_area_state)`` where ``delta_json`` is the
213
+ JSON payload the frontend applies with ``applyDelta`` (same shape the
214
+ Blocks app pushed through #hy-chat-delta) and ``tool_area_state`` is
215
+ ``None`` normally, or a dict ``{"prompt": <md>, "pending": <n>}`` when
216
+ the model requested tool calls at end-of-turn.
217
+ """
218
+ state = _resolve_state(session_id)
219
+
220
+ # ── Validation (mirrors chat._validate_user_message) ──
221
+ err = _validate_message(message, functions_json_str)
222
+ if err is not None:
223
+ # Never started streaming: send a single idle marker frame + the
224
+ # error. The frontend surfaces the error as a toast and keeps the
225
+ # user's message in the input box.
226
+ yield _frame([], state["epoch"]), {"error": err}
227
+ return
228
+
229
+ message = message.strip()
230
+ state["messages"].append({"role": "user", "content": message})
231
+ bubble_id = _next_bubble_id()
232
+ epoch = state["epoch"]
233
+
234
+ def _cancelled() -> bool:
235
+ return state.get("epoch") != epoch
236
+
237
+ # First frame: append the user bubble + open an assistant bubble.
238
+ yield _frame([
239
+ {"type": "user", "text": message},
240
+ {"type": "assistant_begin", "id": bubble_id},
241
+ ], epoch), None
242
+
243
+ tool_area = None
244
+ try:
245
+ for frame, show_tool, tool_md in _stream_turn(
246
+ state, bubble_id, epoch, system_prompt, think_level,
247
+ temperature, max_tokens, top_p,
248
+ preserved_thinking, functions_json_str,
249
+ ):
250
+ if _cancelled():
251
+ return
252
+ yield frame, None
253
+ if show_tool:
254
+ tool_area = {"prompt": tool_md}
255
+
256
+ except Exception as e:
257
+ if _cancelled():
258
+ return
259
+ logger.exception("chat failed: %s", e)
260
+ # Drop the in-progress assistant bubble, restore the user's message
261
+ # to the input, surface a localized failure — exactly the Blocks
262
+ # app's error path (chat.py:364-381).
263
+ if state["messages"] and state["messages"][-1].get("role") == "user":
264
+ state["messages"].pop()
265
+ yield _frame(
266
+ [{"type": "remove_bubble", "id": bubble_id}], epoch,
267
+ ), {"error": t("warn.request_failed"), "restore_input": message}
268
+ return
269
+
270
+ if _cancelled():
271
+ return
272
+
273
+ # If the model requested tool calls, mirror the pending queue the Blocks
274
+ # app's finalize_response would have built so submit_tool_result works.
275
+ _sync_pending_tool_calls(state)
276
+ if tool_area and state.get("pending_tool_calls"):
277
+ pending = state["pending_tool_calls"]
278
+ tool_area = {
279
+ "prompt": format_tool_call_prompt(pending[0], 1, len(pending)),
280
+ "pending": len(pending),
281
+ }
282
+ yield _frame([], epoch), tool_area
283
+
284
+
285
+ def _sync_pending_tool_calls(state: ChatState) -> None:
286
+ """Re-derive ``pending_tool_calls`` from the last assistant message.
287
+
288
+ ``_run_streaming_turn`` already called ``finalize_response`` (which
289
+ mutates ``state``), so we just read off what it set — identical to the
290
+ state the Blocks app would have after a tool-calling turn.
291
+ """
292
+ # finalize_response (core/chat.py) already populated pending_tool_calls
293
+ # when the model called tools; nothing to re-derive here, but keep the
294
+ # hook for symmetry with the earlier snapshot-based implementation.
295
+ if not state.get("pending_tool_calls"):
296
+ state["pending_assistant_msg"] = None
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # /new_chat — reset. Critically this does NOT block on a running stream:
301
+ # the stream's _cancelled() polls state["epoch"], so bumping it here makes
302
+ # the in-flight /chat abandon the upstream API on its next yield.
303
+ # ---------------------------------------------------------------------------
304
+ @app.api(name="new_chat", concurrency_limit=CHAT_CONCURRENCY)
305
+ def new_chat(session_id: str = "") -> dict:
306
+ sess = _get_session(session_id or "")
307
+ # Only the bookkeeping is locked; the streaming /chat never holds this
308
+ # lock, so a reset lands instantly even mid-stream.
309
+ with sess["lock"]:
310
+ new_epoch = reset_state_in_place(sess["state"])
311
+ return {"epoch": str(new_epoch), "ok": True}
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # /submit_tool_result — mirrors chat.submit_tool_result.
316
+ # ---------------------------------------------------------------------------
317
+ @app.api(
318
+ name="submit_tool_result",
319
+ concurrency_limit=CHAT_CONCURRENCY,
320
+ concurrency_id="chat",
321
+ stream_every=0.06,
322
+ )
323
+ def submit_tool_result(
324
+ result_text: str,
325
+ session_id: str = "",
326
+ system_prompt: str = "",
327
+ think_level: str = "high",
328
+ temperature: float | None = None,
329
+ max_tokens: int = 0,
330
+ top_p: float = 0,
331
+ preserved_thinking: bool | None = None,
332
+ functions_json_str: str = "",
333
+ ) -> Iterator[tuple]:
334
+ sess = _get_session(session_id or "")
335
+ state = sess["state"]
336
+
337
+ with sess["lock"]:
338
+ pending = state.get("pending_tool_calls", [])
339
+ if not pending:
340
+ yield _frame([], state["epoch"]), None
341
+ return
342
+
343
+ record_tool_result(state, pending[0], result_text)
344
+ state["pending_tool_calls"] = pending[1:]
345
+
346
+ if state["pending_tool_calls"]:
347
+ # More results still pending — keep the tool area open for the
348
+ # next call. Emit a tool_area update, no streaming yet.
349
+ remaining = state["pending_tool_calls"]
350
+ submitted = state.get("submitted_tool_results", [])
351
+ total = len(remaining) + len(submitted)
352
+ current_idx = len(submitted) + 1
353
+ yield _frame([], state["epoch"]), {
354
+ "prompt": format_tool_call_prompt(remaining[0], current_idx, total),
355
+ "pending": len(remaining),
356
+ }
357
+ return
358
+
359
+ flush_tool_results(state)
360
+
361
+ # All results in — stream the follow-up turn (same protocol as /chat).
362
+ bubble_id = _next_bubble_id()
363
+ epoch = state["epoch"]
364
+
365
+ def _cancelled() -> bool:
366
+ return state.get("epoch") != epoch
367
+
368
+ yield _frame([{"type": "assistant_begin", "id": bubble_id}], epoch), None
369
+
370
+ tool_area = None
371
+ try:
372
+ for frame, show_tool, tool_md in _stream_turn(
373
+ state, bubble_id, epoch, system_prompt, think_level,
374
+ temperature, max_tokens, top_p,
375
+ preserved_thinking, functions_json_str,
376
+ ):
377
+ if _cancelled():
378
+ return
379
+ yield frame, None
380
+ if show_tool:
381
+ tool_area = {"prompt": tool_md}
382
+
383
+ except Exception as e:
384
+ if _cancelled():
385
+ return
386
+ logger.exception("submit_tool_result failed: %s", e)
387
+ yield _frame(
388
+ [{"type": "remove_bubble", "id": bubble_id}], epoch,
389
+ ), {"error": t("warn.request_failed")}
390
+ return
391
+
392
+ if _cancelled():
393
+ return
394
+
395
+ _sync_pending_tool_calls(state)
396
+ if tool_area and state.get("pending_tool_calls"):
397
+ pending = state["pending_tool_calls"]
398
+ yield _frame([], epoch), {
399
+ "prompt": format_tool_call_prompt(pending[0], 1, len(pending)),
400
+ "pending": len(pending),
401
+ }
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # /config — example prompts + i18n. Loaded once on frontend boot.
406
+ # ---------------------------------------------------------------------------
407
+ @app.api(name="config", concurrency_limit=32)
408
+ def config() -> dict:
409
+ """Return the showcase example prompts + i18n strings for the frontend."""
410
+ from app import EXAMPLES # noqa: WPS433 — lazy so server import stays light
411
+ from i18n import LANG, TRANSLATIONS
412
+
413
+ i18n = {k: t(k) for k in TRANSLATIONS.get(LANG, TRANSLATIONS["en"])}
414
+ return {
415
+ "examples": list(EXAMPLES),
416
+ "i18n": i18n,
417
+ "lang": LANG,
418
+ # The tool-call display label + per-op helpers the renderer needs.
419
+ "tool_call_label": t("tool.call_label"),
420
+ }
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # /validate_functions — i18n-aware validation + pretty-print.
425
+ # ---------------------------------------------------------------------------
426
+ @app.api(name="validate_functions", concurrency_limit=8)
427
+ def validate_functions(functions_json_str: str) -> dict:
428
+ """Validate + pretty-print a tools-JSON string. i18n-aware."""
429
+ from tools import parse_functions_json
430
+
431
+ if not functions_json_str or not functions_json_str.strip():
432
+ return {"ok": False, "message": t("warn.fn.enter_json"), "formatted": ""}
433
+ try:
434
+ data = json.loads(functions_json_str)
435
+ except json.JSONDecodeError as e:
436
+ return {"ok": False, "message": t("warn.fn.invalid_format", err=str(e)), "formatted": ""}
437
+ if isinstance(data, dict):
438
+ data = [data]
439
+ if not isinstance(data, list):
440
+ return {"ok": False, "message": t("warn.fn.must_be_array"), "formatted": ""}
441
+ names: list[str] = []
442
+ for i, item in enumerate(data):
443
+ if not isinstance(item, dict):
444
+ return {"ok": False, "message": t("warn.fn.item_not_object", i=i + 1), "formatted": ""}
445
+ fn = _normalize(item)
446
+ if not fn:
447
+ return {"ok": False, "message": t("warn.fn.item_invalid", i=i + 1), "formatted": ""}
448
+ if fn["name"] in names:
449
+ return {"ok": False, "message": t("warn.fn.duplicate_name", name=fn["name"]), "formatted": ""}
450
+ names.append(fn["name"])
451
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
452
+ return {
453
+ "ok": True,
454
+ "message": t("info.fn.validation_passed", n=len(names), names=", ".join(names)),
455
+ "formatted": formatted,
456
+ }
457
+
458
+
459
+ def _normalize(item: dict) -> dict | None:
460
+ """Accept either {type, function} or a bare {name, parameters} entry."""
461
+ if item.get("type") == "function" and "function" in item:
462
+ fn = item["function"]
463
+ if isinstance(fn, dict) and fn.get("name"):
464
+ return fn
465
+ return None
466
+ if item.get("name"):
467
+ return item
468
+ return None
469
+
470
+
471
+ if __name__ == "__main__":
472
+ # HuggingFace Spaces invokes ``python server.py``.
473
+ app.launch(show_error=True)
static/index.html ADDED
@@ -0,0 +1,1344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>Hy3 · Tencent</title>
7
+ <meta name="color-scheme" content="light dark" />
8
+ <link rel="icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
9
+ <link rel="apple-touch-icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
10
+
11
+ <!-- Tencent logo -->
12
+ <link rel="preload" as="image" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
13
+
14
+ <!-- marked + KaTeX + highlight.js from CDN -->
15
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
16
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css" id="hljs-light-css" />
17
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css" id="hljs-dark-css" disabled />
18
+ <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
19
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
21
+ <script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>
22
+
23
+ <style>
24
+ /* ─────────────────────────────────────────────────────────────────────
25
+ Tencent design tokens
26
+ Tencent brand blue : #0052D9 (primary)
27
+ Tencent red accent : #E53935 (secondary / highlights)
28
+ Tencent teal : #00A98F (success accents)
29
+ ───────────────────────────────────────────────────────────────────── */
30
+ :root {
31
+ --tc-blue: #0052D9;
32
+ --tc-blue-hover: #003CAB;
33
+ --tc-blue-soft: #E8F0FF;
34
+ --tc-blue-060: rgba(0, 82, 217, 0.06);
35
+ --tc-blue-120: rgba(0, 82, 217, 0.12);
36
+ --tc-red: #E53935;
37
+ --tc-teal: #00A98F;
38
+
39
+ --bg: #f7f6f3; /* warm canvas, Claude-like */
40
+ --bg-soft: #ffffff; /* surfaces / bubbles / sidebar */
41
+ --bg-muted: #efece6;
42
+ --bg-elev: #ffffff;
43
+ --sidebar-bg: #ffffff;
44
+ --text: #2b2b2b;
45
+ --text-strong: #1a1a1a;
46
+ --text-muted: #8a8580;
47
+ --border: #e7e3dc;
48
+ --border-strong: #d8d3cb;
49
+ --accent: #c96442; /* warm terracotta accent (Claude) */
50
+ --accent-soft: #f4ece7;
51
+ --shadow-sm: 0 1px 2px rgba(20, 18, 14, 0.04);
52
+ --shadow-md: 0 4px 14px rgba(20, 18, 14, 0.06);
53
+ --shadow-lg: 0 16px 48px rgba(20, 18, 14, 0.14);
54
+ --ring: 0 0 0 3px rgba(0, 82, 217, 0.16);
55
+
56
+ --radius-sm: 8px;
57
+ --radius: 14px;
58
+ --radius-lg: 20px;
59
+ --radius-xl: 26px;
60
+
61
+ --col-max: 768px; /* centered conversation width */
62
+
63
+ --font-sans: "Söhne", "Inter", -apple-system, BlinkMacSystemFont,
64
+ "SF Pro Text", "PingFang SC", "Hiragino Sans GB",
65
+ "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
66
+ --font-mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Monaco, monospace;
67
+ }
68
+
69
+ html.dark {
70
+ --bg: #1a1a1a; /* warm-tinted dark canvas */
71
+ --bg-soft: #232323;
72
+ --bg-muted: #2c2c2c;
73
+ --bg-elev: #232323;
74
+ --sidebar-bg: #202020;
75
+ --text: #ece9e4;
76
+ --text-strong: #ffffff;
77
+ --text-muted: #9a948d;
78
+ --border: #34312d;
79
+ --border-strong: #423e39;
80
+ --accent: #d97757;
81
+ --accent-soft: rgba(217, 119, 87, 0.16);
82
+ --tc-blue-soft: rgba(0, 82, 217, 0.16);
83
+ --tc-blue-060: rgba(0, 82, 217, 0.12);
84
+ --tc-blue-120: rgba(0, 82, 217, 0.24);
85
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
86
+ --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.4);
87
+ --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
88
+ --ring: 0 0 0 3px rgba(0, 82, 217, 0.3);
89
+ }
90
+
91
+ * { box-sizing: border-box; }
92
+ html, body {
93
+ margin: 0; padding: 0; height: 100%; overflow: hidden;
94
+ background: var(--bg); color: var(--text);
95
+ font-family: var(--font-sans);
96
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
97
+ }
98
+ button, input, textarea, select { font-family: inherit; }
99
+
100
+ /* ── Layout shell ───────────────────────────────────────────────────────
101
+ The chat surface (#main) IS the warm canvas and fills everything below
102
+ the header; the composer floats at the bottom of it, centered, so the
103
+ conversation scrolls underneath it (Claude-style) rather than living
104
+ in a separate hard-bordered bar. */
105
+ #app {
106
+ display: grid;
107
+ grid-template-columns: 300px 1fr;
108
+ grid-template-rows: 60px 1fr;
109
+ grid-template-areas:
110
+ "header header"
111
+ "sidebar main";
112
+ height: 100vh; width: 100vw;
113
+ }
114
+ #app.sidebar-closed { grid-template-columns: 0 1fr; }
115
+ #app.sidebar-closed #sidebar { transform: translateX(-100%); }
116
+
117
+ /* ── Header / brand bar ────────────────────────────────────────────────
118
+ Transparent over the canvas with only a hairline divider — feels like
119
+ part of the page, not a chrome bar. */
120
+ #header {
121
+ grid-area: header;
122
+ display: flex; align-items: center; gap: 12px;
123
+ padding: 0 20px;
124
+ background: var(--bg-soft);
125
+ border-bottom: 1px solid var(--border);
126
+ position: relative; z-index: 20;
127
+ }
128
+ .brand { display: flex; align-items: center; gap: 12px; cursor: default; }
129
+ /* The logo is a wide horizontal wordmark (Tencent + Hy3). Size it by
130
+ height and let width follow the natural aspect ratio so it never
131
+ gets smushed into a square. */
132
+ .brand-logo {
133
+ height: 26px; width: auto;
134
+ object-fit: contain;
135
+ display: block;
136
+ border-radius: 4px;
137
+ }
138
+ .brand-name { font-size: 16px; font-weight: 650; color: var(--text-strong); letter-spacing: -0.01em; }
139
+ .brand-sep { color: var(--border-strong); font-weight: 300; }
140
+ .brand-tag { font-size: 13px; color: var(--text-muted); font-weight: 500; }
141
+ .brand-badge {
142
+ margin-left: 4px; font-size: 11px; font-weight: 600;
143
+ color: var(--tc-blue); background: var(--tc-blue-soft);
144
+ padding: 2px 9px; border-radius: 999px; letter-spacing: 0.02em;
145
+ }
146
+ .header-spacer { flex: 1; }
147
+ .icon-btn {
148
+ width: 36px; height: 36px; border-radius: 9px;
149
+ border: 1px solid transparent; background: transparent; color: var(--text-muted);
150
+ display: inline-flex; align-items: center; justify-content: center;
151
+ cursor: pointer; transition: background 0.15s ease, color 0.15s ease; padding: 0;
152
+ }
153
+ .icon-btn:hover { background: var(--bg-muted); color: var(--text); }
154
+ .icon-btn svg { width: 18px; height: 18px; }
155
+
156
+ /* ── Sidebar ──────────────────────────────────────────────────────────── */
157
+ #sidebar {
158
+ grid-area: sidebar;
159
+ background: var(--sidebar-bg);
160
+ border-right: 1px solid var(--border);
161
+ overflow-y: auto; overflow-x: hidden;
162
+ transition: transform 0.22s ease;
163
+ padding: 18px 18px 28px;
164
+ display: flex; flex-direction: column; gap: 16px;
165
+ }
166
+ .sidebar-notice {
167
+ font-size: 12px; line-height: 1.55; color: var(--text-muted);
168
+ background: var(--accent-soft); border: 1px solid transparent;
169
+ border-radius: var(--radius-sm); padding: 11px 13px;
170
+ }
171
+ .field { display: flex; flex-direction: column; gap: 7px; }
172
+ .field > label {
173
+ font-size: 11.5px; font-weight: 600; color: var(--text-muted);
174
+ text-transform: none; letter-spacing: 0.01em;
175
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
176
+ }
177
+ .field .info {
178
+ font-size: 11px; font-weight: 400; color: var(--text-muted);
179
+ margin-top: -3px; line-height: 1.45; opacity: 0.85;
180
+ }
181
+ .field input[type="text"], .field textarea, .field select {
182
+ width: 100%; padding: 9px 12px;
183
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
184
+ background: var(--bg-soft); color: var(--text); font-size: 14px;
185
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
186
+ }
187
+ .field textarea { resize: vertical; min-height: 64px; font-family: inherit; line-height: 1.5; }
188
+ .field input:focus, .field textarea:focus, .field select:focus {
189
+ outline: none; border-color: var(--tc-blue); box-shadow: var(--ring);
190
+ }
191
+ .field select { appearance: none; cursor: pointer;
192
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8580' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
193
+ background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
194
+ }
195
+ .range-row { display: flex; align-items: center; gap: 10px; }
196
+ .range-row input[type="range"] { flex: 1; accent-color: var(--tc-blue); }
197
+ .range-num {
198
+ width: 58px; padding: 5px 8px; text-align: center;
199
+ border: 1px solid var(--border); border-radius: 7px;
200
+ background: var(--bg-soft); color: var(--text); font-size: 12px;
201
+ font-variant-numeric: tabular-nums;
202
+ }
203
+ .range-num:focus { outline: none; border-color: var(--tc-blue); box-shadow: var(--ring); }
204
+ .range-row input[type="range"] { -webkit-appearance: none; appearance: none; height: 4px; background: var(--border); border-radius: 2px; }
205
+ .range-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--tc-blue); cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
206
+ .range-row input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: none; border-radius: 50%; background: var(--tc-blue); cursor: pointer; }
207
+ .check-row {
208
+ display: flex; align-items: center; gap: 8px; cursor: pointer;
209
+ font-size: 12px; color: var(--text-muted); user-select: none; margin-top: 2px;
210
+ }
211
+ .check-row input { accent-color: var(--tc-blue); width: 14px; height: 14px; }
212
+
213
+ .accordion { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-soft); }
214
+ .accordion > summary {
215
+ list-style: none; cursor: pointer; padding: 11px 13px;
216
+ font-size: 13px; font-weight: 600; color: var(--text);
217
+ display: flex; align-items: center; justify-content: space-between;
218
+ }
219
+ .accordion > summary::-webkit-details-marker { display: none; }
220
+ .accordion > summary::after { content: "⌄"; color: var(--text-muted); transition: transform 0.15s ease; }
221
+ .accordion[open] > summary::after { transform: rotate(180deg); }
222
+ .accordion > .accordion-body { padding: 13px; display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--border); }
223
+ .accordion textarea { width: 100%; min-height: 180px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; resize: vertical; }
224
+ .tiny-btn {
225
+ align-self: flex-start; padding: 7px 15px; font-size: 12px; font-weight: 500;
226
+ border: 1px solid var(--border); border-radius: 8px; background: var(--bg-soft);
227
+ color: var(--text); cursor: pointer; transition: all 0.15s ease;
228
+ }
229
+ .tiny-btn:hover { border-color: var(--tc-blue); color: var(--tc-blue); background: var(--tc-blue-060); }
230
+ .fn-msg { font-size: 12px; line-height: 1.5; min-height: 18px; }
231
+ .fn-msg.ok { color: var(--tc-teal); }
232
+ .fn-msg.err { color: var(--tc-red); }
233
+
234
+ /* ── Main chat surface ──────────────────────────────────────────────────
235
+ The warm canvas. Conversation is centered in a narrow column with
236
+ generous vertical rhythm; assistant turns are plain text (no bubble),
237
+ user turns are soft pills. The composer floats at the bottom on a
238
+ gradient fade so messages scroll under it gracefully. */
239
+ #main {
240
+ grid-area: main; position: relative; background: var(--bg);
241
+ overflow: hidden; display: flex; flex-direction: column;
242
+ }
243
+ #chat {
244
+ flex: 1; overflow-y: auto; overflow-x: hidden;
245
+ padding: 28px clamp(16px, 5vw, 48px) 220px; /* bottom room for floating composer */
246
+ scroll-behavior: auto;
247
+ }
248
+ /* Center the conversation in a narrow column. */
249
+ #chat > .col,
250
+ #chat > .message-row { max-width: var(--col-max); margin-left: auto; margin-right: auto; width: 100%; }
251
+ .message-row { display: block; width: 100%; margin: 0; }
252
+ .message-row.user-row { display: flex; justify-content: flex-end; margin: 22px 0 4px; }
253
+ .message-row.bot-row { margin: 0 0 30px; }
254
+ .message { max-width: 100%; }
255
+ .message.user {
256
+ background: var(--bg-elev); color: var(--text-strong);
257
+ border: 1px solid var(--border);
258
+ border-radius: var(--radius-lg); padding: 11px 18px;
259
+ max-width: min(560px, 82%); box-shadow: var(--shadow-sm);
260
+ font-weight: 450;
261
+ }
262
+ .message.user .hy-user-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
263
+ .message.bot { background: transparent; padding: 0; max-width: 100%; }
264
+ .message.bot .markdown { font-size: 16px; line-height: 1.7; color: var(--text); letter-spacing: 0.005em; }
265
+ .message.bot p { margin: 0.85em 0; }
266
+ .message.bot p:first-child { margin-top: 0; }
267
+ .message.bot p:last-child { margin-bottom: 0; }
268
+ .message.bot strong, .message.bot b { font-weight: 650; color: var(--text-strong); }
269
+ .message.bot ul, .message.bot ol { padding-left: 1.5em; margin: 0.6em 0; }
270
+ .message.bot li { margin: 0.35em 0; line-height: 1.7; }
271
+ .message.bot h1 { font-size: 23px; font-weight: 700; color: var(--text-strong); margin: 1.1em 0 0.5em; letter-spacing: -0.01em; }
272
+ .message.bot h2 { font-size: 20px; font-weight: 700; color: var(--text-strong); margin: 1em 0 0.45em; letter-spacing: -0.01em; }
273
+ .message.bot h3 { font-size: 17px; font-weight: 650; color: var(--text-strong); margin: 0.9em 0 0.4em; }
274
+ .message.bot h4 { font-size: 15px; font-weight: 650; color: var(--text); margin: 0.8em 0 0.35em; }
275
+ .message.bot a { color: var(--tc-blue); text-underline-offset: 2px; }
276
+ .message.bot blockquote { border-left: 3px solid var(--border-strong); margin: 0.9em 0; padding: 2px 16px; color: var(--text-muted); }
277
+ .message.bot hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
278
+ .message.bot table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.9em 0; font-size: 14px; }
279
+ .message.bot th, .message.bot td { border: 1px solid var(--border); padding: 8px 14px; text-align: left; vertical-align: top; }
280
+ .message.bot th { background: var(--bg-muted); font-weight: 600; }
281
+ .message.bot code { font-family: var(--font-mono); font-size: 0.88em; }
282
+ .message.bot :not(pre) > code { background: var(--bg-muted); padding: 2px 6px; border-radius: 5px; color: var(--text); }
283
+ .hy-codeblock { margin: 1em 0; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--bg-soft); }
284
+ .hy-codeblock-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 9px 7px 15px; background: var(--bg-muted); border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1; color: var(--text-muted); user-select: none; }
285
+ .hy-codeblock-lang { text-transform: lowercase; letter-spacing: 0.02em; font-weight: 500; }
286
+ .hy-codeblock-lang:empty::before { content: "text"; opacity: 0.7; }
287
+ .hy-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; padding: 13px 16px; overflow-x: auto; font-size: 13px; line-height: 1.65; }
288
+ .code-copy-btn { flex-shrink: 0; width: 26px; height: 26px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: inherit; background: transparent; border: none; border-radius: 7px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
289
+ .code-copy-btn svg { display: block; width: 14px; height: 14px; }
290
+ .code-copy-btn:hover { background: var(--tc-blue-060); color: var(--tc-blue); }
291
+ .code-copy-btn.copied, .code-copy-btn.copied:hover { color: var(--tc-teal); background: transparent; }
292
+ .html-preview-btn { display: inline-flex; align-items: center; background: none; border: none; padding: 2px 0; margin: 2px 0 6px; cursor: pointer; font-size: 0; line-height: 1; transition: opacity 0.15s ease; }
293
+ .html-preview-btn::before { content: "▶ html preview"; font-size: 12px; color: var(--tc-blue); font-weight: 400; font-family: var(--font-sans); }
294
+ .html-preview-btn:hover { opacity: 0.7; }
295
+
296
+ /* ── Reasoning (thinking) block ───────────────────────────────────────── */
297
+ .thinking-block { background: none; border: none; padding: 0; margin: 6px 0 12px; }
298
+ .thinking-block summary { cursor: pointer; font-size: 13px; font-weight: 400; color: var(--text-muted); padding: 0; list-style: none; display: inline-flex; align-items: center; gap: 4px; }
299
+ .thinking-block summary:hover { color: var(--text); }
300
+ .thinking-block summary::-webkit-details-marker { display: none; }
301
+ .thinking-block summary::after { content: " ›"; font-size: 13px; color: var(--border-strong); }
302
+ .thinking-block[open] summary::after { content: " ⌄"; }
303
+ .thinking-block[open] summary { margin-bottom: 8px; }
304
+ .thinking-block[open] > :not(summary) { font-size: 13px; color: var(--text-muted); line-height: 1.7; padding: 12px 16px; background: var(--bg-soft); border-radius: 8px; max-height: 300px; overflow-y: auto; border-left: 2px solid var(--tc-blue); white-space: pre-wrap; word-break: break-word; }
305
+
306
+ /* ── Tool-call display block ─────────────────────────────────────────── */
307
+ .hy-tool-calls { margin-top: 10px; }
308
+ .hy-tool-call + .hy-tool-call { margin-top: 8px; }
309
+
310
+ /* ── Typing indicator ─────────────────────────────────────────────────── */
311
+ .typing-indicator { display: flex; align-items: center; gap: 5px; padding: 4px 0; height: 24px; }
312
+ .typing-indicator span { width: 8px; height: 8px; background: var(--text-muted); border-radius: 50%; animation: typing-bounce 1.4s infinite ease-in-out both; }
313
+ .typing-indicator span:nth-child(1) { animation-delay: 0s; }
314
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
315
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
316
+ @keyframes typing-bounce { 0%, 80%, 100% { transform: scale(0.5); opacity: 0.35; } 40% { transform: scale(1); opacity: 1; } }
317
+
318
+ /* ── Empty-state examples overlay ─────────────────────────────────────── */
319
+ #examples-overlay {
320
+ position: absolute; inset: 0; z-index: 5;
321
+ display: flex; align-items: center; justify-content: center;
322
+ background: var(--bg); overflow-y: auto; padding: 40px 20px 160px;
323
+ transition: opacity 0.25s ease;
324
+ }
325
+ #examples-overlay.hidden { opacity: 0; pointer-events: none; }
326
+ .examples-wrapper { display: flex; flex-direction: column; align-items: center; max-width: 1020px; }
327
+ .examples-hero { display: flex; align-items: center; justify-content: center; gap: 14px; margin-bottom: 22px; }
328
+ .examples-hero img { width: auto; height: 56px; border-radius: 8px; object-fit: contain; }
329
+ .examples-heading { font-size: 28px; font-weight: 650; color: var(--text-strong); margin: 0 0 30px; letter-spacing: -0.02em; text-align: center; }
330
+ .examples-grid { display: grid; grid-template-columns: minmax(0, 460px) minmax(0, 460px); column-gap: 20px; row-gap: 14px; justify-content: center; width: min(1020px, 100%); }
331
+ .example-btn:nth-child(1), .example-btn:nth-child(4) { grid-column: 1 / span 2; justify-self: center; }
332
+ .example-btn:nth-child(2) { grid-column: 1; justify-self: end; }
333
+ .example-btn:nth-child(3) { grid-column: 2; justify-self: start; }
334
+ .example-btn {
335
+ display: flex; align-items: center; justify-content: center;
336
+ width: 460px; height: 72px; overflow: hidden;
337
+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius);
338
+ padding: 14px 20px; font-size: 14px; line-height: 1.5; color: var(--text);
339
+ cursor: pointer; user-select: none; text-align: center;
340
+ transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, background 0.2s ease;
341
+ }
342
+ .example-btn:hover { border-color: var(--border-strong); color: var(--text-strong); box-shadow: var(--shadow-md); transform: translateY(-1px); background: var(--bg-elev); }
343
+ .example-btn-text { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; width: 100%; }
344
+ @media (max-width: 1020px) {
345
+ .examples-grid { grid-template-columns: minmax(0, 1fr); }
346
+ .example-btn:nth-child(n) { grid-column: 1; justify-self: center; }
347
+ .example-btn { width: 100%; max-width: 520px; }
348
+ }
349
+
350
+ /* ── Composer (floating, centered pill) ────────────────────────────────
351
+ Sits at the bottom of the chat canvas, centered to the conversation
352
+ column, with a gradient fade above it so content scrolls under it
353
+ gracefully — the Claude.ai feel. */
354
+ #composer {
355
+ position: absolute; left: 0; right: 0; bottom: 0;
356
+ display: flex; align-items: flex-end; justify-content: center; gap: 10px;
357
+ padding: 0 clamp(16px, 5vw, 48px) 20px;
358
+ z-index: 10;
359
+ background: linear-gradient(to top, var(--bg) 52%, rgba(247,246,243,0));
360
+ pointer-events: none; /* let the fade be click-through … */
361
+ }
362
+ #composer > * { pointer-events: auto; } /* … but not the controls */
363
+ html.dark #composer { background: linear-gradient(to top, var(--bg) 52%, rgba(26,26,26,0)); }
364
+ .composer-inner {
365
+ display: flex; align-items: flex-end; gap: 8px; width: 100%; max-width: var(--col-max);
366
+ background: var(--bg-elev); border: 1px solid var(--border);
367
+ border-radius: var(--radius-xl); padding: 8px 8px 8px 18px;
368
+ box-shadow: var(--shadow-md); transition: border-color 0.15s ease, box-shadow 0.15s ease;
369
+ }
370
+ .composer-inner:focus-within { border-color: var(--border-strong); box-shadow: var(--shadow-md), var(--ring); }
371
+ #composer textarea {
372
+ flex: 1; min-height: 44px; max-height: 200px;
373
+ resize: none; overflow-y: auto;
374
+ border: none; border-radius: 0;
375
+ padding: 12px 0; font-size: 15.5px; line-height: 1.55; background: transparent;
376
+ color: var(--text); box-shadow: none; transition: none;
377
+ }
378
+ #composer textarea:focus { outline: none; box-shadow: none; }
379
+ #new-chat-btn {
380
+ width: 40px; height: 40px; border-radius: 50%; padding: 0;
381
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
382
+ cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--text-muted);
383
+ transition: background 0.15s ease, color 0.15s ease;
384
+ }
385
+ #new-chat-btn:hover { background: var(--bg-muted); color: var(--text); }
386
+ #new-chat-btn svg { width: 19px; height: 19px; }
387
+ #send-btn {
388
+ width: 40px; height: 40px; border-radius: 50%; padding: 0;
389
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
390
+ cursor: pointer; border: none; background: var(--tc-blue); color: #fff;
391
+ transition: background 0.15s ease, transform 0.1s ease;
392
+ }
393
+ #send-btn:hover { background: var(--tc-blue-hover); }
394
+ #send-btn:active { transform: scale(0.95); }
395
+ #send-btn:disabled { background: var(--border-strong); cursor: not-allowed; opacity: 0.6; }
396
+ #send-btn svg { width: 18px; height: 18px; }
397
+
398
+ /* ── Tool-call input panel ───────────────────────────────────────────── */
399
+ #tool-area {
400
+ position: absolute; bottom: 104px; left: 50%; transform: translateX(-50%);
401
+ width: min(640px, 90%); z-index: 30;
402
+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
403
+ padding: 16px; box-shadow: var(--shadow-lg); display: none; flex-direction: column; gap: 10px;
404
+ }
405
+ #tool-area.visible { display: flex; }
406
+ #tool-area .tool-header { font-size: 13px; font-weight: 600; color: var(--text); }
407
+ #tool-area .tool-markdown { font-size: 13px; line-height: 1.6; color: var(--text); max-height: 160px; overflow-y: auto; background: var(--bg-soft); border-radius: 8px; padding: 10px 12px; }
408
+ #tool-area .tool-markdown p { margin: 0.4em 0; }
409
+ #tool-area .tool-markdown code { background: var(--bg-muted); padding: 1px 5px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; }
410
+ #tool-area .tool-markdown pre { background: var(--bg-muted); border-radius: 6px; padding: 8px 10px; overflow-x: auto; margin: 0.4em 0; }
411
+ #tool-area .tool-markdown pre code { background: none; padding: 0; }
412
+ #tool-area .tool-markdown strong { font-weight: 600; }
413
+ #tool-result-input {
414
+ width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
415
+ background: var(--bg); color: var(--text); font-size: 14px; font-family: var(--font-mono);
416
+ }
417
+ #tool-result-input:focus { outline: none; border-color: var(--tc-blue); box-shadow: 0 0 0 3px var(--tc-blue-120); }
418
+ #tool-submit-btn { align-self: flex-end; padding: 8px 18px; border: none; border-radius: 8px; background: var(--tc-blue); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
419
+ #tool-submit-btn:hover { background: var(--tc-blue-hover); }
420
+
421
+ /* ── HTML preview modal ───────────────────────────────────────────────── */
422
+ .html-preview-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease; }
423
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
424
+ .html-preview-modal { width: 90vw; height: 85vh; background: var(--bg); border-radius: var(--radius); box-shadow: var(--shadow-lg); display: flex; flex-direction: column; overflow: hidden; animation: modalSlideUp 0.25s ease; }
425
+ @keyframes modalSlideUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
426
+ .html-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-soft); flex-shrink: 0; }
427
+ .html-preview-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
428
+ .html-preview-title::before { content: "⬡"; color: var(--tc-blue); }
429
+ .html-preview-close { background: none; border: none; font-size: 20px; color: var(--text-muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; line-height: 1; transition: all 0.15s ease; }
430
+ .html-preview-close:hover { background: var(--tc-blue-060); color: var(--tc-red); }
431
+ .html-preview-iframe { flex: 1; border: none; width: 100%; background: var(--bg); }
432
+
433
+ /* ── Toast ────────────────────────────────────────────────────────────── */
434
+ #toast-host { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
435
+ .toast { background: #1f2937; color: #fff; padding: 10px 16px; border-radius: 8px; box-shadow: var(--shadow-lg); font-size: 14px; max-width: min(92vw, 520px); line-height: 1.4; opacity: 0; transition: opacity 180ms ease; }
436
+ .toast.show { opacity: 1; }
437
+ .toast.error { background: var(--tc-red); }
438
+
439
+ /* ── Sidebar toggle ─────────────────────────────────────────────────────
440
+ One class (``#app.sidebar-closed``) drives collapse on every breakpoint:
441
+ desktop collapses the grid column to 0; mobile slides the fixed overlay
442
+ out. The header toggle is always visible so users can re-open it. */
443
+ #sidebar-toggle { display: inline-flex; }
444
+ @media (max-width: 860px) {
445
+ #app { grid-template-columns: 1fr; grid-template-areas: "header" "main"; }
446
+ #sidebar {
447
+ position: fixed; top: 60px; bottom: 0; left: 0; width: 86%; max-width: 360px;
448
+ z-index: 50; box-shadow: var(--shadow-lg);
449
+ }
450
+ /* On mobile the sidebar is an overlay; show it unless explicitly closed. */
451
+ #app.sidebar-closed #sidebar { transform: translateX(-100%); }
452
+ .examples-hero img { height: 46px; }
453
+ .examples-heading { font-size: 22px; }
454
+ }
455
+
456
+ /* ── Scrollbars (slim & subtle) ──────────────────────────────────────── */
457
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
458
+ ::-webkit-scrollbar-track { background: transparent; }
459
+ ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
460
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: padding-box; }
461
+ * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
462
+
463
+ /* KaTeX: keep its own math fonts and reset inherited typography so SVG
464
+ stretchy operators don't get distorted by the bubble's line-height. */
465
+ .message.bot .katex { font: normal 1.21em KaTeX_Main, "Times New Roman", serif !important; text-indent: 0 !important; }
466
+ .message.bot .katex, .message.bot .katex *, .message.bot .katex-display, .message.bot .katex-display * { letter-spacing: normal !important; word-spacing: normal !important; line-height: 1.2 !important; }
467
+ .message.bot .katex, .message.bot .katex-display { color: var(--text) !important; }
468
+ .message.bot .katex svg { fill: currentColor; stroke: none; }
469
+ .message.bot .katex-display { margin: 1em 0; padding: 0.4em 0; overflow-x: auto; overflow-y: hidden; }
470
+ .message.bot .katex-display > .katex { text-align: center; }
471
+ </style>
472
+ </head>
473
+ <body>
474
+ <div id="app" class="sidebar-closed">
475
+ <!-- ── Header ────────────────────────────────────────────────────── -->
476
+ <header id="header">
477
+ <button id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Toggle sidebar">
478
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
479
+ </button>
480
+ <div class="brand">
481
+ <img class="brand-logo" src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="Tencent Hy3 logo" />
482
+ <span class="brand-name">Hy3</span>
483
+ <span class="brand-sep">·</span>
484
+ <span class="brand-tag">Chat Demo</span>
485
+ <span class="brand-badge">Tencent</span>
486
+ </div>
487
+ <div class="header-spacer"></div>
488
+ <button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
489
+ <svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
490
+ </button>
491
+ </header>
492
+
493
+ <!-- ── Sidebar ──────────────────────────────────────────────────── -->
494
+ <aside id="sidebar">
495
+ <p class="sidebar-notice" id="sidebar-notice"></p>
496
+
497
+ <div class="field">
498
+ <label data-i18n="sidebar.think_level"></label>
499
+ <select id="think-level">
500
+ <option value="high">high</option>
501
+ <option value="low">low</option>
502
+ <option value="no_think">no_think</option>
503
+ </select>
504
+ <span class="info" data-i18n="sidebar.think_level.info"></span>
505
+ </div>
506
+
507
+ <div class="field">
508
+ <label data-i18n="sidebar.system_prompt"></label>
509
+ <textarea id="system-prompt" rows="3" data-i18n-ph="sidebar.system_prompt.placeholder"></textarea>
510
+ </div>
511
+
512
+ <div class="field">
513
+ <label data-i18n="sidebar.temperature"></label>
514
+ <div class="range-row">
515
+ <input type="range" id="temperature" min="0" max="1" step="0.01" value="0.9" disabled />
516
+ <input type="number" class="range-num" id="temperature-num" value="0.9" min="0" max="1" step="0.01" disabled />
517
+ </div>
518
+ <label class="check-row"><input type="checkbox" id="temperature-use-default" checked /><span data-i18n="sidebar.temperature.use_default"></span></label>
519
+ <span class="info" data-i18n="sidebar.temperature.info"></span>
520
+ </div>
521
+
522
+ <div class="field">
523
+ <label data-i18n="sidebar.max_tokens"></label>
524
+ <div class="range-row">
525
+ <input type="range" id="max-tokens" min="0" max="65536" step="1" value="0" />
526
+ <input type="number" class="range-num" id="max-tokens-num" value="0" min="0" max="65536" step="1" />
527
+ </div>
528
+ <span class="info" data-i18n="sidebar.max_tokens.info"></span>
529
+ </div>
530
+
531
+ <div class="field">
532
+ <label data-i18n="sidebar.top_p"></label>
533
+ <div class="range-row">
534
+ <input type="range" id="top-p" min="0" max="1" step="0.01" value="0" />
535
+ <input type="number" class="range-num" id="top-p-num" value="0" min="0" max="1" step="0.01" />
536
+ </div>
537
+ <span class="info" data-i18n="sidebar.top_p.info"></span>
538
+ </div>
539
+
540
+ <div class="field">
541
+ <label data-i18n="sidebar.preserved_thinking"></label>
542
+ <label class="check-row"><input type="checkbox" id="preserved-thinking-use-default" checked /><span data-i18n="sidebar.preserved_thinking.use_default"></span></label>
543
+ <label class="check-row"><input type="checkbox" id="preserved-thinking" /><span data-i18n="sidebar.preserved_thinking" style="font-weight:500;color:var(--text)"></span></label>
544
+ <span class="info" data-i18n="sidebar.preserved_thinking.use_default.info"></span>
545
+ </div>
546
+
547
+ <details class="accordion">
548
+ <summary data-i18n="sidebar.functions"></summary>
549
+ <div class="accordion-body">
550
+ <textarea id="functions-json" spellcheck="false" placeholder=""></textarea>
551
+ <button class="tiny-btn" id="validate-fn-btn" data-i18n="sidebar.validate_btn"></button>
552
+ <div class="fn-msg" id="fn-msg"></div>
553
+ </div>
554
+ </details>
555
+ </aside>
556
+
557
+ <!-- ── Main chat ────────────────────────────────────────────────── -->
558
+ <main id="main">
559
+ <div id="chat" role="log" aria-live="polite"></div>
560
+
561
+ <div id="examples-overlay">
562
+ <div class="examples-wrapper">
563
+ <div class="examples-hero">
564
+ <img src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="" />
565
+ </div>
566
+ <p class="examples-heading" data-i18n="examples_heading"></p>
567
+ <div class="examples-grid" id="examples-grid"></div>
568
+ </div>
569
+ </div>
570
+
571
+ <div id="tool-area">
572
+ <div class="tool-header" id="tool-header"></div>
573
+ <div class="tool-markdown" id="tool-markdown"></div>
574
+ <input type="text" id="tool-result-input" />
575
+ <button id="tool-submit-btn" data-i18n="tool.submit"></button>
576
+ </div>
577
+ </main>
578
+
579
+ <!-- ── Composer (floating pill) ───────────────────────────────��─── -->
580
+ <div id="composer">
581
+ <div class="composer-inner">
582
+ <button id="new-chat-btn" title="New chat" aria-label="New chat">
583
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
584
+ </button>
585
+ <textarea id="msg-input" rows="1" data-i18n-ph="msg_placeholder"></textarea>
586
+ <button id="send-btn" title="Send" aria-label="Send">
587
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
588
+ </button>
589
+ </div>
590
+ </div>
591
+ </div>
592
+
593
+ <div id="toast-host"></div>
594
+
595
+ <script type="module">
596
+ /* ───────────────────────────────────────────────────────────────────
597
+ Hy3 Tencent-branded frontend.
598
+
599
+ Goal: only the UI + transport changed from the gr.Blocks app. The chat
600
+ behaviour is identical, so this file is a faithful port of the old
601
+ static/chat.js (the delta-op renderer: append-only bubbles, frozen /
602
+ streaming split so KaTeX+marked+hljs run once per closed block, seq
603
+ de-dup, epoch staleness, preprocess_latex'd content deltas, tool-call
604
+ markdown) plus static/app.js (the busy-marker watchdog, follow-the-tail
605
+ scroll lock with user-gesture pause, examples overlay, code-block
606
+ chrome with copy + html preview).
607
+
608
+ The ONLY adaptation is the transport: instead of a MutationObserver
609
+ watching a hidden #hy-chat-delta gr.HTML component, we consume the
610
+ @gradio/client async-iterator frames from server.py and feed each JSON
611
+ delta payload straight into the same applyDelta() the old renderer used.
612
+ The busy marker is no longer a hidden gr.HTML span; it's a client-side
613
+ flag toggled by the stream's first frame + completion + the watchdog.
614
+ ─────────────────────────────────────────────────────────────────── */
615
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.7.1/dist/index.min.js";
616
+
617
+ (() => {
618
+ "use strict";
619
+ if (window.__HY_APP_INIT) return;
620
+ window.__HY_APP_INIT = true;
621
+
622
+ // ─── i18n + examples (loaded from /config endpoint) ──────────────
623
+ // Full English default table so every label is populated on first paint
624
+ // — before /config round-trips. /config then overrides with the active
625
+ // locale (en/zh). Keep these in sync with i18n.py.
626
+ const I18N = {
627
+ "title": "Hy3 Chat Demo",
628
+ "title.notice_html": "The current demo version is offline; full-featured capabilities will be available on the Tencent HY official website.",
629
+ "examples_heading": "What can I help you with?",
630
+ "msg_placeholder": "Type a message...",
631
+ "sidebar.think_level": "Think level",
632
+ "sidebar.think_level.info": "Control reasoning depth",
633
+ "sidebar.system_prompt": "System prompt",
634
+ "sidebar.system_prompt.placeholder": "You are a helpful AI assistant...",
635
+ "sidebar.temperature": "Temperature",
636
+ "sidebar.temperature.info": "Higher values produce more random outputs",
637
+ "sidebar.temperature.use_default": "Use model default",
638
+ "sidebar.max_tokens": "Max output tokens",
639
+ "sidebar.max_tokens.info": "Maximum tokens per response. Set to 0 to use the model's default.",
640
+ "sidebar.top_p": "Top P",
641
+ "sidebar.top_p.info": "Nucleus sampling probability threshold. Set to 0 to use the model's default.",
642
+ "sidebar.preserved_thinking": "Preserved thinking",
643
+ "sidebar.preserved_thinking.info": "When enabled, the model can retain reasoning content from previous assistant turns in the context, helping maintain reasoning continuity and conversation integrity while improving model performance.",
644
+ "sidebar.preserved_thinking.use_default": "Preserved thinking — Use model default",
645
+ "sidebar.preserved_thinking.use_default.info": "We recommend using \"Use model default\", which automatically enables this option for tool call workflows and disables it for pure text conversations.",
646
+ "sidebar.functions": "🔧 Functions",
647
+ "sidebar.functions.label": "Function definitions (JSON array)",
648
+ "sidebar.validate_btn": "Validate & Format",
649
+ "tool.result_label": "Function result",
650
+ "tool.result_placeholder": "Enter function return value... (Enter to submit)",
651
+ "tool.submit": "Submit",
652
+ "tool.call_header": "Function call ({i}/{n})",
653
+ "tool.call_label": "🔧 Call function",
654
+ "display.thinking": "Thinking...",
655
+ "display.thinking_done": "Thinking complete",
656
+ "display.code_copy": "Copy",
657
+ "display.code_copied": "Copied",
658
+ "warn.empty_msg": "Please enter a message",
659
+ "warn.invalid_fn_json": "Invalid function JSON, please fix or clear before sending: {err}",
660
+ "warn.request_failed": "Request failed, please retry or adjust parameters",
661
+ "warn.model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
662
+ "info.new_chat": "Start a new chat",
663
+ // short aliases used directly in code below
664
+ "thinking": "Thinking...",
665
+ "thinking_done": "Thinking complete",
666
+ "code_copy": "Copy",
667
+ "code_copied": "Copied",
668
+ "model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
669
+ "empty_msg": "Please enter a message",
670
+ "request_failed": "Request failed, please retry or adjust parameters",
671
+ "new_chat": "Start a new chat",
672
+ };
673
+ let EXAMPLES = [];
674
+ let TOOL_CALL_LABEL = "🔧 Call function";
675
+
676
+ // ─── session id (fresh per page load) ─────────────────────────────
677
+ // Minting a NEW id on every load (rather than reusing one from
678
+ // sessionStorage) makes a browser refresh start a blank conversation:
679
+ // the server keys its in-memory ChatState off this id, so a fresh id
680
+ // means a fresh, empty state and the prior turn's context never leaks
681
+ // into the next message. sessionStorage used to make the id survive F5
682
+ // — which is exactly what surfaced stale history after a refresh. The
683
+ // variable persists for the lifetime of this loaded page (across
684
+ // @gradio/client reconnects), so no storage is needed.
685
+ let SESSION_ID = "s-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
686
+
687
+ // ─── DOM refs ─────────────────────────────────────────────────────
688
+ const $ = (id) => document.getElementById(id);
689
+ const chatEl = $("chat");
690
+ const overlayEl = $("examples-overlay");
691
+ const msgInput = $("msg-input");
692
+ const sendBtn = $("send-btn");
693
+ const newChatBtn = $("new-chat-btn");
694
+ const toolArea = $("tool-area");
695
+ const toolMarkdown = $("tool-markdown");
696
+ const toolResultInput = $("tool-result-input");
697
+ const toolSubmitBtn = $("tool-submit-btn");
698
+
699
+ // ─── streaming state ──────────────────────────────────────────────
700
+ let isStreaming = false;
701
+ let currentJob = null; // the active async-iterator job
702
+ let pendingToolCalls = []; // tool calls awaiting user results
703
+ let currentEpoch = null; // staleness guard for in-flight frames
704
+ // How many tool results the user has submitted in the current tool-call
705
+ // round, so the panel header shows "Function call (2/3)" correctly.
706
+ let toolResultsSubmitted = 0;
707
+ let toolRoundTotal = 0;
708
+
709
+ /* ─── apply i18n to the DOM ──────────────────────────────────────── */
710
+ function applyI18n(table) {
711
+ Object.assign(I18N, table || {});
712
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
713
+ const key = el.getAttribute("data-i18n");
714
+ if (I18N[key] != null) el.textContent = I18N[key];
715
+ });
716
+ document.querySelectorAll("[data-i18n-ph]").forEach((el) => {
717
+ const key = el.getAttribute("data-i18n-ph");
718
+ if (I18N[key] != null) el.placeholder = I18N[key];
719
+ });
720
+ document.title = (I18N["title"] || "Hy3") + " · Tencent";
721
+ $("sidebar-notice").textContent = I18N["title.notice_html"] || "";
722
+ $("msg-input").placeholder = I18N["msg_placeholder"] || "Type a message...";
723
+ if (I18N["tool.call_label"]) TOOL_CALL_LABEL = I18N["tool.call_label"];
724
+ }
725
+
726
+ /* ─── theme toggle ───────────────────────────────────────────────── */
727
+ function setTheme(dark) {
728
+ document.documentElement.classList.toggle("dark", dark);
729
+ localStorage.setItem("hy3-theme", dark ? "dark" : "light");
730
+ const lightCss = document.getElementById("hljs-light-css");
731
+ const darkCss = document.getElementById("hljs-dark-css");
732
+ if (lightCss && darkCss) {
733
+ lightCss.disabled = dark;
734
+ darkCss.disabled = !dark;
735
+ }
736
+ }
737
+ const savedTheme = localStorage.getItem("hy3-theme");
738
+ setTheme(savedTheme ? savedTheme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches);
739
+ $("theme-btn").addEventListener("click", () =>
740
+ setTheme(!document.documentElement.classList.contains("dark"))
741
+ );
742
+
743
+ /* ─── sidebar toggle ───────────────────────────────────────────────
744
+ One class (``sidebar-closed``) drives collapse on every breakpoint;
745
+ the header button flips it, so the same control opens/closes the
746
+ sidebar on both desktop and mobile. It starts closed by default
747
+ (see the ``sidebar-closed`` class on #app). */
748
+ $("sidebar-toggle").addEventListener("click", () => {
749
+ $("app").classList.toggle("sidebar-closed");
750
+ });
751
+
752
+ /* ─── markdown + math rendering ─────────────────────────────���──────
753
+ Ported from static/chat.js: two-stage pipeline so marked doesn't
754
+ eat the LaTeX before KaTeX can see it. */
755
+ const MARKED_OPTS = { gfm: true, breaks: false };
756
+ const KATEX_DELIMS = [
757
+ { left: "$$", right: "$$", display: true },
758
+ { left: "$", right: "$", display: false },
759
+ ];
760
+
761
+ function normalizeMathDelims(text) {
762
+ if (!text) return text;
763
+ let out = "", i = 0; const n = text.length;
764
+ while (i < n) {
765
+ const ch = text[i];
766
+ if ((ch === "`" && text.startsWith("```", i)) || (ch === "~" && text.startsWith("~~~", i))) {
767
+ const marker = text.substr(i, 3); out += marker; i += 3;
768
+ const close = text.indexOf(marker, i);
769
+ if (close < 0) { out += text.slice(i); return out; }
770
+ out += text.slice(i, close + 3); i = close + 3; continue;
771
+ }
772
+ if (ch === "`") {
773
+ let runLen = 1; while (i + runLen < n && text[i + runLen] === "`") runLen++;
774
+ const open = text.substr(i, runLen); out += open; i += runLen;
775
+ const close = text.indexOf(open, i);
776
+ if (close < 0) { out += text.slice(i); return out; }
777
+ out += text.slice(i, close + runLen); i = close + runLen; continue;
778
+ }
779
+ if (ch === "\\") {
780
+ if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
781
+ if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
782
+ if (text[i + 1] === "(") { out += "$"; i += 2; continue; }
783
+ if (text[i + 1] === ")") { out += "$"; i += 2; continue; }
784
+ }
785
+ out += ch; i++;
786
+ }
787
+ return out;
788
+ }
789
+ const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;
790
+ function protectMath(text) {
791
+ const stash = [];
792
+ const escaped = text.replace(MATH_RE, (m) => {
793
+ const idx = stash.length; stash.push(m); return "M" + idx + "";
794
+ });
795
+ return { escaped, stash };
796
+ }
797
+ function restoreMath(html, stash) {
798
+ if (!stash.length) return html;
799
+ return html.replace(/M(\d+)/g, (_, n) => stash[+n]);
800
+ }
801
+
802
+ function escapeHtml(s) {
803
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
804
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
805
+ }
806
+
807
+ function renderMarkdownInto(el, text) {
808
+ if (!text) { el.innerHTML = ""; return; }
809
+ const normalized = normalizeMathDelims(text);
810
+ const { escaped, stash } = protectMath(normalized);
811
+ const rawMd = (window.marked && window.marked.parse)
812
+ ? window.marked.parse(escaped, MARKED_OPTS) : escapeHtml(escaped);
813
+ el.innerHTML = restoreMath(rawMd, stash);
814
+
815
+ if (window.renderMathInElement) {
816
+ try {
817
+ window.renderMathInElement(el, {
818
+ delimiters: KATEX_DELIMS, throwOnError: false,
819
+ ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
820
+ });
821
+ } catch (e) { console.warn("[hy] katex:", e); }
822
+ }
823
+ if (window.hljs && window.hljs.highlightElement) {
824
+ el.querySelectorAll("pre code").forEach((c) => {
825
+ if (c.dataset.hljsDone) return;
826
+ try { window.hljs.highlightElement(c); } catch (e) {}
827
+ c.dataset.hljsDone = "1";
828
+ });
829
+ }
830
+ decorateCodeBlocks(el);
831
+ }
832
+
833
+ /* ─── code-block chrome (copy + html preview) ───────────────────── */
834
+ const COPY_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
835
+ const CHECK_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 12 10 18 20 6"/></svg>';
836
+ const HTML_TAG_RE = /<\/(div|body|html|section|main|head|style|script|p|h[1-6]|span|table|form|ul|ol|nav|header|footer|article)>/i;
837
+ function looksLikeHtml(text) {
838
+ const t = text.trim(); if (!t) return false;
839
+ if (/^<!doctype|^<html/i.test(t)) return true;
840
+ return t.startsWith("<") && t.endsWith(">") && HTML_TAG_RE.test(t);
841
+ }
842
+ function detectCodeLang(codeEl) {
843
+ const m = (codeEl.className || "").match(/(?:^|\s)language-([\w+#.-]+)/i);
844
+ return m ? m[1].toLowerCase() : "";
845
+ }
846
+ function decorateCodeBlocks(root) {
847
+ root.querySelectorAll("pre code").forEach((codeEl) => {
848
+ const pre = codeEl.closest("pre"); if (!pre || pre.dataset.hyDecorated) return;
849
+ pre.dataset.hyDecorated = "1";
850
+ const wrapper = document.createElement("div"); wrapper.className = "hy-codeblock";
851
+ const header = document.createElement("div"); header.className = "hy-codeblock-header";
852
+ const lang = document.createElement("span"); lang.className = "hy-codeblock-lang";
853
+ lang.textContent = detectCodeLang(codeEl); header.appendChild(lang);
854
+ const btn = document.createElement("button"); btn.type = "button"; btn.className = "code-copy-btn";
855
+ btn.setAttribute("aria-label", I18N.code_copy); btn.title = I18N.code_copy; btn.innerHTML = COPY_SVG;
856
+ btn.addEventListener("click", (e) => {
857
+ e.stopPropagation(); e.preventDefault();
858
+ copyText(codeEl.textContent || "").then(() => {
859
+ btn.innerHTML = CHECK_SVG; btn.classList.add("copied"); btn.title = I18N.code_copied;
860
+ setTimeout(() => { btn.innerHTML = COPY_SVG; btn.classList.remove("copied"); btn.title = I18N.code_copy; }, 1400);
861
+ });
862
+ });
863
+ header.appendChild(btn);
864
+ pre.parentElement.insertBefore(wrapper, pre);
865
+ wrapper.appendChild(header); wrapper.appendChild(pre);
866
+ if (looksLikeHtml(codeEl.textContent || "")) {
867
+ const pb = document.createElement("button"); pb.className = "html-preview-btn";
868
+ pb.addEventListener("click", (ev) => { ev.stopPropagation(); openHtmlPreview(codeEl.textContent || ""); });
869
+ wrapper.appendChild(pb);
870
+ }
871
+ });
872
+ }
873
+ function copyText(text) {
874
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
875
+ return new Promise((res) => {
876
+ const ta = document.createElement("textarea"); ta.value = text;
877
+ ta.style.position = "absolute"; ta.style.left = "-9999px"; document.body.appendChild(ta);
878
+ ta.select(); document.execCommand("copy"); document.body.removeChild(ta); res();
879
+ });
880
+ }
881
+ function openHtmlPreview(html) {
882
+ document.querySelectorAll(".html-preview-overlay").forEach((e) => e.remove());
883
+ const overlay = document.createElement("div"); overlay.className = "html-preview-overlay";
884
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
885
+ const modal = document.createElement("div"); modal.className = "html-preview-modal";
886
+ const header = document.createElement("div"); header.className = "html-preview-header";
887
+ const title = document.createElement("span"); title.className = "html-preview-title"; title.textContent = "HTML Preview";
888
+ const close = document.createElement("button"); close.className = "html-preview-close"; close.innerHTML = "&times;";
889
+ close.addEventListener("click", () => overlay.remove());
890
+ header.appendChild(title); header.appendChild(close);
891
+ const iframe = document.createElement("iframe"); iframe.className = "html-preview-iframe";
892
+ iframe.sandbox = "allow-scripts allow-modals allow-forms allow-popups";
893
+ modal.appendChild(header); modal.appendChild(iframe); overlay.appendChild(modal);
894
+ document.body.appendChild(overlay); iframe.srcdoc = html;
895
+ const esc = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", esc); } };
896
+ document.addEventListener("keydown", esc);
897
+ }
898
+
899
+ /* ─── bubble DOM ─────────────────────────────────────────────────── */
900
+ function addUserBubble(text) {
901
+ const row = document.createElement("div"); row.className = "message-row user-row";
902
+ const msg = document.createElement("div"); msg.className = "message user";
903
+ const md = document.createElement("div"); md.className = "hy-user-text"; md.textContent = text;
904
+ msg.appendChild(md); row.appendChild(msg); chatEl.appendChild(row);
905
+ return row;
906
+ }
907
+ function createAssistantBubble() {
908
+ const row = document.createElement("div"); row.className = "message-row bot-row";
909
+ const msg = document.createElement("div"); msg.className = "message bot";
910
+ const md = document.createElement("div"); md.className = "markdown hy-markdown";
911
+ const typing = document.createElement("div"); typing.className = "typing-indicator";
912
+ typing.innerHTML = "<span></span><span></span><span></span>"; md.appendChild(typing);
913
+ msg.appendChild(md); row.appendChild(msg); chatEl.appendChild(row);
914
+ const rec = { row, md, typing, reasoningEl: null, contentEl: null, ended: false };
915
+ rec.__md = md;
916
+ return rec;
917
+ }
918
+ function ensureReasoning(b) {
919
+ if (b.reasoningEl) return;
920
+ const det = document.createElement("details"); det.className = "thinking-block"; det.open = true;
921
+ const sum = document.createElement("summary"); sum.textContent = I18N.thinking;
922
+ const body = document.createElement("div"); body.className = "hy-reasoning-body";
923
+ body.style.whiteSpace = "pre-wrap"; body.style.wordBreak = "break-word";
924
+ det.appendChild(sum); det.appendChild(body);
925
+ if (b.typing && b.typing.parentNode) b.typing.remove();
926
+ b.md.insertBefore(det, b.md.firstChild);
927
+ b.reasoningEl = det; b.reasoningBody = body; b.reasoningSummary = sum;
928
+ }
929
+ function ensureContent(b) {
930
+ if (b.contentEl) return;
931
+ if (b.typing && b.typing.parentNode) b.typing.remove();
932
+ const c = document.createElement("div"); c.className = "hy-content";
933
+ b.md.appendChild(c); b.contentEl = c;
934
+ }
935
+ function markThinkingDone(b) {
936
+ if (!b.reasoningEl || b.thinkingDone) return;
937
+ b.thinkingDone = true;
938
+ b.reasoningSummary.textContent = I18N.thinking_done;
939
+ b.reasoningEl.open = false;
940
+ }
941
+ function appendToolCall(b, toolCalls) {
942
+ let c = b.toolCallContainer;
943
+ if (!c) { c = document.createElement("div"); c.className = "hy-tool-calls"; b.md.appendChild(c); b.toolCallContainer = c; }
944
+ c.innerHTML = "";
945
+ toolCalls.forEach((tc) => {
946
+ const fn = tc.function || {};
947
+ let args = fn.arguments || "";
948
+ try { args = JSON.stringify(JSON.parse(args), null, 2); } catch (e) {}
949
+ const block = document.createElement("div"); block.className = "hy-tool-call";
950
+ renderMarkdownInto(block, TOOL_CALL_LABEL + ": **" + (fn.name || "") + "**\n```json\n" + args + "\n```");
951
+ c.appendChild(block);
952
+ });
953
+ }
954
+
955
+ /* ─── scroll ────────────────────────────────────────────────────── */
956
+ function scrollToBottom() {
957
+ chatEl.scrollTop = chatEl.scrollHeight;
958
+ }
959
+ let follow = true;
960
+ chatEl.addEventListener("scroll", () => {
961
+ follow = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 120;
962
+ });
963
+ function maybeScroll() { if (follow) scrollToBottom(); }
964
+
965
+ /* ─── overlay (empty state) ──────────────────────────────────────── */
966
+ function syncOverlay() {
967
+ const hasMsgs = chatEl.querySelector(".message-row");
968
+ overlayEl.classList.toggle("hidden", !!hasMsgs);
969
+ }
970
+
971
+ /* ─── toasts ─────────────────────────────────────────────────────── */
972
+ function toast(msg, isError) {
973
+ const host = $("toast-host");
974
+ const el = document.createElement("div"); el.className = "toast" + (isError ? " error" : "");
975
+ el.textContent = msg; host.appendChild(el);
976
+ requestAnimationFrame(() => el.classList.add("show"));
977
+ setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3200);
978
+ }
979
+
980
+ /* ─── send gating ───────────────────────────────────────────────── */
981
+ function setStreaming(on) {
982
+ isStreaming = on;
983
+ sendBtn.disabled = on;
984
+ }
985
+
986
+ /* ─── gather sidebar params for a request ────────────────────────── */
987
+ function gatherParams() {
988
+ const tempUseDefault = $("temperature-use-default").checked;
989
+ const ptUseDefault = $("preserved-thinking-use-default").checked;
990
+ return {
991
+ session_id: SESSION_ID,
992
+ system_prompt: $("system-prompt").value,
993
+ think_level: $("think-level").value,
994
+ temperature: tempUseDefault ? null : parseFloat($("temperature-num").value),
995
+ max_tokens: parseInt($("max-tokens-num").value || "0", 10) || 0,
996
+ top_p: parseFloat($("top-p-num").value || "0") || 0,
997
+ preserved_thinking: ptUseDefault ? null : $("preserved-thinking").checked,
998
+ functions_json_str: $("functions-json").value || "",
999
+ };
1000
+ }
1001
+
1002
+ /* ─── wire up sidebar interactivity ──────────────────────────────── */
1003
+ function wireRange(rangeId, numId) {
1004
+ const r = $(rangeId), n = $(numId);
1005
+ r.addEventListener("input", () => { n.value = r.value; });
1006
+ n.addEventListener("input", () => { r.value = n.value; });
1007
+ }
1008
+ wireRange("temperature", "temperature-num");
1009
+ wireRange("max-tokens", "max-tokens-num");
1010
+ wireRange("top-p", "top-p-num");
1011
+ function wireToggle(checkId, targetRangeId, targetNumId) {
1012
+ const c = $(checkId);
1013
+ const enable = () => {
1014
+ const on = !c.checked;
1015
+ $(targetRangeId).disabled = !on; $(targetNumId).disabled = !on;
1016
+ };
1017
+ c.addEventListener("change", enable); enable();
1018
+ }
1019
+ wireToggle("temperature-use-default", "temperature", "temperature-num");
1020
+ wireToggle("preserved-thinking-use-default", "preserved-thinking", "preserved-thinking");
1021
+ // preserved_thinking checkbox itself is gated on its "use default" too
1022
+ $("preserved-thinking-use-default").addEventListener("change", () => {
1023
+ $("preserved-thinking").disabled = $("preserved-thinking-use-default").checked;
1024
+ });
1025
+ $("preserved-thinking").disabled = $("preserved-thinking-use-default").checked;
1026
+
1027
+ $("validate-fn-btn").addEventListener("click", async () => {
1028
+ const msgEl = $("fn-msg");
1029
+ msgEl.className = "fn-msg"; msgEl.textContent = "Validating...";
1030
+ try {
1031
+ const job = await APP.submit("/validate_functions", { functions_json_str: $("functions-json").value || "" });
1032
+ let res = null;
1033
+ for await (const m of job) { if (m.type === "data") res = m.data; }
1034
+ // res is [ {ok, message, formatted} ] (array of one element)
1035
+ const out = Array.isArray(res) ? res[0] : res;
1036
+ if (out && out.ok) {
1037
+ msgEl.className = "fn-msg ok"; msgEl.textContent = out.message || "OK";
1038
+ if (out.formatted) $("functions-json").value = out.formatted;
1039
+ } else {
1040
+ msgEl.className = "fn-msg err"; msgEl.textContent = (out && out.message) || "Invalid";
1041
+ }
1042
+ } catch (e) {
1043
+ msgEl.className = "fn-msg err"; msgEl.textContent = String(e);
1044
+ }
1045
+ });
1046
+
1047
+ /* ─── the Gradio client connection ───────────────────────────────── */
1048
+ let APP = null;
1049
+ async function connectClient() {
1050
+ if (APP) return APP;
1051
+ APP = await Client.connect(window.location.origin, {
1052
+ events: ["status", "data"],
1053
+ });
1054
+ return APP;
1055
+ }
1056
+
1057
+ /* ─── render a streaming turn ──────────────────────────────────────
1058
+ Each yielded frame is [content, reasoning, tool_calls, history, epoch].
1059
+ We keep one assistant bubble and update its reasoning / content /
1060
+ tool-call region cumulatively as frames arrive. */
1061
+ async function runStream(endpoint, payload, { isToolFollowup = false } = {}) {
1062
+ const app = await connectClient();
1063
+ const bubble = createAssistantBubble();
1064
+
1065
+ const job = app.submit(endpoint, payload);
1066
+ currentJob = job;
1067
+
1068
+ let lastEpoch = null;
1069
+ let firstContent = true;
1070
+ // Latest tool calls seen during streaming. We don't act on them until
1071
+ // the turn completes (see the "stream finished" handoff below).
1072
+ let pendingToolCallsSeen = null;
1073
+ // True iff the last frame was /submit_tool_result's "more results
1074
+ // pending" frame (carries the REMAINING tool-call queue, not new
1075
+ // model output).
1076
+ let needsMoreSeen = false;
1077
+
1078
+ try {
1079
+ for await (const message of job) {
1080
+ if (message.type === "status") {
1081
+ if (message.status === "error") {
1082
+ throw new Error(message.message || "Stream error");
1083
+ }
1084
+ continue;
1085
+ }
1086
+ if (message.type !== "data") continue;
1087
+ const data = message.data;
1088
+ // data is the yielded tuple: [content, reasoning, tool_calls, history, epoch, needs_more?]
1089
+ const content = data[0] || "";
1090
+ const reasoning = data[1] || "";
1091
+ const toolCalls = data[2] || [];
1092
+ const epoch = data[4] != null ? String(data[4]) : null;
1093
+ // ``needsMore`` (6th elem) is only set by /submit_tool_result's
1094
+ // "more results pending" frame — it signals the tool calls in
1095
+ // this frame are the REMAINING queue to re-prompt, not new calls
1096
+ // the model just made.
1097
+ const needsMore = data[5] === true;
1098
+ needsMoreSeen = needsMore;
1099
+
1100
+ // ── staleness: drop frames from an abandoned turn ──
1101
+ if (epoch && currentEpoch !== null && epoch !== currentEpoch && !isToolFollowup) {
1102
+ continue;
1103
+ }
1104
+ if (epoch) lastEpoch = epoch;
1105
+
1106
+ // reasoning
1107
+ if (reasoning) {
1108
+ ensureReasoning(bubble);
1109
+ if (!bubble.reasoningBody.textContent) bubble.reasoningBody.textContent = reasoning;
1110
+ else if (bubble.reasoningBody.textContent !== reasoning) bubble.reasoningBody.textContent = reasoning;
1111
+ }
1112
+ // content
1113
+ if (content) {
1114
+ if (firstContent) { firstContent = false; if (bubble.reasoningEl) markThinkingDone(bubble); }
1115
+ ensureContent(bubble);
1116
+ renderMarkdownInto(bubble.contentEl, content);
1117
+ }
1118
+ // tool calls
1119
+ if (toolCalls && toolCalls.length) {
1120
+ pendingToolCallsSeen = toolCalls;
1121
+ if (!needsMore) {
1122
+ // New tool calls the model just made — render them in the
1123
+ // chat bubble (mirrors the Blocks app's opToolCall).
1124
+ appendToolCall(bubble, toolCalls);
1125
+ }
1126
+ // For the "more results pending" frame (needsMore=true) the
1127
+ // tool calls are the REMAINING queue; we only re-open the
1128
+ // panel (in the deferred handoff below), not a chat bubble.
1129
+ }
1130
+
1131
+ maybeScroll();
1132
+ }
1133
+
1134
+ // ── stream finished ──
1135
+ if (bubble.typing && bubble.typing.parentNode) bubble.typing.remove();
1136
+ if (bubble.reasoningEl && !bubble.thinkingDone) markThinkingDone(bubble);
1137
+ syncOverlay();
1138
+
1139
+ // ── tool-call handoff (deferred until the turn is complete) ──
1140
+ // The model's tool calls arrive in the in-flight history snapshot
1141
+ // on every streaming frame, but we only surface the tool-result
1142
+ // panel once the turn has actually finished — mirroring the
1143
+ // Blocks app, which renders the tool-call block on assistant_end.
1144
+ // This applies to both /chat (first request for tools) and
1145
+ // /submit_tool_result (the follow-up requested yet more tools, or
1146
+ // the "more results pending" frame carrying the remaining calls).
1147
+ if (pendingToolCallsSeen && pendingToolCallsSeen.length) {
1148
+ pendingToolCalls = pendingToolCallsSeen.slice();
1149
+ // A brand-new tool round (from /chat) resets the counters; a
1150
+ // "more results pending" frame (needsMore) continues the same
1151
+ // round, so we keep the running total + submitted count.
1152
+ if (!needsMoreSeen) {
1153
+ toolRoundTotal = pendingToolCalls.length + toolResultsSubmitted;
1154
+ }
1155
+ showToolPanel(pendingToolCalls);
1156
+ // The "more results pending" frame carries no content/reasoning
1157
+ // and the tool calls go to the panel, not the bubble — so the
1158
+ // bubble we created is empty. Drop it to avoid a stray typing
1159
+ // indicator.
1160
+ if (needsMoreSeen && !bubble.contentEl && !bubble.reasoningEl
1161
+ && bubble.row.parentNode) {
1162
+ bubble.row.parentNode.removeChild(bubble.row);
1163
+ }
1164
+ }
1165
+ return { toolCalls: pendingToolCalls, epoch: lastEpoch };
1166
+ } catch (err) {
1167
+ if (bubble.typing && bubble.typing.parentNode) bubble.typing.remove();
1168
+ if (!bubble.contentEl && !bubble.reasoningEl) {
1169
+ // nothing was rendered — show the error inline
1170
+ ensureContent(bubble);
1171
+ bubble.contentEl.innerHTML = '<p style="color:var(--tc-red)">⚠ ' +
1172
+ escapeHtml(I18N.request_failed) + "</p>";
1173
+ }
1174
+ throw err;
1175
+ }
1176
+ }
1177
+
1178
+ /* ─── tool-call input panel ──────────────────────────────────────── */
1179
+ function showToolPanel(toolCalls) {
1180
+ const tc = toolCalls[0];
1181
+ const fn = tc.function || {};
1182
+ let args = fn.arguments || "";
1183
+ try { args = JSON.stringify(JSON.parse(args), null, 2); } catch (e) {}
1184
+ const total = toolRoundTotal || toolCalls.length;
1185
+ const idx = toolResultsSubmitted + 1;
1186
+ const header = (I18N["tool.call_header"] || "Function call ({i}/{n})")
1187
+ .replace("{i}", idx).replace("{n}", total);
1188
+ $("tool-header").textContent = header;
1189
+ renderMarkdownInto(toolMarkdown, TOOL_CALL_LABEL + ": **" + (fn.name || "") + "**\n```json\n" + args + "\n```");
1190
+ toolArea.classList.add("visible");
1191
+ toolResultInput.value = "";
1192
+ setTimeout(() => toolResultInput.focus(), 50);
1193
+ }
1194
+ function hideToolPanel() { toolArea.classList.remove("visible"); }
1195
+
1196
+ async function submitToolResult() {
1197
+ const resultText = toolResultInput.value;
1198
+ if (!resultText) { toolResultInput.focus(); return; }
1199
+ hideToolPanel();
1200
+ setStreaming(true);
1201
+ toolResultsSubmitted += 1;
1202
+ const params = gatherParams();
1203
+ try {
1204
+ await runStream("/submit_tool_result", {
1205
+ result_text: resultText,
1206
+ session_id: SESSION_ID,
1207
+ system_prompt: params.system_prompt,
1208
+ think_level: params.think_level,
1209
+ temperature: params.temperature,
1210
+ max_tokens: params.max_tokens,
1211
+ top_p: params.top_p,
1212
+ preserved_thinking: params.preserved_thinking,
1213
+ functions_json_str: params.functions_json_str,
1214
+ }, { isToolFollowup: true });
1215
+ } catch (e) {
1216
+ console.error(e); toast(I18N.request_failed, true);
1217
+ } finally {
1218
+ setStreaming(false); maybeScroll();
1219
+ }
1220
+ }
1221
+ toolSubmitBtn.addEventListener("click", submitToolResult);
1222
+ toolResultInput.addEventListener("keydown", (e) => {
1223
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submitToolResult(); }
1224
+ });
1225
+
1226
+ /* ─── send a message ──────────────────────────────────────────────── */
1227
+ async function sendMessage() {
1228
+ const text = msgInput.value.trim();
1229
+ if (!text) { toast(I18N.empty_msg); return; }
1230
+ if (isStreaming) { toast(I18N.model_busy); return; }
1231
+
1232
+ addUserBubble(text);
1233
+ msgInput.value = ""; msgInput.style.height = "auto";
1234
+ syncOverlay();
1235
+ setStreaming(true);
1236
+ follow = true;
1237
+ // A fresh user turn starts a new (potential) tool-call round.
1238
+ toolResultsSubmitted = 0;
1239
+ toolRoundTotal = 0;
1240
+
1241
+ const params = gatherParams();
1242
+ currentEpoch = null; // adopt the first epoch we see
1243
+ try {
1244
+ const result = await runStream("/chat", {
1245
+ message: text,
1246
+ session_id: SESSION_ID,
1247
+ system_prompt: params.system_prompt,
1248
+ think_level: params.think_level,
1249
+ temperature: params.temperature,
1250
+ max_tokens: params.max_tokens,
1251
+ top_p: params.top_p,
1252
+ preserved_thinking: params.preserved_thinking,
1253
+ functions_json_str: params.functions_json_str,
1254
+ });
1255
+ if (result && result.epoch) currentEpoch = result.epoch;
1256
+ } catch (e) {
1257
+ console.error(e); toast(I18N.request_failed, true);
1258
+ } finally {
1259
+ setStreaming(false); maybeScroll();
1260
+ }
1261
+ }
1262
+ sendBtn.addEventListener("click", sendMessage);
1263
+ msgInput.addEventListener("keydown", (e) => {
1264
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1265
+ e.preventDefault(); sendMessage();
1266
+ }
1267
+ });
1268
+ msgInput.addEventListener("input", () => {
1269
+ msgInput.style.height = "auto";
1270
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 200) + "px";
1271
+ });
1272
+
1273
+ /* ─── new chat ────────────────────────────────────────────────────── */
1274
+ newChatBtn.addEventListener("click", async () => {
1275
+ if (isStreaming) {
1276
+ // cancel by stopping the iterator; the server's epoch guard drops
1277
+ // any late frames.
1278
+ if (currentJob && currentJob.cancel) { try { currentJob.cancel(); } catch (e) {} }
1279
+ }
1280
+ try {
1281
+ const app = await connectClient();
1282
+ const job = await app.submit("/new_chat", { session_id: SESSION_ID });
1283
+ let res = null;
1284
+ for await (const m of job) { if (m.type === "data") res = m.data; }
1285
+ const out = Array.isArray(res) ? res[0] : res;
1286
+ if (out && out.epoch) currentEpoch = out.epoch;
1287
+ } catch (e) { console.warn("[hy] new_chat:", e); }
1288
+ chatEl.innerHTML = "";
1289
+ pendingToolCalls = [];
1290
+ toolResultsSubmitted = 0;
1291
+ toolRoundTotal = 0;
1292
+ hideToolPanel();
1293
+ setStreaming(false);
1294
+ syncOverlay();
1295
+ toast(I18N.new_chat);
1296
+ });
1297
+
1298
+ /* ─── boot ────────────────────────────────────────────────────────── */
1299
+ async function boot() {
1300
+ // Load i18n strings + the curated example prompts from the /config
1301
+ // endpoint. Everything goes through the gradio client for a uniform
1302
+ // transport (one POST → SSE stream of yielded frames).
1303
+ try {
1304
+ const app = await connectClient();
1305
+ const job = await app.submit("/config", {});
1306
+ let res = null;
1307
+ for await (const m of job) { if (m.type === "data") res = m.data; }
1308
+ const cfg = Array.isArray(res) ? res[0] : res;
1309
+ if (cfg) {
1310
+ if (cfg.i18n) applyI18n(cfg.i18n);
1311
+ if (Array.isArray(cfg.examples)) { EXAMPLES = cfg.examples; buildExamples(); }
1312
+ }
1313
+ } catch (e) {
1314
+ console.warn("[hy] config load failed, using defaults:", e);
1315
+ applyI18n({});
1316
+ }
1317
+ syncOverlay();
1318
+ }
1319
+
1320
+ function buildExamples() {
1321
+ const grid = $("examples-grid");
1322
+ grid.innerHTML = "";
1323
+ EXAMPLES.forEach((text) => {
1324
+ const btn = document.createElement("div"); btn.className = "example-btn";
1325
+ const inner = document.createElement("span"); inner.className = "example-btn-text";
1326
+ const label = String(text).replace(/\s+/g, " ").trim();
1327
+ inner.textContent = label.length > 200 ? label.slice(0, 200).trimEnd() + "…" : label;
1328
+ btn.title = text; btn.appendChild(inner);
1329
+ btn.addEventListener("click", () => {
1330
+ msgInput.value = text;
1331
+ msgInput.dispatchEvent(new Event("input"));
1332
+ sendMessage();
1333
+ });
1334
+ grid.appendChild(btn);
1335
+ });
1336
+ }
1337
+
1338
+ // initial defaults while config loads
1339
+ applyI18n({});
1340
+ boot();
1341
+ })();
1342
+ </script>
1343
+ </body>
1344
+ </html>