1154 lines
49 KiB
JavaScript
1154 lines
49 KiB
JavaScript
// 对话视图(任务列表 + 选择/渲染消息 + 发送/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";
|
||
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 = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
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 = `<div class="empty">(暂无任务)</div>`;
|
||
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 `
|
||
<div class="task-row${active}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="desc">${escapeHtml(taskName)}</div>
|
||
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""}
|
||
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
|
||
<div class="meta">
|
||
<span class="badge ${t.status}">${statusLabel}</span>
|
||
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span class="num right-group">${t.n_messages || 0} 条</span>
|
||
<span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>
|
||
<span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
|
||
</div>
|
||
</div>
|
||
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
|
||
</div>
|
||
`;
|
||
}).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; }
|
||
renderTaskProgressDock([]);
|
||
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// 拉同 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 <span class="tname">${escapeHtml(head.name || "(未命名)")}</span> 正在 <span class="rs">${escapeHtml(head.run_status)}</span>${more} — 并发写同名中间产物可能互覆,建议等它结束再发`;
|
||
el.style.display = "block";
|
||
}
|
||
|
||
function renderChatMeta() {
|
||
const t = state.taskMeta;
|
||
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; 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)
|
||
? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>`
|
||
: "";
|
||
$("chat-meta").innerHTML = `
|
||
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
|
||
<span class="badge ${t.status}">${statusLabel}</span>
|
||
${wdBadge}
|
||
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
|
||
<span class="tid">${t.task_id.slice(0, 8)}</span>
|
||
${formatTaskUsage(t)}
|
||
${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
|
||
<span class="spacer"></span>
|
||
${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 =>
|
||
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||
).join("");
|
||
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
|
||
}
|
||
|
||
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 =>
|
||
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||
).join("");
|
||
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生图</span><span class="mdl-icon" aria-hidden="true">🖼</span><select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
|
||
}
|
||
|
||
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 =>
|
||
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||
).join("");
|
||
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生视频</span><span class="mdl-icon" aria-hidden="true">🎬</span><select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
|
||
}
|
||
|
||
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 = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
|
||
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);
|
||
renderProgressInto(card, 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;
|
||
}
|
||
|
||
function renderProgressHtml(steps) {
|
||
if (!Array.isArray(steps) || !steps.length) return "";
|
||
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
|
||
const rows = steps.map((s) => `
|
||
<div class="tp-step ${escapeHtml(s.status)}">
|
||
<span class="tp-mark">${escapeHtml(mark(s.status))}</span>
|
||
<span class="tp-text">${escapeHtml(s.title)}</span>
|
||
</div>
|
||
`).join("");
|
||
return `<div class="task-progress"><div class="tp-title">进度</div><div class="tp-list">${rows}</div></div>`;
|
||
}
|
||
|
||
function renderProgressInto(card, steps) {
|
||
let el = card.querySelector(":scope > .task-progress");
|
||
if (!Array.isArray(steps) || !steps.length) {
|
||
if (el) el.remove();
|
||
return;
|
||
}
|
||
const html = renderProgressHtml(steps);
|
||
if (el) {
|
||
el.outerHTML = html;
|
||
} else {
|
||
card.insertAdjacentHTML("beforeend", html);
|
||
}
|
||
}
|
||
|
||
function renderTaskProgressDock(steps) {
|
||
const dock = $("task-progress-dock");
|
||
if (!dock) return;
|
||
if (!Array.isArray(steps) || !steps.length) {
|
||
dock.innerHTML = "";
|
||
dock.classList.remove("show");
|
||
return;
|
||
}
|
||
dock.innerHTML = renderProgressHtml(steps);
|
||
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");
|
||
wrap.innerHTML = "";
|
||
if (!msgs.length) {
|
||
setTaskProgress(state.taskId, []);
|
||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||
renderLiveRunIfVisible();
|
||
return;
|
||
}
|
||
// 模型切换点小标: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 (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") {
|
||
if ((p.name || "") === "task_progress") continue;
|
||
// 嵌进上一个 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 = `
|
||
<div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
|
||
<details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
|
||
${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 = `<div class="role">${roleLabel}</div>`;
|
||
if (typeof p.content === "string" && p.content) {
|
||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 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;
|
||
}
|
||
const label = toolActivityLabel(fn, argsObj);
|
||
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
|
||
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
|
||
html += `
|
||
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||
${renderArtifactBarHtml(rels, isProducer)}
|
||
`;
|
||
}
|
||
if (progressResult.sawProgress) html += renderProgressHtml(currentProgressSteps);
|
||
}
|
||
card.innerHTML = html;
|
||
highlightIn(card);
|
||
wrap.appendChild(card);
|
||
}
|
||
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)} <span class="muted">可在右侧文件处查看</span>`;
|
||
} 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 `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该粘贴文件">×</button></span>`;
|
||
}).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 = `<span class="muted">已删除粘贴文件</span>`;
|
||
}
|
||
} 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 内自带"下载")。
|
||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 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 = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
|
||
wrap.appendChild(userCard);
|
||
|
||
// assistant 流式占位卡
|
||
const asstCard = document.createElement("div");
|
||
asstCard.className = "msg assistant live-run";
|
||
asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
|
||
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,
|
||
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||
};
|
||
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 = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
|
||
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);
|
||
if (fn === "task_progress") {
|
||
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
|
||
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
||
renderProgressInto(asstCard, ctx.progressSteps);
|
||
return;
|
||
}
|
||
const label = toolActivityLabel(fn, args);
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>${escapeHtml(label)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||
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) || "";
|
||
if (toolName === "task_progress") return;
|
||
const banner = extractMediaBanner(toolName, txtStr);
|
||
const det = document.createElement("details");
|
||
det.className = "tool-call";
|
||
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
|
||
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 = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
|
||
$("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 = `<span class="muted">(未选中任务)</span>`;
|
||
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
|
||
renderTaskProgressDock([]);
|
||
$("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);
|
||
});
|
||
}
|