Hy3 / static /index.html
akhaliq's picture
akhaliq HF Staff
Clear chat history on browser refresh
175ad16
Raw
History Blame
72.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Hy3 · Tencent</title>
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
<link rel="apple-touch-icon" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
<!-- Tencent logo -->
<link rel="preload" as="image" href="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" />
<!-- marked + KaTeX + highlight.js from CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css" id="hljs-light-css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css" id="hljs-dark-css" disabled />
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>
<style>
/* ─────────────────────────────────────────────────────────────────────
Tencent design tokens
Tencent brand blue : #0052D9 (primary)
Tencent red accent : #E53935 (secondary / highlights)
Tencent teal : #00A98F (success accents)
───────────────────────────────────────────────────────────────────── */
:root {
--tc-blue: #0052D9;
--tc-blue-hover: #003CAB;
--tc-blue-soft: #E8F0FF;
--tc-blue-060: rgba(0, 82, 217, 0.06);
--tc-blue-120: rgba(0, 82, 217, 0.12);
--tc-red: #E53935;
--tc-teal: #00A98F;
--bg: #f7f6f3; /* warm canvas, Claude-like */
--bg-soft: #ffffff; /* surfaces / bubbles / sidebar */
--bg-muted: #efece6;
--bg-elev: #ffffff;
--sidebar-bg: #ffffff;
--text: #2b2b2b;
--text-strong: #1a1a1a;
--text-muted: #8a8580;
--border: #e7e3dc;
--border-strong: #d8d3cb;
--accent: #c96442; /* warm terracotta accent (Claude) */
--accent-soft: #f4ece7;
--shadow-sm: 0 1px 2px rgba(20, 18, 14, 0.04);
--shadow-md: 0 4px 14px rgba(20, 18, 14, 0.06);
--shadow-lg: 0 16px 48px rgba(20, 18, 14, 0.14);
--ring: 0 0 0 3px rgba(0, 82, 217, 0.16);
--radius-sm: 8px;
--radius: 14px;
--radius-lg: 20px;
--radius-xl: 26px;
--col-max: 768px; /* centered conversation width */
--font-sans: "Söhne", "Inter", -apple-system, BlinkMacSystemFont,
"SF Pro Text", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Monaco, monospace;
}
html.dark {
--bg: #1a1a1a; /* warm-tinted dark canvas */
--bg-soft: #232323;
--bg-muted: #2c2c2c;
--bg-elev: #232323;
--sidebar-bg: #202020;
--text: #ece9e4;
--text-strong: #ffffff;
--text-muted: #9a948d;
--border: #34312d;
--border-strong: #423e39;
--accent: #d97757;
--accent-soft: rgba(217, 119, 87, 0.16);
--tc-blue-soft: rgba(0, 82, 217, 0.16);
--tc-blue-060: rgba(0, 82, 217, 0.12);
--tc-blue-120: rgba(0, 82, 217, 0.24);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
--ring: 0 0 0 3px rgba(0, 82, 217, 0.3);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
}
button, input, textarea, select { font-family: inherit; }
/* ── Layout shell ───────────────────────────────────────────────────────
The chat surface (#main) IS the warm canvas and fills everything below
the header; the composer floats at the bottom of it, centered, so the
conversation scrolls underneath it (Claude-style) rather than living
in a separate hard-bordered bar. */
#app {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 60px 1fr;
grid-template-areas:
"header header"
"sidebar main";
height: 100vh; width: 100vw;
}
#app.sidebar-closed { grid-template-columns: 0 1fr; }
#app.sidebar-closed #sidebar { transform: translateX(-100%); }
/* ── Header / brand bar ────────────────────────────────────────────────
Transparent over the canvas with only a hairline divider — feels like
part of the page, not a chrome bar. */
#header {
grid-area: header;
display: flex; align-items: center; gap: 12px;
padding: 0 20px;
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
position: relative; z-index: 20;
}
.brand { display: flex; align-items: center; gap: 12px; cursor: default; }
/* The logo is a wide horizontal wordmark (Tencent + Hy3). Size it by
height and let width follow the natural aspect ratio so it never
gets smushed into a square. */
.brand-logo {
height: 26px; width: auto;
object-fit: contain;
display: block;
border-radius: 4px;
}
.brand-name { font-size: 16px; font-weight: 650; color: var(--text-strong); letter-spacing: -0.01em; }
.brand-sep { color: var(--border-strong); font-weight: 300; }
.brand-tag { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.brand-badge {
margin-left: 4px; font-size: 11px; font-weight: 600;
color: var(--tc-blue); background: var(--tc-blue-soft);
padding: 2px 9px; border-radius: 999px; letter-spacing: 0.02em;
}
.header-spacer { flex: 1; }
.icon-btn {
width: 36px; height: 36px; border-radius: 9px;
border: 1px solid transparent; background: transparent; color: var(--text-muted);
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; transition: background 0.15s ease, color 0.15s ease; padding: 0;
}
.icon-btn:hover { background: var(--bg-muted); color: var(--text); }
.icon-btn svg { width: 18px; height: 18px; }
/* ── Sidebar ──────────────────────────────────────────────────────────── */
#sidebar {
grid-area: sidebar;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
overflow-y: auto; overflow-x: hidden;
transition: transform 0.22s ease;
padding: 18px 18px 28px;
display: flex; flex-direction: column; gap: 16px;
}
.sidebar-notice {
font-size: 12px; line-height: 1.55; color: var(--text-muted);
background: var(--accent-soft); border: 1px solid transparent;
border-radius: var(--radius-sm); padding: 11px 13px;
}
.field { display: flex; flex-direction: column; gap: 7px; }
.field > label {
font-size: 11.5px; font-weight: 600; color: var(--text-muted);
text-transform: none; letter-spacing: 0.01em;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.field .info {
font-size: 11px; font-weight: 400; color: var(--text-muted);
margin-top: -3px; line-height: 1.45; opacity: 0.85;
}
.field input[type="text"], .field textarea, .field select {
width: 100%; padding: 9px 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-soft); color: var(--text); font-size: 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field textarea { resize: vertical; min-height: 64px; font-family: inherit; line-height: 1.5; }
.field input:focus, .field textarea:focus, .field select:focus {
outline: none; border-color: var(--tc-blue); box-shadow: var(--ring);
}
.field select { appearance: none; cursor: pointer;
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");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.range-row { display: flex; align-items: center; gap: 10px; }
.range-row input[type="range"] { flex: 1; accent-color: var(--tc-blue); }
.range-num {
width: 58px; padding: 5px 8px; text-align: center;
border: 1px solid var(--border); border-radius: 7px;
background: var(--bg-soft); color: var(--text); font-size: 12px;
font-variant-numeric: tabular-nums;
}
.range-num:focus { outline: none; border-color: var(--tc-blue); box-shadow: var(--ring); }
.range-row input[type="range"] { -webkit-appearance: none; appearance: none; height: 4px; background: var(--border); border-radius: 2px; }
.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); }
.range-row input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: none; border-radius: 50%; background: var(--tc-blue); cursor: pointer; }
.check-row {
display: flex; align-items: center; gap: 8px; cursor: pointer;
font-size: 12px; color: var(--text-muted); user-select: none; margin-top: 2px;
}
.check-row input { accent-color: var(--tc-blue); width: 14px; height: 14px; }
.accordion { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-soft); }
.accordion > summary {
list-style: none; cursor: pointer; padding: 11px 13px;
font-size: 13px; font-weight: 600; color: var(--text);
display: flex; align-items: center; justify-content: space-between;
}
.accordion > summary::-webkit-details-marker { display: none; }
.accordion > summary::after { content: "⌄"; color: var(--text-muted); transition: transform 0.15s ease; }
.accordion[open] > summary::after { transform: rotate(180deg); }
.accordion > .accordion-body { padding: 13px; display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--border); }
.accordion textarea { width: 100%; min-height: 180px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; resize: vertical; }
.tiny-btn {
align-self: flex-start; padding: 7px 15px; font-size: 12px; font-weight: 500;
border: 1px solid var(--border); border-radius: 8px; background: var(--bg-soft);
color: var(--text); cursor: pointer; transition: all 0.15s ease;
}
.tiny-btn:hover { border-color: var(--tc-blue); color: var(--tc-blue); background: var(--tc-blue-060); }
.fn-msg { font-size: 12px; line-height: 1.5; min-height: 18px; }
.fn-msg.ok { color: var(--tc-teal); }
.fn-msg.err { color: var(--tc-red); }
/* ── Main chat surface ──────────────────────────────────────────────────
The warm canvas. Conversation is centered in a narrow column with
generous vertical rhythm; assistant turns are plain text (no bubble),
user turns are soft pills. The composer floats at the bottom on a
gradient fade so messages scroll under it gracefully. */
#main {
grid-area: main; position: relative; background: var(--bg);
overflow: hidden; display: flex; flex-direction: column;
}
#chat {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 28px clamp(16px, 5vw, 48px) 220px; /* bottom room for floating composer */
scroll-behavior: auto;
}
/* Center the conversation in a narrow column. */
#chat > .col,
#chat > .message-row { max-width: var(--col-max); margin-left: auto; margin-right: auto; width: 100%; }
.message-row { display: block; width: 100%; margin: 0; }
.message-row.user-row { display: flex; justify-content: flex-end; margin: 22px 0 4px; }
.message-row.bot-row { margin: 0 0 30px; }
.message { max-width: 100%; }
.message.user {
background: var(--bg-elev); color: var(--text-strong);
border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 11px 18px;
max-width: min(560px, 82%); box-shadow: var(--shadow-sm);
font-weight: 450;
}
.message.user .hy-user-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.message.bot { background: transparent; padding: 0; max-width: 100%; }
.message.bot .markdown { font-size: 16px; line-height: 1.7; color: var(--text); letter-spacing: 0.005em; }
.message.bot p { margin: 0.85em 0; }
.message.bot p:first-child { margin-top: 0; }
.message.bot p:last-child { margin-bottom: 0; }
.message.bot strong, .message.bot b { font-weight: 650; color: var(--text-strong); }
.message.bot ul, .message.bot ol { padding-left: 1.5em; margin: 0.6em 0; }
.message.bot li { margin: 0.35em 0; line-height: 1.7; }
.message.bot h1 { font-size: 23px; font-weight: 700; color: var(--text-strong); margin: 1.1em 0 0.5em; letter-spacing: -0.01em; }
.message.bot h2 { font-size: 20px; font-weight: 700; color: var(--text-strong); margin: 1em 0 0.45em; letter-spacing: -0.01em; }
.message.bot h3 { font-size: 17px; font-weight: 650; color: var(--text-strong); margin: 0.9em 0 0.4em; }
.message.bot h4 { font-size: 15px; font-weight: 650; color: var(--text); margin: 0.8em 0 0.35em; }
.message.bot a { color: var(--tc-blue); text-underline-offset: 2px; }
.message.bot blockquote { border-left: 3px solid var(--border-strong); margin: 0.9em 0; padding: 2px 16px; color: var(--text-muted); }
.message.bot hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.message.bot table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.9em 0; font-size: 14px; }
.message.bot th, .message.bot td { border: 1px solid var(--border); padding: 8px 14px; text-align: left; vertical-align: top; }
.message.bot th { background: var(--bg-muted); font-weight: 600; }
.message.bot code { font-family: var(--font-mono); font-size: 0.88em; }
.message.bot :not(pre) > code { background: var(--bg-muted); padding: 2px 6px; border-radius: 5px; color: var(--text); }
.hy-codeblock { margin: 1em 0; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--bg-soft); }
.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; }
.hy-codeblock-lang { text-transform: lowercase; letter-spacing: 0.02em; font-weight: 500; }
.hy-codeblock-lang:empty::before { content: "text"; opacity: 0.7; }
.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; }
.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; }
.code-copy-btn svg { display: block; width: 14px; height: 14px; }
.code-copy-btn:hover { background: var(--tc-blue-060); color: var(--tc-blue); }
.code-copy-btn.copied, .code-copy-btn.copied:hover { color: var(--tc-teal); background: transparent; }
.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; }
.html-preview-btn::before { content: "▶ html preview"; font-size: 12px; color: var(--tc-blue); font-weight: 400; font-family: var(--font-sans); }
.html-preview-btn:hover { opacity: 0.7; }
/* ── Reasoning (thinking) block ───────────────────────────────────────── */
.thinking-block { background: none; border: none; padding: 0; margin: 6px 0 12px; }
.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; }
.thinking-block summary:hover { color: var(--text); }
.thinking-block summary::-webkit-details-marker { display: none; }
.thinking-block summary::after { content: " ›"; font-size: 13px; color: var(--border-strong); }
.thinking-block[open] summary::after { content: " ⌄"; }
.thinking-block[open] summary { margin-bottom: 8px; }
.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; }
/* ── Tool-call display block ─────────────────────────────────────────── */
.hy-tool-calls { margin-top: 10px; }
.hy-tool-call + .hy-tool-call { margin-top: 8px; }
/* ── Typing indicator ─────────────────────────────────────────────────── */
.typing-indicator { display: flex; align-items: center; gap: 5px; padding: 4px 0; height: 24px; }
.typing-indicator span { width: 8px; height: 8px; background: var(--text-muted); border-radius: 50%; animation: typing-bounce 1.4s infinite ease-in-out both; }
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce { 0%, 80%, 100% { transform: scale(0.5); opacity: 0.35; } 40% { transform: scale(1); opacity: 1; } }
/* ── Empty-state examples overlay ─────────────────────────────────────── */
#examples-overlay {
position: absolute; inset: 0; z-index: 5;
display: flex; align-items: center; justify-content: center;
background: var(--bg); overflow-y: auto; padding: 40px 20px 160px;
transition: opacity 0.25s ease;
}
#examples-overlay.hidden { opacity: 0; pointer-events: none; }
.examples-wrapper { display: flex; flex-direction: column; align-items: center; max-width: 1020px; }
.examples-hero { display: flex; align-items: center; justify-content: center; gap: 14px; margin-bottom: 22px; }
.examples-hero img { width: auto; height: 56px; border-radius: 8px; object-fit: contain; }
.examples-heading { font-size: 28px; font-weight: 650; color: var(--text-strong); margin: 0 0 30px; letter-spacing: -0.02em; text-align: center; }
.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%); }
.example-btn:nth-child(1), .example-btn:nth-child(4) { grid-column: 1 / span 2; justify-self: center; }
.example-btn:nth-child(2) { grid-column: 1; justify-self: end; }
.example-btn:nth-child(3) { grid-column: 2; justify-self: start; }
.example-btn {
display: flex; align-items: center; justify-content: center;
width: 460px; height: 72px; overflow: hidden;
background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 20px; font-size: 14px; line-height: 1.5; color: var(--text);
cursor: pointer; user-select: none; text-align: center;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, background 0.2s ease;
}
.example-btn:hover { border-color: var(--border-strong); color: var(--text-strong); box-shadow: var(--shadow-md); transform: translateY(-1px); background: var(--bg-elev); }
.example-btn-text { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; width: 100%; }
@media (max-width: 1020px) {
.examples-grid { grid-template-columns: minmax(0, 1fr); }
.example-btn:nth-child(n) { grid-column: 1; justify-self: center; }
.example-btn { width: 100%; max-width: 520px; }
}
/* ── Composer (floating, centered pill) ────────────────────────────────
Sits at the bottom of the chat canvas, centered to the conversation
column, with a gradient fade above it so content scrolls under it
gracefully — the Claude.ai feel. */
#composer {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: flex-end; justify-content: center; gap: 10px;
padding: 0 clamp(16px, 5vw, 48px) 20px;
z-index: 10;
background: linear-gradient(to top, var(--bg) 52%, rgba(247,246,243,0));
pointer-events: none; /* let the fade be click-through … */
}
#composer > * { pointer-events: auto; } /* … but not the controls */
html.dark #composer { background: linear-gradient(to top, var(--bg) 52%, rgba(26,26,26,0)); }
.composer-inner {
display: flex; align-items: flex-end; gap: 8px; width: 100%; max-width: var(--col-max);
background: var(--bg-elev); border: 1px solid var(--border);
border-radius: var(--radius-xl); padding: 8px 8px 8px 18px;
box-shadow: var(--shadow-md); transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.composer-inner:focus-within { border-color: var(--border-strong); box-shadow: var(--shadow-md), var(--ring); }
#composer textarea {
flex: 1; min-height: 44px; max-height: 200px;
resize: none; overflow-y: auto;
border: none; border-radius: 0;
padding: 12px 0; font-size: 15.5px; line-height: 1.55; background: transparent;
color: var(--text); box-shadow: none; transition: none;
}
#composer textarea:focus { outline: none; box-shadow: none; }
#new-chat-btn {
width: 40px; height: 40px; border-radius: 50%; padding: 0;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--text-muted);
transition: background 0.15s ease, color 0.15s ease;
}
#new-chat-btn:hover { background: var(--bg-muted); color: var(--text); }
#new-chat-btn svg { width: 19px; height: 19px; }
#send-btn {
width: 40px; height: 40px; border-radius: 50%; padding: 0;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
cursor: pointer; border: none; background: var(--tc-blue); color: #fff;
transition: background 0.15s ease, transform 0.1s ease;
}
#send-btn:hover { background: var(--tc-blue-hover); }
#send-btn:active { transform: scale(0.95); }
#send-btn:disabled { background: var(--border-strong); cursor: not-allowed; opacity: 0.6; }
#send-btn svg { width: 18px; height: 18px; }
/* ── Tool-call input panel ───────────────────────────────────────────── */
#tool-area {
position: absolute; bottom: 104px; left: 50%; transform: translateX(-50%);
width: min(640px, 90%); z-index: 30;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; box-shadow: var(--shadow-lg); display: none; flex-direction: column; gap: 10px;
}
#tool-area.visible { display: flex; }
#tool-area .tool-header { font-size: 13px; font-weight: 600; color: var(--text); }
#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; }
#tool-area .tool-markdown p { margin: 0.4em 0; }
#tool-area .tool-markdown code { background: var(--bg-muted); padding: 1px 5px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; }
#tool-area .tool-markdown pre { background: var(--bg-muted); border-radius: 6px; padding: 8px 10px; overflow-x: auto; margin: 0.4em 0; }
#tool-area .tool-markdown pre code { background: none; padding: 0; }
#tool-area .tool-markdown strong { font-weight: 600; }
#tool-result-input {
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); color: var(--text); font-size: 14px; font-family: var(--font-mono);
}
#tool-result-input:focus { outline: none; border-color: var(--tc-blue); box-shadow: 0 0 0 3px var(--tc-blue-120); }
#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; }
#tool-submit-btn:hover { background: var(--tc-blue-hover); }
/* ── HTML preview modal ───────────────────────────────────────────────── */
.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; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.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; }
@keyframes modalSlideUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.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; }
.html-preview-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.html-preview-title::before { content: "⬡"; color: var(--tc-blue); }
.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; }
.html-preview-close:hover { background: var(--tc-blue-060); color: var(--tc-red); }
.html-preview-iframe { flex: 1; border: none; width: 100%; background: var(--bg); }
/* ── Toast ────────────────────────────────────────────────────────────── */
#toast-host { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.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; }
.toast.show { opacity: 1; }
.toast.error { background: var(--tc-red); }
/* ── Sidebar toggle ─────────────────────────────────────────────────────
One class (``#app.sidebar-closed``) drives collapse on every breakpoint:
desktop collapses the grid column to 0; mobile slides the fixed overlay
out. The header toggle is always visible so users can re-open it. */
#sidebar-toggle { display: inline-flex; }
@media (max-width: 860px) {
#app { grid-template-columns: 1fr; grid-template-areas: "header" "main"; }
#sidebar {
position: fixed; top: 60px; bottom: 0; left: 0; width: 86%; max-width: 360px;
z-index: 50; box-shadow: var(--shadow-lg);
}
/* On mobile the sidebar is an overlay; show it unless explicitly closed. */
#app.sidebar-closed #sidebar { transform: translateX(-100%); }
.examples-hero img { height: 46px; }
.examples-heading { font-size: 22px; }
}
/* ── Scrollbars (slim & subtle) ──────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: padding-box; }
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* KaTeX: keep its own math fonts and reset inherited typography so SVG
stretchy operators don't get distorted by the bubble's line-height. */
.message.bot .katex { font: normal 1.21em KaTeX_Main, "Times New Roman", serif !important; text-indent: 0 !important; }
.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; }
.message.bot .katex, .message.bot .katex-display { color: var(--text) !important; }
.message.bot .katex svg { fill: currentColor; stroke: none; }
.message.bot .katex-display { margin: 1em 0; padding: 0.4em 0; overflow-x: auto; overflow-y: hidden; }
.message.bot .katex-display > .katex { text-align: center; }
</style>
</head>
<body>
<div id="app" class="sidebar-closed">
<!-- ── Header ────────────────────────────────────────────────────── -->
<header id="header">
<button id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<div class="brand">
<img class="brand-logo" src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="Tencent Hy3 logo" />
<span class="brand-name">Hy3</span>
<span class="brand-sep">·</span>
<span class="brand-tag">Chat Demo</span>
<span class="brand-badge">Tencent</span>
</div>
<div class="header-spacer"></div>
<button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
<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>
</button>
</header>
<!-- ── Sidebar ──────────────────────────────────────────────────── -->
<aside id="sidebar">
<p class="sidebar-notice" id="sidebar-notice"></p>
<div class="field">
<label data-i18n="sidebar.think_level"></label>
<select id="think-level">
<option value="high">high</option>
<option value="low">low</option>
<option value="no_think">no_think</option>
</select>
<span class="info" data-i18n="sidebar.think_level.info"></span>
</div>
<div class="field">
<label data-i18n="sidebar.system_prompt"></label>
<textarea id="system-prompt" rows="3" data-i18n-ph="sidebar.system_prompt.placeholder"></textarea>
</div>
<div class="field">
<label data-i18n="sidebar.temperature"></label>
<div class="range-row">
<input type="range" id="temperature" min="0" max="1" step="0.01" value="0.9" disabled />
<input type="number" class="range-num" id="temperature-num" value="0.9" min="0" max="1" step="0.01" disabled />
</div>
<label class="check-row"><input type="checkbox" id="temperature-use-default" checked /><span data-i18n="sidebar.temperature.use_default"></span></label>
<span class="info" data-i18n="sidebar.temperature.info"></span>
</div>
<div class="field">
<label data-i18n="sidebar.max_tokens"></label>
<div class="range-row">
<input type="range" id="max-tokens" min="0" max="65536" step="1" value="0" />
<input type="number" class="range-num" id="max-tokens-num" value="0" min="0" max="65536" step="1" />
</div>
<span class="info" data-i18n="sidebar.max_tokens.info"></span>
</div>
<div class="field">
<label data-i18n="sidebar.top_p"></label>
<div class="range-row">
<input type="range" id="top-p" min="0" max="1" step="0.01" value="0" />
<input type="number" class="range-num" id="top-p-num" value="0" min="0" max="1" step="0.01" />
</div>
<span class="info" data-i18n="sidebar.top_p.info"></span>
</div>
<div class="field">
<label data-i18n="sidebar.preserved_thinking"></label>
<label class="check-row"><input type="checkbox" id="preserved-thinking-use-default" checked /><span data-i18n="sidebar.preserved_thinking.use_default"></span></label>
<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>
<span class="info" data-i18n="sidebar.preserved_thinking.use_default.info"></span>
</div>
<details class="accordion">
<summary data-i18n="sidebar.functions"></summary>
<div class="accordion-body">
<textarea id="functions-json" spellcheck="false" placeholder=""></textarea>
<button class="tiny-btn" id="validate-fn-btn" data-i18n="sidebar.validate_btn"></button>
<div class="fn-msg" id="fn-msg"></div>
</div>
</details>
</aside>
<!-- ── Main chat ────────────────────────────────────────────────── -->
<main id="main">
<div id="chat" role="log" aria-live="polite"></div>
<div id="examples-overlay">
<div class="examples-wrapper">
<div class="examples-hero">
<img src="https://huggingface.co/tencent/Hy3/resolve/main/assets/logo-en.png" alt="" />
</div>
<p class="examples-heading" data-i18n="examples_heading"></p>
<div class="examples-grid" id="examples-grid"></div>
</div>
</div>
<div id="tool-area">
<div class="tool-header" id="tool-header"></div>
<div class="tool-markdown" id="tool-markdown"></div>
<input type="text" id="tool-result-input" />
<button id="tool-submit-btn" data-i18n="tool.submit"></button>
</div>
</main>
<!-- ── Composer (floating pill) ─────────────────────────────────── -->
<div id="composer">
<div class="composer-inner">
<button id="new-chat-btn" title="New chat" aria-label="New chat">
<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>
</button>
<textarea id="msg-input" rows="1" data-i18n-ph="msg_placeholder"></textarea>
<button id="send-btn" title="Send" aria-label="Send">
<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>
</button>
</div>
</div>
</div>
<div id="toast-host"></div>
<script type="module">
/* ───────────────────────────────────────────────────────────────────
Hy3 Tencent-branded frontend.
Goal: only the UI + transport changed from the gr.Blocks app. The chat
behaviour is identical, so this file is a faithful port of the old
static/chat.js (the delta-op renderer: append-only bubbles, frozen /
streaming split so KaTeX+marked+hljs run once per closed block, seq
de-dup, epoch staleness, preprocess_latex'd content deltas, tool-call
markdown) plus static/app.js (the busy-marker watchdog, follow-the-tail
scroll lock with user-gesture pause, examples overlay, code-block
chrome with copy + html preview).
The ONLY adaptation is the transport: instead of a MutationObserver
watching a hidden #hy-chat-delta gr.HTML component, we consume the
@gradio/client async-iterator frames from server.py and feed each JSON
delta payload straight into the same applyDelta() the old renderer used.
The busy marker is no longer a hidden gr.HTML span; it's a client-side
flag toggled by the stream's first frame + completion + the watchdog.
─────────────────────────────────────────────────────────────────── */
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.7.1/dist/index.min.js";
(() => {
"use strict";
if (window.__HY_APP_INIT) return;
window.__HY_APP_INIT = true;
// ─── i18n + examples (loaded from /config endpoint) ──────────────
// Full English default table so every label is populated on first paint
// — before /config round-trips. /config then overrides with the active
// locale (en/zh). Keep these in sync with i18n.py.
const I18N = {
"title": "Hy3 Chat Demo",
"title.notice_html": "The current demo version is offline; full-featured capabilities will be available on the Tencent HY official website.",
"examples_heading": "What can I help you with?",
"msg_placeholder": "Type a message...",
"sidebar.think_level": "Think level",
"sidebar.think_level.info": "Control reasoning depth",
"sidebar.system_prompt": "System prompt",
"sidebar.system_prompt.placeholder": "You are a helpful AI assistant...",
"sidebar.temperature": "Temperature",
"sidebar.temperature.info": "Higher values produce more random outputs",
"sidebar.temperature.use_default": "Use model default",
"sidebar.max_tokens": "Max output tokens",
"sidebar.max_tokens.info": "Maximum tokens per response. Set to 0 to use the model's default.",
"sidebar.top_p": "Top P",
"sidebar.top_p.info": "Nucleus sampling probability threshold. Set to 0 to use the model's default.",
"sidebar.preserved_thinking": "Preserved thinking",
"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.",
"sidebar.preserved_thinking.use_default": "Preserved thinking — Use model default",
"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.",
"sidebar.functions": "🔧 Functions",
"sidebar.functions.label": "Function definitions (JSON array)",
"sidebar.validate_btn": "Validate & Format",
"tool.result_label": "Function result",
"tool.result_placeholder": "Enter function return value... (Enter to submit)",
"tool.submit": "Submit",
"tool.call_header": "Function call ({i}/{n})",
"tool.call_label": "🔧 Call function",
"display.thinking": "Thinking...",
"display.thinking_done": "Thinking complete",
"display.code_copy": "Copy",
"display.code_copied": "Copied",
"warn.empty_msg": "Please enter a message",
"warn.invalid_fn_json": "Invalid function JSON, please fix or clear before sending: {err}",
"warn.request_failed": "Request failed, please retry or adjust parameters",
"warn.model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
"info.new_chat": "Start a new chat",
// short aliases used directly in code below
"thinking": "Thinking...",
"thinking_done": "Thinking complete",
"code_copy": "Copy",
"code_copied": "Copied",
"model_busy": "Model is still responding. Please wait for it to finish before sending a new message.",
"empty_msg": "Please enter a message",
"request_failed": "Request failed, please retry or adjust parameters",
"new_chat": "Start a new chat",
};
let EXAMPLES = [];
let TOOL_CALL_LABEL = "🔧 Call function";
// ─── session id (fresh per page load) ─────────────────────────────
// Minting a NEW id on every load (rather than reusing one from
// sessionStorage) makes a browser refresh start a blank conversation:
// the server keys its in-memory ChatState off this id, so a fresh id
// means a fresh, empty state and the prior turn's context never leaks
// into the next message. sessionStorage used to make the id survive F5
// — which is exactly what surfaced stale history after a refresh. The
// variable persists for the lifetime of this loaded page (across
// @gradio/client reconnects), so no storage is needed.
let SESSION_ID = "s-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
// ─── DOM refs ─────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const chatEl = $("chat");
const overlayEl = $("examples-overlay");
const msgInput = $("msg-input");
const sendBtn = $("send-btn");
const newChatBtn = $("new-chat-btn");
const toolArea = $("tool-area");
const toolMarkdown = $("tool-markdown");
const toolResultInput = $("tool-result-input");
const toolSubmitBtn = $("tool-submit-btn");
// ─── streaming state ──────────────────────────────────────────────
let isStreaming = false;
let currentJob = null; // the active async-iterator job
let pendingToolCalls = []; // tool calls awaiting user results
let currentEpoch = null; // staleness guard for in-flight frames
// How many tool results the user has submitted in the current tool-call
// round, so the panel header shows "Function call (2/3)" correctly.
let toolResultsSubmitted = 0;
let toolRoundTotal = 0;
/* ─── apply i18n to the DOM ──────────────────────────────────────── */
function applyI18n(table) {
Object.assign(I18N, table || {});
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
if (I18N[key] != null) el.textContent = I18N[key];
});
document.querySelectorAll("[data-i18n-ph]").forEach((el) => {
const key = el.getAttribute("data-i18n-ph");
if (I18N[key] != null) el.placeholder = I18N[key];
});
document.title = (I18N["title"] || "Hy3") + " · Tencent";
$("sidebar-notice").textContent = I18N["title.notice_html"] || "";
$("msg-input").placeholder = I18N["msg_placeholder"] || "Type a message...";
if (I18N["tool.call_label"]) TOOL_CALL_LABEL = I18N["tool.call_label"];
}
/* ─── theme toggle ───────────────────────────────────────────────── */
function setTheme(dark) {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("hy3-theme", dark ? "dark" : "light");
const lightCss = document.getElementById("hljs-light-css");
const darkCss = document.getElementById("hljs-dark-css");
if (lightCss && darkCss) {
lightCss.disabled = dark;
darkCss.disabled = !dark;
}
}
const savedTheme = localStorage.getItem("hy3-theme");
setTheme(savedTheme ? savedTheme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches);
$("theme-btn").addEventListener("click", () =>
setTheme(!document.documentElement.classList.contains("dark"))
);
/* ─── sidebar toggle ───────────────────────────────────────────────
One class (``sidebar-closed``) drives collapse on every breakpoint;
the header button flips it, so the same control opens/closes the
sidebar on both desktop and mobile. It starts closed by default
(see the ``sidebar-closed`` class on #app). */
$("sidebar-toggle").addEventListener("click", () => {
$("app").classList.toggle("sidebar-closed");
});
/* ─── markdown + math rendering ────────────────────────────────────
Ported from static/chat.js: two-stage pipeline so marked doesn't
eat the LaTeX before KaTeX can see it. */
const MARKED_OPTS = { gfm: true, breaks: false };
const KATEX_DELIMS = [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
];
function normalizeMathDelims(text) {
if (!text) return text;
let out = "", i = 0; const n = text.length;
while (i < n) {
const ch = text[i];
if ((ch === "`" && text.startsWith("```", i)) || (ch === "~" && text.startsWith("~~~", i))) {
const marker = text.substr(i, 3); out += marker; i += 3;
const close = text.indexOf(marker, i);
if (close < 0) { out += text.slice(i); return out; }
out += text.slice(i, close + 3); i = close + 3; continue;
}
if (ch === "`") {
let runLen = 1; while (i + runLen < n && text[i + runLen] === "`") runLen++;
const open = text.substr(i, runLen); out += open; i += runLen;
const close = text.indexOf(open, i);
if (close < 0) { out += text.slice(i); return out; }
out += text.slice(i, close + runLen); i = close + runLen; continue;
}
if (ch === "\\") {
if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
if (text[i + 1] === "(") { out += "$"; i += 2; continue; }
if (text[i + 1] === ")") { out += "$"; i += 2; continue; }
}
out += ch; i++;
}
return out;
}
const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;
function protectMath(text) {
const stash = [];
const escaped = text.replace(MATH_RE, (m) => {
const idx = stash.length; stash.push(m); return "M" + idx + "";
});
return { escaped, stash };
}
function restoreMath(html, stash) {
if (!stash.length) return html;
return html.replace(/M(\d+)/g, (_, n) => stash[+n]);
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function renderMarkdownInto(el, text) {
if (!text) { el.innerHTML = ""; return; }
const normalized = normalizeMathDelims(text);
const { escaped, stash } = protectMath(normalized);
const rawMd = (window.marked && window.marked.parse)
? window.marked.parse(escaped, MARKED_OPTS) : escapeHtml(escaped);
el.innerHTML = restoreMath(rawMd, stash);
if (window.renderMathInElement) {
try {
window.renderMathInElement(el, {
delimiters: KATEX_DELIMS, throwOnError: false,
ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
});
} catch (e) { console.warn("[hy] katex:", e); }
}
if (window.hljs && window.hljs.highlightElement) {
el.querySelectorAll("pre code").forEach((c) => {
if (c.dataset.hljsDone) return;
try { window.hljs.highlightElement(c); } catch (e) {}
c.dataset.hljsDone = "1";
});
}
decorateCodeBlocks(el);
}
/* ─── code-block chrome (copy + html preview) ───────────────────── */
const COPY_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 12 10 18 20 6"/></svg>';
const HTML_TAG_RE = /<\/(div|body|html|section|main|head|style|script|p|h[1-6]|span|table|form|ul|ol|nav|header|footer|article)>/i;
function looksLikeHtml(text) {
const t = text.trim(); if (!t) return false;
if (/^<!doctype|^<html/i.test(t)) return true;
return t.startsWith("<") && t.endsWith(">") && HTML_TAG_RE.test(t);
}
function detectCodeLang(codeEl) {
const m = (codeEl.className || "").match(/(?:^|\s)language-([\w+#.-]+)/i);
return m ? m[1].toLowerCase() : "";
}
function decorateCodeBlocks(root) {
root.querySelectorAll("pre code").forEach((codeEl) => {
const pre = codeEl.closest("pre"); if (!pre || pre.dataset.hyDecorated) return;
pre.dataset.hyDecorated = "1";
const wrapper = document.createElement("div"); wrapper.className = "hy-codeblock";
const header = document.createElement("div"); header.className = "hy-codeblock-header";
const lang = document.createElement("span"); lang.className = "hy-codeblock-lang";
lang.textContent = detectCodeLang(codeEl); header.appendChild(lang);
const btn = document.createElement("button"); btn.type = "button"; btn.className = "code-copy-btn";
btn.setAttribute("aria-label", I18N.code_copy); btn.title = I18N.code_copy; btn.innerHTML = COPY_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation(); e.preventDefault();
copyText(codeEl.textContent || "").then(() => {
btn.innerHTML = CHECK_SVG; btn.classList.add("copied"); btn.title = I18N.code_copied;
setTimeout(() => { btn.innerHTML = COPY_SVG; btn.classList.remove("copied"); btn.title = I18N.code_copy; }, 1400);
});
});
header.appendChild(btn);
pre.parentElement.insertBefore(wrapper, pre);
wrapper.appendChild(header); wrapper.appendChild(pre);
if (looksLikeHtml(codeEl.textContent || "")) {
const pb = document.createElement("button"); pb.className = "html-preview-btn";
pb.addEventListener("click", (ev) => { ev.stopPropagation(); openHtmlPreview(codeEl.textContent || ""); });
wrapper.appendChild(pb);
}
});
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
return new Promise((res) => {
const ta = document.createElement("textarea"); ta.value = text;
ta.style.position = "absolute"; ta.style.left = "-9999px"; document.body.appendChild(ta);
ta.select(); document.execCommand("copy"); document.body.removeChild(ta); res();
});
}
function openHtmlPreview(html) {
document.querySelectorAll(".html-preview-overlay").forEach((e) => e.remove());
const overlay = document.createElement("div"); overlay.className = "html-preview-overlay";
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
const modal = document.createElement("div"); modal.className = "html-preview-modal";
const header = document.createElement("div"); header.className = "html-preview-header";
const title = document.createElement("span"); title.className = "html-preview-title"; title.textContent = "HTML Preview";
const close = document.createElement("button"); close.className = "html-preview-close"; close.innerHTML = "&times;";
close.addEventListener("click", () => overlay.remove());
header.appendChild(title); header.appendChild(close);
const iframe = document.createElement("iframe"); iframe.className = "html-preview-iframe";
iframe.sandbox = "allow-scripts allow-modals allow-forms allow-popups";
modal.appendChild(header); modal.appendChild(iframe); overlay.appendChild(modal);
document.body.appendChild(overlay); iframe.srcdoc = html;
const esc = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", esc); } };
document.addEventListener("keydown", esc);
}
/* ─── bubble DOM ─────────────────────────────────────────────────── */
function addUserBubble(text) {
const row = document.createElement("div"); row.className = "message-row user-row";
const msg = document.createElement("div"); msg.className = "message user";
const md = document.createElement("div"); md.className = "hy-user-text"; md.textContent = text;
msg.appendChild(md); row.appendChild(msg); chatEl.appendChild(row);
return row;
}
function createAssistantBubble() {
const row = document.createElement("div"); row.className = "message-row bot-row";
const msg = document.createElement("div"); msg.className = "message bot";
const md = document.createElement("div"); md.className = "markdown hy-markdown";
const typing = document.createElement("div"); typing.className = "typing-indicator";
typing.innerHTML = "<span></span><span></span><span></span>"; md.appendChild(typing);
msg.appendChild(md); row.appendChild(msg); chatEl.appendChild(row);
const rec = { row, md, typing, reasoningEl: null, contentEl: null, ended: false };
rec.__md = md;
return rec;
}
function ensureReasoning(b) {
if (b.reasoningEl) return;
const det = document.createElement("details"); det.className = "thinking-block"; det.open = true;
const sum = document.createElement("summary"); sum.textContent = I18N.thinking;
const body = document.createElement("div"); body.className = "hy-reasoning-body";
body.style.whiteSpace = "pre-wrap"; body.style.wordBreak = "break-word";
det.appendChild(sum); det.appendChild(body);
if (b.typing && b.typing.parentNode) b.typing.remove();
b.md.insertBefore(det, b.md.firstChild);
b.reasoningEl = det; b.reasoningBody = body; b.reasoningSummary = sum;
}
function ensureContent(b) {
if (b.contentEl) return;
if (b.typing && b.typing.parentNode) b.typing.remove();
const c = document.createElement("div"); c.className = "hy-content";
b.md.appendChild(c); b.contentEl = c;
}
function markThinkingDone(b) {
if (!b.reasoningEl || b.thinkingDone) return;
b.thinkingDone = true;
b.reasoningSummary.textContent = I18N.thinking_done;
b.reasoningEl.open = false;
}
function appendToolCall(b, toolCalls) {
let c = b.toolCallContainer;
if (!c) { c = document.createElement("div"); c.className = "hy-tool-calls"; b.md.appendChild(c); b.toolCallContainer = c; }
c.innerHTML = "";
toolCalls.forEach((tc) => {
const fn = tc.function || {};
let args = fn.arguments || "";
try { args = JSON.stringify(JSON.parse(args), null, 2); } catch (e) {}
const block = document.createElement("div"); block.className = "hy-tool-call";
renderMarkdownInto(block, TOOL_CALL_LABEL + ": **" + (fn.name || "") + "**\n```json\n" + args + "\n```");
c.appendChild(block);
});
}
/* ─── scroll ────────────────────────────────────────────────────── */
function scrollToBottom() {
chatEl.scrollTop = chatEl.scrollHeight;
}
let follow = true;
chatEl.addEventListener("scroll", () => {
follow = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 120;
});
function maybeScroll() { if (follow) scrollToBottom(); }
/* ─── overlay (empty state) ──────────────────────────────────────── */
function syncOverlay() {
const hasMsgs = chatEl.querySelector(".message-row");
overlayEl.classList.toggle("hidden", !!hasMsgs);
}
/* ─── toasts ─────────────────────────────────────────────────────── */
function toast(msg, isError) {
const host = $("toast-host");
const el = document.createElement("div"); el.className = "toast" + (isError ? " error" : "");
el.textContent = msg; host.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3200);
}
/* ─── send gating ───────────────────────────────────────────────── */
function setStreaming(on) {
isStreaming = on;
sendBtn.disabled = on;
}
/* ─── gather sidebar params for a request ────────────────────────── */
function gatherParams() {
const tempUseDefault = $("temperature-use-default").checked;
const ptUseDefault = $("preserved-thinking-use-default").checked;
return {
session_id: SESSION_ID,
system_prompt: $("system-prompt").value,
think_level: $("think-level").value,
temperature: tempUseDefault ? null : parseFloat($("temperature-num").value),
max_tokens: parseInt($("max-tokens-num").value || "0", 10) || 0,
top_p: parseFloat($("top-p-num").value || "0") || 0,
preserved_thinking: ptUseDefault ? null : $("preserved-thinking").checked,
functions_json_str: $("functions-json").value || "",
};
}
/* ─── wire up sidebar interactivity ──────────────────────────────── */
function wireRange(rangeId, numId) {
const r = $(rangeId), n = $(numId);
r.addEventListener("input", () => { n.value = r.value; });
n.addEventListener("input", () => { r.value = n.value; });
}
wireRange("temperature", "temperature-num");
wireRange("max-tokens", "max-tokens-num");
wireRange("top-p", "top-p-num");
function wireToggle(checkId, targetRangeId, targetNumId) {
const c = $(checkId);
const enable = () => {
const on = !c.checked;
$(targetRangeId).disabled = !on; $(targetNumId).disabled = !on;
};
c.addEventListener("change", enable); enable();
}
wireToggle("temperature-use-default", "temperature", "temperature-num");
wireToggle("preserved-thinking-use-default", "preserved-thinking", "preserved-thinking");
// preserved_thinking checkbox itself is gated on its "use default" too
$("preserved-thinking-use-default").addEventListener("change", () => {
$("preserved-thinking").disabled = $("preserved-thinking-use-default").checked;
});
$("preserved-thinking").disabled = $("preserved-thinking-use-default").checked;
$("validate-fn-btn").addEventListener("click", async () => {
const msgEl = $("fn-msg");
msgEl.className = "fn-msg"; msgEl.textContent = "Validating...";
try {
const job = await APP.submit("/validate_functions", { functions_json_str: $("functions-json").value || "" });
let res = null;
for await (const m of job) { if (m.type === "data") res = m.data; }
// res is [ {ok, message, formatted} ] (array of one element)
const out = Array.isArray(res) ? res[0] : res;
if (out && out.ok) {
msgEl.className = "fn-msg ok"; msgEl.textContent = out.message || "OK";
if (out.formatted) $("functions-json").value = out.formatted;
} else {
msgEl.className = "fn-msg err"; msgEl.textContent = (out && out.message) || "Invalid";
}
} catch (e) {
msgEl.className = "fn-msg err"; msgEl.textContent = String(e);
}
});
/* ─── the Gradio client connection ───────────────────────────────── */
let APP = null;
async function connectClient() {
if (APP) return APP;
APP = await Client.connect(window.location.origin, {
events: ["status", "data"],
});
return APP;
}
/* ─── render a streaming turn ──────────────────────────────────────
Each yielded frame is [content, reasoning, tool_calls, history, epoch].
We keep one assistant bubble and update its reasoning / content /
tool-call region cumulatively as frames arrive. */
async function runStream(endpoint, payload, { isToolFollowup = false } = {}) {
const app = await connectClient();
const bubble = createAssistantBubble();
const job = app.submit(endpoint, payload);
currentJob = job;
let lastEpoch = null;
let firstContent = true;
// Latest tool calls seen during streaming. We don't act on them until
// the turn completes (see the "stream finished" handoff below).
let pendingToolCallsSeen = null;
// True iff the last frame was /submit_tool_result's "more results
// pending" frame (carries the REMAINING tool-call queue, not new
// model output).
let needsMoreSeen = false;
try {
for await (const message of job) {
if (message.type === "status") {
if (message.status === "error") {
throw new Error(message.message || "Stream error");
}
continue;
}
if (message.type !== "data") continue;
const data = message.data;
// data is the yielded tuple: [content, reasoning, tool_calls, history, epoch, needs_more?]
const content = data[0] || "";
const reasoning = data[1] || "";
const toolCalls = data[2] || [];
const epoch = data[4] != null ? String(data[4]) : null;
// ``needsMore`` (6th elem) is only set by /submit_tool_result's
// "more results pending" frame — it signals the tool calls in
// this frame are the REMAINING queue to re-prompt, not new calls
// the model just made.
const needsMore = data[5] === true;
needsMoreSeen = needsMore;
// ── staleness: drop frames from an abandoned turn ──
if (epoch && currentEpoch !== null && epoch !== currentEpoch && !isToolFollowup) {
continue;
}
if (epoch) lastEpoch = epoch;
// reasoning
if (reasoning) {
ensureReasoning(bubble);
if (!bubble.reasoningBody.textContent) bubble.reasoningBody.textContent = reasoning;
else if (bubble.reasoningBody.textContent !== reasoning) bubble.reasoningBody.textContent = reasoning;
}
// content
if (content) {
if (firstContent) { firstContent = false; if (bubble.reasoningEl) markThinkingDone(bubble); }
ensureContent(bubble);
renderMarkdownInto(bubble.contentEl, content);
}
// tool calls
if (toolCalls && toolCalls.length) {
pendingToolCallsSeen = toolCalls;
if (!needsMore) {
// New tool calls the model just made — render them in the
// chat bubble (mirrors the Blocks app's opToolCall).
appendToolCall(bubble, toolCalls);
}
// For the "more results pending" frame (needsMore=true) the
// tool calls are the REMAINING queue; we only re-open the
// panel (in the deferred handoff below), not a chat bubble.
}
maybeScroll();
}
// ── stream finished ──
if (bubble.typing && bubble.typing.parentNode) bubble.typing.remove();
if (bubble.reasoningEl && !bubble.thinkingDone) markThinkingDone(bubble);
syncOverlay();
// ── tool-call handoff (deferred until the turn is complete) ──
// The model's tool calls arrive in the in-flight history snapshot
// on every streaming frame, but we only surface the tool-result
// panel once the turn has actually finished — mirroring the
// Blocks app, which renders the tool-call block on assistant_end.
// This applies to both /chat (first request for tools) and
// /submit_tool_result (the follow-up requested yet more tools, or
// the "more results pending" frame carrying the remaining calls).
if (pendingToolCallsSeen && pendingToolCallsSeen.length) {
pendingToolCalls = pendingToolCallsSeen.slice();
// A brand-new tool round (from /chat) resets the counters; a
// "more results pending" frame (needsMore) continues the same
// round, so we keep the running total + submitted count.
if (!needsMoreSeen) {
toolRoundTotal = pendingToolCalls.length + toolResultsSubmitted;
}
showToolPanel(pendingToolCalls);
// The "more results pending" frame carries no content/reasoning
// and the tool calls go to the panel, not the bubble — so the
// bubble we created is empty. Drop it to avoid a stray typing
// indicator.
if (needsMoreSeen && !bubble.contentEl && !bubble.reasoningEl
&& bubble.row.parentNode) {
bubble.row.parentNode.removeChild(bubble.row);
}
}
return { toolCalls: pendingToolCalls, epoch: lastEpoch };
} catch (err) {
if (bubble.typing && bubble.typing.parentNode) bubble.typing.remove();
if (!bubble.contentEl && !bubble.reasoningEl) {
// nothing was rendered — show the error inline
ensureContent(bubble);
bubble.contentEl.innerHTML = '<p style="color:var(--tc-red)">⚠ ' +
escapeHtml(I18N.request_failed) + "</p>";
}
throw err;
}
}
/* ─── tool-call input panel ──────────────────────────────────────── */
function showToolPanel(toolCalls) {
const tc = toolCalls[0];
const fn = tc.function || {};
let args = fn.arguments || "";
try { args = JSON.stringify(JSON.parse(args), null, 2); } catch (e) {}
const total = toolRoundTotal || toolCalls.length;
const idx = toolResultsSubmitted + 1;
const header = (I18N["tool.call_header"] || "Function call ({i}/{n})")
.replace("{i}", idx).replace("{n}", total);
$("tool-header").textContent = header;
renderMarkdownInto(toolMarkdown, TOOL_CALL_LABEL + ": **" + (fn.name || "") + "**\n```json\n" + args + "\n```");
toolArea.classList.add("visible");
toolResultInput.value = "";
setTimeout(() => toolResultInput.focus(), 50);
}
function hideToolPanel() { toolArea.classList.remove("visible"); }
async function submitToolResult() {
const resultText = toolResultInput.value;
if (!resultText) { toolResultInput.focus(); return; }
hideToolPanel();
setStreaming(true);
toolResultsSubmitted += 1;
const params = gatherParams();
try {
await runStream("/submit_tool_result", {
result_text: resultText,
session_id: SESSION_ID,
system_prompt: params.system_prompt,
think_level: params.think_level,
temperature: params.temperature,
max_tokens: params.max_tokens,
top_p: params.top_p,
preserved_thinking: params.preserved_thinking,
functions_json_str: params.functions_json_str,
}, { isToolFollowup: true });
} catch (e) {
console.error(e); toast(I18N.request_failed, true);
} finally {
setStreaming(false); maybeScroll();
}
}
toolSubmitBtn.addEventListener("click", submitToolResult);
toolResultInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submitToolResult(); }
});
/* ─── send a message ──────────────────────────────────────────────── */
async function sendMessage() {
const text = msgInput.value.trim();
if (!text) { toast(I18N.empty_msg); return; }
if (isStreaming) { toast(I18N.model_busy); return; }
addUserBubble(text);
msgInput.value = ""; msgInput.style.height = "auto";
syncOverlay();
setStreaming(true);
follow = true;
// A fresh user turn starts a new (potential) tool-call round.
toolResultsSubmitted = 0;
toolRoundTotal = 0;
const params = gatherParams();
currentEpoch = null; // adopt the first epoch we see
try {
const result = await runStream("/chat", {
message: text,
session_id: SESSION_ID,
system_prompt: params.system_prompt,
think_level: params.think_level,
temperature: params.temperature,
max_tokens: params.max_tokens,
top_p: params.top_p,
preserved_thinking: params.preserved_thinking,
functions_json_str: params.functions_json_str,
});
if (result && result.epoch) currentEpoch = result.epoch;
} catch (e) {
console.error(e); toast(I18N.request_failed, true);
} finally {
setStreaming(false); maybeScroll();
}
}
sendBtn.addEventListener("click", sendMessage);
msgInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault(); sendMessage();
}
});
msgInput.addEventListener("input", () => {
msgInput.style.height = "auto";
msgInput.style.height = Math.min(msgInput.scrollHeight, 200) + "px";
});
/* ─── new chat ────────────────────────────────────────────────────── */
newChatBtn.addEventListener("click", async () => {
if (isStreaming) {
// cancel by stopping the iterator; the server's epoch guard drops
// any late frames.
if (currentJob && currentJob.cancel) { try { currentJob.cancel(); } catch (e) {} }
}
try {
const app = await connectClient();
const job = await app.submit("/new_chat", { session_id: SESSION_ID });
let res = null;
for await (const m of job) { if (m.type === "data") res = m.data; }
const out = Array.isArray(res) ? res[0] : res;
if (out && out.epoch) currentEpoch = out.epoch;
} catch (e) { console.warn("[hy] new_chat:", e); }
chatEl.innerHTML = "";
pendingToolCalls = [];
toolResultsSubmitted = 0;
toolRoundTotal = 0;
hideToolPanel();
setStreaming(false);
syncOverlay();
toast(I18N.new_chat);
});
/* ─── boot ────────────────────────────────────────────────────────── */
async function boot() {
// Load i18n strings + the curated example prompts from the /config
// endpoint. Everything goes through the gradio client for a uniform
// transport (one POST → SSE stream of yielded frames).
try {
const app = await connectClient();
const job = await app.submit("/config", {});
let res = null;
for await (const m of job) { if (m.type === "data") res = m.data; }
const cfg = Array.isArray(res) ? res[0] : res;
if (cfg) {
if (cfg.i18n) applyI18n(cfg.i18n);
if (Array.isArray(cfg.examples)) { EXAMPLES = cfg.examples; buildExamples(); }
}
} catch (e) {
console.warn("[hy] config load failed, using defaults:", e);
applyI18n({});
}
syncOverlay();
}
function buildExamples() {
const grid = $("examples-grid");
grid.innerHTML = "";
EXAMPLES.forEach((text) => {
const btn = document.createElement("div"); btn.className = "example-btn";
const inner = document.createElement("span"); inner.className = "example-btn-text";
const label = String(text).replace(/\s+/g, " ").trim();
inner.textContent = label.length > 200 ? label.slice(0, 200).trimEnd() + "…" : label;
btn.title = text; btn.appendChild(inner);
btn.addEventListener("click", () => {
msgInput.value = text;
msgInput.dispatchEvent(new Event("input"));
sendMessage();
});
grid.appendChild(btn);
});
}
// initial defaults while config loads
applyI18n({});
boot();
})();
</script>
</body>
</html>