Persist chat loading state across task switches

This commit is contained in:
caoqianming 2026-05-25 09:16:20 +08:00
parent 7a0d03fb29
commit ea89c5f209
1 changed files with 156 additions and 34 deletions

View File

@ -350,7 +350,22 @@
.cancelled-badge { margin-top: 8px; padding: 4px 10px; font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px dashed var(--accent); border-radius: var(--r-md); display: inline-block; }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: var(--mono); }
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
.msg.assistant.live-run { border-color: rgba(220, 38, 38, 0.28); box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.08), 0 8px 24px rgba(220, 38, 38, 0.08); }
.msg .body.streaming { min-width: 96px; min-height: 22px; }
.msg .body.streaming:empty::before { content: "思考中"; color: var(--muted); }
.msg .body.streaming::after {
content: "";
display: inline-block;
width: 1.15em;
height: 1.15em;
margin-left: 8px;
vertical-align: -0.18em;
border: 2px solid rgba(220, 38, 38, 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
/* markdown 输出:.msg .body 与 file-preview .md-render 共用一组规则 */
.msg .body > :first-child, .md-render > :first-child { margin-top: 0; }
@ -1039,7 +1054,8 @@ const state = {
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
concurrentWarnings: [],
evtSrc: null,
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
streaming: false, // 兼容旧判断:任一 task 是否在流式中
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
// task list 滚动加载 + 筛选
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
taskPageSize: 20,
@ -1703,6 +1719,11 @@ async function selectTask(tid) {
state.taskMeta = meta;
renderChatMeta();
await loadMessages();
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
} else {
renderLiveRunIfVisible();
}
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
@ -1859,11 +1880,68 @@ async function loadMessages() {
renderMessages(data.messages);
}
function getLiveRun(taskId) {
return taskId ? state.liveRuns.get(taskId) : null;
}
function isCurrentTaskStreaming() {
return !!getLiveRun(state.taskId);
}
function createLiveAssistantCard(run) {
const card = document.createElement("div");
card.className = "msg assistant live-run";
card.innerHTML = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
run.card = card;
run.body = card.querySelector(".body");
return card;
}
function renderLiveRunIfVisible() {
const run = getLiveRun(state.taskId);
if (!run) {
setActionMode("idle");
return;
}
const wrap = $("chat-stream");
const card = run.card || createLiveAssistantCard(run);
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight;
setActionMode(run.cancelling ? "cancelling" : "streaming");
$("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
}
function ensureRunningTaskSubscribed(taskId, url) {
if (!taskId || getLiveRun(taskId)) return;
const run = {
taskId,
url,
acc: "",
pending: false,
seenRels: new Set(),
terminal: false,
card: null,
body: null,
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
workingDir: state.taskMeta && state.taskMeta.working_dir,
};
state.liveRuns.set(taskId, run);
state.streaming = true;
renderLiveRunIfVisible();
streamSse(url, run);
}
function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}
function renderMessages(msgs) {
const wrap = $("chat-stream");
wrap.innerHTML = "";
if (!msgs.length) {
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
renderLiveRunIfVisible();
return;
}
// 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
@ -1949,6 +2027,7 @@ function renderMessages(msgs) {
}
wrap.scrollTop = wrap.scrollHeight;
upgradeMediaArtifacts(wrap);
renderLiveRunIfVisible();
}
// ───── send + SSE ─────
@ -1975,7 +2054,7 @@ function setActionMode(mode) {
}
function chatAction() {
if (state.streaming) cancelCurrentTask();
if (isCurrentTaskStreaming()) cancelCurrentTask();
else sendMessage();
}
@ -1984,7 +2063,7 @@ $("chat-input").addEventListener("keydown", (e) => {
// streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!state.streaming) sendMessage();
if (!isCurrentTaskStreaming()) sendMessage();
}
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
@ -2127,11 +2206,12 @@ $("chat-stream").addEventListener("click", (e) => {
async function sendMessage() {
if (!state.taskId) return;
if (state.streaming) return;
if (isCurrentTaskStreaming()) return;
const content = $("chat-input").value.trim();
if (!content) return;
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
$("chat-hint").textContent = "发送中…";
const taskId = state.taskId;
try {
// 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream");
@ -2142,21 +2222,34 @@ async function sendMessage() {
// assistant 流式占位卡
const asstCard = document.createElement("div");
asstCard.className = "msg assistant";
asstCard.className = "msg assistant live-run";
asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight;
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, {
const r = await api("POST", `/v1/tasks/${taskId}/messages`, {
content,
image_model: state.imageModel || "",
video_model: state.videoModel || "",
});
$("chat-input").value = "";
syncOptimizeBtn();
const run = {
taskId,
url: r.events_url,
acc: "",
pending: false,
seenRels: new Set(),
terminal: false,
card: asstCard,
body: asstCard.querySelector(".body"),
cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir,
};
state.liveRuns.set(taskId, run);
state.streaming = true;
setActionMode("streaming");
streamSse(r.events_url, asstCard);
streamSse(r.events_url, run);
} catch (e) {
if (e.status === 401) { logout(); return; }
appendErrorCard(e.message);
@ -2166,7 +2259,9 @@ async function sendMessage() {
}
async function cancelCurrentTask() {
if (!state.taskId || !state.streaming) return;
const run = getLiveRun(state.taskId);
if (!state.taskId || !run) return;
run.cancelling = true;
setActionMode("cancelling");
$("chat-hint").textContent = "停止中…";
try {
@ -2176,21 +2271,37 @@ async function cancelCurrentTask() {
if (e.status === 401) { logout(); return; }
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
if (e.status !== 409) appendErrorCard("cancel: " + e.message);
run.cancelling = false;
setActionMode("streaming");
$("chat-hint").textContent = "接收中…";
}
}
function streamSse(url, asstCard) {
function streamSse(url, run) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
fetchSse(url, asstCard).catch((e) => appendErrorCard("sse: " + e.message));
fetchSse(url, run).catch((e) => {
appendRunError(run, "sse: " + e.message);
});
}
async function fetchSse(url, asstCard) {
const body = asstCard.querySelector(".body");
const ctx = { acc: "", body, pending: false, seenRels: new Set(), terminal: false };
function appendRunError(run, msg) {
if (state.taskId === run.taskId) {
appendErrorCard(msg);
return;
}
const host = run.card || createLiveAssistantCard(run);
const card = document.createElement("div");
card.className = "msg error";
card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
host.appendChild(card);
}
async function fetchSse(url, run) {
const body = run.body || (run.card && run.card.querySelector(".body"));
run.body = body;
const ctx = run;
const hint = $("chat-hint");
// 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
// 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流;
@ -2200,14 +2311,14 @@ async function fetchSse(url, asstCard) {
try {
while (true) {
try {
await consumeSseStream(url, asstCard, ctx);
await consumeSseStream(url, run.card, ctx);
} catch (e) {
if (ctx.terminal) break; // 已收到 done/error,不重连
if (attempt >= backoffs.length) {
appendErrorCard("连接已断开,刚才的回复可能未完成,请重发消息。");
appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
hint.textContent = `连接断开,重连中…(${attempt + 1}/${backoffs.length})`;
setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++;
continue;
@ -2216,28 +2327,35 @@ async function fetchSse(url, asstCard) {
if (ctx.terminal) break; // 正常收尾(看到 done/error)
// 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连
if (attempt >= backoffs.length) {
appendErrorCard("连接已断开,刚才的回复可能未完成,请重发消息。");
appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
break;
}
hint.textContent = `连接断开,重连中…(${attempt + 1}/${backoffs.length})`;
setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++;
}
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc);
highlightIn(asstCard);
if (ctx.body) {
ctx.body.innerHTML = renderMd(ctx.acc);
if (ctx.card) highlightIn(ctx.card);
}
} finally {
body.classList.remove("streaming");
if (ctx.body) ctx.body.classList.remove("streaming");
state.liveRuns.delete(ctx.taskId);
state.streaming = state.liveRuns.size > 0;
if (state.taskId === ctx.taskId) {
hint.textContent = "就绪";
state.streaming = false;
setActionMode("idle");
}
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList();
if (state.taskId === ctx.taskId) {
await loadMessages();
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
}
}
async function consumeSseStream(url, asstCard, ctx) {
const r = await fetch(url, {
@ -2247,7 +2365,7 @@ async function consumeSseStream(url, asstCard, ctx) {
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
$("chat-hint").textContent = "接收中…";
setRunHint(ctx, "接收中…");
while (true) {
const { value, done } = await reader.read();
if (done) return;
@ -2285,9 +2403,13 @@ function parseSseFrame(frame) {
function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event;
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
ctx.card = asstCard;
ctx.body = ctx.body || asstCard.querySelector(".body");
const stream = $("chat-stream");
const visible = state.taskId === ctx.taskId;
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120);
if (t === "text" && ev.data && ev.data.delta) {
ctx.acc += ev.data.delta;
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
@ -2307,7 +2429,7 @@ function handleSseEvent(ev, asstCard, ctx) {
det.className = "tool-call";
det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
const fresh = isProducer
? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
@ -2327,7 +2449,7 @@ function handleSseEvent(ev, asstCard, ctx) {
det.className = "tool-call";
det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
asstCard.appendChild(det);
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
const wd = _workingDirName(ctx.workingDir);
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
const fresh = isProducer
? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
@ -2338,7 +2460,7 @@ function handleSseEvent(ev, asstCard, ctx) {
asstCard.insertAdjacentHTML("beforeend", barHtml);
if (isProducer) upgradeMediaArtifacts(asstCard);
}
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
} else if (t === "cancelled") {
const badge = document.createElement("div");
badge.className = "cancelled-badge";
@ -2346,7 +2468,7 @@ function handleSseEvent(ev, asstCard, ctx) {
asstCard.appendChild(badge);
} else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg);
appendRunError(ctx, msg);
}
if (nearBottom) stream.scrollTop = stream.scrollHeight;
}