ericsqin commited on
Commit
71e4446
·
1 Parent(s): 302b125

publish Hy3-preview

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +40 -0
  2. README.md +18 -8
  3. app.py +438 -0
  4. chat.py +608 -0
  5. config.py +70 -0
  6. core/__init__.py +23 -0
  7. core/chat.py +446 -0
  8. display.py +174 -0
  9. i18n.py +161 -0
  10. pytest.ini +5 -0
  11. requirements.txt +1 -0
  12. static/app.js +997 -0
  13. static/chat.js +688 -0
  14. static/css/_chat.css +144 -0
  15. static/css/_chatbot.css +170 -0
  16. static/css/_dark.css +194 -0
  17. static/css/_examples.css +142 -0
  18. static/css/_hide.css +131 -0
  19. static/css/_input.css +114 -0
  20. static/css/_layout.css +41 -0
  21. static/css/_legacy.css +50 -0
  22. static/css/_math.css +132 -0
  23. static/css/_misc.css +62 -0
  24. static/css/_modals.css +266 -0
  25. static/css/_perf.css +32 -0
  26. static/css/_thinking.css +35 -0
  27. static/css/_typing.css +29 -0
  28. static/css/_variables.css +81 -0
  29. static/vendor/highlight/LICENSE +29 -0
  30. static/vendor/highlight/github-dark.min.css +10 -0
  31. static/vendor/highlight/github.min.css +18 -0
  32. static/vendor/highlight/highlight.min.js +0 -0
  33. static/vendor/katex/LICENSE +27 -0
  34. static/vendor/katex/auto-render.min.js +1 -0
  35. static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  36. static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  37. static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  38. static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  39. static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  40. static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  41. static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  42. static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  43. static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  44. static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  45. static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  46. static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  47. static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  48. static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  49. static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  50. static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
.gitignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ debug/
2
+ logs/
3
+
4
+ # Python-generated files
5
+ __pycache__/
6
+ *.py[oc]
7
+ build/
8
+ .build/
9
+ wheels/
10
+ *.egg-info
11
+
12
+ # Virtual environments
13
+ .venv*
14
+
15
+ .idea/
16
+ .DS_Store
17
+
18
+ # Coverage reports
19
+ .coverage
20
+ coverage.xml
21
+ htmlcov/
22
+
23
+ *.db
24
+ *.log
25
+ *.http
26
+
27
+ .env
28
+
29
+ dist/
30
+ .ruff_cache/
31
+ .pytest_cache/
32
+
33
+ # MkDocs
34
+ site/
35
+
36
+ .claude-trace
37
+ .vscode/
38
+ .cursor/
39
+ .specify/
40
+ test.py
README.md CHANGED
@@ -1,13 +1,23 @@
1
  ---
2
- title: Hy3 preview
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.13.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: true
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Hy3-preview
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-preview multi-turn streaming chat with function calling
11
  ---
12
 
