// zcbot dev 控制台主逻辑(login / 任务 / 流式 / 文件 / 预览 / embed / boot)。 // 路径 1 模块化:叶子(state/format/dom/api/markdown)+ layout 已抽成独立模块; // 本文件是剩余主体,后续步骤会继续从这里把各功能段逐个剥成独立模块。 import { state, LS_TOKEN, LS_UID, LS_NAME, 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"; import { mqPhone, setMobileView } from "./layout.js"; import { logout, closeChpwModal } from "./auth.js"; // embed 首个 task 自动定位的一次性标志(仅 embed 段使用) let _embedInitialTaskHandled = false; // ───── enter app ───── export 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 内自带"下载")。 // 视频走原生