feat: add chat history with scroll-up loading to public agent chat
- Update conversation API to support cursor-based pagination (before/after params) - Default behavior now returns most recent messages first - Add react-virtuoso for efficient virtualized message rendering - Implement scroll-up loading of older messages - Show loading indicator when fetching history
This commit is contained in:
parent
2c0e5f99d8
commit
1594f98cc1
2 changed files with 211 additions and 108 deletions
|
|
@ -6,6 +6,7 @@ import { Icon } from '@tpmjs/ui/Icon/Icon';
|
|||
import Link from 'next/link';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface Agent {
|
||||
|
|
@ -181,8 +182,13 @@ export default function PublicAgentChatPage(): React.ReactElement {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
// Track first item index for prepending (Virtuoso pattern)
|
||||
const [firstItemIndex, setFirstItemIndex] = useState(10000);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const toggleToolCall = (toolCallId: string) => {
|
||||
|
|
@ -214,26 +220,69 @@ export default function PublicAgentChatPage(): React.ReactElement {
|
|||
}
|
||||
}, [agentId]);
|
||||
|
||||
// Fetch messages for conversation
|
||||
// Fetch messages for conversation (initial load - gets most recent 50)
|
||||
const fetchMessages = useCallback(async () => {
|
||||
if (!agent) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`);
|
||||
const response = await fetch(
|
||||
`/api/agents/${agent.uid}/conversation/${conversationId}?limit=50`
|
||||
);
|
||||
if (response.status === 404) {
|
||||
// Conversation doesn't exist yet, that's fine
|
||||
setHasMoreMessages(false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMessages(data.data.messages || []);
|
||||
const msgs = data.data.messages || [];
|
||||
setMessages(msgs);
|
||||
setHasMoreMessages(data.pagination?.hasMore ?? false);
|
||||
// Reset first item index when loading fresh
|
||||
setFirstItemIndex(10000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
}
|
||||
}, [agent, conversationId]);
|
||||
|
||||
// Load older messages when scrolling up
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
if (!agent || isLoadingMore || !hasMoreMessages || messages.length === 0) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
// Get the timestamp of the oldest message we have
|
||||
const oldestMessage = messages[0];
|
||||
const beforeTimestamp = oldestMessage?.createdAt;
|
||||
|
||||
if (!beforeTimestamp) return;
|
||||
|
||||
const response = await fetch(
|
||||
`/api/agents/${agent.uid}/conversation/${conversationId}?limit=50&before=${encodeURIComponent(beforeTimestamp)}`
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const olderMessages = data.data.messages || [];
|
||||
if (olderMessages.length > 0) {
|
||||
// Prepend older messages and adjust firstItemIndex
|
||||
setFirstItemIndex((prev) => prev - olderMessages.length);
|
||||
setMessages((prev) => [...olderMessages, ...prev]);
|
||||
}
|
||||
setHasMoreMessages(data.pagination?.hasMore ?? false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load more messages:', err);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [agent, conversationId, isLoadingMore, hasMoreMessages, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await fetchAgent();
|
||||
|
|
@ -248,10 +297,6 @@ export default function PublicAgentChatPage(): React.ReactElement {
|
|||
}
|
||||
}, [agent, fetchMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !agent || isSending) return;
|
||||
|
||||
|
|
@ -441,106 +486,136 @@ export default function PublicAgentChatPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col max-w-4xl mx-auto w-full">
|
||||
<div className="flex-1 flex flex-col max-w-4xl mx-auto w-full overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && !streamingContent && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="message" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Start a conversation</h3>
|
||||
<p className="text-foreground-secondary max-w-sm">
|
||||
Send a message to start chatting with {agent.name}.
|
||||
</p>
|
||||
{messages.length === 0 && !streamingContent ? (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="message" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Start a conversation</h3>
|
||||
<p className="text-foreground-secondary max-w-sm">
|
||||
Send a message to start chatting with {agent.name}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
// Parse tool output safely
|
||||
const getToolOutput = () => {
|
||||
if (message.toolResult) return message.toolResult;
|
||||
try {
|
||||
return JSON.parse(message.content || '{}');
|
||||
} catch {
|
||||
return { result: message.content };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'TOOL' ? (
|
||||
<div className="max-w-[80%]">
|
||||
<ToolCallCard
|
||||
toolCall={{
|
||||
toolCallId: message.toolCallId || message.id,
|
||||
toolName: message.toolName || 'Unknown Tool',
|
||||
output: getToolOutput(),
|
||||
status: 'success',
|
||||
}}
|
||||
isExpanded={expandedToolCalls.has(message.toolCallId || message.id)}
|
||||
onToggle={() => toggleToolCall(message.toolCallId || message.id)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="flex-1"
|
||||
data={messages}
|
||||
firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
startReached={loadMoreMessages}
|
||||
followOutput="smooth"
|
||||
components={{
|
||||
Header: () =>
|
||||
hasMoreMessages ? (
|
||||
<div className="flex justify-center py-4">
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||
<span className="text-sm">Loading older messages...</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMoreMessages}
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Load older messages
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : null,
|
||||
Footer: () => (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Live tool calls during streaming */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((tc) => (
|
||||
<div key={tc.toolCallId} className="flex justify-start">
|
||||
<div className="max-w-[80%]">
|
||||
<ToolCallCard
|
||||
toolCall={tc}
|
||||
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
||||
onToggle={() => toggleToolCall(tc.toolCallId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<p className="whitespace-pre-wrap text-sm">{streamingContent}</p>
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSending && !streamingContent && toolCalls.length === 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||
<span className="text-sm">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
itemContent={(_index, message) => {
|
||||
// Parse tool output safely
|
||||
const getToolOutput = () => {
|
||||
if (message.toolResult) return message.toolResult;
|
||||
try {
|
||||
return JSON.parse(message.content || '{}');
|
||||
} catch {
|
||||
return { result: message.content };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-4 ${
|
||||
message.role === 'USER'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-secondary'
|
||||
}`}
|
||||
className={`flex ${message.role === 'USER' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Live tool calls during streaming */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((tc) => (
|
||||
<div key={tc.toolCallId} className="flex justify-start">
|
||||
<div className="max-w-[80%]">
|
||||
<ToolCallCard
|
||||
toolCall={tc}
|
||||
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
||||
onToggle={() => toggleToolCall(tc.toolCallId)}
|
||||
/>
|
||||
{message.role === 'TOOL' ? (
|
||||
<div className="max-w-[80%]">
|
||||
<ToolCallCard
|
||||
toolCall={{
|
||||
toolCallId: message.toolCallId || message.id,
|
||||
toolName: message.toolName || 'Unknown Tool',
|
||||
output: getToolOutput(),
|
||||
status: 'success',
|
||||
}}
|
||||
isExpanded={expandedToolCalls.has(message.toolCallId || message.id)}
|
||||
onToggle={() => toggleToolCall(message.toolCallId || message.id)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-4 ${
|
||||
message.role === 'USER'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<p className="whitespace-pre-wrap text-sm">{streamingContent}</p>
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSending && !streamingContent && toolCalls.length === 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||
<span className="text-sm">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -396,14 +396,20 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
*
|
||||
* Query params:
|
||||
* - limit: Max messages to return (default: 50, max: 100)
|
||||
* - offset: Number of messages to skip (default: 0)
|
||||
* - before: Fetch messages created before this ISO timestamp (for loading older messages)
|
||||
* - after: Fetch messages created after this ISO timestamp (for loading newer messages)
|
||||
*
|
||||
* Default behavior (no before/after): Returns the most recent messages
|
||||
* With before: Returns messages older than the timestamp (for scrolling up)
|
||||
* With after: Returns messages newer than the timestamp (for refreshing)
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: idOrUid, conversationId } = await context.params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50', 10), 100);
|
||||
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
|
||||
const before = searchParams.get('before');
|
||||
const after = searchParams.get('after');
|
||||
|
||||
try {
|
||||
// Fetch agent by id or uid
|
||||
|
|
@ -435,16 +441,37 @@ export async function GET(request: NextRequest, context: RouteContext): Promise<
|
|||
);
|
||||
}
|
||||
|
||||
// Fetch messages with pagination
|
||||
// Build the where clause based on cursor
|
||||
const whereClause: {
|
||||
conversationId: string;
|
||||
createdAt?: { lt?: Date; gt?: Date };
|
||||
} = { conversationId: conversation.id };
|
||||
|
||||
if (before) {
|
||||
whereClause.createdAt = { lt: new Date(before) };
|
||||
} else if (after) {
|
||||
whereClause.createdAt = { gt: new Date(after) };
|
||||
}
|
||||
|
||||
// Determine fetch order:
|
||||
// - Default (no cursor) or "before": Fetch desc (newest first), then reverse for chronological order
|
||||
// - "after": Fetch asc (oldest first) to get messages after the cursor
|
||||
const shouldFetchDesc = !after;
|
||||
|
||||
// Fetch messages
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { conversationId: conversation.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: shouldFetchDesc ? 'desc' : 'asc' },
|
||||
take: limit + 1,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const hasMore = messages.length > limit;
|
||||
const paginatedMessages = hasMore ? messages.slice(0, limit) : messages;
|
||||
let paginatedMessages = hasMore ? messages.slice(0, limit) : messages;
|
||||
|
||||
// Reverse if we fetched in desc order to maintain chronological order
|
||||
if (shouldFetchDesc) {
|
||||
paginatedMessages = paginatedMessages.reverse();
|
||||
}
|
||||
|
||||
const mappedMessages = paginatedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
|
|
@ -471,8 +498,9 @@ export async function GET(request: NextRequest, context: RouteContext): Promise<
|
|||
},
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
...(before && { before }),
|
||||
...(after && { after }),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue