Spaces:
Running
Running
gradio server
#1
by akhaliq HF Staff - opened
- README.md +4 -4
- config.py +17 -32
- requirements.txt +1 -0
- server.py +473 -0
- static/index.html +1344 -0
README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
---
|
| 2 |
title: Hy3
|
| 3 |
emoji: ⚡
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
app_file:
|
| 9 |
pinned: false
|
| 10 |
short_description: Hy3 multi-turn streaming chat with function calling
|
| 11 |
---
|
|
|
|
| 1 |
---
|
| 2 |
title: Hy3
|
| 3 |
emoji: ⚡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.19.0
|
| 8 |
+
app_file: server.py
|
| 9 |
pinned: false
|
| 10 |
short_description: Hy3 multi-turn streaming chat with function calling
|
| 11 |
---
|
config.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
-
"""Runtime configuration
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
Required:
|
| 4 |
-
|
| 5 |
-
HY_BASE_URL OpenAI-compatible base URL of the upstream endpoint.
|
| 6 |
-
HY_MODEL Model name passed to chat.completions.create.
|
| 7 |
|
| 8 |
Optional:
|
| 9 |
HY_LOG_LEVEL One of DEBUG / INFO / WARNING / ERROR (default: WARNING).
|
|
@@ -17,9 +19,7 @@ import os
|
|
| 17 |
from openai import OpenAI
|
| 18 |
|
| 19 |
|
| 20 |
-
|
| 21 |
-
BASE_URL = os.environ.get("HY_BASE_URL", "").strip()
|
| 22 |
-
MODEL = os.environ.get("HY_MODEL", "").strip()
|
| 23 |
LOG_LEVEL = os.environ.get("HY_LOG_LEVEL", "WARNING").upper()
|
| 24 |
|
| 25 |
|
|
@@ -37,34 +37,19 @@ if not logging.getLogger().handlers:
|
|
| 37 |
logger = logging.getLogger("Hy3")
|
| 38 |
|
| 39 |
|
| 40 |
-
# Surface missing
|
| 41 |
-
#
|
| 42 |
-
#
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
|
| 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 |
-
"
|
| 58 |
-
"
|
| 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 |
-
|
| 69 |
-
|
| 70 |
)
|
|
|
|
| 1 |
+
"""Runtime configuration for the upstream OpenAI-compatible endpoint.
|
| 2 |
+
|
| 3 |
+
The model is served via the HuggingFace inference router. The only secret
|
| 4 |
+
we need is ``HF_TOKEN`` (a HuggingFace access token); the base URL and model
|
| 5 |
+
name are fixed.
|
| 6 |
|
| 7 |
Required:
|
| 8 |
+
HF_TOKEN HuggingFace access token (read access to the model).
|
|
|
|
|
|
|
| 9 |
|
| 10 |
Optional:
|
| 11 |
HY_LOG_LEVEL One of DEBUG / INFO / WARNING / ERROR (default: WARNING).
|
|
|
|
| 19 |
from openai import OpenAI
|
| 20 |
|
| 21 |
|
| 22 |
+
MODEL = "tencent/Hy3:deepinfra"
|
|
|
|
|
|
|
| 23 |
LOG_LEVEL = os.environ.get("HY_LOG_LEVEL", "WARNING").upper()
|
| 24 |
|
| 25 |
|
|
|
|
| 37 |
logger = logging.getLogger("Hy3")
|
| 38 |
|
| 39 |
|
| 40 |
+
# Surface a missing HF_TOKEN loudly at import time. We don't raise — that
|
| 41 |
+
# would prevent the Gradio server from even starting up (and HuggingFace
|
| 42 |
+
# Spaces would just show a blank build-failed page), making it harder to
|
| 43 |
+
# debug. Instead: warn now, and the per-request code paths will still fail
|
| 44 |
+
# cleanly with the upstream API's own 401 error message.
|
| 45 |
+
if not os.environ.get("HF_TOKEN", "").strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
logger.warning(
|
| 47 |
+
"HF_TOKEN is not set. The app will boot but every chat request will "
|
| 48 |
+
"fail until it is configured (e.g. as a HuggingFace Space secret)."
|
|
|
|
|
|
|
| 49 |
)
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
client = OpenAI(
|
| 53 |
+
base_url="https://router.huggingface.co/v1",
|
| 54 |
+
api_key=os.environ["HF_TOKEN"],
|
| 55 |
)
|
requirements.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
|
|
|
| 1 |
openai>=1.66.0
|
|
|
|
| 1 |
+
gradio>=6.19.0
|
| 2 |
openai>=1.66.0
|
server.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tencent-branded Hy3 chat demo, served from a :class:`gradio.Server`.
|
| 2 |
+
|
| 3 |
+
Goal: ``only the UI and the transport change`` — the chat behaviour is
|
| 4 |
+
identical to the previous ``gr.Blocks`` app (``app.py`` + ``chat.py``).
|
| 5 |
+
We reuse the exact same incremental **delta-op** streaming protocol the
|
| 6 |
+
old app shipped through its hidden ``#hy-chat-delta`` ``gr.HTML`` bridge:
|
| 7 |
+
the same op types (``user``, ``assistant_begin``, ``reasoning_delta``,
|
| 8 |
+
``content_delta``, ``assistant_end``, ``tool_call``, ``remove_bubble``,
|
| 9 |
+
``reset``), the same ``seq`` de-dup, the same ``epoch`` staleness guard,
|
| 10 |
+
the same ``preprocess_latex`` on content deltas, the same tool-call
|
| 11 |
+
markdown from ``display.format_tool_calls_for_display``, and — critically
|
| 12 |
+
— the same ``is_cancelled`` epoch mechanism so clicking "+" mid-stream
|
| 13 |
+
abandons the upstream API call server-side instead of billing tokens for
|
| 14 |
+
an answer nobody will see.
|
| 15 |
+
|
| 16 |
+
The only real change vs. the Blocks app is the transport: instead of a
|
| 17 |
+
``gr.HTML`` component whose value we mutate, we stream the same JSON
|
| 18 |
+
delta payload through ``@app.api`` SSE frames, and the frontend consumes
|
| 19 |
+
them with the same renderer (ported from ``static/chat.js``).
|
| 20 |
+
|
| 21 |
+
Why not reuse ``chat.send_message`` directly? It yields the
|
| 22 |
+
7-tuple-of-``gr.update`` shape the Blocks event handlers expect, plus
|
| 23 |
+
``gr.Warning`` toasts — all Gradio-coupled. The *pure* turn logic it
|
| 24 |
+
wraps (``_run_streaming_turn``, ``_delta``, the op assembly) is already
|
| 25 |
+
Gradio-free, so we call that directly and serialise its op-lists to
|
| 26 |
+
JSON ourselves.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import json
|
| 32 |
+
import logging
|
| 33 |
+
import threading
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Any, Iterator
|
| 36 |
+
|
| 37 |
+
from fastapi.responses import HTMLResponse
|
| 38 |
+
from fastapi.staticfiles import StaticFiles
|
| 39 |
+
|
| 40 |
+
from gradio import Server
|
| 41 |
+
|
| 42 |
+
import chat as _chat # for _delta / _next_bubble_id (pure helpers reused below)
|
| 43 |
+
from chat import _run_streaming_turn
|
| 44 |
+
from core.chat import (
|
| 45 |
+
ChatState,
|
| 46 |
+
finalize_response,
|
| 47 |
+
flush_tool_results,
|
| 48 |
+
init_state,
|
| 49 |
+
record_tool_result,
|
| 50 |
+
reset_state_in_place,
|
| 51 |
+
)
|
| 52 |
+
from display import format_tool_call_prompt
|
| 53 |
+
from i18n import t
|
| 54 |
+
|
| 55 |
+
logger = logging.getLogger(__name__)
|
| 56 |
+
|
| 57 |
+
_STATIC_DIR = Path(__file__).parent / "static"
|
| 58 |
+
_INDEX_HTML = _STATIC_DIR / "index.html"
|
| 59 |
+
|
| 60 |
+
CHAT_CONCURRENCY = 64
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Per-session state (identical in spirit to the Blocks app's gr.State)
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Each browser tab holds a random session id (see static/index.html). We keep
|
| 66 |
+
# an in-process ChatState per id so multi-turn + tool-calling work across the
|
| 67 |
+
# stateless HTTP boundary. The lock guards the *non-streaming* bookkeeping
|
| 68 |
+
# (record/flush tool results, new_chat reset); streaming itself does NOT hold
|
| 69 |
+
# the lock (see /chat) so a new_chat can interrupt an in-flight stream.
|
| 70 |
+
|
| 71 |
+
_SESSIONS: dict[str, dict] = {}
|
| 72 |
+
_SESSIONS_LOCK = threading.Lock()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _get_session(session_id: str) -> dict:
|
| 76 |
+
with _SESSIONS_LOCK:
|
| 77 |
+
sess = _SESSIONS.get(session_id)
|
| 78 |
+
if sess is None:
|
| 79 |
+
sess = {"state": init_state(), "lock": threading.Lock()}
|
| 80 |
+
_SESSIONS[session_id] = sess
|
| 81 |
+
return sess
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _resolve_state(session_id: Any) -> ChatState:
|
| 85 |
+
return _get_session(session_id or "")["state"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Reuse chat.py's HTML-safe JSON delta encoder + bubble-id counter so the
|
| 89 |
+
# wire format is byte-identical to the Blocks app's #hy-chat-delta payload.
|
| 90 |
+
_delta = _chat._delta
|
| 91 |
+
_next_bubble_id = _chat._next_bubble_id
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Server + routes
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
app = Server(title="Hy3 · Tencent")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@app.get("/", response_class=HTMLResponse)
|
| 101 |
+
async def homepage() -> HTMLResponse:
|
| 102 |
+
"""Serve the custom Tencent-branded frontend."""
|
| 103 |
+
return HTMLResponse(_INDEX_HTML.read_text(encoding="utf-8"))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Static asset passthrough (logo, vendored libs the frontend pulls from the
|
| 107 |
+
# same origin instead of a CDN, etc.).
|
| 108 |
+
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="hy-static")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Validation — mirror chat._validate_user_message but return the error string
|
| 113 |
+
# instead of emitting a gr.Warning toast.
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
def _validate_message(message: str, functions_json_str: str) -> str | None:
|
| 116 |
+
"""Return None if valid, else a localized error string."""
|
| 117 |
+
if not message or not message.strip():
|
| 118 |
+
return t("warn.empty_msg")
|
| 119 |
+
if functions_json_str and functions_json_str.strip():
|
| 120 |
+
try:
|
| 121 |
+
json.loads(functions_json_str)
|
| 122 |
+
except json.JSONDecodeError as e:
|
| 123 |
+
return t("warn.invalid_fn_json", err=str(e))
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# A streaming turn = a sequence of JSON delta payloads (the SAME wire format
|
| 129 |
+
# the old app pushed through #hy-chat-delta). Each yielded string is a single
|
| 130 |
+
# {"seq","ops"[,"epoch"]} document the frontend applies with applyDelta.
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def _frame(ops: list[dict], epoch: int) -> str:
|
| 134 |
+
"""Encode a frame's ops as an epoch-tagged delta payload."""
|
| 135 |
+
return _delta(ops, epoch=epoch)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _stream_turn(
|
| 139 |
+
state: ChatState,
|
| 140 |
+
bubble_id: str,
|
| 141 |
+
epoch: int,
|
| 142 |
+
system_prompt: str,
|
| 143 |
+
think_level: str,
|
| 144 |
+
temperature: float | None,
|
| 145 |
+
max_tokens: int,
|
| 146 |
+
top_p: float,
|
| 147 |
+
preserved_thinking: bool | None,
|
| 148 |
+
functions_json_str: str,
|
| 149 |
+
) -> Iterator[str]:
|
| 150 |
+
"""Run one assistant turn, yielding delta payloads.
|
| 151 |
+
|
| 152 |
+
This is the pure core of the Blocks app's ``_run_streaming_turn`` +
|
| 153 |
+
``send_message`` streaming section, translated to JSON frames. It:
|
| 154 |
+
|
| 155 |
+
* collapses the temperature/preserved_thinking "use default" tristate,
|
| 156 |
+
* runs ``preprocess_latex`` on every content delta (so bare
|
| 157 |
+
``\\begin{equation}`` and negation macros render correctly),
|
| 158 |
+
* suppresses tool-call snapshots mid-stream and materialises them as a
|
| 159 |
+
``tool_call`` op at end-of-turn (keeping wire payloads tiny),
|
| 160 |
+
* yields heartbeats (empty-ops frames) so the connection stays alive,
|
| 161 |
+
* bails the moment ``state["epoch"]`` diverges (new_chat clicked) so
|
| 162 |
+
the upstream API stream is abandoned server-side.
|
| 163 |
+
"""
|
| 164 |
+
# The Blocks UI collapses (slider, "use default" checkbox) into a tristate
|
| 165 |
+
# BEFORE calling build_api_kwargs. The Server frontend sends the tristate
|
| 166 |
+
# directly (null = use default), so we only need to pass it through.
|
| 167 |
+
temperature_use_default = temperature is None
|
| 168 |
+
preserved_thinking_use_default = preserved_thinking is None
|
| 169 |
+
|
| 170 |
+
show_tool = False
|
| 171 |
+
tool_md = ""
|
| 172 |
+
for ops, this_show_tool, this_tool_md in _run_streaming_turn(
|
| 173 |
+
state, bubble_id, epoch,
|
| 174 |
+
system_prompt, think_level,
|
| 175 |
+
# _run_streaming_turn takes the raw slider value + use-default flag;
|
| 176 |
+
# reconstruct them from the tristate the frontend sent.
|
| 177 |
+
float(temperature) if temperature is not None else 0.9,
|
| 178 |
+
temperature_use_default,
|
| 179 |
+
max_tokens, top_p,
|
| 180 |
+
bool(preserved_thinking) if preserved_thinking is not None else False,
|
| 181 |
+
preserved_thinking_use_default,
|
| 182 |
+
functions_json_str,
|
| 183 |
+
):
|
| 184 |
+
show_tool = show_tool or this_show_tool
|
| 185 |
+
if this_tool_md:
|
| 186 |
+
tool_md = this_tool_md
|
| 187 |
+
yield _frame(ops, epoch), this_show_tool, this_tool_md
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# /chat — the streaming chat endpoint. Mirrors chat.send_message's flow.
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
@app.api(
|
| 194 |
+
name="chat",
|
| 195 |
+
concurrency_limit=CHAT_CONCURRENCY,
|
| 196 |
+
concurrency_id="chat",
|
| 197 |
+
stream_every=0.06,
|
| 198 |
+
)
|
| 199 |
+
def chat(
|
| 200 |
+
message: str,
|
| 201 |
+
session_id: str = "",
|
| 202 |
+
system_prompt: str = "",
|
| 203 |
+
think_level: str = "high",
|
| 204 |
+
temperature: float | None = None,
|
| 205 |
+
max_tokens: int = 0,
|
| 206 |
+
top_p: float = 0,
|
| 207 |
+
preserved_thinking: bool | None = None,
|
| 208 |
+
functions_json_str: str = "",
|
| 209 |
+
) -> Iterator[tuple]:
|
| 210 |
+
"""Stream a chat turn as delta-op frames.
|
| 211 |
+
|
| 212 |
+
Yields ``(delta_json, tool_area_state)`` where ``delta_json`` is the
|
| 213 |
+
JSON payload the frontend applies with ``applyDelta`` (same shape the
|
| 214 |
+
Blocks app pushed through #hy-chat-delta) and ``tool_area_state`` is
|
| 215 |
+
``None`` normally, or a dict ``{"prompt": <md>, "pending": <n>}`` when
|
| 216 |
+
the model requested tool calls at end-of-turn.
|
| 217 |
+
"""
|
| 218 |
+
state = _resolve_state(session_id)
|
| 219 |
+
|
| 220 |
+
# ── Validation (mirrors chat._validate_user_message) ──
|
| 221 |
+
err = _validate_message(message, functions_json_str)
|
| 222 |
+
if err is not None:
|
| 223 |
+
# Never started streaming: send a single idle marker frame + the
|
| 224 |
+
# error. The frontend surfaces the error as a toast and keeps the
|
| 225 |
+
# user's message in the input box.
|
| 226 |
+
yield _frame([], state["epoch"]), {"error": err}
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
message = message.strip()
|
| 230 |
+
state["messages"].append({"role": "user", "content": message})
|
| 231 |
+
bubble_id = _next_bubble_id()
|
| 232 |
+
epoch = state["epoch"]
|
| 233 |
+
|
| 234 |
+
def _cancelled() -> bool:
|
| 235 |
+
return state.get("epoch") != epoch
|
| 236 |
+
|
| 237 |
+
# First frame: append the user bubble + open an assistant bubble.
|
| 238 |
+
yield _frame([
|
| 239 |
+
{"type": "user", "text": message},
|
| 240 |
+
{"type": "assistant_begin", "id": bubble_id},
|
| 241 |
+
], epoch), None
|
| 242 |
+
|
| 243 |
+
tool_area = None
|
| 244 |
+
try:
|
| 245 |
+
for frame, show_tool, tool_md in _stream_turn(
|
| 246 |
+
state, bubble_id, epoch, system_prompt, think_level,
|
| 247 |
+
temperature, max_tokens, top_p,
|
| 248 |
+
preserved_thinking, functions_json_str,
|
| 249 |
+
):
|
| 250 |
+
if _cancelled():
|
| 251 |
+
return
|
| 252 |
+
yield frame, None
|
| 253 |
+
if show_tool:
|
| 254 |
+
tool_area = {"prompt": tool_md}
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
if _cancelled():
|
| 258 |
+
return
|
| 259 |
+
logger.exception("chat failed: %s", e)
|
| 260 |
+
# Drop the in-progress assistant bubble, restore the user's message
|
| 261 |
+
# to the input, surface a localized failure — exactly the Blocks
|
| 262 |
+
# app's error path (chat.py:364-381).
|
| 263 |
+
if state["messages"] and state["messages"][-1].get("role") == "user":
|
| 264 |
+
state["messages"].pop()
|
| 265 |
+
yield _frame(
|
| 266 |
+
[{"type": "remove_bubble", "id": bubble_id}], epoch,
|
| 267 |
+
), {"error": t("warn.request_failed"), "restore_input": message}
|
| 268 |
+
return
|
| 269 |
+
|
| 270 |
+
if _cancelled():
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
# If the model requested tool calls, mirror the pending queue the Blocks
|
| 274 |
+
# app's finalize_response would have built so submit_tool_result works.
|
| 275 |
+
_sync_pending_tool_calls(state)
|
| 276 |
+
if tool_area and state.get("pending_tool_calls"):
|
| 277 |
+
pending = state["pending_tool_calls"]
|
| 278 |
+
tool_area = {
|
| 279 |
+
"prompt": format_tool_call_prompt(pending[0], 1, len(pending)),
|
| 280 |
+
"pending": len(pending),
|
| 281 |
+
}
|
| 282 |
+
yield _frame([], epoch), tool_area
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _sync_pending_tool_calls(state: ChatState) -> None:
|
| 286 |
+
"""Re-derive ``pending_tool_calls`` from the last assistant message.
|
| 287 |
+
|
| 288 |
+
``_run_streaming_turn`` already called ``finalize_response`` (which
|
| 289 |
+
mutates ``state``), so we just read off what it set — identical to the
|
| 290 |
+
state the Blocks app would have after a tool-calling turn.
|
| 291 |
+
"""
|
| 292 |
+
# finalize_response (core/chat.py) already populated pending_tool_calls
|
| 293 |
+
# when the model called tools; nothing to re-derive here, but keep the
|
| 294 |
+
# hook for symmetry with the earlier snapshot-based implementation.
|
| 295 |
+
if not state.get("pending_tool_calls"):
|
| 296 |
+
state["pending_assistant_msg"] = None
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ---------------------------------------------------------------------------
|
| 300 |
+
# /new_chat — reset. Critically this does NOT block on a running stream:
|
| 301 |
+
# the stream's _cancelled() polls state["epoch"], so bumping it here makes
|
| 302 |
+
# the in-flight /chat abandon the upstream API on its next yield.
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
@app.api(name="new_chat", concurrency_limit=CHAT_CONCURRENCY)
|
| 305 |
+
def new_chat(session_id: str = "") -> dict:
|
| 306 |
+
sess = _get_session(session_id or "")
|
| 307 |
+
# Only the bookkeeping is locked; the streaming /chat never holds this
|
| 308 |
+
# lock, so a reset lands instantly even mid-stream.
|
| 309 |
+
with sess["lock"]:
|
| 310 |
+
new_epoch = reset_state_in_place(sess["state"])
|
| 311 |
+
return {"epoch": str(new_epoch), "ok": True}
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
# /submit_tool_result — mirrors chat.submit_tool_result.
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
@app.api(
|
| 318 |
+
name="submit_tool_result",
|
| 319 |
+
concurrency_limit=CHAT_CONCURRENCY,
|
| 320 |
+
concurrency_id="chat",
|
| 321 |
+
stream_every=0.06,
|
| 322 |
+
)
|
| 323 |
+
def submit_tool_result(
|
| 324 |
+
result_text: str,
|
| 325 |
+
session_id: str = "",
|
| 326 |
+
system_prompt: str = "",
|
| 327 |
+
think_level: str = "high",
|
| 328 |
+
temperature: float | None = None,
|
| 329 |
+
max_tokens: int = 0,
|
| 330 |
+
top_p: float = 0,
|
| 331 |
+
preserved_thinking: bool | None = None,
|
| 332 |
+
functions_json_str: str = "",
|
| 333 |
+
) -> Iterator[tuple]:
|
| 334 |
+
sess = _get_session(session_id or "")
|
| 335 |
+
state = sess["state"]
|
| 336 |
+
|
| 337 |
+
with sess["lock"]:
|
| 338 |
+
pending = state.get("pending_tool_calls", [])
|
| 339 |
+
if not pending:
|
| 340 |
+
yield _frame([], state["epoch"]), None
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
record_tool_result(state, pending[0], result_text)
|
| 344 |
+
state["pending_tool_calls"] = pending[1:]
|
| 345 |
+
|
| 346 |
+
if state["pending_tool_calls"]:
|
| 347 |
+
# More results still pending — keep the tool area open for the
|
| 348 |
+
# next call. Emit a tool_area update, no streaming yet.
|
| 349 |
+
remaining = state["pending_tool_calls"]
|
| 350 |
+
submitted = state.get("submitted_tool_results", [])
|
| 351 |
+
total = len(remaining) + len(submitted)
|
| 352 |
+
current_idx = len(submitted) + 1
|
| 353 |
+
yield _frame([], state["epoch"]), {
|
| 354 |
+
"prompt": format_tool_call_prompt(remaining[0], current_idx, total),
|
| 355 |
+
"pending": len(remaining),
|
| 356 |
+
}
|
| 357 |
+
return
|
| 358 |
+
|
| 359 |
+
flush_tool_results(state)
|
| 360 |
+
|
| 361 |
+
# All results in — stream the follow-up turn (same protocol as /chat).
|
| 362 |
+
bubble_id = _next_bubble_id()
|
| 363 |
+
epoch = state["epoch"]
|
| 364 |
+
|
| 365 |
+
def _cancelled() -> bool:
|
| 366 |
+
return state.get("epoch") != epoch
|
| 367 |
+
|
| 368 |
+
yield _frame([{"type": "assistant_begin", "id": bubble_id}], epoch), None
|
| 369 |
+
|
| 370 |
+
tool_area = None
|
| 371 |
+
try:
|
| 372 |
+
for frame, show_tool, tool_md in _stream_turn(
|
| 373 |
+
state, bubble_id, epoch, system_prompt, think_level,
|
| 374 |
+
temperature, max_tokens, top_p,
|
| 375 |
+
preserved_thinking, functions_json_str,
|
| 376 |
+
):
|
| 377 |
+
if _cancelled():
|
| 378 |
+
return
|
| 379 |
+
yield frame, None
|
| 380 |
+
if show_tool:
|
| 381 |
+
tool_area = {"prompt": tool_md}
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
if _cancelled():
|
| 385 |
+
return
|
| 386 |
+
logger.exception("submit_tool_result failed: %s", e)
|
| 387 |
+
yield _frame(
|
| 388 |
+
[{"type": "remove_bubble", "id": bubble_id}], epoch,
|
| 389 |
+
), {"error": t("warn.request_failed")}
|
| 390 |
+
return
|
| 391 |
+
|
| 392 |
+
if _cancelled():
|
| 393 |
+
return
|
| 394 |
+
|
| 395 |
+
_sync_pending_tool_calls(state)
|
| 396 |
+
if tool_area and state.get("pending_tool_calls"):
|
| 397 |
+
pending = state["pending_tool_calls"]
|
| 398 |
+
yield _frame([], epoch), {
|
| 399 |
+
"prompt": format_tool_call_prompt(pending[0], 1, len(pending)),
|
| 400 |
+
"pending": len(pending),
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
# /config — example prompts + i18n. Loaded once on frontend boot.
|
| 406 |
+
# ---------------------------------------------------------------------------
|
| 407 |
+
@app.api(name="config", concurrency_limit=32)
|
| 408 |
+
def config() -> dict:
|
| 409 |
+
"""Return the showcase example prompts + i18n strings for the frontend."""
|
| 410 |
+
from app import EXAMPLES # noqa: WPS433 — lazy so server import stays light
|
| 411 |
+
from i18n import LANG, TRANSLATIONS
|
| 412 |
+
|
| 413 |
+
i18n = {k: t(k) for k in TRANSLATIONS.get(LANG, TRANSLATIONS["en"])}
|
| 414 |
+
return {
|
| 415 |
+
"examples": list(EXAMPLES),
|
| 416 |
+
"i18n": i18n,
|
| 417 |
+
"lang": LANG,
|
| 418 |
+
# The tool-call display label + per-op helpers the renderer needs.
|
| 419 |
+
"tool_call_label": t("tool.call_label"),
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ---------------------------------------------------------------------------
|
| 424 |
+
# /validate_functions — i18n-aware validation + pretty-print.
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
@app.api(name="validate_functions", concurrency_limit=8)
|
| 427 |
+
def validate_functions(functions_json_str: str) -> dict:
|
| 428 |
+
"""Validate + pretty-print a tools-JSON string. i18n-aware."""
|
| 429 |
+
from tools import parse_functions_json
|
| 430 |
+
|
| 431 |
+
if not functions_json_str or not functions_json_str.strip():
|
| 432 |
+
return {"ok": False, "message": t("warn.fn.enter_json"), "formatted": ""}
|
| 433 |
+
try:
|
| 434 |
+
data = json.loads(functions_json_str)
|
| 435 |
+
except json.JSONDecodeError as e:
|
| 436 |
+
return {"ok": False, "message": t("warn.fn.invalid_format", err=str(e)), "formatted": ""}
|
| 437 |
+
if isinstance(data, dict):
|
| 438 |
+
data = [data]
|
| 439 |
+
if not isinstance(data, list):
|
| 440 |
+
return {"ok": False, "message": t("warn.fn.must_be_array"), "formatted": ""}
|
| 441 |
+
names: list[str] = []
|
| 442 |
+
for i, item in enumerate(data):
|
| 443 |
+
if not isinstance(item, dict):
|
| 444 |
+
return {"ok": False, "message": t("warn.fn.item_not_object", i=i + 1), "formatted": ""}
|
| 445 |
+
fn = _normalize(item)
|
| 446 |
+
if not fn:
|
| 447 |
+
return {"ok": False, "message": t("warn.fn.item_invalid", i=i + 1), "formatted": ""}
|
| 448 |
+
if fn["name"] in names:
|
| 449 |
+
return {"ok": False, "message": t("warn.fn.duplicate_name", name=fn["name"]), "formatted": ""}
|
| 450 |
+
names.append(fn["name"])
|
| 451 |
+
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
| 452 |
+
return {
|
| 453 |
+
"ok": True,
|
| 454 |
+
"message": t("info.fn.validation_passed", n=len(names), names=", ".join(names)),
|
| 455 |
+
"formatted": formatted,
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _normalize(item: dict) -> dict | None:
|
| 460 |
+
"""Accept either {type, function} or a bare {name, parameters} entry."""
|
| 461 |
+
if item.get("type") == "function" and "function" in item:
|
| 462 |
+
fn = item["function"]
|
| 463 |
+
if isinstance(fn, dict) and fn.get("name"):
|
| 464 |
+
return fn
|
| 465 |
+
return None
|
| 466 |
+
if item.get("name"):
|
| 467 |
+
return item
|
| 468 |
+
return None
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
if __name__ == "__main__":
|
| 472 |
+
# HuggingFace Spaces invokes ``python server.py``.
|
| 473 |
+
app.launch(show_error=True)
|
static/index.html
ADDED
|
@@ -0,0 +1,1344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
| 6 |
+
<title>Hy3 · Tencent</title>
|
| 7 |
+
<meta name="color-scheme" content="light dark" />
|
| 8 |
+
<link rel="icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
|
| 9 |
+
<link rel="apple-touch-icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
|
| 10 |
+
|
| 11 |
+
<!-- Tencent logo -->
|
| 12 |
+
<link rel="preload" as="image" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
|
| 13 |
+
|
| 14 |
+
<!-- marked + KaTeX + highlight.js from CDN -->
|
| 15 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
| 16 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css" id="hljs-light-css" />
|
| 17 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css" id="hljs-dark-css" disabled />
|
| 18 |
+
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
| 19 |
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
| 20 |
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
| 21 |
+
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>
|
| 22 |
+
|
| 23 |
+
<style>
|
| 24 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 25 |
+
Tencent design tokens
|
| 26 |
+
Tencent brand blue : #0052D9 (primary)
|
| 27 |
+
Tencent red accent : #E53935 (secondary / highlights)
|
| 28 |
+
Tencent teal : #00A98F (success accents)
|
| 29 |
+
───────────────────────────────────────────────────────────────────── */
|
| 30 |
+
:root {
|
| 31 |
+
--tc-blue: #0052D9;
|
| 32 |
+
--tc-blue-hover: #003CAB;
|
| 33 |
+
--tc-blue-soft: #E8F0FF;
|
| 34 |
+
--tc-blue-060: rgba(0, 82, 217, 0.06);
|
| 35 |
+
--tc-blue-120: rgba(0, 82, 217, 0.12);
|
| 36 |
+
--tc-red: #E53935;
|
| 37 |
+
--tc-teal: #00A98F;
|
| 38 |
+
|
| 39 |
+
--bg: #f7f6f3; /* warm canvas, Claude-like */
|
| 40 |
+
--bg-soft: #ffffff; /* surfaces / bubbles / sidebar */
|
| 41 |
+
--bg-muted: #efece6;
|
| 42 |
+
--bg-elev: #ffffff;
|
| 43 |
+
--sidebar-bg: #ffffff;
|
| 44 |
+
--text: #2b2b2b;
|
| 45 |
+
--text-strong: #1a1a1a;
|
| 46 |
+
--text-muted: #8a8580;
|
| 47 |
+
--border: #e7e3dc;
|
| 48 |
+
--border-strong: #d8d3cb;
|
| 49 |
+
--accent: #c96442; /* warm terracotta accent (Claude) */
|
| 50 |
+
--accent-soft: #f4ece7;
|
| 51 |
+
--shadow-sm: 0 1px 2px rgba(20, 18, 14, 0.04);
|
| 52 |
+
--shadow-md: 0 4px 14px rgba(20, 18, 14, 0.06);
|
| 53 |
+
--shadow-lg: 0 16px 48px rgba(20, 18, 14, 0.14);
|
| 54 |
+
--ring: 0 0 0 3px rgba(0, 82, 217, 0.16);
|
| 55 |
+
|
| 56 |
+
--radius-sm: 8px;
|
| 57 |
+
--radius: 14px;
|
| 58 |
+
--radius-lg: 20px;
|
| 59 |
+
--radius-xl: 26px;
|
| 60 |
+
|
| 61 |
+
--col-max: 768px; /* centered conversation width */
|
| 62 |
+
|
| 63 |
+
--font-sans: "Söhne", "Inter", -apple-system, BlinkMacSystemFont,
|
| 64 |
+
"SF Pro Text", "PingFang SC", "Hiragino Sans GB",
|
| 65 |
+
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
| 66 |
+
--font-mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Monaco, monospace;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
html.dark {
|
| 70 |
+
--bg: #1a1a1a; /* warm-tinted dark canvas */
|
| 71 |
+
--bg-soft: #232323;
|
| 72 |
+
--bg-muted: #2c2c2c;
|
| 73 |
+
--bg-elev: #232323;
|
| 74 |
+
--sidebar-bg: #202020;
|
| 75 |
+
--text: #ece9e4;
|
| 76 |
+
--text-strong: #ffffff;
|
| 77 |
+
--text-muted: #9a948d;
|
| 78 |
+
--border: #34312d;
|
| 79 |
+
--border-strong: #423e39;
|
| 80 |
+
--accent: #d97757;
|
| 81 |
+
--accent-soft: rgba(217, 119, 87, 0.16);
|
| 82 |
+
--tc-blue-soft: rgba(0, 82, 217, 0.16);
|
| 83 |
+
--tc-blue-060: rgba(0, 82, 217, 0.12);
|
| 84 |
+
--tc-blue-120: rgba(0, 82, 217, 0.24);
|
| 85 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
| 86 |
+
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.4);
|
| 87 |
+
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
|
| 88 |
+
--ring: 0 0 0 3px rgba(0, 82, 217, 0.3);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
* { box-sizing: border-box; }
|
| 92 |
+
html, body {
|
| 93 |
+
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
| 94 |
+
background: var(--bg); color: var(--text);
|
| 95 |
+
font-family: var(--font-sans);
|
| 96 |
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
| 97 |
+
}
|
| 98 |
+
button, input, textarea, select { font-family: inherit; }
|
| 99 |
+
|
| 100 |
+
/* ── Layout shell ───────────────────────────────────────────────────────
|
| 101 |
+
The chat surface (#main) IS the warm canvas and fills everything below
|
| 102 |
+
the header; the composer floats at the bottom of it, centered, so the
|
| 103 |
+
conversation scrolls underneath it (Claude-style) rather than living
|
| 104 |
+
in a separate hard-bordered bar. */
|
| 105 |
+
#app {
|
| 106 |
+
display: grid;
|
| 107 |
+
grid-template-columns: 300px 1fr;
|
| 108 |
+
grid-template-rows: 60px 1fr;
|
| 109 |
+
grid-template-areas:
|
| 110 |
+
"header header"
|
| 111 |
+
"sidebar main";
|
| 112 |
+
height: 100vh; width: 100vw;
|
| 113 |
+
}
|
| 114 |
+
#app.sidebar-closed { grid-template-columns: 0 1fr; }
|
| 115 |
+
#app.sidebar-closed #sidebar { transform: translateX(-100%); }
|
| 116 |
+
|
| 117 |
+
/* ── Header / brand bar ────────────────────────────────────────────────
|
| 118 |
+
Transparent over the canvas with only a hairline divider — feels like
|
| 119 |
+
part of the page, not a chrome bar. */
|
| 120 |
+
#header {
|
| 121 |
+
grid-area: header;
|
| 122 |
+
display: flex; align-items: center; gap: 12px;
|
| 123 |
+
padding: 0 20px;
|
| 124 |
+
background: var(--bg-soft);
|
| 125 |
+
border-bottom: 1px solid var(--border);
|
| 126 |
+
position: relative; z-index: 20;
|
| 127 |
+
}
|
| 128 |
+
.brand { display: flex; align-items: center; gap: 12px; cursor: default; }
|
| 129 |
+
/* The logo is a wide horizontal wordmark (Tencent + Hy3). Size it by
|
| 130 |
+
height and let width follow the natural aspect ratio so it never
|
| 131 |
+
gets smushed into a square. */
|
| 132 |
+
.brand-logo {
|
| 133 |
+
height: 26px; width: auto;
|
| 134 |
+
object-fit: contain;
|
| 135 |
+
display: block;
|
| 136 |
+
border-radius: 4px;
|
| 137 |
+
}
|
| 138 |
+
.brand-name { font-size: 16px; font-weight: 650; color: var(--text-strong); letter-spacing: -0.01em; }
|
| 139 |
+
.brand-sep { color: var(--border-strong); font-weight: 300; }
|
| 140 |
+
.brand-tag { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
| 141 |
+
.brand-badge {
|
| 142 |
+
margin-left: 4px; font-size: 11px; font-weight: 600;
|
| 143 |
+
color: var(--tc-blue); background: var(--tc-blue-soft);
|
| 144 |
+
padding: 2px 9px; border-radius: 999px; letter-spacing: 0.02em;
|
| 145 |
+
}
|
| 146 |
+
.header-spacer { flex: 1; }
|
| 147 |
+
.icon-btn {
|
| 148 |
+
width: 36px; height: 36px; border-radius: 9px;
|
| 149 |
+
border: 1px solid transparent; background: transparent; color: var(--text-muted);
|
| 150 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 151 |
+
cursor: pointer; transition: background 0.15s ease, color 0.15s ease; padding: 0;
|
| 152 |
+
}
|
| 153 |
+
.icon-btn:hover { background: var(--bg-muted); color: var(--text); }
|
| 154 |
+
.icon-btn svg { width: 18px; height: 18px; }
|
| 155 |
+
|
| 156 |
+
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
| 157 |
+
#sidebar {
|
| 158 |
+
grid-area: sidebar;
|
| 159 |
+
background: var(--sidebar-bg);
|
| 160 |
+
border-right: 1px solid var(--border);
|
| 161 |
+
overflow-y: auto; overflow-x: hidden;
|
| 162 |
+
transition: transform 0.22s ease;
|
| 163 |
+
padding: 18px 18px 28px;
|
| 164 |
+
display: flex; flex-direction: column; gap: 16px;
|
| 165 |
+
}
|
| 166 |
+
.sidebar-notice {
|
| 167 |
+
font-size: 12px; line-height: 1.55; color: var(--text-muted);
|
| 168 |
+
background: var(--accent-soft); border: 1px solid transparent;
|
| 169 |
+
border-radius: var(--radius-sm); padding: 11px 13px;
|
| 170 |
+
}
|
| 171 |
+
.field { display: flex; flex-direction: column; gap: 7px; }
|
| 172 |
+
.field > label {
|
| 173 |
+
font-size: 11.5px; font-weight: 600; color: var(--text-muted);
|
| 174 |
+
text-transform: none; letter-spacing: 0.01em;
|
| 175 |
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
| 176 |
+
}
|
| 177 |
+
.field .info {
|
| 178 |
+
font-size: 11px; font-weight: 400; color: var(--text-muted);
|
| 179 |
+
margin-top: -3px; line-height: 1.45; opacity: 0.85;
|
| 180 |
+
}
|
| 181 |
+
.field input[type="text"], .field textarea, .field select {
|
| 182 |
+
width: 100%; padding: 9px 12px;
|
| 183 |
+
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
| 184 |
+
background: var(--bg-soft); color: var(--text); font-size: 14px;
|
| 185 |
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
| 186 |
+
}
|
| 187 |
+
.field textarea { resize: vertical; min-height: 64px; font-family: inherit; line-height: 1.5; }
|
| 188 |
+
.field input:focus, .field textarea:focus, .field select:focus {
|
| 189 |
+
outline: none; border-color: var(--tc-blue); box-shadow: var(--ring);
|
| 190 |
+
}
|
| 191 |
+
.field select { appearance: none; cursor: pointer;
|
| 192 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8580' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
| 193 |
+
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
|
| 194 |
+
}
|
| 195 |
+
.range-row { display: flex; align-items: center; gap: 10px; }
|
| 196 |
+
.range-row input[type="range"] { flex: 1; accent-color: var(--tc-blue); }
|
| 197 |
+
.range-num {
|
| 198 |
+
width: 58px; padding: 5px 8px; text-align: center;
|
| 199 |
+
border: 1px solid var(--border); border-radius: 7px;
|
| 200 |
+
background: var(--bg-soft); color: var(--text); font-size: 12px;
|
| 201 |
+
font-variant-numeric: tabular-nums;
|
| 202 |
+
}
|
| 203 |
+
.range-num:focus { outline: none; border-color: var(--tc-blue); box-shadow: var(--ring); }
|
| 204 |
+
.range-row input[type="range"] { -webkit-appearance: none; appearance: none; height: 4px; background: var(--border); border-radius: 2px; }
|
| 205 |
+
.range-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--tc-blue); cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
|
| 206 |
+
.range-row input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: none; border-radius: 50%; background: var(--tc-blue); cursor: pointer; }
|
| 207 |
+
.check-row {
|
| 208 |
+
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
| 209 |
+
font-size: 12px; color: var(--text-muted); user-select: none; margin-top: 2px;
|
| 210 |
+
}
|
| 211 |
+
.check-row input { accent-color: var(--tc-blue); width: 14px; height: 14px; }
|
| 212 |
+
|
| 213 |
+
.accordion { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-soft); }
|
| 214 |
+
.accordion > summary {
|
| 215 |
+
list-style: none; cursor: pointer; padding: 11px 13px;
|
| 216 |
+
font-size: 13px; font-weight: 600; color: var(--text);
|
| 217 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 218 |
+
}
|
| 219 |
+
.accordion > summary::-webkit-details-marker { display: none; }
|
| 220 |
+
.accordion > summary::after { content: "⌄"; color: var(--text-muted); transition: transform 0.15s ease; }
|
| 221 |
+
.accordion[open] > summary::after { transform: rotate(180deg); }
|
| 222 |
+
.accordion > .accordion-body { padding: 13px; display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--border); }
|
| 223 |
+
.accordion textarea { width: 100%; min-height: 180px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; resize: vertical; }
|
| 224 |
+
.tiny-btn {
|
| 225 |
+
align-self: flex-start; padding: 7px 15px; font-size: 12px; font-weight: 500;
|
| 226 |
+
border: 1px solid var(--border); border-radius: 8px; background: var(--bg-soft);
|
| 227 |
+
color: var(--text); cursor: pointer; transition: all 0.15s ease;
|
| 228 |
+
}
|
| 229 |
+
.tiny-btn:hover { border-color: var(--tc-blue); color: var(--tc-blue); background: var(--tc-blue-060); }
|
| 230 |
+
.fn-msg { font-size: 12px; line-height: 1.5; min-height: 18px; }
|
| 231 |
+
.fn-msg.ok { color: var(--tc-teal); }
|
| 232 |
+
.fn-msg.err { color: var(--tc-red); }
|
| 233 |
+
|
| 234 |
+
/* ── Main chat surface ──────────────────────────────────────────────────
|
| 235 |
+
The warm canvas. Conversation is centered in a narrow column with
|
| 236 |
+
generous vertical rhythm; assistant turns are plain text (no bubble),
|
| 237 |
+
user turns are soft pills. The composer floats at the bottom on a
|
| 238 |
+
gradient fade so messages scroll under it gracefully. */
|
| 239 |
+
#main {
|
| 240 |
+
grid-area: main; position: relative; background: var(--bg);
|
| 241 |
+
overflow: hidden; display: flex; flex-direction: column;
|
| 242 |
+
}
|
| 243 |
+
#chat {
|
| 244 |
+
flex: 1; overflow-y: auto; overflow-x: hidden;
|
| 245 |
+
padding: 28px clamp(16px, 5vw, 48px) 220px; /* bottom room for floating composer */
|
| 246 |
+
scroll-behavior: auto;
|
| 247 |
+
}
|
| 248 |
+
/* Center the conversation in a narrow column. */
|
| 249 |
+
#chat > .col,
|
| 250 |
+
#chat > .message-row { max-width: var(--col-max); margin-left: auto; margin-right: auto; width: 100%; }
|
| 251 |
+
.message-row { display: block; width: 100%; margin: 0; }
|
| 252 |
+
.message-row.user-row { display: flex; justify-content: flex-end; margin: 22px 0 4px; }
|
| 253 |
+
.message-row.bot-row { margin: 0 0 30px; }
|
| 254 |
+
.message { max-width: 100%; }
|
| 255 |
+
.message.user {
|
| 256 |
+
background: var(--bg-elev); color: var(--text-strong);
|
| 257 |
+
border: 1px solid var(--border);
|
| 258 |
+
border-radius: var(--radius-lg); padding: 11px 18px;
|
| 259 |
+
max-width: min(560px, 82%); box-shadow: var(--shadow-sm);
|
| 260 |
+
font-weight: 450;
|
| 261 |
+
}
|
| 262 |
+
.message.user .hy-user-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
|
| 263 |
+
.message.bot { background: transparent; padding: 0; max-width: 100%; }
|
| 264 |
+
.message.bot .markdown { font-size: 16px; line-height: 1.7; color: var(--text); letter-spacing: 0.005em; }
|
| 265 |
+
.message.bot p { margin: 0.85em 0; }
|
| 266 |
+
.message.bot p:first-child { margin-top: 0; }
|
| 267 |
+
.message.bot p:last-child { margin-bottom: 0; }
|
| 268 |
+
.message.bot strong, .message.bot b { font-weight: 650; color: var(--text-strong); }
|
| 269 |
+
.message.bot ul, .message.bot ol { padding-left: 1.5em; margin: 0.6em 0; }
|
| 270 |
+
.message.bot li { margin: 0.35em 0; line-height: 1.7; }
|
| 271 |
+
.message.bot h1 { font-size: 23px; font-weight: 700; color: var(--text-strong); margin: 1.1em 0 0.5em; letter-spacing: -0.01em; }
|
| 272 |
+
.message.bot h2 { font-size: 20px; font-weight: 700; color: var(--text-strong); margin: 1em 0 0.45em; letter-spacing: -0.01em; }
|
| 273 |
+
.message.bot h3 { font-size: 17px; font-weight: 650; color: var(--text-strong); margin: 0.9em 0 0.4em; }
|
| 274 |
+
.message.bot h4 { font-size: 15px; font-weight: 650; color: var(--text); margin: 0.8em 0 0.35em; }
|
| 275 |
+
.message.bot a { color: var(--tc-blue); text-underline-offset: 2px; }
|
| 276 |
+
.message.bot blockquote { border-left: 3px solid var(--border-strong); margin: 0.9em 0; padding: 2px 16px; color: var(--text-muted); }
|
| 277 |
+
.message.bot hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
|
| 278 |
+
.message.bot table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.9em 0; font-size: 14px; }
|
| 279 |
+
.message.bot th, .message.bot td { border: 1px solid var(--border); padding: 8px 14px; text-align: left; vertical-align: top; }
|
| 280 |
+
.message.bot th { background: var(--bg-muted); font-weight: 600; }
|
| 281 |
+
.message.bot code { font-family: var(--font-mono); font-size: 0.88em; }
|
| 282 |
+
.message.bot :not(pre) > code { background: var(--bg-muted); padding: 2px 6px; border-radius: 5px; color: var(--text); }
|
| 283 |
+
.hy-codeblock { margin: 1em 0; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--bg-soft); }
|
| 284 |
+
.hy-codeblock-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 9px 7px 15px; background: var(--bg-muted); border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1; color: var(--text-muted); user-select: none; }
|
| 285 |
+
.hy-codeblock-lang { text-transform: lowercase; letter-spacing: 0.02em; font-weight: 500; }
|
| 286 |
+
.hy-codeblock-lang:empty::before { content: "text"; opacity: 0.7; }
|
| 287 |
+
.hy-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; padding: 13px 16px; overflow-x: auto; font-size: 13px; line-height: 1.65; }
|
| 288 |
+
.code-copy-btn { flex-shrink: 0; width: 26px; height: 26px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: inherit; background: transparent; border: none; border-radius: 7px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
| 289 |
+
.code-copy-btn svg { display: block; width: 14px; height: 14px; }
|
| 290 |
+
.code-copy-btn:hover { background: var(--tc-blue-060); color: var(--tc-blue); }
|
| 291 |
+
.code-copy-btn.copied, .code-copy-btn.copied:hover { color: var(--tc-teal); background: transparent; }
|
| 292 |
+
.html-preview-btn { display: inline-flex; align-items: center; background: none; border: none; padding: 2px 0; margin: 2px 0 6px; cursor: pointer; font-size: 0; line-height: 1; transition: opacity 0.15s ease; }
|
| 293 |
+
.html-preview-btn::before { content: "▶ html preview"; font-size: 12px; color: var(--tc-blue); font-weight: 400; font-family: var(--font-sans); }
|
| 294 |
+
.html-preview-btn:hover { opacity: 0.7; }
|
| 295 |
+
|
| 296 |
+
/* ── Reasoning (thinking) block ───────────────────────────────────────── */
|
| 297 |
+
.thinking-block { background: none; border: none; padding: 0; margin: 6px 0 12px; }
|
| 298 |
+
.thinking-block summary { cursor: pointer; font-size: 13px; font-weight: 400; color: var(--text-muted); padding: 0; list-style: none; display: inline-flex; align-items: center; gap: 4px; }
|
| 299 |
+
.thinking-block summary:hover { color: var(--text); }
|
| 300 |
+
.thinking-block summary::-webkit-details-marker { display: none; }
|
| 301 |
+
.thinking-block summary::after { content: " ›"; font-size: 13px; color: var(--border-strong); }
|
| 302 |
+
.thinking-block[open] summary::after { content: " ⌄"; }
|
| 303 |
+
.thinking-block[open] summary { margin-bottom: 8px; }
|
| 304 |
+
.thinking-block[open] > :not(summary) { font-size: 13px; color: var(--text-muted); line-height: 1.7; padding: 12px 16px; background: var(--bg-soft); border-radius: 8px; max-height: 300px; overflow-y: auto; border-left: 2px solid var(--tc-blue); white-space: pre-wrap; word-break: break-word; }
|
| 305 |
+
|
| 306 |
+
/* ── Tool-call display block ─────────────────────────────────────────── */
|
| 307 |
+
.hy-tool-calls { margin-top: 10px; }
|
| 308 |
+
.hy-tool-call + .hy-tool-call { margin-top: 8px; }
|
| 309 |
+
|
| 310 |
+
/* ── Typing indicator ─────────────────────────────────────────────────── */
|
| 311 |
+
.typing-indicator { display: flex; align-items: center; gap: 5px; padding: 4px 0; height: 24px; }
|
| 312 |
+
.typing-indicator span { width: 8px; height: 8px; background: var(--text-muted); border-radius: 50%; animation: typing-bounce 1.4s infinite ease-in-out both; }
|
| 313 |
+
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
| 314 |
+
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
| 315 |
+
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
| 316 |
+
@keyframes typing-bounce { 0%, 80%, 100% { transform: scale(0.5); opacity: 0.35; } 40% { transform: scale(1); opacity: 1; } }
|
| 317 |
+
|
| 318 |
+
/* ── Empty-state examples overlay ─────────────────────────────────────── */
|
| 319 |
+
#examples-overlay {
|
| 320 |
+
position: absolute; inset: 0; z-index: 5;
|
| 321 |
+
display: flex; align-items: center; justify-content: center;
|
| 322 |
+
background: var(--bg); overflow-y: auto; padding: 40px 20px 160px;
|
| 323 |
+
transition: opacity 0.25s ease;
|
| 324 |
+
}
|
| 325 |
+
#examples-overlay.hidden { opacity: 0; pointer-events: none; }
|
| 326 |
+
.examples-wrapper { display: flex; flex-direction: column; align-items: center; max-width: 1020px; }
|
| 327 |
+
.examples-hero { display: flex; align-items: center; justify-content: center; gap: 14px; margin-bottom: 22px; }
|
| 328 |
+
.examples-hero img { width: auto; height: 56px; border-radius: 8px; object-fit: contain; }
|
| 329 |
+
.examples-heading { font-size: 28px; font-weight: 650; color: var(--text-strong); margin: 0 0 30px; letter-spacing: -0.02em; text-align: center; }
|
| 330 |
+
.examples-grid { display: grid; grid-template-columns: minmax(0, 460px) minmax(0, 460px); column-gap: 20px; row-gap: 14px; justify-content: center; width: min(1020px, 100%); }
|
| 331 |
+
.example-btn:nth-child(1), .example-btn:nth-child(4) { grid-column: 1 / span 2; justify-self: center; }
|
| 332 |
+
.example-btn:nth-child(2) { grid-column: 1; justify-self: end; }
|
| 333 |
+
.example-btn:nth-child(3) { grid-column: 2; justify-self: start; }
|
| 334 |
+
.example-btn {
|
| 335 |
+
display: flex; align-items: center; justify-content: center;
|
| 336 |
+
width: 460px; height: 72px; overflow: hidden;
|
| 337 |
+
background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius);
|
| 338 |
+
padding: 14px 20px; font-size: 14px; line-height: 1.5; color: var(--text);
|
| 339 |
+
cursor: pointer; user-select: none; text-align: center;
|
| 340 |
+
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, background 0.2s ease;
|
| 341 |
+
}
|
| 342 |
+
.example-btn:hover { border-color: var(--border-strong); color: var(--text-strong); box-shadow: var(--shadow-md); transform: translateY(-1px); background: var(--bg-elev); }
|
| 343 |
+
.example-btn-text { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; width: 100%; }
|
| 344 |
+
@media (max-width: 1020px) {
|
| 345 |
+
.examples-grid { grid-template-columns: minmax(0, 1fr); }
|
| 346 |
+
.example-btn:nth-child(n) { grid-column: 1; justify-self: center; }
|
| 347 |
+
.example-btn { width: 100%; max-width: 520px; }
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/* ── Composer (floating, centered pill) ────────────────────────────────
|
| 351 |
+
Sits at the bottom of the chat canvas, centered to the conversation
|
| 352 |
+
column, with a gradient fade above it so content scrolls under it
|
| 353 |
+
gracefully — the Claude.ai feel. */
|
| 354 |
+
#composer {
|
| 355 |
+
position: absolute; left: 0; right: 0; bottom: 0;
|
| 356 |
+
display: flex; align-items: flex-end; justify-content: center; gap: 10px;
|
| 357 |
+
padding: 0 clamp(16px, 5vw, 48px) 20px;
|
| 358 |
+
z-index: 10;
|
| 359 |
+
background: linear-gradient(to top, var(--bg) 52%, rgba(247,246,243,0));
|
| 360 |
+
pointer-events: none; /* let the fade be click-through … */
|
| 361 |
+
}
|
| 362 |
+
#composer > * { pointer-events: auto; } /* … but not the controls */
|
| 363 |
+
html.dark #composer { background: linear-gradient(to top, var(--bg) 52%, rgba(26,26,26,0)); }
|
| 364 |
+
.composer-inner {
|
| 365 |
+
display: flex; align-items: flex-end; gap: 8px; width: 100%; max-width: var(--col-max);
|
| 366 |
+
background: var(--bg-elev); border: 1px solid var(--border);
|
| 367 |
+
border-radius: var(--radius-xl); padding: 8px 8px 8px 18px;
|
| 368 |
+
box-shadow: var(--shadow-md); transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
| 369 |
+
}
|
| 370 |
+
.composer-inner:focus-within { border-color: var(--border-strong); box-shadow: var(--shadow-md), var(--ring); }
|
| 371 |
+
#composer textarea {
|
| 372 |
+
flex: 1; min-height: 44px; max-height: 200px;
|
| 373 |
+
resize: none; overflow-y: auto;
|
| 374 |
+
border: none; border-radius: 0;
|
| 375 |
+
padding: 12px 0; font-size: 15.5px; line-height: 1.55; background: transparent;
|
| 376 |
+
color: var(--text); box-shadow: none; transition: none;
|
| 377 |
+
}
|
| 378 |
+
#composer textarea:focus { outline: none; box-shadow: none; }
|
| 379 |
+
#new-chat-btn {
|
| 380 |
+
width: 40px; height: 40px; border-radius: 50%; padding: 0;
|
| 381 |
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
| 382 |
+
cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--text-muted);
|
| 383 |
+
transition: background 0.15s ease, color 0.15s ease;
|
| 384 |
+
}
|
| 385 |
+
#new-chat-btn:hover { background: var(--bg-muted); color: var(--text); }
|
| 386 |
+
#new-chat-btn svg { width: 19px; height: 19px; }
|
| 387 |
+
#send-btn {
|
| 388 |
+
width: 40px; height: 40px; border-radius: 50%; padding: 0;
|
| 389 |
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
| 390 |
+
cursor: pointer; border: none; background: var(--tc-blue); color: #fff;
|
| 391 |
+
transition: background 0.15s ease, transform 0.1s ease;
|
| 392 |
+
}
|
| 393 |
+
#send-btn:hover { background: var(--tc-blue-hover); }
|
| 394 |
+
#send-btn:active { transform: scale(0.95); }
|
| 395 |
+
#send-btn:disabled { background: var(--border-strong); cursor: not-allowed; opacity: 0.6; }
|
| 396 |
+
#send-btn svg { width: 18px; height: 18px; }
|
| 397 |
+
|
| 398 |
+
/* ── Tool-call input panel ───────────────────────────────────────────── */
|
| 399 |
+
#tool-area {
|
| 400 |
+
position: absolute; bottom: 104px; left: 50%; transform: translateX(-50%);
|
| 401 |
+
width: min(640px, 90%); z-index: 30;
|
| 402 |
+
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
| 403 |
+
padding: 16px; box-shadow: var(--shadow-lg); display: none; flex-direction: column; gap: 10px;
|
| 404 |
+
}
|
| 405 |
+
#tool-area.visible { display: flex; }
|
| 406 |
+
#tool-area .tool-header { font-size: 13px; font-weight: 600; color: var(--text); }
|
| 407 |
+
#tool-area .tool-markdown { font-size: 13px; line-height: 1.6; color: var(--text); max-height: 160px; overflow-y: auto; background: var(--bg-soft); border-radius: 8px; padding: 10px 12px; }
|
| 408 |
+
#tool-area .tool-markdown p { margin: 0.4em 0; }
|
| 409 |
+
#tool-area .tool-markdown code { background: var(--bg-muted); padding: 1px 5px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; }
|
| 410 |
+
#tool-area .tool-markdown pre { background: var(--bg-muted); border-radius: 6px; padding: 8px 10px; overflow-x: auto; margin: 0.4em 0; }
|
| 411 |
+
#tool-area .tool-markdown pre code { background: none; padding: 0; }
|
| 412 |
+
#tool-area .tool-markdown strong { font-weight: 600; }
|
| 413 |
+
#tool-result-input {
|
| 414 |
+
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
| 415 |
+
background: var(--bg); color: var(--text); font-size: 14px; font-family: var(--font-mono);
|
| 416 |
+
}
|
| 417 |
+
#tool-result-input:focus { outline: none; border-color: var(--tc-blue); box-shadow: 0 0 0 3px var(--tc-blue-120); }
|
| 418 |
+
#tool-submit-btn { align-self: flex-end; padding: 8px 18px; border: none; border-radius: 8px; background: var(--tc-blue); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
|
| 419 |
+
#tool-submit-btn:hover { background: var(--tc-blue-hover); }
|
| 420 |
+
|
| 421 |
+
/* ── HTML preview modal ───────────────────────────────────────────────── */
|
| 422 |
+
.html-preview-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease; }
|
| 423 |
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 424 |
+
.html-preview-modal { width: 90vw; height: 85vh; background: var(--bg); border-radius: var(--radius); box-shadow: var(--shadow-lg); display: flex; flex-direction: column; overflow: hidden; animation: modalSlideUp 0.25s ease; }
|
| 425 |
+
@keyframes modalSlideUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
| 426 |
+
.html-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-soft); flex-shrink: 0; }
|
| 427 |
+
.html-preview-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
| 428 |
+
.html-preview-title::before { content: "⬡"; color: var(--tc-blue); }
|
| 429 |
+
.html-preview-close { background: none; border: none; font-size: 20px; color: var(--text-muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; line-height: 1; transition: all 0.15s ease; }
|
| 430 |
+
.html-preview-close:hover { background: var(--tc-blue-060); color: var(--tc-red); }
|
| 431 |
+
.html-preview-iframe { flex: 1; border: none; width: 100%; background: var(--bg); }
|
| 432 |
+
|
| 433 |
+
/* ── Toast ────────────────────────────────────────────────────────────── */
|
| 434 |
+
#toast-host { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
| 435 |
+
.toast { background: #1f2937; color: #fff; padding: 10px 16px; border-radius: 8px; box-shadow: var(--shadow-lg); font-size: 14px; max-width: min(92vw, 520px); line-height: 1.4; opacity: 0; transition: opacity 180ms ease; }
|
| 436 |
+
.toast.show { opacity: 1; }
|
| 437 |
+
.toast.error { background: var(--tc-red); }
|
| 438 |
+
|
| 439 |
+
/* ── Sidebar toggle ─────────────────────────────────────────────────────
|
| 440 |
+
One class (``#app.sidebar-closed``) drives collapse on every breakpoint:
|
| 441 |
+
desktop collapses the grid column to 0; mobile slides the fixed overlay
|
| 442 |
+
out. The header toggle is always visible so users can re-open it. */
|
| 443 |
+
#sidebar-toggle { display: inline-flex; }
|
| 444 |
+
@media (max-width: 860px) {
|
| 445 |
+
#app { grid-template-columns: 1fr; grid-template-areas: "header" "main"; }
|
| 446 |
+
#sidebar {
|
| 447 |
+
position: fixed; top: 60px; bottom: 0; left: 0; width: 86%; max-width: 360px;
|
| 448 |
+
z-index: 50; box-shadow: var(--shadow-lg);
|
| 449 |
+
}
|
| 450 |
+
/* On mobile the sidebar is an overlay; show it unless explicitly closed. */
|
| 451 |
+
#app.sidebar-closed #sidebar { transform: translateX(-100%); }
|
| 452 |
+
.examples-hero img { height: 46px; }
|
| 453 |
+
.examples-heading { font-size: 22px; }
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
/* ── Scrollbars (slim & subtle) ──────────────────────────────────────── */
|
| 457 |
+
::-webkit-scrollbar { width: 10px; height: 10px; }
|
| 458 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 459 |
+
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
|
| 460 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: padding-box; }
|
| 461 |
+
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
| 462 |
+
|
| 463 |
+
/* KaTeX: keep its own math fonts and reset inherited typography so SVG
|
| 464 |
+
stretchy operators don't get distorted by the bubble's line-height. */
|
| 465 |
+
.message.bot .katex { font: normal 1.21em KaTeX_Main, "Times New Roman", serif !important; text-indent: 0 !important; }
|
| 466 |
+
.message.bot .katex, .message.bot .katex *, .message.bot .katex-display, .message.bot .katex-display * { letter-spacing: normal !important; word-spacing: normal !important; line-height: 1.2 !important; }
|
| 467 |
+
.message.bot .katex, .message.bot .katex-display { color: var(--text) !important; }
|
| 468 |
+
.message.bot .katex svg { fill: currentColor; stroke: none; }
|
| 469 |
+
.message.bot .katex-display { margin: 1em 0; padding: 0.4em 0; overflow-x: auto; overflow-y: hidden; }
|
| 470 |
+
.message.bot .katex-display > .katex { text-align: center; }
|
| 471 |
+
</style>
|
| 472 |
+
</head>
|
| 473 |
+
<body>
|
| 474 |
+
<div id="app" class="sidebar-closed">
|
| 475 |
+
<!-- ── Header ────────────────────────────────────────────────────── -->
|
| 476 |
+
<header id="header">
|
| 477 |
+
<button id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Toggle sidebar">
|
| 478 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
| 479 |
+
</button>
|
| 480 |
+
<div class="brand">
|
| 481 |
+
<img class="brand-logo" src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="Tencent Hy3 logo" />
|
| 482 |
+
<span class="brand-name">Hy3</span>
|
| 483 |
+
<span class="brand-sep">·</span>
|
| 484 |
+
<span class="brand-tag">Chat Demo</span>
|
| 485 |
+
<span class="brand-badge">Tencent</span>
|
| 486 |
+
</div>
|
| 487 |
+
<div class="header-spacer"></div>
|
| 488 |
+
<button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
|
| 489 |
+
<svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
|
| 490 |
+
</button>
|
| 491 |
+
</header>
|
| 492 |
+
|
| 493 |
+
<!-- ── Sidebar ──────────────────────────────────────────────────── -->
|
| 494 |
+
<aside id="sidebar">
|
| 495 |
+
<p class="sidebar-notice" id="sidebar-notice"></p>
|
| 496 |
+
|
| 497 |
+
<div class="field">
|
| 498 |
+
<label data-i18n="sidebar.think_level"></label>
|
| 499 |
+
<select id="think-level">
|
| 500 |
+
<option value="high">high</option>
|
| 501 |
+
<option value="low">low</option>
|
| 502 |
+
<option value="no_think">no_think</option>
|
| 503 |
+
</select>
|
| 504 |
+
<span class="info" data-i18n="sidebar.think_level.info"></span>
|
| 505 |
+
</div>
|
| 506 |
+
|
| 507 |
+
<div class="field">
|
| 508 |
+
<label data-i18n="sidebar.system_prompt"></label>
|
| 509 |
+
<textarea id="system-prompt" rows="3" data-i18n-ph="sidebar.system_prompt.placeholder"></textarea>
|
| 510 |
+
</div>
|
| 511 |
+
|
| 512 |
+
<div class="field">
|
| 513 |
+
<label data-i18n="sidebar.temperature"></label>
|
| 514 |
+
<div class="range-row">
|
| 515 |
+
<input type="range" id="temperature" min="0" max="1" step="0.01" value="0.9" disabled />
|
| 516 |
+
<input type="number" class="range-num" id="temperature-num" value="0.9" min="0" max="1" step="0.01" disabled />
|
| 517 |
+
</div>
|
| 518 |
+
<label class="check-row"><input type="checkbox" id="temperature-use-default" checked /><span data-i18n="sidebar.temperature.use_default"></span></label>
|
| 519 |
+
<span class="info" data-i18n="sidebar.temperature.info"></span>
|
| 520 |
+
</div>
|
| 521 |
+
|
| 522 |
+
<div class="field">
|
| 523 |
+
<label data-i18n="sidebar.max_tokens"></label>
|
| 524 |
+
<div class="range-row">
|
| 525 |
+
<input type="range" id="max-tokens" min="0" max="65536" step="1" value="0" />
|
| 526 |
+
<input type="number" class="range-num" id="max-tokens-num" value="0" min="0" max="65536" step="1" />
|
| 527 |
+
</div>
|
| 528 |
+
<span class="info" data-i18n="sidebar.max_tokens.info"></span>
|
| 529 |
+
</div>
|
| 530 |
+
|
| 531 |
+
<div class="field">
|
| 532 |
+
<label data-i18n="sidebar.top_p"></label>
|
| 533 |
+
<div class="range-row">
|
| 534 |
+
<input type="range" id="top-p" min="0" max="1" step="0.01" value="0" />
|
| 535 |
+
<input type="number" class="range-num" id="top-p-num" value="0" min="0" max="1" step="0.01" />
|
| 536 |
+
</div>
|
| 537 |
+
<span class="info" data-i18n="sidebar.top_p.info"></span>
|
| 538 |
+
</div>
|
| 539 |
+
|
| 540 |
+
<div class="field">
|
| 541 |
+
<label data-i18n="sidebar.preserved_thinking"></label>
|
| 542 |
+
<label class="check-row"><input type="checkbox" id="preserved-thinking-use-default" checked /><span data-i18n="sidebar.preserved_thinking.use_default"></span></label>
|
| 543 |
+
<label class="check-row"><input type="checkbox" id="preserved-thinking" /><span data-i18n="sidebar.preserved_thinking" style="font-weight:500;color:var(--text)"></span></label>
|
| 544 |
+
<span class="info" data-i18n="sidebar.preserved_thinking.use_default.info"></span>
|
| 545 |
+
</div>
|
| 546 |
+
|
| 547 |
+
<details class="accordion">
|
| 548 |
+
<summary data-i18n="sidebar.functions"></summary>
|
| 549 |
+
<div class="accordion-body">
|
| 550 |
+
<textarea id="functions-json" spellcheck="false" placeholder=""></textarea>
|
| 551 |
+
<button class="tiny-btn" id="validate-fn-btn" data-i18n="sidebar.validate_btn"></button>
|
| 552 |
+
<div class="fn-msg" id="fn-msg"></div>
|
| 553 |
+
</div>
|
| 554 |
+
</details>
|
| 555 |
+
</aside>
|
| 556 |
+
|
| 557 |
+
<!-- ── Main chat ────────────────────────────────────────────────── -->
|
| 558 |
+
<main id="main">
|
| 559 |
+
<div id="chat" role="log" aria-live="polite"></div>
|
| 560 |
+
|
| 561 |
+
<div id="examples-overlay">
|
| 562 |
+
<div class="examples-wrapper">
|
| 563 |
+
<div class="examples-hero">
|
| 564 |
+
<img src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="" />
|
| 565 |
+
</div>
|
| 566 |
+
<p class="examples-heading" data-i18n="examples_heading"></p>
|
| 567 |
+
<div class="examples-grid" id="examples-grid"></div>
|
| 568 |
+
</div>
|
| 569 |
+
</div>
|
| 570 |
+
|
| 571 |
+
<div id="tool-area">
|
| 572 |
+
<div class="tool-header" id="tool-header"></div>
|
| 573 |
+
<div class="tool-markdown" id="tool-markdown"></div>
|
| 574 |
+
<input type="text" id="tool-result-input" />
|
| 575 |
+
<button id="tool-submit-btn" data-i18n="tool.submit"></button>
|
| 576 |
+
</div>
|
| 577 |
+
</main>
|
| 578 |
+
|
| 579 |
+
<!-- ── Composer (floating pill) ───────────────────────────────��─── -->
|
| 580 |
+
<div id="composer">
|
| 581 |
+
<div class="composer-inner">
|
| 582 |
+
<button id="new-chat-btn" title="New chat" aria-label="New chat">
|
| 583 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
| 584 |
+
</button>
|
| 585 |
+
<textarea id="msg-input" rows="1" data-i18n-ph="msg_placeholder"></textarea>
|
| 586 |
+
<button id="send-btn" title="Send" aria-label="Send">
|
| 587 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
| 588 |
+
</button>
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
</div>
|
| 592 |
+
|
| 593 |
+
<div id="toast-host"></div>
|
| 594 |
+
|
| 595 |
+
<script type="module">
|
| 596 |
+
/* ───────────────────────────────────────────────────────────────────
|
| 597 |
+
Hy3 Tencent-branded frontend.
|
| 598 |
+
|
| 599 |
+
Goal: only the UI + transport changed from the gr.Blocks app. The chat
|
| 600 |
+
behaviour is identical, so this file is a faithful port of the old
|
| 601 |
+
static/chat.js (the delta-op renderer: append-only bubbles, frozen /
|
| 602 |
+
streaming split so KaTeX+marked+hljs run once per closed block, seq
|
| 603 |
+
de-dup, epoch staleness, preprocess_latex'd content deltas, tool-call
|
| 604 |
+
markdown) plus static/app.js (the busy-marker watchdog, follow-the-tail
|
| 605 |
+
scroll lock with user-gesture pause, examples overlay, code-block
|
| 606 |
+
chrome with copy + html preview).
|
| 607 |
+
|
| 608 |
+
The ONLY adaptation is the transport: instead of a MutationObserver
|
| 609 |
+
watching a hidden #hy-chat-delta gr.HTML component, we consume the
|
| 610 |
+
@gradio/client async-iterator frames from server.py and feed each JSON
|
| 611 |
+
delta payload straight into the same applyDelta() the old renderer used.
|
| 612 |
+
The busy marker is no longer a hidden gr.HTML span; it's a client-side
|
| 613 |
+
flag toggled by the stream's first frame + completion + the watchdog.
|
| 614 |
+
─────────────────────────────────────────────────────────────────── */
|
| 615 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.7.1/dist/index.min.js";
|
| 616 |
+
|
| 617 |
+
(() => {
|
| 618 |
+
"use strict";
|
| 619 |
+
if (window.__HY_APP_INIT) return;
|
| 620 |
+
window.__HY_APP_INIT = true;
|
| 621 |
+
|
| 622 |
+
// ─── i18n + examples (loaded from /config endpoint) ──────────────
|
| 623 |
+
// Full English default table so every label is populated on first paint
|
| 624 |
+
// — before /config round-trips. /config then overrides with the active
|
| 625 |
+
// locale (en/zh). Keep these in sync with i18n.py.
|
| 626 |
+
const I18N = {
|
| 627 |
+
"title": "Hy3 Chat Demo",
|
| 628 |
+
"title.notice_html": "The current demo version is offline; full-featured capabilities will be available on the Tencent HY official website.",
|
| 629 |
+
"examples_heading": "What can I help you with?",
|
| 630 |
+
"msg_placeholder": "Type a message...",
|
| 631 |
+
"sidebar.think_level": "Think level",
|
| 632 |
+
"sidebar.think_level.info": "Control reasoning depth",
|
| 633 |
+
"sidebar.system_prompt": "System prompt",
|
| 634 |
+
"sidebar.system_prompt.placeholder": "You are a helpful AI assistant...",
|
| 635 |
+
"sidebar.temperature": "Temperature",
|
| 636 |
+
"sidebar.temperature.info": "Higher values produce more random outputs",
|
| 637 |
+
"sidebar.temperature.use_default": "Use model default",
|
| 638 |
+
"sidebar.max_tokens": "Max output tokens",
|
| 639 |
+
"sidebar.max_tokens.info": "Maximum tokens per response. Set to 0 to use the model's default.",
|
| 640 |
+
"sidebar.top_p": "Top P",
|
| 641 |
+
"sidebar.top_p.info": "Nucleus sampling probability threshold. Set to 0 to use the model's default.",
|
| 642 |
+
"sidebar.preserved_thinking": "Preserved thinking",
|
| 643 |
+
"sidebar.preserved_thinking.info": "When enabled, the model can retain reasoning content from previous assistant turns in the context, helping maintain reasoning continuity and conversation integrity while improving model performance.",
|
| 644 |
+
"sidebar.preserved_thinking.use_default": "Preserved thinking — Use model default",
|
| 645 |
+
"sidebar.preserved_thinking.use_default.info": "We recommend using \"Use model default\", which automatically enables this option for tool call workflows and disables it for pure text conversations.",
|
| 646 |
+
"sidebar.functions": "🔧 Functions",
|
| 647 |
+
"sidebar.functions.label": "Function definitions (JSON array)",
|
| 648 |
+
"sidebar.validate_btn": "Validate & Format",
|
| 649 |
+
"tool.result_label": "Function result",
|
| 650 |
+
"tool.result_placeholder": "Enter function return value... (Enter to submit)",
|
| 651 |
+
"tool.submit": "Submit",
|
| 652 |
+
"tool.call_header": "Function call ({i}/{n})",
|
| 653 |
+
"tool.call_label": "🔧 Call function",
|
| 654 |
+
"display.thinking": "Thinking...",
|
| 655 |
+
"display.thinking_done": "Thinking complete",
|
| 656 |
+
"display.code_copy": "Copy",
|
| 657 |
+
"display.code_copied": "Copied",
|
| 658 |
+
"warn.empty_msg": "Please enter a message",
|
| 659 |
+
"warn.invalid_fn_json": "Invalid function JSON, please fix or clear before sending: {err}",
|
| 660 |
+
"warn.request_failed": "Request failed, please retry or adjust parameters",
|
| 661 |
+
"warn.model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
|
| 662 |
+
"info.new_chat": "Start a new chat",
|
| 663 |
+
// short aliases used directly in code below
|
| 664 |
+
"thinking": "Thinking...",
|
| 665 |
+
"thinking_done": "Thinking complete",
|
| 666 |
+
"code_copy": "Copy",
|
| 667 |
+
"code_copied": "Copied",
|
| 668 |
+
"model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
|
| 669 |
+
"empty_msg": "Please enter a message",
|
| 670 |
+
"request_failed": "Request failed, please retry or adjust parameters",
|
| 671 |
+
"new_chat": "Start a new chat",
|
| 672 |
+
};
|
| 673 |
+
let EXAMPLES = [];
|
| 674 |
+
let TOOL_CALL_LABEL = "🔧 Call function";
|
| 675 |
+
|
| 676 |
+
// ─── session id (fresh per page load) ─────────────────────────────
|
| 677 |
+
// Minting a NEW id on every load (rather than reusing one from
|
| 678 |
+
// sessionStorage) makes a browser refresh start a blank conversation:
|
| 679 |
+
// the server keys its in-memory ChatState off this id, so a fresh id
|
| 680 |
+
// means a fresh, empty state and the prior turn's context never leaks
|
| 681 |
+
// into the next message. sessionStorage used to make the id survive F5
|
| 682 |
+
// — which is exactly what surfaced stale history after a refresh. The
|
| 683 |
+
// variable persists for the lifetime of this loaded page (across
|
| 684 |
+
// @gradio/client reconnects), so no storage is needed.
|
| 685 |
+
let SESSION_ID = "s-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
| 686 |
+
|
| 687 |
+
// ─── DOM refs ─────────────────────────────────────────────────────
|
| 688 |
+
const $ = (id) => document.getElementById(id);
|
| 689 |
+
const chatEl = $("chat");
|
| 690 |
+
const overlayEl = $("examples-overlay");
|
| 691 |
+
const msgInput = $("msg-input");
|
| 692 |
+
const sendBtn = $("send-btn");
|
| 693 |
+
const newChatBtn = $("new-chat-btn");
|
| 694 |
+
const toolArea = $("tool-area");
|
| 695 |
+
const toolMarkdown = $("tool-markdown");
|
| 696 |
+
const toolResultInput = $("tool-result-input");
|
| 697 |
+
const toolSubmitBtn = $("tool-submit-btn");
|
| 698 |
+
|
| 699 |
+
// ─── streaming state ──────────────────────────────────────────────
|
| 700 |
+
let isStreaming = false;
|
| 701 |
+
let currentJob = null; // the active async-iterator job
|
| 702 |
+
let pendingToolCalls = []; // tool calls awaiting user results
|
| 703 |
+
let currentEpoch = null; // staleness guard for in-flight frames
|
| 704 |
+
// How many tool results the user has submitted in the current tool-call
|
| 705 |
+
// round, so the panel header shows "Function call (2/3)" correctly.
|
| 706 |
+
let toolResultsSubmitted = 0;
|
| 707 |
+
let toolRoundTotal = 0;
|
| 708 |
+
|
| 709 |
+
/* ─── apply i18n to the DOM ──────────────────────────────────────── */
|
| 710 |
+
function applyI18n(table) {
|
| 711 |
+
Object.assign(I18N, table || {});
|
| 712 |
+
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
| 713 |
+
const key = el.getAttribute("data-i18n");
|
| 714 |
+
if (I18N[key] != null) el.textContent = I18N[key];
|
| 715 |
+
});
|
| 716 |
+
document.querySelectorAll("[data-i18n-ph]").forEach((el) => {
|
| 717 |
+
const key = el.getAttribute("data-i18n-ph");
|
| 718 |
+
if (I18N[key] != null) el.placeholder = I18N[key];
|
| 719 |
+
});
|
| 720 |
+
document.title = (I18N["title"] || "Hy3") + " · Tencent";
|
| 721 |
+
$("sidebar-notice").textContent = I18N["title.notice_html"] || "";
|
| 722 |
+
$("msg-input").placeholder = I18N["msg_placeholder"] || "Type a message...";
|
| 723 |
+
if (I18N["tool.call_label"]) TOOL_CALL_LABEL = I18N["tool.call_label"];
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
/* ─── theme toggle ───────────────────────────────────────────────── */
|
| 727 |
+
function setTheme(dark) {
|
| 728 |
+
document.documentElement.classList.toggle("dark", dark);
|
| 729 |
+
localStorage.setItem("hy3-theme", dark ? "dark" : "light");
|
| 730 |
+
const lightCss = document.getElementById("hljs-light-css");
|
| 731 |
+
const darkCss = document.getElementById("hljs-dark-css");
|
| 732 |
+
if (lightCss && darkCss) {
|
| 733 |
+
lightCss.disabled = dark;
|
| 734 |
+
darkCss.disabled = !dark;
|
| 735 |
+
}
|
| 736 |
+
}
|
| 737 |
+
const savedTheme = localStorage.getItem("hy3-theme");
|
| 738 |
+
setTheme(savedTheme ? savedTheme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches);
|
| 739 |
+
$("theme-btn").addEventListener("click", () =>
|
| 740 |
+
setTheme(!document.documentElement.classList.contains("dark"))
|
| 741 |
+
);
|
| 742 |
+
|
| 743 |
+
/* ─── sidebar toggle ───────────────────────────────────────────────
|
| 744 |
+
One class (``sidebar-closed``) drives collapse on every breakpoint;
|
| 745 |
+
the header button flips it, so the same control opens/closes the
|
| 746 |
+
sidebar on both desktop and mobile. It starts closed by default
|
| 747 |
+
(see the ``sidebar-closed`` class on #app). */
|
| 748 |
+
$("sidebar-toggle").addEventListener("click", () => {
|
| 749 |
+
$("app").classList.toggle("sidebar-closed");
|
| 750 |
+
});
|
| 751 |
+
|
| 752 |
+
/* ─── markdown + math rendering ─────────────────────────────���──────
|
| 753 |
+
Ported from static/chat.js: two-stage pipeline so marked doesn't
|
| 754 |
+
eat the LaTeX before KaTeX can see it. */
|
| 755 |
+
const MARKED_OPTS = { gfm: true, breaks: false };
|
| 756 |
+
const KATEX_DELIMS = [
|
| 757 |
+
{ left: "$$", right: "$$", display: true },
|
| 758 |
+
{ left: "$", right: "$", display: false },
|
| 759 |
+
];
|
| 760 |
+
|
| 761 |
+
function normalizeMathDelims(text) {
|
| 762 |
+
if (!text) return text;
|
| 763 |
+
let out = "", i = 0; const n = text.length;
|
| 764 |
+
while (i < n) {
|
| 765 |
+
const ch = text[i];
|
| 766 |
+
if ((ch === "`" && text.startsWith("```", i)) || (ch === "~" && text.startsWith("~~~", i))) {
|
| 767 |
+
const marker = text.substr(i, 3); out += marker; i += 3;
|
| 768 |
+
const close = text.indexOf(marker, i);
|
| 769 |
+
if (close < 0) { out += text.slice(i); return out; }
|
| 770 |
+
out += text.slice(i, close + 3); i = close + 3; continue;
|
| 771 |
+
}
|
| 772 |
+
if (ch === "`") {
|
| 773 |
+
let runLen = 1; while (i + runLen < n && text[i + runLen] === "`") runLen++;
|
| 774 |
+
const open = text.substr(i, runLen); out += open; i += runLen;
|
| 775 |
+
const close = text.indexOf(open, i);
|
| 776 |
+
if (close < 0) { out += text.slice(i); return out; }
|
| 777 |
+
out += text.slice(i, close + runLen); i = close + runLen; continue;
|
| 778 |
+
}
|
| 779 |
+
if (ch === "\\") {
|
| 780 |
+
if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
|
| 781 |
+
if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
|
| 782 |
+
if (text[i + 1] === "(") { out += "$"; i += 2; continue; }
|
| 783 |
+
if (text[i + 1] === ")") { out += "$"; i += 2; continue; }
|
| 784 |
+
}
|
| 785 |
+
out += ch; i++;
|
| 786 |
+
}
|
| 787 |
+
return out;
|
| 788 |
+
}
|
| 789 |
+
const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;
|
| 790 |
+
function protectMath(text) {
|
| 791 |
+
const stash = [];
|
| 792 |
+
const escaped = text.replace(MATH_RE, (m) => {
|
| 793 |
+
const idx = stash.length; stash.push(m); return " |