zcode-api / src /server /webui.txt
bolikoto's picture
Deploy minimal Docker Space
67d18ac verified
Raw History Blame Contribute Delete
69.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>zcode-proxy</title>
<!-- Markdown + sanitize + code highlight. All optional: the UI degrades to
escaped plaintext if the CDN is unreachable. -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
<style>
:root {
--accent: #6366f1;
--accent-strong: #4f46e5;
--radius: 12px;
--sidebar-w: 264px;
}
:root[data-theme="dark"] {
--bg: #212121;
--sidebar: #171717;
--elevated: #2f2f2f;
--elevated-hover: #383838;
--text: #ececec;
--muted: #9a9a9a;
--border: #3a3a3a;
--user-bubble: #2f2f2f;
--danger: #ef4444;
}
:root[data-theme="light"] {
--bg: #ffffff;
--sidebar: #f7f7f8;
--elevated: #f0f0f0;
--elevated-hover: #e6e6e6;
--text: #1a1a1a;
--muted: #6e6e6e;
--border: #e5e5e5;
--user-bubble: #f0f0f0;
--danger: #dc2626;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button { font-family: inherit; cursor: pointer; }
input, textarea, select { font-family: inherit; font-size: inherit; color: inherit; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
.app { display: flex; height: 100vh; width: 100vw; }
/* ---------- Sidebar ---------- */
.sidebar {
width: var(--sidebar-w);
flex: 0 0 var(--sidebar-w);
background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: margin-left .2s ease;
}
.sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-w)); }
.sidebar-head { padding: 12px; display: flex; gap: 8px; }
.btn-new {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text); font-weight: 500;
}
.btn-new:hover { background: var(--elevated); }
.conv-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
.conv {
display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin: 2px 0;
border-radius: 8px; cursor: pointer; color: var(--text); position: relative;
white-space: nowrap; overflow: hidden;
}
.conv .conv-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.conv:hover { background: var(--elevated); }
.conv.active { background: var(--elevated); }
.conv .conv-del {
opacity: 0; border: none; background: transparent; color: var(--muted);
padding: 2px 6px; border-radius: 6px; flex: 0 0 auto;
}
.conv:hover .conv-del, .conv.active .conv-del { opacity: 1; }
.conv .conv-del:hover { color: var(--danger); background: var(--elevated-hover); }
.sidebar-foot { padding: 10px 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
.icon-btn {
border: 1px solid var(--border); background: transparent; color: var(--text);
border-radius: var(--radius); padding: 8px 10px; display: inline-flex; align-items: center; gap: 6px;
}
.icon-btn:hover { background: var(--elevated); }
.sidebar-foot .icon-btn { flex: 1; justify-content: center; }
/* ---------- Main ---------- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
height: 52px; flex: 0 0 52px; display: flex; align-items: center; gap: 10px;
padding: 0 14px; border-bottom: 1px solid var(--border);
}
.topbar .model-wrap { display: flex; align-items: center; gap: 6px; }
.topbar select#model {
background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;
padding: 7px 10px; color: var(--text); max-width: 220px;
}
.think-wrap { display: flex; align-items: center; gap: 6px; }
.think-wrap select#reasoning-effort {
background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;
padding: 7px 8px; color: var(--text); font-size: 13px;
}
.think-wrap select#reasoning-effort:disabled { opacity: .5; }
.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: 0 0 9px; }
.status-dot.ok { background: #22c55e; }
.status-dot.err { background: var(--danger); }
.topbar .spacer { flex: 1; }
.messages { flex: 1; overflow-y: auto; padding: 20px 0 12px; scroll-behavior: smooth; }
.msg-list { max-width: 768px; margin: 0 auto; padding: 0 18px; display: flex; flex-direction: column; gap: 22px; }
.msg { display: flex; flex-direction: column; gap: 8px; }
.msg .role { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.msg.user .bubble {
background: var(--user-bubble); align-self: flex-end; max-width: 85%;
padding: 10px 14px; border-radius: var(--radius); border-top-right-radius: 4px;
white-space: pre-wrap; word-break: break-word;
}
.msg.assistant .content { color: var(--text); }
.msg-images { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 6px; }
.msg-images img { max-width: 200px; max-height: 200px; border-radius: 8px; border: 1px solid var(--border); }
.think {
border: 1px solid var(--border); border-radius: 10px; background: var(--elevated);
margin-bottom: 8px; overflow: hidden;
}
.think summary {
cursor: pointer; padding: 8px 12px; font-size: 13px; color: var(--muted);
list-style: none; display: flex; align-items: center; gap: 6px; user-select: none;
}
.think summary::-webkit-details-marker { display: none; }
.think summary::before { content: "\25B6"; font-size: 9px; transition: transform .15s; }
.think[open] summary::before { transform: rotate(90deg); }
.think .think-body { padding: 0 12px 10px; font-size: 13px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
.msg-meta { font-size: 12px; color: var(--muted); display: flex; gap: 10px; align-items: center; }
.msg-meta .regen { border: none; background: transparent; color: var(--muted); padding: 2px 6px; border-radius: 6px; }
.msg-meta .regen:hover { color: var(--text); background: var(--elevated); }
.empty { text-align: center; color: var(--muted); padding: 60px 20px; }
.empty h1 { font-size: 22px; margin: 0 0 6px; color: var(--text); font-weight: 600; }
/* markdown rendering */
.md p { margin: 0 0 12px; }
.md p:last-child { margin-bottom: 0; }
.md h1,.md h2,.md h3,.md h4 { margin: 18px 0 8px; line-height: 1.3; font-weight: 600; }
.md h1 { font-size: 1.5em; } .md h2 { font-size: 1.3em; } .md h3 { font-size: 1.15em; }
.md ul,.md ol { margin: 0 0 12px; padding-left: 24px; }
.md li { margin: 3px 0; }
.md a { color: var(--accent); }
.md blockquote { border-left: 3px solid var(--border); margin: 0 0 12px; padding: 2px 14px; color: var(--muted); }
.md code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .9em; }
.md :not(pre) > code { background: var(--elevated); padding: 1px 5px; border-radius: 5px; }
.md pre { position: relative; margin: 0 0 12px; border-radius: 10px; overflow: hidden; border: 1px solid var(--border); }
.md pre .code-head {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 12px; font-size: 12px; color: #adbac7;
background: #2d333b; border-bottom: 1px solid var(--border);
}
.md pre code { display: block; padding: 12px 14px; overflow-x: auto; background: #0d1117; }
.md pre .copy-btn {
border: none; background: transparent; color: #adbac7; font-size: 12px; padding: 2px 6px; border-radius: 5px;
}
.md pre .copy-btn:hover { background: rgba(255,255,255,.1); color: #fff; }
.md pre .code-actions { display: flex; align-items: center; gap: 6px; }
.md pre .html-preview {
display: block; width: 100%; min-height: 200px; max-height: 80vh;
border: 0; background: #fff; resize: vertical;
}
.md table { border-collapse: collapse; margin: 0 0 12px; display: block; overflow-x: auto; }
.md th,.md td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; }
.md hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
.md img { max-width: 100%; border-radius: 8px; }
/* ---------- Composer ---------- */
.composer-wrap { flex: 0 0 auto; padding: 10px 18px 18px; }
.composer {
max-width: 768px; margin: 0 auto; background: var(--elevated);
border: 1px solid var(--border); border-radius: 22px; padding: 8px 8px 8px 14px;
display: flex; align-items: flex-end; gap: 6px;
}
.composer textarea {
flex: 1; background: transparent; border: none; outline: none; resize: none;
max-height: 200px; padding: 8px 2px; line-height: 1.5; color: var(--text);
}
.composer .c-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.composer .think-bar { display: flex; align-items: center; gap: 4px; padding-right: 2px; }
.composer .pill {
border: 1px solid var(--border); background: var(--elevated-hover); color: var(--text);
border-radius: 14px; padding: 0 10px; font-size: 12px; height: 32px; white-space: nowrap;
}
.composer .pill.on { background: var(--accent); color: #fff; border-color: var(--accent); }
.composer select#reasoning-effort {
background: var(--elevated-hover); border: 1px solid var(--border); color: var(--text);
border-radius: 14px; padding: 0 6px; font-size: 12px; height: 32px; max-width: 84px;
}
.composer select#reasoning-effort:disabled { opacity: .45; }
.composer .c-btn {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: var(--accent); color: #fff; display: inline-flex; align-items: center; justify-content: center;
}
.composer .c-btn:disabled { opacity: .5; }
.composer .c-btn.send:hover:not(:disabled) { background: var(--accent-strong); }
.composer .c-btn.stop { background: var(--danger); }
.composer .c-btn.attach { background: var(--elevated-hover); color: var(--text); }
.composer .c-btn.attach[hidden] { display: none; }
.composer .c-btn.attach.active { background: var(--accent); color: #fff; }
.attach-input { display: none; }
.attach-preview { max-width: 768px; margin: 0 auto 8px; display: flex; flex-wrap: wrap; gap: 8px; }
.attach-preview:empty { display: none; }
.attach-preview .ap { position: relative; }
.attach-preview img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); }
.attach-preview .ap-rm {
position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%;
background: var(--danger); color: #fff; border: none; font-size: 11px; line-height: 18px; padding: 0;
}
.composer-hint { max-width: 768px; margin: 6px auto 0; text-align: center; font-size: 12px; color: var(--muted); }
/* ---------- Settings modal ---------- */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; z-index: 50; }
.overlay.open { display: flex; align-items: center; justify-content: center; padding: 20px; }
.modal {
background: var(--bg); border: 1px solid var(--border); border-radius: 16px;
width: 100%; max-width: 560px; max-height: 88vh; overflow-y: auto; padding: 22px;
}
.modal h2 { margin: 0 0 4px; font-size: 18px; }
.modal .sub { color: var(--muted); font-size: 13px; margin: 0 0 18px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; }
.field .hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
.field input[type=text], .field input[type=password], .field input[type=number],
.field textarea, .field select {
width: 100%; background: var(--elevated); border: 1px solid var(--border);
border-radius: 8px; padding: 9px 11px; color: var(--text); outline: none;
}
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 70px; }
.row { display: flex; gap: 12px; } .row .field { flex: 1; }
.range-row { display: flex; align-items: center; gap: 12px; }
.range-row input[type=range] { flex: 1; accent-color: var(--accent); }
.range-row .val { width: 52px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.switch { display: inline-flex; align-items: center; gap: 8px; }
.switch input { accent-color: var(--accent); width: 16px; height: 16px; }
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
.btn { border: 1px solid var(--border); background: var(--elevated); color: var(--text); border-radius: 8px; padding: 9px 16px; }
.btn:hover { background: var(--elevated-hover); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent-strong); }
.banner {
background: rgba(239,68,68,.12); border: 1px solid var(--danger); color: var(--text);
padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; display: none;
}
.mcp-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.mcp-summary { color: var(--muted); font-size: 13px; }
.mcp-srv { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; font-size: 13px; }
.mcp-srv .mcp-srv-head { display: flex; align-items: center; gap: 8px; }
.mcp-srv .mcp-label { font-weight: 600; }
.mcp-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: 0 0 8px; }
.mcp-dot.ok { background: #22c55e; } .mcp-dot.err { background: var(--danger); } .mcp-dot.busy { background: #eab308; }
.mcp-srv .mcp-state { color: var(--muted); font-size: 12px; }
.mcp-srv .mcp-err { color: var(--danger); font-size: 12px; margin-top: 4px; word-break: break-word; }
.mcp-tools { margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.mcp-tool-chip { display: inline-block; background: var(--elevated); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; margin: 2px 2px 0 0; }
.msg.tool .tool-body {
background: var(--elevated); border: 1px dashed var(--border); border-radius: 8px;
padding: 8px 10px; font-size: 12px; color: var(--muted); white-space: pre-wrap; word-break: break-word;
}
.msg .toolcalls { font-size: 12px; color: var(--muted); margin-top: 4px; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--elevated); border: 1px solid var(--border); color: var(--text);
padding: 10px 16px; border-radius: 10px; font-size: 13px; opacity: 0;
transition: opacity .2s; z-index: 100; pointer-events: none;
}
.toast.show { opacity: 1; }
@media (max-width: 720px) {
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; box-shadow: 2px 0 12px rgba(0,0,0,.3); }
.sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-w)); }
.topbar select#model { max-width: 140px; }
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-head">
<button class="btn-new" id="btn-new" title="New chat">+ New chat</button>
</div>
<div class="conv-list" id="conv-list"></div>
<div class="sidebar-foot">
<button class="icon-btn" id="btn-settings" title="Settings">Settings</button>
<button class="icon-btn" id="btn-theme" title="Switch theme">Dark mode</button>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="topbar">
<button class="icon-btn" id="btn-menu" title="Toggle sidebar" style="flex:0 0 auto;width:38px;justify-content:center;">&#9776;</button>
<span class="status-dot" id="status-dot" title="Connection status"></span>
<div class="model-wrap">
<select id="model" title="Model"></select>
</div>
<div class="spacer"></div>
</div>
<div class="messages" id="messages">
<div class="msg-list" id="msg-list"></div>
</div>
<div class="composer-wrap">
<div class="attach-preview" id="attach-preview"></div>
<div class="composer">
<textarea id="input" rows="1" placeholder="Message the model&#8230; (Enter to send, Shift+Enter for newline)"></textarea>
<div class="c-actions">
<div class="think-bar">
<button class="pill" id="btn-think" type="button" title="Toggle deep thinking">Think: on</button>
<select id="reasoning-effort" title="Reasoning effort (GLM-5.2+)"></select>
</div>
<button class="c-btn attach" id="btn-attach" title="Attach image (vision models)" hidden>&#128206;</button>
<button class="c-btn send" id="btn-send" title="Send">&#8593;</button>
</div>
</div>
<div class="composer-hint" id="hint"></div>
<input type="file" class="attach-input" id="attach-input" accept="image/*" multiple />
</div>
</main>
</div>
<!-- Settings modal -->
<div class="overlay" id="overlay">
<div class="modal" role="dialog" aria-modal="true">
<h2>Settings</h2>
<p class="sub">Auto-saved to this browser. The proxy API key is sent on every request.</p>
<div class="banner" id="banner"></div>
<div class="field">
<label for="s-apikey">Proxy API key</label>
<input type="password" id="s-apikey" autocomplete="off" placeholder="(leave blank if proxy has no key)" />
<div class="hint">Sent as <code>Authorization: Bearer &lt;key&gt;</code>.</div>
</div>
<div class="field">
<label for="s-system">System prompt</label>
<textarea id="s-system" placeholder="e.g. You are a concise senior engineer."></textarea>
</div>
<div class="row">
<div class="field">
<label>Temperature</label>
<div class="range-row">
<input type="range" id="s-temp" min="0" max="1" step="0.01" />
<span class="val" id="s-temp-val">1.00</span>
</div>
</div>
<div class="field">
<label>Top-p</label>
<div class="range-row">
<input type="range" id="s-topp" min="0.01" max="1" step="0.01" />
<span class="val" id="s-topp-val">0.95</span>
</div>
</div>
</div>
<div class="row">
<div class="field">
<label for="s-maxtok">Max output tokens</label>
<input type="number" id="s-maxtok" min="1" max="131072" step="1" />
</div>
<div class="field">
<label>Sampling</label>
<div class="switch" style="margin-top:10px;">
<input type="checkbox" id="s-dosample" />
<span>do_sample (disable for deterministic output)</span>
</div>
</div>
</div>
<div class="field">
<label>MCP tool calling</label>
<div class="switch" style="margin-bottom:8px;">
<input type="checkbox" id="s-mcpenable" />
<span>Enable tools (when off, no tools are sent even if servers are connected)</span>
</div>
<label for="s-mcp" style="margin-top:6px;">MCP servers (one per line)</label>
<textarea id="s-mcp" placeholder="https://mcp.example.com/mcp&#10;https://mcp.github.com/mcp,ghp_xxx&#10;http://localhost:3001/mcp"></textarea>
<div class="hint">Format: <code>URL</code> or <code>URL,key</code> (comma-separated). Key sent as <code>Authorization: Bearer</code>. If a server needs the key in the URL, embed it there directly. Caution: keys are stored in plaintext in localStorage.</div>
</div>
<div class="field">
<label for="s-cors">CORS proxy (optional)</label>
<input type="text" id="s-cors" placeholder="https://corsproxy.io/?url= (leave blank for no CORS)" />
<div class="hint">Needed when servers block browser CORS.</div>
</div>
<div class="field">
<label>MCP connection</label>
<div class="mcp-bar">
<button class="btn" id="btn-mcp-connect" type="button">Connect all</button>
<button class="btn" id="btn-mcp-disconnect" type="button">Disconnect</button>
<span class="mcp-summary" id="mcp-summary"></span>
</div>
<div id="mcp-status"></div>
</div>
<div class="field">
<label>Input</label>
<div class="switch">
<input type="checkbox" id="s-enter" />
<span>Enter to send (Shift+Enter = newline)</span>
</div>
</div>
<div class="modal-foot">
<button class="btn" id="btn-clear-all" title="Delete all conversations and reset settings">Reset all</button>
<button class="btn primary" id="btn-close-settings">Done</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function () {
"use strict";
var STORE = "zcode_webui_state_v1";
// Known model catalog (used as a fallback when /v1/models is unreachable,
// e.g. before the proxy key has been entered).
var KNOWN_MODELS = [
"glm-4.5-air", "glm-4.6", "glm-4.6v", "glm-4.7", "glm-5",
"glm-5-turbo", "glm-5v-turbo", "glm-5.1", "glm-5.2"
];
var DEFAULT_SETTINGS = {
apiKey: "",
model: "glm-4.6",
systemPrompt: "",
temperature: 1.0,
topP: 0.95,
maxTokens: 4096,
doSample: true,
thinkingEnabled: true,
reasoningEffort: "max",
mcpEnabled: true, // master toggle: only send tools when on
mcpServers: [], // [{url, authKey}] — "URL" or "URL,key" per line
mcpCorsProxy: "", // optional prefix (or {url} template) for browser CORS bypass
theme: "system",
enterToSend: true
};
// ---- state ----
var state = loadState();
var pendingImages = []; // {name, dataUrl} attached to next user msg
var abortCtrl = null; // current stream controller
var busy = false; // generating?
var systemThemeDark = matchMedia("(prefers-color-scheme: dark)").matches;
// ---- dom ----
var $ = function (id) { return document.getElementById(id); };
var msgList = $("msg-list"), messages = $("messages");
var inputEl = $("input"), sendBtn = $("btn-send"), attachBtn = $("btn-attach");
var modelSel = $("model"), effortSel = $("reasoning-effort"), btnThink = $("btn-think");
var convListEl = $("conv-list"), statusDot = $("status-dot");
var sidebar = $("sidebar"), overlay = $("overlay"), toastEl = $("toast");
var hintEl = $("hint");
// =========================================================
// persistence
// =========================================================
function loadState() {
try {
var raw = localStorage.getItem(STORE);
if (raw) {
var s = JSON.parse(raw);
s.settings = Object.assign({}, DEFAULT_SETTINGS, s.settings || {});
s.conversations = Array.isArray(s.conversations) ? s.conversations : [];
s.activeId = s.activeId || (s.conversations[0] && s.conversations[0].id) || null;
return s;
}
} catch (e) { console.warn("load state failed", e); }
return { settings: Object.assign({}, DEFAULT_SETTINGS), conversations: [], activeId: null };
}
function saveState() {
try { localStorage.setItem(STORE, JSON.stringify(state)); }
catch (e) { toast("Storage full \u2014 older sessions not saved."); }
}
function activeConv() {
return state.conversations.find(function (c) { return c.id === state.activeId; }) || null;
}
function newConv() {
var c = { id: rid(), title: "New chat", messages: [], model: state.settings.model, createdAt: Date.now() };
state.conversations.unshift(c);
state.activeId = c.id;
saveState();
renderSidebar(); renderMessages(); inputEl.focus();
}
function rid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
// =========================================================
// rendering
// =========================================================
function renderSidebar() {
convListEl.innerHTML = "";
state.conversations.forEach(function (c) {
var row = document.createElement("div");
row.className = "conv" + (c.id === state.activeId ? " active" : "");
var title = document.createElement("span");
title.className = "conv-title"; title.textContent = c.title || "New chat";
var del = document.createElement("button");
del.className = "conv-del"; del.innerHTML = "\u2715"; del.title = "Delete";
del.onclick = function (e) { e.stopPropagation(); deleteConv(c.id); };
row.onclick = function () { state.activeId = c.id; saveState(); renderSidebar(); renderMessages(); };
row.appendChild(title); row.appendChild(del);
convListEl.appendChild(row);
});
}
function deleteConv(id) {
var i = state.conversations.findIndex(function (c) { return c.id === id; });
if (i < 0) return;
state.conversations.splice(i, 1);
if (state.activeId === id) state.activeId = state.conversations[0] ? state.conversations[0].id : null;
saveState(); renderSidebar(); renderMessages();
}
function renderMessages() {
msgList.innerHTML = "";
var c = activeConv();
if (!c || c.messages.length === 0) { emptyState(); return; }
c.messages.forEach(function (m, idx) {
msgList.appendChild(buildMsg(m, idx === c.messages.length - 1));
});
scrollBottom(true);
}
function emptyState() {
var d = document.createElement("div");
d.className = "empty";
d.innerHTML = "<h1>zcode-proxy</h1><p>Chat with GLM models through your proxy. Pick a model above and start typing.</p>";
msgList.appendChild(d);
}
function buildMsg(m, isLast) {
var wrap = document.createElement("div");
wrap.className = "msg " + m.role;
var role = document.createElement("div"); role.className = "role";
role.textContent = m.role === "user" ? "You" : "Assistant";
wrap.appendChild(role);
if (m.images && m.images.length) {
var imgWrap = document.createElement("div"); imgWrap.className = "msg-images";
m.images.forEach(function (src) {
var im = document.createElement("img"); im.src = src; imgWrap.appendChild(im);
});
wrap.appendChild(imgWrap);
}
if (m.role === "tool") {
var tbody = document.createElement("div"); tbody.className = "tool-body";
tbody.textContent = "🔧 tool result\n" + (m.content || "");
wrap.appendChild(tbody);
return wrap;
}
if (m.role === "user") {
var bubble = document.createElement("div"); bubble.className = "bubble";
bubble.textContent = m.content || ""; wrap.appendChild(bubble);
} else {
var body = document.createElement("div"); body.className = "content md";
if (m.reasoning) {
var t = document.createElement("details"); t.className = "think"; t.open = true;
var sum = document.createElement("summary"); sum.textContent = "Thinking\u2026";
var tb = document.createElement("div"); tb.className = "think-body"; tb.textContent = m.reasoning;
t.appendChild(sum); t.appendChild(tb);
body.appendChild(t);
}
body.innerHTML = renderMarkdown(m.content || "");
enhanceCode(body);
if (m.toolCalls && m.toolCalls.length) {
var tc = document.createElement("div"); tc.className = "toolcalls";
tc.textContent = "🔧 called: " + m.toolCalls.map(function (x) { return x.name; }).join(", ");
body.appendChild(tc);
}
wrap.appendChild(body);
wrap.appendChild(meta(m, isLast));
}
return wrap;
}
function meta(m, isLast) {
var d = document.createElement("div"); d.className = "msg-meta";
if (m.model) { var s = document.createElement("span"); s.textContent = m.model; d.appendChild(s); }
if (m.truncated) { var t = document.createElement("span"); t.textContent = "\u00b7 truncated (max tokens)"; d.appendChild(t); }
if (m.role === "assistant" && isLast && !busy) {
var b = document.createElement("button"); b.className = "regen"; b.textContent = "\u21bb Regenerate";
b.onclick = regenerate; d.appendChild(b);
}
return d;
}
// =========================================================
// markdown
// =========================================================
function renderMarkdown(text) {
if (typeof marked === "undefined") return escapeHtml(text);
try {
marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
var html = marked.parse(text);
if (typeof DOMPurify !== "undefined") html = DOMPurify.sanitize(html);
return html;
} catch (e) { return escapeHtml(text); }
}
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Tiny script appended to rendered HTML so the sandboxed iframe can tell
// its parent how tall the content is (auto-height). Split "<script" so the
// host parser never sees a real end-tag inside this string.
var RESIZE_SCRIPT = '<' + 'script>(function(){function r(){try{parent.postMessage({__zcpPreview:true,h:document.documentElement.scrollHeight},"*")}catch(e){}}window.addEventListener("load",r);setTimeout(r,60);setTimeout(r,400);setTimeout(r,1200);})();<' + '/script>';
var previewListenerAdded = false;
function ensurePreviewListener() {
if (previewListenerAdded) return;
previewListenerAdded = true;
window.addEventListener("message", function (ev) {
var d = ev.data;
if (!d || d.__zcpPreview !== true || typeof d.h !== "number") return;
var frames = document.querySelectorAll("iframe.html-preview");
for (var i = 0; i < frames.length; i++) {
if (frames[i].contentWindow === ev.source) {
var h = Math.max(120, Math.min(2000, d.h | 0));
frames[i].style.height = h + "px";
break;
}
}
});
}
function toggleHtmlPreview(pre, code, btn, label, raw) {
var frame = pre.querySelector("iframe.html-preview");
if (!frame) {
ensurePreviewListener();
frame = document.createElement("iframe");
frame.className = "html-preview";
// allow-scripts (no allow-same-origin): JS runs but the iframe is a
// null origin that cannot touch the parent page's DOM/storage.
frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("srcdoc", raw + RESIZE_SCRIPT);
pre.appendChild(frame);
}
var showPreview = btn.textContent === "preview";
code.style.display = showPreview ? "none" : "";
frame.style.display = showPreview ? "" : "none";
btn.textContent = showPreview ? "code" : "preview";
label.textContent = showPreview ? "preview" : "html";
}
function enhanceCode(root) {
var pres = root.querySelectorAll("pre > code:not([data-hl])");
pres.forEach(function (code) {
var pre = code.parentElement;
var raw = code.textContent;
var lang = (code.className.match(/language-([\w+-]+)/) || [])[1] || "";
var head = document.createElement("div"); head.className = "code-head";
var label = document.createElement("span"); label.textContent = lang || "code";
var actions = document.createElement("span"); actions.className = "code-actions";
var cp = document.createElement("button"); cp.className = "copy-btn"; cp.textContent = "copy";
cp.onclick = function () {
navigator.clipboard.writeText(raw).then(function () {
cp.textContent = "copied"; setTimeout(function () { cp.textContent = "copy"; }, 1200);
});
};
// In-page render toggle for HTML blocks (sandboxed iframe preview).
if (/^html$/i.test(lang)) {
var pv = document.createElement("button"); pv.className = "copy-btn"; pv.textContent = "preview";
pv.title = "Render this HTML live (sandboxed)";
pv.onclick = function () { toggleHtmlPreview(pre, code, pv, label, raw); };
actions.appendChild(pv);
}
actions.appendChild(cp);
head.appendChild(label); head.appendChild(actions);
pre.insertBefore(head, code);
code.setAttribute("data-hl", "1");
if (typeof hljs !== "undefined") { try { hljs.highlightElement(code); } catch (e) {} }
});
root.querySelectorAll("a[href]").forEach(function (a) {
a.target = "_blank"; a.rel = "noopener noreferrer";
});
}
// =========================================================
// theme
// =========================================================
function applyTheme() {
var t = state.settings.theme;
var dark = t === "dark" || (t === "system" && systemThemeDark);
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
var b = $("btn-theme");
if (b) {
// Label shows the mode you'll switch TO.
b.textContent = dark ? "Light mode" : "Dark mode";
b.title = "Switch to " + (dark ? "light" : "dark") + " theme";
}
}
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function (e) {
systemThemeDark = e.matches; if (state.settings.theme === "system") applyTheme();
});
// =========================================================
// models
// =========================================================
function populateModels(list) {
modelSel.innerHTML = "";
(list.length ? list : KNOWN_MODELS).forEach(function (id) {
var o = document.createElement("option"); o.value = id; o.textContent = id;
modelSel.appendChild(o);
});
if (state.settings.model && Array.from(modelSel.options).some(function (o) { return o.value === state.settings.model; })) {
modelSel.value = state.settings.model;
}
syncAttachVisibility();
if (typeof populateEffort === "function") { populateEffort(); syncThinkButton(); }
}
function fetchModels() {
var headers = {};
if (state.settings.apiKey) headers["authorization"] = "Bearer " + state.settings.apiKey;
fetch("/v1/models", { method: "GET", headers: headers })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (j) {
var ids = (j.data || []).map(function (m) { return m.id; });
populateModels(ids); setStatus("ok");
})
.catch(function () {
populateModels(KNOWN_MODELS);
setStatus(state.settings.apiKey ? "err" : "idle");
});
}
function setStatus(s) {
statusDot.className = "status-dot" + (s === "ok" ? " ok" : s === "err" ? " err" : "");
statusDot.title = s === "ok" ? "Connected" : s === "err" ? "Request failed (check key / proxy)" : "Set proxy API key in settings";
}
function isVisionModel(id) { return /v/i.test(id || ""); }
function syncAttachVisibility() { attachBtn.hidden = !isVisionModel(modelSel.value); }
// =========================================================
// request building
// =========================================================
function buildRequestMessages(conv) {
var msgs = [];
if (state.settings.systemPrompt && state.settings.systemPrompt.trim()) {
msgs.push({ role: "system", content: state.settings.systemPrompt.trim() });
}
conv.messages.forEach(function (m) {
if (m.role === "user" && m.images && m.images.length) {
var parts = m.images.map(function (src) {
return { type: "image_url", image_url: { url: src } };
});
parts.push({ type: "text", text: m.content || "" });
msgs.push({ role: "user", content: parts });
} else if (m.role === "assistant" && m.toolCalls && m.toolCalls.length) {
msgs.push({
role: "assistant",
content: m.content || "",
tool_calls: m.toolCalls.map(function (tc) {
return { id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments || "{}" } };
}),
});
} else if (m.role === "tool") {
msgs.push({ role: "tool", tool_call_id: m.toolCallId, content: m.content || "" });
} else {
msgs.push({ role: m.role, content: m.content || "" });
}
});
return msgs;
}
function buildBody(conv) {
var model = conv.model || state.settings.model;
var body = {
model: model,
messages: buildRequestMessages(conv),
stream: true,
temperature: Number(state.settings.temperature),
top_p: Number(state.settings.topP),
max_tokens: Number(state.settings.maxTokens),
do_sample: !!state.settings.doSample
};
if (state.settings.thinkingEnabled) {
body.thinking = { type: "enabled" };
if (isReasoningModel(model)) body.reasoning_effort = state.settings.reasoningEffort;
} else {
body.thinking = { type: "disabled" };
}
var fnTools = gatherMcpTools();
if (fnTools.length) { body.tools = fnTools; body.tool_choice = "auto"; }
return body;
}
function isReasoningModel(id) {
// reasoning_effort is only honored by GLM-5.2 and above per BigModel docs.
return /glm-5\.[2-9]|glm-[6-9]/i.test(id || "");
}
// =========================================================
// MCP — client-managed (browser-direct, optional CORS proxy).
// The webui connects to each server, lists its tools, injects them as
// function tools, and auto-executes tool calls the model returns.
// =========================================================
var mcpState = {}; // url -> {sessionId, tools[], connected, error, status}
var mcpToolMap = {}; // tool name -> {url, name}
var mcpRpcId = 0;
function nextRpcId() { return ++mcpRpcId; }
function applyCorsProxy(url) {
var cp = (state.settings.mcpCorsProxy || "").trim();
if (!cp) return url;
if (cp.indexOf("{url}") >= 0) return cp.replace("{url}", encodeURIComponent(url));
return cp + encodeURIComponent(url);
}
// POST one JSON-RPC message. Resolves {status, contentType, sessionId, body}.
// Auth: sent only as Authorization: Bearer (KoboldAI parity). If a server
// needs the key in the URL, embed it directly in the server URL line.
function mcpFetch(server, rpc) {
var st = mcpState[server.url] || (mcpState[server.url] = {});
var headers = { "content-type": "application/json", "accept": "application/json, text/event-stream" };
if (st.sessionId) headers["mcp-session-id"] = st.sessionId;
if (server.authKey) headers["authorization"] = "Bearer " + server.authKey;
return fetch(applyCorsProxy(server.url), {
method: "POST", headers: headers, body: JSON.stringify(rpc),
}).then(function (r) {
return r.text().then(function (t) {
return {
ok: r.ok, status: r.status,
contentType: r.headers.get("content-type") || "",
sessionId: r.headers.get("mcp-session-id") || "",
body: t,
};
});
});
}
// streamable-http servers may answer with SSE (data: lines) or plain JSON.
function parseMcpResponse(resp) {
var body = resp.body || "";
var looksSSE = resp.contentType.indexOf("text/event-stream") >= 0 || body.indexOf("data:") === 0;
if (looksSSE) {
var parsed = null;
body.split("\n").forEach(function (ln) {
if (ln.indexOf("data:") === 0) {
var d = ln.slice(5).trim();
if (d) { try { parsed = JSON.parse(d); } catch (e) {} }
}
});
return parsed;
}
try { return JSON.parse(body); } catch (e) { return null; }
}
function mcpCall(server, method, params) {
var rpc = { jsonrpc: "2.0", id: nextRpcId(), method: method };
if (params) rpc.params = params;
return mcpFetch(server, rpc).then(function (resp) {
var st = mcpState[server.url];
if (resp.sessionId && st && !st.sessionId) st.sessionId = resp.sessionId;
var json = parseMcpResponse(resp);
if (!json) throw new Error("MCP " + method + " on " + server.url + " returned no JSON (HTTP " + resp.status + ")");
if (json.error) throw new Error("MCP error" + (json.error.code != null ? " (" + json.error.code + ")" : "") + ": " + (json.error.message || JSON.stringify(json.error)));
return json;
});
}
// initialize + tools/list for one server. Resolves to tools[].
function mcpConnect(server) {
return mcpCall(server, "initialize", {
protocolVersion: "2024-11-05", capabilities: {},
clientInfo: { name: "zcode-proxy-webui", version: "1.0" },
}).then(function () {
// required notification before tools/list on stateful servers
return mcpFetch(server, { jsonrpc: "2.0", method: "notifications/initialized" });
}).then(function () {
return mcpCall(server, "tools/list", {});
}).then(function (j) {
return (j.result && j.result.tools) || [];
});
}
// Build function-tool entries from every connected server + refresh the
// tool name -> {url, name} routing map. No tools when MCP is disabled.
function gatherMcpTools() {
var out = []; mcpToolMap = {};
if (!state.settings.mcpEnabled) return out;
state.settings.mcpServers.forEach(function (s) {
var st = mcpState[s.url];
if (st && st.connected && st.tools) {
st.tools.forEach(function (t) {
if (mcpToolMap[t.name]) return; // dedupe across servers (first wins)
mcpToolMap[t.name] = { url: s.url, name: t.name };
out.push({
type: "function",
function: {
name: t.name,
description: (t.description || t.name).slice(0, 1024),
parameters: t.inputSchema || { type: "object", properties: {} },
},
});
});
}
});
return out;
}
// Execute one model tool_call against its MCP server. Resolves to text.
function execMcpTool(tc) {
var map = mcpToolMap[tc.name];
if (!map) return Promise.reject(new Error("Unknown tool: " + tc.name));
var server = state.settings.mcpServers.find(function (s) { return s.url === map.url; });
if (!server) return Promise.reject(new Error("MCP server not configured: " + map.url));
var args = {};
try { args = JSON.parse(tc.arguments || "{}"); } catch (e) { args = {}; }
return mcpCall(server, "tools/call", { name: map.name, arguments: args }).then(function (j) {
var res = j.result;
if (!res) return "(no result)";
if (Array.isArray(res.content)) {
return res.content.map(function (c) { return c.type === "text" ? c.text : JSON.stringify(c); }).join("\n");
}
return JSON.stringify(res);
});
}
// Parse the MCP textarea (KoboldAI format: "URL" or "URL,key" per line).
function parseServersFromTextarea() {
return $("s-mcp").value.split("\n").map(function (ln) {
var line = ln.trim();
if (!line) return null;
var parts = line.split(",");
var url = (parts[0] || "").trim();
var key = (parts[1] || "").trim();
if (!url) return null;
return { url: url, authKey: key };
}).filter(function (s) { return s.url; });
}
function connectAllMcp() {
var servers = parseServersFromTextarea();
state.settings.mcpServers = servers;
state.settings.mcpCorsProxy = $("s-cors").value.trim();
state.settings.mcpEnabled = $("s-mcpenable").checked;
saveState();
mcpState = {}; // fresh sessions each connect
renderMcpStatus(servers);
var chain = Promise.resolve();
servers.forEach(function (s) {
chain = chain.then(function () {
var st = mcpState[s.url] = { connected: false, tools: [], sessionId: "", error: "", status: "connecting" };
renderMcpStatus(servers);
return mcpConnect(s).then(function (tools) {
st.connected = true; st.tools = tools || []; st.error = ""; st.status = "ok";
}, function (err) {
st.connected = false; st.tools = []; st.error = String((err && err.message) || err); st.status = "error";
}).then(function () { renderMcpStatus(servers); });
});
});
chain.then(updateMcpSummary, updateMcpSummary);
}
function disconnectMcp() {
mcpState = {};
renderMcpStatus(state.settings.mcpServers);
updateMcpSummary();
}
function renderMcpStatus(servers) {
var box = $("mcp-status"); if (!box) return;
box.innerHTML = "";
(servers || state.settings.mcpServers || []).forEach(function (s) {
var st = mcpState[s.url] || { connected: false, tools: [], status: "", error: "" };
var row = document.createElement("div"); row.className = "mcp-srv";
var head = document.createElement("div"); head.className = "mcp-srv-head";
var dot = document.createElement("span");
dot.className = "mcp-dot " + (st.status === "ok" ? "ok" : st.status === "error" ? "err" : st.status === "connecting" ? "busy" : "");
var urlEl = document.createElement("span"); urlEl.className = "mcp-label"; urlEl.textContent = s.url; urlEl.title = s.url;
var info = document.createElement("span"); info.className = "mcp-state";
if (st.status === "connecting") info.textContent = "connecting\u2026";
else if (st.status === "ok") info.textContent = "connected \u00b7 " + (st.tools ? st.tools.length : 0) + " tools";
else if (st.status === "error") info.textContent = "failed";
else info.textContent = "not connected";
head.appendChild(dot); head.appendChild(urlEl); head.appendChild(info);
row.appendChild(head);
if (st.error) { var e = document.createElement("div"); e.className = "mcp-err"; e.textContent = st.error; row.appendChild(e); }
if (st.tools && st.tools.length) {
var tw = document.createElement("div"); tw.className = "mcp-tools";
st.tools.forEach(function (t) {
var c = document.createElement("span"); c.className = "mcp-tool-chip";
c.textContent = t.name; c.title = t.description || "";
tw.appendChild(c);
});
row.appendChild(tw);
}
box.appendChild(row);
});
}
function updateMcpSummary() {
var el = $("mcp-summary"); if (!el) return;
var servers = state.settings.mcpServers || [];
var connected = 0, tools = 0;
servers.forEach(function (s) {
var st = mcpState[s.url];
if (st && st.connected) { connected++; tools += (st.tools ? st.tools.length : 0); }
});
el.textContent = connected + "/" + servers.length + " servers \u00b7 " + tools + " tools";
}
// =========================================================
// send / stream
// =========================================================
function send() {
if (busy) return;
var text = inputEl.value.trim();
if (!text && pendingImages.length === 0) return;
// Block image input for non-vision models with a clear message.
if (pendingImages.length && !isVisionModel(state.settings.model)) {
toast("This model does not support image input. Select a vision model (name contains 'v', e.g. glm-4.6v).");
return;
}
var c = activeConv(); if (!c) { newConv(); c = activeConv(); }
var images = pendingImages.map(function (p) { return p.dataUrl; });
c.messages.push({ role: "user", content: text, images: images.length ? images : undefined });
if (c.messages.filter(function (m) { return m.role === "user"; }).length === 1 && text) {
c.title = text.slice(0, 40);
}
inputEl.value = ""; autoGrow(); pendingImages = []; renderAttachPreview(); renderSidebar();
renderMessages();
runCompletion(c);
}
function regenerate() {
if (busy) return;
var c = activeConv(); if (!c) return;
while (c.messages.length && c.messages[c.messages.length - 1].role === "assistant") c.messages.pop();
renderMessages();
runCompletion(c);
}
function runCompletion(conv) {
setBusy(true);
runToolLoop(conv, 0);
}
// One assistant turn per call; recurses while the model requests tools.
function runToolLoop(conv, depth) {
if (depth > 8) {
toast("Stopped after 8 tool rounds.");
finalizeLoop();
return;
}
var assistant = { role: "assistant", content: "", reasoning: "", model: conv.model || state.settings.model, truncated: false };
conv.messages.push(assistant);
renderMessages();
streamOnce(conv, assistant).then(function (toolCalls) {
if (toolCalls && toolCalls.length) {
assistant.toolCalls = toolCalls;
saveState(); renderMessages();
// Auto-execute every tool call against its MCP server, sequentially.
var seq = Promise.resolve();
toolCalls.forEach(function (tc) {
seq = seq.then(function () {
return execMcpTool(tc).then(function (result) {
conv.messages.push({ role: "tool", toolCallId: tc.id, content: result });
}, function (err) {
conv.messages.push({ role: "tool", toolCallId: tc.id, content: "Error: " + String((err && err.message) || err) });
});
});
});
seq.then(function () {
saveState(); renderMessages(); scrollBottom(true);
runToolLoop(conv, depth + 1); // feed results back to the model
});
} else {
finalizeLoop();
}
});
function finalizeLoop() {
setBusy(false);
abortCtrl = null;
saveState();
renderSidebar();
}
}
// Stream one completion. Resolves to an array of accumulated tool calls
// (empty/null when the model just replied with text).
function streamOnce(conv, assistant) {
return new Promise(function (resolve) {
abortCtrl = new AbortController();
var toolAccum = {}; // index -> {id, name, arguments}
var finished = false;
fetch("/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": state.settings.apiKey ? "Bearer " + state.settings.apiKey : "",
},
body: JSON.stringify(buildBody(conv)),
signal: abortCtrl.signal,
}).then(function (r) {
if (!r.ok || !r.body) {
return r.text().then(function (t) {
assistant.content = "**Request failed (" + r.status + ").**\n\n```\n" + (t || r.statusText).slice(0, 800) + "\n```";
updateStreamingDom(assistant, true);
resolve(null);
throw new Error("__stop__");
});
}
var reader = r.body.getReader();
var dec = new TextDecoder();
var buf = "";
var lastRender = 0;
function pump() {
return reader.read().then(function (chunk) {
if (chunk.done) { return; }
buf += dec.decode(chunk.value, { stream: true });
var lines = buf.split("\n");
buf = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.indexOf("data:") !== 0) continue;
var data = line.slice(5).trim();
if (data === "[DONE]") { finished = true; break; }
var json; try { json = JSON.parse(data); } catch (e) { continue; }
var choice = json.choices && json.choices[0];
if (!choice) continue;
var d = choice.delta || {};
if (d.reasoning_content) assistant.reasoning += d.reasoning_content;
if (d.content) assistant.content += d.content;
if (d.tool_calls) {
d.tool_calls.forEach(function (tc) {
var idx = tc.index || 0;
var slot = toolAccum[idx] = toolAccum[idx] || { id: "", name: "", arguments: "" };
if (tc.id) slot.id = tc.id;
if (tc.function) {
if (tc.function.name) slot.name = tc.function.name;
if (tc.function.arguments) slot.arguments += tc.function.arguments;
}
});
}
if (choice.finish_reason === "length") assistant.truncated = true;
}
var now = Date.now();
if (now - lastRender > 33) { lastRender = now; updateStreamingDom(assistant, false); }
if (finished) { return; }
return pump();
});
}
return pump().then(function () {
updateStreamingDom(assistant, true);
var tcs = Object.keys(toolAccum).map(function (k) { return toolAccum[k]; }).filter(function (t) { return t.id && t.name; });
if (!assistant.content && !assistant.reasoning && !tcs.length) {
assistant.content = "_(empty response)_"; updateStreamingDom(assistant, true);
}
resolve(tcs.length ? tcs : null);
});
}).catch(function (e) {
if (e && e.message === "__stop__") return;
if (e && e.name === "AbortError") {
assistant.content += "\n\n_\u2026stopped._"; updateStreamingDom(assistant, true);
} else {
assistant.content = "**Error:** " + escapeHtml(String((e && e.message) || e));
updateStreamingDom(assistant, true);
}
resolve(null);
});
});
}
function updateStreamingDom(assistant, done) {
var last = msgList.lastChild;
if (!last || !last.classList || !last.classList.contains("msg")) { renderMessages(); last = msgList.lastChild; }
if (!last) return;
var body = last.querySelector(".content");
if (!body) return;
var think = body.querySelector(".think");
if (assistant.reasoning) {
if (!think) {
think = document.createElement("details"); think.className = "think"; think.open = true;
var sum = document.createElement("summary");
var tb = document.createElement("div"); tb.className = "think-body";
think.appendChild(sum); think.appendChild(tb);
body.insertBefore(think, body.firstChild);
}
think.querySelector(".think-body").textContent = assistant.reasoning;
think.querySelector("summary").textContent = done ? "Thought process" : "Thinking\u2026";
if (done) think.open = false;
}
if (assistant.content) {
body.innerHTML = renderMarkdown(assistant.content);
enhanceCode(body);
if (think) body.insertBefore(think, body.firstChild); // keep thinking on top
}
if (done) {
var oldMeta = last.querySelector(".msg-meta"); if (oldMeta) oldMeta.remove();
last.appendChild(meta(assistant, true));
}
scrollBottom();
}
function setBusy(b) {
busy = b;
sendBtn.className = "c-btn " + (b ? "stop" : "send");
sendBtn.innerHTML = b ? "\u25a0" : "\u2191";
sendBtn.title = b ? "Stop" : "Send";
hintEl.textContent = b ? "Generating \u2014 press Stop to interrupt." : "";
}
function stop() { if (abortCtrl) { try { abortCtrl.abort(); } catch (e) {} } }
// =========================================================
// autoscroll
// =========================================================
function nearBottom() {
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 120;
}
function scrollBottom(force) {
if (force || nearBottom()) messages.scrollTop = messages.scrollHeight;
}
// =========================================================
// composer helpers
// =========================================================
function autoGrow() {
inputEl.style.height = "auto";
inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px";
}
function renderAttachPreview() {
var box = $("attach-preview"); box.innerHTML = "";
pendingImages.forEach(function (p, i) {
var w = document.createElement("div"); w.className = "ap";
var img = document.createElement("img"); img.src = p.dataUrl;
var rm = document.createElement("button"); rm.className = "ap-rm"; rm.innerHTML = "\u2715";
rm.onclick = function () { pendingImages.splice(i, 1); renderAttachPreview(); };
w.appendChild(img); w.appendChild(rm); box.appendChild(w);
});
}
function readImages(files) {
Array.from(files).forEach(function (f) {
if (!/image\//.test(f.type)) return;
var r = new FileReader();
r.onload = function () { pendingImages.push({ name: f.name, dataUrl: r.result }); renderAttachPreview(); };
r.readAsDataURL(f);
});
}
// =========================================================
// settings modal
// =========================================================
function openSettings() { syncSettingsForm(); renderMcpStatus(state.settings.mcpServers); updateMcpSummary(); overlay.classList.add("open"); }
function closeSettings() { overlay.classList.remove("open"); }
function syncSettingsForm() {
var s = state.settings;
$("s-apikey").value = s.apiKey;
$("s-system").value = s.systemPrompt;
$("s-temp").value = s.temperature; $("s-temp-val").textContent = Number(s.temperature).toFixed(2);
$("s-topp").value = s.topP; $("s-topp-val").textContent = Number(s.topP).toFixed(2);
$("s-maxtok").value = s.maxTokens;
$("s-dosample").checked = s.doSample;
$("s-mcpenable").checked = s.mcpEnabled !== false;
$("s-mcp").value = (s.mcpServers || []).map(function (m) {
return m.authKey ? (m.url + "," + m.authKey) : m.url;
}).join("\n");
$("s-cors").value = s.mcpCorsProxy || "";
$("s-enter").checked = s.enterToSend;
$("banner").style.display = s.apiKey ? "none" : "block";
$("banner").textContent = "No proxy API key set. If your proxy requires one, requests will return 401.";
}
function readSettingsForm() {
var s = state.settings;
s.apiKey = $("s-apikey").value.trim();
s.systemPrompt = $("s-system").value;
s.temperature = parseFloat($("s-temp").value);
s.topP = parseFloat($("s-topp").value);
s.maxTokens = parseInt($("s-maxtok").value, 10) || 4096;
s.doSample = $("s-dosample").checked;
s.mcpEnabled = $("s-mcpenable").checked;
s.mcpServers = $("s-mcp").value.split("\n").map(function (ln) {
var parts = ln.trim().split(",");
var url = (parts[0] || "").trim();
if (!url) return null;
return { url: url, authKey: (parts[1] || "").trim() };
}).filter(Boolean);
s.mcpCorsProxy = $("s-cors").value.trim();
s.enterToSend = $("s-enter").checked;
saveState();
}
// =========================================================
// toast
// =========================================================
var toastTimer;
function toast(msg) {
toastEl.textContent = msg; toastEl.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2600);
}
// =========================================================
// wiring
// =========================================================
$("btn-new").onclick = newConv;
$("btn-settings").onclick = openSettings;
$("btn-close-settings").onclick = function () { readSettingsForm(); closeSettings(); fetchModels(); populateEffort(); syncThinkButton(); };
$("btn-clear-all").onclick = function () {
if (!confirm("Delete ALL conversations and reset settings? This cannot be undone.")) return;
localStorage.removeItem(STORE);
state = loadState(); saveState(); applyTheme(); renderSidebar(); renderMessages();
syncSettingsForm(); populateModels(KNOWN_MODELS); toast("Reset complete.");
};
$("btn-theme").onclick = function () {
var cur = document.documentElement.getAttribute("data-theme");
state.settings.theme = cur === "dark" ? "light" : "dark";
saveState(); applyTheme();
};
$("btn-menu").onclick = function () { sidebar.classList.toggle("collapsed"); };
$("btn-mcp-connect").onclick = connectAllMcp;
$("btn-mcp-disconnect").onclick = disconnectMcp;
overlay.onclick = function (e) { if (e.target === overlay) closeSettings(); };
$("s-temp").addEventListener("input", function () { $("s-temp-val").textContent = parseFloat($("s-temp").value).toFixed(2); });
$("s-topp").addEventListener("input", function () { $("s-topp-val").textContent = parseFloat($("s-topp").value).toFixed(2); });
$("s-apikey").addEventListener("input", function () {
$("banner").style.display = $("s-apikey").value.trim() ? "none" : "block";
});
modelSel.onchange = function () {
state.settings.model = modelSel.value;
var c = activeConv(); if (c) c.model = modelSel.value;
// If images were queued for a vision model and the user switched to a
// non-vision one, drop them and explain.
if (pendingImages.length && !isVisionModel(modelSel.value)) {
pendingImages = []; renderAttachPreview();
toast("Switched to a non-vision model \u2014 attached images were removed.");
}
saveState(); syncAttachVisibility(); populateEffort();
};
// GLM-5.2 only has two distinct reasoning-effort levels (low/medium map
// to high, xhigh maps to max), so expose just high and max. Other models
// get a disabled placeholder (reasoning_effort is GLM-5.2+ only).
function populateEffort() {
var model = modelSel.value;
effortSel.innerHTML = "";
if (isReasoningModel(model)) {
["max", "high"].forEach(function (v) {
var o = document.createElement("option");
o.value = v; o.textContent = v;
effortSel.appendChild(o);
});
var cur = state.settings.reasoningEffort;
effortSel.value = (cur === "high") ? "high" : "max";
effortSel.disabled = !state.settings.thinkingEnabled;
} else {
var o = document.createElement("option");
o.value = ""; o.textContent = "n/a";
effortSel.appendChild(o);
effortSel.disabled = true;
}
}
function syncThinkButton() {
var on = state.settings.thinkingEnabled;
btnThink.classList.toggle("on", on);
btnThink.textContent = on ? "Think: on" : "Think: off";
btnThink.title = on ? "Deep thinking on \u2014 click to turn off" : "Deep thinking off \u2014 click to turn on";
// Repopulate so the effort dropdown's disabled state tracks the toggle
// (populateEffort sets disabled = !thinkingEnabled for reasoning models).
populateEffort();
}
effortSel.onchange = function () {
state.settings.reasoningEffort = effortSel.value || "max";
saveState();
};
btnThink.onclick = function () {
state.settings.thinkingEnabled = !state.settings.thinkingEnabled;
saveState(); syncThinkButton();
};
attachBtn.onclick = function () { $("attach-input").click(); };
$("attach-input").onchange = function (e) { readImages(e.target.files); e.target.value = ""; };
// drag & drop onto composer
var composer = document.querySelector(".composer");
["dragover", "dragenter"].forEach(function (ev) {
composer.addEventListener(ev, function (e) { e.preventDefault(); composer.style.borderColor = "var(--accent)"; });
});
["dragleave", "drop"].forEach(function (ev) {
composer.addEventListener(ev, function (e) { e.preventDefault(); composer.style.borderColor = ""; });
});
composer.addEventListener("drop", function (e) {
if (!isVisionModel(modelSel.value)) { toast("Select a vision model (name contains 'v') to attach images."); return; }
if (e.dataTransfer && e.dataTransfer.files) readImages(e.dataTransfer.files);
});
inputEl.addEventListener("input", autoGrow);
inputEl.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey && state.settings.enterToSend && !e.isComposing) { e.preventDefault(); send(); }
else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
});
sendBtn.onclick = function () { if (busy) stop(); else send(); };
// =========================================================
// boot
// =========================================================
applyTheme();
populateModels(KNOWN_MODELS);
renderSidebar(); renderMessages();
populateEffort(); syncThinkButton();
fetchModels();
autoGrow();
inputEl.focus();
if (!state.conversations.length) newConv();
})();
</script>
</body>
</html>