feat(webui): segment transcript storage
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
@@ -10,9 +9,7 @@ interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
hiddenUserMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
@@ -66,9 +63,7 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
hiddenUserMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
forkBoundaryMessageCount = null,
|
||||
@@ -90,20 +85,6 @@ export function ThreadMessages({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{hiddenMessageCount > 0 && onLoadEarlier ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadEarlier}
|
||||
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
{t("thread.loadEarlier", {
|
||||
count: hiddenMessageCount,
|
||||
defaultValue: "Load earlier messages",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
|
||||
@@ -250,6 +250,10 @@ export function ThreadShell({
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
loadingOlder,
|
||||
loadOlder,
|
||||
hasMoreBefore,
|
||||
userMessageOffset,
|
||||
hasPendingToolCalls,
|
||||
refresh: refreshHistory,
|
||||
version: historyVersion,
|
||||
@@ -415,6 +419,14 @@ export function ThreadShell({
|
||||
}
|
||||
if (cached && cached.length > 0) {
|
||||
const normalizedCached = projectWebuiThreadMessages(cached);
|
||||
if (
|
||||
normalizedHistory.length > normalizedCached.length
|
||||
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
||||
) {
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
|
||||
return normalizedCached;
|
||||
}
|
||||
@@ -752,6 +764,10 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
userMessageOffset={userMessageOffset}
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
/>
|
||||
|
||||
@@ -38,11 +38,16 @@ interface ThreadViewportProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
hasMoreBefore?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
userMessageOffset?: number;
|
||||
onLoadOlder?: () => Promise<void> | void;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
@@ -72,6 +77,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
forkBoundaryMessageCount = null,
|
||||
hasMoreBefore = false,
|
||||
loadingOlder = false,
|
||||
userMessageOffset = 0,
|
||||
onLoadOlder,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}, ref) {
|
||||
@@ -99,9 +108,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
);
|
||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||
const hiddenUserMessageCount =
|
||||
hiddenMessageCount > 0
|
||||
userMessageOffset
|
||||
+ (hiddenMessageCount > 0
|
||||
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
|
||||
: 0;
|
||||
: 0);
|
||||
const visibleForkBoundaryMessageCount =
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
@@ -126,6 +136,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
} else if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
}
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
@@ -159,10 +170,26 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
}
|
||||
userReadingHistoryRef.current = true;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) =>
|
||||
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
|
||||
);
|
||||
}, [messages.length]);
|
||||
if (hiddenMessageCount > 0) {
|
||||
setVisibleMessageCount((count) =>
|
||||
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (hasMoreBefore && onLoadOlder && !loadingOlder) {
|
||||
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
||||
void onLoadOlder();
|
||||
}
|
||||
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
|
||||
|
||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||
if (!userReadingHistoryRef.current) return;
|
||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||
loadEarlierMessages();
|
||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||
|
||||
const jumpToUserPrompt = useCallback((promptId: string) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -218,8 +245,17 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
el.scrollTop = pending.top + delta;
|
||||
}, [visibleMessages.length]);
|
||||
const nextTop = pending.top + delta;
|
||||
try {
|
||||
el.scrollTop = nextTop;
|
||||
} catch {
|
||||
try {
|
||||
el.scrollTo?.({ top: nextTop, behavior: "auto" });
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
}
|
||||
}, [visibleMessages.length, messages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const promptId = pendingPromptJumpRef.current;
|
||||
@@ -271,17 +307,19 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const onScroll = (allowHistoryLoad = true) => {
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const near = distance < NEAR_BOTTOM_PX;
|
||||
setAtBottom(near);
|
||||
userReadingHistoryRef.current = !near;
|
||||
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
|
||||
};
|
||||
|
||||
onScroll();
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
onScroll(false);
|
||||
const handleScroll = () => onScroll(true);
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [maybeLoadEarlierFromScroll]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
@@ -302,9 +340,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
|
||||
|
||||
Reference in New Issue
Block a user