BlueSkyXN
Wire chat UI to diffusiongemma runtime
469c33c
Raw
History Blame Contribute Delete
114 kB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>DiffusionGemma Chat · 中文界面</title>
<style>
:root {
color-scheme: light;
--bg: #fafafa;
--surface: #ffffff;
--surface-soft: #f5f5f5;
--surface-raised: #fbfbfb;
--ink: #171717;
--muted: #737373;
--line: #e5e5e5;
--line-strong: #d4d4d4;
--accent: #171717;
--accent-strong: #000000;
--accent-soft: #f3f4f6;
--indigo: #525252;
--warn: #a16207;
--bad: #dc2626;
--good: #2563eb;
--code: #f5f5f5;
--ring: rgba(23, 23, 23, 0.18);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.04);
--header-bg: rgba(255, 255, 255, 0.92);
--link: #404040;
--subtle-ink: #525252;
--primary-ink: #ffffff;
--badge-bg: rgba(255, 255, 255, 0.78);
--good-bg: #eff6ff;
--good-border: rgba(37, 99, 235, 0.22);
--warn-bg: #fffbeb;
--warn-border: rgba(161, 98, 7, 0.22);
--bad-bg: #fef2f2;
--bad-border: rgba(220, 38, 38, 0.22);
--user-bubble: #f4f4f5;
--user-bubble-border: #e4e4e7;
--assistant-bubble: #ffffff;
--composer-bg: rgba(255, 255, 255, 0.96);
--composer-panel-bg: rgba(245, 245, 245, 0.82);
--composer-panel-border: rgba(212, 212, 212, 0.72);
--pill-bg: rgba(255, 255, 255, 0.7);
--pill-text: #404040;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #141414;
--surface: #1f1f1f;
--surface-soft: #2a2a2a;
--surface-raised: #232323;
--ink: #f5f5f5;
--muted: #a3a3a3;
--line: rgba(255, 255, 255, 0.14);
--line-strong: rgba(255, 255, 255, 0.26);
--accent: #ededed;
--accent-strong: #ffffff;
--accent-soft: #262626;
--indigo: #737373;
--warn: #fbbf24;
--bad: #f87171;
--good: #93c5fd;
--code: #242424;
--ring: rgba(245, 245, 245, 0.22);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.22);
--header-bg: rgba(20, 20, 20, 0.88);
--link: #d4d4d4;
--subtle-ink: #d4d4d4;
--primary-ink: #171717;
--badge-bg: rgba(31, 31, 31, 0.78);
--good-bg: rgba(59, 130, 246, 0.14);
--good-border: rgba(147, 197, 253, 0.28);
--warn-bg: rgba(245, 158, 11, 0.12);
--warn-border: rgba(251, 191, 36, 0.26);
--bad-bg: rgba(239, 68, 68, 0.13);
--bad-border: rgba(248, 113, 113, 0.28);
--user-bubble: #2a2a2a;
--user-bubble-border: rgba(255, 255, 255, 0.12);
--assistant-bubble: #1f1f1f;
--composer-bg: rgba(31, 31, 31, 0.94);
--composer-panel-bg: rgba(38, 38, 38, 0.86);
--composer-panel-border: rgba(255, 255, 255, 0.16);
--pill-bg: rgba(20, 20, 20, 0.7);
--pill-text: #d4d4d4;
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
min-height: 100%;
width: 100%;
overflow-x: hidden;
background: var(--bg);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
button,
textarea,
input,
select {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
min-height: 36px;
padding: 8px 12px;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease, color 150ms ease;
}
button:hover {
background: var(--surface-soft);
border-color: var(--line-strong);
box-shadow: var(--shadow);
}
button:focus-visible,
textarea:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--primary-ink);
box-shadow: var(--shadow);
}
button.primary:hover { background: var(--accent-strong); }
header {
border-bottom: 1px solid var(--line);
background: var(--header-bg);
backdrop-filter: blur(16px);
position: sticky;
top: 0;
z-index: 20;
}
.topbar {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, auto);
gap: 24px;
align-items: center;
padding: 16px 0;
min-width: 0;
}
h1 {
margin: 0;
font-size: 21px;
line-height: 1.2;
letter-spacing: 0;
font-weight: 700;
}
h2 {
margin: 0;
font-size: 15px;
line-height: 1.2;
letter-spacing: 0;
}
.brand-copy {
min-width: 0;
}
.eyebrow {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-soft);
color: var(--subtle-ink);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 8px;
padding: 3px 8px;
text-transform: uppercase;
}
.model-line {
margin-top: 5px;
color: var(--muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.source-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: baseline;
margin-top: 7px;
max-width: 920px;
}
.source-label {
color: var(--muted);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.model-source {
color: var(--link);
font-size: 12px;
overflow-wrap: anywhere;
word-break: break-word;
text-decoration: none;
min-width: 0;
}
.model-source:hover {
text-decoration: underline;
}
.top-actions {
display: grid;
gap: 10px;
justify-items: end;
min-width: 0;
}
.language-switch,
.theme-switch {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-soft);
display: inline-grid;
padding: 3px;
gap: 3px;
}
.language-switch {
grid-template-columns: 1fr 1fr;
}
.theme-switch {
grid-template-columns: repeat(3, 1fr);
}
.panel-toggle {
justify-self: end;
min-width: 108px;
}
.language-switch button,
.theme-switch button {
border: 0;
min-height: 30px;
padding: 5px 10px;
background: transparent;
color: var(--muted);
}
.language-switch button.active,
.theme-switch button.active {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-soft);
}
.badge-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--badge-bg);
padding: 5px 9px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
}
.badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.72;
}
.badge.good {
color: var(--good);
border-color: var(--good-border);
background: var(--good-bg);
}
.badge.warn {
color: var(--warn);
border-color: var(--warn-border);
background: var(--warn-bg);
}
.badge.bad {
color: var(--bad);
border-color: var(--bad-border);
background: var(--bad-bg);
}
main {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 20px 0 32px;
}
.app-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(330px, 368px);
gap: 16px;
align-items: start;
}
.app-grid.panel-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.app-grid.panel-collapsed .side-panel {
display: none;
}
.chat-shell {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: var(--shadow);
min-width: 0;
}
.chat-shell {
min-height: calc(100vh - 118px);
display: grid;
grid-template-rows: auto minmax(360px, 1fr) auto;
overflow: hidden;
}
.chat-head {
border-bottom: 1px solid var(--line);
padding: 15px 17px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--surface);
}
.chat-head p {
display: block;
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
overflow-wrap: anywhere;
word-break: break-word;
}
.chat-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.timeline {
padding: 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
background: var(--bg);
}
.empty-state {
max-width: 640px;
margin: auto;
padding: 0 20px;
text-align: center;
}
.empty-state strong {
display: block;
font-size: 24px;
line-height: 1.2;
margin-bottom: 10px;
}
.empty-state span {
display: block;
color: var(--muted);
font-size: 14px;
overflow-wrap: anywhere;
word-break: break-word;
}
.prompt-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
justify-content: center;
}
.prompt-chip {
min-height: 34px;
border-color: var(--line);
background: var(--surface-soft);
color: var(--ink);
font-size: 13px;
font-weight: 650;
}
.message-row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.message-row.user {
justify-content: flex-end;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
color: var(--primary-ink);
background: var(--indigo);
}
.message-row.assistant .avatar { background: var(--accent); }
.message-row.error .avatar { background: var(--bad); }
.message-row.user .avatar { order: 2; background: var(--subtle-ink); }
.bubble-wrap {
min-width: 0;
max-width: min(760px, 82%);
}
.role-label {
color: var(--muted);
font-size: 12px;
margin: 0 0 4px;
}
.message-row.user .role-label {
text-align: right;
}
.bubble {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow-soft);
padding: 12px 13px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 14px;
}
.message-row.user .bubble {
background: var(--user-bubble);
border-color: var(--user-bubble-border);
}
.message-row.assistant .bubble {
background: var(--assistant-bubble);
}
.message-row.error .bubble {
color: var(--bad);
border-color: var(--bad-border);
background: var(--bad-bg);
}
.typing {
color: var(--muted);
font-style: italic;
}
.composer {
border-top: 1px solid var(--line);
background: var(--composer-bg);
padding: 12px;
}
.composer-box {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
border: 1px solid var(--composer-panel-border);
border-radius: 18px;
background: var(--composer-panel-bg);
box-shadow: var(--shadow);
padding: 8px;
}
textarea,
input,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
outline: none;
padding: 10px 11px;
font-size: 14px;
box-shadow: var(--shadow-soft);
}
textarea:focus,
input:focus,
select:focus {
border-color: var(--line-strong);
box-shadow: 0 0 0 3px var(--ring);
}
textarea {
min-height: 52px;
max-height: 180px;
resize: none;
line-height: 1.45;
}
.composer textarea {
border: 0;
background: transparent;
box-shadow: none;
padding: 9px 10px;
}
.composer textarea:focus {
border-color: transparent;
box-shadow: none;
}
.send-stack {
display: grid;
gap: 6px;
width: 92px;
}
.composer-input-area {
min-width: 0;
display: grid;
gap: 7px;
}
.composer-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 10px 2px;
}
.model-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
border-radius: 6px;
background: var(--pill-bg);
border: 1px solid var(--line);
color: var(--pill-text);
font-size: 11px;
font-weight: 650;
padding: 2px 7px;
max-width: 100%;
overflow-wrap: anywhere;
}
.model-pill.strong {
color: var(--ink);
}
.status-line {
margin-top: 8px;
min-height: 20px;
color: var(--muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.side-panel {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
display: grid;
gap: 10px;
position: sticky;
top: 94px;
max-height: calc(100vh - 116px);
overflow: auto;
min-width: 0;
}
details {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
box-shadow: var(--shadow);
overflow: hidden;
}
summary {
cursor: pointer;
padding: 11px 12px;
font-weight: 700;
background: var(--surface);
border-bottom: 1px solid transparent;
}
summary:hover {
background: var(--surface-soft);
}
details[open] summary {
border-bottom-color: var(--line);
}
.details-body {
padding: 12px;
display: grid;
gap: 12px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
label {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 700;
margin-bottom: 5px;
}
.toggle-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.toggle-row label {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--ink);
font-size: 13px;
}
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
box-shadow: none;
}
.side-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preset-actions {
margin-bottom: 18px;
}
.api-note {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
background: var(--surface-soft);
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.control-note {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.help-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
font-size: 13px;
}
.help-list li + li {
margin-top: 6px;
}
.kv {
display: grid;
grid-template-columns: 118px minmax(0, 1fr);
gap: 7px 10px;
font-size: 13px;
}
.kv span:nth-child(odd) {
color: var(--muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.log-list {
display: grid;
gap: 8px;
max-height: 360px;
overflow: auto;
}
.log-item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px;
background: var(--surface-raised);
font-size: 12px;
}
.log-item strong {
display: block;
margin-bottom: 2px;
}
pre {
margin: 0;
padding: 12px;
border-radius: 8px;
background: var(--code);
border: 1px solid var(--line);
overflow-x: auto;
font-size: 12px;
line-height: 1.45;
}
code {
background: var(--code);
border: 1px solid var(--line);
border-radius: 5px;
padding: 2px 5px;
overflow-wrap: anywhere;
}
.api-list {
display: grid;
gap: 8px;
font-size: 13px;
}
.api-row {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
@media (max-width: 1040px) {
.app-grid {
grid-template-columns: 1fr;
}
.chat-shell {
min-height: 76vh;
}
.side-panel {
grid-template-columns: 1fr 1fr;
position: static;
max-height: none;
}
}
@media (max-width: 720px) {
.topbar {
grid-template-columns: 1fr;
align-items: start;
gap: 12px;
}
.badge-row {
justify-content: flex-start;
}
.top-actions {
justify-items: start;
width: 100%;
}
.language-switch {
width: min(100%, 164px);
}
.theme-switch {
width: min(100%, 220px);
}
.panel-toggle {
justify-self: start;
min-width: 0;
}
.badge-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
justify-content: flex-start;
}
.badge {
justify-content: flex-start;
white-space: normal;
}
.source-label {
white-space: normal;
}
.model-source {
display: block;
max-width: 100%;
}
main,
.topbar {
width: min(100vw - 20px, 1440px);
}
.chat-head {
align-items: flex-start;
flex-direction: column;
}
.bubble-wrap {
max-width: 88%;
}
.empty-state {
max-width: 100%;
}
.composer-box {
grid-template-columns: 1fr;
}
.send-stack {
width: 100%;
grid-template-columns: 1fr 1fr;
}
.composer-box {
border-radius: 14px;
}
.composer-meta {
padding-inline: 8px;
}
.field-grid,
.side-panel {
grid-template-columns: 1fr;
}
.kv,
.api-row,
.source-row {
grid-template-columns: 1fr;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
:root {
--target-bg: #ffffff;
--target-text: #0a0a0a;
--target-muted: #666666;
--target-faint: #eeeeee;
--target-soft: #f4f4f5;
--target-border: #dddddd;
--target-sidebar: 288px;
--target-radius: 24px;
--target-shadow: 0 2px 7px rgba(0, 0, 0, 0.12);
--target-shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.08);
}
:root[data-theme="dark"] {
--target-bg: #111111;
--target-text: #f5f5f5;
--target-muted: #a3a3a3;
--target-faint: rgba(255, 255, 255, 0.12);
--target-soft: #1f1f1f;
--target-border: rgba(255, 255, 255, 0.16);
--target-shadow: 0 2px 9px rgba(0, 0, 0, 0.34);
--target-shadow-soft: 0 1px 4px rgba(0, 0, 0, 0.28);
}
html,
body {
height: 100%;
background: var(--target-bg);
}
body {
color: var(--target-text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.45;
}
.app-shell {
min-height: 100vh;
background: var(--target-bg);
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 30;
width: var(--target-sidebar);
padding: 20px 24px;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 28px;
background: var(--target-bg);
color: var(--target-text);
overflow-y: auto;
border: 0;
box-shadow: none;
transform: translateX(0);
transition: transform 170ms ease;
}
.app-shell.panel-collapsed .sidebar {
transform: translateX(calc(-1 * var(--target-sidebar)));
}
.sidebar[hidden] {
display: block;
visibility: hidden;
pointer-events: none;
}
.sidebar-brand {
display: flex;
align-items: center;
min-height: 30px;
}
.sidebar-brand h1 {
margin: 0;
font-size: 20px;
line-height: 1.15;
letter-spacing: 0;
font-weight: 750;
}
.sidebar-nav {
display: grid;
gap: 17px;
}
.sidebar-action {
border: 0;
border-radius: 8px;
min-height: 24px;
width: max-content;
padding: 0;
display: inline-flex;
align-items: center;
gap: 10px;
background: transparent;
color: var(--target-text);
font-size: 14px;
font-weight: 650;
box-shadow: none;
}
.sidebar-action:hover {
background: transparent;
border-color: transparent;
box-shadow: none;
color: var(--target-text);
}
.sidebar-action .shortcut {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
color: var(--target-muted);
font-size: 12px;
font-weight: 500;
}
.conversation-section {
display: grid;
align-content: start;
gap: 44px;
color: var(--target-muted);
}
.conversation-title {
font-size: 13px;
font-weight: 550;
color: #484848;
}
:root[data-theme="dark"] .conversation-title {
color: var(--target-muted);
}
.conversation-empty {
margin-left: 52px;
font-size: 15px;
}
.icon-button {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
display: inline-grid;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--target-text);
box-shadow: none;
}
.icon-button:hover {
border-color: transparent;
background: var(--target-soft);
box-shadow: none;
}
.icon {
width: 18px;
height: 18px;
display: inline-block;
color: currentColor;
flex: 0 0 auto;
}
.icon svg {
width: 100%;
height: 100%;
display: block;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.sidebar-toggle {
position: fixed;
left: 24px;
top: 21px;
z-index: 45;
transition: left 170ms ease;
}
.app-shell:not(.panel-collapsed) .sidebar-toggle {
left: 316px;
}
.settings-button {
position: fixed;
top: 21px;
right: 24px;
z-index: 45;
}
.chat-main {
width: auto;
margin: 0;
min-height: 100vh;
padding: 0 24px 44px;
transition: padding-left 170ms ease;
background: var(--target-bg);
}
.app-shell:not(.panel-collapsed) .chat-main {
padding-left: calc(var(--target-sidebar) + 24px);
}
.chat-shell {
width: 100%;
min-height: 100vh;
display: grid;
grid-template-rows: minmax(420px, 47vh) auto minmax(120px, 1fr);
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: visible;
}
.chat-head,
.chat-actions,
.prompt-chips,
.send-stack {
display: none !important;
}
.timeline {
grid-row: 1;
width: 100%;
max-width: 920px;
margin: 0 auto;
padding: 112px 0 38px;
display: flex;
flex-direction: column;
align-items: end;
justify-content: flex-end;
gap: 22px;
overflow: visible;
background: transparent;
}
.timeline:has(.message-row) {
height: calc(100vh - 178px);
padding: 72px 0 24px;
justify-content: flex-start;
align-items: stretch;
overflow-y: auto;
}
.empty-state {
width: min(100%, 760px);
margin: 0 auto;
padding: 0;
text-align: center;
}
.empty-state strong {
display: block;
color: var(--target-text);
font-size: 28px;
line-height: 1.16;
letter-spacing: 0;
font-weight: 800;
margin: 0 0 8px;
}
.empty-state span {
display: block;
color: var(--target-muted);
font-size: 18px;
line-height: 1.35;
}
.composer {
grid-row: 2;
width: min(736px, calc(100vw - 32px));
margin: 0 auto;
padding: 0;
border: 0;
background: transparent;
}
.app-shell:not(.panel-collapsed) .composer {
width: min(736px, calc(100vw - var(--target-sidebar) - 64px));
}
.composer-box {
min-height: 118px;
padding: 15px 14px 12px;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 18px;
border: 1px solid var(--target-border);
border-radius: var(--target-radius);
background: #fbfbfb;
box-shadow: var(--target-shadow);
}
:root[data-theme="dark"] .composer-box {
background: #161616;
}
.composer-input-area {
min-width: 0;
display: block;
}
.composer textarea {
width: 100%;
min-height: 34px;
max-height: 180px;
padding: 0 6px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--target-text);
box-shadow: none;
font-size: 16px;
line-height: 1.55;
resize: none;
}
.composer textarea::placeholder {
color: #707070;
opacity: 1;
}
.composer textarea:focus {
border-color: transparent;
box-shadow: none;
}
.composer-toolbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.round-button,
.send-button {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 999px;
display: inline-grid;
place-items: center;
box-shadow: var(--target-shadow-soft);
}
.round-button {
border: 1px solid var(--target-faint);
background: var(--target-bg);
color: var(--target-text);
font-size: 25px;
font-weight: 300;
line-height: 1;
}
.send-button {
border: 0;
background: #8c8c8c;
color: #ffffff;
font-size: 20px;
font-weight: 700;
}
.send-button:disabled {
opacity: 1;
background: #8f8f8f;
color: #ffffff;
}
.stop-button {
display: inline-grid;
place-items: center;
min-width: 64px;
min-height: 36px;
border-color: var(--target-accent);
background: var(--target-accent-soft);
color: var(--target-accent-strong);
font-weight: 760;
}
.stop-button:disabled {
display: none;
}
.model-button {
justify-self: end;
min-width: min(430px, 100%);
max-width: 100%;
min-height: 26px;
padding: 0 7px;
border: 0;
border-radius: 7px;
background: #ededed;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 7px;
color: var(--target-text);
box-shadow: none;
}
.model-button:hover {
background: #e8e8e8;
border-color: transparent;
box-shadow: none;
}
:root[data-theme="dark"] .model-button {
background: #2b2b2b;
}
.composer-meta {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
overflow: hidden;
}
.model-pill {
min-height: 18px;
max-width: 160px;
padding: 1px 5px;
border: 0;
border-radius: 5px;
background: #e7e7e7;
color: var(--target-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
:root[data-theme="dark"] .model-pill {
background: #383838;
}
.model-pill.strong {
max-width: 88px;
font-family: inherit;
font-size: 12px;
font-weight: 750;
background: transparent;
}
.status-line {
min-height: 18px;
margin-top: 22px;
text-align: center;
color: var(--target-muted);
font-size: 12px;
}
.status-line kbd {
padding: 1px 5px;
border-radius: 4px;
background: var(--target-soft);
color: var(--target-muted);
font-family: inherit;
font-size: 12px;
font-weight: 650;
}
.message-row {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.message-row.user {
justify-content: flex-end;
}
.avatar {
display: none;
}
.bubble-wrap {
max-width: min(760px, 86%);
min-width: 0;
}
.role-label {
margin-bottom: 6px;
color: var(--target-muted);
font-size: 12px;
}
.message-row.user .role-label {
text-align: right;
}
.bubble {
border: 1px solid var(--target-border);
border-radius: 18px;
background: var(--target-bg);
box-shadow: var(--target-shadow-soft);
padding: 12px 14px;
color: var(--target-text);
font-size: 14px;
line-height: 1.55;
}
.message-row.user .bubble {
background: var(--target-soft);
border-color: var(--target-soft);
}
.message-row.error .bubble {
color: var(--bad);
border-color: var(--bad-border);
background: var(--bad-bg);
}
.settings-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.46);
}
.settings-overlay[hidden] {
display: none;
}
.settings-dialog {
position: relative;
width: min(896px, calc(100vw - 48px));
height: min(640px, calc(100vh - 72px));
display: grid;
grid-template-columns: 256px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) 86px;
overflow: hidden;
border: 1px solid var(--target-border);
border-radius: 8px;
background: var(--target-bg);
color: var(--target-text);
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.25);
}
.settings-tabs {
grid-row: 1 / 3;
padding: 32px 24px;
border-right: 1px solid var(--target-faint);
display: grid;
align-content: start;
gap: 8px;
}
.settings-tab {
min-height: 36px;
padding: 8px 12px;
border: 0;
border-radius: 9px;
background: transparent;
display: flex;
align-items: center;
gap: 12px;
color: var(--target-muted);
font-size: 14px;
font-weight: 550;
box-shadow: none;
}
.settings-tab.active,
.settings-tab:hover {
background: var(--target-soft);
color: var(--target-text);
border-color: transparent;
box-shadow: none;
}
.settings-content {
min-width: 0;
padding: 26px 24px 0;
overflow: hidden;
display: grid;
}
.settings-panel {
display: none;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.settings-panel.active {
display: block;
}
.settings-heading {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 26px;
border-bottom: 1px solid var(--target-faint);
margin-bottom: 24px;
}
.settings-heading h2 {
margin: 0;
font-size: 20px;
font-weight: 750;
line-height: 1.2;
}
.settings-close {
position: absolute;
top: 16px;
right: 14px;
z-index: 2;
}
.settings-footer {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px 24px;
border-top: 1px solid var(--target-faint);
background: var(--target-bg);
}
.settings-footer .primary {
background: #171717;
border-color: #171717;
color: #ffffff;
min-width: 122px;
min-height: 36px;
border-radius: 8px;
}
.settings-footer .ghost {
min-height: 36px;
border-radius: 8px;
background: var(--target-bg);
color: var(--target-text);
}
.setting-block {
display: grid;
gap: 8px;
margin-bottom: 24px;
}
.setting-block > label,
.setting-label {
margin: 0;
color: var(--target-text);
font-size: 14px;
font-weight: 750;
}
.setting-help {
margin: 0;
color: var(--target-muted);
font-size: 13px;
line-height: 1.35;
}
.language-switch,
.theme-switch {
width: min(100%, 448px);
border: 1px solid var(--target-border);
border-radius: 8px;
background: var(--target-bg);
display: inline-grid;
padding: 3px;
gap: 3px;
}
.language-switch {
grid-template-columns: 1fr 1fr;
}
.theme-switch {
grid-template-columns: repeat(3, 1fr);
}
.language-switch button,
.theme-switch button {
border: 0;
border-radius: 6px;
min-height: 30px;
padding: 5px 10px;
background: transparent;
color: var(--target-muted);
box-shadow: none;
}
.language-switch button.active,
.theme-switch button.active {
background: var(--target-soft);
color: var(--target-text);
box-shadow: none;
}
.badge-row {
justify-content: flex-start;
}
.badge {
border-radius: 999px;
background: var(--target-soft);
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
textarea,
input,
select {
border: 1px solid var(--target-border);
border-radius: 8px;
background: var(--target-bg);
color: var(--target-text);
box-shadow: var(--target-shadow-soft);
}
.kv {
grid-template-columns: 140px minmax(0, 1fr);
}
.log-list {
max-height: 400px;
}
.log-item,
pre,
code {
background: var(--target-soft);
border-color: var(--target-faint);
}
@media (max-width: 900px) {
.sidebar {
width: 260px;
}
.app-shell:not(.panel-collapsed) .sidebar-toggle {
left: 280px;
}
.app-shell:not(.panel-collapsed) .chat-main {
padding-left: 24px;
}
.settings-dialog {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) 76px;
height: min(720px, calc(100vh - 40px));
}
.settings-tabs {
grid-row: 1;
grid-column: 1;
display: flex;
gap: 6px;
overflow-x: auto;
padding: 16px 18px 10px;
border-right: 0;
border-bottom: 1px solid var(--target-faint);
}
.settings-tab {
flex: 0 0 auto;
}
.settings-content {
grid-row: 2;
padding: 18px 18px 0;
}
.settings-footer {
grid-column: 1;
grid-row: 3;
padding: 14px 18px 18px;
}
}
@media (max-width: 720px) {
.app-shell {
--target-sidebar: 100vw;
}
.sidebar {
width: 100vw;
}
.chat-main,
.app-shell:not(.panel-collapsed) .chat-main {
padding: 0 16px 34px;
}
.sidebar-toggle {
left: 20px;
top: 20px;
}
.app-shell:not(.panel-collapsed) .sidebar-toggle {
left: 20px;
}
.app-shell:not(.panel-collapsed) .sidebar-brand {
padding-left: 44px;
}
.settings-button {
top: 20px;
right: 18px;
}
.chat-shell {
grid-template-rows: minmax(390px, 49vh) auto minmax(120px, 1fr);
}
.timeline {
padding: 88px 0 36px;
}
.empty-state strong {
font-size: 24px;
}
.empty-state span {
font-size: 17px;
}
.composer {
width: 100%;
}
.composer-box {
min-height: 116px;
border-radius: 23px;
padding: 15px 12px 12px;
}
.composer-toolbar {
gap: 10px;
}
.model-button {
justify-self: stretch;
min-width: 0;
}
.model-pill:not(.strong) {
display: none;
}
.model-pill.strong {
max-width: 100%;
}
.status-line {
margin-top: 20px;
}
.settings-overlay {
padding: 20px 12px;
}
.settings-dialog {
width: 100%;
}
.field-grid {
grid-template-columns: 1fr;
}
.settings-footer {
gap: 10px;
}
}
/* Product-design polish: neutral chat surface with a precise ruby accent. */
:root {
--target-bg: #f7f8fb;
--target-bg-end: #eceff4;
--target-panel: #ffffff;
--target-text: #17181d;
--target-muted: #68707c;
--target-faint: #e3e6ec;
--target-soft: #f0f2f6;
--target-border: #d6dbe4;
--target-accent: #c21f3a;
--target-accent-strong: #9f1630;
--target-accent-soft: #fbe8ed;
--target-warning-soft: #fff5df;
--target-sidebar: 292px;
--target-radius: 22px;
--target-shadow: 0 18px 52px rgba(24, 27, 35, 0.13);
--target-shadow-soft: 0 8px 22px rgba(24, 27, 35, 0.08);
}
:root[data-theme="dark"] {
--target-bg: #111115;
--target-bg-end: #18181f;
--target-panel: #1c1c23;
--target-text: #f6f7fb;
--target-muted: #a6acb8;
--target-faint: rgba(255, 255, 255, 0.11);
--target-soft: #262832;
--target-border: rgba(255, 255, 255, 0.17);
--target-accent: #ff5a76;
--target-accent-strong: #ff7c93;
--target-accent-soft: rgba(255, 90, 118, 0.16);
--target-warning-soft: rgba(245, 158, 11, 0.14);
--target-shadow: 0 22px 62px rgba(0, 0, 0, 0.42);
--target-shadow-soft: 0 10px 26px rgba(0, 0, 0, 0.32);
}
body {
font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--target-bg);
}
.app-shell {
background: linear-gradient(180deg, var(--target-bg) 0%, var(--target-bg-end) 100%);
}
.sidebar {
background: var(--target-panel);
border-right: 1px solid var(--target-faint);
box-shadow: 18px 0 42px rgba(24, 27, 35, 0.06);
}
:root[data-theme="dark"] .sidebar {
box-shadow: 18px 0 44px rgba(0, 0, 0, 0.28);
}
.sidebar-brand {
min-height: 38px;
border-bottom: 1px solid var(--target-faint);
padding-bottom: 18px;
}
.sidebar-brand h1 {
color: var(--target-text);
font-size: 19px;
font-weight: 780;
}
.sidebar-nav {
gap: 10px;
}
.sidebar-action {
width: 100%;
min-height: 40px;
padding: 0 10px;
border-radius: 10px;
justify-content: flex-start;
}
.sidebar-action:hover,
.sidebar-action:focus-visible {
background: var(--target-soft);
}
.sidebar-action .shortcut {
margin-left: auto;
padding: 2px 5px;
border: 1px solid var(--target-faint);
border-radius: 5px;
background: var(--target-bg);
}
.conversation-section {
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--target-faint);
}
.conversation-title {
color: var(--target-text);
font-weight: 720;
}
.conversation-empty {
margin-left: 0;
padding: 12px;
border: 1px dashed var(--target-border);
border-radius: 10px;
background: var(--target-soft);
color: var(--target-muted);
font-size: 13px;
line-height: 1.45;
}
.icon-button {
border: 1px solid var(--target-faint);
background: var(--target-panel);
box-shadow: var(--target-shadow-soft);
}
.icon-button:hover {
background: var(--target-soft);
border-color: var(--target-border);
}
.chat-main,
.chat-shell,
.timeline {
background: transparent;
}
.timeline {
max-width: 900px;
padding-top: 116px;
}
.timeline:has(.message-row) {
scrollbar-gutter: stable;
}
.empty-state {
width: min(100%, 780px);
}
.empty-state strong {
color: var(--target-text);
font-size: 34px;
font-weight: 820;
}
.empty-state span {
max-width: 560px;
margin: 0 auto;
color: var(--target-muted);
font-size: 16px;
}
.empty-state .prompt-chips {
display: flex !important;
margin-top: 24px;
justify-content: center;
}
.empty-state .prompt-chip {
min-height: 40px;
border-color: var(--target-border);
border-radius: 12px;
background: var(--target-panel);
color: var(--target-text);
box-shadow: var(--target-shadow-soft);
}
.empty-state .prompt-chip:hover {
border-color: var(--target-accent);
background: var(--target-accent-soft);
box-shadow: var(--target-shadow);
}
.composer {
width: min(780px, calc(100vw - 32px));
}
.app-shell:not(.panel-collapsed) .composer {
width: min(780px, calc(100vw - var(--target-sidebar) - 68px));
}
.composer-box {
border-color: var(--target-border);
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--target-shadow);
backdrop-filter: blur(18px);
}
:root[data-theme="dark"] .composer-box {
background: rgba(28, 28, 35, 0.92);
}
.composer textarea {
color: var(--target-text);
}
.composer textarea::placeholder {
color: var(--target-muted);
}
.round-button,
.model-button {
border: 1px solid var(--target-faint);
background: var(--target-panel);
}
.model-button {
background: var(--target-soft);
}
.model-button:hover,
.round-button:hover {
border-color: var(--target-border);
background: var(--target-accent-soft);
}
.model-pill {
background: rgba(255, 255, 255, 0.72);
color: var(--target-text);
}
:root[data-theme="dark"] .model-pill {
background: rgba(255, 255, 255, 0.08);
}
.send-button:not(:disabled) {
background: var(--target-accent);
color: #ffffff;
}
.send-button:not(:disabled):hover {
background: var(--target-accent-strong);
}
.status-line {
color: var(--target-muted);
}
.message-row.assistant .bubble {
border-left: 3px solid var(--target-accent);
background: var(--target-panel);
}
.message-row.user .bubble {
background: var(--target-accent-soft);
border-color: transparent;
}
.bubble {
box-shadow: var(--target-shadow-soft);
}
.settings-overlay {
background: rgba(17, 17, 21, 0.5);
backdrop-filter: blur(10px);
}
.settings-dialog {
border-color: var(--target-border);
border-radius: 16px;
background: var(--target-panel);
box-shadow: 0 28px 90px rgba(17, 17, 21, 0.28);
}
.settings-tabs {
background: var(--target-bg);
}
.settings-tab.active,
.settings-tab:hover {
background: var(--target-accent-soft);
color: var(--target-text);
}
.settings-heading {
border-bottom-color: var(--target-faint);
}
.settings-heading .icon {
color: var(--target-accent);
}
.settings-footer .primary {
background: var(--target-accent);
border-color: var(--target-accent);
}
.settings-footer .primary:hover {
background: var(--target-accent-strong);
}
input[type="checkbox"] {
accent-color: var(--target-accent);
}
.badge.good {
color: var(--good);
border-color: var(--good-border);
background: var(--good-bg);
}
.badge.warn {
color: var(--warn);
border-color: var(--warn-border);
background: var(--warn-bg);
}
.badge.bad {
color: var(--bad);
border-color: var(--bad-border);
background: var(--bad-bg);
}
@media (max-width: 720px) {
.empty-state strong {
font-size: 27px;
}
.empty-state span {
font-size: 15px;
}
.empty-state .prompt-chips {
display: grid !important;
grid-template-columns: 1fr;
}
.empty-state .prompt-chip {
width: 100%;
justify-content: center;
}
.settings-dialog {
border-radius: 14px;
}
}
</style>
</head>
<body>
<div id="appGrid" class="app-shell panel-collapsed">
<aside id="sidePanel" class="sidebar" aria-label="对话" hidden>
<div class="sidebar-brand">
<h1>Qwen GGUF</h1>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<button type="button" id="newChatBtn" class="sidebar-action">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<span data-i18n="newChat">New chat</span>
</button>
<button type="button" class="sidebar-action" data-settings-open="api">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m16 6-8 12"/><path d="m10 5-6 6 6 6"/><path d="m14 5 6 6-6 6"/></svg></span>
<span data-i18n="toolServers">API 与工具</span>
</button>
<button type="button" id="retryBtn" class="sidebar-action" disabled>
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v6h6"/></svg></span>
<span data-i18n="retry">Retry</span>
</button>
</nav>
<div class="conversation-section">
<div class="conversation-title" data-i18n="conversations">对话记录</div>
<div class="conversation-empty" data-i18n="noConversations">当前会话仅保存在浏览器内存中。</div>
</div>
</aside>
<button type="button" id="panelToggle" class="icon-button sidebar-toggle" aria-expanded="false" aria-controls="sidePanel" title="显示侧栏">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="M9 5v14"/></svg></span>
<span class="sr-only">显示侧栏</span>
</button>
<button type="button" id="settingsBtn" class="icon-button settings-button" title="设置" aria-label="设置" data-i18n-title="settingsLabel">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.33.6.95 1 1.55 1H21a2 2 0 1 1 0 4h-.05A1.7 1.7 0 0 0 19.4 15Z"/></svg></span>
<span class="sr-only">设置</span>
</button>
<main class="chat-main">
<section class="chat-shell" aria-label="Chat">
<div id="timeline" class="timeline" aria-live="polite"></div>
<form id="composer" class="composer">
<div class="composer-box">
<div class="composer-input-area">
<textarea id="userInput" rows="1" placeholder="输入消息..." data-i18n-placeholder="inputPlaceholder" autocomplete="off"></textarea>
</div>
<div class="composer-toolbar">
<button type="button" class="round-button" title="打开设置" aria-label="打开设置" data-i18n-title="addContext" data-settings-open="general">+</button>
<button type="button" id="modelPillButton" class="model-button" title="模型与参数" aria-label="模型与参数" data-i18n-title="modelParams" data-settings-open="parameters">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z"/><path d="M12 12 4 7.5"/><path d="m12 12 8-4.5"/><path d="M12 12v9"/></svg></span>
<span class="composer-meta" aria-label="Current model">
<span id="activeModelPill" class="model-pill strong">unsloth/diffusiongemma-26B-A4B-it-GGUF:Q4_K_M</span>
<span id="activeQuantPill" class="model-pill">Q4_K_M</span>
<span id="activeBackendPill" class="model-pill">GPU</span>
</span>
</button>
<button id="sendBtn" class="send-button" type="submit" disabled aria-label="发送"></button>
<button id="stopBtn" class="stop-button" type="button" disabled data-i18n="stop">Stop</button>
</div>
</div>
<div id="chatStatus" class="status-line"><kbd>Enter</kbd> 发送,<kbd>Shift + Enter</kbd> 换行</div>
</form>
</section>
</main>
</div>
<div id="settingsOverlay" class="settings-overlay" hidden>
<div class="settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<button type="button" id="settingsClose" class="icon-button settings-close" title="关闭" aria-label="关闭" data-i18n-title="close">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></span>
<span class="sr-only">关闭</span>
</button>
<nav class="settings-tabs" aria-label="设置">
<button type="button" class="settings-tab active" data-settings-tab="general">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.33.6.95 1 1.55 1H21a2 2 0 1 1 0 4h-.05A1.7 1.7 0 0 0 19.4 15Z"/></svg></span>
<span id="settingsTitle" data-i18n="generalSummary">通用</span>
</button>
<button type="button" class="settings-tab" data-settings-tab="parameters">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg></span>
<span data-i18n="paramsSummary">Sampling</span>
</button>
<button type="button" class="settings-tab" data-settings-tab="runtime">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M8 20h8"/><path d="M12 18v2"/></svg></span>
<span data-i18n="runtimeSummary">Runtime</span>
</button>
<button type="button" class="settings-tab" data-settings-tab="logs">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg></span>
<span data-i18n="logsSummary">Logs</span>
</button>
<button type="button" class="settings-tab" data-settings-tab="api">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg></span>
<span data-i18n="apiSummary">Developer</span>
</button>
</nav>
<div class="settings-content">
<section class="settings-panel active" data-settings-panel="general">
<div class="settings-heading">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.33.6.95 1 1.55 1H21a2 2 0 1 1 0 4h-.05A1.7 1.7 0 0 0 19.4 15Z"/></svg></span>
<h2 data-i18n="generalSummary">通用</h2>
</div>
<div class="setting-block">
<div class="setting-label" data-i18n="themeLabel">主题</div>
<div class="theme-switch" aria-label="主题">
<button type="button" id="themeSystem" class="active" data-i18n="themeSystem">System</button>
<button type="button" id="themeLight" data-i18n="themeLight">Light</button>
<button type="button" id="themeDark" data-i18n="themeDark">Dark</button>
</div>
<p class="setting-help" data-i18n="themeHelp">按系统主题显示,也可以固定浅色或深色。</p>
</div>
<div class="setting-block">
<div class="setting-label" data-i18n="languageLabel">语言</div>
<div class="language-switch" aria-label="语言">
<button type="button" id="langZh" class="active">中文</button>
<button type="button" id="langEn">English</button>
</div>
</div>
<div class="setting-block">
<div class="setting-label" data-i18n="sourceLabel">Original model source</div>
<div class="model-line" id="modelLine">unsloth/diffusiongemma-26B-A4B-it-GGUF:Q4_K_M</div>
<a class="model-source" id="modelSource" href="https://huggingface.co/unsloth/diffusiongemma-26B-A4B-it-GGUF" target="_blank" rel="noreferrer">https://huggingface.co/unsloth/diffusiongemma-26B-A4B-it-GGUF</a>
</div>
<div class="setting-block">
<div class="setting-label" data-i18n="statusLabel">状态</div>
<div class="badge-row" aria-label="Runtime status">
<span id="runtimeBadge" class="badge warn" data-i18n="badgeRuntimeUnknown">runtime unknown</span>
<span id="modelBadge" class="badge warn" data-i18n="badgeModelUnknown">model unknown</span>
<span id="streamBadge" class="badge warn" data-i18n="badgeStreamUnknown">stream unknown</span>
<span id="gpuBadge" class="badge warn" data-i18n="badgeGpuNotProbed">GPU not probed</span>
</div>
</div>
</section>
<section class="settings-panel" data-settings-panel="parameters">
<div class="settings-heading">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg></span>
<h2 data-i18n="paramsSummary">Sampling</h2>
</div>
<div class="setting-block">
<label for="model" data-i18n="modelLabel">Model</label>
<select id="model">
<option value="unsloth/diffusiongemma-26B-A4B-it-GGUF:Q4_K_M">unsloth/diffusiongemma-26B-A4B-it-GGUF:Q4_K_M</option>
</select>
<p id="precisionNote" class="setting-help"></p>
</div>
<div class="side-actions preset-actions" aria-label="参数预设">
<button type="button" id="stablePresetBtn" data-i18n="stablePreset">稳定验证</button>
<button type="button" id="longPresetBtn" data-i18n="longPreset">长上下文</button>
</div>
<div class="field-grid">
<div class="setting-block">
<label for="maxTokens" data-i18n="maxTokens">Max tokens</label>
<input id="maxTokens" type="number" min="1" max="2048" step="1" value="512" />
</div>
<div class="setting-block">
<label for="ctxSize" data-i18n="context">Context</label>
<input id="ctxSize" type="number" min="1024" max="4096" step="1024" value="4096" />
</div>
<div class="setting-block">
<label for="temperature" data-i18n="temperature">Temperature</label>
<input id="temperature" type="number" min="0" max="3" step="0.1" value="0" />
</div>
<div class="setting-block">
<label for="topP" data-i18n="topP">Top p</label>
<input id="topP" type="number" min="0" max="1" step="0.01" value="1" />
</div>
<div class="setting-block">
<label for="topK" data-i18n="topK">Top k</label>
<input id="topK" type="number" min="0" step="1" value="0" />
</div>
<div class="setting-block">
<label for="minP" data-i18n="minP">Min p</label>
<input id="minP" type="number" min="0" max="1" step="0.01" value="0" />
</div>
<div class="setting-block">
<label for="presencePenalty" data-i18n="presencePenalty">Presence penalty</label>
<input id="presencePenalty" type="number" min="-2" max="2" step="0.1" value="0" />
</div>
<div class="setting-block">
<label for="repetitionPenalty" data-i18n="repetitionPenalty">Repetition penalty</label>
<input id="repetitionPenalty" type="number" min="0" max="2" step="0.1" value="1" />
</div>
</div>
<div class="toggle-row">
<label><input id="stream" type="checkbox" checked /> <span data-i18n="streamLabel">Stream</span></label>
<label><input id="includeUsage" type="checkbox" checked /> <span data-i18n="usageLabel">Usage</span></label>
</div>
</section>
<section class="settings-panel" data-settings-panel="runtime">
<div class="settings-heading">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M8 20h8"/><path d="M12 18v2"/></svg></span>
<h2 data-i18n="runtimeSummary">Runtime</h2>
</div>
<div class="side-actions">
<button type="button" id="refreshRuntimeBtn" data-i18n="refresh">Refresh</button>
<button type="button" id="probeGpuBtn" data-i18n="gpuProbe">GPU Probe</button>
</div>
<div id="runtimeKv" class="kv"></div>
</section>
<section class="settings-panel" data-settings-panel="logs">
<div class="settings-heading">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg></span>
<h2 data-i18n="logsSummary">Logs</h2>
</div>
<div class="field-grid">
<div class="setting-block">
<label for="logLimit" data-i18n="limit">Limit</label>
<input id="logLimit" type="number" min="1" max="200" step="1" value="40" />
</div>
<div class="setting-block">
<label for="logEvent" data-i18n="eventFilter">Event filter</label>
<input id="logEvent" type="text" placeholder="chat.request" data-i18n-placeholder="eventPlaceholder" />
</div>
<div class="setting-block">
<label for="logRequestId" data-i18n="requestFilter">Request ID</label>
<input id="logRequestId" type="text" placeholder="chatcmpl-..." data-i18n-placeholder="requestPlaceholder" />
</div>
</div>
<div class="side-actions">
<button type="button" id="refreshLogsBtn" data-i18n="refreshLogs">Refresh logs</button>
</div>
<div id="logs" class="log-list"></div>
</section>
<section class="settings-panel" data-settings-panel="api">
<div class="settings-heading">
<span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg></span>
<h2 data-i18n="apiSummary">Developer</h2>
</div>
<div id="apiList" class="api-list"></div>
<div class="side-actions">
<button type="button" id="copyCurlBtn" data-i18n="copyCurl">Copy curl</button>
</div>
<pre id="curlBox"></pre>
</section>
</div>
<div class="settings-footer">
<button type="button" id="settingsReset" class="ghost">
<span data-i18n="resetSettings">Reset to default</span>
</button>
<button type="button" id="settingsSave" class="primary" data-i18n="saveSettings">Save settings</button>
</div>
</div>
</div>
<script>
const fallbackConfig = {
model: 'unsloth/diffusiongemma-26B-A4B-it-GGUF:Q4_K_M',
repoId: 'unsloth/diffusiongemma-26B-A4B-it-GGUF',
filename: 'diffusiongemma-26B-A4B-it-Q4_K_M.gguf',
modelSourceUrl: 'https://huggingface.co/unsloth/diffusiongemma-26B-A4B-it-GGUF',
zeroGpuSize: 'large',
defaultMaxTokens: 512,
maxMaxTokens: 2048,
diffusionMaxSteps: 48,
nGpuLayers: 99,
thinkingDefault: false,
diffusionVisualDefault: false,
diffusionKvCache: 'auto'
};
function readConfig() {
const node = document.getElementById('app-config');
try {
const parsed = JSON.parse(node.textContent);
return {...fallbackConfig, ...parsed};
} catch (_) {
return fallbackConfig;
}
}
const APP = readConfig();
const DEFAULT_MODEL = APP.model;
const CPU_MODEL = '';
const DEFAULT_QUANT = (APP.model && APP.model.includes(':') ? APP.model.split(':').pop() : 'Q4_K_M');
const BUILD_RUNTIME_DEFAULTS = {
model: APP.model,
maxTokens: Number(APP.defaultMaxTokens || 512),
maxMaxTokens: Number(APP.maxMaxTokens || APP.defaultMaxTokens || 2048),
ctxSize: 4096,
maxCtxSize: 4096,
temperature: 0,
topP: 1,
topK: 0,
minP: 0,
presencePenalty: 0,
repetitionPenalty: 1,
backendLimits: {
gpu: {
default_max_tokens: Number(APP.defaultMaxTokens || 512),
max_max_tokens: Number(APP.maxMaxTokens || APP.defaultMaxTokens || 2048),
ctx_size: 4096,
max_ctx_size: 4096
}
},
stream: true,
includeUsage: true
};
let runtimeDefaults = {...BUILD_RUNTIME_DEFAULTS};
let modelBackends = {[DEFAULT_MODEL]: 'gpu'};
let messages = [];
let activeController = null;
let lastUserText = '';
let lastRawEvents = [];
let lastRuntimeData = null;
let currentStatus = {key: 'statusReady', args: {}};
let uiRunning = false;
const LANG_KEY = 'diffusiongemma-ui-lang';
const savedLang = localStorage.getItem(LANG_KEY);
let currentLang = savedLang === 'en' ? 'en' : 'zh';
const THEME_KEY = 'diffusiongemma-ui-theme';
const THEME_VALUES = ['system', 'light', 'dark'];
const savedTheme = localStorage.getItem(THEME_KEY);
let currentTheme = THEME_VALUES.includes(savedTheme) ? savedTheme : 'system';
const themeMedia = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
const PANEL_KEY = 'diffusiongemma-ui-panel-collapsed';
const savedPanelState = localStorage.getItem(PANEL_KEY);
let panelCollapsed = savedPanelState === null ? true : savedPanelState === '1';
const SETTINGS_KEY = 'diffusiongemma-ui-settings-v2';
let hasSavedParameterSettings = false;
const I18N = {
zh: {
documentTitle: 'DiffusionGemma Chat · 中文界面',
eyebrow: 'ZeroGPU GGUF API',
appTitle: 'DiffusionGemma ChatUI',
settingsLabel: '设置',
close: '关闭',
addContext: '打开设置',
modelParams: '模型与参数',
sourceLabel: '原始模型来源',
toolServers: 'API 兼容性',
conversations: '对话记录',
noConversations: '当前会话仅保存在浏览器内存中。',
badgeRuntimeUnknown: 'runtime 未知',
badgeModelUnknown: 'model 未知',
badgeStreamUnknown: 'stream 未知',
badgeGpuNotProbed: 'GPU 未探测',
chatTitle: '对话',
chatSubtitle: '流式响应;首次请求可能等待 ZeroGPU 分配。',
themeSystem: '系统',
themeLight: '浅色',
themeDark: '深色',
panelShow: '显示面板',
panelHide: '隐藏面板',
newChat: '新对话',
retry: '重试',
emptyTitle: '和 DiffusionGemma 开始对话',
emptyBody: '保留 qwen 风格外壳,但当前交互绑定的是 DiffusionGemma ZeroGPU 运行态和扩散式生成参数。',
inputPlaceholder: '输入消息...',
send: '发送',
stop: '停止',
generalSummary: '通用',
themeLabel: '主题',
themeHelp: '按系统主题显示,也可以固定浅色或深色。',
languageLabel: '语言',
statusLabel: '状态',
paramsSummary: '采样',
modelLabel: '模型',
maxTokens: '最大输出 tokens',
context: '上下文',
temperature: '温度',
topP: 'Top p',
topK: 'Top k',
minP: 'Min p',
presencePenalty: '存在惩罚',
repetitionPenalty: '重复惩罚',
streamLabel: '流式',
usageLabel: 'Usage 统计',
precisionNote: '当前模型为 {model};量化固定为 {quant};页面参数会映射到 DiffusionGemma 的 /v1 接口。',
runtimeSummary: '运行状态',
refresh: '刷新',
gpuProbe: 'GPU Probe',
helpSummary: '使用说明',
helpModel: '当前模型为 DiffusionGemma GGUF;前端会调用 /v1/chat/completions、/v1/models 与 /v2/runtime。',
stablePreset: '稳定验证',
longPreset: '长上下文',
helpDefaults: '参数来自当前 runtime;稳定验证适合首轮测试,长上下文适合压力实验。',
helpEmpty: '空输出会按服务异常处理;用 /v2/logs 的 request_id 串起一次请求。',
helpApi: 'API 支持 OpenAI-style Chat Completions 和客户端工具调用协议。',
apiCompatNote: 'OpenAI-style /v1/chat/completions:支持 messages 与 stream 最终块兼容模式。运行态与日志继续走 /v2/runtime、/v2/logs、/v2/zerogpu/probe。',
logsSummary: '日志',
limit: '条数',
eventFilter: 'Event 筛选',
requestFilter: 'Request ID',
eventPlaceholder: 'chat.request',
requestPlaceholder: 'chatcmpl-...',
refreshLogs: '刷新日志',
apiSummary: '开发者',
copyCurl: '复制 curl',
resetSettings: '恢复默认',
saveSettings: '保存设置',
settingsSaved: '设置已保存',
statusReady: '按 Enter 发送,Shift + Enter 换行',
statusSent: '请求已发送,等待 ZeroGPU / llama-server',
statusGenerating: '生成中...',
statusStreamFallback: '流式返回为空,正在改用非流式重试',
statusStableFallback: '返回为空,正在用稳定参数重试',
statusDone: '完成',
statusStopped: '已停止',
statusFailed: '请求失败',
statusCurlCopied: 'curl 已复制',
statusGpuDone: 'GPU probe 完成',
statusGpuFailed: 'GPU probe 失败',
roleUser: '你',
roleAssistant: 'DiffusionGemma',
roleError: '错误',
typing: '正在生成...',
samplePrompt: '用五句话解释 block diffusion 和 autoregressive decoding 的差别。',
emptyOutputMessage: '模型返回空内容;这会被按服务异常处理,请查看 request_id 日志。',
emptyOutputFinal: '模型返回空内容,请重试。',
requestStopped: '请求已停止。',
logsEmpty: '暂无日志',
logsFailed: '日志请求失败:{message}',
modelLine: '模型 {model} · 精度 {quant}',
runtimeReady: 'runtime 就绪',
runtimePending: 'runtime 等待中',
runtimeError: 'runtime 错误',
modelCached: 'model 已缓存',
modelMissing: 'model 未缓存',
streamOn: 'server stream 开启',
streamOff: 'server stream 关闭',
gpuProbing: '正在探测 GPU',
gpuAllocated: 'GPU 已分配',
gpuUnavailable: 'GPU 不可用',
gpuProbeFailed: 'probe 失败',
kvZeroGPU: 'ZeroGPU',
kvRunner: 'Runner',
kvStream: 'Stream',
kvDefaultQuant: '默认精度',
kvSource: '来源',
kvDefaultMax: '默认 max tokens',
kvDefaultCtx: '默认 context',
kvTemperature: '温度',
kvTopP: 'Top p',
kvTopK: 'Top k',
kvMinP: 'Min p',
kvPresence: '存在惩罚',
kvRepetition: '重复惩罚',
kvModelCached: 'Model 缓存',
kvModelGB: 'Model GB',
kvLastQuant: '上次精度',
kvLastOutput: '上次输出',
kvLastDuration: '上次耗时',
kvDataDir: 'Data dir',
kvCpuModel: 'CPU model',
kvCpuServer: 'CPU server',
kvCpuRAM: 'CPU RAM',
kvBackend: '后端',
backendGpu: 'GPU',
backendCpu: 'CPU'
},
en: {
documentTitle: 'DiffusionGemma Chat',
eyebrow: 'ZeroGPU GGUF API',
appTitle: 'DiffusionGemma ChatUI',
settingsLabel: 'Settings',
close: 'Close',
addContext: 'Open settings',
modelParams: 'Model and parameters',
sourceLabel: 'Original model source',
toolServers: 'API compatibility',
conversations: 'Conversations',
noConversations: 'This session is stored only in browser memory.',
badgeRuntimeUnknown: 'runtime unknown',
badgeModelUnknown: 'model unknown',
badgeStreamUnknown: 'stream unknown',
badgeGpuNotProbed: 'GPU not probed',
chatTitle: 'Chat',
chatSubtitle: 'Streaming is on; first requests may wait for ZeroGPU allocation.',
themeSystem: 'System',
themeLight: 'Light',
themeDark: 'Dark',
panelShow: 'Show panel',
panelHide: 'Hide panel',
newChat: 'New chat',
retry: 'Retry',
emptyTitle: 'Start with DiffusionGemma',
emptyBody: 'This keeps the qwen-style shell, but the actual interaction is wired to DiffusionGemma ZeroGPU runtime and diffusion-specific defaults.',
inputPlaceholder: 'Type a message...',
send: 'Send',
stop: 'Stop',
generalSummary: 'General',
themeLabel: 'Theme',
themeHelp: 'Follow the system theme or pin light or dark mode.',
languageLabel: 'Language',
statusLabel: 'Status',
paramsSummary: 'Sampling',
modelLabel: 'Model',
maxTokens: 'Max tokens',
context: 'Context',
temperature: 'Temperature',
topP: 'Top p',
topK: 'Top k',
minP: 'Min p',
presencePenalty: 'Presence penalty',
repetitionPenalty: 'Repetition penalty',
streamLabel: 'Stream',
usageLabel: 'Usage',
precisionNote: 'Current model is {model}; quant is fixed to {quant}; this UI is mapped to the DiffusionGemma /v1 API.',
runtimeSummary: 'Runtime',
refresh: 'Refresh',
gpuProbe: 'GPU Probe',
helpSummary: 'Guide',
helpModel: 'This Space serves DiffusionGemma GGUF. The frontend calls /v1/chat/completions, /v1/models, and /v2 runtime endpoints.',
stablePreset: 'Stable check',
longPreset: 'Long context',
helpDefaults: 'Parameters come from the current runtime. Stable check is for first validation; long context is for stress tests.',
helpEmpty: 'Empty output is treated as a service error; use request_id in /v2/logs to trace a request.',
helpApi: 'The API supports OpenAI-style Chat Completions and client-side tool-calling protocol fields.',
apiCompatNote: 'OpenAI-style /v1/chat/completions supports messages and a final-delta stream compatibility mode. Runtime and logs remain under /v2/runtime, /v2/logs, and /v2/zerogpu/probe.',
logsSummary: 'Logs',
limit: 'Limit',
eventFilter: 'Event filter',
requestFilter: 'Request ID',
eventPlaceholder: 'chat.request',
requestPlaceholder: 'chatcmpl-...',
refreshLogs: 'Refresh logs',
apiSummary: 'Developer',
copyCurl: 'Copy curl',
resetSettings: 'Reset to default',
saveSettings: 'Save settings',
settingsSaved: 'Settings saved',
statusReady: 'Press Enter to send, Shift + Enter for new line',
statusSent: 'Request sent; waiting for ZeroGPU / llama-server',
statusGenerating: 'Generating...',
statusStreamFallback: 'Stream returned empty; retrying without stream',
statusStableFallback: 'Empty response; retrying with stable parameters',
statusDone: 'Done',
statusStopped: 'Stopped',
statusFailed: 'Request failed',
statusCurlCopied: 'curl copied',
statusGpuDone: 'GPU probe complete',
statusGpuFailed: 'GPU probe failed',
roleUser: 'You',
roleAssistant: 'DiffusionGemma',
roleError: 'Error',
typing: 'Generating...',
samplePrompt: 'Explain the difference between block diffusion and autoregressive decoding in five sentences.',
emptyOutputMessage: 'The model returned empty content; this is treated as a service error. Check logs by request_id.',
emptyOutputFinal: 'The model returned empty content. Try again.',
requestStopped: 'Request stopped.',
logsEmpty: 'No logs',
logsFailed: 'Log request failed: {message}',
modelLine: 'Model {model} · Precision {quant}',
runtimeReady: 'runtime ready',
runtimePending: 'runtime pending',
runtimeError: 'runtime error',
modelCached: 'model cached',
modelMissing: 'model missing',
streamOn: 'server stream on',
streamOff: 'server stream off',
gpuProbing: 'probing GPU',
gpuAllocated: 'GPU allocated',
gpuUnavailable: 'GPU unavailable',
gpuProbeFailed: 'probe failed',
kvZeroGPU: 'ZeroGPU',
kvRunner: 'Runner',
kvStream: 'Stream',
kvDefaultQuant: 'Default precision',
kvSource: 'Source',
kvDefaultMax: 'Default max tokens',
kvDefaultCtx: 'Default context',
kvTemperature: 'Temperature',
kvTopP: 'Top p',
kvTopK: 'Top k',
kvMinP: 'Min p',
kvPresence: 'Presence penalty',
kvRepetition: 'Repetition penalty',
kvModelCached: 'Model cached',
kvModelGB: 'Model GB',
kvLastQuant: 'Last precision',
kvLastOutput: 'Last output',
kvLastDuration: 'Last duration',
kvDataDir: 'Data dir',
kvCpuModel: 'CPU model',
kvCpuServer: 'CPU server',
kvCpuRAM: 'CPU RAM',
kvBackend: 'Backend',
backendGpu: 'GPU',
backendCpu: 'CPU'
}
};
const QUICK_PROMPTS = {
zh: [
['快速自检', '用五句话解释 block diffusion 和 autoregressive decoding 的差别。'],
['结构化总结', '请把下面材料整理成结构化摘要,并列出关键风险:'],
['API 调用', '给我一个最小可用的 curl 示例,调用 /v1/chat/completions。']
],
en: [
['Quick check', 'Explain the difference between block diffusion and autoregressive decoding in five sentences.'],
['Structured summary', 'Turn the material below into a structured summary and list key risks:'],
['API call', 'Give me a minimal curl example for /v1/chat/completions.']
]
};
const els = {};
const ids = [
'modelLine', 'modelSource', 'langZh', 'langEn', 'themeSystem', 'themeLight', 'themeDark',
'panelToggle', 'settingsBtn', 'settingsOverlay', 'settingsClose', 'settingsSave', 'settingsReset',
'modelPillButton', 'activeModelPill', 'activeQuantPill', 'activeBackendPill', 'appGrid', 'sidePanel',
'runtimeBadge', 'modelBadge', 'streamBadge', 'gpuBadge',
'timeline', 'composer', 'userInput', 'sendBtn', 'stopBtn', 'newChatBtn',
'retryBtn', 'chatStatus', 'model', 'stablePresetBtn', 'longPresetBtn', 'maxTokens', 'ctxSize', 'temperature',
'topP', 'topK', 'minP', 'presencePenalty', 'repetitionPenalty',
'precisionNote',
'stream', 'includeUsage', 'refreshRuntimeBtn', 'probeGpuBtn',
'runtimeKv', 'logLimit', 'logEvent', 'logRequestId', 'refreshLogsBtn', 'logs', 'apiList', 'copyCurlBtn',
'curlBox'
];
for (const id of ids) {
els[id] = document.getElementById(id);
}
function t(key, args = {}) {
let text = (I18N[currentLang] && I18N[currentLang][key]) || I18N.zh[key] || key;
for (const [name, value] of Object.entries(args)) {
text = text.replaceAll('{' + name + '}', String(value ?? ''));
}
return text;
}
function resolvedTheme() {
if (currentTheme === 'dark' || currentTheme === 'light') return currentTheme;
return themeMedia && themeMedia.matches ? 'dark' : 'light';
}
function applyTheme() {
const theme = resolvedTheme();
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
els.themeSystem.classList.toggle('active', currentTheme === 'system');
els.themeLight.classList.toggle('active', currentTheme === 'light');
els.themeDark.classList.toggle('active', currentTheme === 'dark');
}
function setTheme(theme) {
currentTheme = THEME_VALUES.includes(theme) ? theme : 'system';
localStorage.setItem(THEME_KEY, currentTheme);
applyTheme();
}
function setButtonLabel(node, text) {
if (!node) return;
const label = node.querySelector('.sr-only');
if (label) {
label.textContent = text;
} else {
node.textContent = text;
}
node.title = text;
node.setAttribute('aria-label', text);
}
function applyPanelState() {
els.appGrid.classList.toggle('panel-collapsed', panelCollapsed);
els.sidePanel.hidden = panelCollapsed;
els.panelToggle.setAttribute('aria-expanded', String(!panelCollapsed));
setButtonLabel(els.panelToggle, t(panelCollapsed ? 'panelShow' : 'panelHide'));
}
function togglePanel() {
panelCollapsed = !panelCollapsed;
localStorage.setItem(PANEL_KEY, panelCollapsed ? '1' : '0');
applyPanelState();
}
function applyI18n() {
document.documentElement.lang = currentLang === 'zh' ? 'zh-CN' : 'en';
document.title = t('documentTitle');
document.querySelectorAll('[data-i18n]').forEach((node) => {
node.textContent = t(node.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => {
node.placeholder = t(node.dataset.i18nPlaceholder);
});
document.querySelectorAll('[data-i18n-title]').forEach((node) => {
const label = t(node.dataset.i18nTitle);
node.title = label;
node.setAttribute('aria-label', label);
const sr = node.querySelector('.sr-only');
if (sr) sr.textContent = label;
});
els.langZh.classList.toggle('active', currentLang === 'zh');
els.langEn.classList.toggle('active', currentLang === 'en');
applyTheme();
applyPanelState();
els.precisionNote.textContent = t('precisionNote', {model: DEFAULT_MODEL, cpuModel: CPU_MODEL, quant: DEFAULT_QUANT});
syncModelPill();
setStatusKey(currentStatus.key, currentStatus.args);
if (lastRuntimeData) {
renderRuntime(lastRuntimeData, false);
} else {
setBadge(els.runtimeBadge, t('badgeRuntimeUnknown'), 'warn');
setBadge(els.modelBadge, t('badgeModelUnknown'), 'warn');
setBadge(els.streamBadge, t('badgeStreamUnknown'), 'warn');
setBadge(els.gpuBadge, t('badgeGpuNotProbed'), 'warn');
}
renderMessages();
updateCurl();
}
function setLanguage(lang) {
currentLang = lang === 'en' ? 'en' : 'zh';
localStorage.setItem(LANG_KEY, currentLang);
applyI18n();
}
function setBadge(node, text, state) {
node.textContent = text;
node.className = 'badge ' + (state || 'warn');
}
function setStatusKey(key, args = {}) {
currentStatus = {key, args};
if (key === 'statusReady') {
els.chatStatus.innerHTML = currentLang === 'zh'
? '按 <kbd>Enter</kbd> 发送,<kbd>Shift + Enter</kbd> 换行'
: 'Press <kbd>Enter</kbd> to send, <kbd>Shift + Enter</kbd> for new line';
} else {
els.chatStatus.textContent = t(key, args);
}
}
function shortModelLabel(model) {
if (!model) return 'unknown';
const parts = model.split(':');
return parts.length > 1 ? parts[parts.length - 1] : model.split('/').pop();
}
function backendForModel(model) {
if (modelBackends[model]) return modelBackends[model];
return String(model || '').endsWith('-cpu') ? 'cpu' : 'gpu';
}
function currentBackend() {
return backendForModel(els.model.value || DEFAULT_MODEL);
}
function limitsForBackend(backend) {
const limits = lastRuntimeData?.backend_limits?.[backend] || BUILD_RUNTIME_DEFAULTS.backendLimits?.[backend] || {};
return {
maxTokens: Number(limits.default_max_tokens ?? BUILD_RUNTIME_DEFAULTS.maxTokens),
maxMaxTokens: Number(limits.max_max_tokens ?? limits.default_max_tokens ?? BUILD_RUNTIME_DEFAULTS.maxMaxTokens),
ctxSize: Number(limits.ctx_size ?? BUILD_RUNTIME_DEFAULTS.ctxSize),
maxCtxSize: Number(limits.max_ctx_size ?? limits.ctx_size ?? BUILD_RUNTIME_DEFAULTS.ctxSize)
};
}
function syncModelPill() {
const model = els.model.value || DEFAULT_MODEL;
const backend = currentBackend();
if (els.activeModelPill) els.activeModelPill.textContent = shortModelLabel(model);
if (els.activeQuantPill) els.activeQuantPill.textContent = DEFAULT_QUANT;
if (els.activeBackendPill) els.activeBackendPill.textContent = backend === 'cpu' ? t('backendCpu') : t('backendGpu');
}
function createId() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'msg-' + Date.now() + '-' + Math.random().toString(16).slice(2);
}
function roleName(role) {
if (role === 'user') return t('roleUser');
if (role === 'assistant') return t('roleAssistant');
return t('roleError');
}
function fillPrompt(text) {
els.userInput.value = text;
resizeInput();
updateCurl();
updateSendState();
els.userInput.focus();
}
function renderEmptyState() {
const empty = document.createElement('div');
empty.className = 'empty-state';
const title = document.createElement('strong');
title.textContent = t('emptyTitle');
const body = document.createElement('span');
body.textContent = t('emptyBody');
const chips = document.createElement('div');
chips.className = 'prompt-chips';
for (const [label, prompt] of QUICK_PROMPTS[currentLang] || QUICK_PROMPTS.zh) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'prompt-chip';
chip.textContent = label;
chip.addEventListener('click', () => fillPrompt(prompt));
chips.appendChild(chip);
}
empty.append(title, body, chips);
return empty;
}
function renderMessages() {
els.timeline.textContent = '';
if (!messages.length) {
els.timeline.appendChild(renderEmptyState());
return;
}
for (const message of messages) {
const row = document.createElement('div');
row.className = 'message-row ' + message.role;
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = message.role === 'user' ? 'U' : (message.role === 'assistant' ? 'A' : '!');
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap';
const label = document.createElement('div');
label.className = 'role-label';
label.textContent = roleName(message.role);
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (message.pending && !message.content) {
const typing = document.createElement('span');
typing.className = 'typing';
typing.textContent = t('typing');
bubble.appendChild(typing);
} else {
bubble.textContent = message.content || '';
}
wrap.append(label, bubble);
row.append(avatar, wrap);
els.timeline.appendChild(row);
}
els.timeline.scrollTop = els.timeline.scrollHeight;
}
function updateSendState() {
els.sendBtn.disabled = uiRunning || !els.userInput.value.trim();
}
function setRunning(isRunning) {
uiRunning = isRunning;
updateSendState();
els.stopBtn.disabled = !isRunning;
els.retryBtn.disabled = isRunning || !lastUserText;
els.userInput.disabled = isRunning;
}
function numericValue(id, fallback) {
const value = Number(els[id].value);
return Number.isFinite(value) ? value : fallback;
}
function normalizedDefaults(source = runtimeDefaults) {
return {
model: source.model || DEFAULT_MODEL,
maxTokens: Number(source.maxTokens ?? BUILD_RUNTIME_DEFAULTS.maxTokens),
maxMaxTokens: Number(source.maxMaxTokens ?? source.maxTokens ?? BUILD_RUNTIME_DEFAULTS.maxMaxTokens),
ctxSize: Number(source.ctxSize ?? BUILD_RUNTIME_DEFAULTS.ctxSize),
maxCtxSize: Number(source.maxCtxSize ?? source.ctxSize ?? BUILD_RUNTIME_DEFAULTS.ctxSize),
temperature: Number(source.temperature ?? BUILD_RUNTIME_DEFAULTS.temperature),
topP: Number(source.topP ?? BUILD_RUNTIME_DEFAULTS.topP),
topK: Number(source.topK ?? BUILD_RUNTIME_DEFAULTS.topK),
minP: Number(source.minP ?? BUILD_RUNTIME_DEFAULTS.minP),
presencePenalty: Number(source.presencePenalty ?? BUILD_RUNTIME_DEFAULTS.presencePenalty),
repetitionPenalty: Number(source.repetitionPenalty ?? BUILD_RUNTIME_DEFAULTS.repetitionPenalty),
backendLimits: source.backendLimits || BUILD_RUNTIME_DEFAULTS.backendLimits || {},
stream: source.stream !== false,
includeUsage: source.includeUsage !== false
};
}
function setControlValues(values) {
const next = normalizedDefaults({...runtimeDefaults, ...values});
if (next.model && Array.from(els.model.options).some((option) => option.value === next.model)) {
els.model.value = next.model;
}
const backendLimits = limitsForBackend(backendForModel(els.model.value || next.model || DEFAULT_MODEL));
const maxMaxTokens = backendLimits.maxMaxTokens || next.maxMaxTokens || next.maxTokens || 32000;
const maxCtxSize = Math.max(1024, backendLimits.maxCtxSize || backendLimits.ctxSize || next.maxCtxSize || next.ctxSize || 1024);
els.maxTokens.max = String(maxMaxTokens);
els.ctxSize.max = String(maxCtxSize);
els.maxTokens.value = Math.min(next.maxTokens, maxMaxTokens);
els.ctxSize.value = Math.min(next.ctxSize, maxCtxSize);
els.temperature.value = next.temperature;
els.topP.value = next.topP;
els.topK.value = next.topK;
els.minP.value = next.minP;
els.presencePenalty.value = next.presencePenalty;
els.repetitionPenalty.value = next.repetitionPenalty;
els.stream.checked = Boolean(next.stream);
els.includeUsage.checked = Boolean(next.includeUsage);
syncModelPill();
updateCurl();
}
function currentParameterSettings() {
return {
model: els.model.value || DEFAULT_MODEL,
maxTokens: numericValue('maxTokens', runtimeDefaults.maxTokens),
ctxSize: numericValue('ctxSize', runtimeDefaults.ctxSize),
temperature: numericValue('temperature', runtimeDefaults.temperature),
topP: numericValue('topP', runtimeDefaults.topP),
topK: numericValue('topK', runtimeDefaults.topK),
minP: numericValue('minP', runtimeDefaults.minP),
presencePenalty: numericValue('presencePenalty', runtimeDefaults.presencePenalty),
repetitionPenalty: numericValue('repetitionPenalty', runtimeDefaults.repetitionPenalty),
stream: els.stream.checked,
includeUsage: els.includeUsage.checked
};
}
function saveCurrentSettings() {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(currentParameterSettings()));
hasSavedParameterSettings = true;
setStatusKey('settingsSaved');
closeSettings();
}
function loadSavedSettings() {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return false;
hasSavedParameterSettings = true;
setControlValues(parsed);
return true;
} catch (error) {
localStorage.removeItem(SETTINGS_KEY);
return false;
}
}
function applyStablePreset() {
const limits = limitsForBackend(currentBackend());
setControlValues({
...runtimeDefaults,
model: els.model.value || DEFAULT_MODEL,
maxTokens: Math.min(limits.maxTokens || runtimeDefaults.maxTokens || 256, 256),
ctxSize: Math.min(limits.ctxSize || runtimeDefaults.ctxSize || 4096, 4096),
temperature: Math.min(runtimeDefaults.temperature ?? 0.2, 0.2),
stream: true,
includeUsage: true
});
}
function applyLongPreset() {
const limits = limitsForBackend(currentBackend());
setControlValues({
...runtimeDefaults,
model: els.model.value || DEFAULT_MODEL,
maxTokens: limits.maxTokens || runtimeDefaults.maxTokens,
maxMaxTokens: limits.maxMaxTokens || runtimeDefaults.maxMaxTokens,
ctxSize: limits.ctxSize || runtimeDefaults.ctxSize,
maxCtxSize: limits.maxCtxSize || runtimeDefaults.maxCtxSize
});
}
function handleModelChange() {
const limits = limitsForBackend(currentBackend());
setControlValues({
...currentParameterSettings(),
model: els.model.value || DEFAULT_MODEL,
maxTokens: Math.min(numericValue('maxTokens', limits.maxTokens || runtimeDefaults.maxTokens), limits.maxMaxTokens || runtimeDefaults.maxMaxTokens || 32000),
ctxSize: Math.min(numericValue('ctxSize', limits.ctxSize || runtimeDefaults.ctxSize), limits.maxCtxSize || limits.ctxSize || runtimeDefaults.ctxSize || 1024)
});
}
function chatMessagesForRequest(nextUserText) {
const result = messages
.filter((item) => item.role === 'user' || item.role === 'assistant')
.filter((item) => item.content && !item.pending)
.map((item) => ({role: item.role, content: item.content}));
if (nextUserText) result.push({role: 'user', content: nextUserText});
return result;
}
function requestBody(nextUserText) {
const backend = currentBackend();
const limits = limitsForBackend(backend);
const maxTokens = Math.min(
numericValue('maxTokens', limits.maxTokens || runtimeDefaults.maxTokens),
limits.maxMaxTokens || runtimeDefaults.maxMaxTokens || Number.MAX_SAFE_INTEGER
);
const body = {
model: els.model.value || DEFAULT_MODEL,
messages: chatMessagesForRequest(nextUserText),
max_tokens: maxTokens,
temperature: numericValue('temperature', runtimeDefaults.temperature),
top_p: numericValue('topP', runtimeDefaults.topP),
top_k: numericValue('topK', runtimeDefaults.topK),
min_p: numericValue('minP', runtimeDefaults.minP),
presence_penalty: numericValue('presencePenalty', runtimeDefaults.presencePenalty),
repetition_penalty: numericValue('repetitionPenalty', runtimeDefaults.repetitionPenalty),
stream: els.stream.checked,
extra_body: {
thinking: false,
diffusion_visual: false,
max_denoising_steps: Number(APP.diffusionMaxSteps || 48),
diffusion_kv_cache: APP.diffusionKvCache || 'auto',
n_gpu_layers: Number(APP.nGpuLayers || 99)
}
};
if (body.stream && els.includeUsage.checked) {
body.stream_options = {include_usage: true};
}
return body;
}
function updateCurl() {
const draft = els.userInput.value.trim();
const body = requestBody(draft || t('samplePrompt'));
els.curlBox.textContent = [
'curl -sS ' + (body.stream ? '-N ' : '') + '--max-time 900 ' + location.origin + '/v1/chat/completions \\',
" -H 'Content-Type: application/json' \\",
" -d '" + JSON.stringify(body).replaceAll("'", "'\\''") + "'"
].join('\n');
updateSendState();
}
function errorMessageFromPayload(data) {
if (data?.error?.type === 'empty_output') return t('emptyOutputMessage');
return data?.error?.message || JSON.stringify(data);
}
function formatToolCalls(toolCalls) {
if (!Array.isArray(toolCalls) || !toolCalls.length) return '';
return JSON.stringify({tool_calls: toolCalls}, null, 2);
}
async function copyCurl() {
updateCurl();
await navigator.clipboard.writeText(els.curlBox.textContent);
setStatusKey('statusCurlCopied');
}
async function sendMessage(textOverride) {
const text = (textOverride || els.userInput.value).trim();
if (!text || activeController) return;
const payload = requestBody(text);
lastUserText = text;
messages.push({id: createId(), role: 'user', content: text});
const assistant = {id: createId(), role: 'assistant', content: '', pending: true};
messages.push(assistant);
els.userInput.value = '';
resizeInput();
renderMessages();
updateCurl();
updateSendState();
activeController = new AbortController();
setRunning(true);
setStatusKey('statusSent');
try {
if (payload.stream) {
const streamedText = await runStream(payload, assistant, activeController.signal);
if (!streamedText.trim()) {
setStatusKey('statusStreamFallback');
assistant.pending = true;
assistant.content = '';
renderMessages();
await runNonStream(
{...payload, stream: false, temperature: Math.min(payload.temperature, 0.2)},
assistant,
activeController.signal
);
}
} else {
await runNonStream(payload, assistant, activeController.signal);
}
if (!assistant.content.trim() && payload.temperature > 0.2) {
setStatusKey('statusStableFallback');
assistant.pending = true;
assistant.content = '';
renderMessages();
await runNonStream({...payload, stream: false, temperature: 0.2}, assistant, activeController.signal);
}
if (!assistant.content.trim()) {
throw new Error(t('emptyOutputFinal'));
}
assistant.pending = false;
setStatusKey('statusDone');
} catch (error) {
assistant.pending = false;
assistant.role = 'error';
assistant.content = error.name === 'AbortError' ? t('requestStopped') : String(error.message || error);
setStatusKey(error.name === 'AbortError' ? 'statusStopped' : 'statusFailed');
} finally {
activeController = null;
setRunning(false);
renderMessages();
loadRuntime();
loadLogs();
}
}
async function runNonStream(body, assistant, signal) {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({...body, stream: false}),
signal
});
const data = await res.json();
lastRawEvents = [data];
if (!res.ok) {
throw new Error(errorMessageFromPayload(data));
}
const message = data.choices?.[0]?.message || {};
assistant.content = message.content || formatToolCalls(message.tool_calls);
assistant.pending = false;
renderMessages();
return assistant.content;
}
async function runStream(body, assistant, signal) {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({...body, stream: true}),
signal
});
if (!res.ok) {
let data = {};
try {
data = await res.json();
} catch (_) {
data = {error: {message: await res.text()}};
}
throw new Error(errorMessageFromPayload(data));
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
lastRawEvents = [];
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, {stream: true});
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
const lines = part.split('\n').filter((line) => line.startsWith('data:'));
for (const line of lines) {
const payload = line.slice(5).trim();
if (!payload) continue;
if (payload === '[DONE]') return text;
const event = JSON.parse(payload);
lastRawEvents.push(event);
if (event.error) {
throw new Error(errorMessageFromPayload(event));
}
const delta = event.choices?.[0]?.delta?.content || '';
const toolCalls = event.choices?.[0]?.delta?.tool_calls;
if (Array.isArray(toolCalls) && toolCalls.length) {
text = formatToolCalls(toolCalls);
assistant.content = text;
assistant.pending = false;
setStatusKey('statusGenerating');
renderMessages();
}
if (delta) {
text += delta;
assistant.content = text;
assistant.pending = false;
setStatusKey('statusGenerating');
renderMessages();
}
}
}
}
return text;
}
function stopRun() {
if (activeController) activeController.abort();
}
function clearChat() {
if (activeController) activeController.abort();
messages = [];
lastUserText = '';
lastRawEvents = [];
els.userInput.value = '';
resizeInput();
renderMessages();
setStatusKey('statusReady');
updateCurl();
setRunning(false);
}
function retryLast() {
if (lastUserText && !activeController) {
sendMessage(lastUserText);
}
}
function addKv(container, rows) {
container.textContent = '';
for (const [key, value] of rows) {
const k = document.createElement('span');
k.textContent = key;
const v = document.createElement('span');
v.className = 'mono';
v.textContent = String(value ?? 'n/a');
container.append(k, v);
}
}
async function loadModels() {
try {
const res = await fetch('/v1/models');
const data = await res.json();
const selected = els.model.value || DEFAULT_MODEL;
els.model.textContent = '';
modelBackends = {};
for (const item of data.data || []) {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.id;
option.title = item.id;
modelBackends[item.id] = item.inference_backend || item.backend || backendForModel(item.id);
if (item.id === selected || (!selected && item.id === DEFAULT_MODEL)) option.selected = true;
els.model.appendChild(option);
}
if (!els.model.value && Array.from(els.model.options).some((option) => option.value === DEFAULT_MODEL)) {
els.model.value = DEFAULT_MODEL;
}
syncModelPill();
} catch (error) {
console.warn(error);
}
}
function renderRuntime(data, syncControls) {
const runtime = data.runtime || {};
const runtimeReady = Boolean(data.ok && runtime.bin_exists && runtime.model_file_exists);
const modelCached = Boolean(runtime.model_file_exists);
const streamReady = Boolean(data.ok);
setBadge(els.runtimeBadge, runtimeReady ? t('runtimeReady') : t('runtimePending'), runtimeReady ? 'good' : 'warn');
setBadge(els.modelBadge, modelCached ? t('modelCached') : t('modelMissing'), modelCached ? 'good' : 'warn');
setBadge(els.streamBadge, streamReady ? t('streamOn') : t('streamOff'), streamReady ? 'good' : 'warn');
runtimeDefaults = normalizedDefaults({
model: els.model.value || DEFAULT_MODEL,
maxTokens: data.limits?.default_max_tokens,
maxMaxTokens: data.limits?.max_max_tokens,
ctxSize: 4096,
maxCtxSize: 4096,
temperature: runtimeDefaults.temperature,
topP: runtimeDefaults.topP,
topK: runtimeDefaults.topK,
minP: runtimeDefaults.minP,
presencePenalty: runtimeDefaults.presencePenalty,
repetitionPenalty: runtimeDefaults.repetitionPenalty,
backendLimits: {
gpu: {
default_max_tokens: data.limits?.default_max_tokens,
max_max_tokens: data.limits?.max_max_tokens,
ctx_size: 4096,
max_ctx_size: 4096
}
},
stream: true,
includeUsage: true
});
if (syncControls) {
els.maxTokens.max = runtimeDefaults.maxMaxTokens || runtimeDefaults.maxTokens;
els.ctxSize.max = Math.max(1024, runtimeDefaults.maxCtxSize || runtimeDefaults.ctxSize || 4096);
if (!hasSavedParameterSettings) {
setControlValues(runtimeDefaults);
}
}
els.modelLine.textContent = t('modelLine', {
model: data.default_model || DEFAULT_MODEL,
quant: DEFAULT_QUANT
});
if (els.modelSource) {
els.modelSource.href = APP.modelSourceUrl;
els.modelSource.textContent = APP.modelSourceUrl;
}
addKv(els.runtimeKv, [
[t('kvZeroGPU'), data.zero_gpu_size],
[t('kvRunner'), runtime.bin_exists ? runtime.bin : 'missing'],
[t('kvStream'), 'final delta'],
[t('kvDefaultQuant'), DEFAULT_QUANT],
[t('kvSource'), APP.modelSourceUrl],
[t('kvDefaultMax'), data.limits?.default_max_tokens],
[t('kvDefaultCtx'), 4096],
[t('kvTemperature'), runtimeDefaults.temperature],
[t('kvTopP'), runtimeDefaults.topP],
[t('kvTopK'), runtimeDefaults.topK],
[t('kvMinP'), runtimeDefaults.minP],
[t('kvPresence'), runtimeDefaults.presencePenalty],
[t('kvRepetition'), runtimeDefaults.repetitionPenalty],
[t('kvModelCached'), runtime.model_file_exists],
[t('kvModelGB'), runtime.model_file],
[t('kvBackend'), 'GPU'],
[t('kvLastQuant'), DEFAULT_QUANT],
[t('kvLastOutput'), data.service],
[t('kvLastDuration'), data.limits?.cli_timeout_seconds ? data.limits.cli_timeout_seconds + 's timeout' : 'n/a'],
[t('kvDataDir'), data.paths?.data_dir]
]);
}
async function loadRuntime() {
try {
const res = await fetch('/v2/runtime');
const data = await res.json();
lastRuntimeData = data;
renderRuntime(data, true);
} catch (error) {
setBadge(els.runtimeBadge, t('runtimeError'), 'bad');
}
}
async function probeGpu() {
setBadge(els.gpuBadge, t('gpuProbing'), 'warn');
try {
const res = await fetch('/v2/zerogpu/probe?cli=1');
const data = await res.json();
setBadge(els.gpuBadge, data.ok ? t('gpuAllocated') : t('gpuUnavailable'), data.ok ? 'good' : 'bad');
setStatusKey('statusGpuDone');
if (data.cuda_visible_devices) {
els.chatStatus.textContent = 'GPU: ' + data.cuda_visible_devices;
}
loadLogs();
} catch (error) {
setBadge(els.gpuBadge, t('gpuProbeFailed'), 'bad');
setStatusKey('statusGpuFailed');
}
}
async function loadLogs() {
try {
const limit = encodeURIComponent(els.logLimit.value || '40');
const event = els.logEvent.value.trim();
const requestId = els.logRequestId.value.trim();
const url = '/v2/logs?limit=' + limit
+ (event ? '&event=' + encodeURIComponent(event) : '')
+ (requestId ? '&request_id=' + encodeURIComponent(requestId) : '');
const res = await fetch(url);
const data = await res.json();
els.logs.textContent = '';
const rows = data.data || [];
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'log-item';
empty.textContent = t('logsEmpty');
els.logs.appendChild(empty);
return;
}
for (const item of rows.slice().reverse()) {
const row = document.createElement('div');
row.className = 'log-item';
const title = document.createElement('strong');
title.textContent = item.event || 'event';
const body = document.createElement('div');
body.className = 'mono';
body.textContent = (item.ts || '') + ' ' + JSON.stringify(item);
row.append(title, body);
els.logs.appendChild(row);
}
} catch (error) {
els.logs.textContent = t('logsFailed', {message: String(error)});
}
}
async function loadCatalog() {
try {
const res = await fetch('/v2');
const data = await res.json();
els.apiList.textContent = '';
const note = document.createElement('div');
note.className = 'api-note';
note.textContent = t('apiCompatNote');
els.apiList.appendChild(note);
for (const item of data.endpoints || []) {
const row = document.createElement('div');
row.className = 'api-row';
const method = document.createElement('strong');
method.textContent = item.method;
const path = document.createElement('code');
path.textContent = item.path;
row.append(method, path);
els.apiList.appendChild(row);
}
} catch (error) {
console.warn(error);
}
}
function resizeInput() {
els.userInput.style.height = 'auto';
els.userInput.style.height = Math.min(180, Math.max(46, els.userInput.scrollHeight)) + 'px';
}
function setSettingsTab(tabName) {
const name = tabName || 'general';
document.querySelectorAll('[data-settings-tab]').forEach((button) => {
button.classList.toggle('active', button.dataset.settingsTab === name);
});
document.querySelectorAll('[data-settings-panel]').forEach((panel) => {
panel.classList.toggle('active', panel.dataset.settingsPanel === name);
});
}
function openSettings(tabName) {
setSettingsTab(tabName || 'general');
els.settingsOverlay.hidden = false;
document.body.style.overflow = 'hidden';
}
function closeSettings() {
els.settingsOverlay.hidden = true;
document.body.style.overflow = '';
els.settingsBtn.focus();
}
function resetSettings() {
setTheme('system');
setLanguage('zh');
localStorage.removeItem(SETTINGS_KEY);
hasSavedParameterSettings = false;
setControlValues(runtimeDefaults);
loadRuntime();
}
els.composer.addEventListener('submit', (event) => {
event.preventDefault();
sendMessage();
});
els.userInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
els.composer.requestSubmit();
}
});
els.userInput.addEventListener('input', () => {
resizeInput();
updateCurl();
});
for (const id of ['model', 'maxTokens', 'ctxSize', 'temperature', 'topP', 'topK', 'minP', 'presencePenalty', 'repetitionPenalty', 'stream', 'includeUsage']) {
els[id].addEventListener('change', updateCurl);
els[id].addEventListener('input', updateCurl);
}
els.model.addEventListener('change', handleModelChange);
els.langZh.addEventListener('click', () => setLanguage('zh'));
els.langEn.addEventListener('click', () => setLanguage('en'));
els.themeSystem.addEventListener('click', () => setTheme('system'));
els.themeLight.addEventListener('click', () => setTheme('light'));
els.themeDark.addEventListener('click', () => setTheme('dark'));
els.panelToggle.addEventListener('click', togglePanel);
els.stablePresetBtn.addEventListener('click', applyStablePreset);
els.longPresetBtn.addEventListener('click', applyLongPreset);
els.stopBtn.addEventListener('click', stopRun);
els.newChatBtn.addEventListener('click', clearChat);
els.retryBtn.addEventListener('click', retryLast);
els.refreshRuntimeBtn.addEventListener('click', loadRuntime);
els.probeGpuBtn.addEventListener('click', probeGpu);
els.refreshLogsBtn.addEventListener('click', loadLogs);
els.logRequestId.addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadLogs();
});
els.logEvent.addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadLogs();
});
els.copyCurlBtn.addEventListener('click', copyCurl);
els.settingsBtn.addEventListener('click', () => openSettings('general'));
els.settingsClose.addEventListener('click', closeSettings);
els.settingsSave.addEventListener('click', saveCurrentSettings);
els.settingsReset.addEventListener('click', resetSettings);
els.settingsOverlay.addEventListener('click', (event) => {
if (event.target === els.settingsOverlay) closeSettings();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !els.settingsOverlay.hidden) closeSettings();
});
document.querySelectorAll('[data-settings-tab]').forEach((button) => {
button.addEventListener('click', () => setSettingsTab(button.dataset.settingsTab));
});
document.querySelectorAll('[data-settings-open]').forEach((button) => {
button.addEventListener('click', () => openSettings(button.dataset.settingsOpen || 'general'));
});
if (themeMedia) {
const onThemeMediaChange = () => {
if (currentTheme === 'system') applyTheme();
};
if (themeMedia.addEventListener) {
themeMedia.addEventListener('change', onThemeMediaChange);
} else if (themeMedia.addListener) {
themeMedia.addListener(onThemeMediaChange);
}
}
setControlValues(runtimeDefaults);
loadSavedSettings();
applyI18n();
renderMessages();
resizeInput();
updateCurl();
updateSendState();
loadModels().then(updateCurl);
loadCatalog();
loadRuntime();
loadLogs();
</script>
</body>
</html>