// 对话视图(任务列表 + 选择/渲染消息 + 发送/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 = ``;
// 渠道镜像 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 = `
加载失败:${escapeHtml(e.message)}
`;
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 ``;
}
// 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 = `(暂无任务)
`;
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 ? `${statusLabel}` : ""}${runIndicatorHtml(t)}`;
// 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
return `
${escapeHtml(taskName)}
${wdName ? `
${indHtml}📁 ${escapeHtml(wdName)}
` : ""}
${desc ? `
${escapeHtml(desc)}
` : ""}
${wdName ? "" : indHtml}
${t.skill ? `${escapeHtml(t.skill)}` : ""}
${t.n_messages || 0} 条
${fmtTokens(t.tokens)} tok
${escapeHtml(fmtTimeAgo(t.updated_at))}
`;
}).join("");
const listEl = $("task-list");
let newRows;
if (append) {
const tmp = document.createElement("div");
tmp.innerHTML = html;
newRows = Array.from(tmp.children);
newRows.forEach((el) => listEl.appendChild(el));
} else {
listEl.innerHTML = html;
newRows = Array.from(listEl.querySelectorAll(".task-row"));
}
newRows.forEach((el) => {
if (!el.classList || !el.classList.contains("task-row")) return;
el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return; // 菜单按钮点击不触发选中
selectTask(el.dataset.tid);
};
const btn = el.querySelector(".task-menu");
if (btn) {
btn.onclick = (e) => {
e.stopPropagation();
const t = state.tasksById[btn.dataset.tid];
if (!t) return;
showMenu(btn, taskMenuItems(t));
};
}
});
}
// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互
// (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: |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 = `
${WECHAT_ICON}
绑定${cfg.label}
扫码或手填 userid
`;
} else if (!t) {
// 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,右侧 ⚙ 管理
html = `
${WECHAT_ICON}
${cfg.label}对话
发消息后可打开
⚙
`;
} 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 = `
${WECHAT_ICON}
${escapeHtml(name)}
${meta}
⚙
`;
}
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 = `加载中…
`;
// 先锁后放:按钮先置禁用加载态,meta/proc 状态到齐后由下方流程放到正确状态
// (idle/streaming/bgproc)—— 杜绝"刷新/切换瞬间按钮短暂可用"的时序窗口
setActionMode("loading");
renderTaskProgressDock([]);
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
try {
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta。
// 但消息**渲染**依赖 meta(artifact chip 抽取要 working_dir)→ loadMessages 只取数,
// 渲染延后到 taskMeta 赋值之后;代价是首屏渲染等 meta/messages 中较慢者(meta 很轻)。
const [meta] = await Promise.all([
api("GET", "/v1/tasks/" + tid),
loadMessages({ render: false }),
refreshOutline(),
// proc 状态并入预取:后面 renderLiveRunIfVisible 决定按钮态时数据已就位,
// 消掉"刷新后按钮先短暂可用、锁随轮询迟到"的亚秒窗口
refreshProcs(),
]);
state.taskMeta = meta;
renderMessages(state.loadedMessages);
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 = `加载失败:${escapeHtml(e.message)}
`;
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 ${escapeHtml(head.name || "(未命名)")} 正在 ${escapeHtml(head.run_status)}${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 = `(未选中任务)`; 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)
? `📁 ${escapeHtml(wdName)}`
: "";
$("chat-meta").innerHTML = `
${escapeHtml(taskName)}
${statusLabel ? `${statusLabel}` : ""}
${wdBadge}
${t.skill ? `${escapeHtml(t.skill)}` : ""}
${t.task_id.slice(0, 8)}
${formatTaskUsage(t)}
${ctxRingHtml(t)}
${t.description ? `${escapeHtml(t.description)}` : ""}
${renderModelDropdown(t)}
${renderMediaModelTrigger()}
`;
const sel = $("chat-model-sel");
if (sel) sel.onchange = onChangeModel;
// 生图/生视频 收进 ⚙ 弹层(低频),点开时再渲染 select 并接 onChange
const mmBtn = $("media-model-btn");
if (mmBtn) mmBtn.onclick = (e) => { e.stopPropagation(); openMediaModelPop(mmBtn); };
const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none";
syncOptimizeBtn();
$("btn-done").disabled = !active;
// ⋯ 菜单:选中即可用;各项 enable/disable(完成/废弃按 status、清空按 run_status+n_messages)
// 全在 taskMenuItems 内部判定,这里只管整体可用性。
$("btn-task-menu").disabled = false;
}
function renderModelDropdown(t) {
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
if (!state.models || state.models.length === 0) return "";
const cur = t.model_profile || "";
let opts = state.models.map(m =>
``
).join("");
// 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项,
// 下拉如实显示;用户仍可切回档内模型。
if (cur && !state.models.some(m => m.profile === cur)) {
opts += ``;
}
return `模型💬`;
}
// 生图/生视频 是低频情景操作 → 不在 meta 行常驻,收进一个 ⚙ 弹层。
// imageModels/videoModels 均为空(yaml 无 image/video variant)时连 ⚙ 都不画。
function renderMediaModelTrigger() {
const hasImg = state.imageModels && state.imageModels.length > 0;
const hasVid = state.videoModels && state.videoModels.length > 0;
if (!hasImg && !hasVid) return "";
return ``;
}
// 弹层里一行 = 标签(icon + 文字) + select。沿用原 onChange,选中值仍只进 state.* 随下条消息发。
function mediaSelectRow(icon, label, id, list, cur, title) {
const opts = list.map(m =>
``
).join("");
return `${icon} ${label}`
+ `
`;
}
function openMediaModelPop(btn) {
const pop = $("media-model-pop");
let html = "";
if (state.imageModels && state.imageModels.length)
html += mediaSelectRow("🖼", "生图", "chat-image-model-sel", state.imageModels, state.imageModel || "", "下一条消息触发生图时使用的模型(本地选择,不入库)");
if (state.videoModels && state.videoModels.length)
html += mediaSelectRow("🎬", "生视频", "chat-video-model-sel", state.videoModels, state.videoModel || "", "下一条消息触发生视频时使用的模型(本地选择,不入库)");
pop.innerHTML = html;
const imgSel = $("chat-image-model-sel"); if (imgSel) imgSel.onchange = onChangeImageModel;
const vidSel = $("chat-video-model-sel"); if (vidSel) vidSel.onchange = onChangeVideoModel;
// 定位:右上角对齐触发按钮,向下展开;下方空间不足则向上(同 showMenu 思路)
const rect = btn.getBoundingClientRect();
pop.style.visibility = "hidden";
pop.classList.add("show");
const ph = pop.offsetHeight || 100;
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({ render = true } = {}) {
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;
// render=false:selectTask 首屏走延后渲染 —— renderMessages 里 artifact chip 抽取
// 依赖 state.taskMeta.working_dir,必须等 meta 赋值后再渲,否则 wd 是旧 task 的/null,
// 历史消息里的图片预览和 chip 整体丢失(0.12.16 并发化引入的回归)。
if (render) 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 ``;
}).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 = `思考过程`;
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 = `助手
`;
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) => `
${escapeHtml(mark(s.status))}
${escapeHtml(s.title)}
`).join("");
const summary = allDone
? `✓ 全部完成 · ${done}/${total} 步`
: `进度 · ${done}/${total} 步`;
const openAttr = allDone ? "" : " open"; // 全完成默认折叠,其余展开
dock.innerHTML = `${summary}${rows}
`;
dock.classList.add("show");
}
function setTaskProgress(taskId, steps) {
const normalized = cloneProgressSteps(steps);
if (taskId) state.taskProgressByTask.set(taskId, normalized);
if (state.taskId === taskId) renderTaskProgressDock(normalized);
}
function renderMessages(msgs) {
const wrap = $("chat-stream");
_msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
wrap.innerHTML = "";
if (!msgs.length) {
state.loadedMessages = [];
state.msgHasMore = false;
setTaskProgress(state.taskId, []);
wrap.innerHTML = `(暂无消息 · 在下方输入开始对话)
`;
renderLiveRunIfVisible();
return;
}
// 还有更早 → 顶部放 sentinel,进视口自动加载(见 _msgScrollObserver)
if (state.msgHasMore) {
const sentinel = document.createElement("div");
sentinel.className = "msg-top-sentinel muted";
sentinel.textContent = state.msgLoadingEarlier ? "加载更早…" : "↑ 上滑加载更早";
wrap.appendChild(sentinel);
_msgScrollObserver.observe(sentinel);
}
// 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
// (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。
let lastAsstModel = null;
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set();
let currentProgressSteps = [];
const pickFresh = (rels) => {
const fresh = [];
for (const r of rels) {
if (seenRels.has(r)) continue;
seenRels.add(r);
fresh.push(r);
}
return fresh;
};
for (let mi = 0; mi < msgs.length; mi++) {
const m = msgs[mi];
const p = m.payload || {};
const role = p.role || "?";
if (role === "system") continue; // 不显示 system
if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
const sep = document.createElement("div");
sep.className = "model-switch muted";
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
sep.textContent = `── ${dn} ──`;
wrap.appendChild(sep);
lastAsstModel = m.model_profile;
}
if (role === "tool") {
if ((p.name || "") === "task_progress") continue;
if ((p.name || "") === "ask_user") continue; // 占位结果不展示;选项卡在 assistant tool_call 里渲染
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
const card = document.createElement("div");
card.className = "msg tool";
card.dataset.idx = m.idx;
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const banner = extractMediaBanner(p.name || "", txt || "");
// 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
// (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
card.innerHTML = `
工具调用 · ${escapeHtml(p.name || "")}
结果(${(txt || "").length} 字符)${banner}
${escapeHtml(txt || "")}
${renderArtifactBarHtml(rels, isProducer)}
`;
// 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 = `${roleLabel}
`;
// 思考过程(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 += `思考过程
${escapeHtml(rc)}
`;
}
}
if (typeof p.content === "string" && p.content) {
html += `${renderMd(p.content)}
`;
// assistant 正文里 echo 的 /... 路径**永远**挂 chip(绕开 seenRels —— 上面
// tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可
// 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。
if (role === "assistant") {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false);
}
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
currentProgressSteps = progressResult.steps;
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let argsObj = {};
let args = "";
try {
argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
args = JSON.stringify(argsObj, null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
if (fn === "task_progress") {
continue;
}
if (fn === "ask_user") {
// 之后若已有 user 消息 → 这轮选择已答:卡置灰,命中项标「已选」;否则仍可点。
let answered = false, chosen = "";
for (let k = mi + 1; k < msgs.length; k++) {
if (((msgs[k].payload || {}).role) === "user") {
answered = true;
chosen = (msgs[k].payload.content || "");
break;
}
}
html += buildAskUserCard(argsObj, { interactive: !answered, chosenLabel: chosen }).outerHTML;
continue;
}
const label = toolActivityLabel(fn, argsObj);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
html += `
${escapeHtml(label)}
${escapeHtml(args)}
${renderArtifactBarHtml(rels, isProducer)}
`;
}
// 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
if (state.msgHasMoreNewer) {
const sb = document.createElement("div");
sb.className = "msg-bot-sentinel muted";
sb.textContent = state.msgLoadingNewer ? "加载更新…" : "↓ 下滑加载更新";
wrap.appendChild(sb);
_msgScrollObserver.observe(sb);
}
wrap.scrollTop = wrap.scrollHeight;
setTaskProgress(state.taskId, currentProgressSteps);
upgradeMediaArtifacts(wrap);
renderLiveRunIfVisible();
}
// ───── send + SSE ─────
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
function setActionMode(mode) {
const btn = $("chat-action");
btn.classList.remove("primary", "danger");
if (mode === "idle") {
btn.textContent = "发送";
btn.classList.add("primary");
btn.disabled = false;
btn.title = "";
} else if (mode === "streaming") {
btn.textContent = "停止";
btn.classList.add("danger");
btn.disabled = false;
btn.title = "停止当前流式回复";
} else if (mode === "cancelling") {
btn.textContent = "停止中…";
btn.classList.add("danger");
btn.disabled = true;
} 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} 个待发送 可在右侧文件处查看`;
}
// 失败时 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",
``);
}
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 = `已删除文件`;
}
} 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 内自带"下载")。
// 视频走原生