// 对话视图(任务列表 + 选择/渲染消息 + 发送/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";
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";
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();
} 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;
// run_status 仅 taskMeta(中栏 ⋯)带;列表行摘要无此字段 → undefined → running=false(与改前一致)
const running = t.run_status === "running" || t.run_status === "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();
// 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 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();
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");
// 立即清空 + 显示加载占位:切 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();
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; }
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;
card.scrollIntoView({ behavior: "smooth", block: "center" });
card.classList.add("msg-jump-flash");
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
setActiveOutlineIdx(idx);
}
// 顶/底 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));
});
}
// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
function updateActiveOutlineDot() {
const rail = $("msg-outline-rail");
if (!rail || rail.style.display === "none") return;
const wrap = $("chat-stream");
const top = wrap.getBoundingClientRect().top;
let activeIdx = null;
for (const it of (state.outline || [])) {
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
if (!card) continue;
if (card.getBoundingClientRect().top - top <= 80) 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);
}
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);
renderTaskProgressDock(run.progressSteps || []);
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,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
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;
}
// 进度只在对话区顶部的单一 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);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…"。
$("chat-input").addEventListener("paste", async (e) => {
const files = Array.from(e.clipboardData?.files || []);
if (!files.length) return;
e.preventDefault();
const hint = $("chat-hint");
const prevHint = hint.textContent;
hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, {
onProgress: (loaded, total) => {
hint.textContent = formatUploadProgress(files, loaded, total);
},
});
if (saved && saved.length) {
hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} 可在右侧文件处查看`;
} else {
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原
}
});
function renderPasteFileChips(saved) {
return (saved || []).map((f) => {
const rel = f.rel || f.name || "";
const name = f.name || (rel.split("/").pop() || rel);
return ``;
}).join("");
}
$("chat-hint").addEventListener("click", (e) => {
const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
if (del) {
e.stopPropagation();
deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap"));
return;
}
const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
if (!chip) return;
const rel = chip.dataset.rel;
if (rel) openPasteFilePreview(rel);
});
async function deletePastedFile(rel, wrap) {
if (!rel || !wrap) return;
const btn = wrap.querySelector(".paste-chip-del");
if (btn) btn.disabled = true;
try {
await api("POST", "/v1/files/delete", { path: rel, recursive: false });
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 内自带"下载")。
// 视频走原生