mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
feat(frontend): add conversation outline navigation for long chats (#5025)
* feat(frontend): add conversation outline navigation for long chats * fix(frontend): escape bottom lock before outline navigation
This commit is contained in:
parent
bf3e792a6a
commit
6e5a41fd9a
@ -331,6 +331,7 @@ export default function AgentChatPage() {
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
enableConversationOutline
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
|
||||
@ -333,6 +333,7 @@ export default function ChatPage() {
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
enableConversationOutline
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { ListIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ConversationChapter } from "@/core/messages/conversation-outline";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_OUTLINE_TICKS = 24;
|
||||
|
||||
type ConversationOutlineProps = {
|
||||
chapters: readonly ConversationChapter[];
|
||||
activeChapterId: string | null;
|
||||
onChapterSelect: (chapterId: string) => void;
|
||||
};
|
||||
|
||||
type OutlineTick = {
|
||||
id: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function buildOutlineTicks(
|
||||
chapters: readonly ConversationChapter[],
|
||||
activeChapterIndex: number,
|
||||
): OutlineTick[] {
|
||||
const tickCount = Math.min(chapters.length, MAX_OUTLINE_TICKS);
|
||||
|
||||
return Array.from({ length: tickCount }, (_, tickIndex) => {
|
||||
const startIndex = Math.floor((tickIndex * chapters.length) / tickCount);
|
||||
const endIndex = Math.floor(
|
||||
((tickIndex + 1) * chapters.length) / tickCount,
|
||||
);
|
||||
|
||||
return {
|
||||
id: chapters[startIndex]?.id ?? `tick:${tickIndex}`,
|
||||
active: activeChapterIndex >= startIndex && activeChapterIndex < endIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ConversationOutline({
|
||||
chapters,
|
||||
activeChapterId,
|
||||
onChapterSelect,
|
||||
}: ConversationOutlineProps): ReactNode {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeItemRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeChapterIndex = chapters.findIndex(
|
||||
(chapter) => chapter.id === activeChapterId,
|
||||
);
|
||||
const outlineTicks = buildOutlineTicks(chapters, activeChapterIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
activeItemRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeChapterId, open]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-1/2 right-2 z-20 -translate-y-1/2 sm:right-3">
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.conversation.outlineLabel}
|
||||
className="bg-background/80 text-muted-foreground hover:text-foreground pointer-events-auto size-9 rounded-full border shadow-sm backdrop-blur-sm lg:h-auto lg:min-h-10 lg:w-7 lg:flex-col lg:gap-1 lg:px-1 lg:py-2"
|
||||
data-testid="conversation-outline-trigger"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ListIcon className="size-4 lg:hidden" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden max-h-52 w-full flex-col items-center gap-1 overflow-hidden lg:flex"
|
||||
>
|
||||
{outlineTicks.map((tick) => (
|
||||
<span
|
||||
key={tick.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/45 h-0.5 w-3 rounded-full transition-[width,background-color] motion-reduce:transition-none",
|
||||
tick.active && "bg-foreground h-[3px] w-4",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="border-border/80 bg-popover/95 max-h-[min(72vh,36rem)] w-72 overflow-y-auto rounded-2xl p-2 shadow-xl backdrop-blur-sm sm:max-h-[min(80vh,40rem)]"
|
||||
data-testid="conversation-outline-menu"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
>
|
||||
{chapters.map((chapter) => {
|
||||
const active = chapter.id === activeChapterId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={chapter.id}
|
||||
ref={active ? activeItemRef : undefined}
|
||||
aria-current={active ? "location" : undefined}
|
||||
className={cn(
|
||||
"items-start rounded-lg px-3 py-2 text-[15px] leading-5 whitespace-normal",
|
||||
active && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
title={chapter.title}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onChapterSelect(chapter.id);
|
||||
}}
|
||||
>
|
||||
<span className="line-clamp-2 min-w-0 leading-5">
|
||||
{chapter.title}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,10 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildConversationChapters,
|
||||
CONVERSATION_OUTLINE_MIN_TURNS,
|
||||
} from "@/core/messages/conversation-outline";
|
||||
import {
|
||||
deriveAssistantTurnUsageState,
|
||||
deriveStableMessageGroups,
|
||||
@ -83,6 +87,7 @@ import { CopyButton } from "../copy-button";
|
||||
import { useMaybeSidecar } from "../sidecar/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { ConversationOutline } from "./conversation-outline";
|
||||
import {
|
||||
HumanInputCard,
|
||||
type HumanInputSubmitResult,
|
||||
@ -97,7 +102,10 @@ import {
|
||||
import { RunActivity, RunDuration } from "./run-duration";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
import { VirtualMessageList } from "./virtual-message-list";
|
||||
import {
|
||||
VirtualMessageList,
|
||||
type VirtualMessageListHandle,
|
||||
} from "./virtual-message-list";
|
||||
|
||||
const EMPTY_TOKEN_DEBUG_STEPS: TokenDebugStep[] = [];
|
||||
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
|
||||
@ -287,6 +295,7 @@ export function MessageList({
|
||||
canEdit = false,
|
||||
canBranch = false,
|
||||
enableSidecarActions = true,
|
||||
enableConversationOutline = false,
|
||||
sidecarSurface = false,
|
||||
initialScroll = "smooth",
|
||||
resizeScroll = "smooth",
|
||||
@ -320,6 +329,7 @@ export function MessageList({
|
||||
canEdit?: boolean;
|
||||
canBranch?: boolean;
|
||||
enableSidecarActions?: boolean;
|
||||
enableConversationOutline?: boolean;
|
||||
sidecarSurface?: boolean;
|
||||
initialScroll?: ConversationProps["initial"];
|
||||
resizeScroll?: ConversationProps["resize"];
|
||||
@ -330,6 +340,60 @@ export function MessageList({
|
||||
useState<SelectionToolbarState | null>(null);
|
||||
const messages = thread.messages;
|
||||
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
|
||||
const chapters = useMemo(
|
||||
() =>
|
||||
buildConversationChapters(
|
||||
groupedMessages,
|
||||
t.conversation.outlineAttachmentFallback,
|
||||
),
|
||||
[groupedMessages, t.conversation.outlineAttachmentFallback],
|
||||
);
|
||||
const conversationOutlineEnabled =
|
||||
enableConversationOutline &&
|
||||
chapters.length >= CONVERSATION_OUTLINE_MIN_TURNS;
|
||||
const virtualMessageListRef = useRef<VirtualMessageListHandle | null>(null);
|
||||
const [activeChapter, setActiveChapter] = useState<{
|
||||
threadId: string;
|
||||
chapterId: string;
|
||||
} | null>(null);
|
||||
const activeChapterId =
|
||||
activeChapter?.threadId === threadId &&
|
||||
chapters.some((chapter) => chapter.id === activeChapter.chapterId)
|
||||
? activeChapter.chapterId
|
||||
: (chapters.at(-1)?.id ?? null);
|
||||
const handleActiveGroupChange = useCallback(
|
||||
(groupIndex: number) => {
|
||||
let chapterId: string | undefined;
|
||||
for (const chapter of chapters) {
|
||||
if (chapter.groupIndex > groupIndex) {
|
||||
break;
|
||||
}
|
||||
chapterId = chapter.id;
|
||||
}
|
||||
if (chapterId) {
|
||||
setActiveChapter((current) =>
|
||||
current?.threadId === threadId && current.chapterId === chapterId
|
||||
? current
|
||||
: { threadId, chapterId },
|
||||
);
|
||||
}
|
||||
},
|
||||
[chapters, threadId],
|
||||
);
|
||||
const handleChapterSelect = useCallback(
|
||||
(chapterId: string) => {
|
||||
const chapter = chapters.find((candidate) => candidate.id === chapterId);
|
||||
if (!chapter) {
|
||||
return;
|
||||
}
|
||||
setActiveChapter({ threadId, chapterId });
|
||||
virtualMessageListRef.current?.scrollToGroup(chapter.groupIndex, {
|
||||
align: "start",
|
||||
behavior: "auto",
|
||||
});
|
||||
},
|
||||
[chapters, threadId],
|
||||
);
|
||||
const browserView = useMaybeBrowserView();
|
||||
const pushBrowserFrame = browserView?.pushFrame;
|
||||
const messageCount = messages.length;
|
||||
@ -1020,8 +1084,12 @@ export function MessageList({
|
||||
loadMore={loadMoreHistory}
|
||||
/>
|
||||
<VirtualMessageList
|
||||
ref={virtualMessageListRef}
|
||||
groups={groupedMessages}
|
||||
isLoading={thread.isLoading}
|
||||
onActiveGroupChange={
|
||||
conversationOutlineEnabled ? handleActiveGroupChange : undefined
|
||||
}
|
||||
renderGroup={(group, groupIndex) => {
|
||||
const turnUsageMessages =
|
||||
turnUsageMessagesByGroupIndex[groupIndex];
|
||||
@ -1359,6 +1427,13 @@ export function MessageList({
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
{conversationOutlineEnabled && (
|
||||
<ConversationOutline
|
||||
chapters={chapters}
|
||||
activeChapterId={activeChapterId}
|
||||
onChapterSelect={handleChapterSelect}
|
||||
/>
|
||||
)}
|
||||
{selectionToolbar && sidecar && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -6,9 +6,11 @@ import {
|
||||
type Range,
|
||||
} from "@tanstack/react-virtual";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Key,
|
||||
type ReactNode,
|
||||
@ -19,8 +21,29 @@ import type { MessageGroup } from "@/core/messages/utils";
|
||||
|
||||
const VIRTUALIZATION_THRESHOLD = 60;
|
||||
const ESTIMATED_ROW_HEIGHT = 176;
|
||||
const GROUP_START_OFFSET = 16;
|
||||
const VIRTUAL_SCROLL_SETTLE_ATTEMPTS = 4;
|
||||
const STATIC_SCROLL_SETTLE_ATTEMPTS = 2;
|
||||
|
||||
function groupKey(group: MessageGroup | undefined, index: number) {
|
||||
type GroupAlignment = "start" | "center";
|
||||
|
||||
type ScrollToGroupOptions = {
|
||||
behavior?: ScrollBehavior;
|
||||
align?: GroupAlignment;
|
||||
};
|
||||
|
||||
type VirtualMessageListProps = {
|
||||
groups: readonly MessageGroup[];
|
||||
isLoading: boolean;
|
||||
renderGroup: (group: MessageGroup, index: number) => ReactNode;
|
||||
onActiveGroupChange?: (groupIndex: number) => void;
|
||||
};
|
||||
|
||||
export type VirtualMessageListHandle = {
|
||||
scrollToGroup: (groupIndex: number, options?: ScrollToGroupOptions) => void;
|
||||
};
|
||||
|
||||
function groupKey(group: MessageGroup | undefined, index: number): Key {
|
||||
return (
|
||||
group?.id ??
|
||||
group?.messages.find((message) => message.id)?.id ??
|
||||
@ -28,16 +51,16 @@ function groupKey(group: MessageGroup | undefined, index: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export function VirtualMessageList({
|
||||
groups,
|
||||
isLoading,
|
||||
renderGroup,
|
||||
}: {
|
||||
groups: readonly MessageGroup[];
|
||||
isLoading: boolean;
|
||||
renderGroup: (group: MessageGroup, index: number) => ReactNode;
|
||||
}) {
|
||||
const { isAtBottom, scrollRef, scrollToBottom } = useStickToBottomContext();
|
||||
export const VirtualMessageList = forwardRef<
|
||||
VirtualMessageListHandle,
|
||||
VirtualMessageListProps
|
||||
>(function VirtualMessageList(
|
||||
{ groups, isLoading, renderGroup, onActiveGroupChange },
|
||||
ref,
|
||||
) {
|
||||
const { isAtBottom, scrollRef, scrollToBottom, stopScroll } =
|
||||
useStickToBottomContext();
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeIndex = isLoading ? groups.length - 1 : -1;
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => groupKey(groups[index], index),
|
||||
@ -64,13 +87,142 @@ export function VirtualMessageList({
|
||||
});
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const shouldVirtualize = groups.length >= VIRTUALIZATION_THRESHOLD;
|
||||
const firstVirtualIndex = virtualItems[0]?.index ?? -1;
|
||||
const lastVirtualIndex = virtualItems.at(-1)?.index ?? -1;
|
||||
const positionedInitialVirtualWindowRef = useRef(false);
|
||||
const previousCountRef = useRef(groups.length);
|
||||
const previousFirstKeyRef = useRef<string | undefined>(undefined);
|
||||
const previousFirstKeyRef = useRef<Key | undefined>(undefined);
|
||||
const previousActiveGroupRef = useRef(-1);
|
||||
const anchorRef = useRef<{ key: Key; viewportOffset: number } | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const alignGroupToViewport = useCallback(
|
||||
(
|
||||
groupIndex: number,
|
||||
align: GroupAlignment,
|
||||
behavior: ScrollBehavior,
|
||||
): boolean => {
|
||||
const viewport = scrollRef.current;
|
||||
const row = listRef.current?.querySelector<HTMLElement>(
|
||||
`[data-message-group-index="${groupIndex}"]`,
|
||||
);
|
||||
if (!viewport || !row) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const top =
|
||||
viewport.scrollTop +
|
||||
rowRect.top -
|
||||
viewportRect.top -
|
||||
(align === "center"
|
||||
? Math.max(0, (viewport.clientHeight - rowRect.height) / 2)
|
||||
: GROUP_START_OFFSET);
|
||||
viewport.scrollTo({ top: Math.max(0, top), behavior });
|
||||
return true;
|
||||
},
|
||||
[scrollRef],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToGroup(groupIndex, options) {
|
||||
if (groupIndex < 0 || groupIndex >= groups.length) {
|
||||
return;
|
||||
}
|
||||
stopScroll();
|
||||
const behavior = options?.behavior ?? "auto";
|
||||
const align = options?.align ?? "start";
|
||||
if (shouldVirtualize) {
|
||||
virtualizer.scrollToIndex(groupIndex, { align, behavior });
|
||||
}
|
||||
|
||||
const maxAttempts = shouldVirtualize
|
||||
? VIRTUAL_SCROLL_SETTLE_ATTEMPTS
|
||||
: STATIC_SCROLL_SETTLE_ATTEMPTS;
|
||||
let attempt = 0;
|
||||
const settleOnExactGroup = () => {
|
||||
const exactBehavior = attempt === 0 ? behavior : "auto";
|
||||
const aligned = alignGroupToViewport(
|
||||
groupIndex,
|
||||
align,
|
||||
exactBehavior,
|
||||
);
|
||||
attempt += 1;
|
||||
const needsAnotherAttempt = shouldVirtualize || !aligned;
|
||||
if (attempt < maxAttempts && needsAnotherAttempt) {
|
||||
requestAnimationFrame(settleOnExactGroup);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(settleOnExactGroup);
|
||||
},
|
||||
}),
|
||||
[
|
||||
alignGroupToViewport,
|
||||
groups.length,
|
||||
shouldVirtualize,
|
||||
stopScroll,
|
||||
virtualizer,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current;
|
||||
const list = listRef.current;
|
||||
if (!viewport || !list || !onActiveGroupChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
let animationFrame: number | undefined;
|
||||
const updateActiveGroup = () => {
|
||||
animationFrame = undefined;
|
||||
const rows = list.querySelectorAll<HTMLElement>(
|
||||
"[data-message-group-index]",
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readingLine = viewport.getBoundingClientRect().top + 96;
|
||||
let activeRow = rows[0];
|
||||
for (const row of rows) {
|
||||
if (row.getBoundingClientRect().top > readingLine) {
|
||||
break;
|
||||
}
|
||||
activeRow = row;
|
||||
}
|
||||
const groupIndex = Number(activeRow?.dataset.messageGroupIndex);
|
||||
if (
|
||||
Number.isSafeInteger(groupIndex) &&
|
||||
groupIndex !== previousActiveGroupRef.current
|
||||
) {
|
||||
previousActiveGroupRef.current = groupIndex;
|
||||
onActiveGroupChange(groupIndex);
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
animationFrame ??= requestAnimationFrame(updateActiveGroup);
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
viewport.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", scheduleUpdate);
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
firstVirtualIndex,
|
||||
groups,
|
||||
lastVirtualIndex,
|
||||
onActiveGroupChange,
|
||||
scrollRef,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let settleFrame: number | undefined;
|
||||
if (
|
||||
@ -144,22 +296,21 @@ export function VirtualMessageList({
|
||||
}
|
||||
}, [getItemKey, groups, virtualItems, virtualizer]);
|
||||
|
||||
const renderedAll = useMemo(
|
||||
() =>
|
||||
shouldVirtualize
|
||||
? null
|
||||
: groups.map((group, index) => (
|
||||
<div key={getItemKey(index)}>{renderGroup(group, index)}</div>
|
||||
)),
|
||||
[getItemKey, groups, renderGroup, shouldVirtualize],
|
||||
);
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return <div className="flex flex-col gap-8">{renderedAll}</div>;
|
||||
return (
|
||||
<div ref={listRef} className="flex flex-col gap-8">
|
||||
{groups.map((group, index) => (
|
||||
<div key={getItemKey(index)} data-message-group-index={index}>
|
||||
{renderGroup(group, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
@ -171,6 +322,7 @@ export function VirtualMessageList({
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
data-message-group-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full pb-8"
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
@ -180,4 +332,4 @@ export function VirtualMessageList({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -620,6 +620,8 @@ export const enUS: Translations = {
|
||||
branchFailed: "Failed to branch conversation.",
|
||||
streamReplayGap:
|
||||
"Some live updates expired. The conversation was restored from saved state.",
|
||||
outlineLabel: "Conversation outline",
|
||||
outlineAttachmentFallback: "Image or file message",
|
||||
},
|
||||
|
||||
// Chats
|
||||
|
||||
@ -506,6 +506,8 @@ export interface Translations {
|
||||
branchCreated: string;
|
||||
branchFailed: string;
|
||||
streamReplayGap: string;
|
||||
outlineLabel: string;
|
||||
outlineAttachmentFallback: string;
|
||||
};
|
||||
|
||||
// Chats
|
||||
|
||||
@ -592,6 +592,8 @@ export const zhCN: Translations = {
|
||||
branchCreated: "已创建分叉对话",
|
||||
branchFailed: "创建分叉对话失败。",
|
||||
streamReplayGap: "部分实时更新已过期,已从持久化状态恢复对话。",
|
||||
outlineLabel: "对话章节",
|
||||
outlineAttachmentFallback: "图片或文件消息",
|
||||
},
|
||||
|
||||
// Chats
|
||||
|
||||
54
frontend/src/core/messages/conversation-outline.ts
Normal file
54
frontend/src/core/messages/conversation-outline.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
extractTextFromMessage,
|
||||
stripUploadedFilesTag,
|
||||
type MessageGroup,
|
||||
} from "./utils";
|
||||
|
||||
export const CONVERSATION_OUTLINE_MIN_TURNS = 5;
|
||||
export const CONVERSATION_CHAPTER_TITLE_MAX_LENGTH = 48;
|
||||
|
||||
export type ConversationChapter = {
|
||||
id: string;
|
||||
groupIndex: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function normalizeChapterTitle(content: string, fallbackTitle: string): string {
|
||||
const normalized = stripUploadedFilesTag(content).replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
const characters = Array.from(normalized);
|
||||
if (characters.length <= CONVERSATION_CHAPTER_TITLE_MAX_LENGTH) {
|
||||
return normalized;
|
||||
}
|
||||
return `${characters
|
||||
.slice(0, CONVERSATION_CHAPTER_TITLE_MAX_LENGTH)
|
||||
.join("")}…`;
|
||||
}
|
||||
|
||||
export function buildConversationChapters(
|
||||
groups: readonly MessageGroup[],
|
||||
fallbackTitle: string,
|
||||
): ConversationChapter[] {
|
||||
const chapters: ConversationChapter[] = [];
|
||||
|
||||
groups.forEach((group, groupIndex) => {
|
||||
if (group.type !== "human") {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = group.messages[0];
|
||||
chapters.push({
|
||||
id: group.id ?? message?.id ?? `human-turn:${groupIndex}`,
|
||||
groupIndex,
|
||||
title: normalizeChapterTitle(
|
||||
message ? extractTextFromMessage(message) : "",
|
||||
fallbackTitle,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
return chapters;
|
||||
}
|
||||
@ -94,6 +94,52 @@ test.describe("Thread history", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("shows the conversation outline only at the long-chat threshold", async ({
|
||||
page,
|
||||
}) => {
|
||||
const turns = (count: number, prefix: string) =>
|
||||
Array.from({ length: count }, (_, turn) => [
|
||||
{
|
||||
type: "human",
|
||||
id: `${prefix}-human-${turn}`,
|
||||
content: `${prefix} question ${turn}`,
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: `${prefix}-ai-${turn}`,
|
||||
content: `${prefix} answer ${turn}`,
|
||||
},
|
||||
]).flat();
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Four turns",
|
||||
messages: turns(4, "Short"),
|
||||
},
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID_2,
|
||||
title: "Five turns",
|
||||
messages: turns(5, "Long"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("Short answer 3")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("conversation-outline-trigger")).toBeHidden();
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||||
await expect(page.getByText("Long answer 4")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("conversation-outline-trigger"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps a thousand-turn history DOM bounded while preserving navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
@ -126,20 +172,47 @@ test.describe("Thread history", () => {
|
||||
});
|
||||
|
||||
const conversation = page.getByRole("log");
|
||||
const scroller = conversation.locator(":scope > div").first();
|
||||
await expect
|
||||
.poll(() => conversation.locator("[data-index]").count())
|
||||
.toBeLessThan(60);
|
||||
|
||||
await scroller.dispatchEvent("wheel", { deltaY: -1_000 });
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
const outlineTrigger = page.getByTestId("conversation-outline-trigger");
|
||||
await expect(outlineTrigger).toBeVisible();
|
||||
await outlineTrigger.click();
|
||||
const outlineMenu = page.getByTestId("conversation-outline-menu");
|
||||
await outlineMenu
|
||||
.getByText("Long history question 0", { exact: true })
|
||||
.click();
|
||||
|
||||
const targetQuestion = conversation.getByText("Long history question 0", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(page.getByText("Long history question 0")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
const targetAnswer = conversation.getByText("Long history answer 0", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(targetQuestion).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const questionBox = await targetQuestion.boundingBox();
|
||||
const conversationBox = await conversation.boundingBox();
|
||||
if (!questionBox || !conversationBox) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return questionBox.y - conversationBox.y;
|
||||
})
|
||||
.toBeLessThan(200);
|
||||
const questionBox = await targetQuestion.boundingBox();
|
||||
const answerBox = await targetAnswer.boundingBox();
|
||||
expect(questionBox).not.toBeNull();
|
||||
expect(answerBox).not.toBeNull();
|
||||
expect(questionBox!.y).toBeLessThan(answerBox!.y);
|
||||
expect(await conversation.locator("[data-index]").count()).toBeLessThan(60);
|
||||
|
||||
await expect(
|
||||
outlineMenu
|
||||
.getByText("Long history question 0", { exact: true })
|
||||
.locator(".."),
|
||||
).toHaveAttribute("aria-current", "location");
|
||||
});
|
||||
|
||||
test("keeps rendered messages ordered when the latest history page advances", async ({
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { ConversationOutline } from "@/components/workspace/messages/conversation-outline";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { ConversationChapter } from "@/core/messages/conversation-outline";
|
||||
|
||||
const chapters: ConversationChapter[] = [
|
||||
{ id: "human-1", groupIndex: 0, title: "First question" },
|
||||
{ id: "human-2", groupIndex: 2, title: "Second question" },
|
||||
];
|
||||
|
||||
function renderOutline(
|
||||
props: Partial<React.ComponentProps<typeof ConversationOutline>> = {},
|
||||
) {
|
||||
return render(
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
|
||||
>
|
||||
<ConversationOutline
|
||||
chapters={chapters}
|
||||
activeChapterId="human-2"
|
||||
onChapterSelect={() => undefined}
|
||||
{...props}
|
||||
/>
|
||||
<div role="log">Conversation transcript</div>
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
function openOutline() {
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("button", { name: "Conversation outline" }),
|
||||
{ button: 0, ctrlKey: false },
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ConversationOutline", () => {
|
||||
it("renders ordered chapters and exposes the active location", async () => {
|
||||
renderOutline();
|
||||
|
||||
openOutline();
|
||||
|
||||
expect(await screen.findByText("First question")).toBeTruthy();
|
||||
expect(screen.getByText("Second question")).toBeTruthy();
|
||||
const menu = screen.getByTestId("conversation-outline-menu");
|
||||
expect(menu.classList.contains("overflow-y-auto")).toBe(true);
|
||||
expect(menu.classList.contains("max-h-[min(72vh,36rem)]")).toBe(true);
|
||||
expect(menu.className).not.toContain(" h-[");
|
||||
expect(menu.textContent).not.toContain("Conversation outline");
|
||||
expect(
|
||||
screen
|
||||
.getByText("Second question")
|
||||
.closest('[role="menuitem"]')
|
||||
?.getAttribute("aria-current"),
|
||||
).toBe("location");
|
||||
});
|
||||
|
||||
it("selects a chapter", async () => {
|
||||
const onChapterSelect = rs.fn();
|
||||
renderOutline({ onChapterSelect });
|
||||
openOutline();
|
||||
|
||||
fireEvent.click(await screen.findByText("First question"));
|
||||
|
||||
expect(onChapterSelect).toHaveBeenCalledWith("human-1");
|
||||
expect(screen.getByTestId("conversation-outline-menu")).toBeTruthy();
|
||||
expect(screen.getByRole("log")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
106
frontend/tests/unit/core/messages/conversation-outline.test.ts
Normal file
106
frontend/tests/unit/core/messages/conversation-outline.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
buildConversationChapters,
|
||||
CONVERSATION_CHAPTER_TITLE_MAX_LENGTH,
|
||||
CONVERSATION_OUTLINE_MIN_TURNS,
|
||||
} from "@/core/messages/conversation-outline";
|
||||
import { getMessageGroups } from "@/core/messages/utils";
|
||||
|
||||
function message(
|
||||
type: Message["type"],
|
||||
id: string | undefined,
|
||||
content: Message["content"],
|
||||
): Message {
|
||||
return { type, id, content } as Message;
|
||||
}
|
||||
|
||||
describe("conversation outline model", () => {
|
||||
it("creates one ordered chapter per visible human turn", () => {
|
||||
const groups = getMessageGroups([
|
||||
message("human", "human-1", "First question"),
|
||||
message("ai", "assistant-1", "First answer"),
|
||||
message("human", "human-2", "Second question"),
|
||||
{
|
||||
...message("ai", "tool-call", ""),
|
||||
tool_calls: [{ id: "call-1", name: "bash", args: {} }],
|
||||
} as Message,
|
||||
{
|
||||
...message("tool", "tool-result", "done"),
|
||||
tool_call_id: "call-1",
|
||||
} as Message,
|
||||
message("ai", "assistant-2", "Second answer"),
|
||||
]);
|
||||
|
||||
expect(buildConversationChapters(groups, "Attachment")).toEqual([
|
||||
{
|
||||
id: "human-1",
|
||||
groupIndex: 0,
|
||||
title: "First question",
|
||||
},
|
||||
{
|
||||
id: "human-2",
|
||||
groupIndex: 2,
|
||||
title: "Second question",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes whitespace and removes upload metadata", () => {
|
||||
const groups = getMessageGroups([
|
||||
message("human", "human-1", " First line\n\n second\tline "),
|
||||
message(
|
||||
"human",
|
||||
"human-2",
|
||||
"<uploaded_files>\n- report.pdf\n</uploaded_files>\n\nSummarize the report",
|
||||
),
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildConversationChapters(groups, "Attachment").map(
|
||||
(chapter) => chapter.title,
|
||||
),
|
||||
).toEqual(["First line second line", "Summarize the report"]);
|
||||
});
|
||||
|
||||
it("uses the localized fallback for attachment-only structured content", () => {
|
||||
const groups = getMessageGroups([
|
||||
message("human", "human-image", [
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,x" } },
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(buildConversationChapters(groups, "图片或文件消息")[0]?.title).toBe(
|
||||
"图片或文件消息",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates by Unicode code points without splitting emoji", () => {
|
||||
const content = `${"问".repeat(CONVERSATION_CHAPTER_TITLE_MAX_LENGTH - 1)}😀结尾`;
|
||||
const groups = getMessageGroups([message("human", "human-long", content)]);
|
||||
|
||||
const title = buildConversationChapters(groups, "Attachment")[0]?.title;
|
||||
|
||||
expect(Array.from(title ?? "")).toHaveLength(
|
||||
CONVERSATION_CHAPTER_TITLE_MAX_LENGTH + 1,
|
||||
);
|
||||
expect(title?.endsWith("…")).toBe(true);
|
||||
expect(title).not.toContain("<22>");
|
||||
});
|
||||
|
||||
it("falls back to a deterministic group key when message ids are absent", () => {
|
||||
const groups = getMessageGroups([
|
||||
message("human", undefined, "Question"),
|
||||
message("ai", undefined, "Answer"),
|
||||
]);
|
||||
|
||||
expect(buildConversationChapters(groups, "Attachment")[0]?.id).toBe(
|
||||
"human-turn:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the approved long-conversation threshold", () => {
|
||||
expect(CONVERSATION_OUTLINE_MIN_TURNS).toBe(5);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user