chat: switch dashboard chat page to slice and drop superseded aborted rows on resume.

This commit is contained in:
CREDO23 2026-05-09 18:35:39 +02:00
parent d96f966c8f
commit 9c5a178468
2 changed files with 92 additions and 18 deletions

View file

@ -43,13 +43,14 @@ import {
type EditMessageDialogChoice, type EditMessageDialogChoice,
} from "@/components/assistant-ui/edit-message-dialog"; } from "@/components/assistant-ui/edit-message-dialog";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator"; import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { import {
createTokenUsageStore, createTokenUsageStore,
type TokenUsageData, type TokenUsageData,
TokenUsageProvider, TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context"; } from "@/components/assistant-ui/token-usage-context";
import { type BundleSubmit, HitlBundleProvider } from "@/features/chat-messages/hitl";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import { import {
applyActionLogSse, applyActionLogSse,
applyActionLogUpdatedSse, applyActionLogUpdatedSse,
@ -63,7 +64,7 @@ import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier"; import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors"; import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { convertToThreadMessage, filterSupersededAbortedMessages } from "@/lib/chat/message-utils";
import { import {
isPodcastGenerating, isPodcastGenerating,
looksLikePodcastRequest, looksLikePodcastRequest,
@ -107,7 +108,6 @@ import {
type NewChatUserImagePayload, type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts"; } from "@/lib/chat/user-turn-api-parts";
import { NotFoundError } from "@/lib/error"; import { NotFoundError } from "@/lib/error";
import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl";
import { import {
trackChatBlocked, trackChatBlocked,
trackChatCreated, trackChatCreated,
@ -126,7 +126,7 @@ const MobileEditorPanel = dynamic(
); );
const MobileHitlEditPanel = dynamic( const MobileHitlEditPanel = dynamic(
() => () =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({ import("@/features/chat-messages/hitl").then((m) => ({
default: m.MobileHitlEditPanel, default: m.MobileHitlEditPanel,
})), })),
{ ssr: false } { ssr: false }
@ -395,7 +395,7 @@ export default function NewChatPage() {
const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []); const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []);
const prevById = new Map(prev.map((m) => [m.id, m])); const prevById = new Map(prev.map((m) => [m.id, m]));
return syncedMessages.map((msg) => { return filterSupersededAbortedMessages(syncedMessages).map((msg) => {
const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null; const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null;
// Preserve existing author info if member lookup fails (e.g., cloned chats) // Preserve existing author info if member lookup fails (e.g., cloned chats)
@ -622,7 +622,9 @@ export default function NewChatPage() {
setCurrentThread(threadData); setCurrentThread(threadData);
if (messagesResponse.messages && messagesResponse.messages.length > 0) { if (messagesResponse.messages && messagesResponse.messages.length > 0) {
const loadedMessages = messagesResponse.messages.map(convertToThreadMessage); const loadedMessages = filterSupersededAbortedMessages(messagesResponse.messages).map(
convertToThreadMessage
);
setMessages(loadedMessages); setMessages(loadedMessages);
for (const msg of messagesResponse.messages) { for (const msg of messagesResponse.messages) {
@ -1388,6 +1390,8 @@ export default function NewChatPage() {
const existingMsg = messages.find((m) => m.id === assistantMsgId); const existingMsg = messages.find((m) => m.id === assistantMsgId);
if (existingMsg && Array.isArray(existingMsg.content)) { if (existingMsg && Array.isArray(existingMsg.content)) {
// See ``ContentPartsState.suppressStepSeparators`` doc.
contentPartsState.suppressStepSeparators = true;
for (const part of existingMsg.content) { for (const part of existingMsg.content) {
if (typeof part === "object" && part !== null) { if (typeof part === "object" && part !== null) {
const p = part as Record<string, unknown>; const p = part as Record<string, unknown>;
@ -1402,15 +1406,19 @@ export default function NewChatPage() {
toolName: String(p.toolName), toolName: String(p.toolName),
args: (p.args as Record<string, unknown>) ?? {}, args: (p.args as Record<string, unknown>) ?? {},
result: p.result as unknown, result: p.result as unknown,
// Restore argsText so persisted pretty-printed // argsText: assistant-ui prefers it over
// JSON survives reloads (assistant-ui prefers // JSON.stringify(args), so restoring it keeps
// supplied argsText over JSON.stringify(args)). // pretty-printed JSON across reloads.
// langchainToolCallId restoration also fixes a
// pre-existing dropped-id bug on resume.
...(typeof p.argsText === "string" ? { argsText: p.argsText } : {}), ...(typeof p.argsText === "string" ? { argsText: p.argsText } : {}),
...(typeof p.langchainToolCallId === "string" ...(typeof p.langchainToolCallId === "string"
? { langchainToolCallId: p.langchainToolCallId } ? { langchainToolCallId: p.langchainToolCallId }
: {}), : {}),
// metadata: spanId / thinkingStepId drive the
// timeline's step↔tool join. Dropping these
// here orphans every rehydrated tool-call.
...(p.metadata && typeof p.metadata === "object"
? { metadata: p.metadata as Record<string, unknown> }
: {}),
}); });
contentPartsState.currentTextPartIndex = -1; contentPartsState.currentTextPartIndex = -1;
} else if (p.type === "data-thinking-steps") { } else if (p.type === "data-thinking-steps") {
@ -2353,7 +2361,7 @@ export default function NewChatPage() {
return ( return (
<TokenUsageProvider store={tokenUsageStore}> <TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI /> <TimelineDataUI />
<StepSeparatorDataUI /> <StepSeparatorDataUI />
<HitlBundleProvider <HitlBundleProvider
toolCallIds={pendingInterrupt?.bundleToolCallIds ?? null} toolCallIds={pendingInterrupt?.bundleToolCallIds ?? null}

View file

@ -1,9 +1,75 @@
import type { ThreadMessageLike } from "@assistant-ui/react"; import type { ThreadMessageLike } from "@assistant-ui/react";
import type { MessageRecord } from "./thread-persistence"; import type { MessageRecord } from "./thread-persistence";
/** Minimal shape used by ``filterSupersededAbortedMessages``. */
interface AbortableMessage {
id: number;
role: string;
content: unknown;
turn_id?: string | null;
}
/** /**
* Convert backend message to assistant-ui ThreadMessageLike format. * True when the row is a frozen interrupt frame: an assistant message
* Migrates legacy `thinking-steps` parts to `data-thinking-steps` (assistant-ui data parts). * whose tool-calls all carry ``state: "aborted"``. A single non-aborted
* tool-call disqualifies (defensive against future mixed states).
*/
function isAbortedAssistantMessage(msg: AbortableMessage): boolean {
if (msg.role.toLowerCase() !== "assistant") return false;
if (!Array.isArray(msg.content)) return false;
let hasToolCalls = false;
for (const part of msg.content) {
if (typeof part !== "object" || part === null) continue;
if ((part as { type?: string }).type !== "tool-call") continue;
hasToolCalls = true;
if ((part as { state?: unknown }).state !== "aborted") return false;
}
return hasToolCalls;
}
/**
* Positional supersede check: an aborted assistant row is superseded
* iff another assistant row appears later before any user row.
*
* NOT turn-id-based: ``stream_resume_chat`` allocates a fresh
* ``turn_id`` for the resumed row, so interrupt+resume rows never
* share a turn_id. Conversational adjacency is the reliable signal
* an assistantassistant pair without a user row between them is the
* unique signature of an interrupt+resume cycle.
*/
function isSupersededByLaterAssistant<T extends AbortableMessage>(
messages: readonly T[],
idx: number
): boolean {
for (let i = idx + 1; i < messages.length; i++) {
const role = messages[i].role.toLowerCase();
if (role === "user") return false;
if (role === "assistant") return true;
}
return false;
}
/**
* Drop frozen interrupt-frame rows once they have a resumed
* continuation. Pure (returns a new array). Caller passes messages in
* chronological order.
*
* Never-resumed aborts are preserved (user navigated away mid-decision)
* so the user still sees what happened.
*/
export function filterSupersededAbortedMessages<T extends AbortableMessage>(
messages: readonly T[]
): T[] {
return messages.filter((msg, idx) => {
if (!isAbortedAssistantMessage(msg)) return true;
return !isSupersededByLaterAssistant(messages, idx);
});
}
/**
* Convert a backend ``MessageRecord`` to assistant-ui's
* ``ThreadMessageLike``. Also migrates legacy ``thinking-steps`` parts
* to ``data-thinking-steps``.
*/ */
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"]; let content: ThreadMessageLike["content"];
@ -24,9 +90,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
"type" in part && "type" in part &&
(part as { type: string }).type === "thinking-steps" (part as { type: string }).type === "thinking-steps"
) { ) {
const steps = (part as unknown as { steps?: unknown[] }).steps;
return { return {
type: "data-thinking-steps", type: "data-thinking-steps",
data: { steps: (part as { steps: unknown[] }).steps ?? [] }, data: { steps: Array.isArray(steps) ? steps : [] },
}; };
} }
return part; return part;
@ -50,9 +117,8 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
}, },
}), }),
...(msg.token_usage && { usage: msg.token_usage }), ...(msg.token_usage && { usage: msg.token_usage }),
// Surface ``chat_turn_id`` so the assistant message // Surfaced for the assistant footer's per-turn
// footer can scope its "Revert turn" button to just // "Revert turn" button. Null on legacy rows.
// this turn's actions. Null on legacy rows.
...(msg.turn_id && { chatTurnId: msg.turn_id }), ...(msg.turn_id && { chatTurnId: msg.turn_id }),
}, },
} }