diff --git a/PROGRESS.md b/PROGRESS.md index 0ea3cb3..df18cd1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-07(前端模块化 Step 2:抽出 … / newtask / embed.js;main 1154 行) +最后更新:2026-06-08(前端模块化 Step 2 完成:抽出 chat.js;main 75 行,路径 1 收官) --- @@ -23,6 +23,7 @@ ### 2026-06-06 +- **前端模块化 Step 2 收官:抽出 `chat.js`(对话视图)+ main.js 缩成 75 行入口**:最后也是最缠的一块——任务列表(浏览/筛选/滚动)+ selectTask 切换 + renderChatMeta/模型下拉 + renderMessages + live-run 助手 + sendMessage/cancel + fetchSse/handleSseEvent + 润色/粘贴文件 + 完成/废弃/删除/导出/清空(原 main.js 连续区 64–1132)→ `chat.js`(1086 行)。**决策:合一个 chat.js 而非强拆 tasks.js+stream.js**——读完依赖图确认二者共享 `state.liveRuns` + `chat-stream` DOM + run 生命周期,且 live-run 助手(renderLiveRunIfVisible/ensureRunningTaskSubscribed 等)被 selectTask 和 SSE 机器两边调用、骑墙;强拆会制造双向各 ~4-5 个 import 且边界不自然(用户已确认选合一)。导出 `loadTaskList`/`loadModels`/`selectTask`,embed/files/newtask 对这三个的 import 从 `./main.js` 改指 `./chat.js`;`formatUploadProgress` 加 export(粘贴上传进度用)。**chat 不调 enterApp → 与 main 无环**。`main.js` 仅留 `enterApp`(编排)+ `loadStorage` + Esc 关栈 + boot = **75 行入口**,import 精简到 11 行(layout/markdown/media 不再被 main 直接引用,但经 chat 仍在依赖图、副作用照常)。**校验升级**:除 node 全检 + import/export 一致性,新增**从 main BFS 的模块可达性检查**(14/14 可达,确保副作用模块不掉出图)。dev.html 4087 行单文件 → 14 个零构建 ES module + 纯 HTML;main 2719→75。**路径 1(拆文件)完成**,后续可按需进路径 2(给 chat/files 等局部引 Alpine/petite-vue 响应式)。 - **前端模块化 Step 2:抽出 `embed.js`(iframe 模式)**:父页面经 postMessage 推 token 进入应用 + 401 重签(原 main.js 1147–1209 + 顶层 `_embedInitialTaskHandled` 一次性标志)→ `embed.js`(75 行)。导出 `embedInit`(boot 调)+ `embedPostToParent`/`embedShowWaiting`(auth 的 logout 在 embed 下通知父页面/显示等待态)——后两个从 main 迁出后,`auth.js` 对它们的 import 从 `./main.js` 改指 `./embed.js`(auth 仍从 main import enterApp)。反向 import main glue `enterApp`/`loadTaskList`/`selectTask`。main↔embed、auth↔embed 均运行时调用环,安全。main.js 删至 **1154 行**(2719 行起,已搬出约 58%)。node 全检过、import/export 一致性过、静态测试 2 过。剩 main 内:`enterApp` glue + tasks(列表/选择/渲染消息)+ stream(发送/SSE)+ boot + Esc 关栈,待最后一并处理 tasks+stream。 - **前端模块化 Step 2:抽出 `newtask.js`(新建任务弹框)**:任务名 / 工作目录(新建 sentinel 或复用已有 + 二级 input 联动)/ 描述 / skill / 模型 select,提交 `POST /v1/tasks`(原 main.js 1146–1320)→ `newtask.js`(186 行)。顶层自绑 hd-new 打开 / nt-go 提交 / 各 input 联动;唯一对外导出 `loadFolderSuggestions`(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷目录)——它从 main 迁来后,`files.js` 对它的 import 从 `./main.js` 改指 `./newtask.js`。反向 import main glue `loadModels`(加 `export`)/`loadTaskList`/`selectTask` + `logout`(auth)。main.js 删至 1220 行。node 全检过、import/export 一致性校验过、私有符号清零。 - **前端模块化 Step 2:抽出 `media.js`(工具活动标签 + artifact 抽取/渲染)+ 收敛 downloadFile 反向依赖**:对话内 `toolActivityLabel`(工具调用→中文活动名)、`extractArtifactRels`(从结果文本/working_dir 提产物路径)、`extractMediaBanner`(seedream/seedance 横幅)、`renderArtifactBarHtml`(产物 chip 条 + 图/视频内联占位)、`upgradeMediaArtifacts`(占位异步 fetch blob 填 ``/`` 带缓存)、`downloadFile`(blob 下载)→ `media.js`(237 行,原 main.js 1134–1359)。**收敛点**:downloadFile 移入 media 后,`preview.js`/`files.js` 对它的 import 从 `./main.js` 改指 `./media.js` —— 把这条反向依赖从 main 挪开。media 导入极少(`escapeHtml`/`_categorize`(preview)/`state`/`logout`),与 preview 成 media↔preview 环(均运行时调用,安全)。**两次险漏靠校验抓回**:① 共享 const `ARTIFACT_PRODUCING_TOOLS`(main renderMessages/SSE 用 4 处,`.has()` 访问非函数调用,"被调标识符"法漏掉)② 内部函数 `_flushMediaArtifactCache`(selectTask 切任务清缓存用)—— 残留符号检查发现后补 export。新增**全模块 import/export 一致性校验脚本**(每个 `import{X}` 必在目标 `export`),11 模块全过。main.js 删至 1393 行。`node --check` 11 模块全过、静态测试 2 过。 diff --git a/web/static/js/chat.js b/web/static/js/chat.js new file mode 100644 index 0000000..635cc57 --- /dev/null +++ b/web/static/js/chat.js @@ -0,0 +1,1086 @@ +// 对话视图(任务列表 + 选择/渲染消息 + 发送/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 } 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"; +import { logout } from "./auth.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"; + +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(); + } 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 ───── +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(); + 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 => + `${escapeHtml(m.display_name)}` + ).join(""); + return `模型💬${opts}`; +} + +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 => + `${escapeHtml(m.display_name)}` + ).join(""); + return `生图🖼${opts}`; +} + +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 => + `${escapeHtml(m.display_name)}` + ).join(""); + return `生视频🎬${opts}`; +} + +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 `${escapeHtml(name)}×`; + }).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 }); + closePreviewIfShowing(rel); + 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 内自带"下载")。 +// 视频走原生 :点击=播放/暂停,全屏走浏览器自带按钮,不进 modal — +// 弹个 modal 反而打断播放,不如交给浏览器。 +$("chat-stream").addEventListener("click", (e) => { + const chip = e.target.closest && e.target.closest(".art-chip"); + if (chip) { + const rel = chip.dataset.rel; + if (rel) openFilePreview(rel); + return; + } + const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); + if (inlineImg) { + const rel = inlineImg.dataset.rel; + if (rel) openFilePreview(rel); + } +}); + +async function sendMessage() { + if (!state.taskId) return; + if (isCurrentTaskStreaming()) return; + const content = $("chat-input").value.trim(); + if (!content) return; + setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming + $("chat-hint").textContent = "发送中…"; + const taskId = state.taskId; + try { + // 立刻渲染 user 消息卡(乐观) + const wrap = $("chat-stream"); + const userCard = document.createElement("div"); + userCard.className = "msg user"; + userCard.innerHTML = `我${escapeHtml(content)}`; + wrap.appendChild(userCard); + + // assistant 流式占位卡 + const asstCard = document.createElement("div"); + asstCard.className = "msg assistant live-run"; + asstCard.innerHTML = `助手`; + wrap.appendChild(asstCard); + wrap.scrollTop = wrap.scrollHeight; + + const r = await api("POST", `/v1/tasks/${taskId}/messages`, { + content, + image_model: state.imageModel || "", + video_model: state.videoModel || "", + }); + $("chat-input").value = ""; + syncOptimizeBtn(); + const run = { + taskId, + url: r.events_url, + acc: "", + pending: false, + seenRels: new Set(), + terminal: false, + card: asstCard, + body: asstCard.querySelector(".body"), + cancelling: false, + workingDir: state.taskMeta && state.taskMeta.working_dir, + }; + state.liveRuns.set(taskId, run); + state.streaming = true; + setActionMode("streaming"); + streamSse(r.events_url, run); + } catch (e) { + if (e.status === 401) { logout(); return; } + appendErrorCard(e.message); + setActionMode("idle"); + $("chat-hint").textContent = "就绪"; + } +} + +async function cancelCurrentTask() { + const run = getLiveRun(state.taskId); + if (!state.taskId || !run) return; + run.cancelling = true; + setActionMode("cancelling"); + $("chat-hint").textContent = "停止中…"; + try { + await api("POST", `/v1/tasks/${state.taskId}/cancel`); + // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清 + } catch (e) { + if (e.status === 401) { logout(); return; } + // 409 = 已结束 / 已 cancelling,不算错;其他贴 toast + if (e.status !== 409) appendErrorCard("cancel: " + e.message); + run.cancelling = false; + setActionMode("streaming"); + $("chat-hint").textContent = "接收中…"; + } +} + +function streamSse(url, run) { + // EventSource 不支持自定义 header,token 走 query string(?token=...) + // 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头 + // 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource + fetchSse(url, run).catch((e) => { + appendRunError(run, "sse: " + e.message); + }); +} + +function appendRunError(run, msg) { + if (state.taskId === run.taskId) { + appendErrorCard(msg); + return; + } + const host = run.card || createLiveAssistantCard(run); + const card = document.createElement("div"); + card.className = "msg error"; + card.innerHTML = `错误${escapeHtml(msg)}`; + host.appendChild(card); +} + +async function fetchSse(url, run) { + const body = run.body || (run.card && run.card.querySelector(".body")); + run.body = body; + const ctx = run; + const hint = $("chat-hint"); + // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。 + // 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流; + // 这里 3 次失败再放弃,覆盖 systemctl restart 的 1~2s 抖动 + reaper 跑完的窗口。 + const backoffs = [1000, 2000, 4000]; + let attempt = 0; + try { + while (true) { + try { + await consumeSseStream(url, run.card, ctx); + } catch (e) { + if (ctx.terminal) break; // 已收到 done/error,不重连 + if (attempt >= backoffs.length) { + appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。"); + break; + } + setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`); + await new Promise(r => setTimeout(r, backoffs[attempt])); + attempt++; + continue; + } + // consumeSseStream 正常返回:reader 收到 EOF + if (ctx.terminal) break; // 正常收尾(看到 done/error) + // 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连 + if (attempt >= backoffs.length) { + appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。"); + break; + } + setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`); + await new Promise(r => setTimeout(r, backoffs[attempt])); + attempt++; + } + // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU) + if (ctx.body) { + ctx.body.innerHTML = renderMd(ctx.acc); + if (ctx.card) highlightIn(ctx.card); + } + } finally { + if (ctx.body) ctx.body.classList.remove("streaming"); + state.liveRuns.delete(ctx.taskId); + state.streaming = state.liveRuns.size > 0; + if (state.taskId === ctx.taskId) { + hint.textContent = ctx.lastUsageHint || "就绪"; + setActionMode("idle"); + } + } + // 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑 + loadTaskList(); + if (state.taskId === ctx.taskId) { + await loadMessages(); + loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物) + refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了) + } +} + +async function consumeSseStream(url, asstCard, ctx) { + const r = await fetch(url, { + headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" }, + }); + if (!r.ok) throw new Error(r.status + " " + r.statusText); + const reader = r.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + setRunHint(ctx, "接收中…"); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buf += dec.decode(value, { stream: true }); + while (true) { + const idx = buf.indexOf("\n\n"); + if (idx < 0) break; + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const ev = parseSseFrame(frame); + if (!ev) continue; + handleSseEvent(ev, asstCard, ctx); + if (ev.event === "done" || ev.event === "error") { + ctx.terminal = true; + return; + } + } + } +} + +function parseSseFrame(frame) { + const lines = frame.split("\n"); + let event = "msg"; let dataLines = []; + for (const ln of lines) { + if (ln.startsWith(":")) continue; // comment + if (ln.startsWith("event:")) event = ln.slice(6).trim(); + else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, "")); + } + if (!dataLines.length) return { event, data: null }; + const raw = dataLines.join("\n"); + let data = null; + try { data = JSON.parse(raw); } catch (e) { data = raw; } + return { event, data }; +} + +function handleSseEvent(ev, asstCard, ctx) { + const t = ev.event; + asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx); + ctx.card = asstCard; + ctx.body = ctx.body || asstCard.querySelector(".body"); + const stream = $("chat-stream"); + const visible = state.taskId === ctx.taskId; + // 用户拖到上面看历史时不抢滚动,只在贴底时跟流 + const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120); + if (t === "llm_start") { + ctx.contextStats = ev.data || {}; + setRunHint(ctx, formatContextStats(ctx.contextStats)); + } else if (t === "llm_end") { + ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats); + setRunHint(ctx, ctx.lastUsageHint); + } else if (t === "text" && ev.data && ev.data.delta) { + ctx.acc += ev.data.delta; + // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖 + if (!ctx.pending) { + ctx.pending = true; + requestAnimationFrame(() => { + ctx.body.innerHTML = renderMd(ctx.acc); + ctx.pending = false; + if (nearBottom) stream.scrollTop = stream.scrollHeight; + }); + } + } else if (t === "tool_call") { + const fn = (ev.data && ev.data.name) || "?"; + const args = (ev.data && ev.data.args) || ""; + const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2); + const label = toolActivityLabel(fn, args); + const det = document.createElement("details"); + det.className = "tool-call"; + det.innerHTML = `${escapeHtml(label)}${escapeHtml(argsStr)}`; + asstCard.appendChild(det); + const wd = _workingDirName(ctx.workingDir); + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); + const fresh = isProducer + ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; + fresh.forEach(r => ctx.seenRels.add(r)); + const barHtml = renderArtifactBarHtml(fresh, isProducer); + if (barHtml) { + asstCard.insertAdjacentHTML("beforeend", barHtml); + if (isProducer) upgradeMediaArtifacts(asstCard); + } + } else if (t === "tool_result") { + const txt = (ev.data && ev.data.result) || ""; + const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); + const toolName = (ev.data && ev.data.name) || ""; + const banner = extractMediaBanner(toolName, txtStr); + const det = document.createElement("details"); + det.className = "tool-call"; + det.innerHTML = `工具结果${banner}${escapeHtml(txtStr)}`; + asstCard.appendChild(det); + const wd = _workingDirName(ctx.workingDir); + const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName); + const fresh = isProducer + ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) + : []; + fresh.forEach(r => ctx.seenRels.add(r)); + const barHtml = renderArtifactBarHtml(fresh, isProducer); + if (barHtml) { + asstCard.insertAdjacentHTML("beforeend", barHtml); + if (isProducer) upgradeMediaArtifacts(asstCard); + } + if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 + } else if (t === "cancelled") { + const badge = document.createElement("div"); + badge.className = "cancelled-badge"; + badge.textContent = "已停止"; + asstCard.appendChild(badge); + } else if (t === "error") { + const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data); + appendRunError(ctx, msg); + } + if (nearBottom) stream.scrollTop = stream.scrollHeight; +} + +function appendErrorCard(msg) { + const card = document.createElement("div"); + card.className = "msg error"; + card.innerHTML = `错误${escapeHtml(msg)}`; + $("chat-stream").appendChild(card); + $("chat-stream").scrollTop = $("chat-stream").scrollHeight; +} + +// ───── done / abandon / delete / export ───── +$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || ""); +$("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || ""); +$("btn-delete-task").onclick = () => { + if (!state.taskId) return; + const t = state.taskMeta || {}; + deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0); +}; +$("btn-export").onclick = () => state.taskId && exportTask(state.taskId); +$("btn-clear-msgs").onclick = () => { + if (!state.taskId) return; + const t = state.taskMeta || {}; + clearMessages(state.taskId, t.name || "(未命名)", t.n_messages || 0); +}; + +async function clearMessages(tid, name, nMsg) { + if (!confirm(`确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`)) return; + try { + const updated = await api("POST", "/v1/tasks/" + tid + "/clear"); + if (state.taskId === tid) { + state.taskMeta = updated; + renderChatMeta(); + renderMessages([]); + $("chat-hint").textContent = "对话已清空"; + } + loadTaskList(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("清空失败:" + e.message); + } +} + +async function setTaskStatus(tid, status, name) { + const labels = { completed: "已完成", abandoned: "已废弃" }; + if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return; + try { + await api("PATCH", "/v1/tasks/" + tid, { status }); + if (state.taskId === tid) await selectTask(tid); + loadTaskList(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("操作失败:" + e.message); + } +} + +async function deleteTask(tid, name, nMsg) { + if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return; + try { + await api("DELETE", "/v1/tasks/" + tid); + if (state.taskId === tid) { + if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } + state.taskId = null; + state.taskMeta = null; + state.concurrentWarnings = []; + renderConcurrentWarning(); + $("chat-meta").innerHTML = `(未选中任务)`; + $("chat-stream").innerHTML = `请在左侧选一个任务`; + $("chat-form").style.display = "none"; + $("btn-done").disabled = true; + $("btn-abandon").disabled = true; + $("btn-delete-task").disabled = true; + $("btn-export").disabled = true; + $("btn-clear-msgs").disabled = true; + } + loadTaskList(); + loadFiles(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("删除失败:" + e.message); + } +} + +function exportTask(tid) { + fetch("/v1/tasks/" + tid + "/export", { + headers: { "Authorization": "Bearer " + state.token }, + }).then(async (r) => { + if (!r.ok) { alert("导出失败:" + r.status); return; } + const blob = await r.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "chat_" + tid.slice(0, 8) + ".docx"; + document.body.appendChild(a); a.click(); + setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + }); +} diff --git a/web/static/js/embed.js b/web/static/js/embed.js index 85358a9..46433f6 100644 --- a/web/static/js/embed.js +++ b/web/static/js/embed.js @@ -3,7 +3,8 @@ // embedPostToParent / embedShowWaiting(auth 的 logout 在 embed 下通知父页面/显示等待态)。 import { state, LS_TOKEN, LS_UID, LS_NAME, EMBED_PARENT_ORIGIN, EMBED_INITIAL_TASK_ID } from "./state.js"; import { $ } from "./dom.js"; -import { enterApp, loadTaskList, selectTask } from "./main.js"; +import { enterApp } from "./main.js"; +import { loadTaskList, selectTask } from "./chat.js"; // embed 首个 task 自动定位的一次性标志(仅 embed 段使用) let _embedInitialTaskHandled = false; diff --git a/web/static/js/files.js b/web/static/js/files.js index 5b4f5c0..cf3cd0f 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -11,7 +11,7 @@ import { escapeHtml, humanSize } from "./format.js"; import { openFilePreview } from "./preview.js"; import { logout } from "./auth.js"; import { downloadFile } from "./media.js"; -import { selectTask, loadTaskList } from "./main.js"; +import { selectTask, loadTaskList } from "./chat.js"; import { loadFolderSuggestions } from "./newtask.js"; // ───── files(user-rooted,不绑 task) ───── @@ -349,7 +349,7 @@ function uploadFilesLabel(files) { if (!files || !files.length) return ""; return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`; } -function formatUploadProgress(files, loaded, total) { +export function formatUploadProgress(files, loaded, total) { const denom = total || uploadTotalBytes(files); const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : ""; diff --git a/web/static/js/main.js b/web/static/js/main.js index 5c37c65..0fe67e2 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,25 +1,16 @@ -// 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"; +// zcbot dev 控制台入口 + 编排:enterApp(应用初始化)、loadStorage(存储用量)、 +// Esc 关弹窗栈、boot。功能逻辑全在各模块(chat/files/preview/media/auth/newtask/embed/layout/…), +// 经各模块顶层 import 拉入依赖图(layout/markdown/media 由 chat 等引入,副作用照常)。 +import { state, EMBED } from "./state.js"; +import { humanSize, fmtTime } from "./format.js"; +import { $ } 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"; -import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js"; -import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js"; -import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js"; +import { closeChpwModal } from "./auth.js"; +import { closeFilePreview, closeMiniPreview } from "./preview.js"; +import { closeSrcPicker, loadFiles } from "./files.js"; import { loadFolderSuggestions } from "./newtask.js"; import { embedInit } from "./embed.js"; +import { loadTaskList, loadModels } from "./chat.js"; // ───── enter app ───── export function enterApp() { @@ -61,1075 +52,6 @@ async function loadStorage() { el.classList.add("show"); } -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(); - } 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 ───── -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(); - 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 => - `${escapeHtml(m.display_name)}` - ).join(""); - return `模型💬${opts}`; -} - -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 => - `${escapeHtml(m.display_name)}` - ).join(""); - return `生图🖼${opts}`; -} - -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 => - `${escapeHtml(m.display_name)}` - ).join(""); - return `生视频🎬${opts}`; -} - -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 `${escapeHtml(name)}×`; - }).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 }); - closePreviewIfShowing(rel); - 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 内自带"下载")。 -// 视频走原生 :点击=播放/暂停,全屏走浏览器自带按钮,不进 modal — -// 弹个 modal 反而打断播放,不如交给浏览器。 -$("chat-stream").addEventListener("click", (e) => { - const chip = e.target.closest && e.target.closest(".art-chip"); - if (chip) { - const rel = chip.dataset.rel; - if (rel) openFilePreview(rel); - return; - } - const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); - if (inlineImg) { - const rel = inlineImg.dataset.rel; - if (rel) openFilePreview(rel); - } -}); - -async function sendMessage() { - if (!state.taskId) return; - if (isCurrentTaskStreaming()) return; - const content = $("chat-input").value.trim(); - if (!content) return; - setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming - $("chat-hint").textContent = "发送中…"; - const taskId = state.taskId; - try { - // 立刻渲染 user 消息卡(乐观) - const wrap = $("chat-stream"); - const userCard = document.createElement("div"); - userCard.className = "msg user"; - userCard.innerHTML = `我${escapeHtml(content)}`; - wrap.appendChild(userCard); - - // assistant 流式占位卡 - const asstCard = document.createElement("div"); - asstCard.className = "msg assistant live-run"; - asstCard.innerHTML = `助手`; - wrap.appendChild(asstCard); - wrap.scrollTop = wrap.scrollHeight; - - const r = await api("POST", `/v1/tasks/${taskId}/messages`, { - content, - image_model: state.imageModel || "", - video_model: state.videoModel || "", - }); - $("chat-input").value = ""; - syncOptimizeBtn(); - const run = { - taskId, - url: r.events_url, - acc: "", - pending: false, - seenRels: new Set(), - terminal: false, - card: asstCard, - body: asstCard.querySelector(".body"), - cancelling: false, - workingDir: state.taskMeta && state.taskMeta.working_dir, - }; - state.liveRuns.set(taskId, run); - state.streaming = true; - setActionMode("streaming"); - streamSse(r.events_url, run); - } catch (e) { - if (e.status === 401) { logout(); return; } - appendErrorCard(e.message); - setActionMode("idle"); - $("chat-hint").textContent = "就绪"; - } -} - -async function cancelCurrentTask() { - const run = getLiveRun(state.taskId); - if (!state.taskId || !run) return; - run.cancelling = true; - setActionMode("cancelling"); - $("chat-hint").textContent = "停止中…"; - try { - await api("POST", `/v1/tasks/${state.taskId}/cancel`); - // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清 - } catch (e) { - if (e.status === 401) { logout(); return; } - // 409 = 已结束 / 已 cancelling,不算错;其他贴 toast - if (e.status !== 409) appendErrorCard("cancel: " + e.message); - run.cancelling = false; - setActionMode("streaming"); - $("chat-hint").textContent = "接收中…"; - } -} - -function streamSse(url, run) { - // EventSource 不支持自定义 header,token 走 query string(?token=...) - // 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头 - // 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource - fetchSse(url, run).catch((e) => { - appendRunError(run, "sse: " + e.message); - }); -} - -function appendRunError(run, msg) { - if (state.taskId === run.taskId) { - appendErrorCard(msg); - return; - } - const host = run.card || createLiveAssistantCard(run); - const card = document.createElement("div"); - card.className = "msg error"; - card.innerHTML = `错误${escapeHtml(msg)}`; - host.appendChild(card); -} - -async function fetchSse(url, run) { - const body = run.body || (run.card && run.card.querySelector(".body")); - run.body = body; - const ctx = run; - const hint = $("chat-hint"); - // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。 - // 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流; - // 这里 3 次失败再放弃,覆盖 systemctl restart 的 1~2s 抖动 + reaper 跑完的窗口。 - const backoffs = [1000, 2000, 4000]; - let attempt = 0; - try { - while (true) { - try { - await consumeSseStream(url, run.card, ctx); - } catch (e) { - if (ctx.terminal) break; // 已收到 done/error,不重连 - if (attempt >= backoffs.length) { - appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。"); - break; - } - setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`); - await new Promise(r => setTimeout(r, backoffs[attempt])); - attempt++; - continue; - } - // consumeSseStream 正常返回:reader 收到 EOF - if (ctx.terminal) break; // 正常收尾(看到 done/error) - // 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连 - if (attempt >= backoffs.length) { - appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。"); - break; - } - setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`); - await new Promise(r => setTimeout(r, backoffs[attempt])); - attempt++; - } - // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU) - if (ctx.body) { - ctx.body.innerHTML = renderMd(ctx.acc); - if (ctx.card) highlightIn(ctx.card); - } - } finally { - if (ctx.body) ctx.body.classList.remove("streaming"); - state.liveRuns.delete(ctx.taskId); - state.streaming = state.liveRuns.size > 0; - if (state.taskId === ctx.taskId) { - hint.textContent = ctx.lastUsageHint || "就绪"; - setActionMode("idle"); - } - } - // 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑 - loadTaskList(); - if (state.taskId === ctx.taskId) { - await loadMessages(); - loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物) - refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了) - } -} - -async function consumeSseStream(url, asstCard, ctx) { - const r = await fetch(url, { - headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" }, - }); - if (!r.ok) throw new Error(r.status + " " + r.statusText); - const reader = r.body.getReader(); - const dec = new TextDecoder(); - let buf = ""; - setRunHint(ctx, "接收中…"); - while (true) { - const { value, done } = await reader.read(); - if (done) return; - buf += dec.decode(value, { stream: true }); - while (true) { - const idx = buf.indexOf("\n\n"); - if (idx < 0) break; - const frame = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const ev = parseSseFrame(frame); - if (!ev) continue; - handleSseEvent(ev, asstCard, ctx); - if (ev.event === "done" || ev.event === "error") { - ctx.terminal = true; - return; - } - } - } -} - -function parseSseFrame(frame) { - const lines = frame.split("\n"); - let event = "msg"; let dataLines = []; - for (const ln of lines) { - if (ln.startsWith(":")) continue; // comment - if (ln.startsWith("event:")) event = ln.slice(6).trim(); - else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, "")); - } - if (!dataLines.length) return { event, data: null }; - const raw = dataLines.join("\n"); - let data = null; - try { data = JSON.parse(raw); } catch (e) { data = raw; } - return { event, data }; -} - -function handleSseEvent(ev, asstCard, ctx) { - const t = ev.event; - asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx); - ctx.card = asstCard; - ctx.body = ctx.body || asstCard.querySelector(".body"); - const stream = $("chat-stream"); - const visible = state.taskId === ctx.taskId; - // 用户拖到上面看历史时不抢滚动,只在贴底时跟流 - const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120); - if (t === "llm_start") { - ctx.contextStats = ev.data || {}; - setRunHint(ctx, formatContextStats(ctx.contextStats)); - } else if (t === "llm_end") { - ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats); - setRunHint(ctx, ctx.lastUsageHint); - } else if (t === "text" && ev.data && ev.data.delta) { - ctx.acc += ev.data.delta; - // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖 - if (!ctx.pending) { - ctx.pending = true; - requestAnimationFrame(() => { - ctx.body.innerHTML = renderMd(ctx.acc); - ctx.pending = false; - if (nearBottom) stream.scrollTop = stream.scrollHeight; - }); - } - } else if (t === "tool_call") { - const fn = (ev.data && ev.data.name) || "?"; - const args = (ev.data && ev.data.args) || ""; - const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2); - const label = toolActivityLabel(fn, args); - const det = document.createElement("details"); - det.className = "tool-call"; - det.innerHTML = `${escapeHtml(label)}${escapeHtml(argsStr)}`; - asstCard.appendChild(det); - const wd = _workingDirName(ctx.workingDir); - const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn); - const fresh = isProducer - ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r)) - : []; - fresh.forEach(r => ctx.seenRels.add(r)); - const barHtml = renderArtifactBarHtml(fresh, isProducer); - if (barHtml) { - asstCard.insertAdjacentHTML("beforeend", barHtml); - if (isProducer) upgradeMediaArtifacts(asstCard); - } - } else if (t === "tool_result") { - const txt = (ev.data && ev.data.result) || ""; - const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2); - const toolName = (ev.data && ev.data.name) || ""; - const banner = extractMediaBanner(toolName, txtStr); - const det = document.createElement("details"); - det.className = "tool-call"; - det.innerHTML = `工具结果${banner}${escapeHtml(txtStr)}`; - asstCard.appendChild(det); - const wd = _workingDirName(ctx.workingDir); - const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName); - const fresh = isProducer - ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r)) - : []; - fresh.forEach(r => ctx.seenRels.add(r)); - const barHtml = renderArtifactBarHtml(fresh, isProducer); - if (barHtml) { - asstCard.insertAdjacentHTML("beforeend", barHtml); - if (isProducer) upgradeMediaArtifacts(asstCard); - } - if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 - } else if (t === "cancelled") { - const badge = document.createElement("div"); - badge.className = "cancelled-badge"; - badge.textContent = "已停止"; - asstCard.appendChild(badge); - } else if (t === "error") { - const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data); - appendRunError(ctx, msg); - } - if (nearBottom) stream.scrollTop = stream.scrollHeight; -} - -function appendErrorCard(msg) { - const card = document.createElement("div"); - card.className = "msg error"; - card.innerHTML = `错误${escapeHtml(msg)}`; - $("chat-stream").appendChild(card); - $("chat-stream").scrollTop = $("chat-stream").scrollHeight; -} - -// ───── done / abandon / delete / export ───── -$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || ""); -$("btn-abandon").onclick = () => state.taskId && setTaskStatus(state.taskId, "abandoned", (state.taskMeta && state.taskMeta.name) || ""); -$("btn-delete-task").onclick = () => { - if (!state.taskId) return; - const t = state.taskMeta || {}; - deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0); -}; -$("btn-export").onclick = () => state.taskId && exportTask(state.taskId); -$("btn-clear-msgs").onclick = () => { - if (!state.taskId) return; - const t = state.taskMeta || {}; - clearMessages(state.taskId, t.name || "(未命名)", t.n_messages || 0); -}; - -async function clearMessages(tid, name, nMsg) { - if (!confirm(`确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`)) return; - try { - const updated = await api("POST", "/v1/tasks/" + tid + "/clear"); - if (state.taskId === tid) { - state.taskMeta = updated; - renderChatMeta(); - renderMessages([]); - $("chat-hint").textContent = "对话已清空"; - } - loadTaskList(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert("清空失败:" + e.message); - } -} - -async function setTaskStatus(tid, status, name) { - const labels = { completed: "已完成", abandoned: "已废弃" }; - if (!confirm(`确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`)) return; - try { - await api("PATCH", "/v1/tasks/" + tid, { status }); - if (state.taskId === tid) await selectTask(tid); - loadTaskList(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert("操作失败:" + e.message); - } -} - -async function deleteTask(tid, name, nMsg) { - if (!confirm(`确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return; - try { - await api("DELETE", "/v1/tasks/" + tid); - if (state.taskId === tid) { - if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } - state.taskId = null; - state.taskMeta = null; - state.concurrentWarnings = []; - renderConcurrentWarning(); - $("chat-meta").innerHTML = `(未选中任务)`; - $("chat-stream").innerHTML = `请在左侧选一个任务`; - $("chat-form").style.display = "none"; - $("btn-done").disabled = true; - $("btn-abandon").disabled = true; - $("btn-delete-task").disabled = true; - $("btn-export").disabled = true; - $("btn-clear-msgs").disabled = true; - } - loadTaskList(); - loadFiles(); - } catch (e) { - if (e.status === 401) { logout(); return; } - alert("删除失败:" + e.message); - } -} - -function exportTask(tid) { - fetch("/v1/tasks/" + tid + "/export", { - headers: { "Authorization": "Bearer " + state.token }, - }).then(async (r) => { - if (!r.ok) { alert("导出失败:" + r.status); return; } - const blob = await r.blob(); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = "chat_" + tid.slice(0, 8) + ".docx"; - document.body.appendChild(a); a.click(); - setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); - }); -} // ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)───── document.addEventListener("keydown", (e) => { diff --git a/web/static/js/newtask.js b/web/static/js/newtask.js index 00e580e..940f81c 100644 --- a/web/static/js/newtask.js +++ b/web/static/js/newtask.js @@ -6,7 +6,7 @@ import { state } from "./state.js"; import { api } from "./api.js"; import { escapeHtml } from "./format.js"; import { logout } from "./auth.js"; -import { loadModels, loadTaskList, selectTask } from "./main.js"; +import { loadModels, loadTaskList, selectTask } from "./chat.js"; // ───── new task ───── // wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
${escapeHtml(txt || "")}
${escapeHtml(args)}
${escapeHtml(argsStr)}
${escapeHtml(txtStr)}