1630 lines
73 KiB
JavaScript
1630 lines
73 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, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
|
||
import { $, showMenu } from "./dom.js";
|
||
import { api } from "./api.js";
|
||
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats } from "./format.js";
|
||
import { renderMd, highlightIn } from "./markdown.js";
|
||
import { mqPhone, setMobileView } from "./layout.js";
|
||
|
||
// 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白)
|
||
const WECHAT_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.5-6.446 1.45-1.595 3.711-2.55 6.286-2.55.165 0 .33.01.495.027C18.486 4.916 13.929 2.188 8.691 2.188zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.121 2.361-.343a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088a8.067 8.067 0 0 0-.346-.034zm-2.71 3.711c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>`;
|
||
// 渠道镜像 task(后端 _run_channel_conversation 建):'wechat'=个人微信 ClawBot,'wecom'=企业微信。
|
||
// 两者在 web 端都是只读镜像(唯一交互入口在对应 App,web 发的消息推不回去)—— 列表打绿徽章 +
|
||
// 绿边、对话框 readonly。单一真相源:徽章文案 / 锁定提示 / 发送拦截全读这张表。logo 复用微信系图标。
|
||
const CHANNEL_BADGE = {
|
||
wechat: { label: "微信", title: "微信 ClawBot 渠道" },
|
||
wecom: { label: "企业微信", title: "企业微信渠道" },
|
||
};
|
||
function channelCfg(ch) { return CHANNEL_BADGE[ch] || null; }
|
||
import { logout } from "./auth.js";
|
||
import { 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)
|
||
// 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
|
||
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));
|
||
};
|
||
}
|
||
});
|
||
}
|
||
|
||
// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互
|
||
// (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: <task_dict>|null },
|
||
// 前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、已绑定有对话(点进 + ⚙ 管理)。
|
||
export async function loadChannelCards() {
|
||
const box = $("channel-cards");
|
||
if (!box) return;
|
||
let data;
|
||
try {
|
||
data = await api("GET", "/v1/channel_tasks");
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏
|
||
return;
|
||
}
|
||
// CHANNEL_BADGE 的键序决定卡片顺序。每渠道都有一张卡片(占位或对话)。
|
||
const cards = Object.keys(CHANNEL_BADGE).map((kind) => ({ kind, info: data && data[kind] }));
|
||
box.innerHTML = cards.map(({ kind, info }) => {
|
||
const cfg = CHANNEL_BADGE[kind];
|
||
const bound = info && info.bound;
|
||
const t = info && info.task;
|
||
let html = "";
|
||
if (!bound) {
|
||
// 未绑定:占位卡片,点打开弹框绑定
|
||
html = `
|
||
<div class="channel-card cc-placeholder" data-kind="${kind}" data-action="bind"
|
||
title="绑定${cfg.label}后在${cfg.label}里直接和 zcbot 对话">
|
||
<span class="cc-icon">${WECHAT_ICON}</span>
|
||
<span class="cc-body">
|
||
<span class="cc-name">绑定${cfg.label}</span>
|
||
<span class="cc-meta">扫码或手填 userid</span>
|
||
</span>
|
||
</div>`;
|
||
} else if (!t) {
|
||
// 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,⚙ 打开弹框管理
|
||
html = `
|
||
<div class="channel-card cc-placeholder" data-kind="${kind}" data-action="manage"
|
||
title="在${cfg.label}发条消息后即可打开对话">
|
||
<span class="cc-icon">${WECHAT_ICON}</span>
|
||
<span class="cc-body">
|
||
<span class="cc-name">${cfg.label}对话</span>
|
||
<span class="cc-meta">发消息后可打开 · ⚙ 管理</span>
|
||
</span>
|
||
</div>`;
|
||
} else {
|
||
// 已绑定且有对话:正常卡片,点打开,⚙ 打开弹框管理
|
||
const active = state.taskId === t.task_id ? " active" : "";
|
||
const name = t.name || cfg.label + "对话";
|
||
const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))} · ⚙`;
|
||
html = `
|
||
<div class="channel-card${active}" data-tid="${t.task_id}" data-kind="${kind}"
|
||
data-action="select" title="${escapeHtml(cfg.title)}">
|
||
<span class="cc-icon">${WECHAT_ICON}</span>
|
||
<span class="cc-body">
|
||
<span class="cc-name">${escapeHtml(name)}</span>
|
||
<span class="cc-meta">${meta}</span>
|
||
</span>
|
||
</div>`;
|
||
}
|
||
return html;
|
||
}).join("");
|
||
// 绑定事件:绑定点打开弹框;已绑定点打开对话(占位点管理);右键/点击 ⚙ 打开弹框管理
|
||
box.querySelectorAll(".channel-card").forEach((el) => {
|
||
const action = el.dataset.action;
|
||
if (action === "bind") {
|
||
el.onclick = () => {
|
||
if (typeof openWechatModal === "function") openWechatModal();
|
||
};
|
||
} else if (action === "manage") {
|
||
el.onclick = () => {
|
||
if (typeof openWechatModal === "function") openWechatModal();
|
||
};
|
||
} else if (action === "select") {
|
||
el.onclick = () => selectTask(el.dataset.tid);
|
||
// TODO: ⚙ 打开弹框管理(待实现——需要给弹框里当前渠道加高亮或 tab 切换)
|
||
}
|
||
});
|
||
}
|
||
|
||
// selectTask 切换时同步卡片高亮(卡片 task 不在 .task-row 列表里,需单独刷 active 态)
|
||
function syncChannelCardActive(tid) {
|
||
document.querySelectorAll("#channel-cards .channel-card").forEach((el) => {
|
||
el.classList.toggle("active", el.dataset.tid === tid);
|
||
});
|
||
}
|
||
|
||
function taskMenuItems(t) {
|
||
const isActive = t.status === "active";
|
||
const hasMsg = (t.n_messages || 0) > 0;
|
||
// run_status 仅 taskMeta(中栏 ⋯)带;列表行摘要无此字段 → 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(); loadChannelCards(); };
|
||
|
||
// 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。
|
||
function applyTaskFiltersCollapsed(collapsed) {
|
||
document.body.classList.toggle("task-filters-collapsed", collapsed);
|
||
$("filter-toggle").textContent = collapsed ? "筛选 ▸" : "筛选 ▾";
|
||
}
|
||
$("filter-toggle").onclick = () => {
|
||
const next = !document.body.classList.contains("task-filters-collapsed");
|
||
localStorage.setItem(LS_TASK_FILTERS_COLLAPSED, next ? "1" : "0");
|
||
applyTaskFiltersCollapsed(next);
|
||
};
|
||
// 默认折叠:只有用户显式展开过(存 "0")才展开
|
||
applyTaskFiltersCollapsed(localStorage.getItem(LS_TASK_FILTERS_COLLAPSED) !== "0");
|
||
|
||
// 搜索 q 是 text input → 300ms debounce 避免每字符打 API
|
||
let _filterDebounce = null;
|
||
$("filter-q").addEventListener("input", () => {
|
||
clearTimeout(_filterDebounce);
|
||
_filterDebounce = setTimeout(() => loadTaskList(), 300);
|
||
});
|
||
|
||
// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。
|
||
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
|
||
const _taskScrollObserver = new IntersectionObserver((entries) => {
|
||
if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
|
||
loadTaskList({ append: true });
|
||
}
|
||
}, { root: $("task-scroll"), rootMargin: "200px 0px" });
|
||
_taskScrollObserver.observe($("task-sentinel"));
|
||
|
||
// ───── select task ─────
|
||
export async function selectTask(tid) {
|
||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
|
||
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
|
||
if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache();
|
||
state.taskId = tid;
|
||
document.querySelectorAll(".task-row").forEach((el) => {
|
||
el.classList.toggle("active", el.dataset.tid === tid);
|
||
});
|
||
syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
|
||
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
|
||
if (mqPhone.matches) setMobileView("mv-mid");
|
||
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
|
||
$("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
|
||
renderTaskProgressDock([]);
|
||
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
|
||
try {
|
||
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
|
||
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta;
|
||
// 落在不同 DOM 区(chat-meta / chat-stream / outline-rail),谁先返回先渲染。
|
||
const [meta] = await Promise.all([
|
||
api("GET", "/v1/tasks/" + tid),
|
||
loadMessages(),
|
||
refreshOutline(),
|
||
]);
|
||
state.taskMeta = meta;
|
||
renderChatMeta();
|
||
applyChannelComposerLock(meta);
|
||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
|
||
} 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)}
|
||
${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 =>
|
||
`<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>`;
|
||
}
|
||
|
||
// 生图/生视频 是低频情景操作 → 不在 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 `<button id="media-model-btn" class="small" title="生图 / 生视频 模型选择">⚙ 媒体</button>`;
|
||
}
|
||
|
||
// 弹层里一行 = 标签(icon + 文字) + select。沿用原 onChange,选中值仍只进 state.* 随下条消息发。
|
||
function mediaSelectRow(icon, label, id, list, cur, title) {
|
||
const opts = list.map(m =>
|
||
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||
).join("");
|
||
return `<div class="mm-row"><span class="mm-label">${icon} ${label}</span>`
|
||
+ `<select id="${id}" title="${title}">${opts}</select></div>`;
|
||
}
|
||
|
||
function openMediaModelPop(btn) {
|
||
const pop = $("media-model-pop");
|
||
let html = "";
|
||
if (state.imageModels && state.imageModels.length)
|
||
html += mediaSelectRow("🖼", "生图", "chat-image-model-sel", state.imageModels, state.imageModel || "", "下一条消息触发生图时使用的模型(本地选择,不入库)");
|
||
if (state.videoModels && state.videoModels.length)
|
||
html += mediaSelectRow("🎬", "生视频", "chat-video-model-sel", state.videoModels, state.videoModel || "", "下一条消息触发生视频时使用的模型(本地选择,不入库)");
|
||
pop.innerHTML = html;
|
||
const imgSel = $("chat-image-model-sel"); if (imgSel) imgSel.onchange = onChangeImageModel;
|
||
const vidSel = $("chat-video-model-sel"); if (vidSel) vidSel.onchange = onChangeVideoModel;
|
||
// 定位:右上角对齐触发按钮,向下展开;下方空间不足则向上(同 showMenu 思路)
|
||
const rect = btn.getBoundingClientRect();
|
||
pop.style.visibility = "hidden";
|
||
pop.classList.add("show");
|
||
const ph = pop.offsetHeight || 100;
|
||
pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
|
||
pop.style.left = "auto";
|
||
pop.style.top = (rect.bottom + ph + 8 > window.innerHeight)
|
||
? Math.max(4, rect.top - ph - 4) + "px"
|
||
: (rect.bottom + 4) + "px";
|
||
pop.style.visibility = "";
|
||
}
|
||
function closeMediaModelPop() { $("media-model-pop").classList.remove("show"); }
|
||
// 点弹层外 / resize / 滚动 → 关(选 select 选项的点击落在弹层内,不会误关)
|
||
document.addEventListener("click", (e) => {
|
||
if (e.target.closest("#media-model-btn") || e.target.closest("#media-model-pop")) return;
|
||
closeMediaModelPop();
|
||
}, true);
|
||
window.addEventListener("resize", closeMediaModelPop);
|
||
document.addEventListener("scroll", closeMediaModelPop, true);
|
||
|
||
function onChangeImageModel(ev) {
|
||
// 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
|
||
state.imageModel = ev.target.value || "";
|
||
$("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
|
||
}
|
||
|
||
function onChangeVideoModel(ev) {
|
||
state.videoModel = ev.target.value || "";
|
||
$("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
|
||
}
|
||
|
||
async function onChangeModel(ev) {
|
||
const sel = ev.target;
|
||
const newProfile = sel.value;
|
||
const t = state.taskMeta;
|
||
if (!t || !newProfile || newProfile === t.model_profile) return;
|
||
const oldProfile = t.model_profile || "";
|
||
try {
|
||
const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
|
||
state.taskMeta = updated;
|
||
const running = updated.run_status === "running" || updated.run_status === "cancelling";
|
||
$("chat-hint").textContent = running
|
||
? `已切到 ${newProfile} · 当前 run 跑完后生效`
|
||
: `已切到 ${newProfile}`;
|
||
} catch (e) {
|
||
sel.value = oldProfile; // PATCH 失败 UI 回滚
|
||
$("chat-hint").textContent = `切换失败:${e.message}`;
|
||
}
|
||
}
|
||
|
||
// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。
|
||
// 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量,
|
||
// 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。
|
||
const MSG_PAGE = 30;
|
||
|
||
async function loadMessages() {
|
||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
||
state.loadedMessages = data.messages || [];
|
||
state.msgHasMore = !!data.has_more;
|
||
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
|
||
state.msgLoadingEarlier = false;
|
||
state.msgLoadingNewer = false;
|
||
renderMessages(state.loadedMessages);
|
||
}
|
||
|
||
// 向上加载更早一批:取当前已加载窗口最早 idx 之前的 MSG_PAGE 条,prepend 进数组后
|
||
// 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改),
|
||
// 重渲后把滚动位置锚回原处,视口不跳。
|
||
async function loadEarlierMessages() {
|
||
if (state.msgLoadingEarlier || !state.msgHasMore) return;
|
||
const msgs = state.loadedMessages;
|
||
if (!msgs.length) return;
|
||
const tid = state.taskId;
|
||
const firstIdx = msgs[0].idx;
|
||
state.msgLoadingEarlier = true;
|
||
const wrap = $("chat-stream");
|
||
const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop;
|
||
try {
|
||
const data = await api(
|
||
"GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${firstIdx}`,
|
||
);
|
||
if (state.taskId !== tid) return; // 加载途中切走了 task,丢弃结果
|
||
const earlier = data.messages || [];
|
||
if (earlier.length) state.loadedMessages = earlier.concat(state.loadedMessages);
|
||
state.msgHasMore = !!data.has_more;
|
||
state.msgLoadingEarlier = false;
|
||
renderMessages(state.loadedMessages);
|
||
// 锚回:新增内容都在上方,保持原先可见的首条仍在原位
|
||
wrap.scrollTop = prevTop + (wrap.scrollHeight - prevH);
|
||
} catch (e) {
|
||
state.msgLoadingEarlier = false;
|
||
if (e.status === 401) logout();
|
||
}
|
||
}
|
||
|
||
// 向下加载更新一批:从目录跳到旧消息后,窗口下方还有未加载的新消息。取当前窗口
|
||
// 最新 idx 之后的 MSG_PAGE 条,append 进数组重渲,滚动位置锚回原处(新增都在下方)。
|
||
async function loadNewerMessages() {
|
||
if (state.msgLoadingNewer || !state.msgHasMoreNewer) return;
|
||
const msgs = state.loadedMessages;
|
||
if (!msgs.length) return;
|
||
const tid = state.taskId;
|
||
const lastIdx = msgs[msgs.length - 1].idx;
|
||
state.msgLoadingNewer = true;
|
||
const wrap = $("chat-stream");
|
||
const prevTop = wrap.scrollTop;
|
||
try {
|
||
const data = await api(
|
||
"GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&after_idx=${lastIdx}`,
|
||
);
|
||
if (state.taskId !== tid) return;
|
||
const newer = data.messages || [];
|
||
if (newer.length) state.loadedMessages = state.loadedMessages.concat(newer);
|
||
state.msgHasMoreNewer = !!data.has_more_after;
|
||
state.msgLoadingNewer = false;
|
||
renderMessages(state.loadedMessages);
|
||
wrap.scrollTop = prevTop; // 新增在下方,保持原视口不跳
|
||
} catch (e) {
|
||
state.msgLoadingNewer = false;
|
||
if (e.status === 401) logout();
|
||
}
|
||
}
|
||
|
||
// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 用 before_idx 拉一个
|
||
// 围绕目标的居中窗口(替换当前窗口)再定位。idx+11 让目标落窗口偏上、带点下文。
|
||
async function loadMessagesAround(idx) {
|
||
const tid = state.taskId;
|
||
const data = await api(
|
||
"GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${idx + 11}`,
|
||
);
|
||
if (state.taskId !== tid) return false;
|
||
state.loadedMessages = data.messages || [];
|
||
state.msgHasMore = !!data.has_more;
|
||
state.msgHasMoreNewer = !!data.has_more_after;
|
||
state.msgLoadingEarlier = false;
|
||
state.msgLoadingNewer = false;
|
||
renderMessages(state.loadedMessages);
|
||
return true;
|
||
}
|
||
|
||
async function jumpToMessage(idx) {
|
||
const wrap = $("chat-stream");
|
||
let card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
||
if (!card) {
|
||
let ok = false;
|
||
try { ok = await loadMessagesAround(idx); }
|
||
catch (e) { if (e.status === 401) { logout(); return; } }
|
||
if (!ok) return;
|
||
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
||
}
|
||
if (!card) return;
|
||
// 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
|
||
// 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。
|
||
// .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
|
||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
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 `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}">`
|
||
+ `<span class="ol-num">${i + 1}</span>`
|
||
+ `<span class="ol-label">${escapeHtml(label)}</span></button>`;
|
||
}).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;
|
||
// 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界
|
||
// 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。
|
||
if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx;
|
||
else break; // outline 升序,首个落在视口下方的之后都更靠下
|
||
}
|
||
if (activeIdx != null) setActiveOutlineIdx(activeIdx);
|
||
}
|
||
|
||
let _outlineRaf = 0;
|
||
$("chat-stream").addEventListener("scroll", () => {
|
||
if (_outlineRaf) return;
|
||
_outlineRaf = requestAnimationFrame(() => { _outlineRaf = 0; updateActiveOutlineDot(); });
|
||
});
|
||
// embed 等精简页无 outline-rail 元素 → 跳过绑定(renderOutlineRail 已 null-safe)
|
||
const _outlineRailEl = $("msg-outline-rail");
|
||
if (_outlineRailEl) {
|
||
_outlineRailEl.addEventListener("click", (e) => {
|
||
const dot = e.target.closest(".ol-dot");
|
||
if (dot) jumpToMessage(Number(dot.dataset.idx));
|
||
});
|
||
}
|
||
|
||
function getLiveRun(taskId) {
|
||
return taskId ? state.liveRuns.get(taskId) : null;
|
||
}
|
||
|
||
function isCurrentTaskStreaming() {
|
||
return !!getLiveRun(state.taskId);
|
||
}
|
||
|
||
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);
|
||
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) => `
|
||
<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("");
|
||
const summary = allDone
|
||
? `<summary class="tp-summary tp-done">✓ 全部完成 · ${done}/${total} 步</summary>`
|
||
: `<summary class="tp-summary">进度 · ${done}/${total} 步</summary>`;
|
||
const openAttr = allDone ? "" : " open"; // 全完成默认折叠,其余展开
|
||
dock.innerHTML = `<details class="task-progress"${openAttr}>${summary}<div class="tp-list">${rows}</div></details>`;
|
||
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 = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||
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 = `
|
||
<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;
|
||
card.dataset.idx = m.idx;
|
||
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;
|
||
}
|
||
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 += `
|
||
<details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
|
||
${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)} <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);
|
||
}
|
||
}
|
||
|
||
// 渠道镜像 task(微信 / 企业微信)在 web 端只读:这条对话的唯一交互入口锚定在对应 App ——
|
||
// 微信侧 agent 回复必须带 context_token 才发得回,token 只能从用户入站消息拿(24h 过期),
|
||
// 协议层没有无条件说话的能力;企业微信虽可主动推,但同样把交互权威收敛在 App 端,避免
|
||
// web/手机两路输入打架。故 web→渠道单向不同步,web 端做干净的只读镜像(单一交互权威 +
|
||
// 可预测),想主动推走 wechat_push / 定时简报。渠道→web 仍同步(同一条 task)。
|
||
function applyChannelComposerLock(meta) {
|
||
const input = $("chat-input");
|
||
if (!input) return;
|
||
const cfg = meta && channelCfg(meta.channel); // 微信 / 企业微信镜像 → 只读
|
||
input.readOnly = !!cfg;
|
||
input.classList.toggle("readonly-locked", !!cfg);
|
||
input.placeholder = cfg
|
||
? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`
|
||
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
|
||
if (cfg) {
|
||
const opt = $("chat-optimize");
|
||
if (opt) opt.disabled = true;
|
||
}
|
||
}
|
||
|
||
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
|
||
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
|
||
// 不与主对话 run 互斥,各跑各的 LLM)。
|
||
function syncOptimizeBtn() {
|
||
const btn = $("chat-optimize");
|
||
if (!btn) return;
|
||
if (state.optimizing) return; // 进行中不在这条路径切
|
||
const has = ($("chat-input").value || "").trim().length > 0;
|
||
btn.disabled = !has || !state.taskId;
|
||
}
|
||
|
||
async function optimizePrompt() {
|
||
if (state.optimizing) return;
|
||
if (!state.taskId) return;
|
||
const ta = $("chat-input");
|
||
const original = (ta.value || "").trim();
|
||
if (!original) return;
|
||
const btn = $("chat-optimize");
|
||
state.optimizing = true;
|
||
btn.disabled = true;
|
||
const oldLabel = btn.textContent;
|
||
btn.textContent = "润色中…";
|
||
const oldHint = $("chat-hint").textContent;
|
||
$("chat-hint").textContent = "润色中…";
|
||
try {
|
||
const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, {
|
||
text: ta.value, // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变
|
||
image_model: state.imageModel || "",
|
||
video_model: state.videoModel || "",
|
||
});
|
||
const optimized = (r.optimized || "").trim();
|
||
if (!optimized) throw new Error("空结果");
|
||
// execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈
|
||
ta.focus();
|
||
ta.select();
|
||
const ok = document.execCommand("insertText", false, optimized);
|
||
if (!ok) {
|
||
// execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值
|
||
// 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道
|
||
ta.value = optimized;
|
||
$("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)";
|
||
} else {
|
||
const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?";
|
||
$("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`;
|
||
}
|
||
} catch (e) {
|
||
if (e.status === 401) { logout(); return; }
|
||
$("chat-hint").textContent = `润色失败:${e.message}`;
|
||
} finally {
|
||
state.optimizing = false;
|
||
btn.textContent = oldLabel;
|
||
syncOptimizeBtn();
|
||
}
|
||
}
|
||
|
||
$("chat-optimize").onclick = optimizePrompt;
|
||
|
||
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
|
||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||
// 弹个 modal 反而打断播放,不如交给浏览器。
|
||
$("chat-stream").addEventListener("click", (e) => {
|
||
const askBtn = e.target.closest && e.target.closest(".ask-option");
|
||
if (askBtn) {
|
||
if (askBtn.disabled || isCurrentTaskStreaming()) return;
|
||
const label = askBtn.dataset.label || "";
|
||
const card = askBtn.closest(".ask-user");
|
||
if (card) {
|
||
card.classList.add("answered");
|
||
card.querySelectorAll(".ask-option").forEach((b) => { b.disabled = true; });
|
||
}
|
||
askBtn.classList.add("selected");
|
||
if (label) sendMessage(label);
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
});
|
||
|
||
// POST /messages 退避重试:覆盖后端优雅 drain 的部署窗口 ——
|
||
// ① 排空期老进程返 503(背压) ② 进程交接缺口 fetch 拒连(api 抛 TypeError,无 status)。
|
||
// 两种都重试,UI 显"服务更新中";~26s 预算内大多能等到新进程接手。仍失败则抛出由
|
||
// sendMessage 的 catch 贴友好提示,用户稍后重发。其它错误(4xx 等)立即抛不重试。
|
||
async function postMessageWithRetry(taskId, body) {
|
||
const delays = [1000, 2000, 3000, 5000, 5000, 5000, 5000]; // 7 次 ≈ 26s
|
||
for (let attempt = 0; ; attempt++) {
|
||
try {
|
||
return await api("POST", `/v1/tasks/${taskId}/messages`, body);
|
||
} catch (e) {
|
||
const retriable = e.status === 503 || e.name === "TypeError"; // 503 背压 / 网络拒连
|
||
if (!retriable || attempt >= delays.length) throw e;
|
||
$("chat-hint").textContent = "服务更新中,正在重发…";
|
||
await new Promise((res) => setTimeout(res, delays[attempt]));
|
||
}
|
||
}
|
||
}
|
||
|
||
// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
|
||
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
|
||
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
|
||
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
|
||
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
|
||
function takePastedRels() {
|
||
const hint = $("chat-hint");
|
||
const wraps = hint ? Array.from(hint.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
|
||
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
|
||
wraps.forEach((w) => w.remove());
|
||
return rels;
|
||
}
|
||
|
||
async function sendMessage(overrideText) {
|
||
if (!state.taskId) return;
|
||
if (isCurrentTaskStreaming()) return;
|
||
// 渠道镜像 task(微信 / 企业微信)只读:web 发的消息推不回去(单向),挡在入口避免不一致(见 applyChannelComposerLock)
|
||
const _chCfg = state.taskMeta && channelCfg(state.taskMeta.channel);
|
||
if (_chCfg) {
|
||
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
||
return;
|
||
}
|
||
const fromInput = typeof overrideText !== "string";
|
||
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
||
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
|
||
// 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
|
||
const pastedRels = fromInput ? takePastedRels() : [];
|
||
if (pastedRels.length) {
|
||
const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
|
||
content = content ? `${content}\n\n${lines}` : lines;
|
||
}
|
||
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 postMessageWithRetry(taskId, {
|
||
content,
|
||
image_model: state.imageModel || "",
|
||
video_model: state.videoModel || "",
|
||
});
|
||
if (fromInput) $("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; }
|
||
// 重试耗尽仍是 503 / 网络拒连 → 部署窗口比重试预算还长,给友好提示让用户稍后重发
|
||
const msg = (e.status === 503 || e.name === "TypeError")
|
||
? "服务更新中,请稍后重发"
|
||
: e.message;
|
||
appendErrorCard(msg);
|
||
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) {
|
||
// 重新拉 meta:SSE 期间 token/缓存命中/花费只累计进 hint,未落 state.taskMeta,
|
||
// 顶栏计量栏会停在发问前的旧值,直到切走再切回才更新 — 这里补一次让它即时刷新。
|
||
try {
|
||
const meta = await api("GET", "/v1/tasks/" + ctx.taskId);
|
||
if (state.taskId === ctx.taskId) { state.taskMeta = meta; renderChatMeta(); }
|
||
} catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
|
||
await loadMessages();
|
||
refreshOutline(); // 本轮新增 user 提问 → 目录补一条
|
||
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 };
|
||
}
|
||
|
||
// ask_user 选项卡:question + 一组可点击选项。点击走 chat-stream 的事件委托(把选项
|
||
// label 当作新用户消息发出);interactive=false(历史里已答)时按钮置灰、命中项标「已选」。
|
||
// 返回 DOM 元素(实时流式直接 append);历史重渲取其 outerHTML 注入(点击仍由委托接住)。
|
||
function buildAskUserCard(args, opts) {
|
||
const o = opts || {};
|
||
const interactive = o.interactive !== false;
|
||
const chosen = o.chosenLabel || "";
|
||
const question = (args && args.question) || "";
|
||
const options = (args && Array.isArray(args.options)) ? args.options : [];
|
||
const card = document.createElement("div");
|
||
card.className = "ask-user" + (interactive ? "" : " answered");
|
||
const btns = options.map((op) => {
|
||
const label = (op && op.label) || "";
|
||
const desc = (op && op.description) || "";
|
||
const sel = (!interactive && chosen && chosen === label) ? " selected" : "";
|
||
const dis = interactive ? "" : " disabled";
|
||
return `<button type="button" class="ask-option${sel}" data-label="${escapeHtml(label)}"${dis}>
|
||
<span class="ask-option-label">${escapeHtml(label)}</span>
|
||
${desc ? `<span class="ask-option-desc">${escapeHtml(desc)}</span>` : ""}
|
||
</button>`;
|
||
}).join("");
|
||
card.innerHTML = `
|
||
<div class="ask-q">${escapeHtml(question)}</div>
|
||
<div class="ask-options">${btns}</div>
|
||
<div class="ask-hint">${interactive ? "点选项继续,或在下方输入框就此讨论" : "已选择"}</div>`;
|
||
return card;
|
||
}
|
||
|
||
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);
|
||
return;
|
||
}
|
||
if (fn === "ask_user") {
|
||
asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
|
||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||
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;
|
||
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
|
||
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-task-menu").onclick = (e) => {
|
||
if (!state.taskId || !state.taskMeta) return;
|
||
e.stopPropagation();
|
||
showMenu($("btn-task-menu"), taskMenuItems(state.taskMeta).filter((it) => it.act !== "complete"));
|
||
};
|
||
|
||
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-task-menu").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);
|
||
});
|
||
}
|