Hy3 / app.py
ericsqin's picture
add hy3
ad51766
Raw
History Blame Contribute Delete
21.8 kB
import base64
import json
import re
from pathlib import Path
import gradio as gr
from chat import (
HY_CHAT_INITIAL_HTML,
api_chat,
init_state,
new_chat,
send_message,
submit_tool_result,
)
from i18n import LANG, t
from tools import validate_functions_json
from styles import CSS
_STATIC_DIR = Path(__file__).parent / "static"
_VENDOR_DIR = _STATIC_DIR / "vendor"
_FN_PLACEHOLDER = json.dumps([{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather info for a city",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"}
},
"required": ["location"]
}
}
}], indent=2, ensure_ascii=False)
_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.
Initial settings:
1. All extracted information must strictly come from the text provided by the user.
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."
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.
Please help me extract all personal names from the text in order. Note that you do not need to remove duplicates.
Text:
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."""
_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:
Date/Location/Ticket
MM-dd/Travel location/Ticket price
MM-dd/Travel location/Ticket price
......
For the Travel location field, choose exactly one from the following six options: Hongya Cave, Chaotianmen, Jiefangbei, Qiansimen, Shapingba, Jiangbeizui.
Opening hours:
Hongya Cave: 9:00–11:00
Chaotianmen: 9:00–11:00
Jiefangbei: 15:00–17:00
Qiansimen: 11:00–13:00
Shapingba: 12:00–14:00
Jiangbeizui: 16:00–18:00
Ticket prices:
Hongya Cave: CNY 20 per person
Chaotianmen: CNY 60 per person
Jiefangbei: CNY 30 per person
Qiansimen: CNY 50 per person
Shapingba: CNY 60 per person
Jiangbeizui: CNY 50 per person
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.
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.
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.
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."""
_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\)."""
_FISH_PUZZLE_PROMPT = """Three houses stand side by side, numbered 1–3 from left to right. Each house has:
A different color: red, green, blue
A resident of a different nationality: British, French, Japanese
A different pet: dog, cat, fish
Clues:
The British resident lives in the red house.
The French resident owns the dog.
The green house is immediately to the left of the red house.
The Japanese resident lives in House 1.
The cat is kept in the blue house.
Question:
Who owns the fish? 🐟"""
# The 4 showcase prompts are shared across languages — the full text is
# kept verbatim (that's what we send to the model) while the frontend
# truncates the button label for display; see ``createOverlay`` in
# static/app.js for the label-truncation side.
_SHOWCASE_PROMPTS = [
_EXTRACT_NAMES_PROMPT,
_CHONGQING_TRIP_PROMPT,
_RATIONAL_VARIETY_PROMPT,
_FISH_PUZZLE_PROMPT,
]
_EXAMPLES_BY_LANG = {
"en": _SHOWCASE_PROMPTS,
"zh": _SHOWCASE_PROMPTS,
}
EXAMPLES = _EXAMPLES_BY_LANG.get(LANG, _EXAMPLES_BY_LANG["en"])
# ─── Vendored asset inlining ──────────────────────────────────────────
# Every vendored library is INLINED into the ``js`` / ``css`` parameters
# that ``gr.Blocks`` already serves on the page, so the app runs fully
# self-contained without any external CDN or extra static-route plumbing.
#
# Order matters for JS: marked / katex / auto-render / hljs must be
# defined as globals BEFORE static/chat.js runs; static/chat.js must
# define ``window.HY_CHAT`` BEFORE static/app.js's ``bootstrap()``
# polls for ``#hy-chat``. Concatenation order below preserves this.
def _read(p: Path) -> str:
return p.read_text(encoding="utf-8")
_VENDOR_JS = "\n;\n".join([
_read(_VENDOR_DIR / "marked" / "marked.min.js"),
_read(_VENDOR_DIR / "katex" / "katex.min.js"),
_read(_VENDOR_DIR / "katex" / "auto-render.min.js"),
_read(_VENDOR_DIR / "highlight" / "highlight.min.js"),
])
_CHAT_JS = _read(_STATIC_DIR / "chat.js")
_APP_JS = _read(_STATIC_DIR / "app.js")
# KaTeX's stylesheet references font files via ``url(fonts/KaTeX_*.woff2)``
# — a path that only resolves if the CSS itself is served from the same
# directory as the fonts. Since we inline the CSS into a <style> tag
# the relative URLs would resolve against the document root and 404. We
# rewrite each font URL into a ``data:`` URI so the CSS is fully
# self-contained, and strip the ``.woff`` / ``.ttf`` fallback entries
# from each ``@font-face`` src list so the browser doesn't fire requests
# we know will 404 (we only ship the ``.woff2`` files).
_KATEX_CSS = _read(_VENDOR_DIR / "katex" / "katex.min.css")
_FONTS_DIR = _VENDOR_DIR / "katex" / "fonts"
# Drop ``,url(fonts/X.woff) format("woff")`` and the ttf equivalent. The
# minified CSS may use single OR double quotes around the format string;
# match both. Run BEFORE inlining woff2 so the regex still sees the
# ``url(fonts/...)`` token shape.
_KATEX_CSS = re.sub(
r",\s*url\(fonts/[^)]+\.(?:woff|ttf)\)\s*format\(['\"](?:woff|truetype)['\"]\)",
"",
_KATEX_CSS,
)
# Inline every woff2 as a data URI.
for _font_path in sorted(_FONTS_DIR.glob("*.woff2")):
_b64 = base64.b64encode(_font_path.read_bytes()).decode("ascii")
_data_uri = f"data:font/woff2;base64,{_b64}"
_KATEX_CSS = _KATEX_CSS.replace(
f"url(fonts/{_font_path.name})", f"url({_data_uri})"
)
_HLJS_CSS = _read(_VENDOR_DIR / "highlight" / "github.min.css")
_HLJS_DARK_CSS = _read(_VENDOR_DIR / "highlight" / "github-dark.min.css")
# Gradio passes this string as the body of an init function it calls on
# load. We bundle vendored libs first so they install their globals
# (``window.marked``, ``window.katex``, ``window.renderMathInElement``,
# ``window.hljs``), then chat.js (defines ``window.HY_CHAT`` and starts
# the delta-channel observer), then app.js (the rest of the UX glue).
_JS_INIT = f"""
window.HY_EXAMPLES = {json.dumps(EXAMPLES)};
window.HY_I18N = {json.dumps({
"examples_heading": t("examples_heading"),
"model_busy": t("warn.model_busy"),
"thinking": t("display.thinking"),
"thinking_done": t("display.thinking_done"),
"code_copy": t("display.code_copy"),
"code_copied": t("display.code_copied"),
})};
{_VENDOR_JS}
;
{_CHAT_JS}
;
{_APP_JS}
"""
# Append vendored CSS to whatever ``styles.py`` produced. The KaTeX
# theme has higher specificity for math elements than our other rules,
# so loading order doesn't matter for correctness; we just always
# append it last for consistency.
_FULL_CSS = (
CSS
+ "\n/* === katex.min.css (inlined, fonts as data URIs) === */\n"
+ _KATEX_CSS
+ "\n/* === highlight.js github theme === */\n"
+ _HLJS_CSS
+ "\n/* === highlight.js github-dark theme (active under .dark) === */\n"
# Scope the dark theme to ``.dark`` so the light theme above is the
# default and the dark variant only kicks in when Gradio toggles its
# top-level ``.dark`` class. ``github-dark.min.css`` references
# ``.hljs`` (and many ``.hljs-...`` variants) at lots of positions:
# at the start of a rule, right after ``}``, AND comma-separated
# like ``.hljs-doctag,.hljs-keyword,...`` where only the first
# selector is preceded by ``}``. A simple global string replace
# works because ``.hljs`` only appears inside selectors in this
# file (never in property values or strings).
+ _HLJS_DARK_CSS.replace(".hljs", ".dark .hljs")
)
with gr.Blocks(title=t("title"), fill_width=True, js=_JS_INIT) as demo:
state = gr.State(init_state)
with gr.Sidebar(width=420, open=True):
gr.HTML(
f"<h1 style='text-align:center;font-size:2rem;margin:0.2em 0 0.2em'>{t('title')}</h1>"
f"<p class='title-notice' style='text-align:center;font-size:0.88rem;"
f"line-height:1.4;color:var(--body-text-color,#444);"
f"margin:0 0 0.8em;padding:0 0.4em'>{t('title.notice_html')}</p>"
)
think_level = gr.Dropdown(
choices=["no_think", "low", "high"],
value="high",
label=t("sidebar.think_level"),
info=t("sidebar.think_level.info"),
)
system_prompt = gr.Textbox(
value="",
label=t("sidebar.system_prompt"),
lines=3,
placeholder=t("sidebar.system_prompt.placeholder"),
)
temperature = gr.Slider(
minimum=0, maximum=1, step=0.01, value=0.9,
label=t("sidebar.temperature"),
info=t("sidebar.temperature.info"),
interactive=False,
)
temperature_use_default = gr.Checkbox(
value=True,
label=t("sidebar.temperature.use_default"),
)
# Toggle the slider's interactive state to match the checkbox so the
# disabled visual cue matches the request semantics: when "Use model
# default" is checked, temperature is omitted from the API request
# entirely (see ``build_api_kwargs``); when unchecked, the slider
# value is sent literally — including 0, which means greedy decoding.
temperature_use_default.change(
fn=lambda checked: gr.update(interactive=not checked),
inputs=[temperature_use_default],
outputs=[temperature],
api_visibility="private",
show_progress="hidden",
queue=False,
)
max_tokens = gr.Slider(
minimum=0, maximum=65536, step=1, value=0,
label=t("sidebar.max_tokens"),
info=t("sidebar.max_tokens.info"),
)
top_p = gr.Slider(
minimum=0, maximum=1, step=0.01, value=0,
label=t("sidebar.top_p"),
info=t("sidebar.top_p.info"),
)
preserved_thinking = gr.Checkbox(
value=False,
label=t("sidebar.preserved_thinking"),
info=t("sidebar.preserved_thinking.info"),
interactive=False,
)
preserved_thinking_use_default = gr.Checkbox(
value=True,
label=t("sidebar.preserved_thinking.use_default"),
info=t("sidebar.preserved_thinking.use_default.info"),
)
# Mirror the temperature "Use model default" wiring: when checked,
# ``preserved_thinking`` is omitted from the API request entirely
# (see ``build_api_kwargs``) and the toggle is disabled; when
# unchecked, the toggle becomes interactive and its boolean value is
# sent literally (defaulting to False / off).
preserved_thinking_use_default.change(
fn=lambda checked: gr.update(interactive=not checked),
inputs=[preserved_thinking_use_default],
outputs=[preserved_thinking],
api_visibility="private",
show_progress="hidden",
queue=False,
)
with gr.Accordion(t("sidebar.functions"), open=False):
functions_json = gr.Textbox(
value="",
label=t("sidebar.functions.label"),
lines=15,
max_lines=30,
placeholder=_FN_PLACEHOLDER,
)
validate_fn_btn = gr.Button(t("sidebar.validate_btn"), size="sm")
# ── Custom chat surface ───────────────────────────────────────────
# The visible chat lives entirely inside <div id="hy-chat">, owned and
# mutated exclusively by static/chat.js. Server NEVER updates this
# component's value — it's set once at component creation and the
# renderer takes over from there.
chat_host = gr.HTML(
value=HY_CHAT_INITIAL_HTML,
elem_id="hy-chat-host",
)
# Hidden delta channel. Server pushes JSON ops payloads here; the
# client observes mutations on this element and applies each op to
# the chat container. Visually hidden via _hide.css (see the
# #hy-chat-delta rule there).
#
# Why a separate component (not a side-effect of chat_host updates):
# any update to chat_host's value would wipe the entire DOM the
# renderer has built. The delta channel is the safe back-door — its
# value can change freely on every yield without ever touching the
# visible chat surface.
chat_delta = gr.HTML(value="", elem_id="hy-chat-delta")
with gr.Column(visible=False, elem_id="tool-area") as tool_area:
tool_call_info = gr.Markdown("")
tool_result_input = gr.Textbox(
label=t("tool.result_label"),
placeholder=t("tool.result_placeholder"),
lines=1,
)
tool_submit_btn = gr.Button(t("tool.submit"), variant="primary", size="sm")
with gr.Row(elem_classes=["msg-row"]):
msg_input = gr.Textbox(
placeholder=t("msg_placeholder"),
show_label=False,
lines=1,
scale=8,
elem_classes=["msg-input"],
)
send_btn = gr.Button(
"➤", variant="primary",
scale=0, min_width=42,
elem_id="send-btn", elem_classes=["send-btn"],
)
new_chat_btn = gr.Button(
"+", variant="secondary",
scale=0, min_width=42,
elem_classes=["new-chat-btn"],
)
# Hidden busy-marker component. Driven by the streaming generators in
# chat.py:
# * Streaming begins → value = HY_BUSY_HTML.
# * Streaming ends → value = HY_IDLE_HTML.
#
# The browser observes this single element and gates the Send button
# on it. Critically isolated from any chat content — unclosed
# <style>/<script>/<textarea> in model output cannot hijack it
# because it's a separate Gradio component.
busy_marker = gr.HTML("", elem_id="hy-busy-marker")
# ── Event wiring ──────────────────────────────────────────────────────
# IMPORTANT: the Send button's busy state is OWNED BY THE BROWSER, not
# by Gradio. We never call ``gr.update(interactive=False/True)`` on it
# from any handler. Instead the streaming generators drive busy_marker;
# static/app.js gates Send on its content (see file).
#
# ``concurrency_limit`` is SERVER-WIDE: up to N chat requests across
# ALL connected users can run in parallel. Per-session ``gr.State``
# isolates each user's conversation.
send_outputs = [
chat_delta, state, msg_input,
tool_area, tool_call_info, tool_result_input,
busy_marker,
]
send_inputs = [
msg_input, state,
system_prompt, think_level,
temperature, temperature_use_default,
max_tokens, top_p,
preserved_thinking, preserved_thinking_use_default,
functions_json,
]
chat_concurrency = {"concurrency_id": "chat", "concurrency_limit": 64}
for trigger in (msg_input.submit, send_btn.click):
trigger(
fn=send_message,
inputs=send_inputs,
outputs=send_outputs,
show_progress="hidden",
# UI-only handler: ships chat content as DOM-mutation deltas
# through ``chat_delta``, which is unusable from gradio_client.
# The headless ``/chat`` endpoint registered below is the
# supported programmatic surface.
api_visibility="private",
**chat_concurrency,
)
new_chat_btn.click(
fn=new_chat,
inputs=[state],
# ``busy_marker`` is in the output set so ``new_chat`` can release
# the Send-button lock immediately when it cancels an in-flight
# stream — without it the cancelled generator never reaches its
# terminal IDLE yield and the button would stay stuck until the
# client-side soft watchdog force-clears it.
outputs=[chat_delta, state, busy_marker],
show_progress="hidden",
api_visibility="private",
)
validate_fn_btn.click(
fn=validate_functions_json,
inputs=[functions_json],
outputs=[functions_json],
show_progress="hidden",
api_visibility="private",
)
tool_submit_inputs = [
tool_result_input, state,
system_prompt, think_level,
temperature, temperature_use_default,
max_tokens, top_p,
preserved_thinking, preserved_thinking_use_default,
functions_json,
]
tool_submit_outputs = [
chat_delta, state,
tool_area, tool_call_info, tool_result_input,
busy_marker,
]
for trigger in (tool_submit_btn.click, tool_result_input.submit):
trigger(
fn=submit_tool_result,
inputs=tool_submit_inputs,
outputs=tool_submit_outputs,
show_progress="hidden",
api_visibility="private", # UI-only; see send_message wiring above.
**chat_concurrency,
)
# ── Programmatic API endpoint ────────────────────────────────────────
# Registered with ``gr.api`` so it has NO UI footprint — it derives its
# input/output schema from ``api_chat``'s type hints rather than from
# Gradio components. Callers reach it via:
#
# from gradio_client import Client
# client = Client("ericsqin/hy-demo")
# content, reasoning, tool_calls, history = client.predict(
# message="Hello!",
# api_name="/chat",
# )
gr.api(
api_chat,
api_name="chat",
concurrency_id="chat",
concurrency_limit=64,
)
if __name__ == "__main__":
# Guarded so that ``import app`` (e.g. from tests or docs tooling)
# does not start a Gradio server as a side effect. HuggingFace Spaces
# invokes ``python app.py`` so this entry point still runs there.
demo.queue(default_concurrency_limit=64, max_size=128).launch(
css=_FULL_CSS,
ssr_mode=False,
show_error=True,
)