diff --git a/web/static/dev.html b/web/static/dev.html index a29cf50..e272e23 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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 = `
助手
${run.acc ? renderMd(run.acc) : ""}
`; + 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 = `
(暂无消息 · 在下方输入开始对话)
`; + 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 = `
助手
`; 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 = `
错误
${escapeHtml(msg)}
`; + 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"); - hint.textContent = "就绪"; - state.streaming = false; - setActionMode("idle"); + 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 = "就绪"; + setActionMode("idle"); + } } // 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑 loadTaskList(); - await loadMessages(); - loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物) - refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了) + 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 = `工具调用:${escapeHtml(fn)}
${escapeHtml(argsStr)}
`; 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 = `工具结果${banner}
${escapeHtml(txtStr)}
`; 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; }