Template
Improve chat navigation, model selection, and streaming
This commit is contained in:
+37
-230
@@ -1,20 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import { ChatInput, type PromptInputMessage } from './ChatInput';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
import { ChatPrompt, PromptSuggestion } from './ChatPrompt';
|
||||
import { ChatSidebar, type Chat } from './ChatSidebar';
|
||||
import { useChat, UIMessage } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import {
|
||||
createChat,
|
||||
deleteChat,
|
||||
getMessages,
|
||||
listChats,
|
||||
saveMessages,
|
||||
} from '@/services/indexed-db/chat-store';
|
||||
import { ChatPrompt } from './ChatPrompt';
|
||||
import { ChatSidebar } from './ChatSidebar';
|
||||
import { useChatSession } from '@/hooks/use-chat-session';
|
||||
import type { PromptSuggestion } from '@/config/suggestions';
|
||||
import { DEFAULT_OPENROUTER_MODEL_ID, type OpenRouterModelId } from '@/lib/openrouter';
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarInset,
|
||||
@@ -24,228 +16,32 @@ import {
|
||||
interface ChatAgentProps {
|
||||
suggestions?: PromptSuggestion[];
|
||||
webSearchEnabled?: boolean;
|
||||
}
|
||||
|
||||
async function convertToDataUrl(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Could not read the selected file');
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Could not encode the selected file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
defaultModelId?: OpenRouterModelId;
|
||||
}
|
||||
|
||||
export function ChatAgent({
|
||||
suggestions,
|
||||
webSearchEnabled = false,
|
||||
defaultModelId = DEFAULT_OPENROUTER_MODEL_ID,
|
||||
}: ChatAgentProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const chatIdRef = useRef(chatId);
|
||||
const pendingChatIdRef = useRef<string | null>(null);
|
||||
chatIdRef.current = chatId;
|
||||
|
||||
const fetchChats = useCallback(async () => {
|
||||
try {
|
||||
setChats(await listChats());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch chats:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transportRef = useRef(
|
||||
new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
modelId,
|
||||
setModelId,
|
||||
chatId,
|
||||
chats,
|
||||
chatMessages,
|
||||
status,
|
||||
messages: chatMessages,
|
||||
setMessages: setChatMessages,
|
||||
sendMessage,
|
||||
stop,
|
||||
error: aiError,
|
||||
addToolApprovalResponse,
|
||||
} = useChat({
|
||||
transport: transportRef.current,
|
||||
onFinish: async ({ messages }) => {
|
||||
const activeChatId = pendingChatIdRef.current ?? chatIdRef.current;
|
||||
if (!activeChatId) return;
|
||||
|
||||
try {
|
||||
await saveMessages(activeChatId, messages);
|
||||
await fetchChats();
|
||||
} catch (error) {
|
||||
console.error('Failed to save messages:', error);
|
||||
} finally {
|
||||
if (pendingChatIdRef.current === activeChatId) {
|
||||
pendingChatIdRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Chat error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
|
||||
|
||||
const chatStatus: ChatStatus = isPreparingFiles
|
||||
? 'submitted'
|
||||
: status === 'submitted' || status === 'streaming'
|
||||
? status
|
||||
: 'ready';
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
}, [fetchChats]);
|
||||
|
||||
const handleSelectChat = useCallback(
|
||||
async (selectedChatId: string) => {
|
||||
try {
|
||||
const messages = await getMessages(selectedChatId);
|
||||
chatIdRef.current = selectedChatId;
|
||||
setChatId(selectedChatId);
|
||||
|
||||
setChatMessages(messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat:', error);
|
||||
}
|
||||
},
|
||||
[setChatMessages]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
chatIdRef.current = null;
|
||||
setChatId(null);
|
||||
setChatMessages([]);
|
||||
setInputValue('');
|
||||
}, [setChatMessages]);
|
||||
|
||||
const handleDeleteChat = useCallback(
|
||||
async (deletedChatId: string) => {
|
||||
try {
|
||||
await deleteChat(deletedChatId);
|
||||
setChats((prev) => prev.filter((c) => c.id !== deletedChatId));
|
||||
if (chatId === deletedChatId) {
|
||||
chatIdRef.current = null;
|
||||
setChatId(null);
|
||||
setChatMessages([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat:', error);
|
||||
}
|
||||
},
|
||||
[chatId, setChatMessages]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
const trimmedValue = message.text?.trim();
|
||||
if (!trimmedValue && !message.files?.length) return;
|
||||
if (
|
||||
status === 'submitted' ||
|
||||
status === 'streaming' ||
|
||||
isPreparingFiles
|
||||
)
|
||||
return;
|
||||
|
||||
let activeChatId = chatIdRef.current;
|
||||
|
||||
if (!chatIdRef.current) {
|
||||
const newChatId = crypto.randomUUID();
|
||||
const title = trimmedValue ? trimmedValue.slice(0, 100) : 'New Chat';
|
||||
|
||||
activeChatId = newChatId;
|
||||
chatIdRef.current = newChatId;
|
||||
setChatId(newChatId);
|
||||
|
||||
try {
|
||||
await createChat(newChatId, title);
|
||||
await fetchChats();
|
||||
} catch (error) {
|
||||
console.error('Failed to create chat:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'file'; url: string; mediaType: string; filename: string }
|
||||
> = [];
|
||||
|
||||
if (trimmedValue) {
|
||||
parts.push({ type: 'text', text: trimmedValue });
|
||||
}
|
||||
|
||||
// PromptInput supplies files as base64 data URLs. Keep them inline so the
|
||||
// demo never needs a remote storage bucket.
|
||||
if (message.files?.length) {
|
||||
setIsPreparingFiles(true);
|
||||
for (const file of message.files) {
|
||||
try {
|
||||
const dataUrl = file.url.startsWith('data:')
|
||||
? file.url
|
||||
: await convertToDataUrl(file.url);
|
||||
|
||||
parts.push({
|
||||
type: 'file',
|
||||
url: dataUrl,
|
||||
mediaType: file.mediaType || 'application/octet-stream',
|
||||
filename: file.filename || 'file',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('File encoding error:', err);
|
||||
}
|
||||
}
|
||||
setIsPreparingFiles(false);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
const userMessage: UIMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts,
|
||||
};
|
||||
|
||||
setInputValue('');
|
||||
|
||||
if (activeChatId) {
|
||||
try {
|
||||
await saveMessages(activeChatId, [...chatMessages, userMessage]);
|
||||
} catch (error) {
|
||||
console.error('Failed to save user message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
pendingChatIdRef.current = activeChatId;
|
||||
await sendMessage(userMessage);
|
||||
},
|
||||
[
|
||||
status,
|
||||
sendMessage,
|
||||
chatMessages,
|
||||
isPreparingFiles,
|
||||
fetchChats,
|
||||
]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
chatStatus,
|
||||
aiError,
|
||||
isPreparingFiles,
|
||||
handleSelectChat,
|
||||
handleNewChat,
|
||||
handleDeleteChat,
|
||||
handleSendMessage,
|
||||
handleStop,
|
||||
} = useChatSession(defaultModelId);
|
||||
|
||||
const hasMessages = chatMessages.length > 0;
|
||||
|
||||
@@ -268,6 +64,9 @@ export function ChatAgent({
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSendMessage}
|
||||
modelId={modelId}
|
||||
onModelChange={setModelId}
|
||||
onStop={isPreparingFiles ? undefined : handleStop}
|
||||
status={chatStatus}
|
||||
suggestions={suggestions}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
@@ -281,15 +80,23 @@ export function ChatAgent({
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatMessages
|
||||
messages={chatMessages}
|
||||
isLoading={status === 'submitted' || status === 'streaming'}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-muted p-4">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{aiError && (
|
||||
<p role="alert" className="mb-3 text-sm text-destructive">
|
||||
The response could not be completed. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSendMessage}
|
||||
modelId={modelId}
|
||||
onModelChange={setModelId}
|
||||
onStop={isPreparingFiles ? undefined : handleStop}
|
||||
status={chatStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -301,4 +108,4 @@ export function ChatAgent({
|
||||
);
|
||||
}
|
||||
|
||||
export type { UIMessage as Message };
|
||||
export type { UIMessage as Message } from 'ai';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import type { ChatStatus } from 'ai';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { RiAddLine } from '@remixicon/react';
|
||||
import { ChatModelSelector } from './ChatModelSelector';
|
||||
import type { OpenRouterModelId } from '@/lib/openrouter';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputActionAddAttachments,
|
||||
@@ -23,6 +25,9 @@ interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (message: PromptInputMessage) => void;
|
||||
modelId: OpenRouterModelId;
|
||||
onModelChange: (modelId: OpenRouterModelId) => void;
|
||||
onStop?: () => void;
|
||||
status?: ChatStatus;
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -31,9 +36,14 @@ export function ChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
modelId,
|
||||
onModelChange,
|
||||
onStop,
|
||||
status = 'ready',
|
||||
placeholder,
|
||||
}: ChatInputProps) {
|
||||
const isGenerating = status === 'submitted' || status === 'streaming';
|
||||
|
||||
return (
|
||||
<PromptInput
|
||||
className="[&_[data-slot=input-group]]:bg-background"
|
||||
@@ -55,18 +65,22 @@ export function ChatInput({
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="pt-0 pb-2">
|
||||
<PromptInputTools>
|
||||
<PromptInputTools className="min-w-0">
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger>
|
||||
<PlusIcon className="size-4" />
|
||||
<RiAddLine className="size-4" />
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
<ChatModelSelector value={modelId} onChange={onModelChange} disabled={isGenerating} />
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit
|
||||
disabled={status === 'streaming'}
|
||||
aria-label={isGenerating ? 'Stop response' : 'Send message'}
|
||||
type={isGenerating ? 'button' : 'submit'}
|
||||
onClick={isGenerating ? onStop : undefined}
|
||||
disabled={isGenerating && !onStop}
|
||||
status={status}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tool, UIMessage, UIMessagePart } from "ai";
|
||||
import { MessagePartRenderer } from "./ChatMessageParts";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
function getTextContent(parts: UIMessagePart<any, any>[]): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
isUser ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-4 py-3",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
isUser
|
||||
? "prose-invert prose-p:text-primary-foreground prose-headings:text-primary-foreground prose-strong:text-primary-foreground prose-code:text-primary-foreground"
|
||||
: "prose-p:text-foreground prose-headings:text-foreground prose-strong:text-foreground"
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part, index) => (
|
||||
<MessagePartRenderer key={index} part={part} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getTextContent };
|
||||
export type { UIMessage as Message };
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolHeader,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "@/components/ai-elements/tool";
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { toolComponents } from "@/chat.config";
|
||||
|
||||
type MessagePart = UIMessage["parts"][number];
|
||||
type ReasoningPart = Extract<MessagePart, { type: "reasoning" }>;
|
||||
|
||||
// Helper functions
|
||||
export function getTextContent(parts: MessagePart[]): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getReasoningPart(parts: MessagePart[]): ReasoningPart | undefined {
|
||||
return parts.find((p): p is ReasoningPart => p.type === "reasoning");
|
||||
}
|
||||
|
||||
function extractToolName(part: MessagePart): string {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return (part as any).toolName || "unknown";
|
||||
}
|
||||
if (part.type.startsWith("tool-")) {
|
||||
return part.type.slice(5);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function DefaultToolRenderer({ part, toolName }: { part: MessagePart; toolName: string }) {
|
||||
const { input, output, state, errorText } = part as any;
|
||||
|
||||
return (
|
||||
<Tool>
|
||||
<ToolHeader
|
||||
title={toolName}
|
||||
type={part.type as any}
|
||||
state={state || "input-streaming"}
|
||||
/>
|
||||
<ToolContent>
|
||||
{input && <ToolInput input={input} />}
|
||||
<ToolOutput output={output} errorText={errorText} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolLoadingIndicator({ toolName }: { toolName: string }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader size={14} />
|
||||
<span>Running {toolName}...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderTool({ part }: { part: MessagePart }) {
|
||||
const toolName = extractToolName(part);
|
||||
const state = (part as any).state;
|
||||
const isComplete = state === "output-available";
|
||||
|
||||
const CustomRenderer = (
|
||||
toolComponents as Record<
|
||||
string,
|
||||
React.ComponentType<{ part: MessagePart; toolName: string }>
|
||||
>
|
||||
)[toolName];
|
||||
if (CustomRenderer) {
|
||||
return <CustomRenderer part={part} toolName={toolName} />;
|
||||
}
|
||||
|
||||
if (!isComplete && !(part as any).input) {
|
||||
return <ToolLoadingIndicator toolName={toolName} />;
|
||||
}
|
||||
return <DefaultToolRenderer part={part} toolName={toolName} />;
|
||||
}
|
||||
|
||||
export function ToolPartRenderer({ part }: { part: MessagePart }) {
|
||||
return <RenderTool part={part} />;
|
||||
}
|
||||
|
||||
export function MessagePartRenderer({ part }: { part: MessagePart }) {
|
||||
if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
|
||||
return <RenderTool part={part} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { isToolUIPart, type ChatStatus, type UIMessage } from "ai";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { ToolPartRenderer, getTextContent, getReasoningPart } from "./ChatMessageParts";
|
||||
import { ToolPartRenderer } from "@/components/tools/tool-part-renderer";
|
||||
import { getTextContent, getReasoningPart } from "@/lib/chat/messages";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: UIMessage[];
|
||||
isLoading?: boolean;
|
||||
status?: ChatStatus;
|
||||
}
|
||||
|
||||
|
||||
function LoadingIndicator() {
|
||||
return (
|
||||
<Message from="assistant">
|
||||
@@ -37,8 +37,7 @@ function LoadingIndicator() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const isToolPart = (type: string) => type === "file" || type.startsWith("tool-");
|
||||
export function ChatMessages({ messages, status = "ready" }: ChatMessagesProps) {
|
||||
const hasReasoningPart = (parts: typeof messages[0]["parts"]) =>
|
||||
parts.some((p) => p.type === "reasoning" && p.text);
|
||||
|
||||
@@ -46,26 +45,31 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
(message) =>
|
||||
message.role === "user" ||
|
||||
getTextContent(message.parts).trim() !== "" ||
|
||||
message.parts.some((p) => isToolPart(p.type)) ||
|
||||
message.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
|
||||
hasReasoningPart(message.parts)
|
||||
);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const hasContent =
|
||||
getTextContent(lastMessage?.parts || []).trim() !== "" ||
|
||||
lastMessage?.parts.some((p) => isToolPart(p.type)) ||
|
||||
lastMessage?.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
|
||||
hasReasoningPart(lastMessage?.parts || []);
|
||||
const isStreaming = lastMessage?.role === "assistant" && hasContent;
|
||||
const showLoading = isLoading && !isStreaming;
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
const showLoading =
|
||||
isLoading && !(lastMessage?.role === "assistant" && hasContent);
|
||||
|
||||
return (
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto max-w-3xl px-4 pb-4">
|
||||
{visibleMessages.map((message) => {
|
||||
const isStreaming =
|
||||
status === "streaming" &&
|
||||
message.role === "assistant" &&
|
||||
message.id === lastMessage?.id;
|
||||
const textContent = getTextContent(message.parts);
|
||||
const reasoningPart = getReasoningPart(message.parts);
|
||||
const fileParts = message.parts.filter((p) => p.type === "file");
|
||||
const toolParts = message.parts.filter((p) => p.type.startsWith("tool-"));
|
||||
const toolParts = message.parts.filter((p) => isToolUIPart(p));
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
{fileParts.length > 0 && (
|
||||
@@ -80,7 +84,9 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
)}
|
||||
|
||||
{message.role === "assistant" && reasoningPart && (
|
||||
<Reasoning isStreaming={reasoningPart.state !== "done"}>
|
||||
<Reasoning
|
||||
isStreaming={isStreaming && reasoningPart.state === "streaming"}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningPart.text || ""}</ReasoningContent>
|
||||
</Reasoning>
|
||||
@@ -92,7 +98,16 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
|
||||
{textContent && (
|
||||
<MessageContent from={message.role}>
|
||||
<MessageResponse>{textContent}</MessageResponse>
|
||||
<MessageResponse
|
||||
mode={message.role === "assistant" ? "streaming" : "static"}
|
||||
isAnimating={
|
||||
isStreaming && message.parts.some(
|
||||
(part) => part.type === "text" && part.state === "streaming"
|
||||
)
|
||||
}
|
||||
>
|
||||
{textContent}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from '@/components/ai-elements/model-selector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { openRouterModels, type OpenRouterModelId } from '@/lib/openrouter';
|
||||
|
||||
const providers: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
google: 'Google',
|
||||
'x-ai': 'SpaceXAI',
|
||||
'z-ai': 'Z.ai',
|
||||
moonshotai: 'MoonshotAI',
|
||||
minimax: 'MiniMax',
|
||||
deepseek: 'DeepSeek',
|
||||
meta: 'Meta',
|
||||
qwen: 'Qwen',
|
||||
};
|
||||
|
||||
const modelGroups = Object.entries(providers).map(([id, name]) => ({
|
||||
name,
|
||||
models: openRouterModels.filter((model) => model.id.startsWith(`${id}/`)),
|
||||
}));
|
||||
|
||||
interface ChatModelSelectorProps {
|
||||
value: OpenRouterModelId;
|
||||
onChange: (modelId: OpenRouterModelId) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedModel = openRouterModels.find((model) => model.id === value);
|
||||
|
||||
return (
|
||||
<ModelSelector open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-0 max-w-[min(16rem,calc(100vw-10rem))] text-muted-foreground"
|
||||
aria-label={`Select model, current model: ${selectedModel?.name}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ModelSelectorName>{selectedModel?.name}</ModelSelectorName>
|
||||
<RiArrowDownSLine className="size-4 shrink-0" aria-hidden="true" />
|
||||
</Button>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent title="Choose a model" className="max-w-[calc(100%-2rem)] sm:max-w-lg">
|
||||
<ModelSelectorInput placeholder="Search models..." aria-label="Search models" className="pr-10" />
|
||||
<ModelSelectorList className="max-h-[min(24rem,60dvh)]">
|
||||
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
|
||||
{modelGroups.map((group) => (
|
||||
<ModelSelectorGroup heading={group.name} key={group.name}>
|
||||
{group.models.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
keywords={[model.name, group.name]}
|
||||
onSelect={() => {
|
||||
onChange(model.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||||
{model.id === value && (
|
||||
<>
|
||||
<RiCheckLine className="ml-auto size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Selected</span>
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
);
|
||||
}
|
||||
@@ -5,72 +5,40 @@ import { ChatInput, type PromptInputMessage } from './ChatInput';
|
||||
import { Suggestion } from '@/components/ai-elements/suggestion';
|
||||
import { RiOpenaiLine } from '@remixicon/react';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import type { OpenRouterModelId } from '@/lib/openrouter';
|
||||
import {
|
||||
offlineSuggestions,
|
||||
webSearchSuggestions,
|
||||
type PromptSuggestion,
|
||||
} from '@/config/suggestions';
|
||||
|
||||
export interface PromptSuggestion {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
export type { PromptSuggestion } from '@/config/suggestions';
|
||||
|
||||
interface ChatPromptProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (message: PromptInputMessage) => void;
|
||||
modelId: OpenRouterModelId;
|
||||
onModelChange: (modelId: OpenRouterModelId) => void;
|
||||
onStop?: () => void;
|
||||
status?: ChatStatus;
|
||||
suggestions?: PromptSuggestion[];
|
||||
webSearchEnabled?: boolean;
|
||||
}
|
||||
|
||||
const defaultSuggestions: PromptSuggestion[] = [
|
||||
{
|
||||
key: 'Latest AI news',
|
||||
value:
|
||||
'What are the latest developments in AI in 2026? Search for recent news.',
|
||||
},
|
||||
{
|
||||
key: 'Research a company',
|
||||
value: 'Research OpenAI: their latest products, funding, and news.',
|
||||
},
|
||||
{
|
||||
key: 'Fact check',
|
||||
value:
|
||||
'What is the current population of the world and how is it changing?',
|
||||
},
|
||||
{
|
||||
key: 'Compare topics',
|
||||
value:
|
||||
'Compare the latest iPhone vs Samsung Galaxy flagship models with sources.',
|
||||
},
|
||||
];
|
||||
|
||||
const offlineSuggestions: PromptSuggestion[] = [
|
||||
{
|
||||
key: 'Explain a concept',
|
||||
value: 'Explain retrieval-augmented generation in plain language.',
|
||||
},
|
||||
{
|
||||
key: 'Compare approaches',
|
||||
value: 'Compare REST and GraphQL, including when to choose each.',
|
||||
},
|
||||
{
|
||||
key: 'Draft an outline',
|
||||
value: 'Draft an outline for a practical guide to evaluating AI agents.',
|
||||
},
|
||||
{
|
||||
key: 'Brainstorm ideas',
|
||||
value: 'Brainstorm useful AI agent ideas for a small business.',
|
||||
},
|
||||
];
|
||||
|
||||
export function ChatPrompt({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
modelId,
|
||||
onModelChange,
|
||||
onStop,
|
||||
status = 'ready',
|
||||
suggestions,
|
||||
webSearchEnabled = false,
|
||||
}: ChatPromptProps) {
|
||||
const displayedSuggestions =
|
||||
suggestions ?? (webSearchEnabled ? defaultSuggestions : offlineSuggestions);
|
||||
suggestions ?? (webSearchEnabled ? webSearchSuggestions : offlineSuggestions);
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onChange(suggestion);
|
||||
@@ -99,6 +67,9 @@ export function ChatPrompt({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
modelId={modelId}
|
||||
onModelChange={onModelChange}
|
||||
onStop={onStop}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { RiCheckLine, RiSearchLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { StoredChat } from '@/services/indexed-db/chat-store';
|
||||
|
||||
interface ChatSearchProps {
|
||||
chats: StoredChat[];
|
||||
activeChatId: string | null;
|
||||
onSelectChat: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export function ChatSearch({ chats, activeChatId, onSelectChat }: ChatSearchProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Search chats">
|
||||
<RiSearchLine className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[calc(100%-2rem)] overflow-hidden p-0 sm:max-w-lg [&>button]:top-2.5">
|
||||
<DialogTitle className="sr-only">Search chats</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search your saved chats by title and select one to continue the conversation.
|
||||
</DialogDescription>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search chats..." aria-label="Search chats" className="pr-10" />
|
||||
<CommandList className="max-h-[min(24rem,60dvh)]">
|
||||
<CommandEmpty>
|
||||
{chats.length === 0 ? 'No chats yet. Start a new chat to get started.' : 'No chats found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="Recent chats">
|
||||
{chats.map((chat) => (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={chat.id}
|
||||
keywords={[chat.title]}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
onSelectChat(chat.id);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{chat.title}</span>
|
||||
{chat.id === activeChatId && (
|
||||
<>
|
||||
<RiCheckLine className="size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Current chat</span>
|
||||
</>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PlusIcon, MoreHorizontalIcon, TrashIcon } from 'lucide-react';
|
||||
import { RiAddLine, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,12 +21,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatSearch } from './ChatSearch';
|
||||
import type { StoredChat } from '@/services/indexed-db/chat-store';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}
|
||||
export type Chat = StoredChat;
|
||||
|
||||
interface ChatSidebarProps {
|
||||
chats: Chat[];
|
||||
@@ -42,15 +41,29 @@ export function ChatSidebar({
|
||||
onNewChat,
|
||||
onDeleteChat,
|
||||
}: ChatSidebarProps) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
const selectChat = (chatId: string) => {
|
||||
onSelectChat(chatId);
|
||||
setOpenMobile(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="flex h-7 items-center justify-between gap-2 pl-2">
|
||||
<span className="text-xl font-semibold tracking-tight">ChatGPT</span>
|
||||
<ChatSearch chats={chats} activeChatId={activeChatId} onSelectChat={selectChat} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNewChat}
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2"
|
||||
className="mt-2 w-full justify-start gap-2"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<RiAddLine className="size-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
@@ -63,23 +76,22 @@ export function ChatSidebar({
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={chat.id === activeChatId}
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
onClick={() => selectChat(chat.id)}
|
||||
tooltip={chat.title}
|
||||
>
|
||||
<span>{chat.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<SidebarMenuAction showOnHover aria-label={`Options for ${chat.title}`}>
|
||||
<RiMoreLine className="size-4" />
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteChat(chat.id)}
|
||||
className=""
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<RiDeleteBinLine className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export { ChatAgent } from "./ChatAgent";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { ChatMessage } from "./ChatMessage";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
export { ChatPrompt } from "./ChatPrompt";
|
||||
export { ChatSidebar } from "./ChatSidebar";
|
||||
export type { Chat } from "./ChatSidebar";
|
||||
export type { Message } from "./ChatMessage";
|
||||
export type { UIMessage as Message } from "ai";
|
||||
export type { PromptSuggestion } from "./ChatPrompt";
|
||||
|
||||
Reference in New Issue
Block a user