13
+ # Hy3 Preview · Chat Demo
14
+
15
+
16
+ ### Features
17
+
18
+ - Multi-turn chat with automatic context management
19
+ - Token-by-token streaming output
20
+ - Sidebar controls: think level, system prompt, temperature, max tokens, top-p, repetition penalty
21
+ - Function calling: paste JSON definitions, see structured tool calls, submit results back
22
+ - HTML code-block preview rendered in a sandboxed iframe
23
+ - Light & dark themes
app.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+
8
+ from chat import (
9
+ HY_CHAT_INITIAL_HTML,
10
+ api_chat,
11
+ init_state,
12
+ new_chat,
13
+ send_message,
14
+ submit_tool_result,
15
+ )
16
+ from i18n import LANG, t
17
+ from tools import validate_functions_json
18
+ from styles import CSS
19
+
20
+ _STATIC_DIR = Path(__file__).parent / "static"
21
+ _VENDOR_DIR = _STATIC_DIR / "vendor"
22
+
23
+ _FN_PLACEHOLDER = json.dumps([{
24
+ "type": "function",
25
+ "function": {
26
+ "name": "get_weather",
27
+ "description": "Get weather info for a city",
28
+ "parameters": {
29
+ "type": "object",
30
+ "properties": {
31
+ "location": {"type": "string", "description": "City name"}
32
+ },
33
+ "required": ["location"]
34
+ }
35
+ }
36
+ }], indent=2, ensure_ascii=False)
37
+
38
+ _EXTRACT_NAMES_PROMPT = """You are a text information extraction expert with exceptional abilities in information filtering and structured processing. Your core task is to accurately capture key information from the raw text provided by the user and output the result according to the required format, ensuring informational purity and logical consistency.
39
+
40
+ Initial settings:
41
+ 1. All extracted information must strictly come from the text provided by the user.
42
+ 2. If the user's request requires extracting uncertainty-related words such as "possible" or "speculative," you must extract them as requested. Otherwise, you must not output uncertainty-related words such as "possible" or "speculative."
43
+ 3. You must strictly follow the output format below and must not include any content outside the required format. The output format is: "Extracted content: xxx". Here, "xxx" is a placeholder.
44
+
45
+ Please help me extract all personal names from the text in order. Note that you do not need to remove duplicates.
46
+
47
+ Text:
48
+ In a warm and harmonious extended family, four elders—father, mother, grandfather, and grandmother—have always quietly watched over the family, supporting the whole household with love and tolerance. Meanwhile, six younger family members—Emily Carter, Olivia Brooks, Ethan Miller, Sophie Taylor, Grace Wilson, and Daniel Harris—are each growing along their own paths in life. The family supports one another and keeps each other company, making ordinary days feel warm and fulfilling. Father is hardworking and steady, carrying the family's responsibilities with strength. Mother is gentle and attentive, taking care of every detail of daily life. Grandfather is experienced and often tells the children stories from the past. Grandmother is kind and loving, warming everyone who returns home with hot meals. Emily Carter is sensible and motivated, setting a good example for her younger siblings. Olivia Brooks is lively and cheerful, always bringing laughter to the home. Ethan Miller is dependable and practical, taking every task seriously without being careless. Sophie Taylor is gentle and considerate, skilled at caring for the people around her. Grace Wilson loves life and is attentive to the beauty around her. Daniel Harris is energetic and full of expectations for the future. These ten family members each have their own traits and strengths, yet they share the same care and affection for one another. Together, they share joy, ease worries, pass through spring, summer, autumn, and winter, and move through the years side by side. Through family love, they gather the strongest support, making home the safest and warmest harbor, and making ordinary life full of happiness and light because of each other's companionship."""
49
+
50
+ _CHONGQING_TRIP_PROMPT = """You are an experienced local tour guide in Chongqing. Based on the attraction information and the users' situation, you need to generate a travel summary arranged by travel date. You must not output anything else. The travel summary format is as follows:
51
+
52
+ Date/Location/Ticket
53
+ MM-dd/Travel location/Ticket price
54
+ MM-dd/Travel location/Ticket price
55
+ ......
56
+
57
+ For the Travel location field, choose exactly one from the following six options: Hongya Cave, Chaotianmen, Jiefangbei, Qiansimen, Shapingba, Jiangbeizui.
58
+
59
+ Opening hours:
60
+ Hongya Cave: 9:00–11:00
61
+ Chaotianmen: 9:00–11:00
62
+ Jiefangbei: 15:00–17:00
63
+ Qiansimen: 11:00–13:00
64
+ Shapingba: 12:00–14:00
65
+ Jiangbeizui: 16:00–18:00
66
+
67
+ Ticket prices:
68
+ Hongya Cave: CNY 20 per person
69
+ Chaotianmen: CNY 60 per person
70
+ Jiefangbei: CNY 30 per person
71
+ Qiansimen: CNY 50 per person
72
+ Shapingba: CNY 60 per person
73
+ Jiangbeizui: CNY 50 per person
74
+
75
+ Alex: My friend Ben and I came to Chongqing on March 15 for a trip. For every attraction we visit, we must stay from opening time until closing time. We brought a total of CNY 300. On the first day, we visited two attractions.
76
+
77
+ Ben: Although we had a great time on the first day, after the day's trip ended, I realized that I had lost CNY 200. So we did not plan to visit any attractions on the second day.
78
+
79
+ Alex: Losing the money was really stressful for us. However, after 12:00 on the second day, our parents sent us another CNY 200, so we went to visit one attraction that afternoon.
80
+
81
+ Ben: We were too tired from the first two days, so on the final day we visited only one attraction. None of the attractions we visited were repeated."""
82
+
83
+ _RATIONAL_VARIETY_PROMPT = r"""Let \(K\) be an algebraically closed field and \(X\) the hypersurface in \(\mathbf{P}_{K}^{3}\) defined by the homogeneous equation \[ x^{2} w-y^{2} z=0 \] where \((x, y, z, w)\) are homogeneous coordinates on \(\mathbf{P}_{K}^{3}\). Prove that \(X\) is a rational variety, i.e., its function field is \(K\left(t_{1}, t_{2}\right)\), where \(t_{1}, t_{2}\) are algebraically independent over \(K\)."""
84
+
85
+ _FISH_PUZZLE_PROMPT = """Three houses stand side by side, numbered 1–3 from left to right. Each house has:
86
+
87
+ A different color: red, green, blue
88
+ A resident of a different nationality: British, French, Japanese
89
+ A different pet: dog, cat, fish
90
+
91
+ Clues:
92
+ The British resident lives in the red house.
93
+ The French resident owns the dog.
94
+ The green house is immediately to the left of the red house.
95
+ The Japanese resident lives in House 1.
96
+ The cat is kept in the blue house.
97
+
98
+ Question:
99
+ Who owns the fish? 🐟"""
100
+
101
+ # The 4 showcase prompts are shared across languages — the full text is
102
+ # kept verbatim (that's what we send to the model) while the frontend
103
+ # truncates the button label for display; see ``createOverlay`` in
104
+ # static/app.js for the label-truncation side.
105
+ _SHOWCASE_PROMPTS = [
106
+ _EXTRACT_NAMES_PROMPT,
107
+ _CHONGQING_TRIP_PROMPT,
108
+ _RATIONAL_VARIETY_PROMPT,
109
+ _FISH_PUZZLE_PROMPT,
110
+ ]
111
+
112
+ _EXAMPLES_BY_LANG = {
113
+ "en": _SHOWCASE_PROMPTS,
114
+ "zh": _SHOWCASE_PROMPTS,
115
+ }
116
+ EXAMPLES = _EXAMPLES_BY_LANG.get(LANG, _EXAMPLES_BY_LANG["en"])
117
+
118
+ # ─── Vendored asset inlining ──────────────────────────────────────────
119
+ # Every vendored library is INLINED into the ``js`` / ``css`` parameters
120
+ # that ``gr.Blocks`` already serves on the page, so the app runs fully
121
+ # self-contained without any external CDN or extra static-route plumbing.
122
+ #
123
+ # Order matters for JS: marked / katex / auto-render / hljs must be
124
+ # defined as globals BEFORE static/chat.js runs; static/chat.js must
125
+ # define ``window.HY_CHAT`` BEFORE static/app.js's ``bootstrap()``
126
+ # polls for ``#hy-chat``. Concatenation order below preserves this.
127
+ def _read(p: Path) -> str:
128
+ return p.read_text(encoding="utf-8")
129
+
130
+
131
+ _VENDOR_JS = "\n;\n".join([
132
+ _read(_VENDOR_DIR / "marked" / "marked.min.js"),
133
+ _read(_VENDOR_DIR / "katex" / "katex.min.js"),
134
+ _read(_VENDOR_DIR / "katex" / "auto-render.min.js"),
135
+ _read(_VENDOR_DIR / "highlight" / "highlight.min.js"),
136
+ ])
137
+
138
+ _CHAT_JS = _read(_STATIC_DIR / "chat.js")
139
+ _APP_JS = _read(_STATIC_DIR / "app.js")
140
+
141
+ # KaTeX's stylesheet references font files via ``url(fonts/KaTeX_*.woff2)``
142
+ # — a path that only resolves if the CSS itself is served from the same
143
+ # directory as the fonts. Since we inline the CSS into a <style> tag
144
+ # the relative URLs would resolve against the document root and 404. We
145
+ # rewrite each font URL into a ``data:`` URI so the CSS is fully
146
+ # self-contained, and strip the ``.woff`` / ``.ttf`` fallback entries
147
+ # from each ``@font-face`` src list so the browser doesn't fire requests
148
+ # we know will 404 (we only ship the ``.woff2`` files).
149
+ _KATEX_CSS = _read(_VENDOR_DIR / "katex" / "katex.min.css")
150
+ _FONTS_DIR = _VENDOR_DIR / "katex" / "fonts"
151
+
152
+ # Drop ``,url(fonts/X.woff) format("woff")`` and the ttf equivalent. The
153
+ # minified CSS may use single OR double quotes around the format string;
154
+ # match both. Run BEFORE inlining woff2 so the regex still sees the
155
+ # ``url(fonts/...)`` token shape.
156
+ _KATEX_CSS = re.sub(
157
+ r",\s*url\(fonts/[^)]+\.(?:woff|ttf)\)\s*format\(['\"](?:woff|truetype)['\"]\)",
158
+ "",
159
+ _KATEX_CSS,
160
+ )
161
+
162
+ # Inline every woff2 as a data URI.
163
+ for _font_path in sorted(_FONTS_DIR.glob("*.woff2")):
164
+ _b64 = base64.b64encode(_font_path.read_bytes()).decode("ascii")
165
+ _data_uri = f"data:font/woff2;base64,{_b64}"
166
+ _KATEX_CSS = _KATEX_CSS.replace(
167
+ f"url(fonts/{_font_path.name})", f"url({_data_uri})"
168
+ )
169
+
170
+ _HLJS_CSS = _read(_VENDOR_DIR / "highlight" / "github.min.css")
171
+ _HLJS_DARK_CSS = _read(_VENDOR_DIR / "highlight" / "github-dark.min.css")
172
+
173
+ # Gradio passes this string as the body of an init function it calls on
174
+ # load. We bundle vendored libs first so they install their globals
175
+ # (``window.marked``, ``window.katex``, ``window.renderMathInElement``,
176
+ # ``window.hljs``), then chat.js (defines ``window.HY_CHAT`` and starts
177
+ # the delta-channel observer), then app.js (the rest of the UX glue).
178
+ _JS_INIT = f"""
179
+ window.HY_EXAMPLES = {json.dumps(EXAMPLES)};
180
+ window.HY_I18N = {json.dumps({
181
+ "examples_heading": t("examples_heading"),
182
+ "model_busy": t("warn.model_busy"),
183
+ "thinking": t("display.thinking"),
184
+ "thinking_done": t("display.thinking_done"),
185
+ "code_copy": t("display.code_copy"),
186
+ "code_copied": t("display.code_copied"),
187
+ })};
188
+ {_VENDOR_JS}
189
+ ;
190
+ {_CHAT_JS}
191
+ ;
192
+ {_APP_JS}
193
+ """
194
+
195
+ # Append vendored CSS to whatever ``styles.py`` produced. The KaTeX
196
+ # theme has higher specificity for math elements than our other rules,
197
+ # so loading order doesn't matter for correctness; we just always
198
+ # append it last for consistency.
199
+ _FULL_CSS = (
200
+ CSS
201
+ + "\n/* === katex.min.css (inlined, fonts as data URIs) === */\n"
202
+ + _KATEX_CSS
203
+ + "\n/* === highlight.js github theme === */\n"
204
+ + _HLJS_CSS
205
+ + "\n/* === highlight.js github-dark theme (active under .dark) === */\n"
206
+ # Scope the dark theme to ``.dark`` so the light theme above is the
207
+ # default and the dark variant only kicks in when Gradio toggles its
208
+ # top-level ``.dark`` class. ``github-dark.min.css`` references
209
+ # ``.hljs`` (and many ``.hljs-...`` variants) at lots of positions:
210
+ # at the start of a rule, right after ``}``, AND comma-separated
211
+ # like ``.hljs-doctag,.hljs-keyword,...`` where only the first
212
+ # selector is preceded by ``}``. A simple global string replace
213
+ # works because ``.hljs`` only appears inside selectors in this
214
+ # file (never in property values or strings).
215
+ + _HLJS_DARK_CSS.replace(".hljs", ".dark .hljs")
216
+ )
217
+
218
+
219
+
220
+ with gr.Blocks(title=t("title"), fill_width=True, js=_JS_INIT) as demo:
221
+ state = gr.State(init_state)
222
+
223
+ with gr.Sidebar(width=420, open=True):
224
+ gr.HTML(
225
+ f"<h1 style='text-align:center;font-size:2rem;margin:0.2em 0 0.2em'>{t('title')}</h1>"
226
+ f"<p class='title-notice' style='text-align:center;font-size:0.88rem;"
227
+ f"line-height:1.4;color:var(--body-text-color,#444);"
228
+ f"margin:0 0 0.8em;padding:0 0.4em'>{t('title.notice_html')}</p>"
229
+ )
230
+ think_level = gr.Dropdown(
231
+ choices=["no_think", "low", "high"],
232
+ value="high",
233
+ label=t("sidebar.think_level"),
234
+ info=t("sidebar.think_level.info"),
235
+ )
236
+ system_prompt = gr.Textbox(
237
+ value="",
238
+ label=t("sidebar.system_prompt"),
239
+ lines=3,
240
+ placeholder=t("sidebar.system_prompt.placeholder"),
241
+ )
242
+ temperature = gr.Slider(
243
+ minimum=0, maximum=1, step=0.01, value=0.9,
244
+ label=t("sidebar.temperature"),
245
+ info=t("sidebar.temperature.info"),
246
+ )
247
+ max_tokens = gr.Slider(
248
+ minimum=256, maximum=65536, step=1, value=65536,
249
+ label=t("sidebar.max_tokens"),
250
+ info=t("sidebar.max_tokens.info"),
251
+ )
252
+ top_p = gr.Slider(
253
+ minimum=0, maximum=1, step=0.01, value=1.0,
254
+ label=t("sidebar.top_p"),
255
+ info=t("sidebar.top_p.info"),
256
+ )
257
+ rep_penalty = gr.Slider(
258
+ minimum=0, maximum=2, step=0.01, value=1.0,
259
+ label=t("sidebar.rep_penalty"),
260
+ info=t("sidebar.rep_penalty.info"),
261
+ )
262
+ with gr.Accordion(t("sidebar.functions"), open=False):
263
+ functions_json = gr.Textbox(
264
+ value="",
265
+ label=t("sidebar.functions.label"),
266
+ lines=15,
267
+ max_lines=30,
268
+ placeholder=_FN_PLACEHOLDER,
269
+ )
270
+ validate_fn_btn = gr.Button(t("sidebar.validate_btn"), size="sm")
271
+
272
+ # ── Custom chat surface ───────────────────────────────────────────
273
+ # The visible chat lives entirely inside <div id="hy-chat">, owned and
274
+ # mutated exclusively by static/chat.js. Server NEVER updates this
275
+ # component's value — it's set once at component creation and the
276
+ # renderer takes over from there.
277
+ chat_host = gr.HTML(
278
+ value=HY_CHAT_INITIAL_HTML,
279
+ elem_id="hy-chat-host",
280
+ )
281
+
282
+ # Hidden delta channel. Server pushes JSON ops payloads here; the
283
+ # client observes mutations on this element and applies each op to
284
+ # the chat container. Visually hidden via _hide.css (see the
285
+ # #hy-chat-delta rule there).
286
+ #
287
+ # Why a separate component (not a side-effect of chat_host updates):
288
+ # any update to chat_host's value would wipe the entire DOM the
289
+ # renderer has built. The delta channel is the safe back-door — its
290
+ # value can change freely on every yield without ever touching the
291
+ # visible chat surface.
292
+ chat_delta = gr.HTML(value="", elem_id="hy-chat-delta")
293
+
294
+ with gr.Column(visible=False, elem_id="tool-area") as tool_area:
295
+ tool_call_info = gr.Markdown("")
296
+ tool_result_input = gr.Textbox(
297
+ label=t("tool.result_label"),
298
+ placeholder=t("tool.result_placeholder"),
299
+ lines=1,
300
+ )
301
+ tool_submit_btn = gr.Button(t("tool.submit"), variant="primary", size="sm")
302
+
303
+ with gr.Row(elem_classes=["msg-row"]):
304
+ msg_input = gr.Textbox(
305
+ placeholder=t("msg_placeholder"),
306
+ show_label=False,
307
+ lines=1,
308
+ scale=8,
309
+ elem_classes=["msg-input"],
310
+ )
311
+ send_btn = gr.Button(
312
+ "➤", variant="primary",
313
+ scale=0, min_width=42,
314
+ elem_id="send-btn", elem_classes=["send-btn"],
315
+ )
316
+ new_chat_btn = gr.Button(
317
+ "+", variant="secondary",
318
+ scale=0, min_width=42,
319
+ elem_classes=["new-chat-btn"],
320
+ )
321
+
322
+ # Hidden busy-marker component. Driven by the streaming generators in
323
+ # chat.py:
324
+ # * Streaming begins → value = HY_BUSY_HTML.
325
+ # * Streaming ends → value = HY_IDLE_HTML.
326
+ #
327
+ # The browser observes this single element and gates the Send button
328
+ # on it. Critically isolated from any chat content — unclosed
329
+ # <style>/<script>/<textarea> in model output cannot hijack it
330
+ # because it's a separate Gradio component.
331
+ busy_marker = gr.HTML("", elem_id="hy-busy-marker")
332
+
333
+ # ── Event wiring ──────────────────────────────────────────────────────
334
+ # IMPORTANT: the Send button's busy state is OWNED BY THE BROWSER, not
335
+ # by Gradio. We never call ``gr.update(interactive=False/True)`` on it
336
+ # from any handler. Instead the streaming generators drive busy_marker;
337
+ # static/app.js gates Send on its content (see file).
338
+ #
339
+ # ``concurrency_limit`` is SERVER-WIDE: up to N chat requests across
340
+ # ALL connected users can run in parallel. Per-session ``gr.State``
341
+ # isolates each user's conversation.
342
+ send_outputs = [
343
+ chat_delta, state, msg_input,
344
+ tool_area, tool_call_info, tool_result_input,
345
+ busy_marker,
346
+ ]
347
+ send_inputs = [
348
+ msg_input, state,
349
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
350
+ functions_json,
351
+ ]
352
+
353
+ chat_concurrency = {"concurrency_id": "chat", "concurrency_limit": 8}
354
+
355
+ for trigger in (msg_input.submit, send_btn.click):
356
+ trigger(
357
+ fn=send_message,
358
+ inputs=send_inputs,
359
+ outputs=send_outputs,
360
+ show_progress="hidden",
361
+ # UI-only handler: ships chat content as DOM-mutation deltas
362
+ # through ``chat_delta``, which is unusable from gradio_client.
363
+ # The headless ``/chat`` endpoint registered below is the
364
+ # supported programmatic surface.
365
+ api_visibility="private",
366
+ **chat_concurrency,
367
+ )
368
+
369
+ new_chat_btn.click(
370
+ fn=new_chat,
371
+ inputs=[state],
372
+ # ``busy_marker`` is in the output set so ``new_chat`` can release
373
+ # the Send-button lock immediately when it cancels an in-flight
374
+ # stream — without it the cancelled generator never reaches its
375
+ # terminal IDLE yield and the button would stay stuck until the
376
+ # client-side soft watchdog force-clears it.
377
+ outputs=[chat_delta, state, busy_marker],
378
+ show_progress="hidden",
379
+ api_visibility="private",
380
+ )
381
+
382
+ validate_fn_btn.click(
383
+ fn=validate_functions_json,
384
+ inputs=[functions_json],
385
+ outputs=[functions_json],
386
+ show_progress="hidden",
387
+ api_visibility="private",
388
+ )
389
+
390
+ tool_submit_inputs = [
391
+ tool_result_input, state,
392
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
393
+ functions_json,
394
+ ]
395
+ tool_submit_outputs = [
396
+ chat_delta, state,
397
+ tool_area, tool_call_info, tool_result_input,
398
+ busy_marker,
399
+ ]
400
+
401
+ for trigger in (tool_submit_btn.click, tool_result_input.submit):
402
+ trigger(
403
+ fn=submit_tool_result,
404
+ inputs=tool_submit_inputs,
405
+ outputs=tool_submit_outputs,
406
+ show_progress="hidden",
407
+ api_visibility="private", # UI-only; see send_message wiring above.
408
+ **chat_concurrency,
409
+ )
410
+
411
+ # ── Programmatic API endpoint ────────────────────────────────────────
412
+ # Registered with ``gr.api`` so it has NO UI footprint — it derives its
413
+ # input/output schema from ``api_chat``'s type hints rather than from
414
+ # Gradio components. Callers reach it via:
415
+ #
416
+ # from gradio_client import Client
417
+ # client = Client("ericsqin/hy-demo")
418
+ # content, reasoning, tool_calls, history = client.predict(
419
+ # message="Hello!",
420
+ # api_name="/chat",
421
+ # )
422
+ gr.api(
423
+ api_chat,
424
+ api_name="chat",
425
+ concurrency_id="chat",
426
+ concurrency_limit=8,
427
+ )
428
+
429
+
430
+ if __name__ == "__main__":
431
+ # Guarded so that ``import app`` (e.g. from tests or docs tooling)
432
+ # does not start a Gradio server as a side effect. HuggingFace Spaces
433
+ # invokes ``python app.py`` so this entry point still runs there.
434
+ demo.queue(default_concurrency_limit=16, max_size=64).launch(
435
+ css=_FULL_CSS,
436
+ ssr_mode=False,
437
+ show_error=True,
438
+ )
chat.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio adapter layer — wires :mod:`core.chat` into Gradio events.
2
+
3
+ All pure conversation logic lives in :mod:`core.chat`. This module only deals
4
+ with Gradio types (``gr.update``, ``gr.Warning``) and yields the tuples that
5
+ the Blocks event handlers expect.
6
+
7
+ Architecture
8
+ ------------
9
+ The chat surface is a custom HTML container (``<div id="hy-chat">``) owned
10
+ and mutated by ``static/chat.js``. The server ships **deltas**, never full
11
+ snapshots, via two hidden bridge components:
12
+
13
+ * ``chat_delta`` (``gr.HTML``, ``elem_id="hy-chat-delta"``): a single
14
+ ``<div>`` whose textContent is the latest JSON payload of the form
15
+ ``{"seq": N, "ops": [...]}``. The browser observes mutations on this
16
+ element, parses, and applies each op to the chat container.
17
+ * ``busy_marker`` (``gr.HTML``, ``elem_id="hy-busy-marker"``): drives the
18
+ Send button's enabled state. ``HY_BUSY_HTML`` while the model is
19
+ generating, ``HY_IDLE_HTML`` when it's done.
20
+
21
+ Delta wire payload is just the new characters, and the client only
22
+ re-renders the trailing in-progress paragraph; everything above it is
23
+ frozen DOM that never changes again.
24
+
25
+ Send-button busy-state ownership
26
+ --------------------------------
27
+ The Send button's busy state is **owned entirely by the browser** — there
28
+ is NO server-side ``gr.update(interactive=...)`` toggling. See
29
+ ``static/app.js`` for the MutationObserver-based gate.
30
+
31
+ The terminal IDLE-marker frame is shipped *separately* from any
32
+ chat-content frame so that even when WebSocket is back-pressured the
33
+ tiny marker frame still slips through and the UI unblocks promptly.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import itertools
39
+ import json
40
+ import logging
41
+ from typing import Iterator
42
+
43
+ import gradio as gr
44
+
45
+ from core.chat import (
46
+ ChatState,
47
+ build_api_kwargs,
48
+ finalize_response,
49
+ flush_tool_results,
50
+ init_state,
51
+ record_tool_result,
52
+ reset_state_in_place,
53
+ stream_response,
54
+ )
55
+ from display import (
56
+ format_tool_call_prompt,
57
+ format_tool_calls_for_display,
58
+ preprocess_latex,
59
+ )
60
+ from i18n import t
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ # Sentinel values driven into the dedicated #hy-busy-marker gr.HTML
65
+ # component on the server side, observed by the browser to drive the Send
66
+ # button's enabled state.
67
+ HY_BUSY_HTML = '<span data-hy-busy="1" data-hy-streaming="1" hidden aria-hidden="true"></span>'
68
+ HY_IDLE_HTML = '<span data-hy-busy="0" hidden aria-hidden="true"></span>'
69
+
70
+ # Initial value for the chat container (the inner <div> that the renderer
71
+ # owns). Wrapped in another <div> so Gradio's gr.HTML wrapper has a stable
72
+ # child to host the chat surface.
73
+ HY_CHAT_INITIAL_HTML = '<div id="hy-chat" class="hy-chat" role="log" aria-live="polite"></div>'
74
+
75
+ # Thread-safe monotonically-increasing sequence numbers for the delta
76
+ # channel. Each delta payload carries one; the client uses it to dedup
77
+ # (Gradio occasionally re-fires the same value on reconnect, and we never
78
+ # want to re-apply the same ops twice).
79
+ _seq_counter = itertools.count(1)
80
+
81
+
82
+ def _next_seq() -> int:
83
+ return next(_seq_counter)
84
+
85
+
86
+ # Bubble IDs are pre-allocated per assistant turn so all ops for the same
87
+ # bubble (assistant_begin → reasoning_delta → content_delta → assistant_end →
88
+ # tool_call) can be routed to the right DOM node on the client.
89
+ _bubble_counter = itertools.count(1)
90
+
91
+
92
+ def _next_bubble_id() -> str:
93
+ return f"a-{next(_bubble_counter)}"
94
+
95
+
96
+ def _delta(ops: list[dict], epoch: int | None = None) -> str:
97
+ """Encode a list of ops as a delta payload string.
98
+
99
+ The payload is shipped through ``gr.HTML``'s value, which the front
100
+ end renders into the DOM via ``innerHTML``. Plain ``json.dumps``
101
+ output is valid HTML text 99 % of the time, but any user- or
102
+ model-supplied ``<`` would be parsed as the opening of an HTML tag
103
+ and silently truncate the payload (also a stored-XSS sink). We
104
+ pre-escape the three HTML-meta characters into their JSON
105
+ ``\\uNNNN`` form — the browser's ``JSON.parse`` reverses the escapes
106
+ so semantics are preserved end-to-end.
107
+
108
+ ``epoch`` tags the payload with the session's chat-epoch id so the
109
+ client can drop stale deltas that arrive after a "+" / new-chat
110
+ reset. The reset payload itself carries the NEW epoch — the client
111
+ adopts it, then ignores any later payload that still carries the
112
+ old one.
113
+ """
114
+ payload: dict = {"seq": _next_seq(), "ops": ops}
115
+ if epoch is not None:
116
+ payload["epoch"] = epoch
117
+ raw = json.dumps(payload, ensure_ascii=False)
118
+ return (
119
+ raw.replace("<", "\\u003c")
120
+ .replace(">", "\\u003e")
121
+ .replace("&", "\\u0026")
122
+ )
123
+
124
+
125
+ # Re-export so ``from chat import init_state`` keeps working in app.py.
126
+ __all__ = [
127
+ "init_state",
128
+ "send_message",
129
+ "new_chat",
130
+ "submit_tool_result",
131
+ "api_chat",
132
+ "HY_BUSY_HTML",
133
+ "HY_IDLE_HTML",
134
+ "HY_CHAT_INITIAL_HTML",
135
+ ]
136
+
137
+
138
+ def _ensure_state(state) -> ChatState:
139
+ """Normalize ``state`` into a fresh ``ChatState`` dict.
140
+
141
+ ``gr.State(init_state)`` treats ``init_state`` as a per-session factory in
142
+ the browser, but stateless ``gradio_client`` calls hand the factory itself
143
+ to the handler. Accept callable/None and fall back to a fresh state so the
144
+ API works without breaking the in-browser per-session semantics.
145
+ """
146
+ if state is None or callable(state):
147
+ return init_state()
148
+ return state
149
+
150
+
151
+ def _validate_user_message(message: str, functions_json_str: str) -> bool:
152
+ if not message or not message.strip():
153
+ gr.Warning(t("warn.empty_msg"))
154
+ return False
155
+ if functions_json_str and functions_json_str.strip():
156
+ try:
157
+ json.loads(functions_json_str)
158
+ except json.JSONDecodeError as e:
159
+ gr.Warning(t("warn.invalid_fn_json", err=str(e)))
160
+ return False
161
+ return True
162
+
163
+
164
+ def _run_streaming_turn(
165
+ state: ChatState,
166
+ bubble_id: str,
167
+ epoch: int,
168
+ system_prompt: str,
169
+ think_level: str,
170
+ temperature: float,
171
+ max_tokens: int,
172
+ top_p: float,
173
+ rep_penalty: float,
174
+ functions_json_str: str,
175
+ ) -> Iterator[tuple[list[dict], bool, str]]:
176
+ """Stream a single turn. Yields ``(ops, show_tool, tool_md)``.
177
+
178
+ ``ops`` is the list of bubble-scoped ops (each carrying ``id=bubble_id``)
179
+ to ship on this frame. ``show_tool`` / ``tool_md`` carry the tool-area
180
+ UI state for the caller to translate into ``gr.update(...)`` outputs.
181
+
182
+ ``epoch`` is the chat-epoch captured by the caller at turn start.
183
+ We compare it against the live ``state["epoch"]`` between yields;
184
+ if they diverge the user clicked "+", so we stop yielding and let
185
+ the caller exit without finalising the assistant turn.
186
+ """
187
+ kwargs = build_api_kwargs(
188
+ state, system_prompt, functions_json_str,
189
+ think_level, temperature, max_tokens, top_p, rep_penalty,
190
+ )
191
+ logger.debug(
192
+ "streaming turn: reasoning_effort=%r temperature=%s max_tokens=%s top_p=%s",
193
+ think_level, temperature, max_tokens, top_p,
194
+ )
195
+
196
+ def _cancelled() -> bool:
197
+ return state.get("epoch") != epoch
198
+
199
+ last_ac, last_rc, last_tca = "", "", []
200
+ seen_first_content = False
201
+ for ops, ac, rc, tca, _rid in stream_response(kwargs, is_cancelled=_cancelled):
202
+ if _cancelled():
203
+ return
204
+ last_ac, last_rc, last_tca = ac, rc, tca
205
+ if not ops:
206
+ # Heartbeat — no new content. We still surface an empty-ops
207
+ # frame so the wire ticks (Gradio dedups equal values; the
208
+ # heartbeat carries the new seq via the delta payload below).
209
+ yield [], False, ""
210
+ continue
211
+ # Apply per-op LaTeX preprocessing on content_delta. The model may
212
+ # emit bare \begin{equation}...\end{equation} blocks etc.; KaTeX
213
+ # only renders math inside $$ delimiters.
214
+ out_ops: list[dict] = []
215
+ for op in ops:
216
+ if op["type"] == "content_delta":
217
+ # First content delta implicitly closes the thinking block on
218
+ # the client. We tag the FIRST content op with a flag so the
219
+ # client can switch the summary text and auto-collapse.
220
+ delta_text = preprocess_latex(op["delta"])
221
+ wrapped = {
222
+ "type": "content_delta",
223
+ "id": bubble_id,
224
+ "delta": delta_text,
225
+ }
226
+ if not seen_first_content:
227
+ wrapped["thinking_done"] = True
228
+ seen_first_content = True
229
+ out_ops.append(wrapped)
230
+ elif op["type"] == "reasoning_delta":
231
+ out_ops.append({
232
+ "type": "reasoning_delta",
233
+ "id": bubble_id,
234
+ "delta": op["delta"],
235
+ })
236
+ elif op["type"] == "tool_calls":
237
+ # Carry the snapshot through; we materialise it as a UI
238
+ # block only at end-of-turn (see the finalize section
239
+ # below). Skipping here keeps mid-stream wire payloads
240
+ # tiny — the same partial tool-call gets re-shipped on
241
+ # every frame otherwise.
242
+ continue
243
+ if out_ops:
244
+ yield out_ops, False, ""
245
+
246
+ # ── Terminal phase ─────────────────────────────────────────────────
247
+ if _cancelled():
248
+ return
249
+ has_tools, pending = finalize_response(
250
+ state, last_ac, last_rc, last_tca,
251
+ )
252
+ end_ops: list[dict] = [{"type": "assistant_end", "id": bubble_id}]
253
+ if has_tools:
254
+ # Render the tool-call display block as Markdown; the client will
255
+ # run marked over it. Append AFTER the assistant_end op so the DOM
256
+ # ordering stays correct (content → tool calls).
257
+ tc_markdown = format_tool_calls_for_display(pending)
258
+ end_ops.append({
259
+ "type": "tool_call",
260
+ "id": bubble_id,
261
+ "markdown": tc_markdown,
262
+ })
263
+ tool_md = format_tool_call_prompt(pending[0], 1, len(pending))
264
+ yield end_ops, True, tool_md
265
+ else:
266
+ yield end_ops, False, ""
267
+
268
+
269
+ # ─── send_message ────────────────────────────────────────────────────────────
270
+ # Output slot order (must match app.py's ``send_outputs``):
271
+ # 0 chat_delta (gr.HTML, elem_id="hy-chat-delta")
272
+ # 1 state
273
+ # 2 msg_input
274
+ # 3 tool_area
275
+ # 4 tool_call_info
276
+ # 5 tool_result_input
277
+ # 6 busy_marker (gr.HTML, elem_id="hy-busy-marker")
278
+ def send_message(
279
+ message, state,
280
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
281
+ functions_json_str,
282
+ ):
283
+ state = _ensure_state(state)
284
+
285
+ if not _validate_user_message(message, functions_json_str):
286
+ # Validation failure — never started streaming, marker stays idle,
287
+ # no chat ops emitted.
288
+ yield (
289
+ gr.update(), state, gr.update(),
290
+ gr.update(), gr.update(), gr.update(),
291
+ HY_IDLE_HTML,
292
+ )
293
+ return
294
+
295
+ message = message.strip()
296
+ state["messages"].append({"role": "user", "content": message})
297
+ bubble_id = _next_bubble_id()
298
+ # Capture the chat epoch at the start of the turn. ``new_chat`` bumps
299
+ # ``state["epoch"]`` in place; once that happens we stop yielding so
300
+ # the new fresh chat surface stays clean.
301
+ epoch = state["epoch"]
302
+
303
+ def _cancelled() -> bool:
304
+ return state.get("epoch") != epoch
305
+
306
+ # First frame: append the user bubble, open a new assistant bubble
307
+ # (with typing indicator), set busy marker, clear input.
308
+ yield (
309
+ _delta([
310
+ {"type": "user", "text": message},
311
+ {"type": "assistant_begin", "id": bubble_id},
312
+ ], epoch=epoch),
313
+ state, "",
314
+ gr.update(visible=False), gr.update(), gr.update(),
315
+ HY_BUSY_HTML,
316
+ )
317
+
318
+ try:
319
+ for ops, show_tool, tool_md in _run_streaming_turn(
320
+ state, bubble_id, epoch,
321
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
322
+ functions_json_str,
323
+ ):
324
+ if _cancelled():
325
+ return
326
+ # Heartbeat / no-op frame: still ship an empty-ops payload so
327
+ # the wire ticks and the proxy doesn't drop the connection.
328
+ payload = _delta(ops, epoch=epoch) if ops else _delta([], epoch=epoch)
329
+ if show_tool:
330
+ yield (
331
+ payload, state, gr.update(),
332
+ gr.update(visible=True), gr.update(value=tool_md), gr.update(value=""),
333
+ gr.update(),
334
+ )
335
+ else:
336
+ yield (
337
+ payload, state, gr.update(),
338
+ gr.update(visible=False), gr.update(), gr.update(),
339
+ gr.update(),
340
+ )
341
+
342
+ except Exception as e:
343
+ if _cancelled():
344
+ # Reset already cleaned the UI; swallow late-arriving errors
345
+ # from the abandoned upstream so we don't bounce a stale
346
+ # warning at the user.
347
+ return
348
+ logger.exception("send_message failed: %s", e)
349
+ if state["messages"] and state["messages"][-1].get("role") == "user":
350
+ state["messages"].pop()
351
+ gr.Warning(t("warn.request_failed"))
352
+ # Drop the typing-indicator placeholder bubble client-side, restore
353
+ # the user's message into the input box, release the busy marker.
354
+ yield (
355
+ _delta([{"type": "remove_bubble", "id": bubble_id}], epoch=epoch),
356
+ state, gr.update(value=message),
357
+ gr.update(visible=False), gr.update(), gr.update(),
358
+ HY_IDLE_HTML,
359
+ )
360
+ return
361
+
362
+ if _cancelled():
363
+ return
364
+
365
+ # ── Terminal IDLE-marker frame ──────────────────────────────────────
366
+ # Only changes the busy marker; every other slot is gr.update(). Tens
367
+ # of bytes on the wire — slips past any WebSocket back-pressure so the
368
+ # browser MutationObserver fires the moment the stream genuinely ends,
369
+ # releasing the Send button.
370
+ yield (
371
+ gr.update(), state, gr.update(),
372
+ gr.update(), gr.update(), gr.update(),
373
+ HY_IDLE_HTML,
374
+ )
375
+
376
+
377
+ # ─── Programmatic API endpoint ───────────────────────────────────────────────
378
+ # The UI handlers above (``send_message`` / ``submit_tool_result``) ship chat
379
+ # content as DOM-mutation deltas through a hidden ``chat_delta`` HTML
380
+ # component, which is unusable from ``gradio_client``: a remote caller would
381
+ # only ever see the final ``gr.update()`` placeholders and the busy marker.
382
+ #
383
+ # ``api_chat`` is the headless counterpart: a stateless, generator function
384
+ # whose YIELDED tuple is the entire reply so far. ``gradio_client.predict``
385
+ # returns the last yield, so callers get the full ``(content, reasoning,
386
+ # tool_calls, history)`` snapshot once the stream finishes; users that want
387
+ # token-by-token streaming can iterate via ``client.submit(...)``.
388
+ def api_chat(
389
+ message: str,
390
+ system_prompt: str = "",
391
+ history: list | None = None,
392
+ think_level: str = "high",
393
+ temperature: float = 0.9,
394
+ max_tokens: int = 65536,
395
+ top_p: float = 1.0,
396
+ rep_penalty: float = 1.0,
397
+ functions_json_str: str = "",
398
+ ) -> Iterator[tuple[str, str, list, list]]:
399
+ """Stateless chat endpoint for ``gradio_client`` callers.
400
+
401
+ Args:
402
+ message: The new user turn.
403
+ system_prompt: Optional system prompt prepended to every request.
404
+ history: Prior conversation as a list of OpenAI-style messages
405
+ (``[{"role": "user"|"assistant"|"tool", "content": ...}, ...]``).
406
+ Pass ``None`` or ``[]`` to start a fresh conversation. The caller
407
+ owns the history; pass the returned ``updated_history`` back on
408
+ the next call to continue multi-turn.
409
+ think_level: One of ``"no_think" | "low" | "high"``.
410
+ temperature, max_tokens, top_p, rep_penalty: Standard sampling knobs.
411
+ functions_json_str: JSON string of tool definitions (OpenAI tools
412
+ schema). Empty string disables function calling.
413
+
414
+ Yields:
415
+ ``(content, reasoning_content, tool_calls, updated_history)``
416
+ cumulative on every yield. The final yield is the complete reply.
417
+
418
+ * ``content``: assistant visible text.
419
+ * ``reasoning_content``: chain-of-thought (when ``think_level``
420
+ requests it). May be empty for ``no_think``.
421
+ * ``tool_calls``: list of OpenAI-style tool-call dicts the model
422
+ requested. Empty when no function was called. The caller is
423
+ responsible for executing each call and appending the
424
+ corresponding ``{"role": "tool", "tool_call_id": ..., "content":
425
+ ...}`` messages to ``history`` on the next ``api_chat`` call.
426
+ * ``updated_history``: full message list including the new user
427
+ turn and the assistant reply (with ``tool_calls`` attached when
428
+ present). Suitable for round-tripping into the next call.
429
+
430
+ Raises:
431
+ ValueError: when ``message`` is empty or ``functions_json_str`` is
432
+ not valid JSON.
433
+ """
434
+ if not message or not message.strip():
435
+ raise ValueError("message must be a non-empty string")
436
+ if functions_json_str and functions_json_str.strip():
437
+ try:
438
+ json.loads(functions_json_str)
439
+ except json.JSONDecodeError as e:
440
+ raise ValueError(f"functions_json_str is not valid JSON: {e}") from e
441
+
442
+ state = init_state()
443
+ if history:
444
+ # Defensive copy — never mutate the caller's list.
445
+ state["messages"] = [dict(m) for m in history]
446
+ state["messages"].append({"role": "user", "content": message.strip()})
447
+
448
+ kwargs = build_api_kwargs(
449
+ state, system_prompt, functions_json_str,
450
+ think_level, temperature, max_tokens, top_p, rep_penalty,
451
+ )
452
+
453
+ last_ac, last_rc = "", ""
454
+ last_tca: list[dict] = []
455
+ yielded = False
456
+ for _ops, ac, rc, tca, _rid in stream_response(kwargs):
457
+ last_ac, last_rc, last_tca = ac, rc, tca
458
+ # Snapshot the in-flight assistant turn into a transient history
459
+ # view so streaming consumers see the assistant text growing.
460
+ in_flight = list(state["messages"])
461
+ in_flight.append({
462
+ "role": "assistant",
463
+ "content": ac or "",
464
+ **({"reasoning_content": rc} if rc else {}),
465
+ **({"tool_calls": list(tca)} if tca else {}),
466
+ })
467
+ yield ac or "", rc or "", list(tca), in_flight
468
+ yielded = True
469
+
470
+ finalize_response(state, last_ac, last_rc, last_tca)
471
+ final_history = list(state["messages"])
472
+
473
+ # Always emit a terminal frame so callers consuming only the last yield
474
+ # observe the persisted history (the in-flight snapshots above attach a
475
+ # provisional assistant message; this one is the canonical record).
476
+ if not yielded:
477
+ yield "", "", [], final_history
478
+ else:
479
+ yield last_ac or "", last_rc or "", list(last_tca), final_history
480
+
481
+
482
+ def new_chat(state):
483
+ """Reset the conversation.
484
+
485
+ Critical: when there's an in-flight stream we MUTATE the existing
486
+ state dict in place (bumping its epoch) instead of returning a brand
487
+ new dict. The streaming generator holds a reference to this same
488
+ dict and polls ``state["epoch"]`` between yields — the in-place
489
+ bump is what tells it to abandon the rest of its turn. Returning a
490
+ fresh dict would leave the generator pointed at a stale dict it
491
+ would happily keep streaming into.
492
+
493
+ We also push ``HY_IDLE_HTML`` to the busy marker so the Send button
494
+ unlocks immediately. Without this the cancelled generator never
495
+ yields its terminal IDLE frame, so the button would stay stuck
496
+ until the soft watchdog in static/app.js force-clears it (~4s).
497
+ """
498
+ if not state or not isinstance(state, dict) or not state.get("messages"):
499
+ gr.Info(t("info.new_chat"))
500
+ return gr.update(), state, gr.update()
501
+ new_epoch = reset_state_in_place(state)
502
+ return (
503
+ _delta([{"type": "reset"}], epoch=new_epoch),
504
+ state,
505
+ HY_IDLE_HTML,
506
+ )
507
+
508
+
509
+ # ─── submit_tool_result ──────────────────────────────────────────────────────
510
+ # Output slot order (must match app.py's ``tool_submit_outputs``):
511
+ # 0 chat_delta
512
+ # 1 state
513
+ # 2 tool_area
514
+ # 3 tool_call_info
515
+ # 4 tool_result_input
516
+ # 5 busy_marker
517
+ def submit_tool_result(
518
+ result_text, state,
519
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
520
+ functions_json_str,
521
+ ):
522
+ state = _ensure_state(state)
523
+ pending = state.get("pending_tool_calls", [])
524
+ if not pending:
525
+ yield (
526
+ gr.update(), state,
527
+ gr.update(visible=False), gr.update(), gr.update(),
528
+ HY_IDLE_HTML,
529
+ )
530
+ return
531
+
532
+ record_tool_result(state, pending[0], result_text)
533
+ state["pending_tool_calls"] = pending[1:]
534
+
535
+ if state["pending_tool_calls"]:
536
+ # More tool results still pending — keep the tool area visible,
537
+ # marker stays idle (we're not streaming yet).
538
+ remaining = state["pending_tool_calls"]
539
+ submitted = state.get("submitted_tool_results", [])
540
+ total = len(remaining) + len(submitted)
541
+ current_idx = len(submitted) + 1
542
+ tool_info_md = format_tool_call_prompt(remaining[0], current_idx, total)
543
+ yield (
544
+ gr.update(), state,
545
+ gr.update(visible=True), gr.update(value=tool_info_md), gr.update(value=""),
546
+ HY_IDLE_HTML,
547
+ )
548
+ return
549
+
550
+ flush_tool_results(state)
551
+ bubble_id = _next_bubble_id()
552
+ epoch = state["epoch"]
553
+
554
+ def _cancelled() -> bool:
555
+ return state.get("epoch") != epoch
556
+
557
+ # First streaming frame: open a new assistant bubble for the tool
558
+ # follow-up response, hide the tool area, set the busy marker.
559
+ yield (
560
+ _delta([{"type": "assistant_begin", "id": bubble_id}], epoch=epoch),
561
+ state,
562
+ gr.update(visible=False), gr.update(), gr.update(),
563
+ HY_BUSY_HTML,
564
+ )
565
+
566
+ try:
567
+ for ops, show_tool, tool_md in _run_streaming_turn(
568
+ state, bubble_id, epoch,
569
+ system_prompt, think_level, temperature, max_tokens, top_p, rep_penalty,
570
+ functions_json_str,
571
+ ):
572
+ if _cancelled():
573
+ return
574
+ payload = _delta(ops, epoch=epoch) if ops else _delta([], epoch=epoch)
575
+ if show_tool:
576
+ yield (
577
+ payload, state,
578
+ gr.update(visible=True), gr.update(value=tool_md), gr.update(value=""),
579
+ gr.update(),
580
+ )
581
+ else:
582
+ yield (
583
+ payload, state,
584
+ gr.update(visible=False), gr.update(), gr.update(),
585
+ gr.update(),
586
+ )
587
+
588
+ except Exception as e:
589
+ if _cancelled():
590
+ return
591
+ logger.exception("submit_tool_result failed: %s", e)
592
+ gr.Warning(t("warn.request_failed"))
593
+ yield (
594
+ _delta([{"type": "remove_bubble", "id": bubble_id}], epoch=epoch),
595
+ state,
596
+ gr.update(visible=False), gr.update(), gr.update(),
597
+ HY_IDLE_HTML,
598
+ )
599
+ return
600
+
601
+ if _cancelled():
602
+ return
603
+
604
+ yield (
605
+ gr.update(), state,
606
+ gr.update(), gr.update(), gr.update(),
607
+ HY_IDLE_HTML,
608
+ )
config.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+
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
+
26
+ # Configure logging once at import time. Kept module-level (rather than
27
+ # behind ``if __name__ == "__main__"``) because every module in the
28
+ # project does ``logging.getLogger(__name__)`` and expects the format /
29
+ # level to already be set up. The handler is only installed if no other
30
+ # handler exists — embedders that have already configured logging are
31
+ # left untouched.
32
+ if not logging.getLogger().handlers:
33
+ logging.basicConfig(
34
+ level=getattr(logging, LOG_LEVEL, logging.WARNING),
35
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
36
+ )
37
+ logger = logging.getLogger("Hy3-preview")
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
+ )
core/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-Python core: conversation state + streaming logic, no Gradio."""
2
+
3
+ from .chat import (
4
+ ChatState,
5
+ init_state,
6
+ build_messages_for_api,
7
+ build_api_kwargs,
8
+ stream_response,
9
+ finalize_response,
10
+ record_tool_result,
11
+ flush_tool_results,
12
+ )
13
+
14
+ __all__ = [
15
+ "ChatState",
16
+ "init_state",
17
+ "build_messages_for_api",
18
+ "build_api_kwargs",
19
+ "stream_response",
20
+ "finalize_response",
21
+ "record_tool_result",
22
+ "flush_tool_results",
23
+ ]
core/chat.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import logging
5
+ import queue
6
+ import threading
7
+ import time
8
+ from typing import Any, Callable, Iterator, Optional, TypedDict
9
+
10
+ from config import MODEL, client
11
+ from tools import build_tools_list
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Monotonically-increasing epoch ids stamped on every per-session
16
+ # ``ChatState`` and bumped on reset. Streaming handlers capture the
17
+ # epoch at entry and check it between yields; if the user clicks "+"
18
+ # (``new_chat``) mid-stream, ``reset_state_in_place`` mutates the SAME
19
+ # state dict the running generator holds — bumping the epoch — so the
20
+ # generator notices on its next iteration and exits without emitting
21
+ # any further chat deltas.
22
+ _epoch_counter = itertools.count(1)
23
+
24
+
25
+ def _next_epoch() -> int:
26
+ return next(_epoch_counter)
27
+
28
+ # Floor interval between streaming yields. The per-yield cost on the
29
+ # wire is just a small delta payload, so 60ms ≈ 16 yields/sec — close
30
+ # to one yield per browser frame, which is the natural ceiling for
31
+ # human-perceptible smoothness anyway.
32
+ _YIELD_INTERVAL = 0.06
33
+
34
+ # How often to emit a keep-alive yield when no new chunks arrive. This keeps
35
+ # the SSE/WebSocket connection alive through reverse-proxy idle timeouts
36
+ # (e.g. HuggingFace Spaces proxy). Without heartbeats, a long pause between
37
+ # reasoning and content chunks can cause the proxy to drop the connection,
38
+ # silently terminating the generator.
39
+ _HEARTBEAT_INTERVAL = 5.0
40
+
41
+ # Sentinel placed in the chunk queue when the streaming thread finishes.
42
+ _STREAM_DONE = object()
43
+
44
+
45
+ def _drain_queue(q: queue.Queue) -> list:
46
+ """Pull every item currently in *q* without blocking. Empty list if none."""
47
+ out: list = []
48
+ while True:
49
+ try:
50
+ out.append(q.get_nowait())
51
+ except queue.Empty:
52
+ return out
53
+
54
+
55
+ class ChatState(TypedDict, total=False):
56
+ messages: list[dict]
57
+ context_start_index: int
58
+ pending_tool_calls: list[dict]
59
+ pending_assistant_msg: Optional[dict]
60
+ submitted_tool_results: list[dict]
61
+ epoch: int
62
+
63
+
64
+ def init_state() -> ChatState:
65
+ """Fresh per-session conversation state.
66
+
67
+ Note: there is intentionally NO server-side ``is_streaming`` flag. The
68
+ "model is busy" signal is owned entirely by the UI: a click on Send
69
+ instantly disables the Send button via a ``queue=False`` Gradio chain
70
+ BEFORE the streaming generator is even queued, so a duplicate submission
71
+ is impossible regardless of network latency or queue order.
72
+
73
+ ``epoch`` is the cancellation token: bumped by ``reset_state_in_place``
74
+ when the user clicks "+" mid-stream so the running generator can
75
+ detect the reset and abandon further yields.
76
+ """
77
+ return {
78
+ "messages": [],
79
+ "context_start_index": 0,
80
+ "pending_tool_calls": [],
81
+ "pending_assistant_msg": None,
82
+ "submitted_tool_results": [],
83
+ "epoch": _next_epoch(),
84
+ }
85
+
86
+
87
+ def reset_state_in_place(state: ChatState) -> int:
88
+ """Reset *state* in place and bump its epoch. Returns the new epoch.
89
+
90
+ Critical: this MUTATES the caller's dict instead of returning a fresh
91
+ one. A streaming generator started before the reset still holds a
92
+ reference to this same dict — the in-place mutation is what lets it
93
+ observe the bumped epoch and stop yielding chat deltas. Returning a
94
+ new dict (and asking Gradio to swap it into the State component)
95
+ would leave the in-flight generator pointed at a stale dict it would
96
+ happily keep streaming into.
97
+ """
98
+ state["messages"] = []
99
+ state["context_start_index"] = 0
100
+ state["pending_tool_calls"] = []
101
+ state["pending_assistant_msg"] = None
102
+ state["submitted_tool_results"] = []
103
+ state["epoch"] = _next_epoch()
104
+ return state["epoch"]
105
+
106
+
107
+ def get_context_messages(state: ChatState) -> list[dict]:
108
+ return state["messages"][state["context_start_index"]:]
109
+
110
+
111
+ def build_messages_for_api(state: ChatState, system_prompt: str) -> list[dict]:
112
+ context = get_context_messages(state)
113
+ if system_prompt and system_prompt.strip():
114
+ return [{"role": "system", "content": system_prompt.strip()}] + context
115
+ return list(context)
116
+
117
+
118
+ def build_api_kwargs(
119
+ state: ChatState,
120
+ system_prompt: str,
121
+ functions_json_str: Optional[str],
122
+ think_level: Optional[str],
123
+ temperature: Optional[float],
124
+ max_tokens: int,
125
+ top_p: Optional[float],
126
+ rep_penalty: Optional[float],
127
+ ) -> dict:
128
+ """Build the kwargs dict passed to ``client.chat.completions.create``."""
129
+ api_messages = build_messages_for_api(state, system_prompt)
130
+ tools = build_tools_list(functions_json_str)
131
+
132
+ extra_body: dict = {}
133
+ if rep_penalty is not None and float(rep_penalty) != 0:
134
+ extra_body["repetition_penalty"] = float(rep_penalty)
135
+
136
+ kwargs: dict = dict(
137
+ model=MODEL,
138
+ messages=api_messages,
139
+ stream=True,
140
+ max_tokens=int(max_tokens),
141
+ reasoning_effort=think_level or "no_think",
142
+ )
143
+ if temperature is not None and float(temperature) != 0:
144
+ kwargs["temperature"] = float(temperature)
145
+ if top_p is not None and float(top_p) != 0:
146
+ kwargs["top_p"] = float(top_p)
147
+ if extra_body:
148
+ kwargs["extra_body"] = extra_body
149
+ if tools:
150
+ kwargs["tools"] = tools
151
+ return kwargs
152
+
153
+
154
+ def _accumulate_tool_call(tool_calls_acc: list[dict], delta_tcs: list[Any]) -> None:
155
+ """Merge streamed tool-call deltas into the accumulator."""
156
+ for tc in delta_tcs:
157
+ idx = getattr(tc, "index", 0) or 0
158
+ while len(tool_calls_acc) <= idx:
159
+ tool_calls_acc.append(
160
+ {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
161
+ )
162
+ if tc.id:
163
+ tool_calls_acc[idx]["id"] = tc.id
164
+ if tc.function:
165
+ if tc.function.name:
166
+ tool_calls_acc[idx]["function"]["name"] += tc.function.name
167
+ if tc.function.arguments:
168
+ tool_calls_acc[idx]["function"]["arguments"] += tc.function.arguments
169
+
170
+
171
+ def _stream_worker(
172
+ kwargs: dict,
173
+ chunk_queue: queue.Queue,
174
+ ) -> None:
175
+ """Background thread: run the API call and feed chunks into *chunk_queue*."""
176
+ try:
177
+ stream = client.chat.completions.create(**kwargs)
178
+ for chunk in stream:
179
+ chunk_queue.put(chunk)
180
+ except Exception as exc:
181
+ chunk_queue.put(exc)
182
+ finally:
183
+ chunk_queue.put(_STREAM_DONE)
184
+
185
+
186
+ # Hard ceilings: if no chunk has arrived for this long AND the worker thread
187
+ # hasn't terminated, we abandon the stream so the UI lock can release. With a
188
+ # healthy heartbeat the worker normally posts STREAM_DONE within seconds of
189
+ # the model finishing, but reverse proxies / network blips can occasionally
190
+ # leave the SSE connection in a half-open state that hangs ``for chunk in
191
+ # stream`` indefinitely. Capping the wait guarantees ``send_message`` always
192
+ # reaches its final yield (and therefore re-enables the Send button).
193
+ #
194
+ # Two separate ceilings because the two phases have very different shapes:
195
+ # * Before the first chunk the model may be doing reasoning / queueing /
196
+ # KV-cache warmup, so we allow a generous 30s first-token budget.
197
+ # * Once tokens are flowing we expect them to keep flowing; a 15s gap with
198
+ # nothing arriving (and no STREAM_DONE) almost certainly means the SSE
199
+ # socket is dead.
200
+ _FIRST_CHUNK_TIMEOUT = 60.0
201
+ _INTER_CHUNK_TIMEOUT = 15.0
202
+
203
+
204
+ # Op type constants — keep in sync with static/chat.js.
205
+ OP_REASONING_DELTA = "reasoning_delta"
206
+ OP_CONTENT_DELTA = "content_delta"
207
+ OP_TOOL_CALLS = "tool_calls"
208
+
209
+
210
+ def stream_response(
211
+ kwargs: dict,
212
+ is_cancelled: Optional[Callable[[], bool]] = None,
213
+ ) -> Iterator[tuple[list[dict], str, str, list[dict], str]]:
214
+ """Stream chunks from the API and yield delta-op batches.
215
+
216
+ The actual HTTP stream runs in a daemon thread so that the generator can
217
+ emit keep-alive yields during API-side pauses (model thinking, network
218
+ hiccups, etc.). Without these heartbeats the SSE connection between the
219
+ browser and a reverse proxy (e.g. HuggingFace Spaces) may be dropped
220
+ for inactivity, silently killing the generator mid-response.
221
+
222
+ Drain coalescing
223
+ ----------------
224
+ Each iteration drains EVERY chunk currently buffered into a single batch
225
+ and emits one yield reflecting the merged deltas. Under back-pressure the
226
+ yield rate naturally collapses (more chunks per yield) without losing
227
+ data — the deltas accumulate in ``pending_*`` strings until the next
228
+ successful yield can drain them.
229
+
230
+ Two early-exit paths protect the UI from getting stuck:
231
+
232
+ * As soon as we see ``finish_reason`` we drain whatever is already in
233
+ the queue without blocking, then break. The model has logically
234
+ finished; waiting on the SSE socket close would only lengthen the
235
+ visible "stuck" window.
236
+ * Two timeout safety nets force a break if the stream stalls while
237
+ the worker is still technically alive.
238
+
239
+ Yields ``(ops, assistant_total, reasoning_total, tool_calls, request_id)``
240
+ where ``ops`` is the list of delta dicts since the previous yield.
241
+ Heartbeat yields produce an empty ``ops`` list — callers should treat
242
+ that as "no new content but the stream is still healthy".
243
+ """
244
+ assistant_content = ""
245
+ reasoning_content = ""
246
+ tool_calls_acc: list[dict] = []
247
+ request_id = ""
248
+
249
+ # Pending-since-last-yield deltas. Persist across drain iterations so
250
+ # a throttle-suppressed yield doesn't lose the chars; the next yield
251
+ # picks them up.
252
+ pending_reasoning = ""
253
+ pending_content = ""
254
+ tool_calls_dirty = False
255
+
256
+ chunk_q: queue.Queue = queue.Queue()
257
+ worker = threading.Thread(
258
+ target=_stream_worker, args=(kwargs, chunk_q), daemon=True,
259
+ )
260
+ worker.start()
261
+
262
+ saw_finish_reason = False
263
+
264
+ def take_ops() -> list[dict]:
265
+ """Drain pending deltas into an ops list; return [] if nothing pending."""
266
+ nonlocal pending_reasoning, pending_content, tool_calls_dirty
267
+ ops: list[dict] = []
268
+ if pending_reasoning:
269
+ ops.append({"type": OP_REASONING_DELTA, "delta": pending_reasoning})
270
+ pending_reasoning = ""
271
+ if pending_content:
272
+ ops.append({"type": OP_CONTENT_DELTA, "delta": pending_content})
273
+ pending_content = ""
274
+ if tool_calls_dirty:
275
+ ops.append({"type": OP_TOOL_CALLS, "tool_calls": list(tool_calls_acc)})
276
+ tool_calls_dirty = False
277
+ return ops
278
+
279
+ def apply_chunk(chunk) -> bool:
280
+ """Fold a single API chunk into the accumulators.
281
+
282
+ Returns True when this chunk produced visible-state changes
283
+ (content, reasoning, or tool-call deltas). Sets the outer
284
+ ``saw_finish_reason`` / ``request_id`` as a side effect.
285
+ """
286
+ nonlocal request_id, reasoning_content, assistant_content
287
+ nonlocal pending_reasoning, pending_content, tool_calls_dirty
288
+ nonlocal saw_finish_reason
289
+ if not request_id and getattr(chunk, "id", None):
290
+ request_id = chunk.id
291
+ if not chunk.choices:
292
+ return False
293
+ choice = chunk.choices[0]
294
+ delta = choice.delta
295
+ if getattr(choice, "finish_reason", None):
296
+ saw_finish_reason = True
297
+
298
+ changed = False
299
+ rc = getattr(delta, "reasoning_content", None)
300
+ if rc:
301
+ reasoning_content += rc
302
+ pending_reasoning += rc
303
+ changed = True
304
+ if delta.content:
305
+ assistant_content += delta.content
306
+ pending_content += delta.content
307
+ changed = True
308
+ if getattr(delta, "tool_calls", None):
309
+ _accumulate_tool_call(tool_calls_acc, delta.tool_calls)
310
+ tool_calls_dirty = True
311
+ changed = True
312
+ return changed
313
+
314
+ last_yield_at = 0.0
315
+ last_chunk_at = time.monotonic()
316
+ got_first_chunk = False
317
+ yielded = False
318
+ done = False
319
+
320
+ while not done:
321
+ # Cancellation check — caller (e.g. ``new_chat``) bumped the
322
+ # session epoch, so abandon the stream WITHOUT a final yield.
323
+ # The worker thread keeps running until the upstream API closes
324
+ # the connection, but its chunks pile harmlessly into the
325
+ # garbage-collected queue once we return.
326
+ if is_cancelled is not None and is_cancelled():
327
+ logger.debug("stream cancelled by caller, abandoning")
328
+ return
329
+
330
+ # ── block for the next item, with heartbeat / stall guards ──
331
+ try:
332
+ first = chunk_q.get(timeout=_HEARTBEAT_INTERVAL)
333
+ except queue.Empty:
334
+ if not worker.is_alive() and chunk_q.empty():
335
+ break
336
+ stall_budget = (
337
+ _INTER_CHUNK_TIMEOUT if got_first_chunk else _FIRST_CHUNK_TIMEOUT
338
+ )
339
+ if time.monotonic() - last_chunk_at > stall_budget:
340
+ logger.warning(
341
+ "stream stalled %.1fs with no chunks (%s), abandoning",
342
+ stall_budget,
343
+ "inter-chunk" if got_first_chunk else "first-chunk",
344
+ )
345
+ break
346
+ # Heartbeat: re-emit current state with empty ops so Gradio
347
+ # ships an SSE frame and the upstream proxy doesn't consider
348
+ # the channel idle. The empty-ops frame is ~70 bytes and the
349
+ # client treats it as a noop.
350
+ yield [], assistant_content, reasoning_content, tool_calls_acc, request_id
351
+ yielded = True
352
+ last_yield_at = time.monotonic()
353
+ continue
354
+
355
+ # ── coalesce: pull every chunk currently buffered ──
356
+ batch = [first] + _drain_queue(chunk_q)
357
+
358
+ for item in batch:
359
+ if item is _STREAM_DONE:
360
+ done = True
361
+ continue
362
+ if isinstance(item, Exception):
363
+ raise item
364
+ last_chunk_at = time.monotonic()
365
+ got_first_chunk = True
366
+ apply_chunk(item)
367
+
368
+ # ── one throttled yield per drained batch ──
369
+ # Force-emit on done / finish so the final state always ships.
370
+ if pending_reasoning or pending_content or tool_calls_dirty:
371
+ now = time.monotonic()
372
+ if done or saw_finish_reason or now - last_yield_at >= _YIELD_INTERVAL:
373
+ ops = take_ops()
374
+ yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
375
+ yielded = True
376
+ last_yield_at = now
377
+
378
+ # ── finish_reason fast-exit ──
379
+ # Model has logically finished. Drain anything still buffered and
380
+ # exit. Don't wait on the SSE socket close.
381
+ if saw_finish_reason and not done:
382
+ for item in _drain_queue(chunk_q):
383
+ if item is _STREAM_DONE:
384
+ break
385
+ if isinstance(item, Exception):
386
+ raise item
387
+ apply_chunk(item)
388
+ ops = take_ops()
389
+ if ops:
390
+ yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
391
+ yielded = True
392
+ break
393
+
394
+ # Final flush — guarantee callers always observe terminal accumulator
395
+ # values, even when every prior content yield was suppressed by the
396
+ # throttle (e.g. a tiny response that finished within the floor).
397
+ ops = take_ops()
398
+ if ops or not yielded:
399
+ yield ops, assistant_content, reasoning_content, tool_calls_acc, request_id
400
+
401
+
402
+ def finalize_response(
403
+ state: ChatState,
404
+ assistant_content: str,
405
+ reasoning_content: str,
406
+ tool_calls_acc: list[dict],
407
+ ) -> tuple[bool, list[dict]]:
408
+ """Persist the final assistant message into ``state``.
409
+
410
+ Returns ``(has_pending_tool_calls, pending_tool_calls)``. The Gradio
411
+ adapter is responsible for turning ``pending_tool_calls`` into UI
412
+ updates (see ``chat.py``).
413
+ """
414
+ assistant_msg: dict = {"role": "assistant", "content": assistant_content or None}
415
+ if reasoning_content:
416
+ assistant_msg["reasoning_content"] = reasoning_content
417
+
418
+ if tool_calls_acc:
419
+ assistant_msg["tool_calls"] = tool_calls_acc
420
+ state["messages"].append(assistant_msg)
421
+ state["pending_tool_calls"] = list(tool_calls_acc)
422
+ state["submitted_tool_results"] = []
423
+ state["pending_assistant_msg"] = assistant_msg
424
+ logger.debug("queued %d tool call(s)", len(tool_calls_acc))
425
+ return True, list(tool_calls_acc)
426
+
427
+ state["messages"].append(assistant_msg)
428
+ return False, []
429
+
430
+
431
+ def record_tool_result(state: ChatState, tool_call: Any, result_text: str) -> None:
432
+ """Record a single tool-call result in the pending queue."""
433
+ tc_id = tool_call["id"] if isinstance(tool_call, dict) else tool_call.id
434
+ state.setdefault("submitted_tool_results", []).append({
435
+ "role": "tool",
436
+ "tool_call_id": tc_id,
437
+ "content": result_text or "",
438
+ })
439
+
440
+
441
+ def flush_tool_results(state: ChatState) -> None:
442
+ """Move queued tool results into the main message log."""
443
+ for msg in state.get("submitted_tool_results", []):
444
+ state["messages"].append(msg)
445
+ state["submitted_tool_results"] = []
446
+ state["pending_assistant_msg"] = None
display.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers that turn raw model output into Markdown for the chatbot widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ from i18n import t
10
+
11
+ # KaTeX renders negation macros (\neq, \notin, …) via \not + SVG slash
12
+ # overlays. The chat bubble's inflated line-height stretches those SVGs
13
+ # into solid-black rectangles. Replacing with Unicode avoids the SVG
14
+ # path entirely — KaTeX renders the single glyph from its math fonts
15
+ # instead.
16
+ #
17
+ # The dict covers both single-macro forms (\neq) and \not compound forms
18
+ # (\not=, \not\equiv). Sorted longest-first when building the regex so
19
+ # longer patterns match before shorter prefixes.
20
+ _NEGATION_UNICODE: dict[str, str] = {
21
+ # \not + symbol compounds
22
+ r"\not\subseteq": "⊈",
23
+ r"\not\supseteq": "⊉",
24
+ r"\not\parallel": "∦",
25
+ r"\not\approx": "≉",
26
+ r"\not\subset": "⊄",
27
+ r"\not\supset": "⊅",
28
+ r"\not\simeq": "≄",
29
+ r"\not\exists": "∄",
30
+ r"\not\equiv": "≢",
31
+ r"\not\cong": "≇",
32
+ r"\not\leq": "≰",
33
+ r"\not\geq": "≱",
34
+ r"\not\sim": "≁",
35
+ r"\not\mid": "∤",
36
+ r"\not\le": "≰",
37
+ r"\not\ge": "≱",
38
+ r"\not\in": "∉",
39
+ r"\not\ni": "∌",
40
+ r"\not=": "≠",
41
+ r"\not<": "≮",
42
+ r"\not>": "≯",
43
+ # Standard single-macro negations
44
+ r"\nsubseteq": "⊈",
45
+ r"\nsupseteq": "⊉",
46
+ r"\nparallel": "∦",
47
+ r"\nexists": "∄",
48
+ r"\nequiv": "≢",
49
+ r"\notin": "∉",
50
+ r"\nless": "≮",
51
+ r"\nleq": "≰",
52
+ r"\ngeq": "≱",
53
+ r"\ngtr": "≯",
54
+ r"\nmid": "∤",
55
+ r"\ncong": "≇",
56
+ r"\nsim": "≁",
57
+ r"\neq": "≠",
58
+ r"\ne": "≠",
59
+ }
60
+ _NEGATION_RE = re.compile(
61
+ "("
62
+ + "|".join(
63
+ re.escape(m)
64
+ for m in sorted(_NEGATION_UNICODE, key=len, reverse=True)
65
+ )
66
+ + r")(?![a-zA-Z])",
67
+ )
68
+
69
+
70
+ _NOT_SPACE_RE = re.compile(r"\\not\s+(?=[\[=<>]|\\[a-zA-Z])")
71
+
72
+
73
+ def _replace_negation_macros(text: str) -> str:
74
+ """Swap negation macros for Unicode glyphs to bypass SVG overlays."""
75
+ # Collapse optional whitespace after \not so \not \equiv → \not\equiv
76
+ text = _NOT_SPACE_RE.sub(r"\\not", text)
77
+ return _NEGATION_RE.sub(lambda m: _NEGATION_UNICODE[m.group(1)], text)
78
+
79
+
80
+ # LaTeX environments that should be treated as display math.
81
+ _MATH_ENVS = (
82
+ "equation", "align", "aligned", "gather", "gathered",
83
+ "multline", "cases", "array", "split",
84
+ "matrix", "bmatrix", "pmatrix", "vmatrix", "Vmatrix",
85
+ "eqnarray",
86
+ )
87
+ _ENV_NAMES = "|".join(_MATH_ENVS)
88
+
89
+ # Matches \begin{env}...\end{env} blocks NOT already inside $$ delimiters.
90
+ # Uses a backreference (\2) to ensure begin/end names match.
91
+ _BARE_ENV_RE = re.compile(
92
+ r"(?<!\$)"
93
+ r"(\\begin\{(" + _ENV_NAMES + r")\*?\}"
94
+ r"[\s\S]*?"
95
+ r"\\end\{\2\*?\})"
96
+ r"(?!\$)",
97
+ )
98
+
99
+
100
+ def _wrap_bare_latex_envs(text: str) -> str:
101
+ r"""Wrap bare \begin{env}...\end{env} blocks in $$ for KaTeX.
102
+
103
+ Models sometimes output LaTeX environments without wrapping them in
104
+ display-math delimiters. KaTeX only renders them when they appear
105
+ inside ``$$...$$`` or ``\[...\]``.
106
+ """
107
+ return _BARE_ENV_RE.sub(r"\n\n$$\1$$\n\n", text)
108
+
109
+
110
+ def _escape_latex_delimiters(text: str) -> str:
111
+ r"""Replace LaTeX delimiters with HTML entities so a downstream
112
+ Markdown / KaTeX pass does not match them across the <details>
113
+ boundary.
114
+
115
+ Covers ``\(...\)``, ``\[...\]``, ``$...$``, and ``$$...$$``.
116
+ """
117
+ return (
118
+ text
119
+ .replace("\\(", "&#92;(")
120
+ .replace("\\)", "&#92;)")
121
+ .replace("\\[", "&#92;[")
122
+ .replace("\\]", "&#92;]")
123
+ .replace("$", "&#36;")
124
+ )
125
+
126
+
127
+ def preprocess_latex(text: str) -> str:
128
+ """Normalize model output so KaTeX renders math correctly.
129
+
130
+ Applied to the assistant *content* (not thinking) before display.
131
+ """
132
+ text = _replace_negation_macros(text)
133
+ text = _wrap_bare_latex_envs(text)
134
+ return text
135
+
136
+
137
+ def build_display_content(reasoning: str, content: str) -> str:
138
+ """Wrap reasoning + content into a collapsible Markdown block."""
139
+ parts: list[str] = []
140
+ if reasoning:
141
+ is_complete = bool(content)
142
+ summary = t("display.thinking_done") if is_complete else t("display.thinking")
143
+ open_attr = "" if is_complete else " open"
144
+ safe_reasoning = _escape_latex_delimiters(reasoning.strip())
145
+ parts.append(
146
+ f'<details class="thinking-block"{open_attr}>'
147
+ f'<summary>{summary}</summary>\n\n{safe_reasoning}\n\n</details>\n\n'
148
+ )
149
+ if content:
150
+ parts.append(preprocess_latex(content))
151
+ return "".join(parts)
152
+
153
+
154
+ def format_tool_calls_for_display(tool_calls: list[Any]) -> str:
155
+ return "\n\n".join(_format_single_tool_call(tc) for tc in tool_calls)
156
+
157
+
158
+ def format_tool_call_prompt(tc: Any, current_idx: int, total: int) -> str:
159
+ header = f"**{t('tool.call_header', i=current_idx, n=total)}**\n\n"
160
+ return header + _format_single_tool_call(tc)
161
+
162
+
163
+ def _format_single_tool_call(tc: Any) -> str:
164
+ if isinstance(tc, dict):
165
+ name = tc["function"]["name"]
166
+ args_raw = tc["function"]["arguments"]
167
+ else:
168
+ name = tc.function.name
169
+ args_raw = tc.function.arguments
170
+ try:
171
+ args_formatted = json.dumps(json.loads(args_raw), indent=2, ensure_ascii=False)
172
+ except (json.JSONDecodeError, TypeError):
173
+ args_formatted = args_raw
174
+ return f"{t('tool.call_label')}: **{name}**\n```json\n{args_formatted}\n```"
i18n.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny dictionary-based i18n helper.
2
+
3
+ Selects the active language from ``HY_LANG`` env var (falls back to ``LANG`` /
4
+ ``LC_ALL``). Anything starting with ``zh`` resolves to Simplified Chinese,
5
+ everything else to English.
6
+
7
+ Usage::
8
+
9
+ from i18n import t
10
+ label = t("send") # -> "Send" or "发送"
11
+ msg = t("warn.empty_msg") # nested-dotted keys
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ DEFAULT_LANG = "en"
23
+ SUPPORTED_LANGS = ("en", "zh")
24
+
25
+
26
+ def _detect_lang() -> str:
27
+ raw = (
28
+ os.environ.get("HY_LANG")
29
+ or os.environ.get("LC_ALL")
30
+ or os.environ.get("LANG")
31
+ or DEFAULT_LANG
32
+ )
33
+ raw = raw.strip().lower()
34
+ if raw.startswith("zh"):
35
+ return "zh"
36
+ if raw[:2] in SUPPORTED_LANGS:
37
+ return raw[:2]
38
+ return DEFAULT_LANG
39
+
40
+
41
+ LANG = _detect_lang()
42
+
43
+
44
+ # ─── Dictionary ────────────────────────────────────────────────────────────
45
+ # Keys are flat dotted strings to keep lookup simple. Add new keys below in
46
+ # alphabetical groups for readability.
47
+ TRANSLATIONS: dict[str, dict[str, str]] = {
48
+ "en": {
49
+ # App chrome
50
+ "title": "Hy3-preview Chat Demo",
51
+ "title.notice_html": (
52
+ "The current demo version is offline; full-featured capabilities "
53
+ "will be available on the Tencent HY official website."
54
+ ),
55
+ "examples_heading": "What can I help you with?",
56
+ "msg_placeholder": "Type a message...",
57
+ # Sidebar
58
+ "sidebar.think_level": "Think level",
59
+ "sidebar.think_level.info": "Control reasoning depth",
60
+ "sidebar.system_prompt": "System prompt",
61
+ "sidebar.system_prompt.placeholder": "You are a helpful AI assistant...",
62
+ "sidebar.temperature": "Temperature",
63
+ "sidebar.temperature.info": "Higher values produce more random outputs",
64
+ "sidebar.max_tokens": "Max output tokens",
65
+ "sidebar.max_tokens.info": "Maximum tokens per response",
66
+ "sidebar.top_p": "Top P",
67
+ "sidebar.top_p.info": "Nucleus sampling probability threshold",
68
+ "sidebar.rep_penalty": "Repetition penalty",
69
+ "sidebar.rep_penalty.info": "Penalize repeated content",
70
+ "sidebar.functions": "🔧 Functions",
71
+ "sidebar.functions.label": "Function definitions (JSON array)",
72
+ "sidebar.validate_btn": "Validate & Format",
73
+ # Tool area
74
+ "tool.result_label": "Function result",
75
+ "tool.result_placeholder": "Enter function return value... (Enter to submit)",
76
+ "tool.submit": "Submit",
77
+ "tool.call_header": "Function call ({i}/{n})",
78
+ "tool.call_label": "🔧 Call function",
79
+ # Display
80
+ "display.thinking": "Thinking...",
81
+ "display.thinking_done": "Thinking complete",
82
+ "display.code_copy": "Copy",
83
+ "display.code_copied": "Copied",
84
+ # Warnings / info
85
+ "warn.empty_msg": "Please enter a message",
86
+ "warn.invalid_fn_json": "Invalid function JSON, please fix or clear before sending: {err}",
87
+ "warn.request_failed": "Request failed, please retry or adjust parameters",
88
+ "warn.model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
89
+ "info.new_chat": "Start a new chat",
90
+ "warn.fn.enter_json": "Please enter function definition JSON",
91
+ "warn.fn.invalid_format": "Invalid JSON format: {err}",
92
+ "warn.fn.must_be_array": "JSON must be an array [...] or a single object {{...}}",
93
+ "warn.fn.item_not_object": "Item {i} is not a JSON object",
94
+ "warn.fn.item_invalid": "Item {i} has invalid format, expected {{type, function}} or {{name, parameters}}",
95
+ "warn.fn.duplicate_name": "Duplicate function name '{name}'",
96
+ "info.fn.validation_passed": "Validation passed, {n} function(s): {names}",
97
+ },
98
+ "zh": {
99
+ "title": "Hy3-preview Chat Demo",
100
+ "title.notice_html": (
101
+ "The current demo version is offline; full-featured capabilities "
102
+ "will be available on the Tencent HY official website."
103
+ ),
104
+ "examples_heading": "今天我能帮你做点什么?",
105
+ "msg_placeholder": "输入消息...",
106
+ "sidebar.think_level": "思考等级",
107
+ "sidebar.think_level.info": "控制模型推理深度",
108
+ "sidebar.system_prompt": "系统提示词",
109
+ "sidebar.system_prompt.placeholder": "你是一个有帮助的 AI 助手...",
110
+ "sidebar.temperature": "Temperature",
111
+ "sidebar.temperature.info": "数值越高,回复越随机",
112
+ "sidebar.max_tokens": "最大输出 Tokens",
113
+ "sidebar.max_tokens.info": "单次回复的最大 token 数",
114
+ "sidebar.top_p": "Top P",
115
+ "sidebar.top_p.info": "核采样概率阈值",
116
+ "sidebar.rep_penalty": "重复惩��",
117
+ "sidebar.rep_penalty.info": "惩罚重复内容",
118
+ "sidebar.functions": "🔧 函数",
119
+ "sidebar.functions.label": "函数定义(JSON 数组)",
120
+ "sidebar.validate_btn": "校验 & 格式化",
121
+ "tool.result_label": "函数返回值",
122
+ "tool.result_placeholder": "请输入函数返回值...(回车提交)",
123
+ "tool.submit": "提交",
124
+ "tool.call_header": "函数调用 ({i}/{n})",
125
+ "tool.call_label": "🔧 调用函数",
126
+ "display.thinking": "思考中...",
127
+ "display.thinking_done": "已思考",
128
+ "display.code_copy": "复制",
129
+ "display.code_copied": "已复制",
130
+ "warn.empty_msg": "请输入消息内容",
131
+ "warn.invalid_fn_json": "函数 JSON 不合法,请先修正或清空: {err}",
132
+ "warn.request_failed": "请求失败,请重试或调整参数",
133
+ # Intentionally English for both locales: the warning should always
134
+ # appear in English regardless of the UI language.
135
+ "warn.model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
136
+ "info.new_chat": "已开启新会话",
137
+ "warn.fn.enter_json": "请输入函数定义 JSON",
138
+ "warn.fn.invalid_format": "JSON 格式错误: {err}",
139
+ "warn.fn.must_be_array": "JSON 必须是数组 [...] 或单个对象 {{...}}",
140
+ "warn.fn.item_not_object": "第 {i} 项不是 JSON 对象",
141
+ "warn.fn.item_invalid": "第 {i} 项格式不合法,期望 {{type, function}} 或 {{name, parameters}}",
142
+ "warn.fn.duplicate_name": "函数名 '{name}' 重复",
143
+ "info.fn.validation_passed": "校验通过,共 {n} 个函数: {names}",
144
+ },
145
+ }
146
+
147
+
148
+ def t(key: str, /, **fmt: Any) -> str:
149
+ """Translate ``key`` for the active language with optional formatting."""
150
+ table = TRANSLATIONS.get(LANG, TRANSLATIONS[DEFAULT_LANG])
151
+ template = table.get(key) or TRANSLATIONS[DEFAULT_LANG].get(key)
152
+ if template is None:
153
+ logger.warning("missing i18n key: %s", key)
154
+ return key
155
+ if not fmt:
156
+ return template
157
+ try:
158
+ return template.format(**fmt)
159
+ except (KeyError, IndexError):
160
+ logger.warning("i18n format failure for key=%s args=%r", key, fmt)
161
+ return template
pytest.ini ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ addopts = -ra -q
4
+ filterwarnings =
5
+ ignore::DeprecationWarning
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ openai>=1.66.0
static/app.js ADDED
@@ -0,0 +1,997 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable */
2
+ /**
3
+ * Gradio app bootstrap.
4
+ *
5
+ * The Python side injects window.HY_EXAMPLES / window.HY_I18N before this
6
+ * script runs. The custom chat renderer (static/chat.js) owns all DOM
7
+ * mutations under #hy-chat — this file only deals with the surrounding
8
+ * UX (overlay, scroll, send-button state, copy/preview buttons).
9
+ *
10
+ * Responsibilities
11
+ * ────────────────
12
+ * 1. Render the empty-state "What can I help you with?" overlay above the
13
+ * chat surface, with example prompts that auto-send when clicked.
14
+ * 2. Add a "▶ html preview" button below every Markdown HTML code block,
15
+ * and render the code in a sandboxed iframe modal.
16
+ * 3. Add a "Copy" button to the top-right of every <pre> code block.
17
+ * 4. Own scroll behaviour: follow-the-tail while streaming, full user
18
+ * control after streaming ends.
19
+ * 5. OWN the Send-button busy state. The server NEVER toggles the
20
+ * button. Instead, the streaming generators in chat.py drive a
21
+ * dedicated hidden ``gr.HTML`` component (``#hy-busy-marker``):
22
+ * while streaming → it contains a single
23
+ * ``<span data-hy-streaming="1">``; on the final yield → it's
24
+ * idle. We observe that one component and disable Send iff the
25
+ * marker span is present.
26
+ *
27
+ * The marker lives in its OWN component (not inside the chat) so
28
+ * unclosed ``<style>``/``<script>``/``<textarea>``/``<title>``/
29
+ * ``<noscript>`` in model output cannot hijack it.
30
+ */
31
+ (() => {
32
+ if (window.__HY_APP_INIT) return;
33
+ window.__HY_APP_INIT = true;
34
+
35
+ const EXAMPLES = (window.HY_EXAMPLES || []).slice();
36
+ const BUSY_MSG =
37
+ (window.HY_I18N && window.HY_I18N.model_busy) ||
38
+ "Model is still responding. Please wait for it to finish.";
39
+
40
+ // The chat surface lives at #hy-chat (built by static/chat.js into the
41
+ // value of the gr.HTML at #hy-chat-host). Throughout this file we treat
42
+ // #hy-chat as "the chatbot" — it's our custom replacement.
43
+ const CHAT_SELECTOR = "#hy-chat";
44
+
45
+ // CSS selector for the in-flight streaming marker. See chat.py
46
+ // (HY_BUSY_HTML) for the producer side.
47
+ const BUSY_MARKER_HOST_ID = "hy-busy-marker";
48
+ const STREAM_MARKER_SELECTOR =
49
+ '#' + BUSY_MARKER_HOST_ID + ' [data-hy-streaming="1"]';
50
+ // Explicit "server says idle" marker. Distinct from "no marker yet"
51
+ // (the host's initial empty value), so we can tell apart "still
52
+ // waiting for the first server frame" from "server already responded
53
+ // with idle" (e.g. validation short-circuit before any streaming).
54
+ const IDLE_MARKER_SELECTOR =
55
+ '#' + BUSY_MARKER_HOST_ID + ' [data-hy-busy="0"]';
56
+
57
+ // Set to ``performance.now()`` whenever the user clicks Send / hits Enter.
58
+ // Bridges the brief window between "user dispatched" and "first marker
59
+ // frame arrived" so a fast double-click can't sneak through.
60
+ let lastSendAt = 0;
61
+ const SEND_DEBOUNCE_MS = 400;
62
+
63
+ // Set to true the moment the user dispatches a send, cleared the moment
64
+ // either (a) the server's first busy-marker frame actually arrives, or
65
+ // (b) FIRST_FRAME_TIMEOUT_MS elapses (defensive timeout in case the
66
+ // request never lands — e.g. WebSocket dropped before the first frame).
67
+ //
68
+ // SEND_DEBOUNCE_MS alone is NOT enough: on a slow link the round-trip
69
+ // for the first frame can easily exceed 400ms and a second click slips
70
+ // through, causing two parallel streams (server-side concurrency_limit
71
+ // is 8 for the chat fn so the second request DOES run).
72
+ let awaitingFirstFrame = false;
73
+ const FIRST_FRAME_TIMEOUT_MS = 30 * 1000;
74
+
75
+ // Soft watchdog: if the busy marker is set but the chat DOM has been
76
+ // observably idle for this long AND it's been at least
77
+ // CHAT_IDLE_MIN_SINCE_SEND_MS since the user clicked Send, we force-
78
+ // clear the marker. Safety net for "the server's IDLE marker frame
79
+ // got coalesced/dropped on the wire".
80
+ const CHAT_IDLE_MS = 4000;
81
+ const CHAT_IDLE_MIN_SINCE_SEND_MS = 1500;
82
+
83
+ // Hard absolute cap. Defence-in-depth backstop in case the soft
84
+ // watchdog also fails for some reason.
85
+ const ABSOLUTE_BUSY_TIMEOUT_MS = 5 * 60 * 1000;
86
+
87
+ // Flipped to true on a real send dispatch so the scroll lock knows to
88
+ // re-enter follow mode for the next streaming turn even if the user had
89
+ // scrolled up during the previous turn.
90
+ let justSentNewMessage = false;
91
+
92
+ /* ── Chat mutation hub (rAF-batched) ───────────────────────────────────
93
+ *
94
+ * The chat DOM gets HAMMERED during streaming: every new token may
95
+ * mutate the trailing streaming segment. With each subsystem
96
+ * (scroll lock, code-block injector, busy-marker timestamp,
97
+ * empty-state visibility) installing its OWN MutationObserver,
98
+ * the per-mutation work multiplied by the number of subsystems
99
+ * was the dominant browser-side cost as conversations grew.
100
+ *
101
+ * We run a single MutationObserver and dispatch to subscribers via
102
+ * requestAnimationFrame. This caps total dispatches at ≤60/sec
103
+ * regardless of how many mutations the chat fires, and lets each
104
+ * subscriber see the WHOLE batch of mutations in one call instead
105
+ * of being woken N times in the same tick.
106
+ *
107
+ * Subscribers MUST be cheap — the rAF callback is on the critical
108
+ * path of the next paint. */
109
+ const chatSubscribers = [];
110
+ let chatPending = null;
111
+ let chatScheduled = false;
112
+
113
+ function subscribeChatMutations(cb) {
114
+ chatSubscribers.push(cb);
115
+ }
116
+
117
+ function startChatMutationHub(chatEl) {
118
+ new MutationObserver((mutations) => {
119
+ if (chatPending === null) chatPending = [];
120
+ // Ref-share the records — they're owned by the observer and we
121
+ // don't mutate them, just iterate.
122
+ for (let i = 0; i < mutations.length; i++) {
123
+ chatPending.push(mutations[i]);
124
+ }
125
+ if (chatScheduled) return;
126
+ chatScheduled = true;
127
+ requestAnimationFrame(() => {
128
+ chatScheduled = false;
129
+ const batch = chatPending;
130
+ chatPending = null;
131
+ for (let i = 0; i < chatSubscribers.length; i++) {
132
+ try {
133
+ chatSubscribers[i](batch);
134
+ } catch (err) {
135
+ console.error("[hy] chat subscriber error:", err);
136
+ }
137
+ }
138
+ });
139
+ }).observe(chatEl, {
140
+ childList: true,
141
+ subtree: true,
142
+ characterData: true,
143
+ });
144
+ }
145
+
146
+ /* ── Empty-state examples overlay ─────────────────────────────────────── */
147
+ function createOverlay() {
148
+ const chatEl = document.querySelector(CHAT_SELECTOR);
149
+ if (!chatEl) {
150
+ setTimeout(createOverlay, 500);
151
+ return;
152
+ }
153
+ if (document.getElementById("examples-overlay")) return;
154
+
155
+ const overlay = document.createElement("div");
156
+ overlay.id = "examples-overlay";
157
+
158
+ const wrapper = document.createElement("div");
159
+ wrapper.className = "examples-wrapper";
160
+
161
+ const heading = document.createElement("p");
162
+ heading.className = "examples-heading";
163
+ heading.textContent =
164
+ (window.HY_I18N && window.HY_I18N.examples_heading) ||
165
+ "What can I help you with?";
166
+ wrapper.appendChild(heading);
167
+
168
+ const grid = document.createElement("div");
169
+ grid.className = "examples-grid";
170
+
171
+ // Example-button label.
172
+ // * Collapse internal whitespace/newlines to single spaces so each
173
+ // card flows as one paragraph; the CSS 2-line clamp on
174
+ // .example-btn handles the visual cut-off + ellipsis.
175
+ // * Hard cap at LABEL_MAX_CHARS as defence-in-depth — the prompts
176
+ // can be multi-kilobyte system instructions and we don't want
177
+ // all of that in the DOM just to be invisibly clamped.
178
+ // * ``title`` shows the full text on hover.
179
+ // The FULL prompt is always what we dispatch to the model; this is
180
+ // a display-only transform.
181
+ const LABEL_MAX_CHARS = 200;
182
+ function makeLabel(text) {
183
+ const oneLine = String(text).replace(/\s+/g, " ").trim();
184
+ if (oneLine.length <= LABEL_MAX_CHARS) return oneLine;
185
+ return oneLine.slice(0, LABEL_MAX_CHARS).trimEnd() + "…";
186
+ }
187
+
188
+ EXAMPLES.forEach((text) => {
189
+ // Two-layer structure:
190
+ // .example-btn → flex container, owns size + centering
191
+ // .example-btn-text → -webkit-box, owns the 2-line clamp
192
+ // Splitting these is the only reliable way to get BOTH "vertical
193
+ // centering" (needs display:flex) AND "clamp to 2 lines with auto
194
+ // ellipsis" (needs display:-webkit-box) on the same card. Single-
195
+ // element setups force a choice and we always lost one.
196
+ const btn = document.createElement("div");
197
+ btn.className = "example-btn";
198
+ const inner = document.createElement("span");
199
+ inner.className = "example-btn-text";
200
+ inner.textContent = makeLabel(text);
201
+ btn.appendChild(inner);
202
+ btn.title = text;
203
+ btn.addEventListener("click", () => {
204
+ const textarea = document.querySelector(".msg-input textarea");
205
+ if (!textarea) return;
206
+ textarea.focus();
207
+ textarea.select();
208
+ document.execCommand("insertText", false, text);
209
+ setTimeout(() => {
210
+ const sendBtn = document.querySelector("#send-btn");
211
+ if (sendBtn) sendBtn.click();
212
+ }, 200);
213
+ });
214
+ grid.appendChild(btn);
215
+ });
216
+
217
+ wrapper.appendChild(grid);
218
+ overlay.appendChild(wrapper);
219
+
220
+ function positionOverlay() {
221
+ const r = chatEl.getBoundingClientRect();
222
+ overlay.style.top = `${r.top}px`;
223
+ overlay.style.left = `${r.left}px`;
224
+ overlay.style.width = `${r.width}px`;
225
+ overlay.style.height = `${r.height}px`;
226
+ }
227
+ positionOverlay();
228
+ document.body.appendChild(overlay);
229
+
230
+ function syncVisibility() {
231
+ const msgs = chatEl.querySelectorAll(".message-row");
232
+ overlay.style.display = msgs.length > 0 ? "none" : "";
233
+ }
234
+ syncVisibility();
235
+ subscribeChatMutations(syncVisibility);
236
+
237
+ window.addEventListener("resize", positionOverlay);
238
+ new ResizeObserver(positionOverlay).observe(chatEl);
239
+ }
240
+
241
+ /* ── HTML preview modal ───────────────────────────────────────────────── */
242
+ function openHtmlPreview(htmlCode) {
243
+ const existing = document.querySelector(".html-preview-overlay");
244
+ if (existing) existing.remove();
245
+
246
+ const overlay = document.createElement("div");
247
+ overlay.className = "html-preview-overlay";
248
+ overlay.addEventListener("click", (e) => {
249
+ if (e.target === overlay) overlay.remove();
250
+ });
251
+
252
+ const modal = document.createElement("div");
253
+ modal.className = "html-preview-modal";
254
+
255
+ const header = document.createElement("div");
256
+ header.className = "html-preview-header";
257
+
258
+ const title = document.createElement("span");
259
+ title.className = "html-preview-title";
260
+ title.textContent = "HTML Preview";
261
+
262
+ const closeBtn = document.createElement("button");
263
+ closeBtn.className = "html-preview-close";
264
+ closeBtn.innerHTML = "&times;";
265
+ closeBtn.addEventListener("click", () => overlay.remove());
266
+
267
+ header.appendChild(title);
268
+ header.appendChild(closeBtn);
269
+
270
+ const iframe = document.createElement("iframe");
271
+ iframe.className = "html-preview-iframe";
272
+ // Intentionally NOT including allow-same-origin: that would let the
273
+ // user-supplied HTML reach into the parent page and steal cookies/state.
274
+ iframe.sandbox = "allow-scripts allow-modals allow-forms allow-popups";
275
+
276
+ modal.appendChild(header);
277
+ modal.appendChild(iframe);
278
+ overlay.appendChild(modal);
279
+ document.body.appendChild(overlay);
280
+
281
+ iframe.srcdoc = htmlCode;
282
+
283
+ document.addEventListener("keydown", function escHandler(e) {
284
+ if (e.key === "Escape") {
285
+ overlay.remove();
286
+ document.removeEventListener("keydown", escHandler);
287
+ }
288
+ });
289
+ }
290
+
291
+ const HTML_TAG_RE =
292
+ /<\/(div|body|html|section|main|head|style|script|p|h[1-6]|span|table|form|ul|ol|nav|header|footer|article)>/i;
293
+
294
+ function looksLikeHtml(text) {
295
+ const trimmed = text.trim();
296
+ if (!trimmed) return false;
297
+ if (
298
+ trimmed.startsWith("<!DOCTYPE") ||
299
+ trimmed.startsWith("<!doctype") ||
300
+ trimmed.startsWith("<html")
301
+ ) {
302
+ return true;
303
+ }
304
+ return (
305
+ trimmed.startsWith("<") &&
306
+ trimmed.endsWith(">") &&
307
+ HTML_TAG_RE.test(trimmed)
308
+ );
309
+ }
310
+
311
+ function injectPreviewButtonFor(codeEl) {
312
+ // ``previewBtnInserted`` — set once on the codeEl after we attach a
313
+ // button. Must NEVER be set on a code element living inside
314
+ // ``.hy-streaming`` because that container's innerHTML is replaced
315
+ // on every delta — the codeEl gets discarded and a fresh one (no
316
+ // flag) appears, while the button (a sibling outside .hy-streaming)
317
+ // would silently leak. We only inject for code blocks that have
318
+ // settled into ``.hy-frozen`` (append-only, never re-rendered).
319
+ if (codeEl.dataset.previewBtnInserted) {
320
+ codeEl.__hyPreviewText = codeEl.textContent || "";
321
+ return;
322
+ }
323
+
324
+ const pre = codeEl.closest("pre");
325
+ if (!pre) return;
326
+
327
+ // Skip code blocks still in the streaming half. They'll be
328
+ // re-evaluated once the freeze boundary advances past them and the
329
+ // pre is moved into ``.hy-frozen`` as a fresh DOM node — at which
330
+ // point this MutationObserver fires again with the new pre as an
331
+ // added node and we inject exactly once.
332
+ const frozenAncestor = pre.closest(".hy-frozen");
333
+ if (!frozenAncestor) return;
334
+
335
+ const text = codeEl.textContent || "";
336
+ if (!looksLikeHtml(text)) return;
337
+
338
+ codeEl.dataset.previewBtnInserted = "true";
339
+ codeEl.__hyPreviewText = text;
340
+
341
+ const btn = document.createElement("button");
342
+ btn.className = "html-preview-btn";
343
+ btn.title = "Preview HTML";
344
+ btn.addEventListener("click", (e) => {
345
+ e.stopPropagation();
346
+ e.preventDefault();
347
+ openHtmlPreview(codeEl.__hyPreviewText || codeEl.textContent || "");
348
+ });
349
+
350
+ // Insert directly after the <pre> inside the same .hy-frozen
351
+ // container. This binds the button's lifecycle to the specific
352
+ // code block (instead of dumping all preview buttons into b.md as
353
+ // siblings of .hy-frozen / .hy-streaming) and keeps its position
354
+ // visually anchored to its source block.
355
+ pre.parentElement.insertBefore(btn, pre.nextSibling);
356
+ }
357
+
358
+ /* ── Code-block chrome (header w/ lang label + copy button) ───────────── */
359
+ function copyText(text) {
360
+ if (navigator.clipboard && navigator.clipboard.writeText) {
361
+ return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
362
+ }
363
+ return fallbackCopy(text);
364
+ }
365
+
366
+ function fallbackCopy(text) {
367
+ return new Promise((resolve, reject) => {
368
+ try {
369
+ const ta = document.createElement("textarea");
370
+ ta.value = text;
371
+ ta.setAttribute("readonly", "");
372
+ ta.style.position = "absolute";
373
+ ta.style.left = "-9999px";
374
+ document.body.appendChild(ta);
375
+ ta.select();
376
+ document.execCommand("copy");
377
+ document.body.removeChild(ta);
378
+ resolve();
379
+ } catch (err) {
380
+ reject(err);
381
+ }
382
+ });
383
+ }
384
+
385
+ const COPY_ICON_SVG =
386
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
387
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
388
+ 'stroke-linejoin="round" aria-hidden="true">' +
389
+ '<rect x="9" y="9" width="11" height="11" rx="2" ry="2"></rect>' +
390
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' +
391
+ "</svg>";
392
+ const CHECK_ICON_SVG =
393
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
394
+ 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" ' +
395
+ 'stroke-linejoin="round" aria-hidden="true">' +
396
+ '<polyline points="4 12 10 18 20 6"></polyline>' +
397
+ "</svg>";
398
+
399
+ // Pull the language label off the <code> element. ``marked`` always
400
+ // emits ``class="language-X"`` from the fence info string, and
401
+ // ``hljs.highlightElement`` may also add ``language-X`` after detection.
402
+ // Anything else (no fence info, hljs failed) → blank label so the
403
+ // header still renders consistently.
404
+ function detectCodeLang(codeEl) {
405
+ const cls = codeEl.className || "";
406
+ const m = cls.match(/(?:^|\s)language-([\w+#.-]+)/i);
407
+ if (!m) return "";
408
+ const lang = m[1].toLowerCase();
409
+ // A few common aliases for nicer display.
410
+ const ALIASES = {
411
+ js: "javascript",
412
+ ts: "typescript",
413
+ sh: "shell",
414
+ bash: "shell",
415
+ zsh: "shell",
416
+ yml: "yaml",
417
+ md: "markdown",
418
+ "c++": "cpp",
419
+ };
420
+ return ALIASES[lang] || lang;
421
+ }
422
+
423
+ const COPY_LABEL =
424
+ (window.HY_I18N && window.HY_I18N.code_copy) || "Copy";
425
+ const COPIED_LABEL =
426
+ (window.HY_I18N && window.HY_I18N.code_copied) || "Copied";
427
+
428
+ function injectCopyButtonFor(codeEl) {
429
+ const pre = codeEl.closest("pre");
430
+ if (!pre || pre.dataset.copyInjected) return;
431
+ pre.dataset.copyInjected = "true";
432
+
433
+ // ── Build the wrapper card ────────────────────────────────────────
434
+ const wrapper = document.createElement("div");
435
+ wrapper.className = "hy-codeblock";
436
+
437
+ const header = document.createElement("div");
438
+ header.className = "hy-codeblock-header";
439
+
440
+ const lang = document.createElement("span");
441
+ lang.className = "hy-codeblock-lang";
442
+ lang.textContent = detectCodeLang(codeEl);
443
+ header.appendChild(lang);
444
+
445
+ const btn = document.createElement("button");
446
+ btn.type = "button";
447
+ btn.className = "code-copy-btn";
448
+ btn.setAttribute("aria-label", COPY_LABEL);
449
+ btn.title = COPY_LABEL;
450
+ btn.innerHTML = COPY_ICON_SVG;
451
+
452
+ let resetTimer = null;
453
+ btn.addEventListener("click", (e) => {
454
+ e.stopPropagation();
455
+ e.preventDefault();
456
+ const text = codeEl.textContent || "";
457
+ copyText(text).then(
458
+ () => {
459
+ btn.innerHTML = CHECK_ICON_SVG;
460
+ btn.classList.add("copied");
461
+ btn.title = COPIED_LABEL;
462
+ if (resetTimer) clearTimeout(resetTimer);
463
+ resetTimer = setTimeout(() => {
464
+ btn.innerHTML = COPY_ICON_SVG;
465
+ btn.classList.remove("copied");
466
+ btn.title = COPY_LABEL;
467
+ }, 1400);
468
+ },
469
+ () => {
470
+ btn.title = "Copy failed";
471
+ if (resetTimer) clearTimeout(resetTimer);
472
+ resetTimer = setTimeout(() => {
473
+ btn.title = COPY_LABEL;
474
+ }, 1400);
475
+ }
476
+ );
477
+ });
478
+ header.appendChild(btn);
479
+
480
+ // ── Splice wrapper into the DOM around <pre> ─────────────────────
481
+ // Insert the wrapper where <pre> lived, then move <pre> into it.
482
+ // The mutation observer that drives this function will re-fire for
483
+ // the wrapper's appearance, but ``dataset.copyInjected`` on <pre>
484
+ // makes the second pass a no-op.
485
+ const parent = pre.parentElement;
486
+ if (!parent) return;
487
+ parent.insertBefore(wrapper, pre);
488
+ wrapper.appendChild(header);
489
+ wrapper.appendChild(pre);
490
+ }
491
+
492
+ function processCodeBlock(codeEl) {
493
+ injectCopyButtonFor(codeEl);
494
+ injectPreviewButtonFor(codeEl);
495
+ }
496
+
497
+ function watchForHtmlBlocks() {
498
+ const chatEl = document.querySelector(CHAT_SELECTOR);
499
+ if (!chatEl) {
500
+ setTimeout(watchForHtmlBlocks, 500);
501
+ return;
502
+ }
503
+
504
+ chatEl.querySelectorAll("pre code").forEach(processCodeBlock);
505
+
506
+ // Subscribers receive the whole rAF batch in one call. Within a
507
+ // single batch the same code element may appear in many records
508
+ // (one per streamed token); we de-dup with a Set so we only
509
+ // re-evaluate ``injectPreviewButtonFor`` ONCE per affected code
510
+ // element per frame instead of once per mutation.
511
+ subscribeChatMutations((mutations) => {
512
+ const codeElsToCheck = new Set();
513
+ for (let i = 0; i < mutations.length; i++) {
514
+ const m = mutations[i];
515
+ if (m.type === "childList") {
516
+ const added = m.addedNodes;
517
+ for (let j = 0; j < added.length; j++) {
518
+ const node = added[j];
519
+ if (!(node instanceof HTMLElement)) continue;
520
+ if (node.matches && node.matches("pre code")) {
521
+ processCodeBlock(node);
522
+ } else if (node.querySelectorAll) {
523
+ const found = node.querySelectorAll("pre code");
524
+ for (let k = 0; k < found.length; k++) {
525
+ processCodeBlock(found[k]);
526
+ }
527
+ }
528
+ }
529
+ } else if (m.type === "characterData") {
530
+ const el = m.target.parentElement;
531
+ if (el && el.tagName === "CODE") codeElsToCheck.add(el);
532
+ }
533
+ }
534
+ // Late-arriving HTML detection: a code block may not have looked
535
+ // like HTML when first added but does now that more tokens have
536
+ // streamed in. injectPreviewButtonFor is idempotent so calling it
537
+ // again is safe and cheap.
538
+ codeElsToCheck.forEach(injectPreviewButtonFor);
539
+ });
540
+ }
541
+
542
+ /* ── Send button: helpers ─────────────────────────────────────────────── */
543
+ function getSendButton() {
544
+ const wrap = document.querySelector("#send-btn");
545
+ if (!wrap) return null;
546
+ if (wrap.tagName === "BUTTON") return wrap;
547
+ return wrap.querySelector("button");
548
+ }
549
+
550
+ function setSendDisabled(disabled) {
551
+ const btn = getSendButton();
552
+ if (!btn) return;
553
+ if (disabled) {
554
+ if (!btn.disabled) {
555
+ btn.setAttribute("disabled", "");
556
+ btn.disabled = true;
557
+ }
558
+ } else {
559
+ if (btn.disabled || btn.hasAttribute("disabled")) {
560
+ btn.removeAttribute("disabled");
561
+ btn.disabled = false;
562
+ }
563
+ }
564
+ }
565
+
566
+ function streamMarkerPresent() {
567
+ return !!document.querySelector(STREAM_MARKER_SELECTOR);
568
+ }
569
+
570
+ function idleMarkerPresent() {
571
+ return !!document.querySelector(IDLE_MARKER_SELECTOR);
572
+ }
573
+
574
+ function isModelBusy() {
575
+ const now = performance.now();
576
+ // Awaiting the server's first frame after a dispatch — must remain
577
+ // busy regardless of marker state, with a hard timeout to avoid a
578
+ // permanently-stuck button if the request silently failed.
579
+ if (awaitingFirstFrame && now - lastSendAt < FIRST_FRAME_TIMEOUT_MS) {
580
+ return true;
581
+ }
582
+ if (now - lastSendAt < SEND_DEBOUNCE_MS) return true;
583
+ return streamMarkerPresent();
584
+ }
585
+
586
+ /* ── Busy toast ───────────────────────────────────────────────────────── */
587
+ function ensureBusyToastHost() {
588
+ let host = document.getElementById("hy-busy-toast-host");
589
+ if (host) return host;
590
+ host = document.createElement("div");
591
+ host.id = "hy-busy-toast-host";
592
+ Object.assign(host.style, {
593
+ position: "fixed",
594
+ top: "24px",
595
+ left: "50%",
596
+ transform: "translateX(-50%)",
597
+ zIndex: "9999",
598
+ display: "flex",
599
+ flexDirection: "column",
600
+ gap: "8px",
601
+ pointerEvents: "none",
602
+ });
603
+ document.body.appendChild(host);
604
+ return host;
605
+ }
606
+
607
+ let lastToastAt = 0;
608
+ function showBusyToast() {
609
+ const now = performance.now();
610
+ if (now - lastToastAt < 800) return;
611
+ lastToastAt = now;
612
+
613
+ const host = ensureBusyToastHost();
614
+ const toast = document.createElement("div");
615
+ toast.className = "hy-busy-toast";
616
+ toast.textContent = BUSY_MSG;
617
+ Object.assign(toast.style, {
618
+ background: "#1f2937",
619
+ color: "#fff",
620
+ padding: "10px 16px",
621
+ borderRadius: "8px",
622
+ boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
623
+ fontSize: "14px",
624
+ maxWidth: "min(92vw, 520px)",
625
+ lineHeight: "1.4",
626
+ opacity: "0",
627
+ transition: "opacity 180ms ease",
628
+ pointerEvents: "none",
629
+ });
630
+ host.appendChild(toast);
631
+ requestAnimationFrame(() => {
632
+ toast.style.opacity = "1";
633
+ });
634
+ setTimeout(() => {
635
+ toast.style.opacity = "0";
636
+ setTimeout(() => toast.remove(), 250);
637
+ }, 3200);
638
+ }
639
+
640
+ /* ── Send-button busy guard ───────────────────────────────────────────── */
641
+ // Three-layer disable signal, in priority order:
642
+ //
643
+ // 1. SEND_DEBOUNCE_MS round-trip window after the user dispatched
644
+ // → disabled (prevents fast double-click before first server
645
+ // frame arrives).
646
+ // 2. #hy-busy-marker contains [data-hy-streaming="1"]
647
+ // → disabled (server is actively streaming).
648
+ // 3. Chat DOM has been *completely* idle for CHAT_IDLE_MS AND
649
+ // we're past CHAT_IDLE_MIN_SINCE_SEND_MS since dispatch
650
+ // → soft override: force-clear the marker and enable. Safety
651
+ // net for "the server's IDLE marker frame was lost on the
652
+ // wire".
653
+ let busyStartedAt = 0;
654
+ let lastChatMutationAt = 0;
655
+
656
+ function syncSendButtonState() {
657
+ const marker = streamMarkerPresent();
658
+ const now = performance.now();
659
+
660
+ if (marker) {
661
+ // First server frame has arrived — stop relying on the dispatch
662
+ // timer and hand over to the marker as the source of truth.
663
+ awaitingFirstFrame = false;
664
+ if (busyStartedAt === 0) busyStartedAt = now;
665
+
666
+ const sinceSend = now - lastSendAt;
667
+ const sinceMutation = now - lastChatMutationAt;
668
+ if (
669
+ lastSendAt > 0 &&
670
+ sinceSend > CHAT_IDLE_MIN_SINCE_SEND_MS &&
671
+ sinceMutation > CHAT_IDLE_MS
672
+ ) {
673
+ console.warn(
674
+ "[hy] Marker stuck while chat DOM idle for",
675
+ (sinceMutation / 1000).toFixed(1),
676
+ "s — force-clearing (server IDLE frame likely lost)."
677
+ );
678
+ document.querySelectorAll(STREAM_MARKER_SELECTOR).forEach((el) => {
679
+ el.removeAttribute("data-hy-streaming");
680
+ });
681
+ busyStartedAt = 0;
682
+ setSendDisabled(false);
683
+ return;
684
+ }
685
+
686
+ if (now - busyStartedAt > ABSOLUTE_BUSY_TIMEOUT_MS) {
687
+ console.warn(
688
+ "[hy] Stream marker stuck >",
689
+ ABSOLUTE_BUSY_TIMEOUT_MS / 1000,
690
+ "s — force-enabling Send."
691
+ );
692
+ document.querySelectorAll(STREAM_MARKER_SELECTOR).forEach((el) => {
693
+ el.removeAttribute("data-hy-streaming");
694
+ });
695
+ busyStartedAt = 0;
696
+ setSendDisabled(false);
697
+ return;
698
+ }
699
+
700
+ setSendDisabled(true);
701
+ return;
702
+ }
703
+
704
+ busyStartedAt = 0;
705
+
706
+ // Marker is idle. We may still be waiting for the FIRST frame after
707
+ // a freshly-dispatched send: keep the button disabled so a second
708
+ // click can't slip through during the initial request round-trip.
709
+ if (awaitingFirstFrame) {
710
+ // If the marker host explicitly contains the IDLE marker, the
711
+ // server has already responded — typically a short-circuit path
712
+ // like the functions-JSON validation failure in send_message,
713
+ // which yields HY_IDLE_HTML without ever yielding HY_BUSY_HTML.
714
+ // Without this branch, awaitingFirstFrame would stay set until
715
+ // FIRST_FRAME_TIMEOUT_MS (30s), causing the next Enter/click to
716
+ // wrongly trigger the "model is busy" toast.
717
+ if (idleMarkerPresent()) {
718
+ awaitingFirstFrame = false;
719
+ } else if (now - lastSendAt > FIRST_FRAME_TIMEOUT_MS) {
720
+ console.warn(
721
+ "[hy] First-frame timeout — re-enabling Send (request likely failed)."
722
+ );
723
+ awaitingFirstFrame = false;
724
+ setSendDisabled(false);
725
+ return;
726
+ } else {
727
+ setSendDisabled(true);
728
+ return;
729
+ }
730
+ }
731
+
732
+ if (now - lastSendAt < SEND_DEBOUNCE_MS) {
733
+ setSendDisabled(true);
734
+ return;
735
+ }
736
+ setSendDisabled(false);
737
+ }
738
+
739
+ function installBusyGuard() {
740
+ const sendBtn = document.querySelector("#send-btn");
741
+ const textarea = document.querySelector(".msg-input textarea");
742
+ const markerHost = document.getElementById(BUSY_MARKER_HOST_ID);
743
+ const chatEl = document.querySelector(CHAT_SELECTOR);
744
+ if (!sendBtn || !textarea || !markerHost || !chatEl) {
745
+ setTimeout(installBusyGuard, 500);
746
+ return;
747
+ }
748
+
749
+ // Track chat DOM activity for the soft watchdog above. ANY mutation
750
+ // refreshes the timestamp — including post-stream KaTeX/highlight
751
+ // injection — because if anything is still rendering the streaming
752
+ // MIGHT not be over yet. The watchdog only fires after a long quiet
753
+ // period.
754
+ lastChatMutationAt = performance.now();
755
+ subscribeChatMutations(() => {
756
+ lastChatMutationAt = performance.now();
757
+ });
758
+
759
+ // Block clicks while busy. Capture phase + stopImmediatePropagation
760
+ // ensures Gradio's own bubble-phase click handler never sees the
761
+ // event when we want to reject it.
762
+ //
763
+ // CRITICAL: in the NOT-busy path we do NOT call setSendDisabled(true)
764
+ // here. Gradio's Svelte Button checks ``this.disabled`` inside its
765
+ // own click handler that runs AFTER ours; if we flip the disabled
766
+ // attribute synchronously in capture, the actual send request is
767
+ // silently swallowed.
768
+ // Disable the Send button on the NEXT tick (after Gradio's own
769
+ // bubble-phase click handler has run and dispatched the request).
770
+ // We can't disable synchronously in capture or Gradio's Svelte
771
+ // Button checks ``this.disabled`` and silently swallows the send.
772
+ function lockSendAfterDispatch() {
773
+ setTimeout(() => {
774
+ setSendDisabled(true);
775
+ }, 0);
776
+ }
777
+
778
+ sendBtn.addEventListener(
779
+ "click",
780
+ (e) => {
781
+ if (isModelBusy()) {
782
+ e.preventDefault();
783
+ e.stopImmediatePropagation();
784
+ e.stopPropagation();
785
+ showBusyToast();
786
+ return;
787
+ }
788
+ lastSendAt = performance.now();
789
+ awaitingFirstFrame = true;
790
+ justSentNewMessage = true;
791
+ lockSendAfterDispatch();
792
+ },
793
+ true
794
+ );
795
+
796
+ // Same logic for Enter-to-submit on the textarea. Gradio's
797
+ // Textbox.submit fires regardless of the Send button's disabled
798
+ // state, so we ALSO need this gate.
799
+ textarea.addEventListener(
800
+ "keydown",
801
+ (e) => {
802
+ if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
803
+ if (isModelBusy()) {
804
+ e.preventDefault();
805
+ e.stopImmediatePropagation();
806
+ e.stopPropagation();
807
+ showBusyToast();
808
+ return;
809
+ }
810
+ lastSendAt = performance.now();
811
+ awaitingFirstFrame = true;
812
+ justSentNewMessage = true;
813
+ lockSendAfterDispatch();
814
+ },
815
+ true
816
+ );
817
+
818
+ // Drive the busy state from mutations of the dedicated marker host.
819
+ // Tiny subtree (one component, one inner span at most) so this is
820
+ // essentially free.
821
+ new MutationObserver(syncSendButtonState).observe(markerHost, {
822
+ childList: true,
823
+ subtree: true,
824
+ characterData: true,
825
+ attributes: true,
826
+ attributeFilter: ["data-hy-streaming", "data-hy-busy"],
827
+ });
828
+
829
+ // Slow heartbeat in case the MutationObserver misses something.
830
+ setInterval(syncSendButtonState, 500);
831
+
832
+ // Initial sync.
833
+ syncSendButtonState();
834
+ }
835
+
836
+ /* ── Follow-the-tail scroller ─────────────────────────────────────────── */
837
+ // Design (simple & robust):
838
+ // * ``follow`` mirrors "user is currently near the bottom of the
839
+ // chat". It is updated on EVERY scroll event — both user-driven
840
+ // and programmatic — because after our own ``scrollToBottom`` the
841
+ // position IS at the bottom, so follow stays true automatically.
842
+ // * When the user scrolls UP, the very next scroll event flips
843
+ // follow to false; subsequent mutations don't re-pin the view.
844
+ // * When the user is actively scrolling (wheel / touchmove /
845
+ // scroll-key) we briefly pause programmatic scrolling so we don't
846
+ // fight their fingers; once they let go (no gesture for
847
+ // USER_QUIET_MS) auto-follow resumes if they ended near bottom.
848
+ // * Two redundant triggers ensure we never miss a delta: the global
849
+ // chat MutationObserver AND the explicit ``hy-chat:updated``
850
+ // custom event dispatched by static/chat.js after each delta is
851
+ // applied.
852
+ function installScrollLock() {
853
+ const chatEl = document.querySelector(CHAT_SELECTOR);
854
+ if (!chatEl) {
855
+ setTimeout(installScrollLock, 500);
856
+ return;
857
+ }
858
+
859
+ const NEAR_BOTTOM_PX = 100;
860
+ const USER_QUIET_MS = 120;
861
+
862
+ let scrollable = null;
863
+ let follow = true;
864
+ let lastUserGestureAt = 0;
865
+
866
+ function markUserGesture() {
867
+ lastUserGestureAt = performance.now();
868
+ }
869
+ window.addEventListener("wheel", markUserGesture, {
870
+ passive: true,
871
+ capture: true,
872
+ });
873
+ window.addEventListener("touchmove", markUserGesture, {
874
+ passive: true,
875
+ capture: true,
876
+ });
877
+ const SCROLL_KEYS = new Set([
878
+ "ArrowUp",
879
+ "ArrowDown",
880
+ "PageUp",
881
+ "PageDown",
882
+ "Home",
883
+ "End",
884
+ "Space",
885
+ " ",
886
+ ]);
887
+ window.addEventListener(
888
+ "keydown",
889
+ (e) => {
890
+ if (SCROLL_KEYS.has(e.key)) markUserGesture();
891
+ },
892
+ { capture: true }
893
+ );
894
+
895
+ function isNearBottom(el) {
896
+ return el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
897
+ }
898
+
899
+ function onScroll() {
900
+ if (!scrollable) return;
901
+ // Always reflect the current viewport state. After programmatic
902
+ // scrollToBottom the position IS at the bottom, so follow=true.
903
+ // After user scrolls up, follow flips to false on the very next
904
+ // scroll event. Simple and self-correcting.
905
+ follow = isNearBottom(scrollable);
906
+ }
907
+
908
+ function scrollToBottom() {
909
+ if (!scrollable) return;
910
+ // Don't fight an active user gesture. Once they stop (>120ms of
911
+ // silence) we'll catch up on the very next mutation OR custom
912
+ // event — both of which fire for every streamed delta.
913
+ if (performance.now() - lastUserGestureAt < USER_QUIET_MS) return;
914
+ const target = scrollable.scrollHeight - scrollable.clientHeight;
915
+ if (target > scrollable.scrollTop) scrollable.scrollTop = target;
916
+ }
917
+
918
+ function findScrollable() {
919
+ // Our chat element IS itself the scrollable container (see
920
+ // _chat.css). Attach to it eagerly even before content overflows
921
+ // so we catch the first scroll event the moment overflow appears.
922
+ const s = getComputedStyle(chatEl);
923
+ if (s.overflowY === "auto" || s.overflowY === "scroll") return chatEl;
924
+ // Fallback for unexpected layouts: walk the subtree.
925
+ const candidates = chatEl.querySelectorAll("*");
926
+ for (const el of candidates) {
927
+ const cs = getComputedStyle(el);
928
+ if (cs.overflowY === "auto" || cs.overflowY === "scroll") {
929
+ if (el.scrollHeight > el.clientHeight + 4) return el;
930
+ }
931
+ }
932
+ return chatEl;
933
+ }
934
+
935
+ function attach(el) {
936
+ if (scrollable === el) return;
937
+ if (scrollable) scrollable.removeEventListener("scroll", onScroll);
938
+ scrollable = el;
939
+ el.addEventListener("scroll", onScroll, { passive: true });
940
+ if (follow) scrollToBottom();
941
+ }
942
+
943
+ // Attach immediately — chat.js may not have any content yet but the
944
+ // element exists, so we can already wire the scroll listener.
945
+ attach(findScrollable());
946
+
947
+ function tick() {
948
+ if (justSentNewMessage) {
949
+ follow = true;
950
+ justSentNewMessage = false;
951
+ }
952
+ if (!scrollable || !document.contains(scrollable)) {
953
+ attach(findScrollable());
954
+ }
955
+ if (scrollable && follow) {
956
+ scrollToBottom();
957
+ }
958
+ }
959
+
960
+ // Trigger 1: the global chat MutationObserver hub. Catches every
961
+ // DOM change under #hy-chat (delta-driven appends, frozen flush,
962
+ // streaming innerHTML swaps, post-stream highlight injection).
963
+ subscribeChatMutations(tick);
964
+
965
+ // Trigger 2: the explicit ``hy-chat:updated`` event dispatched by
966
+ // static/chat.js at the end of every applyDelta. Belt-and-braces
967
+ // in case some pathological mutation pattern slips past the rAF
968
+ // batcher above.
969
+ chatEl.addEventListener("hy-chat:updated", tick);
970
+
971
+ // Trigger 3: window resize can change clientHeight which makes the
972
+ // previous "at bottom" position no longer at bottom. Re-pin.
973
+ window.addEventListener("resize", () => {
974
+ if (scrollable && follow) scrollToBottom();
975
+ });
976
+ }
977
+
978
+ /* ── kick off ─────────────────────────────────────────────────────────── */
979
+ function bootstrap() {
980
+ const chatEl = document.querySelector(CHAT_SELECTOR);
981
+ if (!chatEl) {
982
+ // chat.js renders #hy-chat into the gr.HTML wrapper; until that
983
+ // happens we can't install observers. Retry until ready —
984
+ // subsystems below all guard for the same node themselves but
985
+ // the mutation hub MUST be running before they install their
986
+ // subscribers — otherwise early mutations would be missed.
987
+ setTimeout(bootstrap, 200);
988
+ return;
989
+ }
990
+ startChatMutationHub(chatEl);
991
+ createOverlay();
992
+ watchForHtmlBlocks();
993
+ installScrollLock();
994
+ installBusyGuard();
995
+ }
996
+ setTimeout(bootstrap, 800);
997
+ })();
static/chat.js ADDED
@@ -0,0 +1,688 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable */
2
+ /**
3
+ * Hy3 custom chat renderer.
4
+ *
5
+ * The DOM under ``#hy-chat`` is owned exclusively by this module —
6
+ * nothing in Gradio touches it. The server pushes JSON deltas via the
7
+ * hidden ``#hy-chat-delta`` ``gr.HTML`` component; we observe its
8
+ * mutations, parse, and apply each op to the chat container.
9
+ *
10
+ * Design notes
11
+ * ────────────
12
+ * 1. Append-only DOM. Each new bubble is its own ``.message-row``.
13
+ * The browser's per-row layout/paint isolation (``contain`` +
14
+ * ``content-visibility`` from ``_perf.css``) keeps rendering cost
15
+ * O(visible) regardless of conversation length.
16
+ *
17
+ * 2. Frozen / streaming split inside each assistant bubble. As content
18
+ * streams in, every paragraph (or fenced code / display math) that
19
+ * has CLOSED gets rendered ONCE into a stable ``.hy-frozen`` DOM
20
+ * subtree and never touched again — KaTeX, ``marked``, and the
21
+ * syntax highlighter all run exactly once per closed block. Only
22
+ * the trailing in-progress paragraph re-renders on each delta,
23
+ * and that block is small. As a result already-rendered formulas
24
+ * never flicker / re-layout mid-stream.
25
+ *
26
+ * 3. Wire payload is ~just the new characters. A 100-char delta is
27
+ * ~150 bytes on the wire, so WebSocket back-pressure essentially
28
+ * goes away.
29
+ *
30
+ * Op contract — keep in sync with chat.py:
31
+ *
32
+ * {type: "reset"}
33
+ * {type: "user", text: "..."}
34
+ * {type: "assistant_begin", id: "a-N"}
35
+ * {type: "reasoning_delta", id: "a-N", delta: "..."}
36
+ * {type: "content_delta", id: "a-N", delta: "...", thinking_done?: bool}
37
+ * {type: "assistant_end", id: "a-N"}
38
+ * {type: "tool_call", id: "a-N", markdown: "..."}
39
+ * {type: "remove_bubble", id: "a-N"}
40
+ */
41
+ (() => {
42
+ if (window.__HY_CHAT_INIT) return;
43
+ window.__HY_CHAT_INIT = true;
44
+
45
+ // ─── State ─────────────────────────────────────────────────────────────
46
+ const state = {
47
+ host: null, // <div id="hy-chat">
48
+ bubbles: new Map(), // bubble_id -> bubble record (see ensureBubble)
49
+ lastSeq: 0, // de-dup gate against re-fires of the same payload
50
+ // Current chat epoch — bumped server-side every time the user clicks
51
+ // "+" / new_chat. Each delta payload carries the epoch it was
52
+ // produced under; deltas whose epoch doesn't match ours are dropped
53
+ // (they're tail bytes from a stream the user already abandoned).
54
+ // Initialised lazily from the first payload that arrives so legacy
55
+ // payloads without an epoch field still apply.
56
+ currentEpoch: null,
57
+ };
58
+
59
+ // ─── DOM helpers ───────────────────────────────────────────────────────
60
+ function ensureHost() {
61
+ if (state.host && document.contains(state.host)) return state.host;
62
+ state.host = document.getElementById("hy-chat");
63
+ return state.host;
64
+ }
65
+
66
+ function escapeHtml(s) {
67
+ return String(s)
68
+ .replace(/&/g, "&amp;")
69
+ .replace(/</g, "&lt;")
70
+ .replace(/>/g, "&gt;")
71
+ .replace(/"/g, "&quot;")
72
+ .replace(/'/g, "&#39;");
73
+ }
74
+
75
+ // ─── Markdown + math renderer ──────────────────────────────────────────
76
+ // We DELIBERATELY do not sanitize here. The rest of the app depends
77
+ // on the model being able to emit raw HTML (e.g.
78
+ // <details class="thinking-block">, the HTML-preview workflow on raw
79
+ // HTML code blocks). The model is trusted output for this product
80
+ // surface; if that ever changes, swap marked for a sanitizing parser
81
+ // like markdown-it + DOMPurify.
82
+ const MARKED_OPTS = { gfm: true, breaks: false };
83
+
84
+ const KATEX_DELIMS = [
85
+ { left: "$$", right: "$$", display: true },
86
+ { left: "$", right: "$", display: false },
87
+ // We pre-convert \[…\] / \(…\) → $$…$$ / $…$ via normalizeMathDelims
88
+ // BEFORE marked.parse runs (see below) — so the auto-render scan
89
+ // never needs to worry about them. They are NOT listed here on
90
+ // purpose: marked treats ``\[`` and ``\(`` as backslash escapes
91
+ // and silently drops the leading ``\`` in the rendered HTML, so by
92
+ // the time KaTeX scans the DOM the original bracket delimiters are
93
+ // already gone. Pre-conversion is the only reliable fix.
94
+ ];
95
+
96
+ // ─── Math handling: two-stage pipeline ────────────────────────────────
97
+ //
98
+ // The model emits LaTeX in any of four delimiter styles: ``$$…$$``,
99
+ // ``\[…\]``, ``$…$``, ``\(…\)``. We need KaTeX (via auto-render) to
100
+ // pick all of them up while still letting ``marked.parse`` handle the
101
+ // surrounding markdown.
102
+ //
103
+ // Two problems block the naïve approach:
104
+ //
105
+ // 1. ``marked`` treats ``\[``, ``\]``, ``\(``, ``\)`` as backslash
106
+ // escapes and silently drops the ``\``. By the time KaTeX scans
107
+ // the rendered HTML the bracket delimiters are gone.
108
+ //
109
+ // 2. Even inside ``$$…$$``, ``marked`` strips backslashes before
110
+ // ``\{``, ``\}``, ``\_``, etc. — so things like ``\{(0:0:1:0)\}``
111
+ // lose their literal-brace escapes and KaTeX renders an empty
112
+ // group instead of the intended literal braces.
113
+ //
114
+ // The fix is two passes that together keep marked away from the math
115
+ // content:
116
+ //
117
+ // Stage A — ``normalizeMathDelims`` converts ``\[/\]`` → ``$$`` and
118
+ // ``\(/\)`` → ``$`` OUTSIDE fenced/inline code blocks. After this
119
+ // pass every math region is delimited with dollars.
120
+ //
121
+ // Stage B — ``protectMath`` extracts every ``$$…$$`` / ``$…$`` from
122
+ // the text and replaces it with a unique placeholder token (a NUL
123
+ // character that ``marked`` will leave alone). After ``marked.parse``
124
+ // we substitute the original math source back in. ``ignoredTags``
125
+ // already keeps KaTeX out of ``<pre>``/``<code>``, so the only
126
+ // regions that end up containing dollars in the rendered HTML are
127
+ // the math ones — exactly what we want.
128
+ //
129
+ // The placeholder uses NUL as a delimiter because ``marked`` cannot
130
+ // produce a literal NUL on its own (every input source we'd see is
131
+ // valid Unicode text without NULs), so the ``restoreMath`` regex is
132
+ // unambiguous.
133
+ function normalizeMathDelims(text) {
134
+ if (!text) return text;
135
+ let out = "";
136
+ let i = 0;
137
+ const n = text.length;
138
+ while (i < n) {
139
+ const ch = text[i];
140
+
141
+ // Triple-backtick or triple-tilde fenced block — passthrough until
142
+ // matching closing fence on its own (or anywhere on a) line.
143
+ if ((ch === "`" && text.startsWith("```", i)) ||
144
+ (ch === "~" && text.startsWith("~~~", i))) {
145
+ const marker = text.substr(i, 3);
146
+ out += marker;
147
+ i += 3;
148
+ const close = text.indexOf(marker, i);
149
+ if (close < 0) {
150
+ // Unclosed fence — emit rest verbatim and stop.
151
+ out += text.slice(i);
152
+ return out;
153
+ }
154
+ out += text.slice(i, close + 3);
155
+ i = close + 3;
156
+ continue;
157
+ }
158
+
159
+ // Inline code: single or double backticks, ended by the same run
160
+ // length. Common case is a single backtick.
161
+ if (ch === "`") {
162
+ let runLen = 1;
163
+ while (i + runLen < n && text[i + runLen] === "`") runLen++;
164
+ const open = text.substr(i, runLen);
165
+ out += open;
166
+ i += runLen;
167
+ const close = text.indexOf(open, i);
168
+ if (close < 0) {
169
+ out += text.slice(i);
170
+ return out;
171
+ }
172
+ out += text.slice(i, close + runLen);
173
+ i = close + runLen;
174
+ continue;
175
+ }
176
+
177
+ // Backslash-bracket / backslash-paren delimiters. Must be the
178
+ // FIRST conversion check after the code-fence skip so we don't
179
+ // mistake e.g. ``\[`` inside a code fence for math.
180
+ if (ch === "\\") {
181
+ if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
182
+ if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
183
+ if (text[i + 1] === "(") { out += "$"; i += 2; continue; }
184
+ if (text[i + 1] === ")") { out += "$"; i += 2; continue; }
185
+ }
186
+
187
+ out += ch;
188
+ i++;
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // Inline math regex requires the opening ``$`` to be IMMEDIATELY
194
+ // followed by a non-space, non-``$`` char (avoids matching prices like
195
+ // ``I have $5 and $10``) and the closing ``$`` to be IMMEDIATELY
196
+ // preceded by a non-space, non-``$`` char. Display math (``$$…$$``)
197
+ // is matched first (the alternative is left-most-greedy in a JS
198
+ // regex, but with non-greedy bodies and ``$$`` being a longer prefix
199
+ // it wins ties naturally).
200
+ const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;
201
+
202
+ function protectMath(text) {
203
+ const stash = [];
204
+ const escaped = text.replace(MATH_RE, (m) => {
205
+ const idx = stash.length;
206
+ stash.push(m);
207
+ return `\u0000M${idx}\u0000`;
208
+ });
209
+ return { escaped, stash };
210
+ }
211
+
212
+ function restoreMath(html, stash) {
213
+ if (!stash.length) return html;
214
+ return html.replace(/\u0000M(\d+)\u0000/g, (_, n) => stash[+n]);
215
+ }
216
+
217
+ function renderMarkdownInto(el, text) {
218
+ if (!text) {
219
+ el.innerHTML = "";
220
+ return;
221
+ }
222
+ const normalized = normalizeMathDelims(text);
223
+ const { escaped, stash } = protectMath(normalized);
224
+ const rawMd = (window.marked && window.marked.parse)
225
+ ? window.marked.parse(escaped, MARKED_OPTS)
226
+ : escapeHtml(escaped);
227
+ el.innerHTML = restoreMath(rawMd, stash);
228
+
229
+ // Math
230
+ if (window.renderMathInElement) {
231
+ try {
232
+ window.renderMathInElement(el, {
233
+ delimiters: KATEX_DELIMS,
234
+ throwOnError: false,
235
+ // Skip math-detection inside elements that should never contain
236
+ // math (matches KaTeX auto-render defaults).
237
+ ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
238
+ });
239
+ } catch (err) {
240
+ console.warn("[hy-chat] katex render error:", err);
241
+ }
242
+ }
243
+
244
+ // Syntax highlighting (idempotent if hljs not loaded yet)
245
+ if (window.hljs && window.hljs.highlightElement) {
246
+ const codes = el.querySelectorAll("pre code");
247
+ for (let i = 0; i < codes.length; i++) {
248
+ const c = codes[i];
249
+ if (c.dataset.hljsDone) continue;
250
+ try {
251
+ window.hljs.highlightElement(c);
252
+ } catch (err) {
253
+ // hljs throws if the language is unknown — harmless, ignore.
254
+ }
255
+ c.dataset.hljsDone = "1";
256
+ }
257
+ }
258
+ }
259
+
260
+ // ─── Frozen / streaming split ──────────────────────────────────────────
261
+ // Find the highest "safe" boundary in the current content buffer up to
262
+ // which we can freeze the rendered DOM and never touch it again. A
263
+ // boundary is safe iff the markdown above it forms a complete set of
264
+ // top-level blocks — i.e. we are NOT inside an open fenced code block
265
+ // (``` / ~~~), display math ($$), or LaTeX env (\begin{…}).
266
+ //
267
+ // Returns the index in ``text`` immediately after the last safe block
268
+ // separator (\n\n).
269
+ function findFreezeBoundary(text) {
270
+ const len = text.length;
271
+ let i = 0;
272
+ let inFence = false; // inside ``` … ``` or ~~~ … ~~~
273
+ let fenceMarker = null;
274
+ let inMath = false; // inside $$ … $$
275
+ let inEnv = false; // inside \begin{…} … \end{…}
276
+ let lastSafe = 0;
277
+
278
+ while (i < len) {
279
+ const ch = text[i];
280
+
281
+ // Fence detection (must check before single-char paths)
282
+ if (!inMath && !inEnv) {
283
+ if (ch === "`" && text.startsWith("```", i)) {
284
+ if (!inFence) { inFence = true; fenceMarker = "```"; }
285
+ else if (fenceMarker === "```") { inFence = false; fenceMarker = null; }
286
+ i += 3;
287
+ continue;
288
+ }
289
+ if (ch === "~" && text.startsWith("~~~", i)) {
290
+ if (!inFence) { inFence = true; fenceMarker = "~~~"; }
291
+ else if (fenceMarker === "~~~") { inFence = false; fenceMarker = null; }
292
+ i += 3;
293
+ continue;
294
+ }
295
+ }
296
+
297
+ if (inFence) { i++; continue; }
298
+
299
+ // Display math $$ … $$
300
+ if (!inEnv && ch === "$" && text[i + 1] === "$") {
301
+ inMath = !inMath;
302
+ i += 2;
303
+ continue;
304
+ }
305
+
306
+ if (inMath) { i++; continue; }
307
+
308
+ // \begin{env} … \end{env}
309
+ if (ch === "\\") {
310
+ if (text.startsWith("\\begin{", i)) { inEnv = true; i += 7; continue; }
311
+ if (text.startsWith("\\end{", i)) { inEnv = false; i += 5; continue; }
312
+ }
313
+
314
+ if (inEnv) { i++; continue; }
315
+
316
+ // Paragraph break outside any block — candidate freeze point.
317
+ if (ch === "\n" && text[i + 1] === "\n") {
318
+ // Skip past the entire run of blank lines so we don't freeze
319
+ // mid-blank-paragraph.
320
+ let j = i + 2;
321
+ while (j < len && (text[j] === "\n" || text[j] === " " || text[j] === "\t")) j++;
322
+ lastSafe = j;
323
+ i = j;
324
+ continue;
325
+ }
326
+
327
+ i++;
328
+ }
329
+
330
+ return lastSafe;
331
+ }
332
+
333
+ // ─── Bubble lifecycle ──────────────────────────────────────────────────
334
+ function ensureBubble(id) {
335
+ return state.bubbles.get(id);
336
+ }
337
+
338
+ function createAssistantBubble(id) {
339
+ const row = document.createElement("div");
340
+ row.className = "message-row bot-row";
341
+ row.dataset.testid = "bot";
342
+ row.dataset.role = "assistant";
343
+ row.dataset.bubbleId = id;
344
+
345
+ const msg = document.createElement("div");
346
+ msg.className = "message bot";
347
+
348
+ const md = document.createElement("div");
349
+ md.className = "markdown hy-markdown";
350
+
351
+ const typing = document.createElement("div");
352
+ typing.className = "typing-indicator hy-typing";
353
+ typing.innerHTML = "<span></span><span></span><span></span>";
354
+ md.appendChild(typing);
355
+
356
+ msg.appendChild(md);
357
+ row.appendChild(msg);
358
+ state.host.appendChild(row);
359
+
360
+ const record = {
361
+ row, md, typing,
362
+ // Reasoning (optional, lazily created)
363
+ reasoningEl: null,
364
+ reasoningSummaryEl: null,
365
+ reasoningBodyEl: null,
366
+ reasoningBuf: "",
367
+ thinkingDone: false,
368
+ // Content (lazily created on first content_delta)
369
+ frozenEl: null,
370
+ streamingEl: null,
371
+ contentBuf: "",
372
+ frozenLen: 0,
373
+ // Tool-call append region (lazily created)
374
+ toolCallContainer: null,
375
+ ended: false,
376
+ };
377
+ state.bubbles.set(id, record);
378
+ return record;
379
+ }
380
+
381
+ function ensureReasoning(b) {
382
+ if (b.reasoningEl) return;
383
+ const det = document.createElement("details");
384
+ det.className = "thinking-block";
385
+ det.open = true;
386
+ const sum = document.createElement("summary");
387
+ sum.textContent = (window.HY_I18N && window.HY_I18N.thinking) || "Thinking...";
388
+ det.appendChild(sum);
389
+ const body = document.createElement("div");
390
+ body.className = "hy-reasoning-body";
391
+ body.style.whiteSpace = "pre-wrap";
392
+ body.style.wordBreak = "break-word";
393
+ det.appendChild(body);
394
+ b.reasoningEl = det;
395
+ b.reasoningSummaryEl = sum;
396
+ b.reasoningBodyEl = body;
397
+ if (b.typing && b.typing.parentNode) b.typing.remove();
398
+ // Reasoning always goes ABOVE content, even if content has already
399
+ // started rendering (rare — most models stream reasoning first).
400
+ b.md.insertBefore(det, b.md.firstChild);
401
+ }
402
+
403
+ function ensureContent(b) {
404
+ if (b.frozenEl) return;
405
+ if (b.typing && b.typing.parentNode) b.typing.remove();
406
+ const frozen = document.createElement("div");
407
+ frozen.className = "hy-frozen";
408
+ const streaming = document.createElement("div");
409
+ streaming.className = "hy-streaming";
410
+ b.md.appendChild(frozen);
411
+ b.md.appendChild(streaming);
412
+ b.frozenEl = frozen;
413
+ b.streamingEl = streaming;
414
+ }
415
+
416
+ function markThinkingDone(b) {
417
+ if (!b.reasoningEl || b.thinkingDone) return;
418
+ b.thinkingDone = true;
419
+ b.reasoningSummaryEl.textContent =
420
+ (window.HY_I18N && window.HY_I18N.thinking_done) || "Thought";
421
+ b.reasoningEl.open = false;
422
+ }
423
+
424
+ // ─── Op handlers ───────────────────────────────────────────────────────
425
+ function opReset() {
426
+ state.host.innerHTML = "";
427
+ state.bubbles.clear();
428
+ state.lastSeq = 0;
429
+ // currentEpoch is updated by applyDelta BEFORE op dispatch when a
430
+ // reset op is in the payload — see the epoch handling there. We
431
+ // intentionally do NOT clear it here, otherwise we'd reopen the
432
+ // window for stale post-reset deltas.
433
+ }
434
+
435
+ function opUser(op) {
436
+ const row = document.createElement("div");
437
+ row.className = "message-row user-row";
438
+ row.dataset.testid = "user";
439
+ row.dataset.role = "user";
440
+
441
+ const msg = document.createElement("div");
442
+ msg.className = "message user user-message";
443
+
444
+ const md = document.createElement("div");
445
+ md.className = "markdown hy-markdown hy-user-text";
446
+ // Render as plain text — never run user input through marked / KaTeX.
447
+ md.style.whiteSpace = "pre-wrap";
448
+ md.style.wordBreak = "break-word";
449
+ md.textContent = op.text || "";
450
+
451
+ msg.appendChild(md);
452
+ row.appendChild(msg);
453
+ state.host.appendChild(row);
454
+ }
455
+
456
+ function opAssistantBegin(op) {
457
+ if (state.bubbles.has(op.id)) return; // idempotent on retry
458
+ createAssistantBubble(op.id);
459
+ }
460
+
461
+ function opReasoningDelta(op) {
462
+ let b = ensureBubble(op.id);
463
+ if (!b) b = createAssistantBubble(op.id);
464
+ ensureReasoning(b);
465
+ const d = op.delta || "";
466
+ if (!d) return;
467
+ b.reasoningBuf += d;
468
+ // Reasoning is shown as raw text — fast and never re-renders math.
469
+ // Append-only via textContent means even very long reasoning stays
470
+ // O(delta) per update; the DOM tree is just a single text node.
471
+ b.reasoningBodyEl.textContent = b.reasoningBuf;
472
+ // Keep the latest reasoning line visible inside the (max-height-
473
+ // scrollable) details body.
474
+ b.reasoningBodyEl.scrollTop = b.reasoningBodyEl.scrollHeight;
475
+ }
476
+
477
+ function opContentDelta(op) {
478
+ let b = ensureBubble(op.id);
479
+ if (!b) b = createAssistantBubble(op.id);
480
+ if (op.thinking_done) markThinkingDone(b);
481
+ ensureContent(b);
482
+ const d = op.delta || "";
483
+ if (!d) return;
484
+ b.contentBuf += d;
485
+
486
+ // Compute new freeze boundary. If it advanced past the last frozen
487
+ // length, render JUST the newly-frozen chunk (text since last
488
+ // boundary) ONCE and APPEND its DOM to the frozen container. The
489
+ // already-frozen DOM is never touched — KaTeX, hljs, and marked
490
+ // run exactly once per closed block.
491
+ //
492
+ // Why we can render the new chunk in isolation: the boundary is
493
+ // always at a paragraph break (\n\n) outside any open code fence /
494
+ // display math / LaTeX env, so each chunk is a complete set of
495
+ // top-level markdown blocks that ``marked`` parses identically
496
+ // whether or not it has the prior context.
497
+ const text = b.contentBuf;
498
+ const newBoundary = findFreezeBoundary(text);
499
+ if (newBoundary > b.frozenLen) {
500
+ const newChunk = text.slice(b.frozenLen, newBoundary);
501
+ const temp = document.createElement("div");
502
+ renderMarkdownInto(temp, newChunk);
503
+ // Move children from temp into frozenEl. Moving (not cloning)
504
+ // preserves the KaTeX/hljs work we just did, and crucially does
505
+ // NOT re-trigger any rendering of the already-frozen DOM.
506
+ while (temp.firstChild) {
507
+ b.frozenEl.appendChild(temp.firstChild);
508
+ }
509
+ b.frozenLen = newBoundary;
510
+ }
511
+ // Streaming segment (everything after the boundary) is re-rendered
512
+ // in full on each delta. It's bounded to roughly one paragraph, so
513
+ // the KaTeX / marked / hljs work is O(paragraph) — and since
514
+ // ``innerHTML = ...`` wipes the previous DOM, KaTeX never
515
+ // double-renders the same formula here either.
516
+ const streamingText = text.slice(b.frozenLen);
517
+ renderMarkdownInto(b.streamingEl, streamingText);
518
+ }
519
+
520
+ function opAssistantEnd(op) {
521
+ const b = ensureBubble(op.id);
522
+ if (!b) return;
523
+ b.ended = true;
524
+ if (b.typing && b.typing.parentNode) b.typing.remove();
525
+ // Final flush: take whatever is still in the streaming segment and
526
+ // append it to the frozen container as one last chunk, then empty
527
+ // streaming. Crucially we do NOT re-render the already-frozen DOM
528
+ // — that would re-run KaTeX over every formula the user has been
529
+ // looking at and produce a final visible flicker.
530
+ if (b.contentBuf) {
531
+ ensureContent(b);
532
+ const tail = b.contentBuf.slice(b.frozenLen);
533
+ if (tail) {
534
+ const temp = document.createElement("div");
535
+ renderMarkdownInto(temp, tail);
536
+ while (temp.firstChild) {
537
+ b.frozenEl.appendChild(temp.firstChild);
538
+ }
539
+ }
540
+ b.frozenLen = b.contentBuf.length;
541
+ b.streamingEl.innerHTML = "";
542
+ } else if (b.streamingEl) {
543
+ // No content at all (might be tool-call only) — clean up.
544
+ b.streamingEl.innerHTML = "";
545
+ }
546
+ if (b.reasoningEl && !b.thinkingDone) markThinkingDone(b);
547
+ }
548
+
549
+ function opToolCall(op) {
550
+ let b = ensureBubble(op.id);
551
+ if (!b) b = createAssistantBubble(op.id);
552
+ if (b.typing && b.typing.parentNode) b.typing.remove();
553
+ if (!b.toolCallContainer) {
554
+ const c = document.createElement("div");
555
+ c.className = "hy-tool-calls";
556
+ b.md.appendChild(c);
557
+ b.toolCallContainer = c;
558
+ }
559
+ const tc = document.createElement("div");
560
+ tc.className = "hy-tool-call";
561
+ renderMarkdownInto(tc, op.markdown || "");
562
+ b.toolCallContainer.appendChild(tc);
563
+ }
564
+
565
+ function opRemoveBubble(op) {
566
+ const b = ensureBubble(op.id);
567
+ if (!b) return;
568
+ if (b.row && b.row.parentNode) b.row.parentNode.removeChild(b.row);
569
+ state.bubbles.delete(op.id);
570
+ }
571
+
572
+ function applyOp(op) {
573
+ if (!op || !op.type) return;
574
+ if (!ensureHost()) return;
575
+ switch (op.type) {
576
+ case "reset": opReset(); break;
577
+ case "user": opUser(op); break;
578
+ case "assistant_begin": opAssistantBegin(op); break;
579
+ case "reasoning_delta": opReasoningDelta(op); break;
580
+ case "content_delta": opContentDelta(op); break;
581
+ case "assistant_end": opAssistantEnd(op); break;
582
+ case "tool_call": opToolCall(op); break;
583
+ case "remove_bubble": opRemoveBubble(op); break;
584
+ default:
585
+ console.warn("[hy-chat] unknown op type:", op.type);
586
+ }
587
+ }
588
+
589
+ function applyDelta(payload) {
590
+ if (!payload || typeof payload !== "object") return;
591
+ const seq = typeof payload.seq === "number" ? payload.seq : null;
592
+ // Dedup: Gradio occasionally re-fires the same component value on
593
+ // reconnect. Since each delta has a fresh global seq, anything ≤
594
+ // lastSeq is a re-fire we've already applied.
595
+ if (seq !== null && seq <= state.lastSeq) return;
596
+ if (seq !== null) state.lastSeq = seq;
597
+ const ops = Array.isArray(payload.ops) ? payload.ops : [];
598
+
599
+ // ── Stale-epoch drop ────────────────────────────────────────────
600
+ // The user clicked "+" mid-stream. The reset payload carries a NEW
601
+ // epoch; we adopt it and apply the reset. Any later payload from
602
+ // the cancelled generator still carries the OLD epoch and is
603
+ // dropped here — without this, late content_delta ops would
604
+ // ``createAssistantBubble`` for the old bubble id and a ghost
605
+ // bubble would appear in the freshly-cleared chat.
606
+ const epoch = payload.epoch;
607
+ if (epoch !== undefined && epoch !== null) {
608
+ const hasReset = ops.some((op) => op && op.type === "reset");
609
+ if (hasReset) {
610
+ state.currentEpoch = epoch;
611
+ } else if (state.currentEpoch === null) {
612
+ // First payload of the session: adopt whatever epoch we see.
613
+ state.currentEpoch = epoch;
614
+ } else if (epoch !== state.currentEpoch) {
615
+ return;
616
+ }
617
+ }
618
+
619
+ for (let i = 0; i < ops.length; i++) {
620
+ try {
621
+ applyOp(ops[i]);
622
+ } catch (err) {
623
+ console.error("[hy-chat] op failed:", ops[i], err);
624
+ }
625
+ }
626
+ // Notify subscribers (the scroll lock and code-block injector in
627
+ // app.js) that the chat DOM changed. We dispatch a single CustomEvent
628
+ // per applied delta — coarser than per-op so subscribers can do their
629
+ // own coalescing on top.
630
+ if (state.host) {
631
+ state.host.dispatchEvent(new CustomEvent("hy-chat:updated", {
632
+ bubbles: false,
633
+ }));
634
+ }
635
+ }
636
+
637
+ // ─── Wire up the delta channel ───────────────────────────────────────��─
638
+ function startDeltaObserver() {
639
+ const deltaHost = document.getElementById("hy-chat-delta");
640
+ if (!deltaHost) {
641
+ setTimeout(startDeltaObserver, 200);
642
+ return;
643
+ }
644
+ let lastValue = "";
645
+ function handle() {
646
+ // Gradio may put the value in textContent or in a nested element
647
+ // depending on version. Walk the subtree to be defensive.
648
+ const v = (deltaHost.textContent || "").trim();
649
+ if (!v || v === lastValue) return;
650
+ lastValue = v;
651
+ let payload;
652
+ try {
653
+ payload = JSON.parse(v);
654
+ } catch (err) {
655
+ console.error("[hy-chat] bad delta JSON:", v.slice(0, 200), err);
656
+ return;
657
+ }
658
+ applyDelta(payload);
659
+ }
660
+ new MutationObserver(handle).observe(deltaHost, {
661
+ childList: true,
662
+ subtree: true,
663
+ characterData: true,
664
+ });
665
+ handle(); // pick up initial value if any
666
+ }
667
+
668
+ function init() {
669
+ if (!ensureHost()) {
670
+ setTimeout(init, 200);
671
+ return;
672
+ }
673
+ startDeltaObserver();
674
+ }
675
+
676
+ // Public surface for the rest of the page (app.js).
677
+ window.HY_CHAT = {
678
+ init,
679
+ applyDelta,
680
+ getHost: ensureHost,
681
+ };
682
+
683
+ if (document.readyState === "loading") {
684
+ document.addEventListener("DOMContentLoaded", () => setTimeout(init, 200));
685
+ } else {
686
+ setTimeout(init, 200);
687
+ }
688
+ })();
static/css/_chat.css ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * Custom chat surface
3
+ *
4
+ * Layout
5
+ * ──────
6
+ * #hy-chat-host → the gr.HTML wrapper component. Owns the
7
+ * viewport-relative height; pure flex container.
8
+ * #hy-chat → our scrollable chat surface. Append-only DOM
9
+ * owned by static/chat.js; one .message-row per
10
+ * bubble.
11
+ *
12
+ * The rest of the file is component styling for the renderer's emitted
13
+ * DOM. The class names (``.message-row``, ``.message``, ``.bot``,
14
+ * ``.user``, ``.markdown``) are deliberately shared with the rules in
15
+ * _chatbot.css / _math.css / _thinking.css / _misc.css so those
16
+ * rulesets apply transparently to our bubbles.
17
+ * ───────────────────────────────────────────────────────────────────────── */
18
+
19
+ /* ── Outer wrapper: takes up the available vertical space. ─────────────
20
+ * We use absolute positioning on #hy-chat so it pins to all four edges
21
+ * of the host regardless of how many intermediate wrapper divs Gradio
22
+ * inserts (varies between versions). The host establishes the
23
+ * positioning context with ``position: relative``. */
24
+ #hy-chat-host {
25
+ position: relative !important;
26
+ height: calc(100vh - 200px) !important;
27
+ padding: 0 !important;
28
+ margin: 0 !important;
29
+ background: var(--hy-bg) !important;
30
+ border: none !important;
31
+ box-shadow: none !important;
32
+ min-height: 0 !important;
33
+ overflow: hidden !important;
34
+ }
35
+ /* Strip styling and positioning from any wrappers Gradio inserts
36
+ * between #hy-chat-host and #hy-chat — they would otherwise create new
37
+ * containing blocks and break the absolute-positioned scroll surface. */
38
+ #hy-chat-host > div,
39
+ #hy-chat-host > div > div {
40
+ position: static !important;
41
+ padding: 0 !important;
42
+ margin: 0 !important;
43
+ background: transparent !important;
44
+ border: none !important;
45
+ box-shadow: none !important;
46
+ height: auto !important;
47
+ }
48
+
49
+ /* ── Inner scrollable surface ──────────────────────────────────────────── */
50
+ #hy-chat {
51
+ position: absolute;
52
+ inset: 0;
53
+ overflow-y: auto;
54
+ overflow-x: hidden;
55
+ padding: 16px 24px 32px;
56
+ background: var(--hy-bg);
57
+ scroll-behavior: auto; /* programmatic scroll-to-bottom should be instant */
58
+ }
59
+
60
+ /* ── Message rows ──────────────────────────────────────────────────────── */
61
+ #hy-chat .message-row {
62
+ display: block;
63
+ width: 100%;
64
+ margin: 0 0 4px;
65
+ padding: 0;
66
+ }
67
+ #hy-chat .message-row.user-row {
68
+ /* Right-align user messages similar to Gradio's bubble layout. */
69
+ display: flex;
70
+ justify-content: flex-end;
71
+ margin: 12px 0 8px;
72
+ }
73
+ #hy-chat .message-row.bot-row {
74
+ margin: 0 0 18px;
75
+ }
76
+
77
+ /* ── Inner message containers ──────────────────────────────────────────── */
78
+ #hy-chat .message {
79
+ max-width: 95%;
80
+ background: transparent;
81
+ border: none;
82
+ padding: 0;
83
+ }
84
+ #hy-chat .message.user {
85
+ background: var(--hy-bg-muted, #f5f5f7);
86
+ border-radius: 18px;
87
+ padding: 10px 18px !important;
88
+ max-width: min(720px, 75%);
89
+ }
90
+ .dark #hy-chat .message.user {
91
+ background: #2a2a2a;
92
+ }
93
+ #hy-chat .message.bot {
94
+ background: transparent;
95
+ padding: 4px 0 !important;
96
+ }
97
+
98
+ /* ── User-text appearance (no markdown rendering — pre-wrap text). ────── */
99
+ #hy-chat .hy-user-text {
100
+ font-size: 15px;
101
+ line-height: 1.7;
102
+ color: var(--hy-text);
103
+ }
104
+
105
+ /* ── Frozen vs streaming segments inside the assistant bubble ──────────
106
+ * Both are direct children of .markdown so the existing #hy-chat .bot
107
+ * .markdown styling cascades into them transparently. The only thing
108
+ * we add here is to mark the streaming segment so future debugging or
109
+ * styling can target it (e.g. a subtle pulse to indicate "still
110
+ * generating" without flickering already-rendered formulas). */
111
+ #hy-chat .hy-frozen,
112
+ #hy-chat .hy-streaming {
113
+ display: block;
114
+ }
115
+
116
+ /* ── Tool-call display block (server-rendered Markdown HTML). ─────────── */
117
+ #hy-chat .hy-tool-calls {
118
+ margin-top: 10px;
119
+ }
120
+ #hy-chat .hy-tool-call + .hy-tool-call {
121
+ margin-top: 8px;
122
+ }
123
+
124
+ /* ── Hide the delta channel completely ─────────────────────────────────
125
+ * The bridge component for delta JSON. Must never occupy layout, must
126
+ * never trap pointer / focus / scroll events — but its content has to
127
+ * remain readable to the JS MutationObserver that drives the renderer
128
+ * (so we cannot use display:none). */
129
+ #hy-chat-delta,
130
+ #hy-chat-delta * {
131
+ position: absolute !important;
132
+ width: 0 !important;
133
+ height: 0 !important;
134
+ margin: 0 !important;
135
+ padding: 0 !important;
136
+ border: 0 !important;
137
+ overflow: hidden !important;
138
+ clip: rect(0 0 0 0) !important;
139
+ clip-path: inset(50%) !important;
140
+ visibility: hidden !important;
141
+ opacity: 0 !important;
142
+ pointer-events: none !important;
143
+ user-select: none !important;
144
+ }
static/css/_chatbot.css ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── chatbot container ── */
2
+ #hy-chat {
3
+ border: none !important;
4
+ box-shadow: none !important;
5
+ background: #ffffff !important;
6
+ --border-color-primary: transparent !important;
7
+ --color-accent-soft: #ffffff !important;
8
+ --background-fill-secondary: #ffffff !important;
9
+ }
10
+ .dark #hy-chat {
11
+ background: #171717 !important;
12
+ --border-color-primary: transparent !important;
13
+ --color-accent-soft: #1f1f1f !important;
14
+ --background-fill-secondary: #171717 !important;
15
+ }
16
+
17
+ /* ── message panel: uniform style for both user and bot ── */
18
+ #hy-chat .message-row .message-bubble-border,
19
+ #hy-chat .bot .message-bubble-border,
20
+ #hy-chat .user .message-bubble-border,
21
+ #hy-chat [data-testid="bot"] .message-bubble-border,
22
+ #hy-chat [data-testid="user"] .message-bubble-border {
23
+ border-color: transparent !important;
24
+ border-width: 0 !important;
25
+ box-shadow: none !important;
26
+ outline: none !important;
27
+ background: #ffffff !important;
28
+ padding: 4px 16px 4px 0 !important;
29
+ max-width: 100% !important;
30
+ }
31
+ .dark #hy-chat .message-row .message-bubble-border,
32
+ .dark #hy-chat .bot .message-bubble-border,
33
+ .dark #hy-chat .user .message-bubble-border,
34
+ .dark #hy-chat [data-testid="bot"] .message-bubble-border,
35
+ .dark #hy-chat [data-testid="user"] .message-bubble-border {
36
+ background: #171717 !important;
37
+ }
38
+
39
+ /* ── remove user message bubble border (all themes) ── */
40
+ #hy-chat .user,
41
+ #hy-chat .user .message,
42
+ #hy-chat .user .bubble,
43
+ #hy-chat .user > div,
44
+ #hy-chat [data-testid="user"],
45
+ #hy-chat [data-testid="user"] .message,
46
+ #hy-chat [data-testid="user"] .bubble,
47
+ #hy-chat [data-testid="user"] > div,
48
+ #hy-chat .message-row.user-row,
49
+ #hy-chat .message-row.user-row > div,
50
+ #hy-chat .user-message,
51
+ #hy-chat .message.user {
52
+ border: none !important;
53
+ border-width: 0 !important;
54
+ border-color: transparent !important;
55
+ box-shadow: none !important;
56
+ outline: none !important;
57
+ }
58
+ #hy-chat .user .message,
59
+ #hy-chat [data-testid="user"] .message,
60
+ #hy-chat .user-message {
61
+ background: transparent !important;
62
+ padding: 4px 16px !important;
63
+ }
64
+ .dark #hy-chat .user .message,
65
+ .dark #hy-chat [data-testid="user"] .message,
66
+ .dark #hy-chat .user-message {
67
+ background: transparent !important;
68
+ }
69
+
70
+ /* ── user text: match bot text style ── */
71
+ #hy-chat .user .markdown,
72
+ #hy-chat .user .message-content,
73
+ #hy-chat .user p,
74
+ #hy-chat [data-testid="user"] .markdown,
75
+ #hy-chat [data-testid="user"] .message-content,
76
+ #hy-chat [data-testid="user"] p {
77
+ font-size: 15px !important;
78
+ line-height: 1.8 !important;
79
+ color: #222222 !important;
80
+ letter-spacing: 0.01em !important;
81
+ }
82
+ .dark #hy-chat .user .markdown,
83
+ .dark #hy-chat .user .message-content,
84
+ .dark #hy-chat .user p,
85
+ .dark #hy-chat [data-testid="user"] .markdown,
86
+ .dark #hy-chat [data-testid="user"] .message-content,
87
+ .dark #hy-chat [data-testid="user"] p {
88
+ color: #e8e8e8 !important;
89
+ }
90
+
91
+ /* ── bot text styling ── */
92
+ #hy-chat .bot .markdown,
93
+ #hy-chat .bot .message-content,
94
+ #hy-chat [data-testid="bot"] .markdown {
95
+ font-size: 15px !important;
96
+ line-height: 1.8 !important;
97
+ color: #222222 !important;
98
+ letter-spacing: 0.01em !important;
99
+ }
100
+ .dark #hy-chat .bot .markdown,
101
+ .dark #hy-chat .bot .message-content,
102
+ .dark #hy-chat [data-testid="bot"] .markdown {
103
+ color: #e8e8e8 !important;
104
+ }
105
+ #hy-chat .bot p { margin: 0.6em 0 !important; }
106
+ #hy-chat .bot strong, #hy-chat .bot b {
107
+ font-weight: 600 !important;
108
+ color: #111 !important;
109
+ }
110
+ .dark #hy-chat .bot strong, .dark #hy-chat .bot b {
111
+ color: #fff !important;
112
+ }
113
+ #hy-chat .bot ul, #hy-chat .bot ol {
114
+ padding-left: 1.5em !important;
115
+ margin: 0.5em 0 !important;
116
+ }
117
+ #hy-chat .bot li {
118
+ margin: 0.3em 0 !important;
119
+ line-height: 1.8 !important;
120
+ }
121
+
122
+ /* ── headings ── */
123
+ #hy-chat .bot h1 { font-size: 22px !important; font-weight: 700 !important; color: #111 !important; margin: 1em 0 0.5em !important; }
124
+ #hy-chat .bot h2 { font-size: 19px !important; font-weight: 700 !important; color: #111 !important; margin: 0.9em 0 0.4em !important; }
125
+ #hy-chat .bot h3 { font-size: 17px !important; font-weight: 600 !important; color: #111 !important; margin: 0.8em 0 0.35em !important; }
126
+ #hy-chat .bot h4 { font-size: 15px !important; font-weight: 600 !important; color: #222 !important; margin: 0.7em 0 0.3em !important; }
127
+ .dark #hy-chat .bot h1 { color: #f2f2f2 !important; }
128
+ .dark #hy-chat .bot h2 { color: #ebebeb !important; }
129
+ .dark #hy-chat .bot h3 { color: #e0e0e0 !important; }
130
+ .dark #hy-chat .bot h4 { color: #d4d4d4 !important; }
131
+
132
+ /* ── tables ── */
133
+ #hy-chat table {
134
+ border-collapse: collapse !important;
135
+ width: auto !important;
136
+ margin: 1em 0 !important;
137
+ font-size: 14px !important;
138
+ line-height: 1.6 !important;
139
+ }
140
+ #hy-chat th, #hy-chat td {
141
+ border: 1px solid #e8e8e8 !important;
142
+ padding: 10px 20px !important;
143
+ text-align: left !important;
144
+ }
145
+ #hy-chat th {
146
+ background: #fafafa !important;
147
+ font-weight: 600 !important;
148
+ color: #333 !important;
149
+ }
150
+ #hy-chat td {
151
+ color: #444 !important;
152
+ background: #fff !important;
153
+ }
154
+ #hy-chat tr:hover td {
155
+ background: #fafcff !important;
156
+ }
157
+ .dark #hy-chat th, .dark #hy-chat td {
158
+ border-color: #2a2a2a !important;
159
+ }
160
+ .dark #hy-chat th {
161
+ background: #242424 !important;
162
+ color: #e0e0e0 !important;
163
+ }
164
+ .dark #hy-chat td {
165
+ color: #d0d0d0 !important;
166
+ background: #1c1c1c !important;
167
+ }
168
+ .dark #hy-chat tr:hover td {
169
+ background: #262626 !important;
170
+ }
static/css/_dark.css ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Dark mode: sidebar ── */
2
+ .dark .sidebar, .dark [class*="sidebar"] {
3
+ background: #1a1a1a !important;
4
+ border: none !important;
5
+ box-shadow: none !important;
6
+ }
7
+ .dark .sidebar h1, .dark [class*="sidebar"] h1 {
8
+ color: #e8e8e8 !important;
9
+ }
10
+ .dark .sidebar .block,
11
+ .dark .sidebar .form,
12
+ .dark .sidebar .panel,
13
+ .dark .sidebar > div,
14
+ .dark [class*="sidebar"] .block,
15
+ .dark [class*="sidebar"] .form,
16
+ .dark [class*="sidebar"] .panel,
17
+ .dark [class*="sidebar"] > div {
18
+ background: transparent !important;
19
+ border: none !important;
20
+ box-shadow: none !important;
21
+ }
22
+
23
+ /* ── Dark mode: global inputs ── */
24
+ .dark textarea, .dark input[type="text"] {
25
+ background: #2a2a2a !important;
26
+ color: #e8e8e8 !important;
27
+ border-color: #3a3a3a !important;
28
+ }
29
+ .dark textarea::placeholder, .dark input[type="text"]::placeholder {
30
+ color: #666 !important;
31
+ }
32
+
33
+ /* ── Dark mode: slider tracks ── */
34
+ .dark .gradio-container input[type="range"]::-moz-range-progress { background-color: #6b9fff !important; }
35
+ .dark .gradio-container input[type="range"]::-moz-range-track { background: #2a2a2a !important; }
36
+
37
+ /* ── Dark mode: dropdowns ── */
38
+ .dark select, .dark .wrap[data-testid="dropdown"] {
39
+ background: #2a2a2a !important;
40
+ color: #e8e8e8 !important;
41
+ border-color: #3a3a3a !important;
42
+ }
43
+
44
+ /* ── Dark mode: accordion ── */
45
+ .dark .accordion {
46
+ background: #1f1f1f !important;
47
+ border-color: #2a2a2a !important;
48
+ }
49
+
50
+ /* ── Dark mode: forms & labels ── */
51
+ .dark .form {
52
+ border-color: #2a2a2a !important;
53
+ }
54
+ .dark label, .dark .label-wrap {
55
+ color: #c0c0c0 !important;
56
+ }
57
+ .dark .info-text, .dark span[data-testid="block-info"] {
58
+ color: #808080 !important;
59
+ }
60
+
61
+ /* ── Dark mode: scrollbars ── */
62
+ .dark *::-webkit-scrollbar {
63
+ width: 8px !important;
64
+ height: 8px !important;
65
+ background: transparent !important;
66
+ }
67
+ .dark *::-webkit-scrollbar-track {
68
+ background: transparent !important;
69
+ border: none !important;
70
+ }
71
+ .dark *::-webkit-scrollbar-thumb {
72
+ background: #3a3a3a !important;
73
+ border-radius: 4px !important;
74
+ border: 2px solid transparent !important;
75
+ background-clip: padding-box !important;
76
+ }
77
+ .dark *::-webkit-scrollbar-thumb:hover {
78
+ background: #505050 !important;
79
+ background-clip: padding-box !important;
80
+ }
81
+ .dark *::-webkit-scrollbar-corner {
82
+ background: transparent !important;
83
+ }
84
+ .dark * {
85
+ scrollbar-width: thin !important;
86
+ scrollbar-color: #3a3a3a transparent !important;
87
+ }
88
+
89
+ /* ── Dark mode: sliders (track + thumb) ── */
90
+ .dark input[type="range"] {
91
+ -webkit-appearance: none !important;
92
+ appearance: none !important;
93
+ background: transparent !important;
94
+ height: 4px !important;
95
+ cursor: pointer !important;
96
+ }
97
+ .dark input[type="range"]::-webkit-slider-runnable-track {
98
+ background: #2f2f2f !important;
99
+ height: 4px !important;
100
+ border-radius: 2px !important;
101
+ border: none !important;
102
+ }
103
+ .dark input[type="range"]::-moz-range-track {
104
+ background: #2f2f2f !important;
105
+ height: 4px !important;
106
+ border-radius: 2px !important;
107
+ border: none !important;
108
+ }
109
+ .dark input[type="range"]::-webkit-slider-thumb {
110
+ -webkit-appearance: none !important;
111
+ appearance: none !important;
112
+ width: 18px !important;
113
+ height: 18px !important;
114
+ border-radius: 50% !important;
115
+ background: #6b9fff !important;
116
+ border: 2px solid #1f1f1f !important;
117
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important;
118
+ margin-top: -7px !important;
119
+ cursor: pointer !important;
120
+ }
121
+ .dark input[type="range"]::-moz-range-thumb {
122
+ width: 18px !important;
123
+ height: 18px !important;
124
+ border-radius: 50% !important;
125
+ background: #6b9fff !important;
126
+ border: 2px solid #1f1f1f !important;
127
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4) !important;
128
+ cursor: pointer !important;
129
+ }
130
+ .dark input[type="range"]::-moz-range-progress {
131
+ background-color: #4b7fd6 !important;
132
+ height: 4px !important;
133
+ border-radius: 2px !important;
134
+ }
135
+
136
+ /* ── Dark mode: number/spin inputs (slider value boxes) ── */
137
+ .dark input[type="number"] {
138
+ background: #1f1f1f !important;
139
+ color: #c8c8c8 !important;
140
+ border: 1px solid #262626 !important;
141
+ border-radius: 6px !important;
142
+ box-shadow: none !important;
143
+ }
144
+ .dark input[type="number"]:focus {
145
+ border-color: rgba(107, 159, 255, 0.35) !important;
146
+ outline: none !important;
147
+ }
148
+ .dark input[type="number"]::-webkit-inner-spin-button,
149
+ .dark input[type="number"]::-webkit-outer-spin-button {
150
+ filter: invert(0.85) !important;
151
+ opacity: 0.4 !important;
152
+ }
153
+ /* Number-input wrappers (the small editable boxes next to sliders).
154
+ * Previously also targeted .wrap.svelte-1cl284s; dropped the hash since
155
+ * Gradio regenerates it. Stable selectors below cover the same elements. */
156
+ .dark .gradio-container [class*="number_input"],
157
+ .dark .gradio-container [data-testid*="number"] input,
158
+ .dark .gradio-container [data-testid*="number"] .wrap,
159
+ .dark .gradio-container .head .wrap,
160
+ .dark .gradio-container .head input {
161
+ background: #1f1f1f !important;
162
+ color: #c8c8c8 !important;
163
+ border-color: #262626 !important;
164
+ box-shadow: none !important;
165
+ }
166
+ .dark .gradio-container .head,
167
+ .dark .gradio-container .head > div,
168
+ .dark .gradio-container .tabular-nums {
169
+ background: transparent !important;
170
+ border-color: #262626 !important;
171
+ }
172
+
173
+ /* ── Dark mode: slider min/max range labels (e.g. "0", "1", "65536") ── */
174
+ .dark .gradio-container .min,
175
+ .dark .gradio-container .max,
176
+ .dark .head .min,
177
+ .dark .head .max,
178
+ .dark span.tabular-nums {
179
+ color: #707070 !important;
180
+ }
181
+
182
+ /* ── Dark mode: reset-to-default arrow button next to inputs ── */
183
+ .dark button[aria-label="Reset to default value"],
184
+ .dark button[title="Reset to default value"] {
185
+ background: transparent !important;
186
+ color: #707070 !important;
187
+ border: none !important;
188
+ box-shadow: none !important;
189
+ }
190
+ .dark button[aria-label="Reset to default value"]:hover,
191
+ .dark button[title="Reset to default value"]:hover {
192
+ color: #b0b0b0 !important;
193
+ background: transparent !important;
194
+ }
static/css/_examples.css ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * Empty-state "What can I help you with?" overlay.
3
+ * Lives outside the Gradio container (appended to <body>) so we don't need
4
+ * !important to win against framework styles.
5
+ * ───────────────────────────────────────────────────────────────────────── */
6
+
7
+ #examples-overlay {
8
+ position: fixed;
9
+ left: 0; right: 0;
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ z-index: 100;
14
+ background: var(--hy-bg);
15
+ pointer-events: none;
16
+ overflow-y: auto;
17
+ }
18
+
19
+ .examples-wrapper {
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ margin: auto;
24
+ pointer-events: auto;
25
+ padding: 40px 20px;
26
+ }
27
+
28
+ .examples-heading {
29
+ font-size: 22px;
30
+ font-weight: 600;
31
+ color: var(--hy-text);
32
+ margin: 0 0 28px;
33
+ letter-spacing: -0.01em;
34
+ }
35
+
36
+ /* 1-2-1 "diamond" layout for exactly 4 example cards.
37
+ * row 1: card #1 (centered, spans both columns)
38
+ * row 2: card #2 (right-aligned in left col) | card #3 (left-aligned in right col)
39
+ * row 3: card #4 (centered, spans both columns)
40
+ * Using CSS grid with explicit nth-child placements rather than flex-
41
+ * wrap, because flex-wrap on equal-width children can only produce
42
+ * 1xN, 2x2, or 4x1 — never 1-2-1. */
43
+ .examples-grid {
44
+ display: grid;
45
+ grid-template-columns: minmax(0, 480px) minmax(0, 480px);
46
+ column-gap: 20px;
47
+ row-gap: 14px;
48
+ justify-content: center;
49
+ width: min(1020px, 100%);
50
+ }
51
+
52
+ .example-btn:nth-child(1),
53
+ .example-btn:nth-child(4) {
54
+ grid-column: 1 / span 2;
55
+ justify-self: center;
56
+ }
57
+ .example-btn:nth-child(2) {
58
+ grid-column: 1;
59
+ justify-self: end;
60
+ }
61
+ .example-btn:nth-child(3) {
62
+ grid-column: 2;
63
+ justify-self: start;
64
+ }
65
+
66
+ .example-btn {
67
+ /* Outer card: flex container that vertically + horizontally centers
68
+ * its single inner ``.example-btn-text`` child. We CANNOT also be
69
+ * a ``-webkit-box`` here (that's the inner element's job, see
70
+ * below) — line-clamp and flex centering need separate elements. */
71
+ display: flex !important;
72
+ align-items: center;
73
+ justify-content: center;
74
+ overflow: hidden !important;
75
+ box-sizing: border-box !important;
76
+
77
+ width: 460px;
78
+ /* 14px font * 1.5 line-height = 21px per line. 2 lines = 42px.
79
+ * + 14px*2 padding-y = 28px. + 1px*2 border = 2px. Total 72px. */
80
+ height: 72px;
81
+
82
+ background: var(--hy-bg);
83
+ border: 1px solid var(--hy-border);
84
+ border-radius: 18px;
85
+ padding: 14px 20px;
86
+ font-size: 14px;
87
+ line-height: 1.5;
88
+ color: var(--hy-text);
89
+ cursor: pointer;
90
+ transition: transform 0.15s ease, background 0.2s ease,
91
+ border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
92
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
93
+ "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica,
94
+ Arial, sans-serif;
95
+ user-select: none;
96
+ text-align: center;
97
+ }
98
+
99
+ /* Inner text element: owns the 2-line clamp + auto ellipsis. The
100
+ * ``!important`` triplet defends against framework-level resets that
101
+ * would otherwise silently change ``display`` and let a 3rd line
102
+ * bleed into view. */
103
+ .example-btn-text {
104
+ display: -webkit-box !important;
105
+ -webkit-line-clamp: 2 !important;
106
+ -webkit-box-orient: vertical !important;
107
+ overflow: hidden !important;
108
+ word-break: break-word;
109
+ width: 100%;
110
+ text-align: center;
111
+ }
112
+
113
+ /* On narrow screens the diamond would overflow horizontally — collapse
114
+ * to a single centered column instead. */
115
+ @media (max-width: 1020px) {
116
+ .examples-grid {
117
+ grid-template-columns: minmax(0, 1fr);
118
+ }
119
+ .example-btn:nth-child(1),
120
+ .example-btn:nth-child(2),
121
+ .example-btn:nth-child(3),
122
+ .example-btn:nth-child(4) {
123
+ grid-column: 1;
124
+ justify-self: center;
125
+ }
126
+ .example-btn {
127
+ width: 100%;
128
+ max-width: 520px;
129
+ }
130
+ }
131
+ .example-btn:hover {
132
+ background: var(--hy-bg-muted);
133
+ border-color: var(--hy-border-strong);
134
+ color: var(--hy-text-strong);
135
+ box-shadow: var(--hy-shadow);
136
+ transform: translateY(-1px);
137
+ }
138
+
139
+ .dark .example-btn:hover {
140
+ border-color: rgba(107, 159, 255, 0.35);
141
+ box-shadow: 0 0 8px rgba(107, 159, 255, 0.08);
142
+ }
static/css/_hide.css ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── hide all avatars ── */
2
+ #hy-chat [class*="avatar"] {
3
+ display: none !important;
4
+ width: 0 !important;
5
+ height: 0 !important;
6
+ min-width: 0 !important;
7
+ min-height: 0 !important;
8
+ overflow: hidden !important;
9
+ opacity: 0 !important;
10
+ margin: 0 !important;
11
+ padding: 0 !important;
12
+ position: absolute !important;
13
+ pointer-events: none !important;
14
+ }
15
+
16
+ /* ── hide Gradio default action buttons & icons ── */
17
+ #hy-chat button[aria-label="Copy message"],
18
+ #hy-chat button[title="Copy message"],
19
+ #hy-chat button[aria-label="Share message"],
20
+ #hy-chat button[title="Share message"],
21
+ #hy-chat button[aria-label="Delete message"],
22
+ #hy-chat button[title="Delete message"],
23
+ #hy-chat button[aria-label="Retry"],
24
+ #hy-chat button[title="Retry"],
25
+ #hy-chat button[aria-label="Undo"],
26
+ #hy-chat button[title="Undo"],
27
+ #hy-chat .message-buttons-bot,
28
+ #hy-chat .message-buttons-user,
29
+ #hy-chat .bot .icon-button-wrapper,
30
+ #hy-chat .user .icon-button-wrapper,
31
+ #hy-chat .bot .icon-button,
32
+ #hy-chat .user .icon-button,
33
+ #hy-chat .likeable {
34
+ display: none !important;
35
+ }
36
+
37
+ /*
38
+ * NOTE: do NOT use a blanket `.message-row button:not(.html-preview-btn)`
39
+ * rule here — it would also hide our per-code-block "Copy" button that
40
+ * lives inside .hy-codeblock-header at the top-right of each fenced
41
+ * code block. The specific aria-label/title selectors above already
42
+ * cover the message-level action buttons we want to hide.
43
+ */
44
+
45
+ /* ── hide stray images/svgs inside messages ── */
46
+ #hy-chat .message-row img:not([class*="markdown"]),
47
+ #hy-chat .message-row svg {
48
+ display: none !important;
49
+ }
50
+ /* …but keep the SVGs inside our custom code-copy-btn visible.
51
+ * Specificity is boosted via duplicated #hy-chat to beat the hide rule. */
52
+ #hy-chat#hy-chat .message-row .code-copy-btn,
53
+ #hy-chat#hy-chat .message-row .code-copy-btn svg,
54
+ #hy-chat#hy-chat .message-row .code-copy-btn svg * {
55
+ display: inline-flex !important;
56
+ }
57
+ #hy-chat#hy-chat .message-row .code-copy-btn svg {
58
+ display: block !important;
59
+ }
60
+ /* …and keep SVGs inside KaTeX math formulas visible.
61
+ * KaTeX uses SVGs for radicals, stretchy delimiters, fraction lines, etc. */
62
+ #hy-chat#hy-chat .message-row .katex svg,
63
+ #hy-chat#hy-chat .message-row .katex-display svg {
64
+ display: inline !important;
65
+ }
66
+
67
+ /* ── Hide scroll-to-bottom button (too jarring in both themes) ── */
68
+ #hy-chat button.scroll-hide,
69
+ #hy-chat button[aria-label*="scroll" i],
70
+ #hy-chat button[aria-label*="bottom" i],
71
+ #hy-chat .scroll-to-bottom,
72
+ #hy-chat [class*="scroll-button"],
73
+ #hy-chat [class*="scrollButton"],
74
+ #hy-chat button[class*="scroll"] {
75
+ display: none !important;
76
+ visibility: hidden !important;
77
+ opacity: 0 !important;
78
+ pointer-events: none !important;
79
+ }
80
+
81
+ /* ── Hide the busy-marker component completely ──
82
+ * Driven by chat.py: contains either "" (idle) or a single
83
+ * <span data-hy-streaming="1"> (streaming). It is a pure machine signal
84
+ * read by static/app.js; it must never occupy any visible layout
85
+ * regardless of value, and must never trap pointer/scroll/focus events. */
86
+ #hy-busy-marker,
87
+ #hy-busy-marker * {
88
+ position: absolute !important;
89
+ width: 0 !important;
90
+ height: 0 !important;
91
+ margin: 0 !important;
92
+ padding: 0 !important;
93
+ border: 0 !important;
94
+ overflow: hidden !important;
95
+ clip: rect(0 0 0 0) !important;
96
+ clip-path: inset(50%) !important;
97
+ visibility: hidden !important;
98
+ opacity: 0 !important;
99
+ pointer-events: none !important;
100
+ user-select: none !important;
101
+ }
102
+
103
+ /* ── Hide all Gradio progress / loading status indicators globally ── */
104
+ .progress-bar-wrap,
105
+ .progress-bar,
106
+ .progress-text,
107
+ .progress,
108
+ .eta-bar,
109
+ [data-testid="loader"],
110
+ .loader,
111
+ .wrap.default.minimal,
112
+ .gradio-container .status-wrap,
113
+ .gradio-container .status,
114
+ .gradio-container .meta-text,
115
+ .gradio-container .meta-text-center,
116
+ .gradio-container [class*="progress"],
117
+ .gradio-container [class*="Progress"],
118
+ .gradio-container .generating,
119
+ #hy-chat .generating,
120
+ #hy-chat .progress,
121
+ #hy-chat .progress-text,
122
+ #hy-chat [class*="progress"],
123
+ #hy-chat [class*="loading"],
124
+ #hy-chat [class*="loader"] {
125
+ display: none !important;
126
+ visibility: hidden !important;
127
+ opacity: 0 !important;
128
+ pointer-events: none !important;
129
+ height: 0 !important;
130
+ width: 0 !important;
131
+ }
static/css/_input.css ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── input row ── */
2
+ .msg-row {
3
+ padding: 8px 0 !important;
4
+ gap: 8px !important;
5
+ border-top: none !important;
6
+ background: #fff !important;
7
+ align-items: center !important;
8
+ height: 88px !important;
9
+ min-height: 88px !important;
10
+ max-height: 88px !important;
11
+ flex-shrink: 0 !important;
12
+ }
13
+ .dark .msg-row {
14
+ background: #171717 !important;
15
+ }
16
+ .dark .msg-row *:not(textarea):not(button):not(button *) {
17
+ background: transparent !important;
18
+ border-color: transparent !important;
19
+ box-shadow: none !important;
20
+ outline: none !important;
21
+ }
22
+ .msg-input textarea {
23
+ height: 72px !important;
24
+ min-height: 72px !important;
25
+ max-height: 72px !important;
26
+ overflow-y: auto !important;
27
+ resize: none !important;
28
+ border-radius: 20px !important;
29
+ border: 1px solid #e0e0e0 !important;
30
+ padding: 18px 22px !important;
31
+ font-size: 15px !important;
32
+ line-height: 1.5 !important;
33
+ background: #fff !important;
34
+ box-shadow: 0 1px 4px rgba(0,0,0,0.06) !important;
35
+ color: #000 !important;
36
+ }
37
+ .dark .msg-input textarea {
38
+ background: #2a2a2a !important;
39
+ border-color: #3a3a3a !important;
40
+ color: #e8e8e8 !important;
41
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3) !important;
42
+ }
43
+ .msg-input textarea:focus {
44
+ border-color: #c0c0c0 !important;
45
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08) !important;
46
+ outline: none !important;
47
+ }
48
+ .dark .msg-input textarea:focus {
49
+ border-color: rgba(107, 159, 255, 0.4) !important;
50
+ box-shadow: 0 0 0 2px rgba(107, 159, 255, 0.1), 0 2px 8px rgba(0,0,0,0.3) !important;
51
+ }
52
+
53
+ /* ── new chat (+) button ── */
54
+ .new-chat-btn {
55
+ width: 42px !important;
56
+ height: 42px !important;
57
+ min-width: 42px !important;
58
+ max-width: 42px !important;
59
+ border-radius: 50% !important;
60
+ border: 1px solid #e5e7eb !important;
61
+ background: #fff !important;
62
+ color: #666 !important;
63
+ font-size: 22px !important;
64
+ font-weight: 300 !important;
65
+ padding: 0 !important;
66
+ display: flex !important;
67
+ align-items: center !important;
68
+ justify-content: center !important;
69
+ cursor: pointer !important;
70
+ flex-shrink: 0 !important;
71
+ }
72
+ .new-chat-btn:hover {
73
+ background: #f5f5f5 !important;
74
+ color: #333 !important;
75
+ border-color: #d0d0d0 !important;
76
+ }
77
+ .dark .new-chat-btn {
78
+ border-color: #3a3a3a !important;
79
+ background: #2a2a2a !important;
80
+ color: #999 !important;
81
+ }
82
+ .dark .new-chat-btn:hover {
83
+ background: #333 !important;
84
+ color: #e8e8e8 !important;
85
+ border-color: #505050 !important;
86
+ }
87
+
88
+ /* ── buttons ── */
89
+ button.primary { border-radius: 20px !important; font-weight: 500 !important; font-size: 14px !important; padding: 8px 20px !important; }
90
+ button.secondary { border: 1px solid #e5e5ea !important; border-radius: 20px !important; font-size: 14px !important; color: #444 !important; padding: 8px 16px !important; }
91
+ .dark button.secondary { border-color: #3a3a3a !important; color: #c0c0c0 !important; }
92
+
93
+ /* ── send button ── */
94
+ .send-btn, #send-btn {
95
+ width: 42px !important;
96
+ height: 42px !important;
97
+ min-width: 42px !important;
98
+ max-width: 42px !important;
99
+ border-radius: 50% !important;
100
+ padding: 0 !important;
101
+ display: flex !important;
102
+ align-items: center !important;
103
+ justify-content: center !important;
104
+ font-size: 18px !important;
105
+ flex-shrink: 0 !important;
106
+ }
107
+ #send-btn button {
108
+ width: 42px !important;
109
+ height: 42px !important;
110
+ min-width: 42px !important;
111
+ border-radius: 50% !important;
112
+ padding: 0 !important;
113
+ font-size: 18px !important;
114
+ }
static/css/_layout.css ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── full-viewport layout ── */
2
+ html, body {
3
+ overflow: hidden !important;
4
+ height: 100vh !important;
5
+ background: #fff !important;
6
+ }
7
+ .dark html, .dark body, html.dark, body.dark {
8
+ background: #171717 !important;
9
+ }
10
+ .gradio-container {
11
+ height: 100vh !important;
12
+ overflow: hidden !important;
13
+ max-width: 100% !important;
14
+ background: #fff !important;
15
+ }
16
+ .dark .gradio-container, .gradio-container.dark {
17
+ background: #171717 !important;
18
+ }
19
+ .main {
20
+ height: 100% !important;
21
+ overflow: hidden !important;
22
+ background: #fff !important;
23
+ }
24
+ .dark .main {
25
+ background: #171717 !important;
26
+ }
27
+
28
+ /* ── global font ──
29
+ * font-family is set on #hy-chat only (not #hy-chat *) so that KaTeX's
30
+ * own math-font declarations (KaTeX_Main, KaTeX_Math, …) are not
31
+ * overridden. Children inherit the system stack; KaTeX overrides it
32
+ * via its own class-based rules. */
33
+ #hy-chat {
34
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
35
+ "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica,
36
+ Arial, sans-serif !important;
37
+ }
38
+ #hy-chat, #hy-chat * {
39
+ -webkit-font-smoothing: antialiased !important;
40
+ -moz-osx-font-smoothing: grayscale !important;
41
+ }
static/css/_legacy.css ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Form / accordion / label tweaks ──────────────────────────────────────
2
+ *
3
+ * Previously these rules targeted Svelte hash classes (e.g. .svelte-d5xbca,
4
+ * .svelte-xzq5jh, .svelte-e5lyqv, .svelte-jdcl7l) which Gradio re-generates on
5
+ * every release. We drop the hashes and use stable class/role selectors so a
6
+ * Gradio bump doesn't silently break our styling.
7
+ */
8
+
9
+ /* hide the rectangular border Gradio puts around forms */
10
+ .gradio-container .form {
11
+ border: none !important;
12
+ }
13
+
14
+ /* small "Validate & Format" style accordion buttons inside forms */
15
+ .gradio-container .form button.sm,
16
+ .gradio-container .accordion button.sm {
17
+ background: rgb(229, 231, 235);
18
+ }
19
+ .dark .gradio-container .form button.sm,
20
+ .dark .gradio-container .accordion button.sm {
21
+ background: #2a2a2a !important;
22
+ color: #c0c0c0 !important;
23
+ }
24
+
25
+ /* accordion / dropdown / slider label colors */
26
+ .gradio-container .label-wrap,
27
+ .gradio-container .label-wrap > span {
28
+ color: #000;
29
+ }
30
+ .dark .gradio-container .label-wrap,
31
+ .dark .gradio-container .label-wrap > span {
32
+ color: #c0c0c0 !important;
33
+ }
34
+
35
+ /* bot paragraph color (kept here because it's adjacent to label color logic) */
36
+ #hy-chat .bot p {
37
+ color: #000;
38
+ }
39
+ .dark #hy-chat .bot p {
40
+ color: #e8e8e8 !important;
41
+ }
42
+
43
+ /* prose inside the Gradio container (markdown rendering); use attribute
44
+ * selector that matches any container-version class instead of a pinned one */
45
+ [class*="gradio-container"] .prose * {
46
+ color: #000;
47
+ }
48
+ .dark [class*="gradio-container"] .prose * {
49
+ color: #e8e8e8 !important;
50
+ }
static/css/_math.css ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * KaTeX math rendering
3
+ *
4
+ * The chatbot CSS sets line-height: 1.8 + letter-spacing: 0.01em on
5
+ * .bot .markdown. These cascade into KaTeX, where SVGs for stretchy
6
+ * operators (like \not=) use height: inherit + width: 100%. An inflated
7
+ * line-height turns those SVGs into solid-colour rectangles. We reset
8
+ * typographic properties on all KaTeX descendants to restore correct
9
+ * glyph and symbol layout.
10
+ * ───────────────────────────────────────────────────────────────────────── */
11
+
12
+ /* ── Restore KaTeX's own font ──
13
+ * _layout.css sets font-family on #hy-chat; KaTeX children inherit it.
14
+ * Override here so KaTeX uses its own math fonts for correct glyphs. */
15
+ #hy-chat .katex {
16
+ font: normal 1.21em KaTeX_Main, "Times New Roman", serif !important;
17
+ text-indent: 0 !important;
18
+ }
19
+
20
+ /* ── Reset inherited typography ──
21
+ * High-specificity selectors ensure we beat the #hy-chat .bot .markdown
22
+ * rule that sets line-height: 1.8 and letter-spacing: 0.01em. */
23
+ #hy-chat .bot .markdown .katex,
24
+ #hy-chat .bot .markdown .katex *,
25
+ #hy-chat .bot .markdown .katex-display,
26
+ #hy-chat .bot .markdown .katex-display *,
27
+ #hy-chat .katex,
28
+ #hy-chat .katex *,
29
+ #hy-chat .katex-display,
30
+ #hy-chat .katex-display * {
31
+ letter-spacing: normal !important;
32
+ word-spacing: normal !important;
33
+ line-height: 1.2 !important;
34
+ }
35
+
36
+ /* ── SVG rendering ──
37
+ * KaTeX uses SVGs for radicals (√), stretchy delimiters, fraction lines,
38
+ * and negation slashes. Ensure they inherit the current text colour and
39
+ * are not distorted by the parent's line-height. */
40
+ #hy-chat .katex svg {
41
+ fill: currentColor;
42
+ stroke: none;
43
+ }
44
+ #hy-chat .katex svg path {
45
+ fill: currentColor;
46
+ }
47
+
48
+ /* ── SVG overlay fix ──
49
+ * Constrain overlay containers (negation slashes, accents) so an
50
+ * inflated line-height can never stretch SVGs into black blocks. */
51
+ #hy-chat .katex .rlap,
52
+ #hy-chat .katex .llap,
53
+ #hy-chat .katex .clap {
54
+ line-height: 0 !important;
55
+ }
56
+ #hy-chat .katex .stretchy,
57
+ #hy-chat .katex .halfarrow-left,
58
+ #hy-chat .katex .halfarrow-right,
59
+ #hy-chat .katex .brace-left,
60
+ #hy-chat .katex .brace-right {
61
+ overflow: hidden !important;
62
+ }
63
+ #hy-chat .katex .rule,
64
+ #hy-chat .katex .mfrac .frac-line,
65
+ #hy-chat .katex .overline .overline-line,
66
+ #hy-chat .katex .underline .underline-line,
67
+ #hy-chat .katex .hline {
68
+ overflow: hidden !important;
69
+ }
70
+
71
+ /* ── Text colour ──
72
+ * Explicit colour in both themes so KaTeX text and SVG fills (via
73
+ * currentColor) always match the surrounding prose. */
74
+ #hy-chat .katex,
75
+ #hy-chat .katex-display {
76
+ color: var(--hy-text, #222222) !important;
77
+ }
78
+
79
+ /* ── Display math (block) ── */
80
+ #hy-chat .katex-display {
81
+ margin: 1em 0 !important;
82
+ padding: 0.4em 0 !important;
83
+ overflow-x: auto !important;
84
+ overflow-y: hidden !important;
85
+ -webkit-overflow-scrolling: touch;
86
+ }
87
+ #hy-chat .katex-display > .katex {
88
+ text-align: center !important;
89
+ }
90
+
91
+ /* Subtle scrollbar for wide equations */
92
+ #hy-chat .katex-display::-webkit-scrollbar {
93
+ height: 3px;
94
+ }
95
+ #hy-chat .katex-display::-webkit-scrollbar-track {
96
+ background: transparent;
97
+ }
98
+ #hy-chat .katex-display::-webkit-scrollbar-thumb {
99
+ background: #d0d0d0;
100
+ border-radius: 2px;
101
+ }
102
+
103
+ /* ── Inline math ── */
104
+ #hy-chat p .katex,
105
+ #hy-chat li .katex,
106
+ #hy-chat td .katex {
107
+ font-size: 1.05em;
108
+ vertical-align: baseline;
109
+ }
110
+
111
+ /* ── KaTeX error fallback: show raw LaTeX legibly ── */
112
+ #hy-chat .katex-error {
113
+ color: var(--hy-text-muted, #666) !important;
114
+ font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace !important;
115
+ font-size: 0.88em !important;
116
+ background: var(--hy-bg-muted, #f5f5f7) !important;
117
+ padding: 1px 5px !important;
118
+ border-radius: 3px !important;
119
+ }
120
+
121
+ /* ── Dark mode ── */
122
+ .dark #hy-chat .katex,
123
+ .dark #hy-chat .katex-display {
124
+ color: var(--hy-text, #e8e8e8) !important;
125
+ }
126
+ .dark #hy-chat .katex-display::-webkit-scrollbar-thumb {
127
+ background: #404040;
128
+ }
129
+ .dark #hy-chat .katex-error {
130
+ color: var(--hy-text-muted, #909090) !important;
131
+ background: var(--hy-bg-muted, #2a2a2a) !important;
132
+ }
static/css/_misc.css ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── inputs global ── */
2
+ textarea, input[type="text"] { border-radius: 10px !important; font-size: 15px !important; }
3
+
4
+ /* ── code blocks ── */
5
+ #hy-chat pre {
6
+ background: #f5f5f7 !important;
7
+ border: 1px solid #ebebeb !important;
8
+ border-radius: 8px !important;
9
+ padding: 14px 18px !important;
10
+ font-size: 13px !important;
11
+ line-height: 1.6 !important;
12
+ overflow-x: auto !important;
13
+ margin: 0.8em 0 !important;
14
+ }
15
+ .dark #hy-chat pre {
16
+ background: #111 !important;
17
+ border-color: #2a2a2a !important;
18
+ }
19
+ #hy-chat code {
20
+ font-family: "SF Mono", "Fira Code", "JetBrains Mono", "Consolas", "Monaco", monospace !important;
21
+ font-size: 0.9em !important;
22
+ }
23
+ .dark #hy-chat code {
24
+ color: #d4d4d4 !important;
25
+ }
26
+ #hy-chat :not(pre) > code {
27
+ background: #f0f0f3 !important;
28
+ padding: 2px 6px !important;
29
+ border-radius: 4px !important;
30
+ color: #333 !important;
31
+ }
32
+ .dark #hy-chat :not(pre) > code {
33
+ background: #2a2a2a !important;
34
+ color: #d4d4d4 !important;
35
+ }
36
+
37
+ /* ── blockquote ── */
38
+ #hy-chat blockquote {
39
+ border-left: 3px solid #d1d1d6 !important;
40
+ margin: 0.8em 0 !important;
41
+ padding: 4px 16px !important;
42
+ color: #555 !important;
43
+ background: transparent !important;
44
+ }
45
+ .dark #hy-chat blockquote {
46
+ border-left-color: #3a3a3a !important;
47
+ color: #909090 !important;
48
+ }
49
+
50
+ /* ── hr ── */
51
+ #hy-chat hr {
52
+ border: none !important;
53
+ border-top: 1px solid #e5e5ea !important;
54
+ margin: 1.2em 0 !important;
55
+ }
56
+ .dark #hy-chat hr {
57
+ border-top-color: #2a2a2a !important;
58
+ }
59
+
60
+ /* ── slider progress ── */
61
+ .gradio-container input[type="range"]::-moz-range-progress { background-color: #2563eb !important; }
62
+ .gradio-container input[type="range"]::-moz-range-track { background: #e5e7eb !important; }
static/css/_modals.css ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * Floating panels: tool-call input area + HTML preview modal.
3
+ *
4
+ * #tool-area lives inside the Gradio tree, so it still needs !important to
5
+ * win the cascade against framework styles.
6
+ *
7
+ * .html-preview-* lives directly under <body> (appended by static/app.js)
8
+ * so it can use plain specificity.
9
+ * ───────────────────────────────────────────────────────────────────────── */
10
+
11
+ /* ── tool area (inside Gradio) ─────────────────────────────────────────── */
12
+ #tool-area {
13
+ position: fixed !important;
14
+ bottom: 120px !important;
15
+ left: 50% !important;
16
+ transform: translateX(-50%) !important;
17
+ width: min(600px, 55%) !important;
18
+ z-index: 999 !important;
19
+ background: var(--hy-bg) !important;
20
+ border: 1px solid var(--hy-border) !important;
21
+ border-radius: 12px !important;
22
+ padding: 16px !important;
23
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08) !important;
24
+ }
25
+ .dark #tool-area {
26
+ background: var(--hy-bg-soft) !important;
27
+ border-color: var(--hy-border) !important;
28
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5) !important;
29
+ }
30
+ .dark #tool-area *:not(button):not(button *) {
31
+ background: transparent !important;
32
+ border-color: var(--hy-border-strong) !important;
33
+ }
34
+ .dark #tool-area textarea,
35
+ .dark #tool-area input[type="text"] {
36
+ background: var(--hy-bg-muted) !important;
37
+ color: var(--hy-text) !important;
38
+ border: 1px solid var(--hy-border-strong) !important;
39
+ }
40
+ .dark #tool-area textarea::placeholder,
41
+ .dark #tool-area input[type="text"]::placeholder {
42
+ color: var(--hy-text-muted) !important;
43
+ }
44
+
45
+ /* ── Code-block card (header w/ language label + copy button) ──────────
46
+ *
47
+ * Each fenced code block is wrapped by static/app.js:
48
+ *
49
+ * <div class="hy-codeblock">
50
+ * <div class="hy-codeblock-header">
51
+ * <span class="hy-codeblock-lang">python</span>
52
+ * <button class="code-copy-btn">…</button>
53
+ * </div>
54
+ * <pre><code class="hljs language-…">…</code></pre>
55
+ * </div>
56
+ *
57
+ * The wrapper owns the rounded border and background so the inner <pre>
58
+ * can be flush with the header (no double border / gap). */
59
+ #hy-chat .hy-codeblock {
60
+ margin: 0.9em 0;
61
+ border: 1px solid var(--hy-border);
62
+ border-radius: 10px;
63
+ overflow: hidden;
64
+ background: #f6f7f9;
65
+ }
66
+ .dark #hy-chat .hy-codeblock {
67
+ border-color: #2a2a2a;
68
+ background: #0f1115;
69
+ }
70
+
71
+ #hy-chat .hy-codeblock-header {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ gap: 12px;
76
+ padding: 6px 8px 6px 14px;
77
+ background: #ececef;
78
+ border-bottom: 1px solid var(--hy-border);
79
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
80
+ "Segoe UI", Roboto, sans-serif;
81
+ font-size: 12px;
82
+ line-height: 1;
83
+ color: #6b6b6b;
84
+ user-select: none;
85
+ }
86
+ .dark #hy-chat .hy-codeblock-header {
87
+ background: #1a1d22;
88
+ border-bottom-color: #262a30;
89
+ color: #9ca3af;
90
+ }
91
+
92
+ #hy-chat .hy-codeblock-lang {
93
+ text-transform: lowercase;
94
+ letter-spacing: 0.02em;
95
+ font-weight: 500;
96
+ }
97
+ #hy-chat .hy-codeblock-lang:empty::before {
98
+ content: "text";
99
+ opacity: 0.7;
100
+ }
101
+
102
+ /* Strip the standalone <pre> chrome from _misc.css when nested in a
103
+ * .hy-codeblock — the wrapper provides the border/radius/background. */
104
+ #hy-chat .hy-codeblock pre {
105
+ margin: 0 !important;
106
+ border: none !important;
107
+ border-radius: 0 !important;
108
+ background: transparent !important;
109
+ padding: 12px 16px !important;
110
+ }
111
+
112
+ /* Copy button — lives in the header, simple icon button. */
113
+ #hy-chat .hy-codeblock-header .code-copy-btn {
114
+ flex-shrink: 0;
115
+ width: 26px;
116
+ height: 26px;
117
+ padding: 0;
118
+ display: inline-flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ color: inherit;
122
+ background: transparent;
123
+ border: none;
124
+ border-radius: 6px;
125
+ cursor: pointer;
126
+ transition: background 0.15s ease, color 0.15s ease;
127
+ }
128
+ #hy-chat .hy-codeblock-header .code-copy-btn svg {
129
+ display: block;
130
+ width: 14px;
131
+ height: 14px;
132
+ }
133
+ #hy-chat .hy-codeblock-header .code-copy-btn:hover {
134
+ background: rgba(0, 0, 0, 0.06);
135
+ color: #111;
136
+ }
137
+ #hy-chat .hy-codeblock-header .code-copy-btn:focus {
138
+ outline: none;
139
+ }
140
+ #hy-chat .hy-codeblock-header .code-copy-btn:focus-visible {
141
+ outline: 2px solid var(--hy-accent, #2563eb);
142
+ outline-offset: 1px;
143
+ }
144
+ #hy-chat .hy-codeblock-header .code-copy-btn.copied,
145
+ #hy-chat .hy-codeblock-header .code-copy-btn.copied:hover {
146
+ color: #0a7f3f;
147
+ background: transparent;
148
+ }
149
+ .dark #hy-chat .hy-codeblock-header .code-copy-btn:hover {
150
+ background: rgba(255, 255, 255, 0.08);
151
+ color: #f5f5f5;
152
+ }
153
+ .dark #hy-chat .hy-codeblock-header .code-copy-btn.copied,
154
+ .dark #hy-chat .hy-codeblock-header .code-copy-btn.copied:hover {
155
+ color: #4ade80;
156
+ background: transparent;
157
+ }
158
+
159
+ /* ── HTML preview play button (under code blocks) ─────────────────────── */
160
+ .html-preview-btn {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ background: none;
164
+ border: none;
165
+ padding: 2px 0;
166
+ margin: 2px 0 6px;
167
+ cursor: pointer;
168
+ font-size: 0;
169
+ line-height: 1;
170
+ transition: opacity 0.15s ease;
171
+ }
172
+ .html-preview-btn::before {
173
+ content: "▶ html preview";
174
+ font-size: 12px;
175
+ color: var(--hy-accent);
176
+ font-weight: 400;
177
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
178
+ }
179
+ .html-preview-btn:hover {
180
+ opacity: 0.7;
181
+ }
182
+
183
+ /* ── HTML preview modal (outside Gradio, under <body>) ────────────────── */
184
+ .html-preview-overlay {
185
+ position: fixed;
186
+ inset: 0;
187
+ z-index: 10000;
188
+ background: rgba(0, 0, 0, 0.5);
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ animation: fadeIn 0.2s ease;
193
+ }
194
+ @keyframes fadeIn {
195
+ from { opacity: 0; }
196
+ to { opacity: 1; }
197
+ }
198
+
199
+ .html-preview-modal {
200
+ width: 90vw;
201
+ height: 85vh;
202
+ background: var(--hy-bg);
203
+ border-radius: 12px;
204
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
205
+ display: flex;
206
+ flex-direction: column;
207
+ overflow: hidden;
208
+ animation: modalSlideUp 0.25s ease;
209
+ }
210
+ .dark .html-preview-modal {
211
+ background: var(--hy-bg-soft);
212
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
213
+ }
214
+ @keyframes modalSlideUp {
215
+ from { transform: translateY(30px); opacity: 0; }
216
+ to { transform: translateY(0); opacity: 1; }
217
+ }
218
+
219
+ .html-preview-header {
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: space-between;
223
+ padding: 12px 20px;
224
+ border-bottom: 1px solid var(--hy-border);
225
+ background: var(--hy-bg-soft);
226
+ flex-shrink: 0;
227
+ }
228
+ .dark .html-preview-header {
229
+ background: var(--hy-bg-muted);
230
+ }
231
+
232
+ .html-preview-title {
233
+ font-size: 14px;
234
+ font-weight: 600;
235
+ color: var(--hy-text);
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 8px;
239
+ }
240
+ .html-preview-title::before {
241
+ content: "⬡";
242
+ color: var(--hy-accent);
243
+ }
244
+
245
+ .html-preview-close {
246
+ background: none;
247
+ border: none;
248
+ font-size: 20px;
249
+ color: var(--hy-text-muted);
250
+ cursor: pointer;
251
+ padding: 4px 8px;
252
+ border-radius: 6px;
253
+ line-height: 1;
254
+ transition: all 0.15s ease;
255
+ }
256
+ .html-preview-close:hover {
257
+ background: #fee2e2;
258
+ color: #dc2626;
259
+ }
260
+
261
+ .html-preview-iframe {
262
+ flex: 1;
263
+ border: none;
264
+ width: 100%;
265
+ background: var(--hy-bg);
266
+ }
static/css/_perf.css ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Long-conversation rendering optimizations ─────────────────────────
2
+ *
3
+ * Two complementary tricks that together make the page feel responsive
4
+ * even after a dozen or more turns:
5
+ *
6
+ * 1. ``content-visibility: auto`` on every message row tells the
7
+ * browser it MAY skip layout, paint, and rendering for rows that
8
+ * are scrolled off-screen. The bottom of the conversation (the
9
+ * part the user actually sees while streaming) renders normally;
10
+ * everything above gets cheaply substituted with the placeholder
11
+ * box defined by ``contain-intrinsic-size``. Result: per-frame
12
+ * style recalc / layout / paint cost stays roughly constant
13
+ * regardless of how many turns are in the history.
14
+ *
15
+ * 2. ``contain: layout style paint`` scopes layout/paint
16
+ * invalidations to within each message row. Without it, ANY
17
+ * style change inside one message can force the whole chatbot
18
+ * to recalc — and during streaming the bottom bubble mutates
19
+ * dozens of times a second.
20
+ *
21
+ * The intrinsic-size value (``auto 240px``) is a guess at an average
22
+ * row height. The ``auto`` keyword (Chromium 110+, Safari 18+,
23
+ * Firefox 132+) lets the browser remember the LAST observed size of
24
+ * each row and use that as the placeholder when the row is skipped,
25
+ * which keeps the scrollbar honest and avoids visible jumps when the
26
+ * user scrolls back up. Browsers that don't support ``auto`` fall back
27
+ * to the literal 240px, which is "good enough" for placeholders. */
28
+ #hy-chat .message-row {
29
+ content-visibility: auto;
30
+ contain-intrinsic-size: auto 240px;
31
+ contain: layout style paint;
32
+ }
static/css/_thinking.css ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── thinking toggle ── */
2
+ #hy-chat .thinking-block {
3
+ background: none !important;
4
+ border: none !important;
5
+ padding: 0 !important;
6
+ margin: 6px 0 10px !important;
7
+ }
8
+ #hy-chat .thinking-block summary {
9
+ cursor: pointer;
10
+ font-size: 13px !important;
11
+ font-weight: 400 !important;
12
+ color: #8a8a8e !important;
13
+ padding: 0 !important;
14
+ list-style: none !important;
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: 4px;
18
+ }
19
+ #hy-chat .thinking-block summary:hover { color: #666 !important; }
20
+ .dark #hy-chat .thinking-block summary { color: #808080 !important; }
21
+ .dark #hy-chat .thinking-block summary:hover { color: #aaa !important; }
22
+ #hy-chat .thinking-block summary::marker,
23
+ #hy-chat .thinking-block summary::-webkit-details-marker { display: none !important; }
24
+ #hy-chat .thinking-block summary::after { content: " ›" !important; font-size: 13px; color: #bbb; }
25
+ #hy-chat .thinking-block[open] summary::after { content: " ⌄" !important; }
26
+ #hy-chat .thinking-block[open] summary { margin-bottom: 8px !important; }
27
+ #hy-chat .thinking-block[open] > :not(summary) {
28
+ font-size: 13px; color: #666; line-height: 1.7;
29
+ padding: 12px 16px; background: #f8f8fa !important; border-radius: 8px;
30
+ max-height: 300px; overflow-y: auto; border-left: 2px solid #e5e5ea !important;
31
+ }
32
+ .dark #hy-chat .thinking-block[open] > :not(summary) {
33
+ color: #909090 !important; background: #1c1c1c !important;
34
+ border-left-color: #333 !important;
35
+ }
static/css/_typing.css ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── typing indicator (waiting for model response) ── */
2
+ .typing-indicator {
3
+ display: flex;
4
+ align-items: center;
5
+ gap: 5px;
6
+ padding: 4px 0;
7
+ height: 24px;
8
+ }
9
+ .typing-indicator span {
10
+ width: 8px;
11
+ height: 8px;
12
+ background: var(--body-text-color-subdued, #999);
13
+ border-radius: 50%;
14
+ animation: typing-bounce 1.4s infinite ease-in-out both;
15
+ }
16
+ .typing-indicator span:nth-child(1) { animation-delay: 0s; }
17
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
18
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
19
+
20
+ @keyframes typing-bounce {
21
+ 0%, 80%, 100% {
22
+ transform: scale(0.5);
23
+ opacity: 0.35;
24
+ }
25
+ 40% {
26
+ transform: scale(1);
27
+ opacity: 1;
28
+ }
29
+ }
static/css/_variables.css ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * Design tokens (custom properties)
3
+ *
4
+ * Two layers:
5
+ * 1. ``--hy-*`` : our own palette — referenced from component CSS.
6
+ * 2. ``--*-*-*`` : Gradio's built-in tokens — overridden so Gradio's
7
+ * components match the rest of the UI.
8
+ *
9
+ * If you want to retheme HY3, change values here and (almost) nowhere else.
10
+ * ───────────────────────────────────────────────────────────────────────── */
11
+
12
+ :root, .gradio-container {
13
+ /* Hy3 palette (light) */
14
+ --hy-bg: #ffffff;
15
+ --hy-bg-soft: #fafafa;
16
+ --hy-bg-muted: #f5f5f7;
17
+ --hy-text: #222222;
18
+ --hy-text-strong: #111111;
19
+ --hy-text-muted: #666666;
20
+ --hy-border: #e5e7eb;
21
+ --hy-border-strong: #d1d1d6;
22
+ --hy-accent: #2563eb;
23
+ --hy-accent-hover: #1d4ed8;
24
+ --hy-accent-soft: #dbeafe;
25
+ --hy-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
26
+
27
+ /* Gradio framework overrides (light) */
28
+ --body-background-fill: var(--hy-bg) !important;
29
+ --block-background-fill: var(--hy-bg) !important;
30
+ --block-shadow: none !important;
31
+ --block-border-color: transparent !important;
32
+ --input-border-width: 1px !important;
33
+ --input-border-color: var(--hy-border) !important;
34
+ --color-accent: var(--hy-accent) !important;
35
+ --color-accent-soft: var(--hy-accent-soft) !important;
36
+ --button-primary-background-fill: var(--hy-accent) !important;
37
+ --button-primary-background-fill-hover: var(--hy-accent-hover) !important;
38
+ --button-primary-text-color: #ffffff !important;
39
+ --slider-color: var(--hy-accent) !important;
40
+ --neutral-200: var(--hy-border) !important;
41
+ }
42
+
43
+ /* Hy3 palette (dark) + matching Gradio overrides */
44
+ .dark {
45
+ --hy-bg: #171717;
46
+ --hy-bg-soft: #1f1f1f;
47
+ --hy-bg-muted: #2a2a2a;
48
+ --hy-text: #e8e8e8;
49
+ --hy-text-strong: #ffffff;
50
+ --hy-text-muted: #909090;
51
+ --hy-border: #2a2a2a;
52
+ --hy-border-strong: #3a3a3a;
53
+ --hy-accent: #6b9fff;
54
+ --hy-accent-hover: #4b8df8;
55
+ --hy-accent-soft: #1a2a3d;
56
+ --hy-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
57
+
58
+ --body-background-fill: var(--hy-bg) !important;
59
+ --block-background-fill: var(--hy-bg-soft) !important;
60
+ --block-shadow: none !important;
61
+ --block-border-color: var(--hy-border) !important;
62
+ --input-border-width: 1px !important;
63
+ --input-border-color: var(--hy-border-strong) !important;
64
+ --color-accent: var(--hy-accent) !important;
65
+ --color-accent-soft: var(--hy-accent-soft) !important;
66
+ --button-primary-background-fill: var(--hy-accent-hover) !important;
67
+ --button-primary-background-fill-hover: #3a7ce6 !important;
68
+ --button-primary-text-color: #ffffff !important;
69
+ --slider-color: var(--hy-accent) !important;
70
+ --neutral-200: var(--hy-border) !important;
71
+ --body-text-color: var(--hy-text) !important;
72
+ --block-label-text-color: #c0c0c0 !important;
73
+ --block-title-text-color: #e0e0e0 !important;
74
+ --input-background-fill: var(--hy-bg-muted) !important;
75
+ --background-fill-secondary: var(--hy-bg-soft) !important;
76
+ --background-fill-primary: var(--hy-bg) !important;
77
+ --border-color-primary: var(--hy-border) !important;
78
+ --panel-background-fill: var(--hy-bg-soft) !important;
79
+ --checkbox-background-color: var(--hy-bg-muted) !important;
80
+ --checkbox-label-background-fill: var(--hy-bg-soft) !important;
81
+ }
static/vendor/highlight/LICENSE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2006, Ivan Sagalaev.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
static/vendor/highlight/github-dark.min.css ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub Dark
3
+ Description: Dark theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-dark
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
static/vendor/highlight/github.min.css ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Minified by jsDelivr using clean-css v5.3.3.
3
+ * Original file: /gh/highlightjs/cdn-release@11.10.0/build/styles/github.css
4
+ *
5
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6
+ */
7
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
8
+ Theme: GitHub
9
+ Description: Light theme as seen on github.com
10
+ Author: github.com
11
+ Maintainer: @Hirse
12
+ Updated: 2021-05-15
13
+
14
+ Outdated base version: https://github.com/primer/github-syntax-light
15
+ Current colors taken from GitHub's CSS
16
+ */
17
+ .hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
18
+ /*# sourceMappingURL=/sm/4af24df1ef2dd5fc126923c27abd2503f82240131784ba83446a7a23be61ec0f.map */
static/vendor/highlight/highlight.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/vendor/katex/LICENSE ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013-2020 Khan Academy and other contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ----------------------------------------------------------------------
24
+
25
+ NOTE: The KaTeX font files (under ./fonts/) are NOT covered by this MIT
26
+ license. They are licensed separately under the SIL Open Font License,
27
+ Version 1.1 — see ./fonts/OFL.txt.
static/vendor/katex/auto-render.min.js ADDED
@@ -0,0 +1 @@
 
 
1
+ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={757:function(t){t.exports=e}},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,r),i.exports}r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o={};r.d(o,{default:function(){return p}});var i=r(757),a=r.n(i);const l=function(e,t,n){let r=n,o=0;const i=e.length;for(;r<t.length;){const n=t[r];if(o<=0&&t.slice(r,r+i)===e)return r;"\\"===n?r++:"{"===n?o++:"}"===n&&o--,r++}return-1},s=/^\\begin{/;var d=function(e,t){let n;const r=[],o=new RegExp("("+t.map((e=>e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"))).join("|")+")");for(;n=e.search(o),-1!==n;){n>0&&(r.push({type:"text",data:e.slice(0,n)}),e=e.slice(n));const o=t.findIndex((t=>e.startsWith(t.left)));if(n=l(t[o].right,e,t[o].left.length),-1===n)break;const i=e.slice(0,n+t[o].right.length),a=s.test(i)?i:e.slice(t[o].left.length,n);r.push({type:"math",data:a,rawData:i,display:t[o].display}),e=e.slice(n+t[o].right.length)}return""!==e&&r.push({type:"text",data:e}),r};const c=function(e,t){const n=d(e,t.delimiters);if(1===n.length&&"text"===n[0].type)return null;const r=document.createDocumentFragment();for(let e=0;e<n.length;e++)if("text"===n[e].type)r.appendChild(document.createTextNode(n[e].data));else{const o=document.createElement("span");let i=n[e].data;t.displayMode=n[e].display;try{t.preProcess&&(i=t.preProcess(i)),a().render(i,o,t)}catch(o){if(!(o instanceof a().ParseError))throw o;t.errorCallback("KaTeX auto-render: Failed to parse `"+n[e].data+"` with ",o),r.appendChild(document.createTextNode(n[e].rawData));continue}r.appendChild(o)}return r},f=function(e,t){for(let n=0;n<e.childNodes.length;n++){const r=e.childNodes[n];if(3===r.nodeType){let o=r.textContent,i=r.nextSibling,a=0;for(;i&&i.nodeType===Node.TEXT_NODE;)o+=i.textContent,i=i.nextSibling,a++;const l=c(o,t);if(l){for(let e=0;e<a;e++)r.nextSibling.remove();n+=l.childNodes.length-1,e.replaceChild(l,r)}else n+=a}else if(1===r.nodeType){const e=" "+r.className+" ";-1===t.ignoredTags.indexOf(r.nodeName.toLowerCase())&&t.ignoredClasses.every((t=>-1===e.indexOf(" "+t+" ")))&&f(r,t)}}};var p=function(e,t){if(!e)throw new Error("No element provided to render");const n={};for(const e in t)t.hasOwnProperty(e)&&(n[e]=t[e]);n.delimiters=n.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],n.ignoredTags=n.ignoredTags||["script","noscript","style","textarea","pre","code","option"],n.ignoredClasses=n.ignoredClasses||[],n.errorCallback=n.errorCallback||console.error,n.macros=n.macros||{},f(e,n)};return o=o.default}()}));
static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 ADDED
Binary file (28.1 kB). View file
 
static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 ADDED
Binary file (6.91 kB). View file
 
static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 ADDED
Binary file (6.91 kB). View file
 
static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 ADDED
Binary file (11.3 kB). View file
 
static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 ADDED
Binary file (11.3 kB). View file
 
static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 ADDED
Binary file (25.3 kB). View file
 
static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 ADDED
Binary file (16.8 kB). View file
 
static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 ADDED
Binary file (17 kB). View file
 
static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 ADDED
Binary file (26.3 kB). View file
 
static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 ADDED
Binary file (16.4 kB). View file
 
static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 ADDED
Binary file (16.4 kB). View file
 
static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 ADDED
Binary file (12.2 kB). View file
 
static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 ADDED
Binary file (12 kB). View file
 
static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 ADDED
Binary file (10.3 kB). View file
 
static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 ADDED
Binary file (9.64 kB). View file
 
static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 ADDED
Binary file (5.47 kB). View file