From 72ae41e122a54be517d63b08e47014ae88107000 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 5 Jun 2026 16:55:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(dev):=20=E5=89=8D=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8C=96=20Step=201=20=E2=80=94=20dev.html=20?= =?UTF-8?q?=E6=8B=86=E9=9B=B6=E6=9E=84=E5=BB=BA=20ES=20module(=E5=8F=B6?= =?UTF-8?q?=E5=AD=90=E4=BC=98=E5=85=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev.html 原 4087 行单文件原生 JS。本步只做"拆文件"(路径 1), 不引框架、不改逻辑——纯剪切 + import 连线。 抽出 4 个无依赖叶子到 web/static/js/: - state.js state 单例 + LS_* + EMBED* - format.js escapeHtml / humanSize / fmtTokens / usage 系列等纯格式化 - dom.js $ + 浮层菜单 showMenu/hideMenu(import escapeHtml) - api.js api() Bearer 封装(import state) - markdown.js renderMd / highlightIn(依赖 vendor 全局) 剩余主体(login→boot)落 main.js 并 import 叶子;dev.html 内联大 + diff --git a/web/static/js/api.js b/web/static/js/api.js new file mode 100644 index 0000000..d99865f --- /dev/null +++ b/web/static/js/api.js @@ -0,0 +1,27 @@ +// 统一 JSON fetch 封装:注入 Bearer token,解析 JSON/文本,非 2xx 抛带 status 的 Error。 +// (login / create_user / SSE / 文件下载 因 header / 流式 / blob 特殊,仍各自手写 fetch) +import { state } from "./state.js"; + +export async function api(method, path, body) { + const opts = { method, headers: {} }; + if (state.token) opts.headers["Authorization"] = "Bearer " + state.token; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const r = await fetch(path, opts); + let data = null; + const ct = r.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + try { data = await r.json(); } catch (e) {} + } else { + data = await r.text(); + } + if (!r.ok) { + const msg = (data && data.detail) || data || (r.status + " " + r.statusText); + const err = new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); + err.status = r.status; + throw err; + } + return data; +} diff --git a/web/static/js/dom.js b/web/static/js/dom.js new file mode 100644 index 0000000..d9a6d57 --- /dev/null +++ b/web/static/js/dom.js @@ -0,0 +1,51 @@ +// DOM 小工具 + 单例浮层菜单。 +import { escapeHtml } from "./format.js"; + +export const $ = (id) => document.getElementById(id); + +// ───── floating dropdown menu (single instance) ───── +// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐 +let _menuItems = null; +export function showMenu(triggerEl, items) { + _menuItems = items; + const menu = $("floating-menu"); + menu.innerHTML = items.map((it) => { + const cls = "dd-item " + (it.cls || ""); + const dis = it.disabled ? " disabled" : ""; + return ``; + }).join(""); + menu.querySelectorAll(".dd-item").forEach((btn) => { + btn.onclick = (e) => { + e.stopPropagation(); + const act = btn.dataset.act; + const item = _menuItems && _menuItems.find((i) => i.act === act); + hideMenu(); + if (item && item.onclick) item.onclick(); + }; + }); + // 默认右下展开;若空间不足则改向上 + const rect = triggerEl.getBoundingClientRect(); + menu.style.visibility = "hidden"; + menu.classList.add("show"); + const mh = menu.offsetHeight || 120; + menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px"; + menu.style.left = "auto"; + if (rect.bottom + mh + 8 > window.innerHeight) { + menu.style.top = Math.max(4, rect.top - mh - 4) + "px"; + } else { + menu.style.top = (rect.bottom + 4) + "px"; + } + menu.style.visibility = ""; +} +export function hideMenu() { + _menuItems = null; + $("floating-menu").classList.remove("show"); +} +document.addEventListener("click", (e) => { + if (e.target.closest(".dd-toggle")) return; + if (e.target.closest("#floating-menu")) return; + hideMenu(); +}, true); +window.addEventListener("resize", hideMenu); +// 滚动 pane 时菜单位置失效,直接关 +document.addEventListener("scroll", hideMenu, true); diff --git a/web/static/js/format.js b/web/static/js/format.js new file mode 100644 index 0000000..6df8379 --- /dev/null +++ b/web/static/js/format.js @@ -0,0 +1,121 @@ +// 纯格式化 / 转义工具(无 DOM、无状态依赖)。dom.js / markdown.js / main.js 共用。 + +export function escapeHtml(s) { + return (s || "").replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); +} + +export function humanSize(n) { + if (n == null) return ""; + if (n < 1024) return n + " B"; + if (n < 1024*1024) return (n/1024).toFixed(1) + " K"; + if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M"; + return (n/1024/1024/1024).toFixed(1) + " G"; +} + +export function fmtTime(iso) { + if (!iso) return ""; + try { return new Date(iso).toLocaleString(); } catch (e) { return iso; } +} + +// 紧凑 token 显示:<1k 原数,<10k 一位小数 k,>=10k 整数 k,>=1M 一位小数 M +// 目的:让列表行 "N tok" 槽位宽度有上限,跨行对齐 +export function fmtTokens(n) { + n = n || 0; + if (n < 1000) return String(n); + if (n < 10000) return (n / 1000).toFixed(1) + "k"; + if (n < 1000000) return Math.round(n / 1000) + "k"; + return (n / 1000000).toFixed(1) + "M"; +} + +// 紧凑成本显示(¥,已按缓存折价的真实花费):0 不显;<0.01 三位小数;否则两位 +export function fmtCost(n) { + n = n || 0; + if (n <= 0) return ""; + if (n < 0.01) return "¥" + n.toFixed(3); + return "¥" + n.toFixed(2); +} + +// 任务累计用量的 hover 详情(多行):输入/输出拆分 · 缓存命中 + 命中率 · 真实花费。 +// 列表行 + 顶栏共用(列表只显 tok 数,花费/缓存藏 tooltip;顶栏额外内联简版)。 +export function taskUsageTooltip(t) { + const pin = t.tokens_prompt || 0; + const pout = t.tokens_completion || 0; + const hit = t.tokens_cache_hit || 0; + const lines = [`输入 ${pin.toLocaleString()} / 输出 ${pout.toLocaleString()} tok(合计 ${(pin + pout).toLocaleString()})`]; + if (pin > 0 && hit > 0) { + lines.push(`前缀缓存命中 ${hit.toLocaleString()} tok(命中率 ${Math.round(hit / pin * 100)}%,命中部分按低价计费)`); + } + if (t.cost_cny > 0) { + lines.push(`真实花费 ¥${(t.cost_cny).toFixed(4)}(已按缓存命中折价)`); + } + return lines.join("\n"); +} + +// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费;详情走 taskUsageTooltip。 +// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱。 +export function formatTaskUsage(t) { + const tok = t.tokens || 0; + if (!tok) return ""; + const hit = t.tokens_cache_hit || 0; + const pin = t.tokens_prompt || 0; + const bits = [`${fmtTokens(tok)} tok`]; + if (pin > 0 && hit > 0) { + bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`); + } + const cost = fmtCost(t.cost_cny); + if (cost) bits.push(cost); + return `${bits.join(" · ")}`; +} + +export function formatContextStats(d) { + d = d || {}; + const orig = d.context_original_chars || 0; + const sent = d.context_sent_chars || 0; + const saved = d.context_saved_chars || 0; + const tools = d.context_compacted_tool_messages || 0; + const skills = d.context_compacted_skill_messages || 0; + if (!orig) return "准备中…"; + const bits = [`ctx ${fmtTokens(orig)}→${fmtTokens(sent)} chars`]; + if (saved > 0) bits.push(`省 ${fmtTokens(saved)}`); + if (tools > 0) bits.push(`压缩工具 ${tools}`); + if (skills > 0) bits.push(`skill ${skills}`); + return bits.join(" · "); +} + +export function formatUsageStats(d, contextStats) { + d = d || {}; + const pt = d.prompt_tokens || 0; + const ct = d.completion_tokens || 0; + const hit = d.cache_hit_tokens || 0; + const miss = d.cache_miss_tokens || 0; + const bits = [`${fmtTokens(pt)}+${fmtTokens(ct)} tok`]; + if (hit || miss) bits.push(`cache ${fmtTokens(hit)}/${fmtTokens(miss)}`); + if (contextStats && contextStats.context_saved_chars) { + bits.push(`ctx省 ${fmtTokens(contextStats.context_saved_chars)}`); + } + return bits.join(" · "); +} + +// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD +export function fmtTimeAgo(iso) { + if (!iso) return ""; + let d; + try { d = new Date(iso); } catch (e) { return iso; } + if (isNaN(d.getTime())) return iso; + const now = new Date(); + const diffSec = Math.floor((now - d) / 1000); + if (diffSec < 0) return d.toLocaleString(); // 时钟漂移兜底 + if (diffSec < 60) return "刚刚"; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`; + if (diffSec < 86400 && now.getDate() === d.getDate()) return `${Math.floor(diffSec / 3600)} 小时前`; + const pad = (n) => String(n).padStart(2, "0"); + const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + const yest = new Date(now); yest.setDate(now.getDate() - 1); + if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) { + return `昨天 ${hhmm}`; + } + if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000..809d645 --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,2718 @@ +// zcbot dev 控制台主逻辑(login / 任务 / 流式 / 文件 / 预览 / embed / boot)。 +// 路径 1 模块化:叶子(state/format/dom/api/markdown)已抽出为独立模块; +// 本文件是剩余主体,后续步骤会继续从这里把各功能段逐个剥成独立模块。 +import { + state, + LS_TOKEN, LS_UID, LS_NAME, + LS_LEFT_COLLAPSED, LS_RIGHT_COLLAPSED, LS_LEFT_WIDTH, LS_RIGHT_WIDTH, + EMBED, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID, +} from "./state.js"; +import { + escapeHtml, humanSize, fmtTime, fmtTokens, + taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, fmtTimeAgo, +} from "./format.js"; +import { $, showMenu } from "./dom.js"; +import { api } from "./api.js"; +import { renderMd, highlightIn } from "./markdown.js"; + +// embed 首个 task 自动定位的一次性标志(仅 embed 段使用) +let _embedInitialTaskHandled = false; + +// ───── login ───── +let loginTab = "pw"; // "pw" | "key";持久化 last-used tab 在 LS,刷新后默认那个 +const LS_TAB = "zcbot_login_tab"; +function switchLoginTab(name) { + loginTab = name; + document.querySelectorAll("#login .tabs button").forEach(b => { + b.classList.toggle("active", b.dataset.tab === name); + }); + document.querySelectorAll("#login .tab-body").forEach(b => { + b.classList.toggle("active", b.id === "body-" + name); + }); + localStorage.setItem(LS_TAB, name); + $("li-err").textContent = ""; + // 自动 focus 第一个空 input,Enter 直接登 + const firstInput = document.querySelector("#body-" + name + " input"); + if (firstInput) firstInput.focus(); +} +document.querySelectorAll("#login .tabs button").forEach(b => { + b.addEventListener("click", () => switchLoginTab(b.dataset.tab)); +}); +const savedTab = localStorage.getItem(LS_TAB); +if (savedTab === "key") switchLoginTab("key"); + +$("li-go").onclick = doLogin; +// 任意 input 上回车都触发登录 +document.querySelectorAll("#login input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); +}); + +async function doLogin() { + $("li-err").textContent = ""; + let url, body, displayLabel; + if (loginTab === "pw") { + const email = $("li-email").value.trim(); + const password = $("li-password").value; + if (!email || !password) { + $("li-err").textContent = "请填邮箱和密码"; + return; + } + url = "/v1/auth/login_password"; + body = { email, password }; + displayLabel = "email"; + } else { + const uid = $("li-uid").value.trim(); + const pkey = $("li-pkey").value; + if (!uid || !pkey) { + $("li-err").textContent = "请填 user_id 和 PLATFORM_KEY"; + return; + } + url = "/v1/auth/login"; + body = { user_id: uid, platform_key: pkey }; + displayLabel = null; // 这条路径不返显示名,顶栏只显 uid 前 8 位 + } + try { + const r = await fetch(url, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || (r.status + " login failed")); + } + const data = await r.json(); + state.token = data.token; + state.userId = data.user_id; + state.userName = displayLabel ? (data[displayLabel] || "") : ""; + localStorage.setItem(LS_TOKEN, state.token); + localStorage.setItem(LS_UID, state.userId); + if (state.userName) { + localStorage.setItem(LS_NAME, state.userName); + } else { + localStorage.removeItem(LS_NAME); + } + enterApp(); + } catch (e) { + $("li-err").textContent = e.message; + } +} + +function logout() { + state.token = ""; state.userId = ""; state.userName = ""; + localStorage.removeItem(LS_TOKEN); + localStorage.removeItem(LS_UID); + localStorage.removeItem(LS_NAME); + if (state.evtSrc) state.evtSrc.close(); + if (EMBED) { + embedPostToParent({ type: "zcbot-401" }); + embedShowWaiting("登录已失效,等待父页面重新签发…", false); + document.body.classList.add("embed-waiting"); + return; + } + location.reload(); +} +$("hd-logout").onclick = logout; + +// ───── admin add-user ───── +// 入口在登录页右下角链接;弹窗收 email/password/admin_token 三项,POST /v1/auth/admin/create_user。 +// 成功后不自动登录(让用户自己用新账号登,逻辑清晰),只回填邮箱到登录表单 + 提示。 +function openAdminModal() { + $("ad-email").value = ""; + $("ad-password").value = ""; + $("ad-token").value = ""; + $("ad-err").textContent = ""; + $("admin-modal").classList.add("show"); + $("ad-email").focus(); +} +function closeAdminModal() { + $("admin-modal").classList.remove("show"); +} +$("open-admin-add").onclick = (e) => { e.preventDefault(); openAdminModal(); }; +$("ad-cancel").onclick = closeAdminModal; +$("admin-modal").addEventListener("click", (e) => { + if (e.target.id === "admin-modal") closeAdminModal(); // 点遮罩关闭 +}); +// 任一 input 上回车触发提交 +document.querySelectorAll("#admin-modal input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doAdminAdd(); }); +}); + +async function doAdminAdd() { + $("ad-err").textContent = ""; + const email = $("ad-email").value.trim(); + const password = $("ad-password").value; + const admin_token = $("ad-token").value; + if (!email || !password || !admin_token) { + $("ad-err").textContent = "请填邮箱、密码、管理员口令"; + return; + } + if (password.length < 6) { + $("ad-err").textContent = "密码至少 6 字符"; + return; + } + try { + const r = await fetch("/v1/auth/admin/create_user", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, admin_token }), + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.detail || (r.status + " create failed")); + } + const data = await r.json(); + closeAdminModal(); + // 切到邮箱密码 tab,回填邮箱,提示一下 + switchLoginTab("pw"); + $("li-email").value = data.email || email; + $("li-password").value = ""; + $("li-password").focus(); + $("li-err").style.color = "var(--muted)"; // 临时降级为提示色 + $("li-err").textContent = `已创建 ${data.email},请登录`; + setTimeout(() => { $("li-err").style.color = ""; }, 4000); + } catch (e) { + $("ad-err").textContent = e.message; + } +} +$("ad-go").onclick = doAdminAdd; + +// ───── 改密码(顶栏入口,需已登录)───── +// 旧/新/确认三项;user_id 不传,后端从 JWT 取。成功后关弹窗,提示一下(不登出)。 +function openChpwModal() { + $("cp-old").value = ""; + $("cp-new").value = ""; + $("cp-new2").value = ""; + $("cp-err").textContent = ""; + $("chpw-modal").classList.add("show"); + $("cp-old").focus(); +} +function closeChpwModal() { + $("chpw-modal").classList.remove("show"); +} +$("hd-chpw").onclick = openChpwModal; +$("cp-cancel").onclick = closeChpwModal; +$("chpw-modal").addEventListener("click", (e) => { + if (e.target.id === "chpw-modal") closeChpwModal(); // 点遮罩关闭 +}); +document.querySelectorAll("#chpw-modal input").forEach(i => { + i.addEventListener("keydown", (e) => { if (e.key === "Enter") doChangePassword(); }); +}); + +async function doChangePassword() { + $("cp-err").textContent = ""; + const oldPw = $("cp-old").value; + const newPw = $("cp-new").value; + const newPw2 = $("cp-new2").value; + if (!oldPw || !newPw || !newPw2) { + $("cp-err").textContent = "请填旧密码和新密码"; + return; + } + if (newPw.length < 6) { + $("cp-err").textContent = "新密码至少 6 字符"; + return; + } + if (newPw !== newPw2) { + $("cp-err").textContent = "两次输入的新密码不一致"; + return; + } + try { + await api("POST", "/v1/auth/change_password", { old_password: oldPw, new_password: newPw }); + closeChpwModal(); + // 不登出:同一会话 JWT 仍有效,下次登录用新密码即可 + alert("密码已修改,下次登录请用新密码"); + } catch (e) { + if (e.status === 401) { closeChpwModal(); logout(); return; } + $("cp-err").textContent = e.message; + } +} +$("cp-go").onclick = doChangePassword; + +// ───── pane 折叠 + splitters(rail 模式 + localStorage 持久化) ───── +const PANE_W = { left: { min: 220, max: 560, def: 320 }, right: { min: 220, max: 560, def: 320 } }; +function clampPaneWidth(side, value) { + const cfg = PANE_W[side]; + const n = Number(value); + if (!Number.isFinite(n)) return cfg.def; + return Math.max(cfg.min, Math.min(cfg.max, Math.round(n))); +} +function applyPaneWidth(side, value) { + const width = clampPaneWidth(side, value); + $("app").style.setProperty(side === "left" ? "--left-pane-width" : "--right-pane-width", width + "px"); + localStorage.setItem(side === "left" ? LS_LEFT_WIDTH : LS_RIGHT_WIDTH, String(width)); +} +function restorePaneWidths() { + applyPaneWidth("left", localStorage.getItem(LS_LEFT_WIDTH) || PANE_W.left.def); + applyPaneWidth("right", localStorage.getItem(LS_RIGHT_WIDTH) || PANE_W.right.def); +} + +// 折叠 = pane 收成 40px rail,只留 toggle 一直可点;按钮符号根据状态翻向 +function applyLeftCollapsed(collapsed) { + document.body.classList.toggle("left-collapsed", collapsed); + const btn = $("pane-toggle-left"); + btn.textContent = collapsed ? "›" : "‹"; + btn.title = collapsed ? "展开任务列表" : "折叠任务列表"; +} +function applyRightCollapsed(collapsed) { + document.body.classList.toggle("right-collapsed", collapsed); + const btn = $("pane-toggle-right"); + btn.textContent = collapsed ? "‹" : "›"; + btn.title = collapsed ? "展开文件列表" : "折叠文件列表"; +} +$("pane-toggle-left").onclick = () => { + const next = !document.body.classList.contains("left-collapsed"); + localStorage.setItem(LS_LEFT_COLLAPSED, next ? "1" : ""); + applyLeftCollapsed(next); +}; +$("pane-toggle-right").onclick = () => { + const next = !document.body.classList.contains("right-collapsed"); + localStorage.setItem(LS_RIGHT_COLLAPSED, next ? "1" : ""); + applyRightCollapsed(next); +}; +restorePaneWidths(); +applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); +applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); + +function attachPaneSplitter(id, side) { + const el = $(id); + let dragging = false; + el.addEventListener("pointerdown", (ev) => { + if (mqPhone.matches) return; + ev.preventDefault(); + dragging = true; + el.classList.add("active"); + document.body.classList.add("resizing-panes"); + el.setPointerCapture(ev.pointerId); + if (side === "left" && document.body.classList.contains("left-collapsed")) { + localStorage.setItem(LS_LEFT_COLLAPSED, ""); + applyLeftCollapsed(false); + } + if (side === "right" && document.body.classList.contains("right-collapsed")) { + localStorage.setItem(LS_RIGHT_COLLAPSED, ""); + applyRightCollapsed(false); + } + }); + el.addEventListener("pointermove", (ev) => { + if (!dragging) return; + const rect = $("app").getBoundingClientRect(); + const width = side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; + applyPaneWidth(side, width); + }); + const stop = (ev) => { + if (!dragging) return; + dragging = false; + el.classList.remove("active"); + document.body.classList.remove("resizing-panes"); + try { el.releasePointerCapture(ev.pointerId); } catch (_) {} + }; + el.addEventListener("pointerup", stop); + el.addEventListener("pointercancel", stop); +} +attachPaneSplitter("split-left", "left"); +attachPaneSplitter("split-right", "right"); + +// ───── 手机视图切换(单列 + tab) ───── +// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class +// 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) +const mqPhone = window.matchMedia("(max-width: 640px)"); +function setMobileView(view) { + // view ∈ "mv-left" | "mv-mid" | "mv-right" + document.body.classList.remove("mv-left", "mv-mid", "mv-right"); + document.body.classList.add(view); + for (const b of document.querySelectorAll(".mobile-tabs button")) { + b.classList.toggle("active", b.dataset.mv === view); + } +} +function applyMobileMode() { + if (mqPhone.matches) { + // 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过) + document.body.classList.remove("left-collapsed"); + document.body.classList.remove("right-collapsed"); + if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) { + setMobileView("mv-left"); + } + } else { + // 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class) + applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); + applyRightCollapsed(localStorage.getItem(LS_RIGHT_COLLAPSED) === "1"); + } +} +mqPhone.addEventListener("change", applyMobileMode); +applyMobileMode(); +for (const b of document.querySelectorAll(".mobile-tabs button")) { + b.onclick = () => setMobileView(b.dataset.mv); +} + +// ───── enter app ───── +function enterApp() { + $("login").style.display = "none"; + $("app").classList.add("ready"); + // 显示「name · uuid 前 8 位」;name 缺失(老 token 升级前)只显 uuid + const uid8 = (state.userId || "").slice(0, 8); + $("hd-who").textContent = state.userName ? `${state.userName} · ${uid8}` : state.userId; + $("hd-who").title = state.userId; + loadTaskList(); + loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root + loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 + loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) + loadStorage(); // 顶栏存储用量(后台扫描快照,非实时) +} + +// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描, +// 故无需高频刷新 —— enterApp 拉一次即可。无配额上限时只显已用、不画进度条(nolimit)。 +async function loadStorage() { + let s; + try { s = await api("GET", "/v1/user/storage"); } catch (e) { return; } + const el = $("storage-foot"); + const used = s.bytes_used || 0; + const limit = s.limit_bytes; + if (limit && limit > 0) { + const pct = Math.min(100, Math.round(used / limit * 100)); + $("storage-foot-bar").style.width = pct + "%"; + $("storage-foot-txt").textContent = `${humanSize(used)} / ${humanSize(limit)}`; + el.classList.remove("nolimit"); + el.classList.toggle("over", used >= limit); + } else { + // 不限额:只显已用,隐藏进度条 + $("storage-foot-txt").textContent = humanSize(used); + el.classList.add("nolimit"); + el.classList.remove("over"); + } + const when = s.scanned_at ? fmtTime(s.scanned_at) : "尚未统计"; + el.title = `已用 ${humanSize(used)} · ${s.file_count || 0} 个文件\n统计于 ${when}(后台每 15 分钟扫描,非实时)`; + el.classList.add("show"); +} + +async function loadModels() { + try { + const data = await api("GET", "/v1/models"); + state.models = data.models || []; + } catch (e) { + state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程 + } + try { + const data = await api("GET", "/v1/image_models"); + state.imageModels = data.models || []; + // 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新 + if (!state.imageModel) { + const def = state.imageModels.find(m => m.is_default) || state.imageModels[0]; + state.imageModel = def ? def.variant : ""; + } + } catch (e) { + state.imageModels = []; + state.imageModel = ""; + } + try { + const data = await api("GET", "/v1/video_models"); + state.videoModels = data.models || []; + if (!state.videoModel) { + const def = state.videoModels.find(m => m.is_default) || state.videoModels[0]; + state.videoModel = def ? def.variant : ""; + } + } catch (e) { + state.videoModels = []; + state.videoModel = ""; + } + // embed + task_id 场景下 selectTask 可能在 loadModels 完成前就跑完 renderChatMeta, + // 此时 models 为空 → 模型下拉不渲染。loadModels 收尾时如果已选中 task,补一次 chat-meta 重渲。 + if (state.taskMeta) renderChatMeta(); +} + +// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发 +// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应 +let _taskLoadSeq = 0; +async function loadTaskList({ append = false } = {}) { + if (append && (state.taskLoading || !state.taskHasMore)) return; + const mySeq = ++_taskLoadSeq; + const nextPage = append ? state.taskPage + 1 : 1; + const params = new URLSearchParams(); + params.set("page", nextPage); + params.set("page_size", state.taskPageSize); + const st = $("filter-status").value; + if (st) params.set("status", st); + const q = $("filter-q").value.trim(); + if (q) params.set("q", q); + const wd = $("filter-wd").value.trim(); + if (wd) params.set("working_dir", wd); + const ord = $("filter-order").value; + if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净 + state.taskLoading = true; + setSentinel(append ? "加载中…" : ""); + try { + const data = await api("GET", "/v1/tasks?" + params.toString()); + if (mySeq !== _taskLoadSeq) return; // 已被更新的请求 supersede,丢弃 + state.taskTotal = data.count || 0; + state.taskPage = data.page || nextPage; + state.taskPageSize = data.page_size || state.taskPageSize; + const results = data.results || []; + if (!append) state.taskLoaded = 0; + state.taskLoaded += results.length; + state.taskHasMore = state.taskLoaded < state.taskTotal; + renderTaskList(results, append); + renderTaskCount(); + } catch (e) { + if (mySeq !== _taskLoadSeq) return; + if (e.status === 401) { logout(); return; } + if (!append) { + $("task-list").innerHTML = `
加载失败:${escapeHtml(e.message)}
`; + state.taskHasMore = false; + } + setSentinel(`加载失败:${e.message}`); + } finally { + if (mySeq === _taskLoadSeq) state.taskLoading = false; + } +} + +function renderTaskCount() { + $("task-count").textContent = state.taskTotal > 0 ? `共 ${state.taskTotal} 个` : ""; + if (state.taskTotal === 0) setSentinel(""); + else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : ""); + else setSentinel(""); // 还有更多 → 留空,observer 触发时再填"加载中" +} + +function setSentinel(text) { + $("task-sentinel").textContent = text || ""; +} + +function renderTaskList(tasks, append = false) { + if (!append) state.tasksById = {}; + for (const t of tasks) state.tasksById[t.task_id] = t; + if (!append && !tasks.length) { + $("task-list").innerHTML = `
(暂无任务)
`; + return; + } + if (append && !tasks.length) return; // 末页空 batch,不动 DOM + const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" }; + const html = tasks.map((t) => { + const active = state.taskId === t.task_id ? " active" : ""; + // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示) + const taskName = t.name || "(未命名)"; + const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; + const desc = t.description || ""; + const statusLabel = statusLabels[t.status] || t.status; + const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8) + return ` +
+
+
${escapeHtml(taskName)}
+ ${wdName ? `
📁 ${escapeHtml(wdName)}
` : ""} + ${desc ? `
${escapeHtml(desc)}
` : ""} +
+ ${statusLabel} + ${t.skill ? `${escapeHtml(t.skill)}` : ""} + ${t.n_messages || 0} 条 + ${fmtTokens(t.tokens)} tok + ${escapeHtml(fmtTimeAgo(t.updated_at))} +
+
+ +
+ `; + }).join(""); + const listEl = $("task-list"); + let newRows; + if (append) { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + newRows = Array.from(tmp.children); + newRows.forEach((el) => listEl.appendChild(el)); + } else { + listEl.innerHTML = html; + newRows = Array.from(listEl.querySelectorAll(".task-row")); + } + newRows.forEach((el) => { + if (!el.classList || !el.classList.contains("task-row")) return; + el.onclick = (e) => { + if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中 + selectTask(el.dataset.tid); + }; + const btn = el.querySelector(".task-menu"); + if (btn) { + btn.onclick = (e) => { + e.stopPropagation(); + const t = state.tasksById[btn.dataset.tid]; + if (!t) return; + showMenu(btn, taskMenuItems(t)); + }; + } + }); +} + +function taskMenuItems(t) { + const isActive = t.status === "active"; + const hasMsg = (t.n_messages || 0) > 0; + return [ + { act: "complete", label: "完成", cls: "act-complete", disabled: !isActive, + onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") }, + { act: "abandon", label: "废弃", cls: "act-abandon", disabled: !isActive, + onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") }, + { act: "export", label: "导出对话记录", cls: "act-export", disabled: !hasMsg, + onclick: () => exportTask(t.task_id) }, + { act: "delete", label: "删除", cls: "act-delete", + onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) }, + ]; +} + +// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发 +$("filter-status").onchange = () => loadTaskList(); +$("filter-order").onchange = () => loadTaskList(); +$("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛 +$("btn-refresh-tasks").onclick = () => loadTaskList(); + +// 搜索 q 是 text input → 300ms debounce 避免每字符打 API +let _filterDebounce = null; +$("filter-q").addEventListener("input", () => { + clearTimeout(_filterDebounce); + _filterDebounce = setTimeout(() => loadTaskList(), 300); +}); + +// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。 +// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖) +const _taskScrollObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) { + loadTaskList({ append: true }); + } +}, { root: $("task-scroll"), rootMargin: "200px 0px" }); +_taskScrollObserver.observe($("task-sentinel")); + +// ───── select task ───── +async function selectTask(tid) { + if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } + // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同, + // 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。 + if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache(); + state.taskId = tid; + document.querySelectorAll(".task-row").forEach((el) => { + el.classList.toggle("active", el.dataset.tid === tid); + }); + // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op) + if (mqPhone.matches) setMobileView("mv-mid"); + try { + const meta = await api("GET", "/v1/tasks/" + tid); + state.taskMeta = meta; + renderChatMeta(); + await loadMessages(); + if (meta.run_status === "running" || meta.run_status === "cancelling") { + ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`); + } else { + renderLiveRunIfVisible(); + } + // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录), + // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 + const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; + state.filesPath = wdName || ""; + await loadFiles(); + refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget + } catch (e) { + if (e.status === 401) { logout(); return; } + $("chat-stream").innerHTML = `
加载失败:${escapeHtml(e.message)}
`; + } +} + +// 拉同 wd 内除自己外仍 running/cancelling 的 task,渲染软警告 banner。 +// 同 wd 多 task 同时跑频率近 0(用户工作流以"同项目对话历史轨迹"为主,不并发), +// 这里只做提示,不挡发送(对应 DESIGN §7.8 / §7.9 "信任 + 软警告 + 承认边界")。 +async function refreshConcurrentWarnings() { + const t = state.taskMeta; + if (!t || !t.working_dir) { state.concurrentWarnings = []; renderConcurrentWarning(); return; } + const wdName = t.working_dir.split("/").filter(Boolean).pop(); + if (!wdName) { state.concurrentWarnings = []; renderConcurrentWarning(); return; } + const params = new URLSearchParams(); + params.set("working_dir", wdName); + params.set("run_status", "running,cancelling"); + params.set("page_size", "10"); + try { + const data = await api("GET", "/v1/tasks?" + params.toString()); + const others = (data.results || []).filter(r => r.task_id !== t.task_id); + state.concurrentWarnings = others; + } catch (e) { + // 警告失败不影响主功能,静默 + state.concurrentWarnings = []; + } + renderConcurrentWarning(); +} + +function renderConcurrentWarning() { + const el = $("wd-concurrent-warn"); + const others = state.concurrentWarnings; + if (!others || others.length === 0) { el.style.display = "none"; el.innerHTML = ""; return; } + const head = others[0]; + const t = state.taskMeta; + const wdName = t && t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; + const more = others.length > 1 ? ` 等 ${others.length} 个` : ""; + el.innerHTML = `⚠ 项目 "${escapeHtml(wdName)}" 内 task ${escapeHtml(head.name || "(未命名)")} 正在 ${escapeHtml(head.run_status)}${more} — 并发写同名中间产物可能互覆,建议等它结束再发`; + el.style.display = "block"; +} + +function renderChatMeta() { + const t = state.taskMeta; + if (!t) { $("chat-meta").innerHTML = `(未选中任务)`; return; } + const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; + const taskName = t.name || "(未命名)"; + const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status; + // wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁; + // 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属" + const wdBadge = (wdName && wdName !== taskName) + ? `📁 ${escapeHtml(wdName)}` + : ""; + $("chat-meta").innerHTML = ` + ${escapeHtml(taskName)} + ${statusLabel} + ${wdBadge} + ${t.skill ? `${escapeHtml(t.skill)}` : ""} + ${t.task_id.slice(0, 8)} + ${formatTaskUsage(t)} + ${t.description ? `${escapeHtml(t.description)}` : ""} + + ${renderModelDropdown(t)} + ${renderImageModelDropdown()} + ${renderVideoModelDropdown()} + `; + const sel = $("chat-model-sel"); + if (sel) sel.onchange = onChangeModel; + const imgSel = $("chat-image-model-sel"); + if (imgSel) imgSel.onchange = onChangeImageModel; + const vidSel = $("chat-video-model-sel"); + if (vidSel) vidSel.onchange = onChangeVideoModel; + const active = t.status === "active"; + $("chat-form").style.display = active ? "flex" : "none"; + syncOptimizeBtn(); + $("btn-done").disabled = !active; + $("btn-abandon").disabled = !active; + $("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm) + // 导出 / 清空:只要选中 task 就允许点(不按 n_messages 门禁 —— 历史 bug: + // 清空后 n_messages=0 disable,但新对话进来后 taskMeta 不重渲一直 disable; + // 0 条时点击不会出错(导出空 docx / 清空 confirm 显 0 条),让 UX 一致更省心)。 + $("btn-export").disabled = false; + // 清空对话:仅活跃 run 期间禁用(后端 409,confirm 通过后才报错 UX 差) + const running = t.run_status === "running" || t.run_status === "cancelling"; + $("btn-clear-msgs").disabled = running; +} + +function renderModelDropdown(t) { + // 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile) + if (!state.models || state.models.length === 0) return ""; + const cur = t.model_profile || ""; + const opts = state.models.map(m => + `` + ).join(""); + return `模型`; +} + +function renderImageModelDropdown() { + // imageModels 为空(yaml 无 image variant)→ 不画下拉。注意不依赖 ARK_API_KEY 是否设了 + // —— 这里只是展示元数据,真正调用时 backend 那边没 key 自然 tool 不挂(用户不会 + // 在没 key 的环境点出图,prompt 里 seedream 工具压根不在 schema)。 + if (!state.imageModels || state.imageModels.length === 0) return ""; + const cur = state.imageModel || ""; + const opts = state.imageModels.map(m => + `` + ).join(""); + return `生图`; +} + +function onChangeImageModel(ev) { + // 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发 + state.imageModel = ev.target.value || ""; + $("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`; +} + +function renderVideoModelDropdown() { + // 同 renderImageModelDropdown:videoModels 为空 → 不画。yaml 无 video 段 / 后端 + // /v1/video_models 返空时下拉不出现,seedance tool 也不会在 schema 里。 + if (!state.videoModels || state.videoModels.length === 0) return ""; + const cur = state.videoModel || ""; + const opts = state.videoModels.map(m => + `` + ).join(""); + return `生视频`; +} + +function onChangeVideoModel(ev) { + state.videoModel = ev.target.value || ""; + $("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`; +} + +async function onChangeModel(ev) { + const sel = ev.target; + const newProfile = sel.value; + const t = state.taskMeta; + if (!t || !newProfile || newProfile === t.model_profile) return; + const oldProfile = t.model_profile || ""; + try { + const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile }); + state.taskMeta = updated; + const running = updated.run_status === "running" || updated.run_status === "cancelling"; + $("chat-hint").textContent = running + ? `已切到 ${newProfile} · 当前 run 跑完后生效` + : `已切到 ${newProfile}`; + } catch (e) { + sel.value = oldProfile; // PATCH 失败 UI 回滚 + $("chat-hint").textContent = `切换失败:${e.message}`; + } +} + +async function loadMessages() { + const data = await api("GET", `/v1/tasks/${state.taskId}/messages`); + renderMessages(data.messages); +} + +function getLiveRun(taskId) { + return taskId ? state.liveRuns.get(taskId) : null; +} + +function isCurrentTaskStreaming() { + return !!getLiveRun(state.taskId); +} + +function createLiveAssistantCard(run) { + const card = document.createElement("div"); + card.className = "msg assistant live-run"; + card.innerHTML = `
助手
${run.acc ? renderMd(run.acc) : ""}
`; + run.card = card; + run.body = card.querySelector(".body"); + return card; +} + +function renderLiveRunIfVisible() { + const run = getLiveRun(state.taskId); + if (!run) { + setActionMode("idle"); + return; + } + const wrap = $("chat-stream"); + const card = run.card || createLiveAssistantCard(run); + if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc); + if (card.parentElement !== wrap) wrap.appendChild(card); + wrap.scrollTop = wrap.scrollHeight; + setActionMode(run.cancelling ? "cancelling" : "streaming"); + $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…"; +} + +function ensureRunningTaskSubscribed(taskId, url) { + if (!taskId || getLiveRun(taskId)) return; + const run = { + taskId, + url, + acc: "", + pending: false, + seenRels: new Set(), + terminal: false, + card: null, + body: null, + cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling", + workingDir: state.taskMeta && state.taskMeta.working_dir, + }; + state.liveRuns.set(taskId, run); + state.streaming = true; + renderLiveRunIfVisible(); + streamSse(url, run); +} + +function setRunHint(run, text) { + if (state.taskId === run.taskId) $("chat-hint").textContent = text; +} + +function renderMessages(msgs) { + const wrap = $("chat-stream"); + wrap.innerHTML = ""; + if (!msgs.length) { + wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`; + renderLiveRunIfVisible(); + return; + } + // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔 + // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。 + let lastAsstModel = null; + // chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。 + // chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。 + const seenRels = new Set(); + const pickFresh = (rels) => { + const fresh = []; + for (const r of rels) { + if (seenRels.has(r)) continue; + seenRels.add(r); + fresh.push(r); + } + return fresh; + }; + for (const m of msgs) { + const p = m.payload || {}; + const role = p.role || "?"; + if (role === "system") continue; // 不显示 system + if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) { + const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile; + const sep = document.createElement("div"); + sep.className = "model-switch muted"; + sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;"; + sep.textContent = `── ${dn} ──`; + wrap.appendChild(sep); + lastAsstModel = m.model_profile; + } + if (role === "tool") { + // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) + const card = document.createElement("div"); + card.className = "msg tool"; + const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); + const banner = extractMediaBanner(p.name || "", txt || ""); + // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具 + // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。 + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || ""); + const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : []; + card.innerHTML = ` +
工具调用 · ${escapeHtml(p.name || "")}
+
结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
+ ${renderArtifactBarHtml(rels, isProducer)} + `; + wrap.appendChild(card); + continue; + } + const card = document.createElement("div"); + card.className = "msg " + role; + const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role; + let html = `
${roleLabel}
`; + if (typeof p.content === "string" && p.content) { + html += `
${renderMd(p.content)}
`; + // assistant 正文里 echo 的 /... 路径**永远**挂 chip(绕开 seenRels —— 上面 + // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可 + // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。 + if (role === "assistant") { + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); + html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false); + } + } + if (Array.isArray(p.tool_calls) && p.tool_calls.length) { + const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir); + for (const tc of p.tool_calls) { + const fn = (tc.function && tc.function.name) || "?"; + let argsObj = {}; + let args = ""; + try { + argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}"); + args = JSON.stringify(argsObj, null, 2); + } catch (e) { args = (tc.function && tc.function.arguments) || ""; } + const label = toolActivityLabel(fn, argsObj); + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); + const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : []; + html += ` +
${escapeHtml(label)}
${escapeHtml(args)}
+ ${renderArtifactBarHtml(rels, isProducer)} + `; + } + } + card.innerHTML = html; + highlightIn(card); + wrap.appendChild(card); + } + wrap.scrollTop = wrap.scrollHeight; + upgradeMediaArtifacts(wrap); + renderLiveRunIfVisible(); +} + +// ───── send + SSE ───── +// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边); +// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。 +function setActionMode(mode) { + const btn = $("chat-action"); + btn.classList.remove("primary", "danger"); + if (mode === "idle") { + btn.textContent = "发送"; + btn.classList.add("primary"); + btn.disabled = false; + btn.title = ""; + } else if (mode === "streaming") { + btn.textContent = "停止"; + btn.classList.add("danger"); + btn.disabled = false; + btn.title = "停止当前流式回复"; + } else if (mode === "cancelling") { + btn.textContent = "停止中…"; + btn.classList.add("danger"); + btn.disabled = true; + } +} + +function chatAction() { + if (isCurrentTaskStreaming()) cancelCurrentTask(); + else sendMessage(); +} + +$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); }); +$("chat-input").addEventListener("keydown", (e) => { + // streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高 + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (!isCurrentTaskStreaming()) sendMessage(); + } +}); +$("chat-input").addEventListener("input", syncOptimizeBtn); +// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认 +// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。 +$("chat-input").addEventListener("paste", async (e) => { + const files = Array.from(e.clipboardData?.files || []); + if (!files.length) return; + e.preventDefault(); + const hint = $("chat-hint"); + const prevHint = hint.textContent; + hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`; + const saved = await uploadFiles(files, { + onProgress: (loaded, total) => { + hint.textContent = formatUploadProgress(files, loaded, total); + }, + }); + if (saved && saved.length) { + hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} 可在右侧文件处查看`; + } else { + hint.textContent = prevHint; // 失败 alert 已弹,hint 回原 + } +}); + +function renderPasteFileChips(saved) { + return (saved || []).map((f) => { + const rel = f.rel || f.name || ""; + const name = f.name || (rel.split("/").pop() || rel); + return ``; + }).join(""); +} + +$("chat-hint").addEventListener("click", (e) => { + const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]"); + if (del) { + e.stopPropagation(); + deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap")); + return; + } + const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]"); + if (!chip) return; + const rel = chip.dataset.rel; + if (rel) openPasteFilePreview(rel); +}); + +async function deletePastedFile(rel, wrap) { + if (!rel || !wrap) return; + const btn = wrap.querySelector(".paste-chip-del"); + if (btn) btn.disabled = true; + try { + await api("POST", "/v1/files/delete", { path: rel, recursive: false }); + if (_fpCurrentRel === rel) closeFilePreview(); + if (_mpCurrentRel === rel) closeMiniPreview(); + wrap.remove(); + await loadFiles(); + const hint = $("chat-hint"); + if (!hint.querySelector(".paste-chip-wrap")) { + hint.innerHTML = `已删除粘贴文件`; + } + } catch (e) { + if (btn) btn.disabled = false; + if (e.status === 401) { logout(); return; } + alert("删除失败:" + e.message); + } +} + +// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText') +// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 +// 不与主对话 run 互斥,各跑各的 LLM)。 +function syncOptimizeBtn() { + const btn = $("chat-optimize"); + if (!btn) return; + if (state.optimizing) return; // 进行中不在这条路径切 + const has = ($("chat-input").value || "").trim().length > 0; + btn.disabled = !has || !state.taskId; +} + +async function optimizePrompt() { + if (state.optimizing) return; + if (!state.taskId) return; + const ta = $("chat-input"); + const original = (ta.value || "").trim(); + if (!original) return; + const btn = $("chat-optimize"); + state.optimizing = true; + btn.disabled = true; + const oldLabel = btn.textContent; + btn.textContent = "润色中…"; + const oldHint = $("chat-hint").textContent; + $("chat-hint").textContent = "润色中…"; + try { + const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, { + text: ta.value, // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变 + image_model: state.imageModel || "", + video_model: state.videoModel || "", + }); + const optimized = (r.optimized || "").trim(); + if (!optimized) throw new Error("空结果"); + // execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈 + ta.focus(); + ta.select(); + const ok = document.execCommand("insertText", false, optimized); + if (!ok) { + // execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值 + // 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道 + ta.value = optimized; + $("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)"; + } else { + const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?"; + $("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`; + } + } catch (e) { + if (e.status === 401) { logout(); return; } + $("chat-hint").textContent = `润色失败:${e.message}`; + } finally { + state.optimizing = false; + btn.textContent = oldLabel; + syncOptimizeBtn(); + } +} + +$("chat-optimize").onclick = optimizePrompt; + +// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。 +// 视频走原生