Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ export const zh: Strings = {
},
ask: {
streamInterrupted: "回答连接已中断,请重新打开会话查看状态。",
noActiveAnswer: "未发现正在生成的回答,你可以发送新消息。",
historyLoadFailed: "无法读取此会话。",
retryHistory: "重试",
loadingHistory: "正在读取会话…",
Expand Down
40 changes: 32 additions & 8 deletions web/src/pages/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
streamChat,
type ChatStep,
type ConversationRow,
type ConversationMessage,
type Source,
} from "../api";
import { S } from "../i18n";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -112,6 +119,7 @@ export function Chat() {
// 已经结束的那些轮次,从库里读来。**进行中的那一次不在这里**——见下
const [turns, setTurns] = useState<Turn[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [idleHistoryKey, setIdleHistoryKey] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
// Object identity is the viewing epoch: A → B → A creates three owners.
Expand Down Expand Up @@ -267,10 +275,11 @@ export function Chat() {
});
};

/** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,什么都不发生。 */
/** 接回一个正在生成的回答。没有在跑的话服务端回 `idle`,补读一次已保存历史。 */
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: () => {},
/* **快照到了才建这一轮。** 先摆一个空位再等回答的话,没有在跑的会话
Expand Down Expand Up @@ -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);
// 回到正在写的那一场:直接认领,别去库里读——库里要等它写完才有那一行
Expand All @@ -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 只活在这一个页面里;刷新、
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -711,6 +732,9 @@ export function Chat() {
{/* 对话区:新对话首屏 = 问候 + 居中 composer(ChatGPT/Claude 惯例);
有消息后 composer 停靠底部 */}
<div className="flex-1 min-w-0 flex flex-col">
{idleHistoryKey === loadedKey && idleHistoryKey === viewKey(kbId, currentId) && !streaming && (
<p role="status" className="px-4 pt-4 text-body text-ink-2">{S.ask.noActiveAnswer}</p>
)}
{historyError ? (
<div role="alert" className="p-6 text-body">
<p>{S.ask.historyLoadFailed}</p>
Expand Down
197 changes: 197 additions & 0 deletions web/tests/chat-view.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -449,5 +449,202 @@ 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();
}
},
);

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();
}
},
);
},
);
Loading