Spaces:
Sleeping
Sleeping
| // /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 生成 <p> 导致默认 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 = ` | |
| <div style="padding:16px"> | |
| <div style="color:rgba(0,0,0,0.75);margin-bottom:8px;">该类型暂不支持内嵌预览:</div> | |
| <div style="font-family:ui-monospace,monospace;font-size:12px;margin-bottom:12px;">${this.escapeHtml(asset.name || asset.id)}</div> | |
| <a href="${asset.file_url}" target="_blank" rel="noopener">打开/下载</a> | |
| </div> | |
| `; | |
| 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 = ` | |
| <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| <rect x="5" y="5" width="14" height="14" rx="1.2" fill="currentColor" stroke="none"></rect> | |
| </svg> | |
| `; | |
| 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(); | |
| } | |