+ {aiError && (
+
+ The response could not be completed. Please try again.
+
+ )}
@@ -301,4 +108,4 @@ export function ChatAgent({
);
}
-export type { UIMessage as Message };
+export type { UIMessage as Message } from 'ai';
diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx
index fd67ec7..1ee80ba 100644
--- a/components/chat/ChatInput.tsx
+++ b/components/chat/ChatInput.tsx
@@ -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 (
-
+
-
+
+
diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx
deleted file mode 100644
index a20b26b..0000000
--- a/components/chat/ChatMessage.tsx
+++ /dev/null
@@ -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
[]): string {
- return parts
- .filter((p) => p.type === "text")
- .map((p) => p.text || "")
- .join("");
-}
-
-export function ChatMessage({ message }: ChatMessageProps) {
- const isUser = message.role === "user";
-
- return (
-
-
-
- {message.parts.map((part, index) => (
-
- ))}
-
-
-
- );
-}
-
-export { getTextContent };
-export type { UIMessage as Message };
diff --git a/components/chat/ChatMessageParts.tsx b/components/chat/ChatMessageParts.tsx
deleted file mode 100644
index 7da64c5..0000000
--- a/components/chat/ChatMessageParts.tsx
+++ /dev/null
@@ -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;
-
-// 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 (
-
-
-
- {input && }
-
-
-
- );
-}
-
-function ToolLoadingIndicator({ toolName }: { toolName: string }) {
- return (
-
-
- Running {toolName}...
-
- );
-}
-
-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 ;
- }
-
- if (!isComplete && !(part as any).input) {
- return ;
- }
- return ;
-}
-
-export function ToolPartRenderer({ part }: { part: MessagePart }) {
- return ;
-}
-
-export function MessagePartRenderer({ part }: { part: MessagePart }) {
- if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
- return ;
- }
- return null;
-}
diff --git a/components/chat/ChatMessages.tsx b/components/chat/ChatMessages.tsx
index f51bdf7..5f9a4e9 100644
--- a/components/chat/ChatMessages.tsx
+++ b/components/chat/ChatMessages.tsx
@@ -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 (
@@ -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 (
{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 (
{fileParts.length > 0 && (
@@ -80,7 +84,9 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
)}
{message.role === "assistant" && reasoningPart && (
-
+
{reasoningPart.text || ""}
@@ -92,7 +98,16 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
{textContent && (
- {textContent}
+ part.type === "text" && part.state === "streaming"
+ )
+ }
+ >
+ {textContent}
+
)}
diff --git a/components/chat/ChatModelSelector.tsx b/components/chat/ChatModelSelector.tsx
new file mode 100644
index 0000000..c5c0648
--- /dev/null
+++ b/components/chat/ChatModelSelector.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+
+ No models found.
+ {modelGroups.map((group) => (
+
+ {group.models.map((model) => (
+ {
+ onChange(model.id);
+ setOpen(false);
+ }}
+ >
+ {model.name}
+ {model.id === value && (
+ <>
+
+ Selected
+ >
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/chat/ChatPrompt.tsx b/components/chat/ChatPrompt.tsx
index b200ab6..8575d18 100644
--- a/components/chat/ChatPrompt.tsx
+++ b/components/chat/ChatPrompt.tsx
@@ -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}
/>
diff --git a/components/chat/ChatSearch.tsx b/components/chat/ChatSearch.tsx
new file mode 100644
index 0000000..c611730
--- /dev/null
+++ b/components/chat/ChatSearch.tsx
@@ -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 (
+