-
Notifications
You must be signed in to change notification settings - Fork 99
Expand file tree
/
Copy pathchat-html.js
More file actions
4 lines (4 loc) · 84.6 KB
/
Copy pathchat-html.js
File metadata and controls
4 lines (4 loc) · 84.6 KB
1
2
3
4
// 本文件由 scripts/build-chat-html.js 从 chat.html 自动生成,请勿手改。
// 修改 chat.html 后执行: node scripts/build-chat-html.js
// 生成时间: 2026-08-14T08:06:07.095Z
module.exports = "<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n <meta charset=\"UTF-8\" />\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\r\n <title>Qwen2API Chat</title>\r\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\r\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\r\n <link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap\" rel=\"stylesheet\" />\r\n <style>\r\n :root {\r\n color-scheme: light;\r\n --page-bg: #f6f7fb;\r\n --ink: #0f172a;\r\n --muted: #475569;\r\n --panel: rgba(255, 255, 255, 0.88);\r\n --panel-2: rgba(255, 255, 255, 0.72);\r\n --border: rgba(15, 23, 42, 0.10);\r\n --shadow: 0 18px 42px rgba(15, 23, 42, 0.10);\r\n --shadow-soft: 0 10px 24px rgba(15, 23, 42, 0.08);\r\n --ring: 0 0 0 4px rgba(14, 165, 164, 0.18);\r\n --accent: #0ea5a4;\r\n --accent-2: #16a34a;\r\n --danger: #ef4444;\r\n --radius: 14px;\r\n --radius-sm: 10px;\r\n --empty-hint: \"开始对话吧。支持粘贴文本、上传文件,Enter 发送。\";\r\n }\r\n\r\n * { box-sizing: border-box; }\r\n html, body { height: 100%; }\r\n body {\r\n margin: 0;\r\n color: var(--ink);\r\n background:\r\n radial-gradient(1200px 700px at 15% 10%, rgba(14, 165, 164, 0.14), transparent 55%),\r\n radial-gradient(900px 520px at 85% 20%, rgba(34, 197, 94, 0.10), transparent 60%),\r\n radial-gradient(1000px 600px at 50% 100%, rgba(56, 189, 248, 0.10), transparent 55%),\r\n var(--page-bg);\r\n font-family: \"Noto Sans SC\", system-ui, -apple-system, \"Segoe UI\", \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\r\n letter-spacing: 0.1px;\r\n }\r\n\r\n main {\r\n max-width: 1120px;\r\n margin: 0 auto;\r\n padding: 18px 14px 20px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 12px;\r\n min-height: 100vh;\r\n }\r\n\r\n .panel {\r\n background: var(--panel);\r\n border: 1px solid var(--border);\r\n border-radius: var(--radius);\r\n box-shadow: var(--shadow-soft);\r\n backdrop-filter: blur(10px);\r\n }\r\n\r\n .topbar {\r\n padding: 12px;\r\n display: flex;\r\n gap: 12px;\r\n align-items: center;\r\n justify-content: space-between;\r\n }\r\n .brand {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n min-width: 150px;\r\n }\r\n .brand b {\r\n font-family: \"Space Grotesk\", \"Noto Sans SC\", sans-serif;\r\n font-size: 16px;\r\n letter-spacing: 0.3px;\r\n }\r\n .brand span {\r\n color: var(--muted);\r\n font-size: 12px;\r\n }\r\n\r\n .controls {\r\n display: flex;\r\n gap: 8px;\r\n align-items: center;\r\n flex-wrap: wrap;\r\n justify-content: flex-end;\r\n }\r\n .controls .grow { flex: 1; min-width: 160px; }\r\n\r\n input, select, textarea, button {\r\n font: inherit;\r\n border-radius: 12px;\r\n border: 1px solid rgba(15, 23, 42, 0.14);\r\n background: rgba(255, 255, 255, 0.75);\r\n color: var(--ink);\r\n outline: none;\r\n }\r\n\r\n input, select, button { padding: 10px 11px; }\r\n textarea { padding: 12px 12px; width: 100%; min-height: 96px; resize: vertical; }\r\n\r\n input:focus, select:focus, textarea:focus { box-shadow: var(--ring); border-color: rgba(14, 165, 164, 0.55); }\r\n button { cursor: pointer; transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease; }\r\n button:disabled { cursor: not-allowed; opacity: 0.6; }\r\n button:active:not(:disabled) { transform: translateY(1px); }\r\n\r\n .btn {\r\n background: rgba(255, 255, 255, 0.75);\r\n }\r\n .primary {\r\n background: linear-gradient(135deg, #0ea5a4, #22c55e);\r\n color: #ffffff;\r\n border-color: rgba(14, 165, 164, 0.55);\r\n box-shadow: 0 10px 22px rgba(14, 165, 164, 0.18);\r\n }\r\n .primary:hover:not(:disabled) { box-shadow: 0 14px 28px rgba(14, 165, 164, 0.22); }\r\n .warn {\r\n background: rgba(239, 68, 68, 0.10);\r\n color: #991b1b;\r\n border-color: rgba(239, 68, 68, 0.35);\r\n }\r\n .warn:hover:not(:disabled) { background: rgba(239, 68, 68, 0.14); }\r\n\r\n .log-toggle {\r\n background: rgba(15, 23, 42, 0.06);\r\n border-color: rgba(15, 23, 42, 0.14);\r\n }\r\n .log-toggle.active {\r\n background: rgba(14, 165, 164, 0.12);\r\n border-color: rgba(14, 165, 164, 0.35);\r\n }\r\n\r\n .shell {\r\n display: grid;\r\n grid-template-columns: 1fr;\r\n gap: 12px;\r\n align-items: stretch;\r\n min-height: 58vh;\r\n }\r\n\r\n .shell.has-logs { grid-template-columns: 1fr; }\r\n @media (min-width: 981px) {\r\n .shell.has-logs { grid-template-columns: 1fr 360px; }\r\n }\r\n\r\n #messages {\r\n padding: 14px;\r\n overflow: auto;\r\n min-height: 56vh;\r\n max-height: 62vh;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n }\r\n #messages:empty::before {\r\n content: var(--empty-hint);\r\n color: rgba(71, 85, 105, 0.9);\r\n background: rgba(255, 255, 255, 0.55);\r\n border: 1px dashed rgba(15, 23, 42, 0.18);\r\n border-radius: 14px;\r\n padding: 14px 14px;\r\n line-height: 1.5;\r\n }\r\n\r\n .msg {\r\n max-width: min(760px, 92%);\r\n padding: 10px 12px;\r\n border-radius: 16px;\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n overflow-wrap: anywhere;\r\n border: 1px solid rgba(15, 23, 42, 0.10);\r\n animation: popIn 220ms ease both;\r\n }\r\n .msg.u {\r\n align-self: flex-end;\r\n background: linear-gradient(135deg, rgba(14, 165, 164, 0.12), rgba(34, 197, 94, 0.10));\r\n }\r\n .msg.a {\r\n align-self: flex-start;\r\n background: rgba(255, 255, 255, 0.72);\r\n }\r\n .msg-head {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n gap: 10px;\r\n margin-bottom: 6px;\r\n font-size: 12px;\r\n color: rgba(71, 85, 105, 0.92);\r\n }\r\n .msg-head span {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 8px;\r\n }\r\n\r\n .think {\r\n border: 1px solid rgba(15, 23, 42, 0.10);\r\n background: rgba(15, 23, 42, 0.035);\r\n border-radius: 12px;\r\n padding: 8px 10px;\r\n margin-bottom: 8px;\r\n }\r\n .think summary {\r\n cursor: pointer;\r\n font-size: 12px;\r\n color: rgba(71, 85, 105, 0.92);\r\n user-select: none;\r\n list-style: none;\r\n }\r\n .think summary::-webkit-details-marker { display: none; }\r\n .think summary::before {\r\n content: \"▸\";\r\n display: inline-block;\r\n margin-right: 6px;\r\n transform: translateY(-1px);\r\n }\r\n .think[open] summary::before { content: \"▾\"; }\r\n .think pre {\r\n margin: 8px 0 0;\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n overflow-wrap: anywhere;\r\n font-size: 12px;\r\n line-height: 1.55;\r\n color: rgba(71, 85, 105, 0.96);\r\n font-family: ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\r\n }\r\n .mini {\r\n padding: 6px 10px;\r\n border-radius: 999px;\r\n font-size: 12px;\r\n background: rgba(15, 23, 42, 0.06);\r\n border-color: rgba(15, 23, 42, 0.10);\r\n }\r\n .mini:hover:not(:disabled) { background: rgba(15, 23, 42, 0.08); }\r\n\r\n .composer {\r\n padding: 12px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n }\r\n\r\n .video-panel { padding: 12px; }\r\n .action-row {\r\n display: flex;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n align-items: center;\r\n }\r\n .file {\r\n flex: 1;\r\n min-width: 220px;\r\n padding: 8px;\r\n background: rgba(255, 255, 255, 0.55);\r\n }\r\n .file::file-selector-button {\r\n font: inherit;\r\n padding: 8px 10px;\r\n border-radius: 999px;\r\n border: 1px solid rgba(15, 23, 42, 0.14);\r\n background: rgba(15, 23, 42, 0.05);\r\n margin-right: 10px;\r\n cursor: pointer;\r\n }\r\n .file::file-selector-button:hover { background: rgba(15, 23, 42, 0.08); }\r\n\r\n .status {\r\n font-size: 12px;\r\n min-height: 18px;\r\n color: rgba(71, 85, 105, 0.95);\r\n }\r\n .status.err { color: #b91c1c; }\r\n .status.ok { color: #047857; }\r\n\r\n #atts { display: grid; gap: 8px; }\r\n .fi {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n gap: 10px;\r\n padding: 10px 10px;\r\n border: 1px solid rgba(15, 23, 42, 0.12);\r\n border-radius: var(--radius-sm);\r\n background: rgba(255, 255, 255, 0.62);\r\n }\r\n .fi button {\r\n padding: 8px 10px;\r\n border-radius: 999px;\r\n background: rgba(239, 68, 68, 0.10);\r\n color: #991b1b;\r\n border-color: rgba(239, 68, 68, 0.28);\r\n }\r\n\r\n .log-panel { display: none; padding: 12px; }\r\n .log-panel.visible {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n }\r\n .log-header {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n gap: 10px;\r\n }\r\n .log-header span {\r\n font-family: \"Space Grotesk\", \"Noto Sans SC\", sans-serif;\r\n font-weight: 600;\r\n letter-spacing: 0.2px;\r\n }\r\n\r\n .video-row {\r\n display: flex;\r\n gap: 8px;\r\n align-items: center;\r\n flex-wrap: wrap;\r\n }\r\n .video-tag {\r\n font-size: 12px;\r\n color: rgba(71, 85, 105, 0.95);\r\n padding: 6px 10px;\r\n border-radius: 999px;\r\n border: 1px solid rgba(15, 23, 42, 0.12);\r\n background: rgba(255, 255, 255, 0.55);\r\n white-space: nowrap;\r\n }\r\n .video-row input {\r\n flex: 1;\r\n min-width: 220px;\r\n font-size: 12px;\r\n padding: 10px 10px;\r\n }\r\n .video-clear {\r\n padding: 10px 12px;\r\n font-size: 12px;\r\n border-radius: 999px;\r\n background: rgba(15, 23, 42, 0.06);\r\n border-color: rgba(15, 23, 42, 0.12);\r\n }\r\n\r\n #logs {\r\n flex: 1;\r\n min-height: 46vh;\r\n max-height: 56vh;\r\n overflow: auto;\r\n font-size: 12px;\r\n font-family: ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\r\n background:\r\n radial-gradient(900px 450px at 20% 10%, rgba(14, 165, 164, 0.14), transparent 60%),\r\n radial-gradient(800px 420px at 80% 30%, rgba(34, 197, 94, 0.10), transparent 60%),\r\n #0b1220;\r\n color: rgba(226, 232, 240, 0.92);\r\n border-radius: var(--radius-sm);\r\n border: 1px solid rgba(148, 163, 184, 0.20);\r\n padding: 10px 10px;\r\n }\r\n .log-entry { padding: 3px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.16); }\r\n .log-entry:last-child { border-bottom: none; }\r\n .log-time { color: rgba(34, 197, 94, 0.85); margin-right: 8px; }\r\n .log-event { color: rgba(56, 189, 248, 0.95); font-weight: 600; }\r\n .log-detail { color: rgba(251, 191, 36, 0.88); margin-left: 8px; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }\r\n\r\n .modal-backdrop {\r\n position: fixed;\r\n inset: 0;\r\n background: rgba(15, 23, 42, 0.45);\r\n z-index: 999;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 16px;\r\n animation: popIn 200ms ease;\r\n }\r\n .modal {\r\n background: var(--panel);\r\n border: 1px solid var(--border);\r\n border-radius: var(--radius);\r\n box-shadow: var(--shadow);\r\n max-width: 480px;\r\n width: 100%;\r\n padding: 18px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 12px;\r\n backdrop-filter: blur(10px);\r\n }\r\n .modal h3 {\r\n margin: 0;\r\n font-family: \"Space Grotesk\", \"Noto Sans SC\", sans-serif;\r\n font-size: 16px;\r\n letter-spacing: 0.2px;\r\n }\r\n .modal .field-label {\r\n font-size: 12px;\r\n color: var(--muted);\r\n }\r\n .modal .hint {\r\n font-size: 12px;\r\n color: var(--muted);\r\n line-height: 1.5;\r\n }\r\n .modal .actions {\r\n display: flex;\r\n gap: 8px;\r\n justify-content: flex-end;\r\n flex-wrap: wrap;\r\n }\r\n\r\n @keyframes popIn {\r\n from { opacity: 0; transform: translateY(6px); }\r\n to { opacity: 1; transform: translateY(0); }\r\n }\r\n @media (prefers-reduced-motion: reduce) {\r\n .msg { animation: none; }\r\n button { transition: none; }\r\n }\r\n\r\n @media (max-width: 980px) {\r\n .shell.has-logs { grid-template-columns: 1fr; }\r\n #messages { max-height: 56vh; }\r\n #logs { max-height: 44vh; }\r\n .brand { display: none; }\r\n }\r\n </style>\r\n</head>\r\n<body>\r\n <main>\r\n <header class=\"panel topbar\">\r\n <div class=\"brand\">\r\n <b>Qwen2API Chat</b>\r\n <span id=\"brandSub\">轻量 · 流式 · 支持附件</span>\r\n </div>\r\n <div class=\"controls\">\r\n <button id=\"langToggle\" class=\"btn\" type=\"button\" aria-label=\"Language\">EN</button>\r\n <input id=\"apiKey\" class=\"grow\" type=\"text\" placeholder=\"API Key(可选)\" autocomplete=\"off\" spellcheck=\"false\" />\r\n <select id=\"model\" class=\"grow\"><option value=\"qwen3.5-plus\">qwen3.5-plus</option></select>\r\n <button id=\"refreshModels\" class=\"btn\" type=\"button\">刷新模型</button>\r\n <button id=\"logToggle\" class=\"log-toggle\" type=\"button\">显示日志</button>\r\n <button id=\"settingsBtn\" class=\"btn\" type=\"button\">设置</button>\r\n </div>\r\n </header>\r\n\r\n <section class=\"panel video-panel\" aria-label=\"视频分析\">\r\n <div class=\"video-row\">\r\n <span class=\"video-tag\" id=\"videoTag\">视频分析(可选)</span>\r\n <input id=\"videoUrl\" type=\"text\" placeholder=\"粘贴视频链接;发送时自动进入视频分析;留空则普通对话\" autocomplete=\"off\" spellcheck=\"false\" />\r\n <button id=\"clearVideo\" class=\"video-clear\" type=\"button\">清空</button>\r\n </div>\r\n </section>\r\n\r\n <section class=\"panel video-panel\" aria-label=\"图片生成\">\r\n <div class=\"video-row\">\r\n <select id=\"imageGenMode\" style=\"flex: 0 0 auto; min-width: 120px;\">\r\n <option value=\"chat\">聊天模式</option>\r\n <option value=\"image\">图片生成</option>\r\n </select>\r\n <select id=\"imageSize\" style=\"flex: 0 0 auto; min-width: 120px;\">\r\n <option value=\"1:1\">1:1 (正方形)</option>\r\n <option value=\"16:9\">16:9 (宽屏)</option>\r\n <option value=\"9:16\">9:16 (竖屏)</option>\r\n <option value=\"4:3\">4:3 (传统)</option>\r\n <option value=\"3:4\">3:4 (传统竖)</option>\r\n </select>\r\n <select id=\"imageCount\" style=\"flex: 0 0 auto; min-width: 80px;\">\r\n <option value=\"1\">1张</option>\r\n <option value=\"2\">2张</option>\r\n <option value=\"3\">3张</option>\r\n <option value=\"4\">4张</option>\r\n <option value=\"5\">5张</option>\r\n </select>\r\n </div>\r\n </section>\r\n\r\n <div class=\"shell\" id=\"shell\">\r\n <section id=\"messages\" class=\"panel\" aria-label=\"消息列表\"></section>\r\n\r\n <aside class=\"panel log-panel\" id=\"logPanel\" aria-label=\"运行日志\">\r\n <div class=\"log-header\">\r\n <span id=\"logsTitle\">运行日志</span>\r\n <button id=\"clearLogs\" type=\"button\" class=\"mini\">清空</button>\r\n </div>\r\n <div id=\"logs\"></div>\r\n </aside>\r\n </div>\r\n\r\n <section class=\"panel composer\" aria-label=\"输入区\">\r\n <textarea id=\"prompt\" placeholder=\"输入消息;Enter 发送,Shift+Enter 换行\"></textarea>\r\n <div class=\"action-row\">\r\n <input id=\"files\" class=\"file\" type=\"file\" multiple accept=\".pdf,.doc,.docx,.dot,.csv,.xlsx,.xls,.txt,.text,.md,.js,.mjs,.ts,.jsx,.tsx,.vue,.html,.htm,.css,.svg,.svgz,.xml,.json,.jsonc,.wasm,.tex,.latex,.c,.h,.cc,.cxx,.cpp,.hpp,.hh,.hxx,.ino,.java,.kt,.kts,.scala,.groovy,.go,.rs,.swift,.php,.rb,.cs,.vb,.fs,.csproj,.sln,.sql,.lua,.r,.pl,.tcl,.awk,.fish,.yaml,.yml,.toml,.ini,.sh,.bat,.cmd,.dockerfile,.containerfile,.proto,.thrift,.graphql,.gql,.qmd,.smali,.gif,.webp,.jpg,.jpeg,.png,.bmp,.icns,.jp2,.sgi,.tif,.tiff,.mkv,.mov,.wav,.mp3,.m4a,.amr,.aac,image/*,audio/*,video/*\" />\r\n <button id=\"send\" class=\"primary\" type=\"button\">发送</button>\r\n <button id=\"stop\" class=\"btn\" type=\"button\" disabled>中断</button>\r\n <button id=\"clear\" class=\"warn\" type=\"button\">清空会话</button>\r\n </div>\r\n <div id=\"atts\"></div>\r\n <div id=\"status\" class=\"status\"></div>\r\n </section>\r\n \r\n <footer style=\"text-align: center; padding: 16px; margin-top: 8px;\">\r\n <a href=\"https://github.com/smanx/qwen2api\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"display: inline-flex; align-items: center; gap: 8px; text-decoration: none; color: var(--muted); font-size: 14px; padding: 10px 16px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); transition: all 140ms ease;\">\r\n <svg viewBox=\"0 0 24 24\" style=\"width: 20px; height: 20px; fill: currentColor;\">\r\n <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/>\r\n </svg>\r\n <span>GitHub</span>\r\n </a>\r\n </footer>\r\n </main>\r\n <script>\r\n (function () {\r\n var MAX = 5;\r\n var MAX_SIZE = 100 * 1024 * 1024;\r\n var MAX_HISTORY = 80;\r\n var MAX_HISTORY_TEXT_CHARS = 12000;\r\n var MAX_LOG_ENTRIES = 300;\r\n var MAX_LOG_RAW_PREVIEW = 300;\r\n var KEY = 'qwen2api.chat.history.v1';\r\n var VIDEO_URL_KEY = 'qwen2api.video.url.v1';\r\n var SHOW_LOGS_KEY = 'qwen2api.chat.showLogs.v1';\r\n var MODEL_KEY = 'qwen2api.chat.model.v1';\r\n var LANG_KEY = 'qwen2api.chat.lang.v1';\r\n var IMAGE_PROXY_KEY = 'qwen2api.chat.imageProxy.v1';\r\n var state = { hist: [], files: [], ac: null, sending: false, showLogs: false, videoUrl: '', modelLoadId: 0, modelLoading: false, preferredModel: '', lastModelAuthKey: null, imageGenMode: 'chat', imageProxy: '' };\r\n var API_BASE = (function () {\r\n var p = (location && location.pathname) ? location.pathname : '';\r\n if (p === '/.netlify/functions/api' || p.indexOf('/.netlify/functions/api/') === 0) return '/.netlify/functions/api';\r\n if (p === '/api' || p.indexOf('/api/') === 0) return '/api';\r\n return '';\r\n })();\r\n function apiUrl(path) {\r\n return API_BASE ? (API_BASE + path) : path;\r\n }\r\n\r\n var e = {\r\n shell: document.getElementById('shell'),\r\n langToggle: document.getElementById('langToggle'),\r\n apiKey: document.getElementById('apiKey'),\r\n model: document.getElementById('model'),\r\n refreshModels: document.getElementById('refreshModels'),\r\n clear: document.getElementById('clear'),\r\n logToggle: document.getElementById('logToggle'),\r\n settingsBtn: document.getElementById('settingsBtn'),\r\n logPanel: document.getElementById('logPanel'),\r\n logs: document.getElementById('logs'),\r\n clearLogs: document.getElementById('clearLogs'),\r\n videoUrl: document.getElementById('videoUrl'),\r\n clearVideo: document.getElementById('clearVideo'),\r\n messages: document.getElementById('messages'),\r\n prompt: document.getElementById('prompt'),\r\n files: document.getElementById('files'),\r\n atts: document.getElementById('atts'),\r\n send: document.getElementById('send'),\r\n stop: document.getElementById('stop'),\r\n status: document.getElementById('status'),\r\n imageGenMode: document.getElementById('imageGenMode'),\r\n imageSize: document.getElementById('imageSize'),\r\n imageCount: document.getElementById('imageCount')\r\n };\r\n\r\n function getPreferredLang() {\r\n var v = '';\r\n try { v = localStorage.getItem(LANG_KEY) || ''; } catch (_) {}\r\n v = String(v || '').toLowerCase();\r\n if (v === 'en' || v === 'zh') return v;\r\n try {\r\n var nav = (navigator && (navigator.language || (navigator.languages && navigator.languages[0]))) || '';\r\n nav = String(nav || '').toLowerCase();\r\n if (nav.indexOf('zh') === 0) return 'zh';\r\n } catch (_) {}\r\n return 'en';\r\n }\r\n\r\n function t(key) {\r\n var L = state.lang || 'zh';\r\n var dict = {\r\n zh: {\r\n brandSub: '轻量 · 流式 · 支持附件',\r\n apiKeyPh: 'API Key(可选)',\r\n refreshModels: '刷新模型',\r\n refreshModelsLoading: '加载中...',\r\n showLogs: '显示日志',\r\n hideLogs: '隐藏日志',\r\n clearChat: '清空会话',\r\n logsTitle: '运行日志',\r\n clear: '清空',\r\n videoTag: '视频分析(可选)',\r\n videoPh: '粘贴视频链接;发送时自动进入视频分析;留空则普通对话',\r\n inputPh: '输入消息;Enter 发送,Shift+Enter 换行',\r\n send: '发送',\r\n sending: '发送中...',\r\n stop: '中断',\r\n stopping: '中断中...',\r\n retry: '重发',\r\n assistant: '助手',\r\n user: '用户',\r\n think: '思考过程',\r\n emptyAssistant: '(空响应)',\r\n attachMsg: '[附件消息]',\r\n attachPrefix: '[附件] ',\r\n emptyHint: '开始对话吧。支持粘贴文本、上传文件,Enter 发送。'\r\n ,ready: '就绪。'\r\n ,loadingModels: '正在加载模型列表...'\r\n ,modelsUpdated: '模型列表已更新({n})'\r\n ,modelsLoadingWait: '模型列表加载中,请稍候。'\r\n ,modelsLoadingWaitSend: '模型列表加载中,请稍候发送。'\r\n ,resendWait: '模型列表加载中,请稍候重发。'\r\n ,onlyResendUser: '仅支持重发用户消息。'\r\n ,historySimplified: '历史附件已简化,已回退为文本重发。'\r\n ,inputOrFileRequired: '请输入内容或选择附件。'\r\n ,processingAttachments: '正在处理附件...'\r\n ,attachmentsFailed: '附件处理失败'\r\n ,requestingChat: '请求中(流式)...'\r\n ,requestingLogs: '请求中(日志)...'\r\n ,requestingVideo: '请求中(视频分析)...'\r\n ,requestingVideoLogs: '请求中(视频分析 + 日志)...'\r\n ,done: '完成。'\r\n ,aborted: '已中断请求。'\r\n ,abortInProgress: '正在中断请求...'\r\n ,noActiveRequest: '当前没有进行中的请求。'\r\n ,busyNoDoubleSend: '请求进行中,请勿重复发送。'\r\n ,maxAttachments: '最多允许 {n} 个附件。'\r\n ,fileTooLarge: '文件过大:{name}(上限 {max})'\r\n ,addedAttachments: '已选择 {n} 个附件。'\r\n ,addedAndSkipped: '已添加 {added} 个附件,跳过 {skipped} 个重复/无效附件。'\r\n ,addedNoneSkipped: '未新增附件(均为重复或无效文件)。'\r\n ,videoUrlInvalid: '视频链接需以 http:// 或 https:// 开头。'\r\n ,videoSet: '已设置视频链接:发送时将自动进入视频分析。'\r\n ,videoCleared: '已清空视频链接:将恢复普通对话。'\r\n ,videoRestored: '已恢复上次视频链接。'\r\n ,logsClearBusy: '请求进行中,暂不能清空日志。'\r\n ,logsClearBusyModels: '模型列表加载中,请稍候清空日志。'\r\n ,logsCleared: '日志已清空。'\r\n ,logsAlreadyEmpty: '日志已为空。'\r\n ,clearChatBusy: '请求进行中,暂不能清空会话。'\r\n ,clearChatBusyModels: '模型列表加载中,请稍候清空会话。'\r\n ,chatCleared: '会话已清空。'\r\n ,chatAlreadyEmpty: '会话已为空。'\r\n ,refreshModelsBusy: '请求进行中,暂不能刷新模型。'\r\n ,loadingModelsStatus: '正在加载模型列表...'\r\n ,requestFailed: '请求失败'\r\n ,abortedTag: '[已中断]'\r\n ,errorTag: '[错误]'\r\n ,fileReadFailed: '读取文件失败: {name}'\r\n ,attachmentRemoveBusy: '请求进行中,暂不能修改附件。'\r\n ,attachmentRemoveBusyModels: '模型列表加载中,请稍候再修改附件。'\r\n ,remove: '移除'\r\n ,modelsLoadFailedFallback: '加载模型失败,已回退默认模型。'\r\n ,authFailed: '鉴权失败:Incorrect API key provided.'\r\n ,payloadTooLarge: '请求体过大,请减小附件。'\r\n ,serverError: '服务端异常,请稍后重试。'\r\n ,settings: '设置'\r\n ,imageProxy: '图片代理前缀'\r\n ,imageProxyPh: '例如:https://proxy.example.com/?url='\r\n ,imageProxyHint: '设置后,图片链接会直接拼接到该代理地址之后。留空则不使用代理。'\r\n ,save: '保存'\r\n ,cancel: '取消'\r\n ,clearProxy: '清除代理'\r\n ,imageProxySet: '图片代理已设置。'\r\n ,imageProxyCleared: '图片代理已清除。'\r\n },\r\n en: {\r\n brandSub: 'Light · Streaming · Attachments',\r\n apiKeyPh: 'API key (optional)',\r\n refreshModels: 'Refresh models',\r\n refreshModelsLoading: 'Loading...',\r\n showLogs: 'Show logs',\r\n hideLogs: 'Hide logs',\r\n clearChat: 'Clear chat',\r\n logsTitle: 'Runtime logs',\r\n clear: 'Clear',\r\n videoTag: 'Video analysis (optional)',\r\n videoPh: 'Paste a video URL; sending will trigger video analysis; empty = normal chat',\r\n inputPh: 'Type a message; Enter to send, Shift+Enter for newline',\r\n send: 'Send',\r\n sending: 'Sending...',\r\n stop: 'Stop',\r\n stopping: 'Stopping...',\r\n retry: 'Resend',\r\n assistant: 'Assistant',\r\n user: 'User',\r\n think: 'Thinking',\r\n emptyAssistant: '(empty response)',\r\n attachMsg: '[Attachment message]',\r\n attachPrefix: '[Attachments] ',\r\n emptyHint: 'Start chatting. Paste text, upload files, press Enter to send.'\r\n ,ready: 'Ready.'\r\n ,loadingModels: 'Loading model list...'\r\n ,modelsUpdated: 'Model list updated ({n})'\r\n ,modelsLoadingWait: 'Model list is loading, please wait.'\r\n ,modelsLoadingWaitSend: 'Model list is loading, please wait before sending.'\r\n ,resendWait: 'Model list is loading, please wait before resending.'\r\n ,onlyResendUser: 'Only user messages can be resent.'\r\n ,historySimplified: 'Attachments in history were simplified; falling back to text resend.'\r\n ,inputOrFileRequired: 'Type a message or choose attachments.'\r\n ,processingAttachments: 'Processing attachments...'\r\n ,attachmentsFailed: 'Failed to process attachments'\r\n ,requestingChat: 'Requesting (streaming)...'\r\n ,requestingLogs: 'Requesting (logs)...'\r\n ,requestingVideo: 'Requesting (video analysis)...'\r\n ,requestingVideoLogs: 'Requesting (video + logs)...'\r\n ,done: 'Done.'\r\n ,aborted: 'Request aborted.'\r\n ,abortInProgress: 'Aborting request...'\r\n ,noActiveRequest: 'No active request.'\r\n ,busyNoDoubleSend: 'Request in progress. Please do not send again.'\r\n ,maxAttachments: 'Up to {n} attachments.'\r\n ,fileTooLarge: 'File too large: {name} (limit {max})'\r\n ,addedAttachments: '{n} attachments selected.'\r\n ,addedAndSkipped: 'Added {added} attachments, skipped {skipped} duplicates/invalid.'\r\n ,addedNoneSkipped: 'No new attachments (all duplicates/invalid).'\r\n ,videoUrlInvalid: 'Video URL must start with http:// or https://'\r\n ,videoSet: 'Video URL set: sending will trigger video analysis.'\r\n ,videoCleared: 'Video URL cleared: normal chat mode.'\r\n ,videoRestored: 'Restored previous video URL.'\r\n ,logsClearBusy: 'Request in progress; cannot clear logs.'\r\n ,logsClearBusyModels: 'Model list is loading; cannot clear logs yet.'\r\n ,logsCleared: 'Logs cleared.'\r\n ,logsAlreadyEmpty: 'Logs are already empty.'\r\n ,clearChatBusy: 'Request in progress; cannot clear chat.'\r\n ,clearChatBusyModels: 'Model list is loading; cannot clear chat yet.'\r\n ,chatCleared: 'Chat cleared.'\r\n ,chatAlreadyEmpty: 'Chat is already empty.'\r\n ,refreshModelsBusy: 'Request in progress; cannot refresh models.'\r\n ,loadingModelsStatus: 'Loading model list...'\r\n ,requestFailed: 'Request failed'\r\n ,abortedTag: '[ABORTED]'\r\n ,errorTag: '[ERROR]'\r\n ,fileReadFailed: 'Failed to read file: {name}'\r\n ,attachmentRemoveBusy: 'Request in progress; attachments cannot be modified.'\r\n ,attachmentRemoveBusyModels: 'Model list is loading; attachments cannot be modified yet.'\r\n ,remove: 'Remove'\r\n ,modelsLoadFailedFallback: 'Failed to load models; fell back to default model.'\r\n ,authFailed: 'Auth failed: Incorrect API key provided.'\r\n ,payloadTooLarge: 'Request body too large; please reduce attachments.'\r\n ,serverError: 'Server error; please try again later.'\r\n ,settings: 'Settings'\r\n ,imageProxy: 'Image proxy prefix'\r\n ,imageProxyPh: 'e.g. https://proxy.example.com/?url='\r\n ,imageProxyHint: 'When set, image URLs are prepended with this proxy. Leave empty to disable.'\r\n ,save: 'Save'\r\n ,cancel: 'Cancel'\r\n ,clearProxy: 'Clear proxy'\r\n ,imageProxySet: 'Image proxy set.'\r\n ,imageProxyCleared: 'Image proxy cleared.'\r\n }\r\n };\r\n return (dict[L] && dict[L][key]) || (dict.zh && dict.zh[key]) || key;\r\n }\r\n\r\n function tf(key, params) {\r\n var s = String(t(key) || '');\r\n var p = params && typeof params === 'object' ? params : {};\r\n return s.replace(/\\{(\\w+)\\}/g, function (_, k) { return (p[k] !== undefined && p[k] !== null) ? String(p[k]) : ''; });\r\n }\r\n\r\n function applyLangToUI() {\r\n document.documentElement.setAttribute('lang', state.lang === 'en' ? 'en' : 'zh-CN');\r\n try { document.documentElement.style.setProperty('--empty-hint', '\"' + String(t('emptyHint')).replace(/\"/g, '\\\\\"') + '\"'); } catch (_) {}\r\n if (e.langToggle) e.langToggle.textContent = state.lang === 'en' ? '中文' : 'EN';\r\n\r\n var brandSub = document.getElementById('brandSub');\r\n if (brandSub) brandSub.textContent = t('brandSub');\r\n\r\n if (e.apiKey) e.apiKey.placeholder = t('apiKeyPh');\r\n if (e.refreshModels) e.refreshModels.textContent = state.modelLoading ? t('refreshModelsLoading') : t('refreshModels');\r\n if (e.clear) e.clear.textContent = t('clearChat');\r\n if (e.clearLogs) e.clearLogs.textContent = t('clear');\r\n if (e.videoUrl) e.videoUrl.placeholder = t('videoPh');\r\n var videoTagEl = document.getElementById('videoTag');\r\n if (videoTagEl) videoTagEl.textContent = t('videoTag');\r\n if (e.clearVideo) e.clearVideo.textContent = t('clear');\r\n var logsTitle = document.getElementById('logsTitle');\r\n if (logsTitle) logsTitle.textContent = t('logsTitle');\r\n if (e.prompt) e.prompt.placeholder = t('inputPh');\r\n if (e.send) e.send.textContent = state.sending ? t('sending') : t('send');\r\n if (e.stop) e.stop.textContent = (state.ac && state.stop.disabled) ? t('stopping') : t('stop');\r\n\r\n if (e.logToggle) {\r\n e.logToggle.textContent = state.showLogs ? t('hideLogs') : t('showLogs');\r\n }\r\n\r\n if (e.settingsBtn) e.settingsBtn.textContent = t('settings');\r\n\r\n rHist();\r\n }\r\n\r\n function setLang(next) {\r\n var v = String(next || '').toLowerCase();\r\n if (v !== 'en' && v !== 'zh') v = 'zh';\r\n state.lang = v;\r\n try { localStorage.setItem(LANG_KEY, v); } catch (_) {}\r\n applyLangToUI();\r\n }\r\n\r\n // state/history\r\n function st(t, k) { e.status.textContent = t || ''; e.status.className = 'status ' + (k || ''); }\r\n function compactMessageContentForStorage(content) {\r\n if (typeof content === 'string') {\r\n if (content.length > MAX_HISTORY_TEXT_CHARS) return content.slice(0, MAX_HISTORY_TEXT_CHARS) + '\\n\\n[TRUNCATED]';\r\n return content;\r\n }\r\n return summarizeMessageContent(content);\r\n }\r\n function compactHistoryForStorage(list) {\r\n var source = Array.isArray(list) ? list : [];\r\n var out = [];\r\n for (var i = 0; i < source.length; i++) {\r\n var item = source[i] || {};\r\n out.push({\r\n role: item.role === 'assistant' ? 'assistant' : 'user',\r\n content: compactMessageContentForStorage(item.content)\r\n });\r\n }\r\n return out;\r\n }\r\n function persistHistoryWithFallback(kept) {\r\n var list = compactHistoryForStorage(kept);\r\n for (var start = 0; start <= list.length; start++) {\r\n try {\r\n localStorage.setItem(KEY, JSON.stringify(list.slice(start)));\r\n return;\r\n } catch (_) {}\r\n }\r\n }\r\n function save() {\r\n var kept = [];\r\n try {\r\n kept = state.hist.filter(function (x) {\r\n if (!x) return false;\r\n if (typeof x.content === 'string') return !!x.content.trim();\r\n return Array.isArray(x.content) && x.content.length > 0;\r\n }).slice(-MAX_HISTORY);\r\n } catch (_) {}\r\n try { persistHistoryWithFallback(kept); } catch (_) {}\r\n try {\r\n if (state.videoUrl) localStorage.setItem(VIDEO_URL_KEY, state.videoUrl);\r\n else localStorage.removeItem(VIDEO_URL_KEY);\r\n localStorage.setItem(SHOW_LOGS_KEY, state.showLogs ? '1' : '0');\r\n if (state.preferredModel) localStorage.setItem(MODEL_KEY, state.preferredModel);\r\n else localStorage.removeItem(MODEL_KEY);\r\n if (state.imageProxy) localStorage.setItem(IMAGE_PROXY_KEY, state.imageProxy);\r\n else localStorage.removeItem(IMAGE_PROXY_KEY);\r\n } catch (_) {}\r\n }\r\n function load() {\r\n try {\r\n var v = localStorage.getItem(KEY);\r\n if (v) {\r\n var a = JSON.parse(v);\r\n if (Array.isArray(a)) {\r\n var normalized = [];\r\n for (var i = 0; i < a.length; i++) {\r\n var item = a[i] || {};\r\n var content = item.content;\r\n if (typeof content !== 'string' && !Array.isArray(content)) continue;\r\n normalized.push({\r\n role: item.role === 'assistant' ? 'assistant' : 'user',\r\n content: content\r\n });\r\n }\r\n state.hist = normalized.slice(-MAX_HISTORY);\r\n }\r\n }\r\n } catch (_) {}\r\n try {\r\n var savedVideoUrl = localStorage.getItem(VIDEO_URL_KEY);\r\n if (savedVideoUrl) {\r\n state.videoUrl = savedVideoUrl;\r\n e.videoUrl.value = savedVideoUrl;\r\n }\r\n } catch (_) {}\r\n try {\r\n state.showLogs = localStorage.getItem(SHOW_LOGS_KEY) === '1';\r\n } catch (_) {}\r\n try {\r\n var savedModel = localStorage.getItem(MODEL_KEY);\r\n if (savedModel) state.preferredModel = savedModel;\r\n } catch (_) {}\r\n try {\r\n var savedLang = localStorage.getItem(LANG_KEY);\r\n if (savedLang) state.lang = String(savedLang || '').toLowerCase();\r\n } catch (_) {}\r\n try {\r\n var savedImageProxy = localStorage.getItem(IMAGE_PROXY_KEY);\r\n if (savedImageProxy) state.imageProxy = String(savedImageProxy);\r\n } catch (_) {}\r\n }\r\n function summarizeMessageContent(content) {\r\n if (typeof content === 'string') {\r\n if (content.length > MAX_HISTORY_TEXT_CHARS) return content.slice(0, MAX_HISTORY_TEXT_CHARS) + '\\n\\n[TRUNCATED]';\r\n return content;\r\n }\r\n if (!Array.isArray(content)) return '';\r\n var texts = [];\r\n var names = [];\r\n for (var i = 0; i < content.length; i++) {\r\n var part = content[i] || {};\r\n var type = part.type || '';\r\n if (type === 'input_text' && typeof part.input_text === 'string' && part.input_text.trim()) texts.push(part.input_text.trim());\r\n if ((type === 'input_file' || type === 'input_image' || type === 'input_video' || type === 'input_audio') && typeof part.filename === 'string' && part.filename.trim()) names.push(part.filename.trim());\r\n }\r\n var base = texts.join('\\n');\r\n if (base.length > MAX_HISTORY_TEXT_CHARS) base = base.slice(0, MAX_HISTORY_TEXT_CHARS) + '\\n\\n[TRUNCATED]';\r\n if (names.length > 0) return (base ? base + '\\n' : '') + t('attachPrefix') + names.join(', ');\r\n return base;\r\n }\r\n\r\n function splitThinking(rawText) {\r\n var raw = String(rawText || '');\r\n if (!raw) return { think: '', final: '' };\r\n var thinkParts = [];\r\n var final = raw.replace(/<think>([\\s\\S]*?)<\\/think>/gi, function (_, inner) {\r\n if (inner && String(inner).trim()) thinkParts.push(String(inner).trim());\r\n return '';\r\n });\r\n final = final.replace(/\\n{3,}/g, '\\n\\n').trim();\r\n return { think: thinkParts.join('\\n\\n').trim(), final: final };\r\n }\r\n function msg(role, text, idx) {\r\n var d = document.createElement('div');\r\n d.className = 'msg ' + (role === 'assistant' ? 'a' : 'u');\r\n var head = document.createElement('div');\r\n head.className = 'msg-head';\r\n var roleLabel = document.createElement('span');\r\n roleLabel.textContent = role === 'assistant' ? t('assistant') : t('user');\r\n head.appendChild(roleLabel);\r\n if (role === 'user' && typeof idx === 'number') {\r\n var retry = document.createElement('button');\r\n retry.type = 'button';\r\n retry.className = 'mini';\r\n retry.textContent = t('retry');\r\n retry.disabled = state.sending || state.modelLoading;\r\n retry.onclick = function () { resendFromIndex(idx); };\r\n head.appendChild(retry);\r\n }\r\n var body = document.createElement('div');\r\n d.appendChild(head);\r\n d.appendChild(body);\r\n\r\n if (role === 'assistant') {\r\n var thinkWrap = document.createElement('details');\r\n thinkWrap.className = 'think';\r\n thinkWrap.open = false;\r\n var thinkSummary = document.createElement('summary');\r\n thinkSummary.textContent = t('think');\r\n var thinkPre = document.createElement('pre');\r\n thinkWrap.appendChild(thinkSummary);\r\n thinkWrap.appendChild(thinkPre);\r\n\r\n var finalDiv = document.createElement('div');\r\n var imageContainer = document.createElement('div');\r\n imageContainer.style.display = 'flex';\r\n imageContainer.style.flexWrap = 'wrap';\r\n imageContainer.style.gap = '10px';\r\n imageContainer.style.marginTop = '10px';\r\n \r\n body.appendChild(thinkWrap);\r\n body.appendChild(finalDiv);\r\n body.appendChild(imageContainer);\r\n\r\n function setAssistantRaw(rawText, imageUrls) {\r\n var sp = splitThinking(rawText);\r\n if (sp.think) {\r\n thinkWrap.style.display = '';\r\n thinkPre.textContent = sp.think;\r\n } else {\r\n thinkWrap.style.display = 'none';\r\n thinkPre.textContent = '';\r\n }\r\n finalDiv.textContent = sp.final || '';\r\n \r\n if (imageUrls && imageUrls.length > 0) {\r\n imageContainer.innerHTML = '';\r\n for (var i = 0; i < imageUrls.length; i++) {\r\n var img = document.createElement('img');\r\n var rawUrl = imageUrls[i];\r\n img.src = state.imageProxy ? (state.imageProxy + rawUrl) : rawUrl;\r\n img.style.maxWidth = '100%';\r\n img.style.maxHeight = '400px';\r\n img.style.borderRadius = '8px';\r\n img.style.cursor = 'pointer';\r\n img.onclick = function() {\r\n window.open(this.src, '_blank');\r\n };\r\n imageContainer.appendChild(img);\r\n }\r\n }\r\n }\r\n\r\n setAssistantRaw(text || '');\r\n e.messages.appendChild(d);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n return { setRaw: setAssistantRaw };\r\n }\r\n\r\n body.textContent = text || '';\r\n e.messages.appendChild(d);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n return body;\r\n }\r\n function rHist() {\r\n e.messages.innerHTML = '';\r\n for (var i = 0; i < state.hist.length; i++) {\r\n var x = state.hist[i];\r\n if (!x) continue;\r\n var content = x.content;\r\n if (typeof content !== 'string' && !Array.isArray(content)) continue;\r\n var preview = summarizeMessageContent(content);\r\n if (!preview || !String(preview).trim()) preview = x.role === 'assistant' ? t('emptyAssistant') : t('attachMsg');\r\n var msgObj = msg(x.role, preview, i);\r\n if (x.role === 'assistant' && x.imageUrls && x.imageUrls.length > 0 && msgObj && msgObj.setRaw) {\r\n msgObj.setRaw(preview, x.imageUrls);\r\n }\r\n }\r\n syncRetryEnabled();\r\n }\r\n function canSendNow() {\r\n return !!(e.prompt.value || '').trim() || state.files.length > 0;\r\n }\r\n function syncSendEnabled() {\r\n e.send.disabled = !!state.sending || !!state.modelLoading || !canSendNow();\r\n }\r\n function syncRetryEnabled() {\r\n var retryButtons = e.messages.querySelectorAll('.msg-head .mini');\r\n for (var i = 0; i < retryButtons.length; i++) {\r\n retryButtons[i].disabled = !!state.sending || !!state.modelLoading;\r\n }\r\n }\r\n function busy(b) {\r\n e.stop.disabled = !b;\r\n if (!b) e.stop.textContent = t('stop');\r\n e.prompt.disabled = b;\r\n e.files.disabled = b;\r\n e.clear.disabled = b;\r\n e.refreshModels.disabled = b || state.modelLoading;\r\n e.logToggle.disabled = b;\r\n e.clearLogs.disabled = b;\r\n e.videoUrl.disabled = b;\r\n e.clearVideo.disabled = b;\r\n e.apiKey.disabled = b;\r\n e.model.disabled = b;\r\n e.imageGenMode.disabled = b;\r\n e.imageSize.disabled = b;\r\n e.imageCount.disabled = b;\r\n e.send.textContent = b ? t('sending') : t('send');\r\n attRow();\r\n }\r\n\r\n // attachment\r\n function bfmt(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; }\r\n function attRow() {\r\n e.atts.innerHTML = '';\r\n for (var i = 0; i < state.files.length; i++) {\r\n (function (idx) {\r\n var w = document.createElement('div');\r\n w.className = 'fi';\r\n var textDiv = document.createElement('div');\r\n var f = state.files[idx];\r\n textDiv.textContent = f.name + ' (' + bfmt(f.size) + ')';\r\n var rm = document.createElement('button');\r\n rm.type = 'button';\r\n rm.textContent = t('remove');\r\n rm.disabled = state.sending || state.modelLoading;\r\n rm.onclick = function () {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? t('attachmentRemoveBusy') : t('attachmentRemoveBusyModels'), 'err');\r\n return;\r\n }\r\n state.files.splice(idx, 1);\r\n attRow();\r\n };\r\n w.appendChild(textDiv);\r\n w.appendChild(rm);\r\n e.atts.appendChild(w);\r\n })(i);\r\n }\r\n syncSendEnabled();\r\n syncRetryEnabled();\r\n }\r\n function toDataUrl(file) {\r\n return new Promise(function (ok, bad) {\r\n var r = new FileReader();\r\n r.onload = function () { ok(String(r.result || '')); };\r\n r.onerror = function () { bad(new Error(tf('fileReadFailed', { name: file.name }))); };\r\n r.readAsDataURL(file);\r\n });\r\n }\r\n async function buildContent(text, files) {\r\n var c = [];\r\n if (text) c.push({ type: 'input_text', input_text: text });\r\n for (var i = 0; i < files.length; i++) {\r\n var f = files[i];\r\n var d = await toDataUrl(f);\r\n var m = (f.type || 'application/octet-stream').toLowerCase();\r\n if (m.startsWith('image/')) c.push({ type: 'input_image', image_url: d, filename: f.name, mime_type: m });\r\n else c.push({ type: 'input_file', file_data: d, filename: f.name, mime_type: m });\r\n }\r\n return c;\r\n }\r\n\r\n // logs\r\n function formatTime(ts) {\r\n var d = new Date(ts);\r\n var h = String(d.getHours()).padStart(2, '0');\r\n var m = String(d.getMinutes()).padStart(2, '0');\r\n var s = String(d.getSeconds()).padStart(2, '0');\r\n var ms = String(d.getMilliseconds()).padStart(3, '0');\r\n return h + ':' + m + ':' + s + '.' + ms;\r\n }\r\n function formatLogValue(v) {\r\n var out = '';\r\n if (v === null || v === undefined) out = '';\r\n else if (typeof v === 'string') out = v;\r\n else if (typeof v === 'number' || typeof v === 'boolean') out = String(v);\r\n else {\r\n try { out = JSON.stringify(v); } catch (_) { out = String(v); }\r\n }\r\n if (out.length > 1000) out = out.slice(0, 1000) + '...';\r\n return out;\r\n }\r\n function addLog(logData) {\r\n if (!e.logs) return;\r\n var payload = logData && typeof logData === 'object' ? logData : { raw: String(logData || '') };\r\n var ts = Number(payload.timestamp || Date.now());\r\n var eventName = String(payload.event || 'log');\r\n var detailKeys = ['model', 'chatId', 'index', 'filename', 'filetype', 'status', 'error', 'messageCount', 'contentLength', 'attachmentCount', 'uploadedCount', 'outputLength', 'chatType', 'enableSearch', 'videoUrl', 'downloadProgress', 'downloadStatus', 'filepath', 'size', 'sizeMB', 'minResolution', 'raw'];\r\n var details = [];\r\n for (var i = 0; i < detailKeys.length; i++) {\r\n var k = detailKeys[i];\r\n if (payload[k] !== undefined && payload[k] !== null && payload[k] !== '') details.push(k + '=' + formatLogValue(payload[k]));\r\n }\r\n if (details.length === 0) {\r\n var fallback = [];\r\n for (var key in payload) {\r\n if (!Object.prototype.hasOwnProperty.call(payload, key)) continue;\r\n if (key === 'timestamp' || key === 'event') continue;\r\n if (payload[key] === undefined || payload[key] === null || payload[key] === '') continue;\r\n fallback.push(key + '=' + formatLogValue(payload[key]));\r\n }\r\n details = fallback;\r\n }\r\n var atBottom = (e.logs.scrollHeight - e.logs.scrollTop - e.logs.clientHeight) < 12;\r\n var row = document.createElement('div');\r\n row.className = 'log-entry';\r\n var t = document.createElement('span');\r\n t.className = 'log-time';\r\n t.textContent = '[' + formatTime(ts) + ']';\r\n var ev = document.createElement('span');\r\n ev.className = 'log-event';\r\n ev.textContent = eventName;\r\n var detail = document.createElement('span');\r\n detail.className = 'log-detail';\r\n detail.textContent = details.join(' | ');\r\n row.appendChild(t);\r\n row.appendChild(ev);\r\n row.appendChild(detail);\r\n e.logs.appendChild(row);\r\n while (e.logs.childNodes.length > MAX_LOG_ENTRIES) e.logs.removeChild(e.logs.firstChild);\r\n if (atBottom) e.logs.scrollTop = e.logs.scrollHeight;\r\n }\r\n\r\n // API + SSE\r\n function emsg(code, payloadText, payloadJson) {\r\n if (payloadJson && payloadJson.error && (payloadJson.error.message || payloadJson.error.details)) return String(payloadJson.error.message || payloadJson.error.details);\r\n if (code === 401) return t('authFailed');\r\n if (code === 413) return t('payloadTooLarge');\r\n if (code >= 500) return t('serverError');\r\n return payloadText ? (t('requestFailed') + ':' + payloadText) : (t('requestFailed') + '(HTTP ' + code + ')');\r\n }\r\n async function readErrorPayload(resp) {\r\n var txt = await resp.text().catch(function () { return ''; });\r\n var json = null;\r\n try { json = txt ? JSON.parse(txt) : null; } catch (_) {}\r\n return { txt: txt, json: json };\r\n }\r\n function extractAssistantText(payloadJson, payloadText) {\r\n if (payloadJson) {\r\n var c0 = payloadJson.choices && payloadJson.choices[0];\r\n var msgContent = c0 && c0.message && c0.message.content;\r\n var reasoning = c0 && c0.message && (c0.message.reasoning_content || c0.message.thinking || c0.message.reasoning);\r\n if (typeof msgContent === 'string' && msgContent.trim()) return msgContent;\r\n if (Array.isArray(msgContent)) {\r\n var parts = [];\r\n for (var i = 0; i < msgContent.length; i++) {\r\n var p = msgContent[i] || {};\r\n if (typeof p === 'string' && p.trim()) parts.push(p.trim());\r\n else if (typeof p.text === 'string' && p.text.trim()) parts.push(p.text.trim());\r\n else if (typeof p.output_text === 'string' && p.output_text.trim()) parts.push(p.output_text.trim());\r\n }\r\n if (parts.length > 0) return parts.join('\\n');\r\n }\r\n var c0text = c0 && c0.text;\r\n if (typeof c0text === 'string' && c0text.trim()) return c0text;\r\n if (typeof payloadJson.output_text === 'string' && payloadJson.output_text.trim()) return payloadJson.output_text;\r\n if (typeof reasoning === 'string' && reasoning.trim()) return '<think>' + reasoning.trim() + '</think>';\r\n }\r\n return payloadText || '';\r\n }\r\n function createSSEState() {\r\n return { buffer: '', eventType: '', dataLines: [] };\r\n }\r\n function parseSSELines(state, chunkText, isDone, consumeLogs, outRef, aMsg) {\r\n state.buffer += chunkText;\r\n var lines = state.buffer.split('\\n');\r\n state.buffer = lines.pop() || '';\r\n if (isDone && state.buffer) {\r\n lines.push(state.buffer);\r\n state.buffer = '';\r\n }\r\n\r\n var reachedDone = false;\r\n var apiError = '';\r\n\r\n function handlePayload(payload) {\r\n if (!payload) return;\r\n\r\n if (state.eventType === 'log') {\r\n if (!consumeLogs) return;\r\n try {\r\n addLog(JSON.parse(payload));\r\n } catch (_) {\r\n var clippedRaw = payload.length > MAX_LOG_RAW_PREVIEW\r\n ? (payload.slice(0, MAX_LOG_RAW_PREVIEW) + '...')\r\n : payload;\r\n addLog({ event: 'log.parse.failed', timestamp: Date.now(), raw: clippedRaw, size: payload.length });\r\n }\r\n return;\r\n }\r\n\r\n if (payload === '[DONE]') {\r\n reachedDone = true;\r\n return;\r\n }\r\n\r\n try {\r\n var parsed = JSON.parse(payload);\r\n if (parsed && parsed.error) {\r\n apiError = String((parsed.error && (parsed.error.message || parsed.error.details)) || t('requestFailed'));\r\n return;\r\n }\r\n var d0 = parsed && parsed.choices && parsed.choices[0] && parsed.choices[0].delta;\r\n var delta = d0 && d0.content;\r\n var reasoning = d0 && (d0.reasoning_content || d0.thinking || d0.reasoning);\r\n if (typeof reasoning === 'string' && reasoning) outRef.value += '<think>' + reasoning + '</think>';\r\n if (typeof delta === 'string' && delta) outRef.value += delta;\r\n if (aMsg && aMsg.setRaw) {\r\n aMsg.setRaw(outRef.value);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n }\r\n } catch (_) {\r\n // 忽略损坏行,继续消费后续流\r\n }\r\n }\r\n\r\n for (var i = 0; i < lines.length; i++) {\r\n var raw = lines[i] || '';\r\n var line = raw.replace(/\\r$/, '');\r\n var normalized = line.trimStart();\r\n if (!normalized.trim()) {\r\n if (state.dataLines.length > 0) {\r\n handlePayload(state.dataLines.join('\\n'));\r\n state.dataLines = [];\r\n }\r\n state.eventType = '';\r\n if (reachedDone || apiError) break;\r\n continue;\r\n }\r\n if (normalized.indexOf(':') === 0) {\r\n continue;\r\n }\r\n var eventMatch = normalized.match(/^event\\s*:(.*)$/);\r\n if (eventMatch) {\r\n state.eventType = eventMatch[1].trim();\r\n continue;\r\n }\r\n var dataMatch = line.match(/^\\s*data\\s*:(.*)$/);\r\n if (dataMatch) {\r\n var dataLine = dataMatch[1];\r\n if (dataLine.indexOf(' ') === 0) dataLine = dataLine.slice(1);\r\n state.dataLines.push(dataLine);\r\n }\r\n }\r\n\r\n if ((isDone || reachedDone || apiError) && state.dataLines.length > 0) {\r\n handlePayload(state.dataLines.join('\\n'));\r\n state.dataLines = [];\r\n }\r\n\r\n if (reachedDone || apiError) state.eventType = '';\r\n return { reachedDone: reachedDone, apiError: apiError };\r\n }\r\n async function loadModels() {\r\n var reqId = ++state.modelLoadId;\r\n var prev = (e.model.value || '').trim();\r\n var preferred = (state.preferredModel || '').trim();\r\n var headers = {};\r\n var key = (e.apiKey.value || '').trim();\r\n var authKey = key ? ('k:' + key) : 'anon';\r\n if (key) headers.Authorization = 'Bearer ' + key;\r\n e.refreshModels.disabled = true;\r\n e.refreshModels.textContent = t('refreshModelsLoading');\r\n state.modelLoading = true;\r\n syncSendEnabled();\r\n syncRetryEnabled();\r\n e.apiKey.disabled = true;\r\n e.model.disabled = true;\r\n e.files.disabled = true;\r\n e.clear.disabled = true;\r\n e.logToggle.disabled = true;\r\n e.clearLogs.disabled = true;\r\n e.videoUrl.disabled = true;\r\n e.clearVideo.disabled = true;\r\n try {\r\n st(t('loadingModels'), 'ok');\r\n var resp = await fetch(apiUrl('/v1/models'), { headers: headers });\r\n if (reqId !== state.modelLoadId) return;\r\n if (!resp.ok) {\r\n var modelErr = await readErrorPayload(resp);\r\n throw new Error(emsg(resp.status, modelErr.txt, modelErr.json));\r\n }\r\n var data = await resp.json();\r\n if (reqId !== state.modelLoadId) return;\r\n var latestSelected = (e.model.value || '').trim();\r\n var arr = Array.isArray(data && data.data) ? data.data : [];\r\n var ids = arr.map(function (x) { return x && x.id; }).filter(Boolean);\r\n if (ids.length === 0) ids = ['qwen3.5-plus'];\r\n e.model.innerHTML = '';\r\n for (var i = 0; i < ids.length; i++) {\r\n var op = document.createElement('option');\r\n op.value = ids[i];\r\n op.textContent = ids[i];\r\n e.model.appendChild(op);\r\n }\r\n\r\n var authChanged = state.lastModelAuthKey !== null && state.lastModelAuthKey !== authKey;\r\n if (!authChanged && latestSelected && ids.indexOf(latestSelected) >= 0) e.model.value = latestSelected;\r\n else if (!authChanged && prev && ids.indexOf(prev) >= 0) e.model.value = prev;\r\n else if (!authChanged && preferred && ids.indexOf(preferred) >= 0) e.model.value = preferred;\r\n else e.model.value = ids[0];\r\n\r\n state.lastModelAuthKey = authKey;\r\n state.preferredModel = e.model.value;\r\n save();\r\n st(tf('modelsUpdated', { n: ids.length }), 'ok');\r\n } catch (err) {\r\n if (reqId !== state.modelLoadId) return;\r\n e.model.innerHTML = '<option value=\"qwen3.5-plus\">qwen3.5-plus</option>';\r\n e.model.value = 'qwen3.5-plus';\r\n state.lastModelAuthKey = authKey;\r\n state.preferredModel = e.model.value;\r\n save();\r\n st(err && err.message ? err.message : t('modelsLoadFailedFallback'), 'err');\r\n } finally {\r\n if (reqId === state.modelLoadId) {\r\n state.modelLoading = false;\r\n e.refreshModels.textContent = t('refreshModels');\r\n e.refreshModels.disabled = !!state.sending;\r\n syncSendEnabled();\r\n syncRetryEnabled();\r\n e.apiKey.disabled = !!state.sending;\r\n e.model.disabled = !!state.sending;\r\n e.files.disabled = !!state.sending;\r\n e.clear.disabled = !!state.sending;\r\n e.logToggle.disabled = !!state.sending;\r\n e.clearLogs.disabled = !!state.sending;\r\n e.videoUrl.disabled = !!state.sending;\r\n e.clearVideo.disabled = !!state.sending;\r\n }\r\n }\r\n }\r\n function isResendContentReplayable(content) {\r\n if (!Array.isArray(content)) return true;\r\n for (var i = 0; i < content.length; i++) {\r\n var part = content[i] || {};\r\n var type = part.type || '';\r\n if (type === 'input_file' && typeof part.file_data !== 'string') return false;\r\n if (type === 'input_image' && typeof part.image_url !== 'string') return false;\r\n if (type === 'input_video' && typeof part.video_url !== 'string' && typeof part.file_data !== 'string') return false;\r\n if (type === 'input_audio' && typeof part.audio_url !== 'string' && typeof part.file_data !== 'string') return false;\r\n }\r\n return true;\r\n }\r\n function resendFromIndex(idx) {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? t('busyNoDoubleSend') : t('resendWait'), 'err');\r\n return;\r\n }\r\n var item = state.hist[idx];\r\n if (!item || item.role !== 'user' || (typeof item.content !== 'string' && !Array.isArray(item.content))) {\r\n st(t('onlyResendUser'), 'err');\r\n return;\r\n }\r\n var replayable = isResendContentReplayable(item.content);\r\n var summarized = summarizeMessageContent(item.content).trim();\r\n if (!summarized) summarized = t('attachMsg');\r\n var resendContent = replayable ? item.content : summarized;\r\n e.prompt.value = summarizeMessageContent(resendContent) || t('attachMsg');\r\n if (!replayable) st(t('historySimplified'), 'ok');\r\n send(resendContent, true, idx);\r\n }\r\n async function send(forceText, fromResend, replayFromIndex) {\r\n if (state.sending) return;\r\n if (state.modelLoading) {\r\n st(t('modelsLoadingWaitSend'), 'err');\r\n return;\r\n }\r\n\r\n // 重发时先清掉该消息及其之后的所有消息(对话流和图片生成流通用)\r\n if (typeof replayFromIndex === 'number' && replayFromIndex >= 0) {\r\n state.hist = state.hist.slice(0, replayFromIndex);\r\n save();\r\n rHist();\r\n }\r\n\r\n var text = '';\r\n var filesForSend = fromResend ? [] : state.files;\r\n var userMessageContent;\r\n if (Array.isArray(forceText)) {\r\n userMessageContent = forceText;\r\n text = summarizeMessageContent(forceText).trim();\r\n } else {\r\n text = (typeof forceText === 'string' ? forceText : (e.prompt.value || '')).trim();\r\n if (!text && filesForSend.length === 0) {\r\n st(t('inputOrFileRequired'), 'err');\r\n return;\r\n }\r\n }\r\n\r\n var model = (e.model.value || '').trim() || 'qwen3.5-plus';\r\n var key = (e.apiKey.value || '').trim();\r\n \r\n var imageGenMode = e.imageGenMode.value;\r\n if (imageGenMode === 'image') {\r\n var imageSize = e.imageSize.value;\r\n var imageCount = parseInt(e.imageCount.value, 10) || 1;\r\n \r\n msg('user', text, state.hist.length);\r\n var aMsg = msg('assistant', '');\r\n \r\n state.sending = true;\r\n state.ac = new AbortController();\r\n busy(true);\r\n st('正在生成图片...', 'ok');\r\n \r\n try {\r\n var h = { 'Content-Type': 'application/json' };\r\n if (key) h.Authorization = 'Bearer ' + key;\r\n \r\n var endpoint = apiUrl('/v1/images/generations');\r\n var reqBody = { \r\n model: model, \r\n prompt: text, \r\n n: imageCount, \r\n size: imageSize,\r\n response_format: 'url'\r\n };\r\n \r\n var resp = await fetch(endpoint, {\r\n method: 'POST',\r\n headers: h,\r\n body: JSON.stringify(reqBody),\r\n signal: state.ac.signal\r\n });\r\n \r\n if (!resp.ok) {\r\n var apiErr = await readErrorPayload(resp);\r\n throw new Error(emsg(resp.status, apiErr.txt, apiErr.json));\r\n }\r\n \r\n var data = await resp.json();\r\n var imageUrls = (data.data || []).map(function(item) { return item.url; });\r\n \r\n var resultText = '图片生成完成!点击图片可在新窗口打开。';\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(resultText, imageUrls);\r\n \r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n state.hist.push({ role: 'user', content: text });\r\n state.hist.push({ role: 'assistant', content: resultText, imageUrls: imageUrls });\r\n if (state.hist.length > MAX_HISTORY) state.hist = state.hist.slice(-MAX_HISTORY);\r\n save();\r\n e.prompt.value = '';\r\n attRow();\r\n st('图片生成完成!', 'ok');\r\n return;\r\n } catch (err) {\r\n if (err && err.name === 'AbortError') {\r\n st(t('aborted'), 'err');\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(t('abortedTag'));\r\n } else {\r\n var m = (err && err.message) || t('requestFailed');\r\n st(m, 'err');\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(t('errorTag') + ' ' + m);\r\n }\r\n state.hist.push({ role: 'user', content: text });\r\n state.hist.push({ role: 'assistant', content: (err && err.name === 'AbortError') ? t('abortedTag') : (t('errorTag') + ' ' + t('requestFailed')) });\r\n if (state.hist.length > MAX_HISTORY) state.hist = state.hist.slice(-MAX_HISTORY);\r\n save();\r\n } finally {\r\n state.ac = null;\r\n state.sending = false;\r\n busy(false);\r\n e.prompt.focus();\r\n }\r\n return;\r\n }\r\n\r\n var model = (e.model.value || '').trim() || 'qwen3.5-plus';\r\n var key = (e.apiKey.value || '').trim();\r\n\r\n var videoUrl = (e.videoUrl.value || '').trim();\r\n if (videoUrl) {\r\n var normalizedVideoUrl = normalizeVideoUrl(videoUrl);\r\n if (normalizedVideoUrl === 'INVALID') {\r\n st(t('videoUrlInvalid'), 'err');\r\n e.videoUrl.focus();\r\n return;\r\n }\r\n if (normalizedVideoUrl !== videoUrl) e.videoUrl.value = normalizedVideoUrl;\r\n if (state.videoUrl !== normalizedVideoUrl) {\r\n state.videoUrl = normalizedVideoUrl;\r\n save();\r\n }\r\n videoUrl = normalizedVideoUrl;\r\n }\r\n\r\n if (!userMessageContent) {\r\n try {\r\n st(t('processingAttachments'), 'ok');\r\n var content = await buildContent(text, filesForSend);\r\n userMessageContent = (content.length <= 1 && text) ? text : content;\r\n } catch (err) {\r\n st((err && err.message) || t('attachmentsFailed'), 'err');\r\n return;\r\n }\r\n }\r\n\r\n msg('user', summarizeMessageContent(userMessageContent) || t('attachMsg'), state.hist.length);\r\n var aMsg = msg('assistant', '');\r\n\r\n var payload = state.hist.slice();\r\n payload.push({ role: 'user', content: userMessageContent });\r\n\r\n state.sending = true;\r\n state.ac = new AbortController();\r\n busy(true);\r\n\r\n var wantVideo = !!videoUrl;\r\n var wantLogs = !!state.showLogs;\r\n var useLogEndpoint = wantLogs || wantVideo;\r\n if (wantVideo && wantLogs) st(t('requestingVideoLogs'), 'ok');\r\n else if (wantVideo) st(t('requestingVideo'), 'ok');\r\n else if (wantLogs) st(t('requestingLogs'), 'ok');\r\n else st(t('requestingChat'), 'ok');\r\n\r\n try {\r\n var h = { 'Content-Type': 'application/json' };\r\n if (key) h.Authorization = 'Bearer ' + key;\r\n\r\n var endpoint = useLogEndpoint ? apiUrl('/v1/chat/completions/log') : apiUrl('/v1/chat/completions');\r\n var reqBody = { model: model, stream: true, messages: payload };\r\n if (wantVideo) reqBody.video_url = videoUrl;\r\n\r\n var resp = await fetch(endpoint, {\r\n method: 'POST',\r\n headers: h,\r\n body: JSON.stringify(reqBody),\r\n signal: state.ac.signal\r\n });\r\n\r\n if (!resp.ok) {\r\n var apiErr = await readErrorPayload(resp);\r\n throw new Error(emsg(resp.status, apiErr.txt, apiErr.json));\r\n }\r\n\r\n var contentType = String(resp.headers.get('content-type') || '').toLowerCase();\r\n var isEventStream = contentType.indexOf('text/event-stream') >= 0;\r\n\r\n if (!isEventStream) {\r\n var plain = await readErrorPayload(resp);\r\n if (plain.json && plain.json.error) {\r\n throw new Error(emsg(resp.status, plain.txt, plain.json));\r\n }\r\n var directText = extractAssistantText(plain.json, plain.txt).trim();\r\n var plainOutput = directText || t('emptyAssistant');\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(plainOutput);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n state.hist.push({ role: 'user', content: userMessageContent });\r\n state.hist.push({ role: 'assistant', content: plainOutput });\r\n if (state.hist.length > MAX_HISTORY) state.hist = state.hist.slice(-MAX_HISTORY);\r\n save();\r\n e.prompt.value = '';\r\n state.files = [];\r\n attRow();\r\n st(t('done'), 'ok');\r\n return;\r\n }\r\n\r\n if (!resp.body) throw new Error('浏览器不支持流式响应。');\r\n\r\n var reader = resp.body.getReader();\r\n var decoder = new TextDecoder();\r\n var sseState = createSSEState();\r\n var outRef = { value: '' };\r\n var gotDone = false;\r\n var streamError = '';\r\n\r\n while (!gotDone) {\r\n var ch = await reader.read();\r\n var done = !!ch.done;\r\n var chunkText = decoder.decode(ch.value || new Uint8Array(), { stream: !done });\r\n var parsed = parseSSELines(sseState, chunkText, done, wantLogs, outRef, aMsg);\r\n if (parsed.apiError) {\r\n streamError = parsed.apiError;\r\n gotDone = true;\r\n }\r\n if (parsed.reachedDone || done) gotDone = true;\r\n }\r\n\r\n if (streamError) {\r\n throw new Error(streamError);\r\n }\r\n\r\n if (!outRef.value) outRef.value = t('emptyAssistant');\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(outRef.value);\r\n\r\n state.hist.push({ role: 'user', content: userMessageContent });\r\n state.hist.push({ role: 'assistant', content: outRef.value });\r\n if (state.hist.length > MAX_HISTORY) state.hist = state.hist.slice(-MAX_HISTORY);\r\n save();\r\n e.prompt.value = '';\r\n state.files = [];\r\n attRow();\r\n st(t('done'), 'ok');\r\n } catch (err) {\r\n if (err && err.name === 'AbortError') {\r\n st(t('aborted'), 'err');\r\n var cur = outRef.value || '';\r\n outRef.value = cur ? (cur + '\\n\\n' + t('abortedTag')) : t('abortedTag');\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(outRef.value);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n } else {\r\n var m = (err && err.message) || t('requestFailed');\r\n st(m, 'err');\r\n var cur2 = outRef.value || '';\r\n outRef.value = cur2 ? (cur2 + '\\n\\n' + t('errorTag') + ' ' + m) : (t('errorTag') + ' ' + m);\r\n if (aMsg && aMsg.setRaw) aMsg.setRaw(outRef.value);\r\n e.messages.scrollTop = e.messages.scrollHeight;\r\n }\r\n state.hist.push({ role: 'user', content: userMessageContent });\r\n state.hist.push({ role: 'assistant', content: outRef.value || ((err && err.name === 'AbortError') ? t('abortedTag') : (t('errorTag') + ' ' + t('requestFailed'))) });\r\n if (state.hist.length > MAX_HISTORY) state.hist = state.hist.slice(-MAX_HISTORY);\r\n save();\r\n } finally {\r\n state.ac = null;\r\n state.sending = false;\r\n busy(false);\r\n e.prompt.focus();\r\n }\r\n }\r\n\r\n function setLogPanelVisible(visible) {\r\n state.showLogs = !!visible;\r\n if (state.showLogs) {\r\n if (e.shell) e.shell.classList.add('has-logs');\r\n e.logPanel.classList.add('visible');\r\n e.logToggle.textContent = '隐藏日志';\r\n e.logToggle.classList.add('active');\r\n e.logs.scrollTop = e.logs.scrollHeight;\r\n } else {\r\n if (e.shell) e.shell.classList.remove('has-logs');\r\n e.logPanel.classList.remove('visible');\r\n e.logToggle.textContent = '显示日志';\r\n e.logToggle.classList.remove('active');\r\n }\r\n }\r\n\r\n function openSettingsDialog() {\r\n if (state.settingsDialog) return;\r\n\r\n var backdrop = document.createElement('div');\r\n backdrop.className = 'modal-backdrop';\r\n\r\n function close() {\r\n document.removeEventListener('keydown', escListener);\r\n if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);\r\n state.settingsDialog = null;\r\n }\r\n function escListener(ev) {\r\n if (ev.key === 'Escape') {\r\n ev.preventDefault();\r\n close();\r\n }\r\n }\r\n\r\n var dialog = document.createElement('div');\r\n dialog.className = 'modal';\r\n\r\n var title = document.createElement('h3');\r\n title.textContent = t('settings');\r\n\r\n var label = document.createElement('div');\r\n label.className = 'field-label';\r\n label.textContent = t('imageProxy');\r\n\r\n var input = document.createElement('input');\r\n input.type = 'text';\r\n input.id = 'imageProxyInput';\r\n input.placeholder = t('imageProxyPh');\r\n input.value = state.imageProxy || '';\r\n input.autocomplete = 'off';\r\n input.spellcheck = false;\r\n input.style.width = '100%';\r\n\r\n var hint = document.createElement('div');\r\n hint.className = 'hint';\r\n hint.textContent = t('imageProxyHint');\r\n\r\n var actions = document.createElement('div');\r\n actions.className = 'actions';\r\n\r\n var clearBtn = document.createElement('button');\r\n clearBtn.type = 'button';\r\n clearBtn.className = 'warn';\r\n clearBtn.textContent = t('clearProxy');\r\n clearBtn.onclick = function () {\r\n state.imageProxy = '';\r\n save();\r\n close();\r\n st(t('imageProxyCleared'), 'ok');\r\n if (!state.sending) rHist();\r\n };\r\n\r\n var cancelBtn = document.createElement('button');\r\n cancelBtn.type = 'button';\r\n cancelBtn.className = 'btn';\r\n cancelBtn.textContent = t('cancel');\r\n cancelBtn.onclick = close;\r\n\r\n var saveBtn = document.createElement('button');\r\n saveBtn.type = 'button';\r\n saveBtn.className = 'primary';\r\n saveBtn.textContent = t('save');\r\n saveBtn.onclick = function () {\r\n var v = (input.value || '').trim();\r\n state.imageProxy = v;\r\n save();\r\n close();\r\n st(v ? t('imageProxySet') : t('imageProxyCleared'), 'ok');\r\n if (!state.sending) rHist();\r\n };\r\n\r\n input.addEventListener('keydown', function (ev) {\r\n if (ev.key === 'Enter' && !ev.isComposing) {\r\n ev.preventDefault();\r\n saveBtn.click();\r\n }\r\n });\r\n\r\n actions.appendChild(clearBtn);\r\n actions.appendChild(cancelBtn);\r\n actions.appendChild(saveBtn);\r\n\r\n dialog.appendChild(title);\r\n dialog.appendChild(label);\r\n dialog.appendChild(input);\r\n dialog.appendChild(hint);\r\n dialog.appendChild(actions);\r\n\r\n backdrop.appendChild(dialog);\r\n\r\n backdrop.addEventListener('click', function (ev) {\r\n if (ev.target === backdrop) close();\r\n });\r\n\r\n document.addEventListener('keydown', escListener);\r\n\r\n document.body.appendChild(backdrop);\r\n state.settingsDialog = backdrop;\r\n\r\n setTimeout(function () { input.focus(); input.select(); }, 50);\r\n }\r\n\r\n // events\r\n if (e.langToggle) {\r\n e.langToggle.onclick = function () {\r\n setLang(state.lang === 'en' ? 'zh' : 'en');\r\n };\r\n }\r\n e.logToggle.onclick = function () {\r\n setLogPanelVisible(!state.showLogs);\r\n save();\r\n };\r\n if (e.settingsBtn) {\r\n e.settingsBtn.onclick = function () { openSettingsDialog(); };\r\n }\r\n e.clearLogs.onclick = function () {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? t('logsClearBusy') : t('logsClearBusyModels'), 'err');\r\n return;\r\n }\r\n var hadLogs = e.logs.childNodes.length > 0;\r\n e.logs.innerHTML = '';\r\n st(hadLogs ? t('logsCleared') : t('logsAlreadyEmpty'), 'ok');\r\n };\r\n function normalizeVideoUrl(v) {\r\n var url = (v || '').trim();\r\n if (!url) return '';\r\n if (!/^https?:\\/\\//i.test(url)) return 'INVALID';\r\n return url;\r\n }\r\n function syncVideoUrlFromInput(showHint) {\r\n var normalized = normalizeVideoUrl(e.videoUrl.value || '');\r\n if (normalized === 'INVALID') {\r\n st(t('videoUrlInvalid'), 'err');\r\n return false;\r\n }\r\n state.videoUrl = normalized;\r\n e.videoUrl.value = normalized;\r\n save();\r\n if (showHint) {\r\n if (normalized) st(t('videoSet'), 'ok');\r\n else st(t('videoCleared'), 'ok');\r\n }\r\n return true;\r\n }\r\n e.clearVideo.onclick = function () {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? t('busyNoDoubleSend') : t('modelsLoadingWait'), 'err');\r\n return;\r\n }\r\n e.videoUrl.value = '';\r\n syncVideoUrlFromInput(true);\r\n if (state.showLogs) addLog({ event: 'video.url.cleared', timestamp: Date.now() });\r\n };\r\n e.videoUrl.addEventListener('blur', function () {\r\n if (state.sending || state.modelLoading) return;\r\n syncVideoUrlFromInput(false);\r\n });\r\n e.videoUrl.addEventListener('keydown', function (ev) {\r\n if (ev.key === 'Escape') {\r\n if (state.sending || state.modelLoading) return;\r\n ev.preventDefault();\r\n e.videoUrl.value = state.videoUrl || '';\r\n st(t('videoRestored'), 'ok');\r\n return;\r\n }\r\n if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {\r\n ev.preventDefault();\r\n if (state.sending || state.modelLoading) return;\r\n syncVideoUrlFromInput(true);\r\n e.prompt.focus();\r\n }\r\n });\r\n e.files.onchange = function () {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? '请求进行中,暂不能修改附件。' : '模型列表加载中,请稍候再修改附件。', 'err');\r\n e.files.value = '';\r\n return;\r\n }\r\n var fs = Array.from(e.files.files || []);\r\n var added = 0;\r\n var skipped = 0;\r\n for (var i = 0; i < fs.length; i++) {\r\n if (state.files.length >= MAX) {\r\n st(tf('maxAttachments', { n: MAX }), 'err');\r\n break;\r\n }\r\n var f = fs[i];\r\n if (f.size > MAX_SIZE) {\r\n st(tf('fileTooLarge', { name: f.name, max: bfmt(MAX_SIZE) }), 'err');\r\n skipped++;\r\n continue;\r\n }\r\n var exists = false;\r\n for (var j = 0; j < state.files.length; j++) {\r\n var existing = state.files[j];\r\n if (existing && existing.name === f.name && existing.size === f.size && existing.lastModified === f.lastModified) {\r\n exists = true;\r\n break;\r\n }\r\n }\r\n if (exists) {\r\n skipped++;\r\n continue;\r\n }\r\n state.files.push(f);\r\n added++;\r\n }\r\n e.files.value = '';\r\n attRow();\r\n if (added > 0 && skipped > 0) st(tf('addedAndSkipped', { added: added, skipped: skipped }), 'ok');\r\n else if (added > 0) st(tf('addedAttachments', { n: state.files.length }), 'ok');\r\n else if (skipped > 0) st(t('addedNoneSkipped'), 'err');\r\n };\r\n e.send.onclick = function () {\r\n if (state.sending) {\r\n st(t('busyNoDoubleSend'), 'err');\r\n return;\r\n }\r\n if (!canSendNow()) return;\r\n if (state.modelLoading) {\r\n st(t('modelsLoadingWaitSend'), 'err');\r\n return;\r\n }\r\n send();\r\n };\r\n e.stop.onclick = function () {\r\n if (!state.ac) {\r\n st(t('noActiveRequest'), 'err');\r\n return;\r\n }\r\n state.ac.abort();\r\n e.stop.disabled = true;\r\n e.stop.textContent = t('stopping');\r\n st(t('abortInProgress'), 'ok');\r\n };\r\n e.clear.onclick = function () {\r\n if (state.sending || state.modelLoading) {\r\n st(state.sending ? t('clearChatBusy') : t('clearChatBusyModels'), 'err');\r\n return;\r\n }\r\n var hadSession = state.hist.length > 0 || state.files.length > 0 || !!(e.prompt.value || '').trim();\r\n state.hist = [];\r\n save();\r\n state.files = [];\r\n attRow();\r\n rHist();\r\n e.prompt.value = '';\r\n syncSendEnabled();\r\n st(hadSession ? t('chatCleared') : t('chatAlreadyEmpty'), 'ok');\r\n e.prompt.focus();\r\n };\r\n e.refreshModels.onclick = function () {\r\n if (state.sending) {\r\n st(t('refreshModelsBusy'), 'err');\r\n return;\r\n }\r\n if (state.modelLoading) {\r\n st(t('modelsLoadingWait'), 'ok');\r\n return;\r\n }\r\n loadModels();\r\n };\r\n e.apiKey.addEventListener('blur', function () {\r\n if (state.sending || state.modelLoading) return;\r\n var key = (e.apiKey.value || '').trim();\r\n var authKey = key ? ('k:' + key) : 'anon';\r\n if (state.lastModelAuthKey === authKey) return;\r\n loadModels();\r\n });\r\n e.model.addEventListener('change', function () {\r\n state.preferredModel = (e.model.value || '').trim();\r\n save();\r\n });\r\n e.apiKey.addEventListener('keydown', function (ev) {\r\n if (ev.key === 'Enter' && !ev.isComposing) {\r\n ev.preventDefault();\r\n if (state.sending || state.modelLoading) return;\r\n var key = (e.apiKey.value || '').trim();\r\n var authKey = key ? ('k:' + key) : 'anon';\r\n if (state.lastModelAuthKey === authKey) return;\r\n loadModels();\r\n }\r\n });\r\n e.prompt.onkeydown = function (ev) {\r\n if (ev.key === 'Escape') {\r\n if (state.ac) {\r\n ev.preventDefault();\r\n state.ac.abort();\r\n e.stop.disabled = true;\r\n e.stop.textContent = t('stopping');\r\n st(t('abortInProgress'), 'ok');\r\n }\r\n return;\r\n }\r\n if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {\r\n ev.preventDefault();\r\n if (state.sending) {\r\n st(t('busyNoDoubleSend'), 'err');\r\n return;\r\n }\r\n if (!canSendNow()) return;\r\n if (state.modelLoading) {\r\n st(t('modelsLoadingWaitSend'), 'err');\r\n return;\r\n }\r\n send();\r\n }\r\n };\r\n e.prompt.addEventListener('input', syncSendEnabled);\r\n\r\n load();\r\n state.lang = getPreferredLang();\r\n rHist();\r\n setLogPanelVisible(state.showLogs);\r\n syncSendEnabled();\r\n applyLangToUI();\r\n st(t('ready'), 'ok');\r\n loadModels();\r\n })();\r\n </script>\r\n</body>\r\n</html>\r\n";