// /static/app.js const $ = (sel) => document.querySelector(sel); const SIDEBAR_COLLAPSED_KEY = "openstoryline_sidebar_collapsed"; const DEVBAR_COLLAPSED_KEY = "openstoryline_devbar_collapsed"; const AUDIO_PREVIEW_MAX = 3; const CUSTOM_MODEL_KEY = "__custom__"; const TTS_VENDOR_BYTEDANCE = "bytedance"; class ApiClient { async createSession() { const r = await fetch("/api/sessions", { method: "POST" }); if (!r.ok) throw new Error(await r.text()); return await r.json(); } async getSession(sessionId) { const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`); if (!r.ok) throw new Error(await r.text()); return await r.json(); } async cancelTurn(sessionId) { const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" }); if (!r.ok) throw new Error(await this._readFetchError(r)); return await r.json(); } async _readFetchError(r) { const t = await r.text(); try { const j = JSON.parse(t); // 兼容 middleware/接口的 429: {detail:"Too Many Requests", retry_after:n} if (j && typeof j === "object") { const ra = (j.retry_after != null) ? Number(j.retry_after) : (j.detail && j.detail.retry_after != null ? Number(j.detail.retry_after) : null); if (typeof j.detail === "string") return ra != null ? `${j.detail}(${ra}s后再试)` : j.detail; if (j.detail && typeof j.detail === "object") { const msg = j.detail.message || j.detail.detail || j.detail.error || JSON.stringify(j.detail); return ra != null ? `${msg}(${ra}s后再试)` : msg; } if (typeof j.message === "string") return ra != null ? `${j.message}(${ra}s后再试)` : j.message; } } catch {} return t || `HTTP ${r.status}`; } async initResumableAsset(sessionId, file, { chunkSize } = {}) { const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/assets/init`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filename: file.name, size: file.size, mime_type: file.type, last_modified: file.lastModified, chunk_size: chunkSize, // 服务端可忽略(以服务端配置为准) }), }); if (!r.ok) throw new Error(await this._readFetchError(r)); return await r.json(); } uploadResumableChunk(sessionId, uploadId, index, blob, onProgress) { return new Promise((resolve, reject) => { const form = new FormData(); form.append("index", String(index)); // 这里用 blob(分片),而不是整文件 form.append("chunk", blob, "chunk"); const xhr = new XMLHttpRequest(); xhr.open( "POST", `/api/sessions/${encodeURIComponent(sessionId)}/assets/${encodeURIComponent(uploadId)}/chunk`, true ); xhr.upload.onprogress = (e) => { if (typeof onProgress === "function") { const loaded = e && typeof e.loaded === "number" ? e.loaded : 0; const total = e && typeof e.total === "number" ? e.total : (blob ? blob.size : 0); onProgress(loaded, total); } }; xhr.onload = () => { const ok = xhr.status >= 200 && xhr.status < 300; if (ok) { try { resolve(JSON.parse(xhr.responseText || "{}")); } catch (e) { resolve({}); } return; } // 错误:尽量把 JSON detail 解析成可读信息 const text = xhr.responseText || ""; let msg = text || `HTTP ${xhr.status}`; try { const j = JSON.parse(text); const ra = (j && typeof j === "object" && j.retry_after != null) ? Number(j.retry_after) : null; if (j && typeof j.detail === "string") msg = ra != null ? `${j.detail}(${ra}s后再试)` : j.detail; else if (j && typeof j.detail === "object") { const m = j.detail.message || j.detail.detail || j.detail.error || JSON.stringify(j.detail); msg = ra != null ? `${m}(${ra}s后再试)` : m; } } catch {} reject(new Error(msg)); }; xhr.onerror = () => reject(new Error("network error")); xhr.send(form); }); } async completeResumableAsset(sessionId, uploadId) { const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/assets/${encodeURIComponent(uploadId)}/complete`, { method: "POST", }); if (!r.ok) throw new Error(await this._readFetchError(r)); return await r.json(); // { asset, pending_assets } } async cancelResumableAsset(sessionId, uploadId) { try { await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/assets/${encodeURIComponent(uploadId)}/cancel`, { method: "POST" }); } catch {} } // 单文件:init -> chunk... -> complete async uploadAssetChunked(sessionId, file, { chunkSize, onProgress } = {}) { const init = await this.initResumableAsset(sessionId, file, { chunkSize }); const uploadId = init.upload_id; const cs = Number(init.chunk_size) || Number(chunkSize) || (32 * 1024 * 1024); const totalChunks = Number(init.total_chunks) || Math.ceil((file.size || 0) / cs) || 1; let confirmed = 0; // 已完成分片字节数(本文件内) try { for (let i = 0; i < totalChunks; i++) { const start = i * cs; const end = Math.min(file.size, start + cs); const blob = file.slice(start, end); await this.uploadResumableChunk(sessionId, uploadId, i, blob, (loaded) => { if (typeof onProgress === "function") { // confirmed + 当前分片已上传字节 onProgress(Math.min(file.size, confirmed + (loaded || 0)), file.size); } }); confirmed += blob.size; if (typeof onProgress === "function") onProgress(Math.min(file.size, confirmed), file.size); } return await this.completeResumableAsset(sessionId, uploadId); } catch (e) { // 失败尽量清理服务端临时文件 await this.cancelResumableAsset(sessionId, uploadId); throw e; } } async deletePendingAsset(sessionId, assetId) { const r = await fetch( `/api/sessions/${encodeURIComponent(sessionId)}/assets/pending/${encodeURIComponent(assetId)}`, { method: "DELETE" } ); if (!r.ok) throw new Error(await r.text()); return await r.json(); } } class WsClient { constructor(url, onEvent) { this.url = url; this.onEvent = onEvent; this.ws = null; this._timer = null; this._closedByUser = false; } connect() { this._closedByUser = false; this.ws = new WebSocket(this.url); this.ws.onopen = () => { // 心跳(可选) this._timer = setInterval(() => { if (this.ws && this.ws.readyState === 1) { this.send("ping", {}); } }, 25000); }; this.ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } if (this.onEvent) this.onEvent(msg); }; this.ws.onclose = (ev) => { if (this._timer) clearInterval(this._timer); this._timer = null; console.warn("[ws] closed", { code: ev?.code, reason: ev?.reason, wasClean: ev?.wasClean, }); if (this._closedByUser) return; // session 不存在就不要重连 if (ev && ev.code === 4404) { localStorage.removeItem("openstoryline_session_id"); location.reload(); return; } setTimeout(() => this.connect(), 1000); }; } close() { this._closedByUser = true; if (this._timer) clearInterval(this._timer); this._timer = null; if (this.ws) { try { this.ws.close(1000, "client switch session"); } catch {} this.ws = null; } } send(type, data) { if (!this.ws || this.ws.readyState !== 1) return; this.ws.send(JSON.stringify({ type, data })); } } class ChatUI { constructor() { this.chatEl = $("#chat"); this.pendingBarEl = $("#pendingBar"); this.pendingRowEl = $("#pendingRow"); this.toastEl = $("#toast"); // developer this.devLogEl = $("#devLog") this.devDomByID = new Map() this.modalEl = $("#modal"); this.modalBackdrop = $("#modalBackdrop"); this.modalClose = $("#modalClose"); this.modalContent = $("#modalContent"); this.toolDomById = new Map(); this.toolMediaDomById = new Map(); this.currentAssistant = null; // { bubbleEl, rawText } this.mdStreaming = true; // 是否启用流式 markdown this._mdRaf = 0; // requestAnimationFrame id this._mdTimer = null; // setTimeout id this._mdLastRenderAt = 0; // 上次渲染时间 this._mdRenderInterval = 80; // 渲染时间间隔 this._toolUi = this._loadToolUiConfig(); this.scrollBtnEl = $("#scrollToBottomBtn"); this._bindScrollJumpBtn(); this._bindScrollWatcher(); } setSessionId(sessionId) { this._sessionId = sessionId; const s = `session_id: ${sessionId}`; const el = $("#sidebarSid"); if (el) el.textContent = s; } showToast(text) { this.toastEl.textContent = text; this.toastEl.classList.remove("hidden"); } hideToast() { this.toastEl.classList.add("hidden"); } _docScrollHeight() { const de = document.documentElement; return (de && de.scrollHeight) ? de.scrollHeight : document.body.scrollHeight; } isNearBottom(threshold = 160) { const top = window.scrollY || window.pageYOffset || 0; const h = window.innerHeight || 0; return (top + h) >= (this._docScrollHeight() - threshold); } _updateScrollJumpBtnVisibility(force) { if (!this.scrollBtnEl) return; let show; if (force === true) show = true; else if (force === false) show = false; else show = !this.isNearBottom(); this.scrollBtnEl.classList.toggle("hidden", !show); } scrollToBottom({ behavior = "smooth" } = {}) { requestAnimationFrame(() => { window.scrollTo({ top: this._docScrollHeight(), behavior }); }); } maybeAutoScroll(wasNearBottom, { behavior = "auto" } = {}) { if (wasNearBottom) { this.scrollToBottom({ behavior }); this._updateScrollJumpBtnVisibility(false); } else { this._updateScrollJumpBtnVisibility(true); } } _bindScrollJumpBtn() { if (!this.scrollBtnEl || this._scrollBtnBound) return; this._scrollBtnBound = true; this.scrollBtnEl.addEventListener("click", (e) => { e.preventDefault(); this.scrollToBottom({ behavior: "smooth" }); this._updateScrollJumpBtnVisibility(false); }); } _bindScrollWatcher() { if (this._scrollWatchBound) return; this._scrollWatchBound = true; const handler = () => this._updateScrollJumpBtnVisibility(); window.addEventListener("scroll", handler, { passive: true }); window.addEventListener("resize", handler, { passive: true }); requestAnimationFrame(handler); } clearAll() { this.chatEl.innerHTML = ""; // 停掉所有假进度条 timer for (const [, dom] of this.toolDomById) { if (dom && dom._fakeTimer) { clearInterval(dom._fakeTimer); dom._fakeTimer = null; } } this.toolDomById.clear(); this.currentAssistant = null; if (this.devLogEl) this.devLogEl.innerHTML = ""; this.devDomByID.clear() // 清掉 tool 外部媒体块 if (this.toolMediaDomById) { for (const [, dom] of this.toolMediaDomById) { try { dom?.wrap?.remove(); } catch {} } this.toolMediaDomById.clear(); } } setBubbleContent(bubbleEl, text, { markdown = true } = {}) { const s = String(text ?? ""); // 纯文本模式:用于 user bubble(避免 marked 生成
导致默认 margin 撑大气泡) if (!markdown || !window.marked || !window.DOMPurify) { bubbleEl.textContent = s; return; } if (!this._mdInited) { window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false, }); window.DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (node.tagName === "A") { node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener noreferrer"); } }); this._mdInited = true; } const rawHtml = window.marked.parse(s); const safeHtml = window.DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } }); bubbleEl.innerHTML = safeHtml; } renderPendingAssets(pendingAssets) { this.pendingRowEl.innerHTML = ""; if (!pendingAssets || !pendingAssets.length) { this.pendingBarEl.classList.add("hidden"); return; } this.pendingBarEl.classList.remove("hidden"); for (const a of pendingAssets) { this.pendingRowEl.appendChild(this.renderAssetThumb(a, { removable: true })); } } assetTag(kind) { if (kind === "image") return "IMG"; if (kind === "video") return "VID"; return ""; } renderAssetThumb(asset, { removable } = { removable: false }) { const el = document.createElement("div"); el.className = "asset-item"; el.title = asset.name || ""; const img = document.createElement("img"); img.src = asset.thumb_url; img.alt = asset.name || ""; el.appendChild(img); const tag = document.createElement("div"); tag.className = "asset-tag"; tag.textContent = this.assetTag(asset.kind); el.appendChild(tag); if (asset.kind === "video") { const play = document.createElement("div"); play.className = "asset-play"; el.appendChild(play); } el.addEventListener("click", (e) => { if (e.target?.classList?.contains("asset-remove")) return; this.openPreview(asset); }); if (removable) { const rm = document.createElement("div"); rm.className = "asset-remove"; rm.textContent = "×"; rm.dataset.assetId = asset.id; el.appendChild(rm); } return el; } renderAttachmentsRow(attachments, alignRight) { if (!attachments || !attachments.length) return null; const wrap = document.createElement("div"); wrap.className = "attach-wrap"; if (alignRight) wrap.classList.add("align-right"); const row = document.createElement("div"); row.className = "attach-row"; for (const a of attachments) { row.appendChild(this.renderAssetThumb(a, { removable: false })); } wrap.appendChild(row); return wrap; } appendUserMessage(text, attachments) { const wrap = document.createElement("div"); wrap.className = "msg user"; const container = document.createElement("div"); container.style.maxWidth = "78%"; const attachRow = this.renderAttachmentsRow(attachments, true); if (attachRow) container.appendChild(attachRow); const bubble = document.createElement("div"); bubble.className = "bubble"; this.setBubbleContent(bubble, text, { markdown: false }); container.appendChild(bubble); wrap.appendChild(container); this.chatEl.appendChild(wrap); this.scrollToBottom({ behavior: "smooth" }); this._updateScrollJumpBtnVisibility(false); } startAssistantMessage({ placeholder = true } = {}) { const wasNearBottom = this.isNearBottom(); const wrap = document.createElement("div"); wrap.className = "msg assistant"; const bubble = document.createElement("div"); bubble.className = "bubble"; this.setBubbleContent(bubble, placeholder ? "正在调用大模型中…" : ""); wrap.appendChild(bubble); this.chatEl.appendChild(wrap); this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); // 记录 wrapEl,flush 时可移除“纯占位气泡” this.currentAssistant = { wrapEl: wrap, bubbleEl: bubble, rawText: "" }; } _normalizeStreamingMarkdown(s) { s = String(s ?? "").replace(/\r\n?/g, "\n"); const ticks = (s.match(/```/g) || []).length; if (ticks % 2 === 1) s += "\n```"; return s; } _renderAssistantStreaming(cur) { this._mdLastRenderAt = Date.now(); const wasNearBottom = this.isNearBottom(160); const md = this._normalizeStreamingMarkdown(cur.rawText); this.setBubbleContent(cur.bubbleEl, md); if (wasNearBottom) this.scrollToBottom({ behavior: "auto" }); else this._updateScrollJumpBtnVisibility(true); } appendAssistantDelta(delta) { console.log("md deps", !!window.marked, !!window.DOMPurify); if (!this.currentAssistant) this.startAssistantMessage({ placeholder: false }); const cur = this.currentAssistant; cur.rawText += (delta || ""); // 节流:避免每 token 都 parse + sanitize const now = Date.now(); const due = now - this._mdLastRenderAt >= this._mdRenderInterval; if (due) { this._renderAssistantStreaming(cur); return; } if (this._mdTimer) return; const wait = Math.max(0, this._mdRenderInterval - (now - this._mdLastRenderAt)); this._mdTimer = setTimeout(() => { this._mdTimer = null; if (this.currentAssistant) this._renderAssistantStreaming(this.currentAssistant); }, wait); } finalizeAssistant(text) { const wasNearBottom = this.isNearBottom(); if (!this.currentAssistant) { this.startAssistantMessage({ placeholder: false}); } const cur = this.currentAssistant; cur.rawText = (text ?? cur.rawText ?? "").trim(); this.setBubbleContent(cur.bubbleEl, cur.rawText || "(未生成最终答复)"); this.currentAssistant = null; this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); } // 结束当前 assistant 分段(用于 tool.start 前封口) flushAssistantSegment() { const wasNearBottom = this.isNearBottom(); const cur = this.currentAssistant; if (!cur) return; const text = (cur.rawText || "").trim(); if (!text) { // 没有任何 token(只有占位文案)=> 直接移除 if (cur.wrapEl) cur.wrapEl.remove(); } else { this.setBubbleContent(cur.bubbleEl, text); } this.currentAssistant = null; this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); } // 结束整个 turn(对应后端 assistant.end) endAssistantTurn(text) { const wasNearBottom = this.isNearBottom(); const s = String(text ?? "").trim(); if (this.currentAssistant) { const cur = this.currentAssistant; // 如果服务端给了最终文本,以服务端为准 if (s) cur.rawText = s; const finalText = (cur.rawText || "").trim(); if (!finalText) { if (cur.wrapEl) cur.wrapEl.remove(); } else { this.setBubbleContent(cur.bubbleEl, finalText); } this.currentAssistant = null; this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); return; } // 没有正在流的 bubble:只有当确实有文本时才新建一条 if (s) { this.startAssistantMessage({ placeholder: false }); const cur = this.currentAssistant; cur.rawText = s; this.setBubbleContent(cur.bubbleEl, s); this.currentAssistant = null; this.scrollToBottom(); } } _loadToolUiConfig() { const cfg = (window.OPENSTORYLINE_TOOL_UI && typeof window.OPENSTORYLINE_TOOL_UI === "object") ? window.OPENSTORYLINE_TOOL_UI : {}; const labels = (cfg.labels && typeof cfg.labels === "object") ? cfg.labels : (window.OPENSTORYLINE_TOOL_LABELS && typeof window.OPENSTORYLINE_TOOL_LABELS === "object") ? window.OPENSTORYLINE_TOOL_LABELS : {}; const estimatesMs = (cfg.estimates_ms && typeof cfg.estimates_ms === "object") ? cfg.estimates_ms : (cfg.estimatesMs && typeof cfg.estimatesMs === "object") ? cfg.estimatesMs : (window.OPENSTORYLINE_TOOL_ESTIMATES && typeof window.OPENSTORYLINE_TOOL_ESTIMATES === "object") ? window.OPENSTORYLINE_TOOL_ESTIMATES : {}; const defaultEstimateMs = Number(cfg.default_estimate_ms ?? cfg.defaultEstimateMs ?? 8000); const tickMs = Number(cfg.tick_ms ?? cfg.tickMs ?? 120); const capRunning = Number(cfg.cap_running_progress ?? cfg.capRunningProgress ?? 0.99); return { labels, estimatesMs, defaultEstimateMs: (Number.isFinite(defaultEstimateMs) && defaultEstimateMs > 0) ? defaultEstimateMs : 8000, tickMs: (Number.isFinite(tickMs) && tickMs >= 30) ? tickMs : 120, capRunningProgress: (Number.isFinite(capRunning) && capRunning > 0 && capRunning < 1) ? capRunning : 0.99, // autoOpenWhileRunning: (cfg.auto_open_while_running != null) ? !!cfg.auto_open_while_running : false, // autoCollapseOnDone: (cfg.auto_collapse_on_done != null) ? !!cfg.auto_collapse_on_done : false, hideRawToolName: (cfg.hide_raw_tool_name != null) ? !!cfg.hide_raw_tool_name : true, showRawToolNameInDev: (cfg.show_raw_tool_name_in_dev != null) ? !!cfg.show_raw_tool_name_in_dev : false, }; } _toolFullName(server, name) { return `${server || ""}.${name || ""}`.replace(/^\./, ""); } _toolDisplayName(server, name) { const full = this._toolFullName(server, name); const labels = (this._toolUi && this._toolUi.labels) || {}; const hit = labels[full] ?? labels[name] ?? labels[String(full).toLowerCase()] ?? labels[String(name).toLowerCase()]; if (hit != null) return String(hit); // 默认:不要展示原始工具名 if (this._toolUi && this._toolUi.hideRawToolName) return "工具调用"; return full || "MCP Tool"; } _toolEstimateMs(server, name) { const full = this._toolFullName(server, name); const map = (this._toolUi && this._toolUi.estimatesMs) || {}; const v = map[full] ?? map[name]; const ms = Number(v); if (Number.isFinite(ms) && ms > 0) return ms; return (this._toolUi && this._toolUi.defaultEstimateMs) ? this._toolUi.defaultEstimateMs : 8000; } _normToolState(s) { s = String(s || ""); if (s === "running") return "running"; if (s === "error" || s === "failed") return "error"; if (s === "success" || s === "complete" || s === "done") return "success"; return "running"; } _calcFakeProgress(dom) { const est = Math.max(1, Number(dom._fakeEstimateMs || 8000)); const startAt = Number(dom._fakeStartAt || Date.now()); const cap = (this._toolUi && this._toolUi.capRunningProgress) ? this._toolUi.capRunningProgress : 0.99; const elapsed = Math.max(0, Date.now() - startAt); const raw = elapsed / est; // 慢了就停 99% const p = Math.min(Math.max(raw, 0), cap); dom._fakeProgress = p; return p; } _updateFakeProgress(dom) { if (!dom || !dom.data) return; if (this._normToolState(dom.data.state) !== "running") return; const p = this._calcFakeProgress(dom); if (dom.fill) dom.fill.style.width = `${Math.round(p * 100)}%`; // 百分比:最多显示 99% const pct = Math.min(99, Math.max(0, Math.floor(p * 100))); if (dom.pctEl) dom.pctEl.textContent = `${pct}%`; } _ensureFakeProgress(dom, { server, name, progress } = {}) { if (!dom) return; dom._fakeEstimateMs = this._toolEstimateMs(server, name); // 用已有 progress 做“冷启动”平滑(例如 snapshot 恢复时) if (!Number.isFinite(dom._fakeStartAt)) { const cap = (this._toolUi && this._toolUi.capRunningProgress) ? this._toolUi.capRunningProgress : 0.99; const init = Math.min(Math.max(Number(progress) || 0, 0), cap); dom._fakeStartAt = Date.now() - init * dom._fakeEstimateMs; } // 先同步刷新一次 this._updateFakeProgress(dom); // 已经有 timer 就不再启动 if (dom._fakeTimer) return; const tickMs = (this._toolUi && this._toolUi.tickMs) ? this._toolUi.tickMs : 120; dom._fakeTimer = setInterval(() => { // dom 可能被 clearAll 清理 if (!dom || !dom.data) { if (dom && dom._fakeTimer) clearInterval(dom._fakeTimer); if (dom) dom._fakeTimer = null; return; } const st = this._normToolState(dom.data.state); if (st !== "running") { if (dom._fakeTimer) clearInterval(dom._fakeTimer); dom._fakeTimer = null; return; } this._updateFakeProgress(dom); }, tickMs); } _stopFakeProgress(dom) { if (!dom) return; if (dom._fakeTimer) clearInterval(dom._fakeTimer); dom._fakeTimer = null; dom._fakeStartAt = NaN; dom._fakeProgress = 0; } _summaryToObject(summary) { if (summary == null) return null; if (typeof summary === "object") return summary; if (typeof summary === "string") { // 后端可能把 summary 转成 JSON 字符串 try { const obj = JSON.parse(summary); return (obj && typeof obj === "object") ? obj : null; } catch { return null; } } return null; } // tool 卡片:按 tool_call_id upsert(可折叠、极简、带状态符号) upsertToolCard(tool_call_id, patch) { const wasNearBottom = this.isNearBottom(); const clamp01 = (n) => Math.max(0, Math.min(1, Number.isFinite(n) ? n : 0)); const safeStringify = (x) => { try { return JSON.stringify(x); } catch { return String(x ?? ""); } }; const truncate = (s, n = 160) => { s = String(s ?? ""); return s.length > n ? (s.slice(0, n) + "…") : s; }; const normState = (s) => { s = String(s || ""); if (s === "running") return "running"; if (s === "error" || s === "failed") return "error"; if (s === "success" || s === "complete" || s === "done") return "success"; return "running"; }; let dom = this.toolDomById.get(tool_call_id); if (!dom) { const wrap = document.createElement("div"); wrap.className = "msg assistant"; const details = document.createElement("details"); details.className = "tool-card"; details.open = false; // 强制默认折叠 const head = document.createElement("summary"); head.className = "tool-head"; // 单行:状态符号 + 工具名 + args 预览(ellipsis) const line = document.createElement("div"); line.className = "tool-line"; const left = document.createElement("div"); left.className = "tool-left"; const statusEl = document.createElement("span"); statusEl.className = "tool-status"; const nameEl = document.createElement("span"); nameEl.className = "tool-name"; left.appendChild(statusEl); left.appendChild(nameEl); const argsPreviewEl = document.createElement("div"); argsPreviewEl.className = "tool-args-preview"; line.appendChild(left); line.appendChild(argsPreviewEl); // 自定义短进度条 + 百分比 const progRow = document.createElement("div"); progRow.className = "tool-progress-row"; const prog = document.createElement("div"); prog.className = "tool-progress"; const fill = document.createElement("div"); fill.className = "tool-progress-fill"; prog.appendChild(fill); const pctEl = document.createElement("span"); pctEl.className = "tool-progress-pct"; pctEl.textContent = "0%"; progRow.appendChild(prog); progRow.appendChild(pctEl); head.appendChild(line); head.appendChild(progRow); // 展开内容:args + summary const bodyWrap = document.createElement("div"); bodyWrap.className = "tool-body-wrap"; const pre = document.createElement("pre"); pre.className = "tool-body"; const preview = document.createElement("div"); preview.className = "tool-preview"; preview.style.display = "none"; // 永久隐藏:不在 tool-card 内展示媒体 bodyWrap.appendChild(pre); bodyWrap.appendChild(preview); details.appendChild(head); details.appendChild(bodyWrap); wrap.appendChild(details); this.chatEl.appendChild(wrap); this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); dom = { wrap, details, statusEl, nameEl, argsPreviewEl, progRow, prog, fill, pctEl, pre, preview, data: { server: "", name: "", args: undefined, message: "", summary: null, state: "running", progress: 0 } }; this.toolDomById.set(tool_call_id, dom); } // merge patch -> dom.data(关键:progress/end 不传 args 时要保留 start 的 args) const d = dom.data || {}; const merged = { server: (patch && patch.server != null) ? patch.server : d.server, name: (patch && patch.name != null) ? patch.name : d.name, state: (patch && patch.state != null) ? patch.state : d.state, progress: (patch && typeof patch.progress === "number") ? patch.progress : d.progress, message: (patch && Object.prototype.hasOwnProperty.call(patch, "message")) ? (patch.message || "") : d.message, summary: (patch && Object.prototype.hasOwnProperty.call(patch, "summary")) ? patch.summary : d.summary, args: (patch && Object.prototype.hasOwnProperty.call(patch, "args")) ? patch.args : d.args, }; dom.data = merged; const st = this._normToolState(merged.state); const displayName = this._toolDisplayName(merged.server, merged.name); dom.nameEl.textContent = displayName; // 状态符号 dom.statusEl.classList.remove("is-running", "is-success", "is-error"); if (st === "running") { dom.statusEl.textContent = ""; dom.statusEl.classList.add("is-running"); } else if (st === "success") { dom.statusEl.textContent = "✓"; dom.statusEl.classList.add("is-success"); } else { dom.statusEl.textContent = "!"; dom.statusEl.classList.add("is-error"); } // args 预览(单行) dom.argsPreviewEl.style.display = "none"; dom.argsPreviewEl.textContent = ""; if (st === "running") { // 假进度条:基于预估时间推进;超时卡 99% this._ensureFakeProgress(dom, { server: merged.server, name: merged.name, progress: merged.progress, // 仅用于冷启动平滑,可有可无 }); // if (this._toolUi && this._toolUi.autoOpenWhileRunning) dom.details.open = true; dom.progRow.style.display = "flex"; this._updateFakeProgress(dom); } else { this._stopFakeProgress(dom); // if (this._toolUi && this._toolUi.autoCollapseOnDone) dom.details.open = false; dom.progRow.style.display = "none"; dom.fill.style.width = "0%"; if (dom.pctEl) dom.pctEl.textContent = "0%"; } // 展开体内容(完整展示参数/消息/结果摘要) const lines = []; if (merged.args != null) lines.push(`args = ${JSON.stringify(merged.args, null, 2)}`); if (merged.message) lines.push(`message: ${merged.message}`); if (merged.summary != null) { // 把“可见的 \n”解码成真实换行 const unescapeVisible = (s) => { if (typeof s !== "string") return s; return s .replace(/\\r\\n/g, "\n") .replace(/\\n/g, "\n") .replace(/\\r/g, "\r") .replace(/\\t/g, "\t"); }; let obj = merged.summary; if (typeof obj === "string") { try { obj = JSON.parse(obj); } catch { obj = null; } } let v = (obj && typeof obj === "object") ? obj["INFO_USER"] : undefined; if (typeof v === "string") { v = unescapeVisible(v); const t = v.trim(); if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) { try { v = JSON.stringify(JSON.parse(t), null, 2); } catch {} } lines.push(`\n${v}`); } else if (v != null) { lines.push(`${JSON.stringify(v, null, 2)}`); } else { lines.push(``); } } dom.pre.textContent = lines.join("\n\n").trim(); if (merged && merged.summary != null) { this._upsertToolMediaMessage(tool_call_id, merged, dom); } else { // 没 summary 就清理对应媒体块(通常发生在 running/progress 阶段) this._removeToolMediaMessage(tool_call_id); } } appendDevSummary(tool_call_id, { server, name, summary, is_error } = {}) { // 只有 developer mode 才输出 if (!document.body.classList.contains("dev-mode")) return; if (!this.devLogEl) return; if (!tool_call_id) return; const fullName = `${server || ""}.${name || ""}`.replace(/^\./, "") || "MCP Tool"; const headText = `${fullName} (${tool_call_id})${is_error ? " [error]" : ""}`; let summaryText = ""; if (summary == null) { summaryText = "(无 summary)"; } else if (typeof summary === "string") { summaryText = summary; } else { try { summaryText = JSON.stringify(summary, null, 2); } catch { summaryText = String(summary); } } let dom = this.devDomByID.get(tool_call_id); if (!dom) { const item = document.createElement("div"); item.className = "devlog-item"; const head = document.createElement("div"); head.className = "devlog-head"; head.textContent = headText; const pre = document.createElement("pre"); pre.className = "devlog-pre"; pre.textContent = summaryText; item.appendChild(head); item.appendChild(pre); this.devLogEl.appendChild(item); this.devDomByID.set(tool_call_id, { item, head, pre }); } else { dom.head.textContent = headText; dom.pre.textContent = summaryText; } // 自动滚到底部,便于实时追踪 requestAnimationFrame(() => { const el = this.devLogEl; if (!el) return; el.scrollTop = el.scrollHeight; }); } // 工具调用结果中展示视频、图片、音频 _stripUrlQueryHash(u) { return String(u ?? "").split("#")[0].split("?")[0]; } _basenameFromUrl(u) { const s = this._stripUrlQueryHash(u); const parts = s.split(/[\\/]/); return parts[parts.length - 1] || s; } _guessMediaKindFromUrl(u) { const s = this._stripUrlQueryHash(u).toLowerCase(); const m = s.match(/\.([a-z0-9]+)$/); const ext = m ? "." + m[1] : ""; if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"].includes(ext)) return "image"; if ([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"].includes(ext)) return "video"; if ([".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus"].includes(ext)) return "audio"; return "unknown"; } _isSafeMediaUrl(u) { const s = String(u ?? "").trim(); if (!s) return false; try { const parsed = new URL(s, window.location.href); const proto = String(parsed.protocol || "").toLowerCase(); // allow: same-origin relative -> becomes http(s) here; allow absolute http(s) and blob return proto === "http:" || proto === "https:" || proto === "blob:"; } catch { return false; } } _getPreviewUrlsFromSummary(summary) { let obj = summary; if (typeof obj === "string") { try { obj = JSON.parse(obj); } catch { return []; } } const urls = obj && obj.preview_urls; if (!Array.isArray(urls)) return []; return urls.filter((u) => typeof u === "string" && u.trim()); } _extractMediaItemsFromSummary(summary) { const raws = this._getPreviewUrlsFromSummary(summary); const out = []; const seen = new Set(); for (const raw of raws) { const url = this._normalizePreviewUrl(raw); if (!url) continue; // 关键:kind 用 raw 判定(因为 /preview?path=... 本身不带后缀) const kind = this._guessMediaKindFromUrl(String(raw)); if (kind === "unknown") continue; const key = this._stripUrlQueryHash(String(raw)); if (seen.has(key)) continue; seen.add(key); out.push({ url, // 可访问 URL:网络/或 /api/.../preview?path=... kind, name: this._basenameFromUrl(String(raw)), }); } return out; } _makeToolPreviewTitle(text) { const t = document.createElement("div"); t.className = "tool-preview-title"; t.textContent = String(text ?? ""); return t; } _makeInlineVideoBlock(item, title) { const block = document.createElement("div"); block.className = "tool-preview-block"; if (title) block.appendChild(this._makeToolPreviewTitle(title)); const v = document.createElement("video"); v.style.objectFit = "contain"; v.style.objectPosition = "center"; v.className = "tool-inline-video"; v.controls = true; v.preload = "metadata"; v.playsInline = true; v.src = item.url; block.appendChild(v); const actions = document.createElement("div"); actions.className = "tool-preview-actions"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "tool-preview-btn"; btn.textContent = "弹窗预览"; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.openPreview({ kind: "video", file_url: item.url, name: item.name }); }); actions.appendChild(btn); const link = document.createElement("a"); link.className = "tool-preview-link"; link.href = item.url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = "打开"; actions.appendChild(link); block.appendChild(actions); return block; } _makeAudioListBlock(items, title, { maxItems = AUDIO_PREVIEW_MAX } = {}) { const block = document.createElement("div"); block.className = "tool-preview-block"; if (title) block.appendChild(this._makeToolPreviewTitle(title)); const list = document.createElement("div"); list.className = "tool-audio-list"; const show = items.slice(0, maxItems); show.forEach((it, idx) => { const row = document.createElement("div"); row.className = "tool-audio-item"; const label = document.createElement("div"); label.className = "tool-media-label"; label.textContent = it.name || `音频 ${idx + 1}`; row.appendChild(label); const a = document.createElement("audio"); a.controls = true; a.preload = "metadata"; a.src = it.url; row.appendChild(a); list.appendChild(row); }); block.appendChild(list); if (items.length > maxItems) { const more = document.createElement("div"); more.className = "tool-media-more"; more.textContent = `还有 ${items.length - maxItems} 个音频未展示`; block.appendChild(more); } return block; } _makeMediaGridBlock(items, { title, kind, labelPrefix, maxItems = 12 } = {}) { const block = document.createElement("div"); block.className = "tool-preview-block"; if (title) block.appendChild(this._makeToolPreviewTitle(title)); const grid = document.createElement("div"); grid.className = "tool-media-grid"; // 根据宽高给 thumb 打标签,动态调整 aspect-ratio const applyThumbAspect = (thumb, w, h) => { const W = Number(w) || 0; const H = Number(h) || 0; if (!(W > 0 && H > 0)) return; thumb.classList.remove("is-portrait", "is-square"); const r = W / H; // square: 0.92~1.08 if (r >= 0.92 && r <= 1.08) { thumb.classList.add("is-square"); return; } // portrait: r < 1 if (r < 1) { thumb.classList.add("is-portrait"); } }; const show = items.slice(0, maxItems); show.forEach((it, idx) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "tool-media-item"; btn.title = it.name || it.url; const thumb = document.createElement("div"); thumb.className = "tool-media-thumb"; if (kind === "image") { const img = document.createElement("img"); img.src = it.url; img.alt = it.name || ""; // FIX(1): 强制不裁切(不依赖 CSS 是否命中/是否被覆盖) img.style.objectFit = "contain"; img.style.objectPosition = "center"; img.addEventListener("load", () => { applyThumbAspect(thumb, img.naturalWidth, img.naturalHeight); }); thumb.appendChild(img); } else if (kind === "video") { const v = document.createElement("video"); v.preload = "metadata"; v.muted = true; v.playsInline = true; // FIX(1): 强制不裁切 v.style.objectFit = "contain"; v.style.objectPosition = "center"; const apply = () => applyThumbAspect(thumb, v.videoWidth, v.videoHeight); // 先绑定,再设置 src,避免缓存命中导致事件丢失 v.addEventListener("loadedmetadata", apply, { once: true }); // 少数浏览器/资源场景 loadedmetadata 不稳定,再用 loadeddata 兜底一次 v.addEventListener("loadeddata", apply, { once: true }); v.src = it.url; thumb.appendChild(v); if (v.readyState >= 1) apply(); const play = document.createElement("div"); play.className = "tool-media-play"; thumb.appendChild(play); } btn.appendChild(thumb); const label = document.createElement("div"); label.className = "tool-media-label"; label.textContent = it.name || `${labelPrefix || (kind === "video" ? "视频" : "图片")} ${idx + 1}`; btn.appendChild(label); btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.openPreview({ kind, file_url: it.url, name: it.name }); }); grid.appendChild(btn); }); block.appendChild(grid); if (items.length > maxItems) { const more = document.createElement("div"); more.className = "tool-media-more"; more.textContent = `还有 ${items.length - maxItems} 个未展示`; block.appendChild(more); } return block; } _removeToolMediaMessage(tool_call_id) { const dom = this.toolMediaDomById && this.toolMediaDomById.get(tool_call_id); if (dom) { try { dom.wrap?.remove(); } catch {} this.toolMediaDomById.delete(tool_call_id); } } // 在 chat 列表中,把“媒体预览块”插在 tool-card 后面(不放进 tool-card) _upsertToolMediaMessage(tool_call_id, merged, toolCardDom) { if (!tool_call_id) return; const summary = merged?.summary; if (summary == null) { // 没 summary 就不展示(也可选择清理旧的) this._removeToolMediaMessage(tool_call_id); return; } // 从 summary.preview_urls 提取媒体 const media = this._extractMediaItemsFromSummary(summary); if (!media || !media.length) { this._removeToolMediaMessage(tool_call_id); return; } // 已存在就复用(并确保位置在 tool-card 之后) let dom = this.toolMediaDomById.get(tool_call_id); const wasNearBottom = this.isNearBottom(); if (!dom) { const wrap = document.createElement("div"); wrap.className = "msg assistant tool-media-msg"; const card = document.createElement("div"); card.className = "media-card"; const preview = document.createElement("div"); // 复用现有 tool-preview 的样式与内部 block 结构 preview.className = "tool-preview"; card.appendChild(preview); wrap.appendChild(card); // 插入到 tool-card 之后(保证顺序:tool card -> media) if (toolCardDom && toolCardDom.wrap && toolCardDom.wrap.parentNode) { toolCardDom.wrap.after(wrap); } else { this.chatEl.appendChild(wrap); } dom = { wrap, card, preview }; this.toolMediaDomById.set(tool_call_id, dom); this.maybeAutoScroll(wasNearBottom, { behavior: "auto" }); } else { // 如果 DOM 顺序被打乱,强制挪回 tool-card 后面 try { if (toolCardDom && toolCardDom.wrap && dom.wrap && toolCardDom.wrap.nextSibling !== dom.wrap) { toolCardDom.wrap.after(dom.wrap); } } catch {} } // 关键:复用你现有的渲染逻辑,但渲染目标换成“外部 preview 容器” // _renderToolMediaPreview 里会自己做 summary key 去重 this._renderToolMediaPreview({ preview: dom.preview, details: null }, merged); } _renderToolMediaPreview(dom, merged) { if (!dom || !dom.preview) return; const st = this._normToolState(merged?.state); const summary = merged?.summary; // running 且无 summary:清空,避免复用上一轮残留 if (st === "running" && summary == null) { dom.preview.innerHTML = ""; dom.preview._lastMediaKey = ""; return; } if (summary == null) { dom.preview.innerHTML = ""; dom.preview._lastMediaKey = ""; return; } // summary 不变就不重复渲染 let key = ""; try { key = (typeof summary === "string") ? summary : JSON.stringify(summary); } catch { key = String(summary); } if (dom.preview._lastMediaKey === key) return; dom.preview._lastMediaKey = key; const media = this._extractMediaItemsFromSummary(summary); if (!media.length) { dom.preview.innerHTML = ""; return; } const toolName = String(merged?.name || "").toLowerCase(); const toolFull = String(this._toolFullName(merged?.server, merged?.name) || "").toLowerCase(); const isSplitShots = toolName.includes("split_shots") || toolFull.includes("split_shots"); const isRender = toolName.includes("render") || toolFull.includes("render"); const isTtsOrMusic = toolName.includes("tts") || toolFull.includes("tts") || toolName.includes("music") || toolFull.includes("music"); const videos = media.filter((x) => x.kind === "video"); const audios = media.filter((x) => x.kind === "audio"); const images = media.filter((x) => x.kind === "image"); dom.preview.innerHTML = ""; // Render:成片直接内嵌展示(第一条 video) if (isRender && videos.length) { dom.preview.appendChild(this._makeInlineVideoBlock(videos[0], "成片预览")); const restVideos = videos.slice(1); if (restVideos.length) { dom.preview.appendChild(this._makeMediaGridBlock(restVideos, { title: "其它视频(点击预览)", kind: "video", labelPrefix: "视频", maxItems: 8, })); } if (audios.length) { dom.preview.appendChild(this._makeAudioListBlock(audios, "音频")); } if (images.length) { dom.preview.appendChild(this._makeMediaGridBlock(images, { title: "图片(点击预览)", kind: "image", labelPrefix: "图片", maxItems: 12, })); } // 关键节点:完成后默认展开,做到“直接展示成片” if (st !== "running" && dom.details) dom.details.open = true; return; } // 配音/音乐:优先展示试听 if (isTtsOrMusic && audios.length) { dom.preview.appendChild(this._makeAudioListBlock(audios, "试听")); if (st !== "running" && dom.details) dom.details.open = true; } // 镜头切分:展示切分后视频(可点击弹窗预览) if (videos.length) { dom.preview.appendChild(this._makeMediaGridBlock(videos, { title: isSplitShots ? "镜头切分结果(点击预览)" : "视频(点击预览)", kind: "video", labelPrefix: isSplitShots ? "镜头" : "视频", maxItems: isSplitShots ? 12 : 8, })); if (isSplitShots && st !== "running" && dom.details) dom.details.open = true; } // 图片 if (images.length) { dom.preview.appendChild(this._makeMediaGridBlock(images, { title: "图片(点击预览)", kind: "image", labelPrefix: "图片", maxItems: 12, })); } // 其它工具也可能产生音频:给一个通用展示 if (!isTtsOrMusic && audios.length) { dom.preview.appendChild(this._makeAudioListBlock(audios, "音频")); } } _isLikelyLocalPath(s) { s = String(s ?? "").trim(); if (!s) return false; // 相对路径:.xxx 或 xxx/yyy;绝对路径:/xxx/yyy if (s.startsWith(".") || s.startsWith("/")) return true; // windows 盘符(可选兜底) if (/^[a-zA-Z]:[\\/]/.test(s)) return true; return false; } // 只认为“显式 scheme”的才是网络 URL,避免把 .server_cache/... 误判成 http(s) 相对 URL _isAbsoluteNetworkUrl(s) { s = String(s ?? "").trim().toLowerCase(); return s.startsWith("http://") || s.startsWith("https://") || s.startsWith("blob:"); } // 已经是你服务端可直接访问的相对路径(不要再走 preview 代理) _isServedRelativeUrlPath(s) { s = String(s ?? "").trim(); return s.startsWith("/api/") || s.startsWith("/static/"); } // 判断“服务器本地路径” // - .server_cache/.. // - ./xxx/.. // - /abs/path/.. (但排除 /api/, /static/) // - windows: C:\... // - 其它不带 scheme 且包含 / 或 \ 的相对路径(例如 outputs/xxx.mp4) _isLikelyServerLocalPath(s) { s = String(s ?? "").trim(); if (!s) return false; if (this._isServedRelativeUrlPath(s)) return false; // 已可访问 if (/^[a-zA-Z]:[\\/]/.test(s)) return true; // Windows drive if (s.startsWith(".") || s.startsWith("./") || s.startsWith(".\\")) return true; if (s.startsWith("/")) return true; // 绝对路径(同样排除 /api,/static 已在上面处理) // 没 scheme,但像路径(含斜杠) if (!this._isAbsoluteNetworkUrl(s) && (s.includes("/") || s.includes("\\"))) return true; return false; } _localPathToPreviewUrl(p) { const sid = this._sessionId; if (!sid) return null; return `/api/sessions/${encodeURIComponent(sid)}/preview?path=${encodeURIComponent(String(p ?? ""))}`; } // 将 preview_urls 里的 raw 字符串转为真正可在浏览器加载的 URL _normalizePreviewUrl(raw) { const s = String(raw ?? "").trim(); if (!s) return null; // 1) 已可访问的相对 URL if (this._isServedRelativeUrlPath(s)) return s; // 2) 显式网络 URL if (this._isAbsoluteNetworkUrl(s)) return s; // 3) 本地路径 -> preview 代理 if (this._isLikelyServerLocalPath(s)) return this._localPathToPreviewUrl(s); return null; } openPreview(asset) { if (!this._modalBound) this.bindModalClose(); this.modalContent.innerHTML = ""; this.modalEl.classList.remove("hidden"); const preferSrc = asset.local_url || asset.file_url; if (asset.kind === "image") { const img = document.createElement("img"); img.src = preferSrc; img.alt = asset.name || ""; this.modalContent.appendChild(img); return; } if (asset.kind === "video") { const v = document.createElement("video"); v.src = preferSrc; v.controls = true; v.autoplay = true; v.preload = "metadata"; this.modalContent.appendChild(v); return; } if (asset.kind === "audio") { const a = document.createElement("audio"); a.src = preferSrc; a.controls = true; a.autoplay = true; a.preload = "metadata"; this.modalContent.appendChild(a); return; } const box = document.createElement("div"); box.className = "file-fallback"; box.innerHTML = `
`; this.modalContent.appendChild(box); } closePreview() { this.modalEl.classList.add("hidden"); this.modalContent.innerHTML = ""; } bindModalClose() { // 防止重复绑定(openPreview 里也会兜底调用一次) if (this._modalBound) return; this._modalBound = true; const close = (e) => { if (e) { e.preventDefault(); e.stopPropagation(); // 同一元素上其它监听也停掉,避免“关闭后又被底层点击重新打开” if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation(); } this.closePreview(); }; // 1) 明确绑定 backdrop/close if (this.modalBackdrop) { this.modalBackdrop.addEventListener("click", close, true); // capture this.modalBackdrop.addEventListener("pointerdown", close, true); // 兼容移动端/某些浏览器 } if (this.modalClose) { this.modalClose.addEventListener("click", close, true); this.modalClose.addEventListener("pointerdown", close, true); } // 2) 兜底:document capture 里判断“点到内容区外”就关闭 document.addEventListener("click", (e) => { if (!this.modalEl || this.modalEl.classList.contains("hidden")) return; const t = e.target; // 点到 close(或其子元素) => 关闭 if (this.modalClose && (t === this.modalClose || this.modalClose.contains(t))) { close(e); return; } // 点到内容区内部 => 不关闭(允许操作 video controls/滚动等) if (this.modalContent && (t === this.modalContent || this.modalContent.contains(t))) { return; } // 其他任何地方(含 click 穿透到页面底层)=> 关闭 close(e); }, true); // 3) Esc 关闭 document.addEventListener("keydown", (e) => { if (!this.modalEl || this.modalEl.classList.contains("hidden")) return; if (e.key === "Escape") { e.preventDefault(); this.closePreview(); } }, true); } escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); } } class App { constructor() { this.api = new ApiClient(); this.ui = new ChatUI(); this.ws = null; this.sessionId = null; this.pendingAssets = []; this.modelSelect = $("#modelSelect"); this.chatModels = []; this.chatModel = null; // Custom model UI this.customModelBox = $("#customModelBox"); this.customLlmModel = $("#customLlmModel"); this.customLlmBaseUrl = $("#customLlmBaseUrl"); this.customLlmApiKey = $("#customLlmApiKey"); this.customVlmModel = $("#customVlmModel"); this.customVlmBaseUrl = $("#customVlmBaseUrl"); this.customVlmApiKey = $("#customVlmApiKey"); // TTS UI this.ttsBox = $("#ttsBox"); this.ttsVendorSelect = $("#ttsVendorSelect"); this.ttsBytedanceFields = $("#ttsBytedanceFields"); this.ttsBytedanceUid = $("#ttsBytedanceUid"); this.ttsBytedanceAppId = $("#ttsBytedanceAppId"); this.ttsBytedanceAccessToken = $("#ttsBytedanceAccessToken"); this.limits = { max_assets_per_session: 30, max_pending_assets_per_session: 30, upload_chunk_bytes: 8 * 1024 * 1024, }; this.localObjectUrlByAssetId = new Map(); this.fileInput = $("#fileInput"); this.uploadBtn = $("#uploadBtn"); this.promptInput = $("#promptInput"); this.sendBtn = $("#sendBtn"); this.sidebarToggleBtn = $("#sidebarToggle"); this.createDialogBtn = $("#createDialogBtn"); this.devbarToggleBtn = $("#devbarToggle"); this.devbarEl = $("#devbar"); this.canceling = false; // 保存“发送箭头”的原始 SVG this._sendIconSend = this.sendBtn ? this.sendBtn.innerHTML : ""; // “打断”图标:白色实心正方形 this._sendIconStop = ` `; this.streaming = false; this.uploading = false; } wsUrl(sessionId) { const proto = location.protocol === "https:" ? "wss" : "ws"; return `${proto}://${location.host}/ws/sessions/${encodeURIComponent(sessionId)}/chat`; } async bootstrap() { // this.restoreSidebarState(); // this.restoreDevbarState(); this.ui.bindModalClose(); this.bindUI(); // 复用 localStorage session;如果失效就创建新 session const saved = localStorage.getItem("openstoryline_session_id"); if (saved) { try { const snap = await this.api.getSession(saved); await this.useSession(saved, snap); return; } catch { localStorage.removeItem("openstoryline_session_id"); } } await this.newSession(); } // restoreSidebarState() { // const v = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); // if (v === null) { // // 首次访问:默认收起,并写入本地存储(后续刷新保持一致) // document.body.classList.add("sidebar-collapsed"); // localStorage.setItem(SIDEBAR_COLLAPSED_KEY, "1"); // return; // } // // 已有配置:1 收起,0 展开 // document.body.classList.toggle("sidebar-collapsed", v === "1"); // } // restoreDevbarState() { // const v = localStorage.getItem(DEVBAR_COLLAPSED_KEY); // if (v === null) { // // 首次访问:默认收起 // document.body.classList.add("devbar-collapsed"); // localStorage.setItem(DEVBAR_COLLAPSED_KEY, "1"); // return; // } // document.body.classList.toggle("devbar-collapsed", v === "1"); // } _updateSendButtonUI() { if (!this.sendBtn) return; if (this.streaming) { this.sendBtn.innerHTML = this._sendIconStop; this.sendBtn.setAttribute("aria-label", "打断"); this.sendBtn.title = "打断"; } else { this.sendBtn.innerHTML = this._sendIconSend; this.sendBtn.setAttribute("aria-label", "发送"); this.sendBtn.title = "发送"; } } async interruptTurn() { if (!this.sessionId) return; if (!this.streaming) return; if (this.canceling) return; this.canceling = true; this._updateComposerDisabledState(); try { await this.api.cancelTurn(this.sessionId); // 不需要本地立刻 finalize,等后端 assistant.end 来收尾并把上下文写干净 } catch (e) { this.canceling = false; this._updateComposerDisabledState(); this.ui.showToast(`打断失败:${e.message || e}`); setTimeout(() => this.ui.hideToast(), 1600); } } toggleDevbar() { document.body.classList.toggle("devbar-collapsed"); // const collapsed = document.body.classList.contains("devbar-collapsed"); // localStorage.setItem(DEVBAR_COLLAPSED_KEY, collapsed ? "1" : "0"); } setDeveloperMode(enabled) { const on = !!enabled; const devbar = this.devbarEl || $("#devbar"); if (!devbar) return; if (on) { document.body.classList.add("dev-mode"); devbar.classList.remove("hidden"); } else { document.body.classList.remove("dev-mode"); devbar.classList.add("hidden"); } } toggleSidebar() { document.body.classList.toggle("sidebar-collapsed"); // const collapsed = document.body.classList.contains("sidebar-collapsed"); // localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0"); } applySnapshotLimits(snapshot) { const lim = (snapshot && snapshot.limits) ? snapshot.limits : {}; const toInt = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; }; this.limits = { max_assets_per_session: toInt(lim.max_assets_per_session, this.limits.max_assets_per_session || 30), max_pending_assets_per_session: toInt(lim.max_pending_assets_per_session, this.limits.max_pending_assets_per_session || 30), upload_chunk_bytes: toInt(lim.upload_chunk_bytes, this.limits.upload_chunk_bytes || (8 * 1024 * 1024)), }; } applySnapshotChatModels(snapshot) { const models = (snapshot && Array.isArray(snapshot.chat_models)) ? snapshot.chat_models : []; const current = (snapshot && typeof snapshot.chat_model_key === "string") ? snapshot.chat_model_key : ""; // 确保至少有一个选项 const list = (models && models.length) ? models.slice() : (current ? [current] : []); this.chatModels = list; if (!this.modelSelect) return; // 重新渲染 options this.modelSelect.innerHTML = ""; for (const m of list) { const opt = document.createElement("option"); opt.value = m; opt.textContent = (m === CUSTOM_MODEL_KEY) ? "使用自定义模型" : m; this.modelSelect.appendChild(opt); } // 选中策略:优先使用 session 当前模型 let selected = ""; if (current && list.includes(current)) selected = current; else if (list.length) selected = list[0]; this.chatModel = selected || null; if (this.chatModel) this.modelSelect.value = this.chatModel; this._syncConfigPanels(); } _syncConfigPanels() { const isCustom = (this.chatModel === CUSTOM_MODEL_KEY); if (this.customModelBox) this.customModelBox.classList.toggle("hidden", !isCustom); const vendor = (this.ttsVendorSelect && this.ttsVendorSelect.value) ? String(this.ttsVendorSelect.value).trim() : ""; if (this.ttsBytedanceFields) { this.ttsBytedanceFields.classList.toggle("hidden", vendor !== TTS_VENDOR_BYTEDANCE); } } _readCustomModelsFromUI() { const s = (x) => String(x ?? "").trim(); return { llm: { model: s(this.customLlmModel?.value), base_url: s(this.customLlmBaseUrl?.value), api_key: s(this.customLlmApiKey?.value), }, vlm: { model: s(this.customVlmModel?.value), base_url: s(this.customVlmBaseUrl?.value), api_key: s(this.customVlmApiKey?.value), }, }; } _validateCustomModels(cfg) { const llm = cfg?.llm || {}; const vlm = cfg?.vlm || {}; const miss = (x) => !x || !String(x).trim(); // if (miss(llm.model) || miss(llm.base_url) || miss(llm.api_key)) return "自定义 LLM 配置不完整:请填写 model/base_url/api_key"; // if (miss(vlm.model) || miss(vlm.base_url) || miss(vlm.api_key)) return "自定义 VLM 配置不完整:请填写 model/base_url/api_key"; return ""; } _readTtsConfigFromUI() { const vendor = (this.ttsVendorSelect && this.ttsVendorSelect.value) ? String(this.ttsVendorSelect.value).trim() : ""; if (!vendor) return null; if (vendor === TTS_VENDOR_BYTEDANCE) { const uid = String(this.ttsBytedanceUid?.value ?? "").trim(); const appid = String(this.ttsBytedanceAppId?.value ?? "").trim(); const access_token = String(this.ttsBytedanceAccessToken?.value ?? "").trim(); // 不强制阻断发送:不完整就不带 tts config if (!uid || !appid || !access_token) return null; return { vendor: TTS_VENDOR_BYTEDANCE, bytedance: { uid, appid, access_token }, }; } // 未来扩展:其它 vendor 走各自字段 return { vendor }; } _makeChatSendPayload(text, attachment_ids) { const payload = { text, attachment_ids }; if (this.chatModel) payload.model = this.chatModel; const rc = {}; if (this.chatModel === CUSTOM_MODEL_KEY) { const cm = this._readCustomModelsFromUI(); const err = this._validateCustomModels(cm); if (err) return { error: err }; rc.custom_models = cm; } const tts = this._readTtsConfigFromUI(); if (tts) rc.tts = tts; if (Object.keys(rc).length) payload.service_config = rc; return { payload }; } setChatModel(model) { const m = String(model || "").trim(); if (!m) return; this.chatModel = m; } clearLocalObjectUrls() { for (const [, url] of this.localObjectUrlByAssetId) { try { URL.revokeObjectURL(url); } catch {} } this.localObjectUrlByAssetId.clear(); } bindLocalUrlsToAssets(list) { const arr = Array.isArray(list) ? list : []; return arr.map((a) => { const url = a && a.id ? this.localObjectUrlByAssetId.get(a.id) : null; return url ? { ...a, local_url: url } : a; }); } revokeLocalUrl(assetId) { const url = this.localObjectUrlByAssetId.get(assetId); if (url) { try { URL.revokeObjectURL(url); } catch {} this.localObjectUrlByAssetId.delete(assetId); } } _updateComposerDisabledState() { // - streaming=true:sendBtn 是“打断键”,必须可点(除非正在 canceling) // - streaming=false:uploading=true 时不能发送 => 禁用 const disableSend = this.canceling ? true : (!this.streaming && this.uploading); if (this.sendBtn) this.sendBtn.disabled = disableSend; if (this.uploadBtn) this.uploadBtn.disabled = !!this.uploading; this._updateSendButtonUI(); } _autosizePrompt() { const el = this.promptInput; if (!el) return; // 读取 CSS 的 max-height(比如 180px),读不到就 fallback const cs = window.getComputedStyle(el); const mh = parseFloat(cs.maxHeight); const maxPx = Number.isFinite(mh) && mh > 0 ? mh : 180; // 先让它回到 auto,才能正确拿到 scrollHeight el.style.height = "auto"; const next = Math.min(el.scrollHeight, maxPx); el.style.height = next + "px"; // 没超过上限:隐藏滚动条;超过上限:出现滚动条 el.style.overflowY = (el.scrollHeight > maxPx) ? "auto" : "hidden"; } bindUI() { // sidebar if (this.sidebarToggleBtn) { this.sidebarToggleBtn.addEventListener("click", () => this.toggleSidebar()); } if (this.createDialogBtn) { this.createDialogBtn.addEventListener("click", () => this.newSession()); } if (this.modelSelect) { this.modelSelect.addEventListener("change", () => { const v = (this.modelSelect.value || "").trim(); this.setChatModel(v); this._syncConfigPanels(); }); } if (this.ttsVendorSelect) { this.ttsVendorSelect.addEventListener("change", () => this._syncConfigPanels()); } // devbar toggle(仅 developer_mode=true 时 devbar 会显示) if (this.devbarToggleBtn) { this.devbarToggleBtn.addEventListener("click", () => this.toggleDevbar()); } // uploader this.uploadBtn.addEventListener("click", () => this.fileInput.click()); this.fileInput.addEventListener("change", async () => { let files = Array.from(this.fileInput.files || []); this.fileInput.value = ""; if (!files.length) return; // 会话内 pending 上限 const maxPending = Number(this.limits.max_pending_assets_per_session || 30); const remain = Math.max(0, maxPending - (this.pendingAssets.length || 0)); if (remain <= 0) { this.ui.showToast(`待发送素材已达上限(${maxPending} 个),请先发送/删除后再上传。`); setTimeout(() => this.ui.hideToast(), 1600); return; } if (files.length > remain) { this.ui.showToast(`最多还能上传 ${remain} 个素材(上限 ${maxPending})。将只上传前 ${remain} 个。`); setTimeout(() => this.ui.hideToast(), 1400); files = files.slice(0, remain); } const totalBytes = Math.max(1, files.reduce((s, f) => s + (f.size || 0), 0)); let confirmedBytesAll = 0; this.uploading = true; this._updateComposerDisabledState(); try { this.ui.showToast("正在上传素材中… 0%"); // 分片 for (let i = 0; i < files.length; i++) { const f = files[i]; // 预先创建 ObjectURL(用于 (3) 预览走本地缓存) const localUrl = URL.createObjectURL(f); try { const resp = await this.api.uploadAssetChunked(this.sessionId, f, { chunkSize: this.limits.upload_chunk_bytes, onProgress: (loadedInFile, fileTotal) => { const overallLoaded = Math.min(totalBytes, confirmedBytesAll + (loadedInFile || 0)); const pct = Math.round((overallLoaded / totalBytes) * 100); this.ui.showToast(`正在上传素材(${i + 1}/${files.length}):${f.name}… ${pct}%`); }, }); // 上传完成:把 asset_id -> localUrl 绑定起来 if (resp && resp.asset && resp.asset.id) { this.localObjectUrlByAssetId.set(resp.asset.id, localUrl); } else { // 理论不应发生;发生就释放 try { URL.revokeObjectURL(localUrl); } catch {} } confirmedBytesAll += (f.size || 0); // pending 更新(绑定 local_url 后再渲染) this.setPending((resp && resp.pending_assets) ? resp.pending_assets : []); } catch (e) { // 本文件失败:释放 URL,避免泄漏 try { URL.revokeObjectURL(localUrl); } catch {} throw e; } } this.ui.hideToast(); } catch (e) { this.ui.hideToast(); this.ui.showToast(`上传失败:${e.message || e}`); setTimeout(() => this.ui.hideToast(), 1800); } finally { this.uploading = false; this._updateComposerDisabledState(); } }); // pending 删除:用事件委托 $("#pendingRow").addEventListener("click", async (e) => { const el = e.target; if (!el.classList.contains("asset-remove")) return; const assetId = el.dataset.assetId; if (!assetId) return; try { const resp = await this.api.deletePendingAsset(this.sessionId, assetId); this.revokeLocalUrl(assetId); this.setPending(resp.pending_assets || []); } catch (err) { this.ui.showToast(`删除失败:${err.message || err}`); setTimeout(() => this.ui.hideToast(), 1600); } }); // send this.sendBtn.addEventListener("click", () => this.sendPrompt({ source: "button" })); this.promptInput.addEventListener("keydown", (e) => { // 避免中文输入法“正在组词/选词”时按 Enter 误触发发送 if (e.isComposing || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendPrompt({ source: "enter" }); } }); // PATCH: prompt 自动长高 if (this.promptInput && !this._promptAutoResizeBound) { this._promptAutoResizeBound = true; const resize = () => this._autosizePrompt(); this.promptInput.addEventListener("input", resize); window.addEventListener("resize", resize, { passive: true }); // 首次初始化/切换会话后确保高度正确 requestAnimationFrame(resize); } } setPending(list) { const arr = this.bindLocalUrlsToAssets(Array.isArray(list) ? list : []); this.pendingAssets = arr; this.ui.renderPendingAssets(this.pendingAssets); } async newSession() { const snap = await this.api.createSession(); await this.useSession(snap.session_id, snap); } async useSession(sessionId, snapshot) { this.streaming = false; this.uploading = false; this._updateComposerDisabledState(); this.sessionId = sessionId; // 切会话:清掉上一会话的本地缓存 URL,避免泄漏 this.clearLocalObjectUrls(); // 从后端 snapshot 读取 limits(按素材个数限制/分片大小等) this.applySnapshotLimits(snapshot); this.applySnapshotChatModels(snapshot); localStorage.setItem("openstoryline_session_id", sessionId); this.setDeveloperMode(!!snapshot.developer_mode); this.ui.setSessionId(sessionId); this.ui.clearAll(); // 回放 history const history = snapshot.history || []; for (const item of history) { if (item.role === "user") { this.ui.appendUserMessage(item.content || "", item.attachments || []); } else if (item.role === "assistant") { this.ui.startAssistantMessage({placeholder: false}); this.ui.finalizeAssistant(item.content || ""); } else if (item.role === "tool") { this.ui.upsertToolCard(item.tool_call_id, { server: item.server, name: item.name, state: item.state, args: item.args, progress: item.progress, message: item.message, summary: item.summary, }); if (item.summary != null) { this.ui.appendDevSummary(item.tool_call_id, { server: item.server, name: item.name, summary: item.summary, is_error: item.state === "error", }); } } } this.setPending(snapshot.pending_assets || []); this.connectWs(); } connectWs() { if (this.ws) this.ws.close(); this.ws = new WsClient(this.wsUrl(this.sessionId), (evt) => this.onWsEvent(evt)); this.ws.connect(); } onWsEvent(evt) { const { type, data } = evt || {}; if (type === "session.snapshot") { // 一般用不上(useSession 已经回放了),但保留兼容 this.setDeveloperMode(!!data.developer_mode); this.ui.setSessionId(data.session_id); this.applySnapshotChatModels(data || {}); this.setPending(data.pending_assets || []); return; } if (type === "chat.user") { // 以服务端为准更新 pending(避免客户端/服务端状态漂移) this.setPending((data || {}).pending_assets || []); return; } if (type === "assistant.start") { this.streaming = true; this._updateComposerDisabledState(); this.ui.startAssistantMessage({placeholder: true}); return; } if (type === "assistant.flush") { this.ui.flushAssistantSegment(); return; } if (type === "assistant.delta") { this.ui.appendAssistantDelta((data || {}).delta || ""); return; } if (type === "assistant.end") { this.streaming = false; this.canceling = false; this._updateComposerDisabledState(); this.ui.endAssistantTurn((data || {}).text || ""); return; } if (type === "tool.start") { this.ui.upsertToolCard(data.tool_call_id, { server: data.server, name: data.name, state: "running", args: data.args || {}, progress: 0, }); return; } if (type === "tool.progress") { this.ui.upsertToolCard(data.tool_call_id, { server: data.server, name: data.name, state: "running", progress: typeof data.progress === "number" ? data.progress : 0, message: data.message || "", }); return; } if (type === "tool.end") { this.ui.upsertToolCard(data.tool_call_id, { server: data.server, name: data.name, state: data.is_error ? "error" : "success", summary: (data && Object.prototype.hasOwnProperty.call(data, "summary")) ? data.summary : null, }); this.ui.appendDevSummary(data.tool_call_id, { server: data.server, name: data.name, summary: data.summary, is_error: !!data.is_error, }); return; } if (type === "chat.cleared") { this.streaming = false; this.canceling = false; this._updateComposerDisabledState(); this.ui.clearAll(); return; } if (type === "error") { this.streaming = false; this.canceling = false; this._updateComposerDisabledState(); const msg = String((data || {}).message || "unknown error"); const partial = String((data || {}).partial_text || "").trim(); // 用 endAssistantTurn 结束当前流式气泡: // - 有 partial:保留已输出内容,并追加错误说明 // - 无 partial:直接显示错误 const text = partial ? `${partial}\n\n(发生错误:${msg})` : `发生错误:${msg}`; this.ui.endAssistantTurn(text); return; } } sendPrompt({ source = "button" } = {}) { if (!this.ws) return; const text = (this.promptInput.value || "").trim(); if (this.streaming) { // Enter 防误触:输入为空 -> 不打断、不发送 if (source === "enter" && !text) { return; } // Enter 且有文本:打断 + 发送新 prompt if (source === "enter" && text) { if (this.canceling) return; // 上传中提示并仅打断(让旧输出停掉),等用户上传完再回车发送 if (this.uploading) { this.ui.showToast("素材正在上传中,暂时无法发送新消息。已为你打断当前回复;上传完成后再按 Enter 发送。"); setTimeout(() => this.ui.hideToast(), 1600); this.interruptTurn(); // 有意图(非空)=> 仍然打断 return; } const attachments = this.pendingAssets.slice(); const attachment_ids = attachments.map(a => a.id); // 1) 立即在 UI 插入 user 气泡(体验更顺滑) this.ui.appendUserMessage(text, attachments); this.setPending([]); // 2) 清空输入框 this.promptInput.value = ""; this._autosizePrompt(); // 3) 触发打断(异步,不 await) this.interruptTurn(); // 4) 立即把新消息发到 WS(服务器会在旧 turn 结束后按序处理) const built = this._makeChatSendPayload(text, attachment_ids); if (built.error) { this.ui.showToast(built.error); setTimeout(() => this.ui.hideToast(), 1800); return; } this.ws.send("chat.send", built.payload); return; } // 其它情况(按钮点击/停止图标):打断 this.interruptTurn(); return; } // ----------------------------- // 非 streaming:正常发送 // ----------------------------- if (this.uploading) { this.ui.showToast("素材正在上传中,上传完成后才能发送。"); setTimeout(() => this.ui.hideToast(), 1400); return; } if (!text) return; const attachments = this.pendingAssets.slice(); const attachment_ids = attachments.map(a => a.id); this.ui.appendUserMessage(text, attachments); this.setPending([]); this.promptInput.value = ""; this._autosizePrompt(); const built = this._makeChatSendPayload(text, attachment_ids); if (built.error) { this.ui.showToast(built.error); setTimeout(() => this.ui.hideToast(), 1800); return; } this.ws.send("chat.send", built.payload); } } new App().bootstrap(); /* ========================================================= PATCH (mobile viewport / keyboard safe area) - updates CSS vars: --kb, --composer-h, --vvh ========================================================= */ (function () { const root = document.documentElement; const composer = document.querySelector(".composer"); if (!root || !composer) return; let raf = 0; const compute = () => { raf = 0; const vv = window.visualViewport; const layoutH = window.innerHeight || document.documentElement.clientHeight || 0; const vvH = vv ? vv.height : layoutH; const vvTop = vv ? vv.offsetTop : 0; // Keyboard overlay height (0 on most desktops) const kb = vv ? Math.max(0, layoutH - (vvH + vvTop)) : 0; root.style.setProperty("--vvh", `${Math.round(vvH)}px`); root.style.setProperty("--kb", `${Math.round(kb)}px`); const ch = composer.getBoundingClientRect().height || 0; if (ch > 0) root.style.setProperty("--composer-h", `${Math.round(ch)}px`); }; const schedule = () => { if (raf) return; raf = requestAnimationFrame(compute); }; compute(); // Window resize / orientation window.addEventListener("resize", schedule, { passive: true }); window.addEventListener("orientationchange", schedule, { passive: true }); // iOS: focusing inputs changes visual viewport document.addEventListener("focusin", schedule, true); document.addEventListener("focusout", schedule, true); // visualViewport gives the best signal on mobile browsers if (window.visualViewport) { window.visualViewport.addEventListener("resize", schedule, { passive: true }); window.visualViewport.addEventListener("scroll", schedule, { passive: true }); } // composer height changes (pending bar / textarea autosize) if (window.ResizeObserver) { const ro = new ResizeObserver(schedule); ro.observe(composer); } })(); /* ========================================================= Persist sidebar config across refresh (keys, base_url, etc.) ========================================================= */ const __OS_PERSIST_STORAGE = window.sessionStorage; // <- 改成 localStorage 即可“关浏览器也还在” const __OS_PERSIST_KEY = "openstoryline_user_config_v1"; function __osSafeParseJson(s, fallback) { try { const v = JSON.parse(s); return (v && typeof v === "object") ? v : fallback; } catch { return fallback; } } function __osLoadConfig() { return __osSafeParseJson(__OS_PERSIST_STORAGE.getItem(__OS_PERSIST_KEY), {}); } function __osSaveConfig(cfg) { try { __OS_PERSIST_STORAGE.setItem(__OS_PERSIST_KEY, JSON.stringify(cfg || {})); } catch (e) { console.warn("[persist] save failed:", e); } } function __osGetByPath(obj, path) { if (!obj || !path) return undefined; const parts = String(path).split(".").filter(Boolean); let cur = obj; for (const p of parts) { if (!cur || typeof cur !== "object") return undefined; cur = cur[p]; } return cur; } function __osSetByPath(obj, path, value) { const parts = String(path).split(".").filter(Boolean); if (!parts.length) return; let cur = obj; for (let i = 0; i < parts.length - 1; i++) { const k = parts[i]; if (!cur[k] || typeof cur[k] !== "object") cur[k] = {}; cur = cur[k]; } cur[parts[parts.length - 1]] = value; } const __osPendingSelectValues = new Map(); function __osApplySelectValue(selectEl, desiredValue) { const desired = String(desiredValue ?? ""); const before = selectEl.value; selectEl.value = desired; const ok = selectEl.value === desired; if (ok && before !== selectEl.value) { // 触发你现有的 UI 联动逻辑(显示/隐藏 box 等) selectEl.dispatchEvent(new Event("change", { bubbles: true })); } return ok; } function __osObserveSelectOptions(selectEl) { if (selectEl.__osSelectObserver) return; const observer = new MutationObserver(() => { const desired = __osPendingSelectValues.get(selectEl); if (desired == null) return; if (__osApplySelectValue(selectEl, desired)) { __osPendingSelectValues.delete(selectEl); observer.disconnect(); selectEl.__osSelectObserver = null; } }); observer.observe(selectEl, { childList: true, subtree: true }); selectEl.__osSelectObserver = observer; } function __osHydratePersistedFields(root = document) { const cfg = __osLoadConfig(); const nodes = root.querySelectorAll("[data-os-persist]"); nodes.forEach((el) => { const key = el.getAttribute("data-os-persist"); if (!key) return; const v = __osGetByPath(cfg, key); if (v == null) return; const tag = (el.tagName || "").toLowerCase(); const type = String(el.type || "").toLowerCase(); try { if (type === "checkbox") { el.checked = !!v; } else if (tag === "select") { // 如果选项是异步加载的(比如 modelSelect),先尝试设置,不行就等 options 出来再设置 if (!__osApplySelectValue(el, v)) { __osPendingSelectValues.set(el, String(v)); __osObserveSelectOptions(el); } else { // 已成功设置,确保联动触发一次(有些情况下 before==after 不触发) el.dispatchEvent(new Event("change", { bubbles: true })); } } else { el.value = String(v); } } catch {} }); // 再额外触发一次:确保 vendor/model 这类联动都跑起来 root.querySelectorAll('select[data-os-persist]').forEach((sel) => { try { sel.dispatchEvent(new Event("change", { bubbles: true })); } catch {} }); return cfg; } function __osBindPersistedFields(root = document) { let cfg = __osLoadConfig(); const nodes = root.querySelectorAll("[data-os-persist]"); nodes.forEach((el) => { const key = el.getAttribute("data-os-persist"); if (!key) return; if (el.__osPersistBound) return; el.__osPersistBound = true; const handler = () => { const tag = (el.tagName || "").toLowerCase(); const type = String(el.type || "").toLowerCase(); let v; if (type === "checkbox") v = !!el.checked; else if (tag === "select") v = String(el.value ?? ""); else v = String(el.value ?? ""); __osSetByPath(cfg, key, v); __osSaveConfig(cfg); }; el.addEventListener("input", handler); el.addEventListener("change", handler); }); return { getConfig: () => (cfg = __osLoadConfig()), clear: () => { __OS_PERSIST_STORAGE.removeItem(__OS_PERSIST_KEY); cfg = {}; }, saveNow: () => __osSaveConfig(cfg), }; } function __osInitPersistSidebarConfig() { __osHydratePersistedFields(document); window.OPENSTORYLINE_PERSIST = __osBindPersistedFields(document); // 可选:调试用 } if (document.readyState === "loading") { window.addEventListener("DOMContentLoaded", __osInitPersistSidebarConfig); } else { __osInitPersistSidebarConfig(); }