Persist chat loading state across task switches
This commit is contained in:
parent
7a0d03fb29
commit
ea89c5f209
|
|
@ -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,27 +2327,34 @@ 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) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue