fix(ai-chat): WYSIWYG Copy chat export keeps the on-screen partial reply (#160)
"Copy chat" built the Markdown from persisted rows plus a live tail that was only included while isStreaming. When a turn was interrupted (dropped stream / "Lost connection" banner) isStreaming flipped false, the live tail was dropped, and the partial assistant reply visible on screen — whose row often never persisted — vanished from the export, leaving only the user messages. - buildChatMarkdown is now live-first: the on-screen `live` messages ARE the document. Each is matched to a persisted row by id to enrich it with token usage / error / timestamp; authoritative usage/error already on the live message win over the row. When `live` is empty it falls back to the persisted rows (old format preserved). Only the tail assistant is flagged "still generating", and only when it is genuinely the streaming tail — so the status==="submitted" window (tail is the user message) never mislabels the previous, completed answer. - The on-screen banner (classified error / dropped connection / manual stop) is flattened to a string in ChatThread, mirrored into liveStateRef alongside the messages/isStreaming snapshot, and appended at the end of the export. - handleCopy maps the live messages and passes live/rows/isStreaming/banner. Tests: chat-markdown rewritten for the live/enrichment/fallback/banner paths and the submitted-window regression (26); full ai-chat suite green (186). tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -151,9 +151,14 @@ export default function AiChatWindow() {
|
|||||||
// Live snapshot of the active thread's useChat state, kept up to date by
|
// Live snapshot of the active thread's useChat state, kept up to date by
|
||||||
// ChatThread. Lets the export include the in-progress (not-yet-persisted)
|
// ChatThread. Lets the export include the in-progress (not-yet-persisted)
|
||||||
// streaming turn. A ref avoids re-rendering this window on every token.
|
// streaming turn. A ref avoids re-rendering this window on every token.
|
||||||
const liveThreadRef = useRef<{ messages: UIMessage[]; isStreaming: boolean }>({
|
const liveThreadRef = useRef<{
|
||||||
|
messages: UIMessage[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
banner: string | null;
|
||||||
|
}>({
|
||||||
messages: [],
|
messages: [],
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
banner: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||||
@@ -249,28 +254,42 @@ export default function AiChatWindow() {
|
|||||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
// call) and copy it to the clipboard. The "Copied" notification is the
|
||||||
// feedback.
|
// feedback.
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
|
// Export gate. Requiring at least one persisted row means a brand-new chat
|
||||||
|
// whose VERY FIRST turn dropped before the server persisted even the user
|
||||||
|
// message cannot be exported (the button is also hidden — see `canExport`).
|
||||||
|
// That narrow first-turn case is deliberately out of scope for #160; the user
|
||||||
|
// message is normally persisted before model contact, so an interrupted later
|
||||||
|
// turn still has rows and exports the on-screen partial reply WYSIWYG.
|
||||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
||||||
// While the active thread is streaming, the current user message and the
|
// WYSIWYG export: the live on-screen messages ARE the document (so a partial
|
||||||
// in-progress assistant reply are NOT yet in messageRows (the persisted
|
// reply from an interrupted turn — which never reached the persisted rows —
|
||||||
// query is only refetched after the turn finishes). Pull the live tail —
|
// is exported just as it appears). The persisted rows enrich each live
|
||||||
// messages whose id is not among the persisted rows — and append them,
|
// message (token usage / error / timestamp) by id and serve as the fallback
|
||||||
// flagging the streaming assistant message as still generating.
|
// when the live mirror is empty. The on-screen banner is appended too. See
|
||||||
|
// issue #160.
|
||||||
const live = liveThreadRef.current;
|
const live = liveThreadRef.current;
|
||||||
const rowIds = new Set(messageRows.map((r) => r.id));
|
|
||||||
const pending = live.isStreaming
|
|
||||||
? live.messages
|
|
||||||
.filter((m) => !rowIds.has(m.id))
|
|
||||||
.map((m) => ({
|
|
||||||
role: m.role,
|
|
||||||
parts: (m.parts ?? []) as { type: string; text?: string }[],
|
|
||||||
generating: m.role === "assistant",
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
const markdown = buildChatMarkdown({
|
const markdown = buildChatMarkdown({
|
||||||
title: activeChat?.title ?? null,
|
title: activeChat?.title ?? null,
|
||||||
chatId: activeChatId,
|
chatId: activeChatId,
|
||||||
|
live: live.messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
parts: (m.parts ?? []) as { type: string; text?: string }[],
|
||||||
|
metadata: m.metadata as
|
||||||
|
| {
|
||||||
|
usage?: {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
reasoningTokens?: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
})),
|
||||||
rows: messageRows,
|
rows: messageRows,
|
||||||
pending,
|
isStreaming: live.isStreaming,
|
||||||
|
banner: live.banner,
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
clipboard.copy(markdown);
|
clipboard.copy(markdown);
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ interface ChatThreadProps {
|
|||||||
* "Copy chat" export can include the in-progress, not-yet-persisted
|
* "Copy chat" export can include the in-progress, not-yet-persisted
|
||||||
* assistant message. A ref (not state) avoids re-rendering the parent on
|
* assistant message. A ref (not state) avoids re-rendering the parent on
|
||||||
* every streamed delta. */
|
* every streamed delta. */
|
||||||
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>;
|
liveStateRef?: MutableRefObject<{
|
||||||
|
messages: UIMessage[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
banner: string | null;
|
||||||
|
}>;
|
||||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||||
@@ -309,18 +313,37 @@ export default function ChatThread({
|
|||||||
if (isStreaming) setStopNotice(null);
|
if (isStreaming) setStopNotice(null);
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
// Classify the turn error into a heading + detail so the banner names the cause
|
||||||
|
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
||||||
|
// of a generic "Something went wrong". Computed here (not only in the JSX) so
|
||||||
|
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||||
|
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||||
|
|
||||||
|
// The exact banner the user sees under the message list, flattened to a single
|
||||||
|
// string for the "Copy chat" export so the artifact records the interruption
|
||||||
|
// WYSIWYG. Mirrors the JSX precedence below: error first, else the stop notice.
|
||||||
|
const banner = errorView
|
||||||
|
? errorView.detail
|
||||||
|
? `${errorView.title} — ${errorView.detail}`
|
||||||
|
: errorView.title
|
||||||
|
: stopNotice === "manual"
|
||||||
|
? t("Response stopped.")
|
||||||
|
: stopNotice === "disconnect"
|
||||||
|
? t("Connection lost — the answer was interrupted.")
|
||||||
|
: null;
|
||||||
|
|
||||||
// Mirror the live useChat snapshot into the parent-owned ref so the export
|
// Mirror the live useChat snapshot into the parent-owned ref so the export
|
||||||
// (handled in AiChatWindow) can include the in-progress streaming turn. The
|
// (handled in AiChatWindow) can include the in-progress streaming turn AND the
|
||||||
// cleanup clears the ref on unmount so a thread torn down by `key` on chat
|
// on-screen banner. The cleanup clears the ref on unmount so a thread torn down
|
||||||
// switch can't leak its (possibly still-streaming) tail into the next chat's
|
// by `key` on chat switch can't leak its (possibly still-streaming) tail into
|
||||||
// export before the new thread's effect repopulates the ref.
|
// the next chat's export before the new thread's effect repopulates the ref.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!liveStateRef) return;
|
if (!liveStateRef) return;
|
||||||
liveStateRef.current = { messages, isStreaming };
|
liveStateRef.current = { messages, isStreaming, banner };
|
||||||
return () => {
|
return () => {
|
||||||
liveStateRef.current = { messages: [], isStreaming: false };
|
liveStateRef.current = { messages: [], isStreaming: false, banner: null };
|
||||||
};
|
};
|
||||||
}, [liveStateRef, messages, isStreaming]);
|
}, [liveStateRef, messages, isStreaming, banner]);
|
||||||
|
|
||||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||||
@@ -370,11 +393,6 @@ export default function ChatThread({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Classify the turn error into a heading + detail so the banner names the cause
|
|
||||||
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
|
||||||
// of a generic "Something went wrong".
|
|
||||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
|
||||||
|
|
||||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||||
|
|||||||
@@ -367,125 +367,258 @@ describe("buildChatMarkdown — token totals", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildChatMarkdown — pending / in-progress messages", () => {
|
// A minimal on-screen (live) message, matching the subset buildChatMarkdown reads.
|
||||||
it("continues the heading numbering after the persisted rows", () => {
|
function live(partial: {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
parts?: { type: string; text?: string }[];
|
||||||
|
metadata?: { usage?: Record<string, number>; error?: string };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: partial.id ?? "live-id",
|
||||||
|
role: partial.role ?? "assistant",
|
||||||
|
parts: partial.parts ?? [],
|
||||||
|
metadata: partial.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildChatMarkdown — live (WYSIWYG) source", () => {
|
||||||
|
it("uses the live messages as the document (what's on screen), numbered from 1", () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: "t",
|
title: "t",
|
||||||
chatId: "c",
|
chatId: "c",
|
||||||
rows: [row({ role: "user", content: "persisted" })],
|
// Persisted rows hold only the user turn; the assistant reply is live-only.
|
||||||
pending: [
|
rows: [row({ id: "u1", role: "user", content: "persisted user" })],
|
||||||
{
|
live: [
|
||||||
role: "user",
|
live({ id: "u1", role: "user", parts: [{ type: "text", text: "on-screen user" }] }),
|
||||||
parts: [{ type: "text", text: "live question" }],
|
live({ id: "a1", role: "assistant", parts: [{ type: "text", text: "on-screen reply" }] }),
|
||||||
generating: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: "live answer" }],
|
|
||||||
generating: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
isStreaming: false,
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
expect(md).toContain("## 1. You");
|
expect(md).toContain("## 1. You");
|
||||||
expect(md).toContain("## 2. You");
|
expect(md).toContain("## 2. AI agent");
|
||||||
expect(md).toContain("## 3. AI agent");
|
expect(md).toContain("on-screen user");
|
||||||
expect(md).toContain("live question");
|
expect(md).toContain("on-screen reply");
|
||||||
expect(md).toContain("live answer");
|
// Message count reflects the LIVE document, not rows + live.
|
||||||
|
expect(md).toContain("- Messages: 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a generating assistant pending message as still being generated", () => {
|
it("captures a partial reply from an interrupted (non-streaming) turn — no 'generating' note", () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: "t",
|
title: "t",
|
||||||
chatId: "c",
|
chatId: "c",
|
||||||
rows: [row({ role: "user", content: "persisted" })],
|
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||||
pending: [
|
live: [
|
||||||
{
|
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||||
|
live({
|
||||||
|
id: "a-live",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "partial reply" }],
|
parts: [{ type: "text", text: "partial plan before the drop" }],
|
||||||
generating: true,
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
isStreaming: false, // the stream dropped — not streaming anymore
|
||||||
|
banner: "Connection lost — the answer was interrupted.",
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
expect(md).toContain("partial reply");
|
// The partial assistant answer that was on screen IS in the export.
|
||||||
expect(md).toContain("still being generated");
|
expect(md).toContain("partial plan before the drop");
|
||||||
|
// It is NOT flagged still-generating (the turn is over, just interrupted).
|
||||||
|
expect(md).not.toContain("still being generated");
|
||||||
|
// The on-screen banner is recorded at the end.
|
||||||
|
expect(md).toContain("Connection lost — the answer was interrupted.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a non-generating user pending message without the note", () => {
|
it("flags ONLY the tail assistant as still generating, and only while streaming", () => {
|
||||||
|
const streaming = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [],
|
||||||
|
live: [
|
||||||
|
live({ id: "a", role: "assistant", parts: [{ type: "text", text: "done earlier" }] }),
|
||||||
|
live({ id: "u", role: "user", parts: [{ type: "text", text: "next q" }] }),
|
||||||
|
live({ id: "b", role: "assistant", parts: [{ type: "text", text: "streaming now" }] }),
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
// Exactly one "still being generated" note (the tail assistant).
|
||||||
|
expect(streaming.match(/still being generated/g)?.length).toBe(1);
|
||||||
|
|
||||||
|
const idle = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [],
|
||||||
|
live: [live({ id: "b", role: "assistant", parts: [{ type: "text", text: "final" }] })],
|
||||||
|
isStreaming: false,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(idle).not.toContain("still being generated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag a completed assistant as generating when the streaming tail is a user message", () => {
|
||||||
|
// The `status === "submitted"` window: the user just sent, isStreaming is
|
||||||
|
// already true, but the new assistant turn has no message yet so the tail is
|
||||||
|
// the USER message. The previous assistant answer is complete on screen and
|
||||||
|
// must not be marked still-generating (WYSIWYG; regression for #160 review).
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: "t",
|
title: "t",
|
||||||
chatId: "c",
|
chatId: "c",
|
||||||
rows: [row({ role: "user", content: "persisted" })],
|
rows: [],
|
||||||
pending: [
|
live: [
|
||||||
{
|
live({ id: "a", role: "assistant", parts: [{ type: "text", text: "completed answer" }] }),
|
||||||
role: "user",
|
live({ id: "u", role: "user", parts: [{ type: "text", text: "the new question" }] }),
|
||||||
parts: [{ type: "text", text: "my live message" }],
|
|
||||||
generating: false,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
isStreaming: true,
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
expect(md).toContain("my live message");
|
expect(md).toContain("completed answer");
|
||||||
expect(md).not.toContain("still being generated");
|
expect(md).not.toContain("still being generated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes the pending messages in the metadata message count", () => {
|
it("emits the heading + note for a streaming tail assistant with empty parts", () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: "t",
|
title: "t",
|
||||||
chatId: "c",
|
chatId: "c",
|
||||||
rows: [
|
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||||
row({ role: "user", content: "a" }),
|
live: [
|
||||||
row({ role: "assistant", content: "b" }),
|
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||||
],
|
live({ id: "a-live", role: "assistant", parts: [] }),
|
||||||
pending: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [{ type: "text", text: "c" }],
|
|
||||||
generating: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: "d" }],
|
|
||||||
generating: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
// 2 persisted rows + 2 pending = 4.
|
|
||||||
expect(md).toContain("- Messages: 4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the heading and note for a generating assistant with empty parts", () => {
|
|
||||||
expect(() =>
|
|
||||||
buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [row({ role: "user", content: "persisted" })],
|
|
||||||
pending: [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
parts: [],
|
|
||||||
generating: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
t,
|
|
||||||
}),
|
|
||||||
).not.toThrow();
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: "t",
|
|
||||||
chatId: "c",
|
|
||||||
rows: [row({ role: "user", content: "persisted" })],
|
|
||||||
pending: [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
parts: [],
|
|
||||||
generating: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
isStreaming: true,
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
expect(md).toContain("## 2. AI agent");
|
expect(md).toContain("## 2. AI agent");
|
||||||
expect(md).toContain("still being generated");
|
expect(md).toContain("still being generated");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildChatMarkdown — live enrichment from persisted rows", () => {
|
||||||
|
it("pulls usage / error / timestamp from the persisted row matched by id", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "x",
|
||||||
|
createdAt: "2026-06-22T10:00:00.000Z",
|
||||||
|
metadata: { usage: { inputTokens: 10, outputTokens: 5 }, error: "rate limited" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
live: [
|
||||||
|
// Same id as the persisted row, but no usage/error/timestamp on the live msg.
|
||||||
|
live({ id: "a1", role: "assistant", parts: [{ type: "text", text: "reply" }] }),
|
||||||
|
],
|
||||||
|
isStreaming: false,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(md).toContain("reply");
|
||||||
|
// Token footer + total come from the enriched row.
|
||||||
|
expect(md).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||||
|
expect(md).toContain("- Total tokens: 15");
|
||||||
|
expect(md).toContain("**⚠️ Error:** rate limited");
|
||||||
|
// The persisted timestamp is carried into the export.
|
||||||
|
expect(md).toContain("<!-- 2026-06-22T10:00:00.000Z -->");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers authoritative usage already on the live message over the row's", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "x",
|
||||||
|
metadata: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
live: [
|
||||||
|
live({
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: "reply" }],
|
||||||
|
metadata: { usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
isStreaming: false,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
// The live (authoritative, freshest) usage wins, not the stale row usage.
|
||||||
|
expect(md).toContain("- Total tokens: 150");
|
||||||
|
expect(md).not.toContain("- Total tokens: 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a current-turn live message with no matching row renders without a footer", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||||
|
live: [
|
||||||
|
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||||
|
live({ id: "a-live", role: "assistant", parts: [{ type: "text", text: "fresh reply" }] }),
|
||||||
|
],
|
||||||
|
isStreaming: false,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(md).toContain("fresh reply");
|
||||||
|
// No persisted row for the live assistant -> no token footer, no timestamp.
|
||||||
|
expect(md).not.toContain("_Tokens —");
|
||||||
|
expect(md).not.toContain("<!-- undefined -->");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildChatMarkdown — fallback + banner", () => {
|
||||||
|
it("falls back to the persisted rows when there are no live messages", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [
|
||||||
|
row({ role: "user", content: "from rows" }),
|
||||||
|
row({
|
||||||
|
role: "assistant",
|
||||||
|
content: "answer",
|
||||||
|
metadata: { usage: { inputTokens: 4, outputTokens: 6 } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
live: [], // empty live mirror -> fallback path
|
||||||
|
isStreaming: false,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(md).toContain("## 1. You");
|
||||||
|
expect(md).toContain("## 2. AI agent");
|
||||||
|
expect(md).toContain("from rows");
|
||||||
|
expect(md).toContain("- Messages: 2");
|
||||||
|
expect(md).toContain("- Total tokens: 10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the on-screen banner once, after the messages", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [row({ role: "user", content: "q" })],
|
||||||
|
live: [live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] })],
|
||||||
|
isStreaming: false,
|
||||||
|
banner: "Rate limit reached — try again shortly.",
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(md).toContain("_⚠️ Rate limit reached — try again shortly._");
|
||||||
|
// Banner comes after the (only) message block.
|
||||||
|
expect(md.indexOf("Rate limit reached")).toBeGreaterThan(md.indexOf("## 1."));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the banner block when there is no banner", () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: "t",
|
||||||
|
chatId: "c",
|
||||||
|
rows: [row({ role: "user", content: "q" })],
|
||||||
|
live: [live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] })],
|
||||||
|
isStreaming: false,
|
||||||
|
banner: null,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
expect(md).not.toContain("_⚠️");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,11 +25,23 @@ type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|||||||
interface BuildChatMarkdownArgs {
|
interface BuildChatMarkdownArgs {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
/** The live, on-screen messages — the WYSIWYG source of the export. When
|
||||||
|
* present and non-empty these DRIVE the document (so it mirrors exactly what
|
||||||
|
* the user sees, including a partial reply from an interrupted turn). Each is
|
||||||
|
* matched to a persisted row by `id` to enrich it with token usage / error /
|
||||||
|
* timestamp. When absent or empty the builder falls back to `rows`. */
|
||||||
|
live?: LiveMessage[];
|
||||||
|
/** Persisted message rows. Enrichment source (matched to `live` by id) AND the
|
||||||
|
* fallback document source when `live` is empty. */
|
||||||
rows: IAiChatMessageRow[];
|
rows: IAiChatMessageRow[];
|
||||||
/** In-progress, not-yet-persisted live messages (the current streaming
|
/** Whether the live thread is still streaming. Only then is the tail assistant
|
||||||
* turn) to append after the persisted rows. `generating: true` adds a
|
* message flagged "still generating"; an interrupted (non-streaming) partial
|
||||||
* note that the message is still being produced. */
|
* reply is exported as-is and the `banner` explains the interruption. */
|
||||||
pending?: PendingMessage[];
|
isStreaming?: boolean;
|
||||||
|
/** The on-screen banner text (error / dropped connection / manual stop),
|
||||||
|
* appended at the end of the export so the artifact records the interruption
|
||||||
|
* the user saw. */
|
||||||
|
banner?: string | null;
|
||||||
t: Translate;
|
t: Translate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +51,31 @@ interface TextLikePart {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A live, not-yet-persisted message (current streaming turn) to append. */
|
/** Authoritative per-turn usage the server attaches to a message / row. */
|
||||||
interface PendingMessage {
|
interface UsageLike {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
reasoningTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live, on-screen message (subset of the AI SDK UIMessage we consume). */
|
||||||
|
interface LiveMessage {
|
||||||
|
id: string;
|
||||||
role: "user" | "assistant" | string;
|
role: "user" | "assistant" | string;
|
||||||
parts: TextLikePart[];
|
parts: TextLikePart[];
|
||||||
|
metadata?: { usage?: UsageLike; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message normalized for rendering, regardless of live/persisted origin. */
|
||||||
|
interface ExportItem {
|
||||||
|
role: string;
|
||||||
|
parts: TextLikePart[];
|
||||||
|
usage?: UsageLike;
|
||||||
|
error?: string;
|
||||||
|
/** ISO timestamp from the persisted row, when one is known. */
|
||||||
|
createdAt?: string;
|
||||||
|
/** True only for the tail assistant message while the thread is streaming. */
|
||||||
generating: boolean;
|
generating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,53 +160,128 @@ function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a persisted row's parts: prefer the rich persisted parts, else a
|
||||||
|
* single text part built from the plain-text content (mirrors `rowToUiMessage`). */
|
||||||
|
function rowParts(row: IAiChatMessageRow): TextLikePart[] {
|
||||||
|
return Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
||||||
|
? (row.metadata.parts as TextLikePart[])
|
||||||
|
: [{ type: "text", text: row.content ?? "" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the export to one ordered list of {@link ExportItem}, WYSIWYG-first:
|
||||||
|
*
|
||||||
|
* - When `live` messages are present, THEY are the document (what the user sees,
|
||||||
|
* incl. an interrupted turn's partial reply). Each is matched to a persisted
|
||||||
|
* row by `id` to pull token usage / error / timestamp — a live message of the
|
||||||
|
* CURRENT turn has no matching row yet, so it simply renders without a footer.
|
||||||
|
* Authoritative `usage`/`error` already on the live message metadata win over
|
||||||
|
* the row (the server attaches usage to the streamed message at a step
|
||||||
|
* boundary before the row is refetched). Only the tail assistant message is
|
||||||
|
* flagged `generating`, and only while `isStreaming`.
|
||||||
|
* - When `live` is empty (e.g. the export runs before the live mirror is
|
||||||
|
* populated), fall back to the persisted `rows` so the format never regresses.
|
||||||
|
*/
|
||||||
|
function resolveItems(
|
||||||
|
live: LiveMessage[] | undefined,
|
||||||
|
rows: IAiChatMessageRow[],
|
||||||
|
isStreaming: boolean,
|
||||||
|
): ExportItem[] {
|
||||||
|
if (live && live.length > 0) {
|
||||||
|
const rowsById = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
// The "still generating" note may apply ONLY to an assistant message that is
|
||||||
|
// the actual TAIL of the list — that is where the on-screen typing indicator
|
||||||
|
// sits. While `status === "submitted"` (isStreaming true) right after the
|
||||||
|
// user hit send, the tail is the USER message and the new assistant turn has
|
||||||
|
// no message yet; the previous assistant answer is shown complete on screen,
|
||||||
|
// so it must NOT be flagged (the indicator renders as a separate bottom
|
||||||
|
// block, not on that answer).
|
||||||
|
const lastIndex = live.length - 1;
|
||||||
|
const tailIsStreamingAssistant =
|
||||||
|
isStreaming && live[lastIndex]?.role === "assistant";
|
||||||
|
return live.map((m, i) => {
|
||||||
|
const row = rowsById.get(m.id);
|
||||||
|
return {
|
||||||
|
role: m.role,
|
||||||
|
parts: m.parts ?? [],
|
||||||
|
// Authoritative usage/error already on the live message (the server
|
||||||
|
// attaches usage to the streamed message at a step boundary) wins over
|
||||||
|
// the persisted row; a current-turn live message has no matching row yet
|
||||||
|
// and simply renders without a token footer (the accepted WYSIWYG
|
||||||
|
// tradeoff — an interrupted turn loses only its token footer, not text).
|
||||||
|
usage: m.metadata?.usage ?? row?.metadata?.usage,
|
||||||
|
error: m.metadata?.error ?? row?.metadata?.error ?? undefined,
|
||||||
|
createdAt: row?.createdAt,
|
||||||
|
generating: tailIsStreamingAssistant && i === lastIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
role: row.role,
|
||||||
|
parts: rowParts(row),
|
||||||
|
usage: row.metadata?.usage,
|
||||||
|
error: row.metadata?.error ?? undefined,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
generating: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
||||||
* export timestamp), so it is straightforward to unit-test.
|
* export timestamp), so it is straightforward to unit-test.
|
||||||
*/
|
*/
|
||||||
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
||||||
const { title, chatId, rows, pending, t } = args;
|
const { title, chatId, live, rows, isStreaming, banner, t } = args;
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
const items = resolveItems(live, rows, isStreaming === true);
|
||||||
|
|
||||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
const heading = (title ?? "").trim() || t("Untitled chat");
|
||||||
blocks.push(`# ${heading}`);
|
blocks.push(`# ${heading}`);
|
||||||
|
|
||||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
||||||
const totalTokens = rows.reduce((sum, row) => {
|
const totalTokens = items.reduce(
|
||||||
const usage = row.metadata?.usage;
|
(sum, item) => (item.usage ? sum + rowTokens(item.usage) : sum),
|
||||||
return usage ? sum + rowTokens(usage) : sum;
|
0,
|
||||||
}, 0);
|
);
|
||||||
const meta = [
|
const meta = [
|
||||||
`- Chat ID: \`${chatId}\``,
|
`- Chat ID: \`${chatId}\``,
|
||||||
`- Exported: ${new Date().toISOString()}`,
|
`- Exported: ${new Date().toISOString()}`,
|
||||||
`- Messages: ${rows.length + (pending?.length ?? 0)}`,
|
`- Messages: ${items.length}`,
|
||||||
];
|
];
|
||||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
||||||
blocks.push(meta.join("\n"));
|
blocks.push(meta.join("\n"));
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
items.forEach((item, index) => {
|
||||||
blocks.push("---");
|
blocks.push("---");
|
||||||
|
|
||||||
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
|
const roleLabel = item.role === "assistant" ? t("AI agent") : t("You");
|
||||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
||||||
|
|
||||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
||||||
blocks.push(`<!-- ${row.createdAt} -->`);
|
// A live message of the current turn has no persisted row yet — omit it.
|
||||||
|
if (item.createdAt) blocks.push(`<!-- ${item.createdAt} -->`);
|
||||||
|
|
||||||
// Resolve parts: prefer the rich persisted parts, else a single text part
|
blocks.push(...renderMessageParts(item.parts, t));
|
||||||
// built from the plain-text content (mirrors `rowToUiMessage`).
|
|
||||||
const parts: TextLikePart[] =
|
|
||||||
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
|
||||||
? (row.metadata.parts as TextLikePart[])
|
|
||||||
: [{ type: "text", text: row.content ?? "" }];
|
|
||||||
|
|
||||||
blocks.push(...renderMessageParts(parts, t));
|
// A generating assistant may have empty/no parts yet — the heading (above)
|
||||||
|
// and this note still record the in-progress turn.
|
||||||
if (row.metadata?.error) {
|
if (item.generating) {
|
||||||
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
|
blocks.push(
|
||||||
|
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = row.metadata?.usage;
|
// A persisted per-message error (the raw provider text) may coexist with the
|
||||||
|
// trailing `banner` (the classified on-screen alert) when the failed turn's
|
||||||
|
// row has already been refetched by export time. They describe the same
|
||||||
|
// failure at different fidelity; showing both is an accepted, minor redundancy.
|
||||||
|
if (item.error) {
|
||||||
|
blocks.push(`**⚠️ Error:** ${item.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = item.usage;
|
||||||
if (usage) {
|
if (usage) {
|
||||||
const total = usage.totalTokens ?? rowTokens(usage);
|
const total = usage.totalTokens ?? rowTokens(usage);
|
||||||
// Reasoning (thinking) tokens are shown only when the provider reported a
|
// Reasoning (thinking) tokens are shown only when the provider reported a
|
||||||
@@ -188,27 +296,12 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append the in-progress, not-yet-persisted live messages (the current
|
// Record the on-screen banner (error / dropped connection / manual stop) so
|
||||||
// streaming turn) after the persisted rows. Heading numbering CONTINUES from
|
// the export reflects exactly what the user saw, including an interruption.
|
||||||
// the persisted rows. A `generating` assistant gets a note that the captured
|
if (banner && banner.trim().length > 0) {
|
||||||
// response is partial; pending messages carry no usage/token footer yet.
|
|
||||||
(pending ?? []).forEach((message, p) => {
|
|
||||||
blocks.push("---");
|
blocks.push("---");
|
||||||
|
blocks.push(`_⚠️ ${banner.trim()}_`);
|
||||||
const num = rows.length + p + 1;
|
|
||||||
const roleLabel = message.role === "assistant" ? t("AI agent") : t("You");
|
|
||||||
blocks.push(`## ${num}. ${roleLabel}`);
|
|
||||||
|
|
||||||
blocks.push(...renderMessageParts(message.parts, t));
|
|
||||||
|
|
||||||
// A generating assistant may have empty/no parts yet — still emit the
|
|
||||||
// heading (above) and this note so the export shows the in-progress turn.
|
|
||||||
if (message.generating === true) {
|
|
||||||
blocks.push(
|
|
||||||
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Blank line between blocks so the Markdown renders cleanly.
|
// Blank line between blocks so the Markdown renders cleanly.
|
||||||
return blocks.join("\n\n");
|
return blocks.join("\n\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user