test_agent / web /static /app.js
xusijie
update app.js
462cf24
// /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) => ({
"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
}[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();
}