Files
deer-flow/frontend/src/components/workspace/messages/message-list.tsx
T
JeffJiang df63c104a7 Refactor API fetch calls to use a unified fetch function; enhance chat history loading with new hooks and UI components
- Replaced `fetchWithAuth` with a generic `fetch` function across various API modules for consistency.
- Updated `useThreadStream` and `useThreadHistory` hooks to manage chat history loading, including loading states and pagination.
- Introduced `LoadMoreHistoryIndicator` component for better user experience when loading more chat history.
- Enhanced message handling in `MessageList` to accommodate new loading states and history management.
- Added support for run messages in the thread context, improving the overall message handling logic.
- Updated translations for loading indicators in English and Chinese.
2026-04-17 23:41:11 +08:00

333 lines
10 KiB
TypeScript

import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { ChevronUpIcon, Loader2Icon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
extractTextFromMessage,
groupMessages,
hasContent,
hasPresentFiles,
hasReasoning,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;
function LoadMoreHistoryIndicator({
isLoading,
hasMore,
loadMore,
}: {
isLoading?: boolean;
hasMore?: boolean;
loadMore?: () => void;
}) {
const { t } = useI18n();
const sentinelRef = useRef<HTMLDivElement | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadRef = useRef(0);
const throttledLoadMore = useCallback(() => {
if (!hasMore || isLoading) {
return;
}
const now = Date.now();
const remaining =
LOAD_MORE_HISTORY_THROTTLE_MS - (now - lastLoadRef.current);
if (remaining <= 0) {
lastLoadRef.current = now;
loadMore?.();
return;
}
if (timeoutRef.current) {
return;
}
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
if (!hasMore || isLoading) {
return;
}
lastLoadRef.current = Date.now();
loadMore?.();
}, remaining);
}, [hasMore, isLoading, loadMore]);
useEffect(() => {
const element = sentinelRef.current;
if (!element || !hasMore) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
throttledLoadMore();
}
},
{
rootMargin: "120px 0px 0px 0px",
},
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [hasMore, throttledLoadMore]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!hasMore && !isLoading) {
return null;
}
return (
<div ref={sentinelRef} className="flex w-full justify-center">
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground rounded-full px-3"
disabled={(isLoading ?? false) || !hasMore}
onClick={throttledLoadMore}
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
{t.common.loading}
</>
) : (
<>
<ChevronUpIcon className="mr-2 size-4" />
{t.common.loadMore}
</>
)}
</Button>
</div>
);
}
export function MessageList({
className,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
hasMoreHistory,
loadMoreHistory,
isHistoryLoading,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
hasMoreHistory?: boolean;
loadMoreHistory?: () => void;
isHistoryLoading?: boolean;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
<LoadMoreHistoryIndicator
isLoading={isHistoryLoading}
hasMore={hasMoreHistory}
loadMore={loadMoreHistory}
/>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
threadId={threadId}
message={msg}
isLoading={thread.isLoading}
/>
);
});
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
return (
<MarkdownContent
key={group.id}
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
return null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
const presentFiles = extractPresentFilesFromMessage(message);
files.push(...presentFiles);
}
}
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
</div>
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
for (const message of group.messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
const task: Subtask = {
id: toolCall.id!,
subagent_type: toolCall.args.subagent_type,
description: toolCall.args.description,
prompt: toolCall.args.prompt,
status: "in_progress",
};
updateSubtask(task);
tasks.add(task);
}
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (taskId) {
const result = extractTextFromMessage(message);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result
.split("Task Succeeded. Result:")[1]
?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({
id: taskId,
status: "failed",
error: result,
});
} else {
updateSubtask({
id: taskId,
status: "in_progress",
});
}
}
}
}
const results: React.ReactNode[] = [];
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
/>,
);
}
results.push(
<div
key="subtask-count"
className="text-muted-foreground font-norma pt-2 text-sm"
>
{t.subtasks.executing(tasks.size)}
</div>,
);
const taskIds = message.tool_calls
?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId!}
isLoading={thread.isLoading}
/>,
);
}
}
return (
<div
key={"subtask-group-" + group.id}
className="relative z-1 flex flex-col gap-2"
>
{results}
</div>
);
}
return (
<MessageGroup
key={"group-" + group.id}
messages={group.messages}
isLoading={thread.isLoading}
/>
);
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
);
}