// 对话视图(任务列表 + 选择/渲染消息 + 发送/SSE 流式 + 任务生命周期): // 任务列表浏览/筛选/滚动加载、selectTask 切换、renderChatMeta、模型下拉、 // renderMessages、live-run 助手、sendMessage/cancel、fetchSse/handleSseEvent、 // 润色/粘贴文件、完成/废弃/删除/导出/清空。共享 state.liveRuns + chat-stream DOM。 // 各入口模块顶层自绑;对外导出 loadTaskList / loadModels / selectTask // (供 main enterApp、embed、files、newtask)。 import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js"; import { $, showMenu } from "./dom.js"; import { api } from "./api.js"; import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; import { mqPhone, setMobileView } from "./layout.js"; // 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白) const WECHAT_ICON = ``; // 渠道镜像 task(后端 _run_channel_conversation 建):'wechat'=个人微信 ClawBot,'wecom'=企业微信。 // 两者在 web 端都是只读镜像(唯一交互入口在对应 App,web 发的消息推不回去)—— 列表打绿徽章 + // 绿边、对话框 readonly。单一真相源:徽章文案 / 锁定提示 / 发送拦截全读这张表。logo 复用微信系图标。 const CHANNEL_BADGE = { wechat: { label: "微信", title: "微信 ClawBot 渠道" }, wecom: { label: "企业微信", title: "企业微信渠道" }, }; function channelCfg(ch) { return CHANNEL_BADGE[ch] || null; } import { logout } from "./auth.js"; import { openWechatModal } from "./wechat.js"; import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js"; import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js"; import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js"; import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js"; export 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; export 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(); subscribeRunningRows(results); } 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; } } // 列表带出的 running/cancelling 行,本地未订阅的自动挂 SSE 接管(刷新后重新盯上、 // 别的标签页/渠道启动的 run 也能盯):跑完 done/error 走 fetchSse 现有收尾 // (清 liveRuns + 就地清标识 + loadTaskList 重拉),标识全程实时,不需要轮询。 // 上限防浏览器同源连接数被后台流占满(HTTP/1.1 并发约 6,要给选中 task 的 // 订阅和普通 API 留口子);超限的行标识仍显示(服务端快照),只是不自动清。 const MAX_BG_SSE = 4; function subscribeRunningRows(tasks) { for (const t of tasks) { if (t.run_status !== "running" && t.run_status !== "cancelling") continue; if (getLiveRun(t.task_id)) continue; if (state.liveRuns.size >= MAX_BG_SSE) break; ensureRunningTaskSubscribed(t.task_id, `/v1/tasks/${t.task_id}/events`, t); } } 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 || ""; } // 列表行运行态:服务端 run_status 快照 + 本地 liveRuns 叠加(本会话刚发出的 run 比列表快照新) function taskRunState(t) { const live = state.liveRuns.get(t.task_id); if (live) return live.cancelling ? "cancelling" : "running"; return t.run_status || "idle"; } // 只渲一个带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案收进 hover title—— // 列表 meta 行本就拥挤,多两个字就挤断行 const RUN_IND_TITLE = { running: "运行中(调用工具 / 回复中)", cancelling: "停止中", error: "", // 动态填 run_error }; function runIndicatorHtml(t) { const rs = taskRunState(t); if (!(rs in RUN_IND_TITLE)) return ""; // idle → 不显示 const title = rs === "error" ? (t.run_error || "上次执行出错") : RUN_IND_TITLE[rs]; return ``; } // run 开始 / 停止请求后就地刷新对应列表行的运行态标识(不重拉列表 — loadTaskList reset // 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。 // 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。 function syncTaskRowRunIndicator(tid) { const row = document.querySelector(`.task-row[data-tid="${CSS.escape(tid)}"]`); if (!row) return; const metaLine = row.querySelector(".meta:not(.muted)"); if (!metaLine) return; const old = metaLine.querySelector(".run-ind"); if (old) old.remove(); const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid }); if (!html) return; const badge = metaLine.querySelector(".badge"); if (badge) badge.insertAdjacentHTML("afterend", html); else metaLine.insertAdjacentHTML("afterbegin", html); } 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) // 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards) return `
${escapeHtml(taskName)}
${wdName ? `
📁 ${escapeHtml(wdName)}
` : ""} ${desc ? `
${escapeHtml(desc)}
` : ""}
${statusLabel} ${runIndicatorHtml(t)} ${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)); }; } }); } // 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互 // (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: |null }, // 前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、已绑定有对话(点进 + ⚙ 管理)。 export async function loadChannelCards() { const box = $("channel-cards"); if (!box) return; let data; try { data = await api("GET", "/v1/channel_tasks"); } catch (e) { if (e.status === 401) { logout(); return; } box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏 return; } // CHANNEL_BADGE 的键序决定卡片顺序。每渠道都有一张卡片(占位或对话)。 const cards = Object.keys(CHANNEL_BADGE).map((kind) => ({ kind, info: data && data[kind] })); box.innerHTML = cards.map(({ kind, info }) => { const cfg = CHANNEL_BADGE[kind]; const bound = info && info.bound; const t = info && info.task; let html = ""; if (!bound) { // 未绑定:占位卡片,点打开弹框绑定 html = `
${WECHAT_ICON} 绑定${cfg.label} 扫码或手填 userid
`; } else if (!t) { // 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,右侧 ⚙ 管理 html = `
${WECHAT_ICON} ${cfg.label}对话 发消息后可打开
`; } else { // 已绑定且有对话:正常卡片,点打开,右侧 ⚙ 管理 const active = state.taskId === t.task_id ? " active" : ""; const name = t.name || cfg.label + "对话"; const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))}`; html = `
${WECHAT_ICON} ${escapeHtml(name)} ${meta}
`; } return html; }).join(""); // 绑定事件:卡片整体点击 → selectTask(有 tid 时);右侧 cc-action 点击 → openWechatModal,阻止冒泡 box.querySelectorAll(".channel-card").forEach((el) => { const action = el.dataset.action; if (action === "bind" || action === "manage") { el.onclick = () => openWechatModal(); } else if (action === "select") { el.onclick = (e) => { if (e.target.closest(".cc-action")) { e.stopPropagation(); // ⚙ 点开弹框,不触发 selectTask openWechatModal(); } else { selectTask(el.dataset.tid); } }; } }); } // selectTask 切换时同步卡片高亮(卡片 task 不在 .task-row 列表里,需单独刷 active 态) function syncChannelCardActive(tid) { document.querySelectorAll("#channel-cards .channel-card").forEach((el) => { el.classList.toggle("active", el.dataset.tid === tid); }); } function taskMenuItems(t) { const isActive = t.status === "active"; const hasMsg = (t.n_messages || 0) > 0; // run_status 列表行与 taskMeta 都带(_task_dict 统一出);running/cancelling 时禁清空 const running = taskRunState(t) === "running" || taskRunState(t) === "cancelling"; 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: "clear", label: "清空对话", cls: "act-clear", disabled: !hasMsg || running, onclick: () => clearMessages(t.task_id, t.name || "(未命名)", t.n_messages || 0) }, { 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(); loadChannelCards(); }; // 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。 function applyTaskFiltersCollapsed(collapsed) { document.body.classList.toggle("task-filters-collapsed", collapsed); $("filter-toggle").textContent = collapsed ? "筛选 ▸" : "筛选 ▾"; } $("filter-toggle").onclick = () => { const next = !document.body.classList.contains("task-filters-collapsed"); localStorage.setItem(LS_TASK_FILTERS_COLLAPSED, next ? "1" : "0"); applyTaskFiltersCollapsed(next); }; // 默认折叠:只有用户显式展开过(存 "0")才展开 applyTaskFiltersCollapsed(localStorage.getItem(LS_TASK_FILTERS_COLLAPSED) !== "0"); // 搜索 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 ───── export 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(); clearAttachTray(); } state.taskId = tid; document.querySelectorAll(".task-row").forEach((el) => { el.classList.toggle("active", el.dataset.tid === tid); }); syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮 // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op) if (mqPhone.matches) setMobileView("mv-mid"); // 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await $("chat-stream").innerHTML = `
加载中…
`; renderTaskProgressDock([]); state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲 try { // meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。 // loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta; // 落在不同 DOM 区(chat-meta / chat-stream / outline-rail),谁先返回先渲染。 const [meta] = await Promise.all([ api("GET", "/v1/tasks/" + tid), loadMessages(), refreshOutline(), ]); state.taskMeta = meta; renderChatMeta(); applyChannelComposerLock(meta); if (meta.run_status === "running" || meta.run_status === "cancelling") { ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`, meta); } 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; } renderTaskProgressDock([]); $("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)} ${renderMediaModelTrigger()} `; const sel = $("chat-model-sel"); if (sel) sel.onchange = onChangeModel; // 生图/生视频 收进 ⚙ 弹层(低频),点开时再渲染 select 并接 onChange const mmBtn = $("media-model-btn"); if (mmBtn) mmBtn.onclick = (e) => { e.stopPropagation(); openMediaModelPop(mmBtn); }; const active = t.status === "active"; $("chat-form").style.display = active ? "flex" : "none"; syncOptimizeBtn(); $("btn-done").disabled = !active; // ⋯ 菜单:选中即可用;各项 enable/disable(完成/废弃按 status、清空按 run_status+n_messages) // 全在 taskMenuItems 内部判定,这里只管整体可用性。 $("btn-task-menu").disabled = false; } 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 `模型`; } // 生图/生视频 是低频情景操作 → 不在 meta 行常驻,收进一个 ⚙ 弹层。 // imageModels/videoModels 均为空(yaml 无 image/video variant)时连 ⚙ 都不画。 function renderMediaModelTrigger() { const hasImg = state.imageModels && state.imageModels.length > 0; const hasVid = state.videoModels && state.videoModels.length > 0; if (!hasImg && !hasVid) return ""; return ``; } // 弹层里一行 = 标签(icon + 文字) + select。沿用原 onChange,选中值仍只进 state.* 随下条消息发。 function mediaSelectRow(icon, label, id, list, cur, title) { const opts = list.map(m => `` ).join(""); return `
${icon} ${label}` + `
`; } function openMediaModelPop(btn) { const pop = $("media-model-pop"); let html = ""; if (state.imageModels && state.imageModels.length) html += mediaSelectRow("🖼", "生图", "chat-image-model-sel", state.imageModels, state.imageModel || "", "下一条消息触发生图时使用的模型(本地选择,不入库)"); if (state.videoModels && state.videoModels.length) html += mediaSelectRow("🎬", "生视频", "chat-video-model-sel", state.videoModels, state.videoModel || "", "下一条消息触发生视频时使用的模型(本地选择,不入库)"); pop.innerHTML = html; const imgSel = $("chat-image-model-sel"); if (imgSel) imgSel.onchange = onChangeImageModel; const vidSel = $("chat-video-model-sel"); if (vidSel) vidSel.onchange = onChangeVideoModel; // 定位:右上角对齐触发按钮,向下展开;下方空间不足则向上(同 showMenu 思路) const rect = btn.getBoundingClientRect(); pop.style.visibility = "hidden"; pop.classList.add("show"); const ph = pop.offsetHeight || 100; pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px"; pop.style.left = "auto"; pop.style.top = (rect.bottom + ph + 8 > window.innerHeight) ? Math.max(4, rect.top - ph - 4) + "px" : (rect.bottom + 4) + "px"; pop.style.visibility = ""; } function closeMediaModelPop() { $("media-model-pop").classList.remove("show"); } // 点弹层外 / resize / 滚动 → 关(选 select 选项的点击落在弹层内,不会误关) document.addEventListener("click", (e) => { if (e.target.closest("#media-model-btn") || e.target.closest("#media-model-pop")) return; closeMediaModelPop(); }, true); window.addEventListener("resize", closeMediaModelPop); document.addEventListener("scroll", closeMediaModelPop, true); 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 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}`; } } // 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。 // 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量, // 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。 const MSG_PAGE = 30; async function loadMessages() { const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`); state.loadedMessages = data.messages || []; state.msgHasMore = !!data.has_more; state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false state.msgLoadingEarlier = false; state.msgLoadingNewer = false; renderMessages(state.loadedMessages); } // 向上加载更早一批:取当前已加载窗口最早 idx 之前的 MSG_PAGE 条,prepend 进数组后 // 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改), // 重渲后把滚动位置锚回原处,视口不跳。 async function loadEarlierMessages() { if (state.msgLoadingEarlier || !state.msgHasMore) return; const msgs = state.loadedMessages; if (!msgs.length) return; const tid = state.taskId; const firstIdx = msgs[0].idx; state.msgLoadingEarlier = true; const wrap = $("chat-stream"); const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop; try { const data = await api( "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${firstIdx}`, ); if (state.taskId !== tid) return; // 加载途中切走了 task,丢弃结果 const earlier = data.messages || []; if (earlier.length) state.loadedMessages = earlier.concat(state.loadedMessages); state.msgHasMore = !!data.has_more; state.msgLoadingEarlier = false; renderMessages(state.loadedMessages); // 锚回:新增内容都在上方,保持原先可见的首条仍在原位 wrap.scrollTop = prevTop + (wrap.scrollHeight - prevH); } catch (e) { state.msgLoadingEarlier = false; if (e.status === 401) logout(); } } // 向下加载更新一批:从目录跳到旧消息后,窗口下方还有未加载的新消息。取当前窗口 // 最新 idx 之后的 MSG_PAGE 条,append 进数组重渲,滚动位置锚回原处(新增都在下方)。 async function loadNewerMessages() { if (state.msgLoadingNewer || !state.msgHasMoreNewer) return; const msgs = state.loadedMessages; if (!msgs.length) return; const tid = state.taskId; const lastIdx = msgs[msgs.length - 1].idx; state.msgLoadingNewer = true; const wrap = $("chat-stream"); const prevTop = wrap.scrollTop; try { const data = await api( "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&after_idx=${lastIdx}`, ); if (state.taskId !== tid) return; const newer = data.messages || []; if (newer.length) state.loadedMessages = state.loadedMessages.concat(newer); state.msgHasMoreNewer = !!data.has_more_after; state.msgLoadingNewer = false; renderMessages(state.loadedMessages); wrap.scrollTop = prevTop; // 新增在下方,保持原视口不跳 } catch (e) { state.msgLoadingNewer = false; if (e.status === 401) logout(); } } // 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 用 before_idx 拉一个 // 围绕目标的居中窗口(替换当前窗口)再定位。idx+11 让目标落窗口偏上、带点下文。 async function loadMessagesAround(idx) { const tid = state.taskId; const data = await api( "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${idx + 11}`, ); if (state.taskId !== tid) return false; state.loadedMessages = data.messages || []; state.msgHasMore = !!data.has_more; state.msgHasMoreNewer = !!data.has_more_after; state.msgLoadingEarlier = false; state.msgLoadingNewer = false; renderMessages(state.loadedMessages); return true; } async function jumpToMessage(idx) { const wrap = $("chat-stream"); let card = wrap.querySelector(`.msg[data-idx="${idx}"]`); if (!card) { let ok = false; try { ok = await loadMessagesAround(idx); } catch (e) { if (e.status === 401) { logout(); return; } } if (!ok) return; card = wrap.querySelector(`.msg[data-idx="${idx}"]`); } if (!card) return; // 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot // 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。 // .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。 setActiveOutlineIdx(idx); lockOutlineDuringJump(); // 锁住活跃圆点:平滑滚动途中的 scroll 事件不得把活跃态抢到途经轮次 card.scrollIntoView({ behavior: "smooth", block: "start" }); card.classList.add("msg-jump-flash"); setTimeout(() => card.classList.remove("msg-jump-flash"), 1200); } // 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。 // root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。 const _msgScrollObserver = new IntersectionObserver((entries) => { for (const en of entries) { if (!en.isIntersecting) continue; if (en.target.classList.contains("msg-top-sentinel")) { if (state.msgHasMore && !state.msgLoadingEarlier) loadEarlierMessages(); } else if (en.target.classList.contains("msg-bot-sentinel")) { if (state.msgHasMoreNewer && !state.msgLoadingNewer) loadNewerMessages(); } } }, { root: $("chat-stream"), rootMargin: "150px 0px" }); // ───── 消息目录(右侧悬浮圆点轨道)───── // 切 task / run 收尾后拉全部 user 轮次;点圆点 jumpToMessage 定位;滚动时高亮当前轮。 async function refreshOutline() { const tid = state.taskId; if (!tid) { state.outline = []; renderOutlineRail(); return; } try { const data = await api("GET", `/v1/tasks/${tid}/outline`); if (state.taskId !== tid) return; state.outline = data.items || []; } catch (e) { if (e.status === 401) { logout(); return; } state.outline = []; } renderOutlineRail(); } function renderOutlineRail() { const rail = $("msg-outline-rail"); if (!rail) return; // embed 等精简页无此元素 → no-op const items = state.outline || []; if (items.length < 2) { // 0/1 轮没必要显示目录 rail.style.display = "none"; rail.innerHTML = ""; return; } rail.style.display = ""; rail.innerHTML = items.map((it, i) => { const label = it.snippet || `第 ${i + 1} 轮`; return ``; }).join(""); updateActiveOutlineDot(); } function setActiveOutlineIdx(idx) { const rail = $("msg-outline-rail"); if (!rail) return; rail.querySelectorAll(".ol-dot").forEach((d) => { d.classList.toggle("active", Number(d.dataset.idx) === Number(idx)); }); } // 点圆点跳转期间锁定活跃态:平滑滚动会连发 scroll 事件,若不锁,updateActiveOutlineDot // 会按动画途中的位置反复改写,把刚点中的圆点抢成途经轮次(表现:点 #1 不变红 / 跳到 #2)。 let _outlineJumpLock = false; let _outlineJumpTimer = 0; function lockOutlineDuringJump() { _outlineJumpLock = true; clearTimeout(_outlineJumpTimer); // 平滑滚动一般 <500ms;700ms 兜底解锁后按落点重算一次(触底轮由下面 atBottom 分支兜) _outlineJumpTimer = setTimeout(() => { _outlineJumpLock = false; updateActiveOutlineDot(); }, 700); } // 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点 function updateActiveOutlineDot() { const rail = $("msg-outline-rail"); if (!rail || rail.style.display === "none") return; if (_outlineJumpLock) return; // 显式跳转动画期间不抢 const wrap = $("chat-stream"); const items = state.outline || []; // 触底兜底:最后几轮永远顶不到顶线(容器先到底),按原逻辑会一直停在倒数第二个 // (表现:点最后一个 / 滚到底时倒数第二个变红)。滚到容器底且无更新内容可加载时, // 直接判最后一个已加载轮为当前。 if (!state.msgHasMoreNewer && wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 2) { for (let i = items.length - 1; i >= 0; i--) { if (wrap.querySelector(`.msg[data-idx="${items[i].idx}"]`)) { setActiveOutlineIdx(items[i].idx); return; } } } const top = wrap.getBoundingClientRect().top; let activeIdx = null; for (const it of items) { const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`); if (!card) continue; // 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界 // 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。 if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx; else break; // outline 升序,首个落在视口下方的之后都更靠下 } if (activeIdx != null) setActiveOutlineIdx(activeIdx); } let _outlineRaf = 0; $("chat-stream").addEventListener("scroll", () => { if (_outlineRaf) return; _outlineRaf = requestAnimationFrame(() => { _outlineRaf = 0; updateActiveOutlineDot(); }); }); // embed 等精简页无 outline-rail 元素 → 跳过绑定(renderOutlineRail 已 null-safe) const _outlineRailEl = $("msg-outline-rail"); if (_outlineRailEl) { _outlineRailEl.addEventListener("click", (e) => { const dot = e.target.closest(".ol-dot"); if (dot) jumpToMessage(Number(dot.dataset.idx)); }); } function getLiveRun(taskId) { return taskId ? state.liveRuns.get(taskId) : null; } function isCurrentTaskStreaming() { return !!getLiveRun(state.taskId); } // 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字 // 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。 // 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。 function ensureTextSeg(run) { if (run.curSeg) return run.curSeg; const el = document.createElement("div"); el.className = "body streaming"; run.card.appendChild(el); run.curSeg = { el, acc: "", pending: false }; return run.curSeg; } // 关闭当前文字段:空占位段(还没吐字就来了工具)直接移除,避免留下「思考中」孤块; // 有内容的段落定稿(去光标 + 代码高亮),之后的新文字会另起新段。 function closeTextSeg(run) { const seg = run.curSeg; if (!seg) return; if (!seg.acc) { seg.el.remove(); } else { seg.el.classList.remove("streaming"); highlightIn(seg.el); } run.curSeg = null; } function createLiveAssistantCard(run) { const card = document.createElement("div"); card.className = "msg assistant live-run"; card.innerHTML = `
助手
`; run.card = card; run.curSeg = null; if (run.acc) { // 重连:已累积文字作为初始(仍打开的)文字段渲染,后续事件在其后穿插 const el = document.createElement("div"); el.className = "body streaming"; el.innerHTML = renderMd(run.acc); card.appendChild(el); run.curSeg = { el, acc: run.acc, pending: false }; } else { ensureTextSeg(run); // 空占位段:首字到达前显示「思考中」 } return card; } function renderLiveRunIfVisible() { const run = getLiveRun(state.taskId); if (!run) { setActionMode("idle"); return; } const wrap = $("chat-stream"); // card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲); // 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。 const card = run.card || createLiveAssistantCard(run); renderTaskProgressDock(run.progressSteps || []); if (card.parentElement !== wrap) wrap.appendChild(card); wrap.scrollTop = wrap.scrollHeight; setActionMode(run.cancelling ? "cancelling" : "streaming"); $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…"; } // seed = 该 task 的 API dict(taskMeta 或列表行),取 run_status/working_dir。 // 之前从全局 state.taskMeta 读 —— 只对"订阅选中 task"成立;现在列表也会给后台 // running task 挂订阅,workingDir 必须跟着各自 task 走(媒体产物 rel 解析用它)。 function ensureRunningTaskSubscribed(taskId, url, seed = {}) { if (!taskId || getLiveRun(taskId)) return; const run = { taskId, url, acc: "", seenRels: new Set(), terminal: false, card: null, curSeg: null, cancelling: seed.run_status === "cancelling", workingDir: seed.working_dir || "", progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), }; state.liveRuns.set(taskId, run); state.streaming = true; syncTaskRowRunIndicator(taskId); // 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰 // 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话) if (taskId === state.taskId) renderLiveRunIfVisible(); streamSse(url, run); } function setRunHint(run, text) { if (state.taskId === run.taskId) $("chat-hint").textContent = text; } // 进度只在对话区顶部的单一 dock 里渲染(codex 式钉顶面板),不再内联进每条消息卡。 // 进行中:展开实时显示 pending/in_progress/completed;全部完成:折叠成一行摘要,点开看清单。 function renderTaskProgressDock(steps) { const dock = $("task-progress-dock"); if (!dock) return; if (!Array.isArray(steps) || !steps.length) { dock.innerHTML = ""; dock.classList.remove("show"); return; } const total = steps.length; const done = steps.filter(s => s.status === "completed").length; const allDone = done === total; const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : ""); const rows = steps.map((s) => `
${escapeHtml(mark(s.status))} ${escapeHtml(s.title)}
`).join(""); const summary = allDone ? `✓ 全部完成 · ${done}/${total} 步` : `进度 · ${done}/${total} 步`; const openAttr = allDone ? "" : " open"; // 全完成默认折叠,其余展开 dock.innerHTML = `
${summary}
${rows}
`; dock.classList.add("show"); } function setTaskProgress(taskId, steps) { const normalized = cloneProgressSteps(steps); if (taskId) state.taskProgressByTask.set(taskId, normalized); if (state.taskId === taskId) renderTaskProgressDock(normalized); } function renderMessages(msgs) { const wrap = $("chat-stream"); _msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe wrap.innerHTML = ""; if (!msgs.length) { state.loadedMessages = []; state.msgHasMore = false; setTaskProgress(state.taskId, []); wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`; renderLiveRunIfVisible(); return; } // 还有更早 → 顶部放 sentinel,进视口自动加载(见 _msgScrollObserver) if (state.msgHasMore) { const sentinel = document.createElement("div"); sentinel.className = "msg-top-sentinel muted"; sentinel.textContent = state.msgLoadingEarlier ? "加载更早…" : "↑ 上滑加载更早"; wrap.appendChild(sentinel); _msgScrollObserver.observe(sentinel); } // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔 // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。 let lastAsstModel = null; // chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。 // chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。 const seenRels = new Set(); let currentProgressSteps = []; const pickFresh = (rels) => { const fresh = []; for (const r of rels) { if (seenRels.has(r)) continue; seenRels.add(r); fresh.push(r); } return fresh; }; for (let mi = 0; mi < msgs.length; mi++) { const m = msgs[mi]; 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") { if ((p.name || "") === "task_progress") continue; if ((p.name || "") === "ask_user") continue; // 占位结果不展示;选项卡在 assistant tool_call 里渲染 // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示) const card = document.createElement("div"); card.className = "msg tool"; card.dataset.idx = m.idx; 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; card.dataset.idx = m.idx; 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); const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps); currentProgressSteps = progressResult.steps; 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) || ""; } if (fn === "task_progress") { continue; } if (fn === "ask_user") { // 之后若已有 user 消息 → 这轮选择已答:卡置灰,命中项标「已选」;否则仍可点。 let answered = false, chosen = ""; for (let k = mi + 1; k < msgs.length; k++) { if (((msgs[k].payload || {}).role) === "user") { answered = true; chosen = (msgs[k].payload.content || ""); break; } } html += buildAskUserCard(argsObj, { interactive: !answered, chosenLabel: chosen }).outerHTML; continue; } 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)} `; } // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress) } card.innerHTML = html; highlightIn(card); wrap.appendChild(card); } // 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补 if (state.msgHasMoreNewer) { const sb = document.createElement("div"); sb.className = "msg-bot-sentinel muted"; sb.textContent = state.msgLoadingNewer ? "加载更新…" : "↓ 下滑加载更新"; wrap.appendChild(sb); _msgScrollObserver.observe(sb); } wrap.scrollTop = wrap.scrollHeight; setTaskProgress(state.taskId, currentProgressSteps); 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); // 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦, // 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认。 $("chat-input").addEventListener("paste", (e) => { const files = Array.from(e.clipboardData?.files || []); if (!files.length) return; e.preventDefault(); uploadAttachFiles(files); }); // 拖拽落点 = 整个 #chat-form(命中面积大);用 enter/leave 计数防子元素冒泡时高亮闪烁。 // 只认文件拖拽(_hasFiles),只读镜像(微信渠道)不接收。 let _composerDragDepth = 0; function _dragHasFiles(ev) { const t = ev.dataTransfer; return !!(t && t.types && [...t.types].includes("Files")); } function _composerLocked() { const input = $("chat-input"); return !!(input && input.readOnly); } $("chat-form").addEventListener("dragenter", (e) => { if (_composerLocked() || !_dragHasFiles(e)) return; e.preventDefault(); _composerDragDepth++; $("chat-form").classList.add("drag-over"); }); $("chat-form").addEventListener("dragover", (e) => { if (_composerLocked() || !_dragHasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }); $("chat-form").addEventListener("dragleave", (e) => { if (!_dragHasFiles(e)) return; _composerDragDepth = Math.max(0, _composerDragDepth - 1); if (_composerDragDepth === 0) $("chat-form").classList.remove("drag-over"); }); $("chat-form").addEventListener("drop", (e) => { if (_composerLocked() || !_dragHasFiles(e)) return; e.preventDefault(); _composerDragDepth = 0; $("chat-form").classList.remove("drag-over"); const files = Array.from(e.dataTransfer.files || []); if (files.length) uploadAttachFiles(files); }); // 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。 async function uploadAttachFiles(files) { if (!files || !files.length) return; const hint = $("chat-hint"); 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) { addAttachChips(saved); const n = attachCount(); hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 可在右侧文件处查看`; } // 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖) } function attachTray() { return $("chat-attach"); } function attachWraps() { const tray = attachTray(); return tray ? Array.from(tray.querySelectorAll(".paste-chip-wrap[data-rel]")) : []; } function attachCount() { return attachWraps().length; } // 切 task 时清掉上个 task 残留的未发送 chip(它们指向上个 task_dir,新 task 用不上)。 // 只清 DOM,不删已上传的文件(用户可能切回去发,文件还在原目录)。 function clearAttachTray() { const tray = attachTray(); if (!tray) return; tray.innerHTML = ""; tray.classList.remove("show"); } // 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。 function addAttachChips(saved) { const tray = attachTray(); if (!tray) return; const existing = new Set(attachWraps().map((w) => w.dataset.rel)); for (const f of saved || []) { const rel = f.rel || f.name || ""; if (!rel || existing.has(rel)) continue; existing.add(rel); const name = f.name || (rel.split("/").pop() || rel); tray.insertAdjacentHTML("beforeend", ``); } tray.classList.toggle("show", attachCount() > 0); } attachTray().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 }); closePreviewIfShowing(rel); wrap.remove(); await loadFiles(); const tray = attachTray(); if (tray) tray.classList.toggle("show", attachCount() > 0); if (attachCount() === 0) { $("chat-hint").innerHTML = `已删除文件`; } } catch (e) { if (btn) btn.disabled = false; if (e.status === 401) { logout(); return; } alert("删除失败:" + e.message); } } // 渠道镜像 task(微信 / 企业微信)在 web 端只读:这条对话的唯一交互入口锚定在对应 App —— // 微信侧 agent 回复必须带 context_token 才发得回,token 只能从用户入站消息拿(24h 过期), // 协议层没有无条件说话的能力;企业微信虽可主动推,但同样把交互权威收敛在 App 端,避免 // web/手机两路输入打架。故 web→渠道单向不同步,web 端做干净的只读镜像(单一交互权威 + // 可预测),想主动推走 wechat_push / 定时简报。渠道→web 仍同步(同一条 task)。 function applyChannelComposerLock(meta) { const input = $("chat-input"); if (!input) return; const cfg = meta && channelCfg(meta.channel); // 微信 / 企业微信镜像 → 只读 input.readOnly = !!cfg; input.classList.toggle("readonly-locked", !!cfg); input.placeholder = cfg ? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史` : "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"; if (cfg) { const opt = $("chat-optimize"); if (opt) opt.disabled = true; } } // 润色:同步调后端,把 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 内自带"下载")。 // 视频走原生