From dd04493e2597c1a634ae195368026e34b223592c Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Wed, 16 Sep 2026 13:14:55 -0400 Subject: [PATCH] Improve chat navigation, model selection, and streaming --- .env.example | 9 + .gitignore | 5 +- .yarnrc.yml | 1 + app/api/chat/route.ts | 53 +- app/icon.svg | 2 + app/layout.tsx | 23 +- app/page.tsx | 8 +- chat.config.ts | 14 - components/ai-elements/artifact.tsx | 6 +- components/ai-elements/chain-of-thought.tsx | 18 +- components/ai-elements/checkpoint.tsx | 10 +- components/ai-elements/code-block.tsx | 4 +- components/ai-elements/conversation.tsx | 4 +- components/ai-elements/inline-citation.tsx | 6 +- components/ai-elements/message.tsx | 23 +- components/ai-elements/open-in-chat.tsx | 24 +- components/ai-elements/plan.tsx | 4 +- components/ai-elements/prompt-input.tsx | 32 +- components/ai-elements/queue.tsx | 6 +- components/ai-elements/reasoning.tsx | 6 +- components/ai-elements/sandbox.tsx | 6 +- components/ai-elements/sources.tsx | 6 +- components/ai-elements/task.tsx | 6 +- components/ai-elements/tool.tsx | 32 +- components/ai-elements/web-preview.tsx | 4 +- components/chat/ChatAgent.tsx | 267 +------- components/chat/ChatInput.tsx | 22 +- components/chat/ChatMessage.tsx | 54 -- components/chat/ChatMessageParts.tsx | 96 --- components/chat/ChatMessages.tsx | 41 +- components/chat/ChatModelSelector.tsx | 93 +++ components/chat/ChatPrompt.tsx | 63 +- components/chat/ChatSearch.tsx | 76 ++ components/chat/ChatSidebar.tsx | 40 +- components/chat/index.ts | 3 +- components/tools/registry.ts | 12 + components/tools/tool-part-renderer.tsx | 45 ++ components/tools/types.ts | 6 +- components/ui/accordion.tsx | 4 +- components/ui/breadcrumb.tsx | 6 +- components/ui/calendar.tsx | 14 +- components/ui/carousel.tsx | 10 +- components/ui/checkbox.tsx | 4 +- components/ui/combobox.tsx | 10 +- components/ui/command.tsx | 4 +- components/ui/context-menu.tsx | 8 +- components/ui/dialog.tsx | 2 +- components/ui/dropdown-menu.tsx | 8 +- components/ui/input-otp.tsx | 4 +- components/ui/menubar.tsx | 8 +- components/ui/native-select.tsx | 4 +- components/ui/navigation-menu.tsx | 4 +- components/ui/pagination.tsx | 14 +- components/ui/radio-group.tsx | 4 +- components/ui/resizable.tsx | 4 +- components/ui/select.tsx | 10 +- components/ui/sheet.tsx | 4 +- components/ui/sidebar.tsx | 4 +- components/ui/sonner.tsx | 22 +- components/ui/spinner.tsx | 6 +- config/suggestions.ts | 45 ++ hooks/use-chat-session.ts | 254 +++++++ lib/chat/messages.ts | 15 + lib/openrouter.ts | 30 + next.config.js | 39 +- package.json | 23 +- services/chat/instructions.ts | 27 + services/chat/model.ts | 19 + .../chat}/tools/firecrawl-web-fetch.ts | 3 +- .../chat}/tools/firecrawl-web-search.ts | 3 +- services/chat/tools/index.ts | 8 + tests/chat-stream.test.mjs | 237 +++++++ yarn.lock | 648 +----------------- 73 files changed, 1272 insertions(+), 1367 deletions(-) create mode 100644 .env.example create mode 100644 .yarnrc.yml create mode 100644 app/icon.svg delete mode 100644 chat.config.ts delete mode 100644 components/chat/ChatMessage.tsx delete mode 100644 components/chat/ChatMessageParts.tsx create mode 100644 components/chat/ChatModelSelector.tsx create mode 100644 components/chat/ChatSearch.tsx create mode 100644 components/tools/registry.ts create mode 100644 components/tools/tool-part-renderer.tsx create mode 100644 config/suggestions.ts create mode 100644 hooks/use-chat-session.ts create mode 100644 lib/chat/messages.ts create mode 100644 lib/openrouter.ts create mode 100644 services/chat/instructions.ts create mode 100644 services/chat/model.ts rename {actions => services/chat}/tools/firecrawl-web-fetch.ts (94%) rename {actions => services/chat}/tools/firecrawl-web-search.ts (94%) create mode 100644 services/chat/tools/index.ts create mode 100644 tests/chat-stream.test.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf5cbc6 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env.local, then run yarn install and yarn dev. +# Required for chat responses. This key is used only by the server. +OPENROUTER_API_KEY= + +# Optional initial model ID from lib/openrouter.ts; users can change it in the composer. +OPENROUTER_MODEL= + +# Optional: enables web search and page fetching when configured. +FIRECRAWL_API_KEY= diff --git a/.gitignore b/.gitignore index 29092b1..05b6ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ .DS_Store -node_modules/* +node_modules/ .next next-env.d.ts -.env.local +.env* +!.env.example ai/ vercel-ai/ tsconfig.tsbuildinfo diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0459b16..e120d36 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,16 +4,20 @@ import { stepCountIs, pruneMessages, } from 'ai'; -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { tools } from '@/chat.config'; +import { getChatModel } from '@/services/chat/model'; +import { getChatInstructions } from '@/services/chat/instructions'; +import { chatTools } from '@/services/chat/tools'; +import { isOpenRouterModelId } from '@/lib/openrouter'; -const openrouter = createOpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, -}); +export const maxDuration = 300; export async function POST(request: Request) { try { - const { messages } = await request.json(); + const { messages, modelId } = await request.json(); + + if (modelId !== undefined && !isOpenRouterModelId(modelId)) { + return Response.json({ error: 'Select a supported model' }, { status: 400 }); + } if (!process.env.OPENROUTER_API_KEY) { return Response.json( @@ -22,30 +26,11 @@ export async function POST(request: Request) { ); } - const model = openrouter( - process.env.OPENROUTER_MODEL || 'openai/gpt-5.6-luna-pro' - ); + const model = getChatModel(modelId); const webSearchEnabled = Boolean(process.env.FIRECRAWL_API_KEY); - const instructions = webSearchEnabled - ? `You are an expert ChatGPT Agent with real-time access to the internet. - -Your capabilities: -- Search the web using Firecrawl to find up-to-date information -- Fetch and read full web pages to extract detailed content -- Synthesize information from multiple sources into clear, accurate answers -- Cite your sources inline so the user can verify information - -Guidelines: -- Always search the web before answering questions that require current or factual information -- Use multiple searches when needed to get comprehensive answers -- Cross-reference sources for accuracy -- Structure your responses clearly with headings, bullet points, and citations where appropriate -- When fetching pages, extract only the relevant information -- Be transparent about the sources you used -- Today's date is ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}` - : `You are a helpful AI research assistant. Answer clearly and accurately. You do not have live web access, so be transparent when a question requires current information. Today's date is ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`; + const instructions = getChatInstructions(webSearchEnabled); const convertedMessages = await convertToModelMessages(messages); @@ -55,12 +40,16 @@ Guidelines: toolCalls: 'none', }); - const result = await streamText({ + const result = streamText({ model, instructions, messages: prunedMessages, - tools: webSearchEnabled ? tools : {}, + abortSignal: request.signal, + tools: webSearchEnabled ? chatTools : {}, stopWhen: stepCountIs(20), + onError: ({ error }) => { + if (!request.signal.aborted) console.error('Chat stream error:', error); + }, providerOptions: { openrouter: { reasoning: { @@ -71,7 +60,11 @@ Guidelines: }, }); - return result.toUIMessageStreamResponse({ sendReasoning: true }); + return result.toUIMessageStreamResponse({ + sendReasoning: true, + headers: { 'Cache-Control': 'no-cache, no-transform' }, + onError: () => 'The response was interrupted. Please try again.', + }); } catch (error) { console.error('Chat API error:', error); return new Response( diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..e2a06d1 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,2 @@ + + diff --git a/app/layout.tsx b/app/layout.tsx index 1e39fec..9a8ecbd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,9 +3,28 @@ import type { Metadata } from 'next'; import { ThemeProvider } from '@/components/theme-provider'; import './globals.css'; +const title = 'ChatGPT Agent | AI Chat & Research Assistant'; +const description = + 'Ask questions, explore ideas, and get clear AI answers with streaming responses, optional web research, file attachments, and saved conversations.'; + export const metadata: Metadata = { - title: 'ChatGPT Agent', - description: 'A ChatGPT-powered assistant for clear, useful answers.', + applicationName: 'ChatGPT Agent', + title: { + default: title, + template: '%s | ChatGPT Agent', + }, + description, + openGraph: { + type: 'website', + siteName: 'ChatGPT Agent', + title, + description, + }, + twitter: { + card: 'summary', + title, + description, + }, }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 09f4b3e..c53e271 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,11 @@ import { ChatAgent } from "@/components/chat/ChatAgent"; +import { getDefaultChatModelId } from "@/services/chat/model"; export default function ChatPage() { - return ; + return ( + + ); } diff --git a/chat.config.ts b/chat.config.ts deleted file mode 100644 index 00c43fc..0000000 --- a/chat.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { firecrawlWebFetch } from '@/actions/tools/firecrawl-web-fetch'; -import { FirecrawlWebFetchTool } from '@/components/tools/firecrawl-web-fetch-tool'; -import { firecrawlWebSearch } from '@/actions/tools/firecrawl-web-search'; -import { FirecrawlWebSearchTool } from '@/components/tools/firecrawl-web-search-tool'; - -export const tools = { - firecrawlWebSearch, - firecrawlWebFetch, -}; - -export const toolComponents = { - firecrawlWebSearch: FirecrawlWebSearchTool, - firecrawlWebFetch: FirecrawlWebFetchTool, -}; diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx index c90cb5f..6a687f6 100644 --- a/components/ai-elements/artifact.tsx +++ b/components/ai-elements/artifact.tsx @@ -8,7 +8,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { type LucideIcon, XIcon } from "lucide-react"; +import { type RemixiconComponentType, RiCloseLine } from "@remixicon/react"; import type { ComponentProps, HTMLAttributes } from "react"; export type ArtifactProps = HTMLAttributes; @@ -57,7 +57,7 @@ export const ArtifactClose = ({ variant={variant} {...props} > - {children ?? } + {children ?? } Close ); @@ -92,7 +92,7 @@ export const ArtifactActions = ({ export type ArtifactActionProps = ComponentProps & { tooltip?: string; label?: string; - icon?: LucideIcon; + icon?: RemixiconComponentType; }; export const ArtifactAction = ({ diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx index 195c465..782237a 100644 --- a/components/ai-elements/chain-of-thought.tsx +++ b/components/ai-elements/chain-of-thought.tsx @@ -9,11 +9,11 @@ import { } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; import { - BrainIcon, - ChevronDownIcon, - DotIcon, - type LucideIcon, -} from "lucide-react"; + RiBrainLine, + RiArrowDownSLine, + RiCircleFill, + type RemixiconComponentType, +} from "@remixicon/react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, memo, useContext, useMemo } from "react"; @@ -92,11 +92,11 @@ export const ChainOfThoughtHeader = memo( )} {...props} > - + {children ?? "Chain of Thought"} - & { - icon?: LucideIcon; + icon?: RemixiconComponentType; label: ReactNode; description?: ReactNode; status?: "complete" | "active" | "pending"; @@ -118,7 +118,7 @@ export type ChainOfThoughtStepProps = ComponentProps<"div"> & { export const ChainOfThoughtStep = memo( ({ className, - icon: Icon = DotIcon, + icon: Icon = RiCircleFill, label, description, status = "complete", diff --git a/components/ai-elements/checkpoint.tsx b/components/ai-elements/checkpoint.tsx index d9a5d32..99ba427 100644 --- a/components/ai-elements/checkpoint.tsx +++ b/components/ai-elements/checkpoint.tsx @@ -8,8 +8,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { BookmarkIcon, type LucideProps } from "lucide-react"; -import type { ComponentProps, HTMLAttributes } from "react"; +import { RiBookmarkLine } from "@remixicon/react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; export type CheckpointProps = HTMLAttributes; @@ -27,7 +27,9 @@ export const Checkpoint = ({ ); -export type CheckpointIconProps = LucideProps; +export type CheckpointIconProps = Omit, "children"> & { + children?: ReactNode; +}; export const CheckpointIcon = ({ className, @@ -35,7 +37,7 @@ export const CheckpointIcon = ({ ...props }: CheckpointIconProps) => children ?? ( - + ); export type CheckpointTriggerProps = ComponentProps & { diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx index b6865f0..127f998 100644 --- a/components/ai-elements/code-block.tsx +++ b/components/ai-elements/code-block.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { RiCheckLine, RiFileCopyLine } from "@remixicon/react"; import { type ComponentProps, createContext, @@ -162,7 +162,7 @@ export const CodeBlockCopyButton = ({ } }; - const Icon = isCopied ? CheckIcon : CopyIcon; + const Icon = isCopied ? RiCheckLine : RiFileCopyLine; return ( ) ); diff --git a/components/ai-elements/inline-citation.tsx b/components/ai-elements/inline-citation.tsx index 5977081..a636a74 100644 --- a/components/ai-elements/inline-citation.tsx +++ b/components/ai-elements/inline-citation.tsx @@ -13,7 +13,7 @@ import { HoverCardTrigger, } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; -import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"; import { type ComponentProps, createContext, @@ -206,7 +206,7 @@ export const InlineCitationCarouselPrev = ({ type="button" {...props} > - + ); }; @@ -233,7 +233,7 @@ export const InlineCitationCarouselNext = ({ type="button" {...props} > - + ); }; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 08e5f34..14572b6 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -14,11 +14,11 @@ import { import { cn } from "@/lib/utils"; import type { FileUIPart, UIMessage } from "ai"; import { - ChevronLeftIcon, - ChevronRightIcon, - PaperclipIcon, - XIcon, -} from "lucide-react"; + RiArrowLeftSLine, + RiArrowRightSLine, + RiAttachment2, + RiCloseLine, +} from "@remixicon/react"; import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react"; import { Children, @@ -267,7 +267,7 @@ export const MessageBranchPrevious = ({ variant="ghost" {...props} > - {children ?? } + {children ?? } ); }; @@ -291,7 +291,7 @@ export const MessageBranchNext = ({ variant="ghost" {...props} > - {children ?? } + {children ?? } ); }; @@ -338,8 +338,7 @@ export const MessageResponse = memo( }} {...props} /> - ), - (prevProps, nextProps) => prevProps.children === nextProps.children + ) ); MessageResponse.displayName = "MessageResponse"; @@ -390,7 +389,7 @@ export function MessageAttachment({ type="button" variant="ghost" > - + Remove )} @@ -400,7 +399,7 @@ export function MessageAttachment({
- +
@@ -418,7 +417,7 @@ export function MessageAttachment({ type="button" variant="ghost" > - + Remove )} diff --git a/components/ai-elements/open-in-chat.tsx b/components/ai-elements/open-in-chat.tsx index 0c62a6a..4520928 100644 --- a/components/ai-elements/open-in-chat.tsx +++ b/components/ai-elements/open-in-chat.tsx @@ -11,10 +11,10 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { - ChevronDownIcon, - ExternalLinkIcon, - MessageCircleIcon, -} from "lucide-react"; + RiArrowDownSLine, + RiExternalLinkLine, + RiChat3Line, +} from "@remixicon/react"; import { type ComponentProps, createContext, useContext } from "react"; const providers = { @@ -140,7 +140,7 @@ const providers = { `https://t3.chat/new?${new URLSearchParams({ q, })}`, - icon: , + icon: , }, v0: { title: "Open in v0", @@ -238,7 +238,7 @@ export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( {children ?? ( )} @@ -258,7 +258,7 @@ export const OpenInChatGPT = (props: OpenInChatGPTProps) => { > {providers.chatgpt.icon} {providers.chatgpt.title} - + ); @@ -278,7 +278,7 @@ export const OpenInClaude = (props: OpenInClaudeProps) => { > {providers.claude.icon} {providers.claude.title} - + ); @@ -298,7 +298,7 @@ export const OpenInT3 = (props: OpenInT3Props) => { > {providers.t3.icon} {providers.t3.title} - + ); @@ -318,7 +318,7 @@ export const OpenInScira = (props: OpenInSciraProps) => { > {providers.scira.icon} {providers.scira.title} - + ); @@ -338,7 +338,7 @@ export const OpenInv0 = (props: OpenInv0Props) => { > {providers.v0.icon} {providers.v0.title} - + ); @@ -358,7 +358,7 @@ export const OpenInCursor = (props: OpenInCursorProps) => { > {providers.cursor.icon} {providers.cursor.title} - + ); diff --git a/components/ai-elements/plan.tsx b/components/ai-elements/plan.tsx index be04d88..4b04fd5 100644 --- a/components/ai-elements/plan.tsx +++ b/components/ai-elements/plan.tsx @@ -16,7 +16,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { ChevronsUpDownIcon } from "lucide-react"; +import { RiExpandUpDownLine } from "@remixicon/react"; import type { ComponentProps } from "react"; import { createContext, useContext } from "react"; import { Shimmer } from "./shimmer"; @@ -135,7 +135,7 @@ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( variant="ghost" {...props} > - + Toggle plan diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx index d0f46de..609f999 100644 --- a/components/ai-elements/prompt-input.tsx +++ b/components/ai-elements/prompt-input.tsx @@ -37,14 +37,14 @@ import { import { cn } from "@/lib/utils"; import type { ChatStatus, FileUIPart } from "ai"; import { - CornerDownLeftIcon, - ImageIcon, - MicIcon, - PaperclipIcon, - PlusIcon, - SquareIcon, - XIcon, -} from "lucide-react"; + RiCornerDownLeftLine, + RiImageLine, + RiMicLine, + RiAttachment2, + RiAddLine, + RiStopFill, + RiCloseLine, +} from "@remixicon/react"; import { Loader } from "@/components/ai-elements/loader"; import { nanoid } from "nanoid"; import { @@ -319,7 +319,7 @@ export function PromptInputAttachment({ /> ) : (
- +
)} @@ -333,7 +333,7 @@ export function PromptInputAttachment({ type="button" variant="ghost" > - + Remove @@ -421,7 +421,7 @@ export const PromptInputActionAddAttachments = ({ attachments.openFileDialog(); }} > - {label} + {label} ); }; @@ -989,7 +989,7 @@ export const PromptInputActionMenuTrigger = ({ }: PromptInputActionMenuTriggerProps) => ( - {children ?? } + {children ?? } ); @@ -1029,14 +1029,14 @@ export const PromptInputSubmit = ({ children, ...props }: PromptInputSubmitProps) => { - let Icon = ; + let Icon = ; if (status === "submitted") { Icon = ; } else if (status === "streaming") { - Icon = ; + Icon = ; } else if (status === "error") { - Icon = ; + Icon = ; } return ( @@ -1208,7 +1208,7 @@ export const PromptInputSpeechButton = ({ onClick={toggleListening} {...props} > - + ); }; diff --git a/components/ai-elements/queue.tsx b/components/ai-elements/queue.tsx index 0c91d13..a49f6ec 100644 --- a/components/ai-elements/queue.tsx +++ b/components/ai-elements/queue.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/collapsible"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; -import { ChevronDownIcon, PaperclipIcon } from "lucide-react"; +import { RiArrowDownSLine, RiAttachment2 } from "@remixicon/react"; import type { ComponentProps } from "react"; export type QueueMessagePart = { @@ -174,7 +174,7 @@ export const QueueItemFile = ({ )} {...props} > - + {children} ); @@ -241,7 +241,7 @@ export const QueueSectionLabel = ({ ...props }: QueueSectionLabelProps) => ( - + {icon} {count} {label} diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index f8b0116..776ee3c 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -7,7 +7,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { RiBrainLine, RiArrowDownSLine } from "@remixicon/react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react"; import { Streamdown } from "streamdown"; @@ -139,9 +139,9 @@ export const ReasoningTrigger = memo( > {children ?? ( <> - + {getThinkingMessage(isStreaming, duration)} -
- + {title} {getStatusBadge(state)}
- + ); diff --git a/components/ai-elements/sources.tsx b/components/ai-elements/sources.tsx index 0756664..14c7243 100644 --- a/components/ai-elements/sources.tsx +++ b/components/ai-elements/sources.tsx @@ -6,7 +6,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { BookIcon, ChevronDownIcon } from "lucide-react"; +import { RiBookOpenLine, RiArrowDownSLine } from "@remixicon/react"; import type { ComponentProps } from "react"; export type SourcesProps = ComponentProps<"div">; @@ -35,7 +35,7 @@ export const SourcesTrigger = ({ {children ?? ( <>

Used {count} sources

- + )} @@ -69,7 +69,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => ( > {children ?? ( <> - + {title} )} diff --git a/components/ai-elements/task.tsx b/components/ai-elements/task.tsx index cd9d6b4..f3c7f8b 100644 --- a/components/ai-elements/task.tsx +++ b/components/ai-elements/task.tsx @@ -6,7 +6,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { ChevronDownIcon, SearchIcon } from "lucide-react"; +import { RiArrowDownSLine, RiSearchLine } from "@remixicon/react"; import type { ComponentProps, ReactNode } from "react"; export type TaskItemFileProps = ComponentProps<"div">; @@ -60,9 +60,9 @@ export const TaskTrigger = ({ {children ?? (
- {icon ?? } + {icon ?? }

{title}

- +
)}
diff --git a/components/ai-elements/tool.tsx b/components/ai-elements/tool.tsx index 259f01c..c00864f 100644 --- a/components/ai-elements/tool.tsx +++ b/components/ai-elements/tool.tsx @@ -9,13 +9,13 @@ import { import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { - CheckCircleIcon, - ChevronDownIcon, - CircleIcon, - ClockIcon, - WrenchIcon, - XCircleIcon, -} from "lucide-react"; + RiCheckboxCircleLine, + RiArrowDownSLine, + RiCircleLine, + RiTimeLine, + RiToolsLine, + RiCloseCircleLine, +} from "@remixicon/react"; import type { ComponentProps, ReactNode } from "react"; import { isValidElement } from "react"; import { CodeBlock } from "./code-block"; @@ -48,13 +48,13 @@ export const getStatusBadge = (status: ToolUIPart["state"]) => { }; const icons: Record = { - "input-streaming": , - "input-available": , - "approval-requested": , - "approval-responded": , - "output-available": , - "output-error": , - "output-denied": , + "input-streaming": , + "input-available": , + "approval-requested": , + "approval-responded": , + "output-available": , + "output-error": , + "output-denied": , }; return ( @@ -80,13 +80,13 @@ export const ToolHeader = ({ {...props} >
- + {title ?? type.split("-").slice(1).join("-")} {getStatusBadge(state)}
- + ); diff --git a/components/ai-elements/web-preview.tsx b/components/ai-elements/web-preview.tsx index 8f0ab5a..f699424 100644 --- a/components/ai-elements/web-preview.tsx +++ b/components/ai-elements/web-preview.tsx @@ -14,7 +14,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { ChevronDownIcon } from "lucide-react"; +import { RiArrowDownSLine } from "@remixicon/react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, useContext, useEffect, useState } from "react"; @@ -220,7 +220,7 @@ export const WebPreviewConsole = ({ variant="ghost" > Console - { - 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(null); - const [chats, setChats] = useState([]); - const chatIdRef = useRef(chatId); - const pendingChatIdRef = useRef(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({
+ {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 && ( + <> + + ))} + + ))} + + + + ); +} 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 ( + + + + + + Search chats + + Search your saved chats by title and select one to continue the conversation. + + + + + + {chats.length === 0 ? 'No chats yet. Start a new chat to get started.' : 'No chats found.'} + + + {chats.map((chat) => ( + { + setOpen(false); + onSelectChat(chat.id); + }} + > + {chat.title} + {chat.id === activeChatId && ( + <> + + ))} + + + + + + ); +} diff --git a/components/chat/ChatSidebar.tsx b/components/chat/ChatSidebar.tsx index 9dff580..0c88a26 100644 --- a/components/chat/ChatSidebar.tsx +++ b/components/chat/ChatSidebar.tsx @@ -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 ( +
+ ChatGPT + +
@@ -63,23 +76,22 @@ export function ChatSidebar({ onSelectChat(chat.id)} + onClick={() => selectChat(chat.id)} tooltip={chat.title} > {chat.title} - - + + onDeleteChat(chat.id)} - className="" > - + Delete diff --git a/components/chat/index.ts b/components/chat/index.ts index 045a785..1610567 100644 --- a/components/chat/index.ts +++ b/components/chat/index.ts @@ -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"; diff --git a/components/tools/registry.ts b/components/tools/registry.ts new file mode 100644 index 0000000..9ca7711 --- /dev/null +++ b/components/tools/registry.ts @@ -0,0 +1,12 @@ +'use client'; + +import type { ComponentType } from 'react'; +import { FirecrawlWebFetchTool } from './firecrawl-web-fetch-tool'; +import { FirecrawlWebSearchTool } from './firecrawl-web-search-tool'; +import type { ToolComponentProps } from './types'; + +// Add a renderer here for each tool that needs a custom presentation. +export const toolComponents: Record | undefined> = { + firecrawlWebSearch: FirecrawlWebSearchTool, + firecrawlWebFetch: FirecrawlWebFetchTool, +}; diff --git a/components/tools/tool-part-renderer.tsx b/components/tools/tool-part-renderer.tsx new file mode 100644 index 0000000..1cc2e65 --- /dev/null +++ b/components/tools/tool-part-renderer.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { getToolName } from 'ai'; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from '@/components/ai-elements/tool'; +import { Loader } from '@/components/ai-elements/loader'; +import { toolComponents } from './registry'; +import type { ToolComponentProps } from './types'; + +export function ToolPartRenderer({ part }: Pick) { + const toolName = getToolName(part); + const CustomRenderer = toolComponents[toolName]; + + if (CustomRenderer) { + return ; + } + + if (part.state === 'input-streaming' && !part.input) { + return ( +
+ + Running {toolName}... +
+ ); + } + + return ( + + + + {part.input != null && } + + + + ); +} diff --git a/components/tools/types.ts b/components/tools/types.ts index c1321ec..fe5a3d2 100644 --- a/components/tools/types.ts +++ b/components/tools/types.ts @@ -1,8 +1,6 @@ -import type { UIMessage } from "ai"; - -type MessagePart = UIMessage["parts"][number]; +import type { DynamicToolUIPart, ToolUIPart } from "ai"; export interface ToolComponentProps { - part: MessagePart; + part: ToolUIPart | DynamicToolUIPart; toolName: string; } diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx index 4a8cca4..3e300e3 100644 --- a/components/ui/accordion.tsx +++ b/components/ui/accordion.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" +import { RiArrowDownSLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -41,7 +41,7 @@ function AccordionTrigger({ {...props} > {children} - + ) diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx index eb88f32..6072063 100644 --- a/components/ui/breadcrumb.tsx +++ b/components/ui/breadcrumb.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" +import { RiArrowRightSLine, RiMoreLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -75,7 +75,7 @@ function BreadcrumbSeparator({ className={cn("[&>svg]:size-3.5", className)} {...props} > - {children ?? } + {children ?? } ) } @@ -92,7 +92,7 @@ function BreadcrumbEllipsis({ className={cn("flex size-9 items-center justify-center", className)} {...props} > - + More
) diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx index 9fb18ca..3091af8 100644 --- a/components/ui/calendar.tsx +++ b/components/ui/calendar.tsx @@ -2,10 +2,10 @@ import * as React from "react" import { - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, -} from "lucide-react" + RiArrowDownSLine, + RiArrowLeftSLine, + RiArrowRightSLine, +} from "@remixicon/react" import { DayPicker, getDefaultClassNames, @@ -145,13 +145,13 @@ function Calendar({ Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { return ( - + ) } if (orientation === "right") { return ( - @@ -159,7 +159,7 @@ function Calendar({ } return ( - + ) }, DayButton: CalendarDayButton, diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx index 0e05a77..ef8fa2d 100644 --- a/components/ui/carousel.tsx +++ b/components/ui/carousel.tsx @@ -4,7 +4,7 @@ import * as React from "react" import useEmblaCarousel, { type UseEmblaCarouselType, } from "embla-carousel-react" -import { ArrowLeft, ArrowRight } from "lucide-react" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -77,10 +77,10 @@ function Carousel({ const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === "ArrowLeft") { + if (event.key === "RiArrowLeftLine") { event.preventDefault() scrollPrev() - } else if (event.key === "ArrowRight") { + } else if (event.key === "RiArrowRightLine") { event.preventDefault() scrollNext() } @@ -195,7 +195,7 @@ function CarouselPrevious({ onClick={scrollPrev} {...props} > - + Previous slide ) @@ -225,7 +225,7 @@ function CarouselNext({ onClick={scrollNext} {...props} > - + Next slide ) diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index cb0b07b..0bb326d 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import { RiCheckLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -23,7 +23,7 @@ function Checkbox({ data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none" > - + ) diff --git a/components/ui/combobox.tsx b/components/ui/combobox.tsx index 2f9df6d..bc8ed4d 100644 --- a/components/ui/combobox.tsx +++ b/components/ui/combobox.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Combobox as ComboboxPrimitive } from "@base-ui/react" -import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" +import { RiCheckLine, RiArrowDownSLine, RiCloseLine } from "@remixicon/react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -31,7 +31,7 @@ function ComboboxTrigger({ {...props} > {children} - @@ -47,7 +47,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { className={cn(className)} {...props} > - + ) } @@ -160,7 +160,7 @@ function ComboboxItem({ } > - + ) @@ -265,7 +265,7 @@ function ComboboxChip({ className="-ml-1 opacity-50 hover:opacity-100" data-slot="combobox-chip-remove" > - + )} diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 76e7900..fe037f5 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Command as CommandPrimitive } from "cmdk" -import { SearchIcon } from "lucide-react" +import { RiSearchLine } from "@remixicon/react" import { cn } from "@/lib/utils" import { @@ -69,7 +69,7 @@ function CommandInput({ data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3" > - + {children} - + ) } @@ -152,7 +152,7 @@ function ContextMenuCheckboxItem({ > - + {children} @@ -176,7 +176,7 @@ function ContextMenuRadioItem({ > - + {children} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 8cccf25..874989c 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -45,7 +45,7 @@ const DialogContent = React.forwardRef< - + {children} @@ -135,7 +135,7 @@ function DropdownMenuRadioItem({ > - + {children} @@ -217,7 +217,7 @@ function DropdownMenuSubTrigger({ {...props} > {children} - + ) } diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx index 614f70e..fb214e0 100644 --- a/components/ui/input-otp.tsx +++ b/components/ui/input-otp.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { OTPInput, OTPInputContext } from "input-otp" -import { MinusIcon } from "lucide-react" +import { RiSubtractLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -69,7 +69,7 @@ function InputOTPSlot({ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { return (
- +
) } diff --git a/components/ui/menubar.tsx b/components/ui/menubar.tsx index 8480f0a..b688907 100644 --- a/components/ui/menubar.tsx +++ b/components/ui/menubar.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as MenubarPrimitive from "@radix-ui/react-menubar" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -129,7 +129,7 @@ function MenubarCheckboxItem({ > - + {children} @@ -153,7 +153,7 @@ function MenubarRadioItem({ > - + {children} @@ -235,7 +235,7 @@ function MenubarSubTrigger({ {...props} > {children} - + ) } diff --git a/components/ui/native-select.tsx b/components/ui/native-select.tsx index 0bb7681..7c68d9a 100644 --- a/components/ui/native-select.tsx +++ b/components/ui/native-select.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { ChevronDownIcon } from "lucide-react" +import { RiArrowDownSLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -24,7 +24,7 @@ function NativeSelect({ )} {...props} /> -
) diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx index 5e6778c..5b3dd7a 100644 --- a/components/ui/radio-group.tsx +++ b/components/ui/radio-group.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" -import { CircleIcon } from "lucide-react" +import { RiCircleFill } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -36,7 +36,7 @@ function RadioGroupItem({ data-slot="radio-group-indicator" className="relative flex items-center justify-center" > - + ) diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx index 8d130de..947ca10 100644 --- a/components/ui/resizable.tsx +++ b/components/ui/resizable.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { GripVerticalIcon } from "lucide-react" +import { RiDraggable } from "@remixicon/react" import * as ResizablePrimitive from "react-resizable-panels" import { cn } from "@/lib/utils" @@ -46,7 +46,7 @@ function ResizableHandle({ > {withHandle && (
- +
)} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 88302a8..95badcd 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { RiCheckLine, RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -44,7 +44,7 @@ function SelectTrigger({ > {children} - + ) @@ -119,7 +119,7 @@ function SelectItem({ className="absolute right-2 flex size-3.5 items-center justify-center" > - +
{children} @@ -153,7 +153,7 @@ function SelectScrollUpButton({ )} {...props} > - + ) } @@ -171,7 +171,7 @@ function SelectScrollDownButton({ )} {...props} > - + ) } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx index 7bf2857..61da959 100644 --- a/components/ui/sheet.tsx +++ b/components/ui/sheet.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" +import { RiCloseLine } from "@remixicon/react" import { cn } from "@/lib/utils" @@ -76,7 +76,7 @@ function SheetContent({ {children} {showCloseButton && ( - + Close )} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 30638ac..fe1aa51 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" -import { PanelLeftIcon } from "lucide-react" +import { RiLayoutLeftLine } from "@remixicon/react" import { useIsMobile } from "@/hooks/use-mobile" import { cn } from "@/lib/utils" @@ -273,7 +273,7 @@ function SidebarTrigger({ }} {...props} > - +