zcbot/web/static/js/chat.js

2466 lines
114 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 对话视图(任务列表 + 选择/渲染消息 + 发送/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, ctxRingHtml } 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 { openWechatModal } from "./wechat.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js";
import { refreshProcs, decorateBgprocCard, hasRunningProc, killTaskProcs } from "./procs.js";
export async function loadModels() {
try {
const data = await api("GET", "/v1/models");
state.models = data.models || [];
} catch (e) {
state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程
}
try {
const data = await api("GET", "/v1/image_models");
state.imageModels = data.models || [];
// 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新
if (!state.imageModel) {
const def = state.imageModels.find(m => m.is_default) || state.imageModels[0];
state.imageModel = def ? def.variant : "";
}
} catch (e) {
state.imageModels = [];
state.imageModel = "";
}
try {
const data = await api("GET", "/v1/video_models");
state.videoModels = data.models || [];
if (!state.videoModel) {
const def = state.videoModels.find(m => m.is_default) || state.videoModels[0];
state.videoModel = def ? def.variant : "";
}
} catch (e) {
state.videoModels = [];
state.videoModel = "";
}
// embed + task_id 场景下 selectTask 可能在 loadModels 完成前就跑完 renderChatMeta,
// 此时 models 为空 → 模型下拉不渲染。loadModels 收尾时如果已选中 task,补一次 chat-meta 重渲。
if (state.taskMeta) renderChatMeta();
}
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0;
export async function loadTaskList({ append = false } = {}) {
if (append && (state.taskLoading || !state.taskHasMore)) return;
const mySeq = ++_taskLoadSeq;
const nextPage = append ? state.taskPage + 1 : 1;
const params = new URLSearchParams();
params.set("page", nextPage);
params.set("page_size", state.taskPageSize);
const st = $("filter-status").value;
if (st) params.set("status", st);
const q = $("filter-q").value.trim();
if (q) params.set("q", q);
const wd = $("filter-wd").value.trim();
if (wd) params.set("working_dir", wd);
const ord = $("filter-order").value;
if (ord && ord !== "-created_at") params.set("ordering", ord); // 默认值不发送,URL 更干净
state.taskLoading = true;
setSentinel(append ? "加载中…" : "");
try {
const data = await api("GET", "/v1/tasks?" + params.toString());
if (mySeq !== _taskLoadSeq) return; // 已被更新的请求 supersede,丢弃
state.taskTotal = data.count || 0;
state.taskPage = data.page || nextPage;
state.taskPageSize = data.page_size || state.taskPageSize;
const results = data.results || [];
if (!append) state.taskLoaded = 0;
state.taskLoaded += results.length;
state.taskHasMore = state.taskLoaded < state.taskTotal;
renderTaskList(results, append);
renderTaskCount();
subscribeRunningRows(results);
} catch (e) {
if (mySeq !== _taskLoadSeq) return;
if (e.status === 401) { logout(); return; }
if (!append) {
$("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
state.taskHasMore = false;
}
setSentinel(`加载失败:${e.message}`);
} finally {
if (mySeq === _taskLoadSeq) state.taskLoading = false;
}
}
// 列表带出的 running/cancelling 行,本地未订阅的自动挂 SSE 接管(刷新后重新盯上、
// 别的标签页/渠道启动的 run 也能盯):跑完 done/error 走 fetchSse 现有收尾
// (清 liveRuns + 就地清标识 + loadTaskList 重拉),标识全程实时,不需要轮询。
// 上限防浏览器同源连接数被后台流占满(HTTP/1.1 并发约 6,要给选中 task 的
// 订阅和普通 API 留口子);超限的行标识仍显示(服务端快照),只是不自动清。
const MAX_BG_SSE = 4;
function subscribeRunningRows(tasks) {
for (const t of tasks) {
if (t.run_status !== "running" && t.run_status !== "cancelling") continue;
if (getLiveRun(t.task_id)) continue;
if (state.liveRuns.size >= MAX_BG_SSE) break;
ensureRunningTaskSubscribed(t.task_id, `/v1/tasks/${t.task_id}/events`, t);
}
}
function renderTaskCount() {
$("task-count").textContent = state.taskTotal > 0 ? `${state.taskTotal}` : "";
if (state.taskTotal === 0) setSentinel("");
else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : "");
else setSentinel(""); // 还有更多 → 留空,observer 触发时再填"加载中"
}
function setSentinel(text) {
$("task-sentinel").textContent = text || "";
}
// 列表行运行态:服务端 run_status 快照 + 本地 liveRuns 叠加(本会话刚发出的 run 比列表快照新)
function taskRunState(t) {
const live = state.liveRuns.get(t.task_id);
if (live) return live.cancelling ? "cancelling" : "running";
return t.run_status || "idle";
}
// 只渲一个带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案收进 hover title——
// 列表 meta 行本就拥挤,多两个字就挤断行
const RUN_IND_TITLE = {
running: "运行中(调用工具 / 回复中)",
cancelling: "停止中",
error: "", // 动态填 run_error
};
function runIndicatorHtml(t) {
const rs = taskRunState(t);
if (!(rs in RUN_IND_TITLE)) return ""; // idle → 不显示
const title = rs === "error" ? (t.run_error || "上次执行出错") : RUN_IND_TITLE[rs];
return `<span class="run-ind ${rs}" title="${escapeHtml(title)}"><span class="run-dot"></span></span>`;
}
// run 开始 / 停止请求后就地刷新对应列表行的运行态标识(不重拉列表 — loadTaskList reset
// 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。
// 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。
function syncTaskRowRunIndicator(tid) {
const row = document.querySelector(`.task-row[data-tid="${CSS.escape(tid)}"]`);
if (!row) return;
// 圆点 host 与渲染时同规则:优先文件夹行,无文件夹行回落底部数据行
const host = row.querySelector(".wd-line") || row.querySelector(".meta.stats");
if (!host) return;
const old = host.querySelector(".run-ind");
if (old) old.remove();
const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid });
if (!html) return;
const badge = host.querySelector(".badge");
if (badge) badge.insertAdjacentHTML("afterend", html);
else host.insertAdjacentHTML("afterbegin", html);
}
function renderTaskList(tasks, append = false) {
if (!append) state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t;
if (!append && !tasks.length) {
$("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return;
}
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
// 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音),
// 终态(completed/abandoned)才着色 + 整行淡化(st-* class),瞬时态交给运行圆点
const statusLabels = { 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] || "";
// st- 前缀防撞 .task-row.active(选中态)——status 值 "active" 不能直接当 class 用
const stCls = statusLabel ? ` st-${t.status}` : "";
const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
// 状态(终态徽章 + 运行圆点)放文件夹行左侧——行首左上区最先被扫到;
// 无文件夹行的 task 回落到底部数据行行首(syncTaskRowRunIndicator 同规则找 host)
const indHtml = `${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""}${runIndicatorHtml(t)}`;
// 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
return `
<div class="task-row${active}${stCls}" 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 wd-line" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${indHtml}📁 ${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 stats">
${wdName ? "" : indHtml}
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
<span class="num">${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>
<span class="cc-action" title="管理${cfg.label}绑定">⚙</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>
<span class="cc-action" title="管理${cfg.label}绑定">⚙</span>
</div>`;
}
return html;
}).join("");
// 绑定事件:卡片整体点击 → selectTask(有 tid 时);右侧 cc-action 点击 → openWechatModal,阻止冒泡
box.querySelectorAll(".channel-card").forEach((el) => {
const action = el.dataset.action;
if (action === "bind" || action === "manage") {
el.onclick = () => openWechatModal();
} else if (action === "select") {
el.onclick = (e) => {
if (e.target.closest(".cc-action")) {
e.stopPropagation(); // ⚙ 点开弹框,不触发 selectTask
openWechatModal();
} else {
selectTask(el.dataset.tid);
}
};
}
});
}
// selectTask 切换时同步卡片高亮(卡片 task 不在 .task-row 列表里,需单独刷 active 态)
function syncChannelCardActive(tid) {
document.querySelectorAll("#channel-cards .channel-card").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
}
function taskMenuItems(t) {
const isActive = t.status === "active";
const hasMsg = (t.n_messages || 0) > 0;
// run_status 列表行与 taskMeta 都带(_task_dict 统一出);running/cancelling 时禁清空
const running = taskRunState(t) === "running" || taskRunState(t) === "cancelling";
return [
{ act: "complete", label: "完成", cls: "act-complete", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
{ act: "abandon", label: "废弃", cls: "act-abandon", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
{ act: "export", label: "导出对话", cls: "act-export", disabled: !hasMsg,
onclick: () => exportTask(t.task_id) },
{ act: "clear", label: "清空对话", cls: "act-clear", disabled: !hasMsg || running,
onclick: () => clearMessages(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
{ act: "delete", label: "删除", cls: "act-delete",
onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
];
}
// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发
$("filter-status").onchange = () => loadTaskList();
$("filter-order").onchange = () => loadTaskList();
$("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛
$("btn-refresh-tasks").onclick = () => { loadTaskList(); loadChannelCards(); };
// 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。
function applyTaskFiltersCollapsed(collapsed) {
document.body.classList.toggle("task-filters-collapsed", collapsed);
$("filter-toggle").textContent = collapsed ? "筛选 ▸" : "筛选 ▾";
}
$("filter-toggle").onclick = () => {
const next = !document.body.classList.contains("task-filters-collapsed");
localStorage.setItem(LS_TASK_FILTERS_COLLAPSED, next ? "1" : "0");
applyTaskFiltersCollapsed(next);
};
// 默认折叠:只有用户显式展开过(存 "0")才展开
applyTaskFiltersCollapsed(localStorage.getItem(LS_TASK_FILTERS_COLLAPSED) !== "0");
// 搜索 q 是 text input → 300ms debounce 避免每字符打 API
let _filterDebounce = null;
$("filter-q").addEventListener("input", () => {
clearTimeout(_filterDebounce);
_filterDebounce = setTimeout(() => loadTaskList(), 300);
});
// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
const _taskScrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
loadTaskList({ append: true });
}
}, { root: $("task-scroll"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));
// ───── select task ─────
export async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
if (mqPhone.matches) setMobileView("mv-mid");
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
$("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
// 先锁后放:按钮先置禁用加载态,meta/proc 状态到齐后由下方流程放到正确状态
// (idle/streaming/bgproc)—— 杜绝"刷新/切换瞬间按钮短暂可用"的时序窗口
setActionMode("loading");
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(),
// proc 状态并入预取:后面 renderLiveRunIfVisible 决定按钮态时数据已就位,
// 消掉"刷新后按钮先短暂可用、锁随轮询迟到"的亚秒窗口
refreshProcs(),
]);
state.taskMeta = meta;
renderChatMeta();
applyChannelComposerLock(meta);
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`, meta);
} else {
renderLiveRunIfVisible();
}
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
state.filesPath = wdName || "";
await loadFiles();
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
} catch (e) {
if (e.status === 401) { logout(); return; }
renderTaskProgressDock([]);
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
setActionMode("idle"); // 加载失败也要把"先锁后放"的锁解开,别把 composer 卡死
}
}
// 拉同 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";
}
// 压缩指示环就地重绘(run 中 llm_start / context_fold 事件驱动,不整行重渲 meta —
// 避免打断模型下拉等交互态)。taskMeta 上的 context_* 字段是唯一数据源。
function refreshCtxRing() {
const el = $("ctx-ring");
if (el && state.taskMeta) el.outerHTML = ctxRingHtml(state.taskMeta);
}
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 || "(未命名)";
// 同列表规则:active 静默,终态才挂徽章(它同时解释"输入框为什么消失了")
const statusLabel = { completed: "已完成", abandoned: "已废弃" }[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>
${statusLabel ? `<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)}
${ctxRingHtml(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 || "";
let opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
// 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项,
// 下拉如实显示;用户仍可切回档内模型。
if (cur && !state.models.some(m => m.profile === cur)) {
opts += `<option value="${escapeHtml(cur)}" selected>${escapeHtml(cur)}</option>`;
}
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;
const pw = pop.offsetWidth || 220;
// 同 showMenu:触发按钮靠左时右对齐会冲出左边缘 → 改左对齐并钳在视口内
if (rect.right - pw < 4) {
pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - pw - 4)) + "px";
pop.style.right = "auto";
} else {
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 });
// PATCH 返回不含 context_* 字段(仅详情端点算),merge 保住指示环数据;
// 换模型后容量略陈旧,下一次 llm_start / 重开任务自动校正
state.taskMeta = { ...t, ...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 跳过的行也算在这窗口里,留了余量。
//
// 分页单位仍是消息条数(业界通行:Discord/OpenAI 均按条 + 每页上限,轮次大小无上界
// 不能当传输单位),但向上翻页/跳转的窗口边界尽量对齐到 outline 的 user 轮次起点:
// idx 连续递增(Session.append 递增 / append_channel_message max+1,无单条删除),
// 轮次消息数 = 相邻 outline 边界差,前端即可精确算区间,后端 API 不用动。
const MSG_PAGE = 30;
// 单次拉取硬上限(后端对 limit 无 clamp,靠前端自律)。超大单轮(工具风暴一轮几百条)
// 退化为按条截断 —— 窗口顶落在轮中,后续向上加载会继续朝该轮起点推进,逐步自愈对齐。
const MSG_FETCH_MAX = 100;
// 向上翻页的对齐条数:从当前窗口顶(firstIdx)往前按整轮累加,攒够 MSG_PAGE 条为止,
// 使新窗口顶正好是某轮的 user 消息(不把 assistant 工具调用序列切成孤儿)。
// outline 未加载 / firstIdx 已在首轮之前 → 退回固定 MSG_PAGE(行为同旧版)。
function alignedEarlierLimit(firstIdx) {
const bounds = (state.outline || []).map((it) => it.idx).filter((b) => b < firstIdx);
if (!bounds.length) return MSG_PAGE;
let j = bounds.length - 1;
while (j > 0 && firstIdx - bounds[j] < MSG_PAGE) j--;
return Math.min(firstIdx - bounds[j], MSG_FETCH_MAX);
}
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 之前、按整轮对齐的一批,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 limit = alignedEarlierLimit(firstIdx);
const data = await api(
"GET", `/v1/tasks/${tid}/messages?limit=${limit}&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;未加载 → 从该轮起点精确拉整轮
// (after_idx = 起点-1,limit = 相邻边界差;短轮补到 MSG_PAGE 铺满一屏,超大轮按
// MSG_FETCH_MAX 截断)。窗口首条即目标 user 消息,顶部对齐后正好带全该轮下文,
// 替代旧的 before_idx=idx+11 居中启发式(轮次长时拉不全、短时混入无关上文)。
// idx 不在 outline 里(理论不发生)→ 退回旧启发式窗口。
async function loadMessagesAround(idx) {
const tid = state.taskId;
const items = state.outline || [];
const pos = items.findIndex((it) => it.idx === idx);
let query;
if (pos >= 0) {
const next = items[pos + 1];
const span = next ? next.idx - idx : MSG_PAGE; // 末轮长度未知,按 MSG_PAGE 拉
const limit = Math.min(Math.max(span, MSG_PAGE), MSG_FETCH_MAX);
query = `limit=${limit}&after_idx=${idx - 1}`;
} else {
query = `limit=${MSG_PAGE}&before_idx=${idx + 11}`;
}
const data = await api(
"GET", `/v1/tasks/${tid}/messages?${query}`,
);
if (state.taskId !== tid) return false;
state.loadedMessages = data.messages || [];
state.msgHasMore = !!data.has_more;
state.msgHasMoreNewer = !!data.has_more_after;
state.msgLoadingEarlier = false;
state.msgLoadingNewer = false;
renderMessages(state.loadedMessages);
return true;
}
async function jumpToMessage(idx) {
const wrap = $("chat-stream");
let card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
if (!card) {
let ok = false;
try { ok = await loadMessagesAround(idx); }
catch (e) { if (e.status === 401) { logout(); return; } }
if (!ok) return;
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
}
if (!card) return;
// 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
// 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。
// .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
setActiveOutlineIdx(idx);
lockOutlineDuringJump(); // 锁住活跃圆点:平滑滚动途中的 scroll 事件不得把活跃态抢到途经轮次
card.scrollIntoView({ behavior: "smooth", block: "start" });
card.classList.add("msg-jump-flash");
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
}
// 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。
// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。
const _msgScrollObserver = new IntersectionObserver((entries) => {
for (const en of entries) {
if (!en.isIntersecting) continue;
if (en.target.classList.contains("msg-top-sentinel")) {
if (state.msgHasMore && !state.msgLoadingEarlier) loadEarlierMessages();
} else if (en.target.classList.contains("msg-bot-sentinel")) {
if (state.msgHasMoreNewer && !state.msgLoadingNewer) loadNewerMessages();
}
}
}, { root: $("chat-stream"), rootMargin: "150px 0px" });
// ───── 消息目录(右侧悬浮圆点轨道)─────
// 切 task / run 收尾后拉全部 user 轮次;点圆点 jumpToMessage 定位;滚动时高亮当前轮。
async function refreshOutline() {
const tid = state.taskId;
if (!tid) { state.outline = []; renderOutlineRail(); return; }
try {
const data = await api("GET", `/v1/tasks/${tid}/outline`);
if (state.taskId !== tid) return;
state.outline = data.items || [];
} catch (e) {
if (e.status === 401) { logout(); return; }
state.outline = [];
}
renderOutlineRail();
}
function renderOutlineRail() {
const rail = $("msg-outline-rail");
if (!rail) return; // embed 等精简页无此元素 → no-op
const items = state.outline || [];
if (items.length < 2) { // 0/1 轮没必要显示目录
rail.style.display = "none";
rail.innerHTML = "";
return;
}
rail.style.display = "";
rail.innerHTML = items.map((it, i) => {
const label = it.snippet || `${i + 1}`;
return `<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));
});
}
// 点圆点跳转期间锁定活跃态:平滑滚动会连发 scroll 事件,若不锁,updateActiveOutlineDot
// 会按动画途中的位置反复改写,把刚点中的圆点抢成途经轮次(表现:点 #1 不变红 / 跳到 #2)。
let _outlineJumpLock = false;
let _outlineJumpTimer = 0;
function lockOutlineDuringJump() {
_outlineJumpLock = true;
clearTimeout(_outlineJumpTimer);
// 平滑滚动一般 <500ms;700ms 兜底解锁后按落点重算一次(触底轮由下面 atBottom 分支兜)
_outlineJumpTimer = setTimeout(() => {
_outlineJumpLock = false;
updateActiveOutlineDot();
}, 700);
}
// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
function updateActiveOutlineDot() {
const rail = $("msg-outline-rail");
if (!rail || rail.style.display === "none") return;
if (_outlineJumpLock) return; // 显式跳转动画期间不抢
const wrap = $("chat-stream");
const items = state.outline || [];
// 触底兜底:最后几轮永远顶不到顶线(容器先到底),按原逻辑会一直停在倒数第二个
// (表现:点最后一个 / 滚到底时倒数第二个变红)。滚到容器底且无更新内容可加载时,
// 直接判最后一个已加载轮为当前。
if (!state.msgHasMoreNewer
&& wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 2) {
for (let i = items.length - 1; i >= 0; i--) {
if (wrap.querySelector(`.msg[data-idx="${items[i].idx}"]`)) {
setActiveOutlineIdx(items[i].idx);
return;
}
}
}
const top = wrap.getBoundingClientRect().top;
let activeIdx = null;
for (const it of items) {
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
if (!card) continue;
// 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界
// 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。
if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx;
else break; // outline 升序,首个落在视口下方的之后都更靠下
}
if (activeIdx != null) setActiveOutlineIdx(activeIdx);
}
let _outlineRaf = 0;
$("chat-stream").addEventListener("scroll", () => {
if (_outlineRaf) return;
_outlineRaf = requestAnimationFrame(() => { _outlineRaf = 0; updateActiveOutlineDot(); });
});
// embed 等精简页无 outline-rail 元素 → 跳过绑定(renderOutlineRail 已 null-safe)
const _outlineRailEl = $("msg-outline-rail");
if (_outlineRailEl) {
_outlineRailEl.addEventListener("click", (e) => {
const dot = e.target.closest(".ol-dot");
if (dot) jumpToMessage(Number(dot.dataset.idx));
});
}
function getLiveRun(taskId) {
return taskId ? state.liveRuns.get(taskId) : null;
}
function isCurrentTaskStreaming() {
return !!getLiveRun(state.taskId);
}
// 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字
// 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。
// 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。
function ensureTextSeg(run) {
if (run.curSeg) return run.curSeg;
const el = document.createElement("div");
el.className = "body streaming";
run.card.appendChild(el);
run.curSeg = { el, acc: "", pending: false };
return run.curSeg;
}
// 关闭当前文字段:空占位段(还没吐字就来了工具)直接移除,避免留下「思考中」孤块;
// 有内容的段落定稿(去光标 + 代码高亮),之后的新文字会另起新段。
function closeTextSeg(run) {
const seg = run.curSeg;
if (!seg) return;
if (!seg.acc) {
seg.el.remove();
} else {
seg.el.classList.remove("streaming");
highlightIn(seg.el);
}
run.curSeg = null;
}
// reasoning(思考过程)段:thinking 模型的推理 delta 流进灰色可折叠卡,首个正文
// delta / 工具调用到达时定稿折叠。插在当前空占位段之前,保持「思考过程 → 正文」
// 时序;与历史渲染同款结构(details.reasoning),run 结束 reload 无跳变。
function ensureReasonSeg(run) {
if (run.curReason) return run.curReason;
const det = document.createElement("details");
det.className = "reasoning streaming";
det.open = true;
det.innerHTML = `<summary>思考过程</summary><div class="reason-body"></div>`;
const anchor = run.curSeg && !run.curSeg.acc ? run.curSeg.el : null;
if (anchor) run.card.insertBefore(det, anchor);
else run.card.appendChild(det);
run.curReason = { el: det, body: det.querySelector(".reason-body"), acc: "", pending: false };
return run.curReason;
}
function closeReasonSeg(run) {
const rs = run.curReason;
if (!rs) return;
if (!rs.acc) {
rs.el.remove();
} else {
rs.el.classList.remove("streaming");
rs.el.open = false; // 正文开始后折叠,点开可回看
}
run.curReason = null;
}
// ───── run 活跃状态指示(阶段 + 已耗时) ─────
// 长耗时阶段(TTFT / 深度推理 / 工具执行)原本页面全静止,和卡死无法区分。
// 每秒把「阶段 · Ns」写进空占位段的 data-status(CSS :empty::before 渲染),
// 工具卡运行中在 summary 里跳秒 —— 秒数在走本身就是最强的"没卡住"信号。
let _statusTicker = null;
function setRunPhase(run, phase) {
run.phase = phase;
run.phaseSince = Date.now();
tickRunStatus(run);
if (!_statusTicker) {
_statusTicker = setInterval(() => {
if (!state.liveRuns.size) { clearInterval(_statusTicker); _statusTicker = null; return; }
state.liveRuns.forEach(tickRunStatus);
}, 1000);
}
}
function tickRunStatus(run) {
const secs = Math.floor((Date.now() - (run.phaseSince || Date.now())) / 1000);
const suffix = secs >= 2 ? ` · ${secs}s` : "";
if (run.curSeg && !run.curSeg.acc && run.curSeg.el) {
const label = run.phase === "reasoning" ? "深度思考中" : "思考中";
run.curSeg.el.dataset.status = label + suffix;
}
if (run.toolElapsedEl && suffix) run.toolElapsedEl.textContent = suffix;
}
// 工具执行收尾:去 spinner,elapsed 定格(留着当执行耗时看)
function finishRunningTool(run) {
if (run.runningToolEl) { run.runningToolEl.classList.remove("running"); run.runningToolEl = null; }
run.toolElapsedEl = null;
}
function createLiveAssistantCard(run) {
const card = document.createElement("div");
card.className = "msg assistant live-run";
card.innerHTML = `<div class="role">助手</div>`;
run.card = card;
run.curSeg = null;
if (run.acc) {
// 重连:已累积文字作为初始(仍打开的)文字段渲染,后续事件在其后穿插
const el = document.createElement("div");
el.className = "body streaming";
el.innerHTML = renderMd(run.acc);
card.appendChild(el);
run.curSeg = { el, acc: run.acc, pending: false };
} else {
ensureTextSeg(run); // 空占位段:首字到达前显示「思考中」
}
// 刷新/重连接管:phaseSince 随页面内存丢了,阶段未知先按"思考中"起跳
// (计时从重连时刻起,非真实累计);下一个 llm_start/tool_call 事件会校正。
// 不跳秒的静态"思考中"恰是用户刷新的诱因,重连后必须立刻恢复活跃信号。
if (!run.phaseSince) setRunPhase(run, run.phase || "llm");
return card;
}
function renderLiveRunIfVisible() {
const run = getLiveRun(state.taskId);
if (!run) {
// run 收尾/切 task 后:本 task 还有 running 后台进程 → 保持对话锁定不回 idle
if (hasRunningProc(state.taskId)) {
setActionMode("bgproc");
if (!_bgprocLocked) {
_bgprocLocked = true;
$("chat-hint").textContent = "后台进程运行中,完成后可继续对话…";
}
} else {
_bgprocLocked = false;
setActionMode("idle");
}
return;
}
const wrap = $("chat-stream");
// card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲);
// 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。
const card = run.card || createLiveAssistantCard(run);
renderTaskProgressDock(run.progressSteps || []);
if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight;
setActionMode(run.cancelling ? "cancelling" : "streaming");
$("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
}
// seed = 该 task 的 API dict(taskMeta 或列表行),取 run_status/working_dir。
// 之前从全局 state.taskMeta 读 —— 只对"订阅选中 task"成立;现在列表也会给后台
// running task 挂订阅,workingDir 必须跟着各自 task 走(媒体产物 rel 解析用它)。
function ensureRunningTaskSubscribed(taskId, url, seed = {}) {
if (!taskId || getLiveRun(taskId)) return;
const run = {
taskId,
url,
acc: "",
seenRels: new Set(),
terminal: false,
card: null,
curSeg: null,
cancelling: seed.run_status === "cancelling",
workingDir: seed.working_dir || "",
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
state.liveRuns.set(taskId, run);
state.streaming = true;
syncTaskRowRunIndicator(taskId);
// 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰
// 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话)
if (taskId === state.taskId) renderLiveRunIfVisible();
streamSse(url, run);
}
function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}
// 进度只在对话区顶部的单一 dock 里渲染(codex 式钉顶面板),不再内联进每条消息卡。
// 进行中:展开实时显示 pending/in_progress/completed;全部完成:折叠成一行摘要,点开看清单。
function renderTaskProgressDock(steps) {
const dock = $("task-progress-dock");
if (!dock) return;
if (!Array.isArray(steps) || !steps.length) {
dock.innerHTML = "";
dock.classList.remove("show");
return;
}
const total = steps.length;
const done = steps.filter(s => s.status === "completed").length;
const allDone = done === total;
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
const rows = steps.map((s) => `
<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)}
`;
// bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
// 由 selectTask 尾部的 refreshProcs 校正,终态卡显示 exit/耗时定格)
decorateBgprocCard(card.querySelector("details.tool-call"), txt || "");
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>`;
// 思考过程(thinking 模型持久化的 reasoning_content):默认折叠,与直播态同构
if (role === "assistant") {
const rc = (typeof p.reasoning_content === "string" && p.reasoning_content)
|| ((p.provider_specific_fields || {}).reasoning_content || "");
if (rc) {
html += `<details class="reasoning"><summary>思考过程</summary><div class="reason-body">${escapeHtml(rc)}</div></details>`;
}
}
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;
} else if (mode === "bgproc") {
// 后台进程运行期:对话锁定,观感与前台执行一致(发送→停止)。后台化的收益
// 定位是"进程扛超时/服务重启",不改变"一个任务同时只做一件事"的对话心智。
btn.textContent = "停止";
btn.classList.add("danger");
btn.disabled = false;
btn.title = "终止后台进程(已产生的输出会保留)";
} else if (mode === "loading") {
// 切 task / 刷新的加载窗口:先锁后放(悲观默认),状态到齐再切真实模式
btn.textContent = "发送";
btn.classList.add("primary");
btn.disabled = true;
}
}
// 无直播 run 时的 composer 状态:有本 task 的 running 后台进程 → 锁定(bgproc),
// 否则 idle。procs.js 每次拉取后回调,进程结束的那次拉取在这里解锁。
let _bgprocLocked = false;
export function syncBgprocLock() {
if (!state.taskId || getLiveRun(state.taskId)) return; // 直播 run 拥有按钮
if (hasRunningProc(state.taskId)) {
setActionMode("bgproc");
// hint 只在进入锁定那一刻写一次 —— 5s 轮询重复写会盖掉"已转写"/"润色中"
// 等临时提示(润色/语音在锁定期与 streaming 期一样可用,只编辑草稿不发送)
if (!_bgprocLocked) {
_bgprocLocked = true;
$("chat-hint").textContent = "后台进程运行中,完成后可继续对话…";
}
} else if (_bgprocLocked) {
_bgprocLocked = false;
setActionMode("idle");
$("chat-hint").textContent = "";
}
}
function chatAction() {
if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; }
if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; }
sendMessage();
}
$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); });
$("chat-input").addEventListener("keydown", (e) => {
// streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// streaming / 后台进程锁定 / 加载态期间 Enter 不发送(与按钮语义一致,防误触)
if ($("chat-action").disabled) return;
if (!isCurrentTaskStreaming() && !hasRunningProc(state.taskId)) sendMessage();
}
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦,
// 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认。
$("chat-input").addEventListener("paste", (e) => {
const files = Array.from(e.clipboardData?.files || []);
if (!files.length) return;
e.preventDefault();
uploadAttachFiles(files);
});
// 拖拽落点 = 整个 #chat-form(命中面积大);用 enter/leave 计数防子元素冒泡时高亮闪烁。
// 只认文件拖拽(_hasFiles),只读镜像(微信渠道)不接收。
let _composerDragDepth = 0;
function _dragHasFiles(ev) {
const t = ev.dataTransfer;
return !!(t && t.types && [...t.types].includes("Files"));
}
function _composerLocked() {
const input = $("chat-input");
return !!(input && input.readOnly);
}
$("chat-form").addEventListener("dragenter", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
_composerDragDepth++;
$("chat-form").classList.add("drag-over");
});
$("chat-form").addEventListener("dragover", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
$("chat-form").addEventListener("dragleave", (e) => {
if (!_dragHasFiles(e)) return;
_composerDragDepth = Math.max(0, _composerDragDepth - 1);
if (_composerDragDepth === 0) $("chat-form").classList.remove("drag-over");
});
$("chat-form").addEventListener("drop", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
_composerDragDepth = 0;
$("chat-form").classList.remove("drag-over");
const files = Array.from(e.dataTransfer.files || []);
if (files.length) uploadAttachFiles(files);
});
// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。
async function uploadAttachFiles(files) {
if (!files || !files.length) return;
const hint = $("chat-hint");
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, {
onProgress: (loaded, total) => {
hint.textContent = formatUploadProgress(files, loaded, total);
},
});
if (saved && saved.length) {
addAttachChips(saved);
const n = attachCount();
hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
}
// 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖)
}
function attachTray() { return $("chat-attach"); }
function attachWraps() {
const tray = attachTray();
return tray ? Array.from(tray.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
}
function attachCount() { return attachWraps().length; }
// 切 task 时清掉上个 task 残留的未发送 chip(它们指向上个 task_dir,新 task 用不上)。
// 只清 DOM,不删已上传的文件(用户可能切回去发,文件还在原目录)。
function clearAttachTray() {
const tray = attachTray();
if (!tray) return;
tray.innerHTML = "";
tray.classList.remove("show");
}
// 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。
function addAttachChips(saved) {
const tray = attachTray();
if (!tray) return;
const existing = new Set(attachWraps().map((w) => w.dataset.rel));
for (const f of saved || []) {
const rel = f.rel || f.name || "";
if (!rel || existing.has(rel)) continue;
existing.add(rel);
const name = f.name || (rel.split("/").pop() || rel);
tray.insertAdjacentHTML("beforeend",
`<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>`);
}
tray.classList.toggle("show", attachCount() > 0);
}
attachTray().addEventListener("click", (e) => {
const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
if (del) {
e.stopPropagation();
deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap"));
return;
}
const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
if (!chip) return;
const rel = chip.dataset.rel;
if (rel) openPasteFilePreview(rel);
});
async function deletePastedFile(rel, wrap) {
if (!rel || !wrap) return;
const btn = wrap.querySelector(".paste-chip-del");
if (btn) btn.disabled = true;
try {
await api("POST", "/v1/files/delete", { path: rel, recursive: false });
closePreviewIfShowing(rel);
wrap.remove();
await loadFiles();
const tray = attachTray();
if (tray) tray.classList.toggle("show", attachCount() > 0);
if (attachCount() === 0) {
$("chat-hint").innerHTML = `<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 换行,可粘贴 / 拖拽文件)";
if (cfg) {
const opt = $("chat-optimize");
if (opt) opt.disabled = true;
}
const mic = $("chat-mic");
if (mic) mic.disabled = !!cfg; // 只读镜像没有发送入口,语音输入同样锁掉
}
// 润色:同步调后端,把 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;
// ── 语音输入(讯飞 IAT)────────────────────────────────────────────────────────
// 采集:Web Audio ScriptProcessor 采 PCM。转写走双通道:
// - 流式(主通道):录音开始即连 WS /v1/asr/stream(首消息发 JWT),每片音频增量降采样
// 16k/16bit 实时上行,服务端转发讯飞并回推 partial 全文(wpgs 动态修正在服务端合并),
// 录音面板 #vr-live 实时字幕;停止只需等最终帧(百 ms 级)—— 消掉旧"整段按 40ms/帧
// 回放给讯飞"造成的按时长等比例停顿。
// - 整段(兜底):WS 连不上(老服务端/网络)或中途挂 → 停止后把累积 PCM POST
// /v1/asr/transcribe(原链路保留)。
// 最终文本插到输入框光标处(insertText 接原生 undo 栈,不自动发送)。
// 手势两端统一(PC 鼠标 / 手机触屏同一心智模型),按下时长判别,共用同一条
// 采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
// - 按住(≥300ms)= 按住说话:松开即转写,上滑超阈值松开取消(微信 / DeepSeek 式)。
// - 快速点一下(<300ms)= 连续录音(免持,长口述不用一直按着 —— 鼠标按住 60s 会累且
// 易失手):说完 Enter / 再点一下结束并转写,Esc 取消。
// 注:不做 VAD 静音自动停 —— 口述任务需求时"边想边说"停顿 3~5s 是常态,自动截断
// 比多按一下更伤;业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾。
// 录音期 #voice-rec-panel 替换 textarea:实时波浪(录音 chunk 顺手算 RMS,零额外
// 采集开销)+ 计时 + 手势提示。
const ASR_MAX_SECONDS = 60;
const HOLD_CANCEL_PX = 60; // 按住说话:上滑多远进入"松开取消"
let _voice = null; // 录音态:{stream,ctx,source,node,chunks,levels,rate,t0,timer,raf,mode,startY,cancelArmed}
let _holdReleased = false; // 手指在 getUserMedia 授权完成前已松开 → 启动完成后立即收尾
let _suppressClickUntil = 0; // touch 序列结束后浏览器补发的合成 click,不当 toggle 用
// 线性插值降采样到 16k 并量化 Int16 小端(讯飞只收 16k/16bit/mono raw)
function _pcm16kFromChunks(chunks, srcRate) {
let total = 0;
for (const c of chunks) total += c.length;
const src = new Float32Array(total);
let off = 0;
for (const c of chunks) { src.set(c, off); off += c.length; }
const ratio = srcRate / 16000;
const out = new Int16Array(Math.floor(src.length / ratio));
for (let i = 0; i < out.length; i++) {
const pos = i * ratio, i0 = Math.floor(pos), i1 = Math.min(i0 + 1, src.length - 1);
const s = src[i0] + (src[i1] - src[i0]) * (pos - i0);
out[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
}
return out;
}
// 增量重采样器:逐片喂 Float32 原始采样,吐 16k Int16;片间用 carry 缓冲保持插值连续
// (每片独立重采样会在拼缝处引入细微断裂,长音频里累积影响识别)。
function _makeResampler16k(srcRate) {
const ratio = srcRate / 16000;
let carry = new Float32Array(0);
return (chunk) => {
let src = chunk;
if (carry.length) {
src = new Float32Array(carry.length + chunk.length);
src.set(carry, 0);
src.set(chunk, carry.length);
}
const n = Math.max(0, Math.floor((src.length - 1) / ratio));
const out = new Int16Array(n);
for (let i = 0; i < n; i++) {
const pos = i * ratio, i0 = Math.floor(pos);
const s = src[i0] + (src[i0 + 1] - src[i0]) * (pos - i0);
out[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
}
carry = src.slice(Math.floor(n * ratio));
return out;
};
}
function _vrHint(text) { $("vr-hint").textContent = text; }
// 转写结果统一入口:光标处插入(不覆盖已有草稿);execCommand 接 textarea 原生 undo 栈
function _insertTranscript(text) {
const ta = $("chat-input");
ta.focus();
const ok = document.execCommand("insertText", false, text);
if (!ok) ta.value += text;
syncOptimizeBtn();
$("chat-hint").textContent = "已转写,确认后发送";
}
function _drawWave() {
if (!_voice) return;
const cv = $("vr-wave"), g = cv.getContext("2d");
const W = cv.width, H = cv.height, dpr = window.devicePixelRatio || 1;
g.clearRect(0, 0, W, H);
g.fillStyle = _voice.cancelArmed ? "#fff" : "#d33";
const bw = 3 * dpr, gap = 2 * dpr;
const lv = _voice.levels.slice(-Math.floor(W / (bw + gap)));
for (let i = 0; i < lv.length; i++) {
const h = Math.max(2 * dpr, Math.min(1, lv[i] * 6) * H);
g.fillRect(W - (lv.length - i) * (bw + gap), (H - h) / 2, bw, h);
}
_voice.raf = requestAnimationFrame(_drawWave);
}
async function startVoiceInput(mode, startY) {
if (_voice) return;
const btn = $("chat-mic");
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
$("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
} catch (e) {
$("chat-hint").textContent = "无法使用麦克风:" +
(e.name === "NotAllowedError" ? "请在浏览器里允许麦克风权限" : e.message);
return;
}
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {} // iOS Safari:手势内 resume 才真正开跑
const source = ctx.createMediaStreamSource(stream);
// ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [], levels = [];
const vo = { stream, ctx, source, node, chunks, levels, rate: ctx.sampleRate,
t0: Date.now(), timer: 0, raf: 0, mode, startY: startY || 0, cancelArmed: false,
// 流式通道状态:wsDead 置位后 stop 走整段 POST 兜底;finalText/finalResolve
// 承接服务端 final 帧(可能先于用户松手 —— 静音超 10s / 60s 引擎自行收尾)
ws: null, wsDead: false, live: "", finalText: null, finalResolve: null,
resample: _makeResampler16k(ctx.sampleRate) };
_voice = vo;
node.onaudioprocess = (ev) => {
const buf = new Float32Array(ev.inputBuffer.getChannelData(0));
chunks.push(buf); // 全量累积:流式挂了停止时还能整段 POST 兜底
let sum = 0, n = 0;
for (let i = 0; i < buf.length; i += 8) { sum += buf[i] * buf[i]; n++; } // 1/8 抽样够画波浪
levels.push(Math.sqrt(sum / n));
if (vo.ws && vo.ws.readyState === 1 && !vo.wsDead) {
const out = vo.resample(buf);
if (out.length) { try { vo.ws.send(out.buffer); } catch (e) { vo.wsDead = true; } }
}
};
source.connect(node);
node.connect(ctx.destination); // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声
// 流式通道:失败不阻塞录音(采集照跑),只标 wsDead 让停止走兜底
try {
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/v1/asr/stream");
ws.onopen = () => { try { ws.send(JSON.stringify({ token: state.token })); } catch (e) { vo.wsDead = true; } };
ws.onmessage = (ev) => {
let m = null;
try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.error) { vo.wsDead = true; try { ws.close(); } catch (e) {} return; }
vo.live = m.text || "";
if (_voice === vo) $("vr-live").textContent = vo.live; // 实时字幕
if (m.final) {
vo.finalText = vo.live;
if (vo.finalResolve) vo.finalResolve(vo.finalText);
else if (_voice === vo) stopVoiceInput(false); // 引擎提前收尾 → 自动结束录音
}
};
ws.onerror = () => { vo.wsDead = true; };
ws.onclose = () => {
if (vo.finalText == null) vo.wsDead = true; // 没等到 final 就关了 = 通道废
if (vo.finalResolve) vo.finalResolve(vo.finalText); // 别让 stop 干等超时
};
vo.ws = ws;
} catch (e) { vo.wsDead = true; }
$("vr-live").textContent = "";
$("chat-form").classList.add("recording");
$("voice-rec-panel").classList.remove("cancel-armed");
const cv = $("vr-wave"), dpr = window.devicePixelRatio || 1;
cv.width = Math.max(1, cv.clientWidth * dpr); // 面板刚显示,读 clientWidth 强制同步布局
cv.height = Math.max(1, cv.clientHeight * dpr);
_vrHint(mode === "hold" ? "松开转写 · 上滑取消" : "说完按 Enter 结束 · Esc 取消");
btn.classList.add("recording");
if (mode === "click") btn.textContent = "■ 停止";
const tick = () => {
if (!_voice) return;
const sec = Math.floor((Date.now() - _voice.t0) / 1000);
$("vr-time").textContent = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(false); return; }
_voice.timer = setTimeout(tick, 500);
};
tick();
_drawWave();
$("chat-hint").textContent = "录音中…";
// 按住说话但 getUserMedia 完成前已松开(快速点按 / 授权弹窗期间松手):
// 短按 → 照常转成连续录音;长按(多半是授权弹窗耗掉的)→ 按"松开"收尾(太短走取消)
if (mode === "hold" && _holdReleased) {
if (Date.now() - _pressT0 < HOLD_TOGGLE_MS && !_voice.cancelArmed) {
_voice.mode = "click";
btn.textContent = "■ 停止";
_vrHint("说完按 Enter 结束 · Esc 取消");
} else {
stopVoiceInput(_voice.cancelArmed);
}
}
}
async function stopVoiceInput(discard) {
const v = _voice;
if (!v) return;
_voice = null;
clearTimeout(v.timer);
cancelAnimationFrame(v.raf);
const btn = $("chat-mic");
btn.classList.remove("recording");
btn.textContent = "🎙 语音";
$("chat-form").classList.remove("recording");
$("voice-rec-panel").classList.remove("cancel-armed");
$("vr-time").textContent = "0:00";
try { v.source.disconnect(); v.node.disconnect(); } catch (e) {}
v.stream.getTracks().forEach((t) => t.stop());
try { await v.ctx.close(); } catch (e) {}
$("vr-live").textContent = "";
if (discard) {
if (v.ws) { try { v.ws.close(); } catch (e) {} } // 服务端见 disconnect 即丢弃会话
$("chat-hint").textContent = "已取消";
return;
}
const pcm = _pcm16kFromChunks(v.chunks, v.rate);
if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触
if (v.ws) { try { v.ws.close(); } catch (e) {} }
$("chat-hint").textContent = "录音太短,已取消";
return;
}
// ── 流式通道健在:发 end 只等最终帧(音频早已实时上行)──
if (v.ws && !v.wsDead && (v.finalText != null || v.ws.readyState === 1)) {
$("chat-hint").textContent = "转写中…";
const text = v.finalText != null ? v.finalText : await new Promise((res) => {
v.finalResolve = res; // ws.onmessage(final) / onclose 会 resolve
try { v.ws.send(JSON.stringify({ type: "end" })); } catch (e) { res(null); }
setTimeout(() => res(null), 10000); // 超时兜底 → 落整段 POST
});
try { v.ws.close(); } catch (e) {}
if (text != null) {
if (!text.trim()) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
_insertTranscript(text.trim());
return;
}
}
// ── 整段 POST 兜底(WS 没连上 / 中途挂 / 等最终帧超时)──
btn.disabled = true;
$("chat-hint").textContent = "转写中…";
try {
const r = await fetch("/v1/asr/transcribe", {
method: "POST",
headers: { "Authorization": "Bearer " + state.token, "Content-Type": "application/octet-stream" },
body: pcm.buffer,
});
if (r.status === 401) { logout(); return; }
const data = await r.json().catch(() => null);
if (!r.ok) throw new Error((data && data.detail) || r.status + " " + r.statusText);
const text = ((data && data.text) || "").trim();
if (!text) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
_insertTranscript(text);
} catch (e) {
$("chat-hint").textContent = "转写失败:" + e.message;
} finally {
btn.disabled = false;
}
}
// 手势接线(两端统一,按下时长判别):按住 ≥300ms = 按住说话(松开转写);短按 <300ms
// = 转成连续录音(免持)。press 发起的录音,其松手后浏览器补发的合成 click 一律压掉 ——
// 否则"松开转写完又立刻开新录音"/"短按刚转免持就被自己的 click 停掉"。点按钮停止连续
// 录音的那次 press 不压(pointerdown 因 _voice 存在没发起录音,_pressActive=false)。
// 键盘激活按钮(Space/Enter,无 pointer 事件)兜底走 click 起录。
const HOLD_TOGGLE_MS = 300;
const _micBtn = $("chat-mic");
let _pressT0 = 0; // pointerdown 时刻,判短按/长按
let _pressActive = false; // 本次 press 确实发起了录音
_micBtn.addEventListener("pointerdown", (e) => {
e.preventDefault(); // 触屏压掉长按选中/滚动(CSS 已 touch-action:none);鼠标压掉拖选
if (_voice || _micBtn.disabled) return;
_pressActive = true;
_pressT0 = Date.now();
_holdReleased = false;
try { _micBtn.setPointerCapture(e.pointerId); } catch (err) {} // 滑出按钮仍收 move/up
startVoiceInput("hold", e.clientY);
});
_micBtn.addEventListener("pointermove", (e) => {
if (!_voice || _voice.mode !== "hold") return;
const armed = (_voice.startY - e.clientY) > HOLD_CANCEL_PX;
if (armed !== _voice.cancelArmed) {
_voice.cancelArmed = armed;
$("voice-rec-panel").classList.toggle("cancel-armed", armed);
_vrHint(armed ? "松开取消" : "松开转写 · 上滑取消");
}
});
const _holdEnd = (e) => {
if (!_pressActive) return;
_pressActive = false;
_suppressClickUntil = Date.now() + 700;
_holdReleased = true;
if (!_voice || _voice.mode !== "hold") return; // 还在 getUserMedia → startVoiceInput 末尾兜底
if (e.type !== "pointercancel" && !_voice.cancelArmed && Date.now() - _pressT0 < HOLD_TOGGLE_MS) {
// 短按 → 连续录音(免持):说完 Enter / 再点一下结束
_voice.mode = "click";
_micBtn.textContent = "■ 停止";
_vrHint("说完按 Enter 结束 · Esc 取消");
return;
}
stopVoiceInput(_voice.cancelArmed || e.type === "pointercancel");
};
_micBtn.addEventListener("pointerup", _holdEnd);
_micBtn.addEventListener("pointercancel", _holdEnd);
_micBtn.addEventListener("contextmenu", (e) => e.preventDefault()); // 长按不出系统菜单
_micBtn.addEventListener("click", () => {
if (Date.now() < _suppressClickUntil) return;
if (_voice) { if (_voice.mode === "click") stopVoiceInput(false); }
else startVoiceInput("click"); // 仅键盘激活可达(press 路径的 click 已被压掉)
});
// 桌面录音收尾快捷键(capture 拦截,防 Enter 落回 textarea 触发发消息)
document.addEventListener("keydown", (e) => {
if (!_voice || _voice.mode !== "click") return;
if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(false); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(true); }
}, true);
// 对话流里 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 wraps = attachWraps();
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
wraps.forEach((w) => w.remove());
const tray = attachTray();
if (tray) tray.classList.remove("show");
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: "",
seenRels: new Set(),
terminal: false,
card: asstCard,
curSeg: null,
cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
setRunPhase(run, "llm"); // POST 返回即起跳:覆盖 build_agent + 首轮 TTFT 的空窗
state.liveRuns.set(taskId, run);
state.streaming = true;
syncTaskRowRunIndicator(taskId);
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");
syncTaskRowRunIndicator(state.taskId);
$("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");
syncTaskRowRunIndicator(run.taskId);
$("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 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):定稿当前打开的文字段,
// 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。
closeReasonSeg(ctx); // 仍开着的思考过程段定稿折叠(如推理中被 cancel)
if (ctx.curSeg && ctx.curSeg.el) {
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
}
if (ctx.card) highlightIn(ctx.card);
} finally {
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
if (ctx.card) {
ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
ctx.card.querySelectorAll(".tool-call.running").forEach((d) => d.classList.remove("running"));
ctx.card.querySelectorAll("details.reasoning.streaming").forEach((d) => { d.classList.remove("streaming"); d.open = false; });
}
state.liveRuns.delete(ctx.taskId);
state.streaming = state.liveRuns.size > 0;
syncTaskRowRunIndicator(ctx.taskId); // 先按本地态即时清标识,loadTaskList 随后带回服务端真相(如 error)
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;
refreshProcs(); // run 收尾顺手校正后台进程条(run 中可能起了/杀了 proc)
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;
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));
// 每轮 LLM 起点带最新窗口体量 + 容量 → 实时刷压缩指示环
const cs = ctx.contextStats;
if (visible && state.taskMeta && cs.context_limit_chars) {
state.taskMeta.context_window_chars = cs.context_original_chars || 0;
state.taskMeta.context_limit_chars = cs.context_limit_chars;
state.taskMeta.context_pressure =
(cs.context_original_chars || 0) / cs.context_limit_chars;
refreshCtxRing();
}
// 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示
// 「思考中 · Ns」,而不是页面静止
ensureTextSeg(ctx);
setRunPhase(ctx, "llm");
} else if (t === "llm_end") {
ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats);
setRunHint(ctx, ctx.lastUsageHint);
} else if (t === "reasoning" && ev.data && ev.data.delta) {
if (ctx.phase !== "reasoning") setRunPhase(ctx, "reasoning");
const rs = ensureReasonSeg(ctx);
rs.acc += ev.data.delta;
if (!rs.pending) {
rs.pending = true;
requestAnimationFrame(() => {
rs.body.textContent = rs.acc;
rs.body.scrollTop = rs.body.scrollHeight; // 内滚容器跟到最新一行
rs.pending = false;
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "text" && ev.data && ev.data.delta) {
closeReasonSeg(ctx); // 正文开始 → 思考过程定稿折叠
ctx.acc += ev.data.delta;
const seg = ensureTextSeg(ctx); // 无打开文字段则在卡片底部另起一段
seg.acc += ev.data.delta;
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖。闭包捕获 seg —— 若 rAF
// 触发前来了工具调用把当前段关掉,仍渲染这一段自己的累积文本,不会错渲到别的段。
if (!seg.pending) {
seg.pending = true;
requestAnimationFrame(() => {
seg.el.innerHTML = renderMd(seg.acc);
seg.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);
// dock 是 chat-stream 上方的 flex 兄弟:它一涨,chat-stream 可视高度就缩,
// 原本贴底的内容被挤到视口下方看不见。进度是隐形动作会早 return 跳过末尾的
// 触底兜底,所以这里补一次:贴底时重新钉到底,别让 dock 展开遮住最新内容。
if (nearBottom) stream.scrollTop = stream.scrollHeight;
return; // 进度是隐形动作,不落可见卡 → 不打断当前文字段
}
closeReasonSeg(ctx); // 思考过程随文字段一起定稿
closeTextSeg(ctx); // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段
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 running";
det.innerHTML = `<summary>${escapeHtml(label)}<span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
ctx.runningToolEl = det;
ctx.toolElapsedEl = det.querySelector(".tool-elapsed");
setRunPhase(ctx, "tool");
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 阶段渲染,结果是占位不展示
finishRunningTool(ctx); // 去 spinner,elapsed 定格
closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段
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 刷新右侧
// bg proc 刚启动([Background] 开头的结果)→ 结果卡活化(spinner/跳秒/停止)
// + 立刻拉一次状态,秒数即时起跳
if (txtStr.startsWith("[Background]")) {
decorateBgprocCard(det, txtStr);
refreshProcs();
}
} else if (t === "context_fold") {
// 长会话中段折叠(§8.8 Phase 2):start 阶段在状态行提示(摘要调用要几秒,别静止),
// done 阶段插一条与 model_switch 同款的分隔线,让"上下文被整理过"在流里可见。
const d = ev.data || {};
if (d.phase === "start") {
setRunHint(ctx, `整理长对话上下文中…(折叠 ${d.folding_messages || 0} 条早期消息)`);
} else if (d.phase === "done") {
closeTextSeg(ctx);
const sep = document.createElement("div");
sep.className = "context-fold muted";
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
sep.textContent = `── 已整理长对话:${d.folded_messages || 0} 条早期消息折叠为摘要`
+ `(ctx ${fmtTokens(d.before_chars || 0)}${fmtTokens(d.after_chars || 0)} chars)──`;
asstCard.appendChild(sep);
// 折叠计数 + 折叠后体量同步到指示环(limit 不变,窗口骤降 → 环回绿)
if (visible && state.taskMeta) {
state.taskMeta.context_folds = (state.taskMeta.context_folds || 0) + 1;
if (d.after_chars && state.taskMeta.context_limit_chars) {
state.taskMeta.context_window_chars = d.after_chars;
state.taskMeta.context_pressure =
d.after_chars / state.taskMeta.context_limit_chars;
}
refreshCtxRing();
}
}
} else if (t === "model_switch") {
// skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线,
// 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。
const prof = (ev.data && ev.data.model_profile) || "";
if (prof) {
const dn = (state.models.find(x => x.profile === prof) || {}).display_name || prof;
closeTextSeg(ctx);
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}skill 指定)──`;
asstCard.appendChild(sep);
if (visible && state.taskMeta) {
state.taskMeta.model_profile = prof;
const sel = $("chat-model-sel");
if (sel) {
// 档外定向模型不在该用户的下拉清单里 → 临时补一项,保持显示真实
if (![...sel.options].some(o => o.value === prof)) {
const o = document.createElement("option");
o.value = prof; o.textContent = dn;
sel.appendChild(o);
}
sel.value = prof;
}
$("chat-hint").textContent = `skill 指定模型 → ${dn}`;
}
}
} else if (t === "warn") {
// loop 的运行时提醒(无进展熔断/重复拦截/折叠失败等)。此前被静默丢弃,与"停下
// 不静默"的意图相悖 —— 渲染成流内 muted 单行,不打断当前文字段的排版节奏。
const msg = (ev.data && ev.data.msg) || "";
if (msg) {
closeTextSeg(ctx);
const w = document.createElement("div");
w.className = "run-warn muted";
w.style.cssText = "margin:6px 0;font-size:12px;color:var(--warn,#b8860b);";
w.textContent = `${msg}`;
asstCard.appendChild(w);
}
} 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) {
// clear 返回不含 context_* 字段;全删后窗口/折叠归零,指示环回空
state.taskMeta = {
...state.taskMeta, ...updated,
context_window_chars: 0, context_pressure: 0, context_folds: 0,
};
renderChatMeta();
renderMessages([]);
state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空
$("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);
});
}