From 192a1fa12e04e800d2998f816f6e30ad1f2200a8 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Mon, 21 Sep 2026 18:24:28 +1000 Subject: [PATCH 1/2] Reread unanswered history once when chat reattachment is idle Signed-off-by: dada-yan --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/pages/Chat.tsx | 38 +++++++-- web/tests/chat-view.test.mjs | 147 +++++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 7 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e3d2cd312..5c95b78ee 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -766,6 +766,7 @@ export const en = { }, ask: { streamInterrupted: "The answer stream was interrupted. Reopen the conversation to check its status.", + noActiveAnswer: "No active answer was found. You can send a new message.", historyLoadFailed: "Could not load this conversation.", retryHistory: "Retry", loadingHistory: "Loading conversation…", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7f6640402..2f20d885b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -707,6 +707,7 @@ export const zh: Strings = { }, ask: { streamInterrupted: "回答连接已中断,请重新打开会话查看状态。", + noActiveAnswer: "未发现正在生成的回答,你可以发送新消息。", historyLoadFailed: "无法读取此会话。", retryHistory: "重试", loadingHistory: "正在读取会话…", diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index b286b22dd..dba4bcc9d 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -34,6 +34,7 @@ import { streamChat, type ChatStep, type ConversationRow, + type ConversationMessage, type Source, } from "../api"; import { S } from "../i18n"; @@ -82,6 +83,12 @@ const DRAFT_KEY = "chat:draft"; const NO_SOURCES: Source[] = []; type ViewRequest = { kbId: string; id: string | null }; +const historyTurns = (messages: ConversationMessage[]): Turn[] => messages.map((m) => ({ + role: m.role, + content: m.content, + steps: m.steps.length ? m.steps : undefined, + sources: m.sources.length ? m.sources : undefined, +})); const viewKey = (kbId: string, id: string | null) => `${kbId}/${id ?? ""}`; export function Chat() { @@ -112,6 +119,7 @@ export function Chat() { // 已经结束的那些轮次,从库里读来。**进行中的那一次不在这里**——见下 const [turns, setTurns] = useState([]); const [loadedKey, setLoadedKey] = useState(null); + const [idleHistoryKey, setIdleHistoryKey] = useState(null); const [historyError, setHistoryError] = useState(null); const [loadingHistory, setLoadingHistory] = useState(false); // Object identity is the viewing epoch: A → B → A creates three owners. @@ -271,6 +279,7 @@ export function Chat() { const attachIfRunning = (id: string, history: Turn[], owner: ViewRequest) => { let abort = () => {}; let handle: LiveHandle | null = null; + let checkedIdle = false; const stop = reattachChat(owner.kbId, id, { onConversation: () => {}, /* **快照到了才建这一轮。** 先摆一个空位再等回答的话,没有在跑的会话 @@ -307,13 +316,29 @@ export function Chat() { handle?.patchLast((t) => ({ ...t, error: message })); handle?.finish(); }, - onIdle: () => {}, + onIdle: async () => { + if (checkedIdle || handle || !ownsView(owner)) return; + checkedIdle = true; + try { + // The answer may have committed between the history read and attach. + // One read closes that handoff; never re-POST or recursively attach. + const { messages } = await conversationsApi.detail(owner.kbId, id); + if (!ownsView(owner)) return; + const refreshed = historyTurns(messages); + setTurns(refreshed); + setLoadedKey(viewKey(owner.kbId, id)); + setIdleHistoryKey(refreshed.at(-1)?.role === "user" ? viewKey(owner.kbId, id) : null); + } catch (error) { + if (ownsView(owner)) setHistoryError(error instanceof Error ? error.message : String(error)); + } + }, }); abort = stop; }; const loadConversation = async (id: string) => { const owner = claimView(id); + setIdleHistoryKey(null); setHistoryError(null); setLoadingHistory(false); // 回到正在写的那一场:直接认领,别去库里读——库里要等它写完才有那一行 @@ -331,12 +356,7 @@ export function Chat() { const { messages } = await conversationsApi.detail(owner.kbId, id); if (!ownsView(owner)) return; sessionStorage.setItem(lastKey(owner.kbId), id); - const history: Turn[] = messages.map((m) => ({ - role: m.role, - content: m.content, - steps: m.steps.length ? m.steps : undefined, - sources: m.sources.length ? m.sources : undefined, - })); + const history = historyTurns(messages); setTurns(history); setLoadedKey(viewKey(owner.kbId, id)); /* **刷新之后接回去。** 上面那个 store 只活在这一个页面里;刷新、 @@ -394,6 +414,7 @@ export function Chat() { const q = input.trim(); if (!q || streaming || !kb || kb.id !== kbId || loadingHistory || historyError) return; const owner = claimView(activeId); + setIdleHistoryKey(null); setInput(""); sessionStorage.removeItem(DRAFT_KEY); if (inputRef.current) inputRef.current.style.height = "auto"; @@ -711,6 +732,9 @@ export function Chat() { {/* 对话区:新对话首屏 = 问候 + 居中 composer(ChatGPT/Claude 惯例); 有消息后 composer 停靠底部 */}
+ {idleHistoryKey === viewKey(kbId, currentId) && !streaming && ( +

{S.ask.noActiveAnswer}

+ )} {historyError ? (

{S.ask.historyLoadFailed}

diff --git a/web/tests/chat-view.test.mjs b/web/tests/chat-view.test.mjs index ca9e672e2..a53ee20a3 100644 --- a/web/tests/chat-view.test.mjs +++ b/web/tests/chat-view.test.mjs @@ -449,5 +449,152 @@ test( } }, ); + await t.test( + "idle reattachment rereads a just-completed history exactly once", + async () => { + let reads = 0, + posts = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill({ + json: { + messages: + reads === 1 + ? [message("Question", "user")] + : [ + message("Question", "user"), + message("Saved between reads"), + ], + }, + }); + return true; + } + if (p.endsWith("/chat") && route.request().method() === "POST") { + posts++; + return false; + } + }); + try { + await f.page + .getByText("Saved between reads", { exact: true }) + .waitFor(); + assert.equal(reads, 2); + assert.equal(posts, 0); + } finally { + await f.close(); + } + }, + ); + await t.test( + "idle plus unanswered history is bounded without resending", + async () => { + let reads = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill({ + json: { messages: [message("Still unanswered", "user")] }, + }); + return true; + } + }); + try { + await f.page + .getByText( + "No active answer was found. You can send a new message.", + { exact: true }, + ) + .waitFor(); + assert.equal(reads, 2); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); + await t.test( + "late idle refresh cannot overwrite another conversation", + async () => { + let reads = 0; + const pending = deferred(); + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + if (++reads === 1) + await route.fulfill({ + json: { messages: [message("Question", "user")] }, + }); + else pending.resolve(route); + return true; + } + }); + try { + const refill = await Promise.race([ + pending.promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("expected idle refresh request")), + 5000, + ), + ), + ]); + await f.page.evaluate(() => window.__go("/kb/one/chat/b")); + await f.page.getByText("Answer beta", { exact: true }).waitFor(); + await refill.fulfill({ + json: { messages: [message("Obsolete saved answer")] }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("Answer beta", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page + .getByText("Obsolete saved answer", { exact: true }) + .count(), + 0, + ); + } finally { + await f.close(); + } + }, + ); + await t.test( + "an idle refresh failure can be retried without a POST", + async () => { + let reads = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + reads++; + await route.fulfill( + reads === 2 + ? { status: 500, json: { error: "refresh failed" } } + : { + json: { + messages: + reads === 1 + ? [message("Question", "user")] + : [message("Recovered saved answer")], + }, + }, + ); + return true; + } + }); + try { + await f.page.getByRole("alert").waitFor(); + await f.page + .getByRole("button", { name: "Retry", exact: true }) + .click(); + await f.page + .getByText("Recovered saved answer", { exact: true }) + .waitFor(); + assert.equal(reads, 3); + assert.deepEqual(f.errors, []); + } finally { + await f.close(); + } + }, + ); }, ); From cd87e88539d79d9b8b916119e5e3020ac3761dda Mon Sep 17 00:00:00 2001 From: dada-yan Date: Mon, 21 Sep 2026 18:32:56 +1000 Subject: [PATCH 2/2] Verify idle refresh cannot overwrite a new send Signed-off-by: dada-yan --- web/src/pages/Chat.tsx | 4 +-- web/tests/chat-view.test.mjs | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index dba4bcc9d..b69ef73d4 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -275,7 +275,7 @@ export function Chat() { }); }; - /** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,什么都不发生。 */ + /** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,补读一次已保存历史。 */ const attachIfRunning = (id: string, history: Turn[], owner: ViewRequest) => { let abort = () => {}; let handle: LiveHandle | null = null; @@ -732,7 +732,7 @@ export function Chat() { {/* 对话区:新对话首屏 = 问候 + 居中 composer(ChatGPT/Claude 惯例); 有消息后 composer 停靠底部 */}
- {idleHistoryKey === viewKey(kbId, currentId) && !streaming && ( + {idleHistoryKey === loadedKey && idleHistoryKey === viewKey(kbId, currentId) && !streaming && (

{S.ask.noActiveAnswer}

)} {historyError ? ( diff --git a/web/tests/chat-view.test.mjs b/web/tests/chat-view.test.mjs index a53ee20a3..bc54b0eb6 100644 --- a/web/tests/chat-view.test.mjs +++ b/web/tests/chat-view.test.mjs @@ -596,5 +596,55 @@ test( } }, ); + + await t.test( + "a pending idle refresh cannot overwrite a new send", + async () => { + const pending = deferred(); + let reads = 0; + let posts = 0; + const f = await open("/kb/one/chat/a", async (route, p) => { + if (p.endsWith("/conversations/a")) { + if (++reads === 1) + await route.fulfill({ + json: { messages: [message("Earlier question", "user")] }, + }); + else pending.resolve(route); + return true; + } + if (p.endsWith("/chat") && route.request().method() === "POST") { + posts++; + await route.fulfill({ + contentType: "text/event-stream", + body: 'event: conversation\ndata: {"id":"a"}\n\nevent: delta\ndata: {"text":"New answer"}\n\nevent: done\ndata: {}\n\n', + }); + return true; + } + }); + try { + const old = await pending.promise; + await f.page.getByPlaceholder("Ask anything…").fill("New question"); + await f.page.getByPlaceholder("Ask anything…").press("Enter"); + await f.page.getByText("New answer", { exact: true }).waitFor(); + await old.fulfill({ + json: { messages: [message("Obsolete refreshed answer")] }, + }); + await f.page.evaluate(() => new Promise(requestAnimationFrame)); + assert.equal( + await f.page.getByText("New answer", { exact: true }).count(), + 1, + ); + assert.equal( + await f.page + .getByText("Obsolete refreshed answer", { exact: true }) + .count(), + 0, + ); + assert.equal(posts, 1); + } finally { + await f.close(); + } + }, + ); }, );