├── .env.example ├── .eslintrc.json ├── .github └── funding.yaml ├── .gitignore ├── .husky └── pre-commit ├── README.md ├── actions ├── chats.ts ├── messages.ts ├── not-diamond │ ├── create-preference-id.ts │ ├── report-regeneration.ts │ ├── select-not-diamond-model.ts │ ├── submit-arena-choice.ts │ └── submit-feedback.ts └── stream-message.tsx ├── app ├── [chatid] │ └── page.tsx ├── global-error.tsx ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── arena-mode-indicator.tsx ├── chats-bar │ └── chats-bar.tsx ├── chats │ ├── chat-list-item.tsx │ ├── chat.tsx │ ├── chats-list.tsx │ └── create-chat-button.tsx ├── dashboard.tsx ├── messages │ ├── assistant-message.tsx │ ├── copy-message.tsx │ ├── message-action-button.tsx │ ├── message-feedback.tsx │ ├── message-markdown-memoized.tsx │ ├── message-markdown.tsx │ ├── message-preference.tsx │ ├── message-stats.tsx │ ├── messsage-codeblock.tsx │ ├── regenerate-message-from-model.tsx │ ├── regenerate-message.tsx │ └── user-message.tsx ├── onboarding-message.tsx ├── router-progress-bar.tsx ├── settings-bar │ └── settings-bar.tsx ├── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── use-toast.ts └── utility │ ├── providers.tsx │ └── wait-for-hydration.tsx ├── db ├── db.ts ├── migrations │ ├── 0000_lying_violations.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json ├── queries │ ├── chats.ts │ ├── messages.ts │ └── profiles.ts └── schema │ ├── chats.ts │ ├── index.ts │ ├── messages.ts │ └── profiles.ts ├── drizzle.config.ts ├── lib ├── context │ └── app-context.tsx ├── hooks │ └── use-copy-to-clipboard.tsx ├── not-diamond │ ├── not-diamond-config.ts │ └── select-random-model.ts ├── utils.ts └── utils │ ├── get-cost.ts │ ├── handle-fetch.ts │ └── local-storage.ts ├── license ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prettier.config.cjs ├── public └── readme.png ├── tailwind.config.ts ├── tsconfig.json └── types └── chat-data.ts /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | NOT_DIAMOND_API_KEY= 3 | OPENAI_API_KEY= 4 | ANTHROPIC_API_KEY= 5 | GOOGLE_GENERATIVE_AI_API_KEY= 6 | GROQ_API_KEY= 7 | PERPLEXITY_API_KEY= 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-img-element": "off", 12 | "jsx-a11y/alt-text": "off", 13 | "react-hooks/exhaustive-deps": "off", 14 | "tailwindcss/enforces-negative-arbitrary-values": "off", 15 | "tailwindcss/no-contradicting-classname": "off", 16 | "tailwindcss/no-custom-classname": "off", 17 | "tailwindcss/no-unnecessary-arbitrary-value": "off" 18 | }, 19 | "settings": { 20 | "tailwindcss": { 21 | "callees": ["cn", "cva"], 22 | "config": "tailwind.config.js" 23 | } 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["*.ts", "*.tsx"], 28 | "parser": "@typescript-eslint/parser" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/funding.yaml: -------------------------------------------------------------------------------- 1 | # If you find my open-source work helpful, please consider sponsoring me! 2 | 3 | github: mckaywrigley 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | . "$(dirname -- "$0")/_/husky.sh" 4 | 5 | npm run lint:fix && npm run format:write && git add . 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Router Chat 2 | 3 | An AI chat app that utilizes advanced LLM model routing powered by [Not Diamond](https://notdiamond.readme.io/). 4 | 5 | Includes an "Arena mode" where you can dynamically improve your model router. 6 | 7 | ![AI Router Chat](/public/readme.png) 8 | 9 | ## Demo 10 | 11 | See the latest demo [here](https://x.com/mckaywrigley/status/1818717706037977182). 12 | 13 | ## Sponsor 14 | 15 | If you find AI Router Chat useful, please consider [sponsoring](https://github.com/sponsors/mckaywrigley) us to support my open-source work :) 16 | 17 | ## Requirements 18 | 19 | Get a Not Diamond API key from [Not Diamond](https://app.notdiamond.ai/keys). 20 | 21 | You'll also need API keys from each LLM provider (OpenAI, Anthropic, Groq, Perplexity, Google) that you'd like to use. 22 | 23 | ## Running Locally 24 | 25 | **1. Clone Repo** 26 | 27 | ```bash 28 | git clone https://github.com/mckaywrigley/ai-router-chat.git 29 | ``` 30 | 31 | **2. Provide API Keys** 32 | 33 | Create a .env file in the root of the repo with these values: 34 | 35 | ```bash 36 | cp .env.example .env 37 | ``` 38 | 39 | ```bash 40 | DATABASE_URL= 41 | NOT_DIAMOND_API_KEY= 42 | OPENAI_API_KEY= 43 | ANTHROPIC_API_KEY= 44 | GOOGLE_GENERATIVE_AI_API_KEY= 45 | GROQ_API_KEY= 46 | PERPLEXITY_API_KEY= 47 | ``` 48 | 49 | **3. Install Dependencies** 50 | 51 | ```bash 52 | npm i 53 | ``` 54 | 55 | **4. Setup DB** 56 | 57 | [Drizzle w/ Supabase Example](https://orm.drizzle.team/learn/tutorials/drizzle-with-supabase) 58 | 59 | Set up a new project on [Supabase](https://supabase.com/). 60 | 61 | Copy the connection string (DB URL) from the Supabase output, and paste it into the above .env file. 62 | 63 | Once you have the DB URL, run the following command to create the database and tables: 64 | 65 | ```bash 66 | npm run migrate 67 | ``` 68 | 69 | **5. Run App** 70 | 71 | ```bash 72 | npm run dev 73 | ``` 74 | 75 | ## Deploying 76 | 77 | Deploy to Vercel in 1 click: 78 | 79 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmckaywrigley%2Fai-router-chat.git&env=DATABASE_URL,NOT_DIAMOND_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY,GOOGLE_GENERATIVE_AI_API_KEY,GROQ_API_KEY,PERPLEXITY_API_KEY) 80 | -------------------------------------------------------------------------------- /actions/chats.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { createChat, deleteChat } from "@/db/queries/chats" 4 | import { InsertChat } from "@/db/schema/chats" 5 | import { revalidatePath } from "next/cache" 6 | import { redirect } from "next/navigation" 7 | 8 | export const createChatAction = async (chatData: InsertChat) => { 9 | const chat = await createChat(chatData) 10 | revalidatePath("/") 11 | return chat 12 | } 13 | 14 | export const deleteChatAction = async ( 15 | chatId: string, 16 | deletingCurrent: boolean 17 | ) => { 18 | await deleteChat(chatId) 19 | revalidatePath("/") 20 | if (deletingCurrent) { 21 | return redirect("/") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /actions/messages.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { 4 | createMessage, 5 | deleteMessagesIncludingAndAfterTurn, 6 | deleteRegeneratedMessages, 7 | getMessagesByChatIdAndTurn, 8 | updateMessage, 9 | updateTurnPreferences 10 | } from "@/db/queries/messages" 11 | import { InsertMessage, SelectMessage } from "@/db/schema/messages" 12 | import { revalidatePath } from "next/cache" 13 | import { submitFeedback } from "./not-diamond/submit-feedback" 14 | 15 | export const createMessageAction = async (data: InsertMessage) => { 16 | const newMessage = await createMessage(data) 17 | revalidatePath("/") 18 | return newMessage 19 | } 20 | 21 | export const updateMessageAction = async ( 22 | messageId: string, 23 | updateData: Partial 24 | ) => { 25 | const updatedMessage = await updateMessage(messageId, updateData) 26 | revalidatePath("/") 27 | return updatedMessage 28 | } 29 | 30 | export const updateTurnPreferenceAction = async ( 31 | chatId: string, 32 | messageId: string, 33 | turn: number 34 | ) => { 35 | const messages = await getMessagesByChatIdAndTurn(chatId, turn) 36 | await updateTurnPreferences( 37 | chatId, 38 | turn, 39 | messages.map(msg => ({ id: msg.id, isPreferred: msg.id === messageId })) 40 | ) 41 | revalidatePath("/") 42 | } 43 | 44 | export const deleteMessagesIncludingAndAfterTurnAction = async ( 45 | chatId: string, 46 | turn: number 47 | ) => { 48 | await deleteMessagesIncludingAndAfterTurn(chatId, turn) 49 | revalidatePath("/") 50 | } 51 | 52 | export const deleteRegeneratedMessagesAction = async ( 53 | chatId: string, 54 | turn: number 55 | ) => { 56 | await deleteRegeneratedMessages(chatId, turn) 57 | revalidatePath("/") 58 | } 59 | 60 | export const handleBothResponsesAreBad = async ({ 61 | messages, 62 | sessionId 63 | }: { 64 | messages: SelectMessage[] 65 | sessionId: string | null 66 | }) => { 67 | const promises = messages.map(async message => { 68 | if (sessionId) { 69 | await submitFeedback(sessionId, -1, { 70 | provider: message.provider, 71 | model: message.model 72 | }) 73 | } 74 | 75 | await updateMessageAction(message.id, { 76 | thumbsUp: false 77 | }) 78 | 79 | await updateTurnPreferences( 80 | message.chatId, 81 | message.turn, 82 | messages.map(msg => ({ id: msg.id, isPreferred: false })) 83 | ) 84 | }) 85 | 86 | await Promise.all(promises) 87 | } 88 | 89 | export const handleBothResponsesAreGood = async ({ 90 | messages 91 | }: { 92 | messages: SelectMessage[] 93 | }) => { 94 | const promises = messages.map(async message => { 95 | await updateMessageAction(message.id, { 96 | thumbsUp: false 97 | }) 98 | 99 | await updateTurnPreferences( 100 | message.chatId, 101 | message.turn, 102 | messages.map(msg => ({ id: msg.id, isPreferred: true })) 103 | ) 104 | }) 105 | 106 | await Promise.all(promises) 107 | } 108 | -------------------------------------------------------------------------------- /actions/not-diamond/create-preference-id.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { ndBaseUrl, ndHeaders } from "../../lib/not-diamond/not-diamond-config" 4 | import { handleFetch } from "../../lib/utils/handle-fetch" 5 | 6 | export const createPreferenceId = async (name: string) => { 7 | const json = await handleFetch( 8 | `${ndBaseUrl}/v2/chat/preferences/preferenceCreate`, 9 | { 10 | method: "POST", 11 | headers: ndHeaders, 12 | body: JSON.stringify({ 13 | name 14 | }) 15 | } 16 | ) 17 | 18 | return json.preference_id 19 | } 20 | -------------------------------------------------------------------------------- /actions/not-diamond/report-regeneration.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { ndBaseUrl, ndHeaders } from "../../lib/not-diamond/not-diamond-config" 4 | import { NDLLMProvider } from "../../lib/not-diamond/select-random-model" 5 | import { handleFetch } from "../../lib/utils/handle-fetch" 6 | 7 | export const reportRegeneration = async ( 8 | session_id: string, 9 | provider: NDLLMProvider 10 | ) => { 11 | await handleFetch(`${ndBaseUrl}/v2/chat/report/regenerated`, { 12 | method: "POST", 13 | headers: ndHeaders, 14 | body: JSON.stringify({ 15 | session_id, 16 | provider, 17 | regenerated: true 18 | }) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /actions/not-diamond/select-not-diamond-model.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import console from "console" 4 | import { 5 | fallbackModels, 6 | ndBaseUrl, 7 | ndHeaders, 8 | ndLLMProviders 9 | } from "../../lib/not-diamond/not-diamond-config" 10 | import { NDLLMProvider } from "../../lib/not-diamond/select-random-model" 11 | import { handleFetch } from "../../lib/utils/handle-fetch" 12 | 13 | export async function selectNdModel( 14 | preferenceId: string, 15 | messages: { role: string; content: string }[], 16 | activeModels: NDLLMProvider[], 17 | previousSession: string | null 18 | ) { 19 | try { 20 | const response = await handleFetch(`${ndBaseUrl}/v2/chat/modelSelect`, { 21 | method: "POST", 22 | headers: ndHeaders, 23 | body: JSON.stringify({ 24 | messages, 25 | llm_providers: activeModels.filter(model => model.provider !== ""), 26 | preference_id: preferenceId, 27 | previous_session: previousSession 28 | }) 29 | }) 30 | 31 | const { 32 | session_id, 33 | provider: { model } 34 | } = response 35 | 36 | const selectedModel = ndLLMProviders.find( 37 | item => item.ndModelId === model 38 | )?.providerModelId 39 | if (!selectedModel) { 40 | throw new Error("No model selected") 41 | } 42 | 43 | return { session_id, model: selectedModel } 44 | } catch (error: any) { 45 | console.error(`Error selecting model: ${error.message}`) 46 | 47 | // Fallback mechanism 48 | for (const fallbackModel of fallbackModels) { 49 | const fallbackProvider = ndLLMProviders.find( 50 | provider => provider.providerModelId === fallbackModel 51 | ) 52 | if ( 53 | fallbackProvider && 54 | activeModels.some(item => item.model === fallbackModel) 55 | ) { 56 | return { session_id: null, model: fallbackModel } 57 | } 58 | } 59 | 60 | return { session_id: null, model: null } 61 | } 62 | } 63 | 64 | export async function selectNdArenaModels( 65 | preferenceId: string, 66 | messages: { role: string; content: string }[], 67 | activeModels: NDLLMProvider[] 68 | ) { 69 | try { 70 | const response = await handleFetch( 71 | `${ndBaseUrl}/v2/chat/arena/arenaModels`, 72 | { 73 | method: "POST", 74 | headers: ndHeaders, 75 | body: JSON.stringify({ 76 | messages, 77 | llm_providers: activeModels.filter(model => model.provider !== ""), 78 | preference_id: preferenceId 79 | }) 80 | } 81 | ) 82 | 83 | const { session_id, model_1, model_2 } = response 84 | 85 | return { 86 | session_id, 87 | model1: model_1.model, 88 | model2: model_2.model 89 | } 90 | } catch (error: any) { 91 | console.error(`Error selecting arena models: ${error.message}`) 92 | 93 | // Fallback mechanism 94 | let fallbackModel1: string | null = null 95 | let fallbackModel2: string | null = null 96 | 97 | for (const fallbackModel of fallbackModels) { 98 | const fallbackProvider = ndLLMProviders.find( 99 | provider => provider.providerModelId === fallbackModel 100 | ) 101 | if ( 102 | fallbackProvider && 103 | activeModels.some(item => item.model === fallbackModel) 104 | ) { 105 | if (!fallbackModel1) { 106 | fallbackModel1 = fallbackModel 107 | } else if (!fallbackModel2) { 108 | fallbackModel2 = fallbackModel 109 | break 110 | } 111 | } 112 | } 113 | 114 | return { 115 | session_id: null, 116 | model_1: fallbackModel1, 117 | model_2: fallbackModel2 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /actions/not-diamond/submit-arena-choice.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { ndBaseUrl, ndHeaders } from "../../lib/not-diamond/not-diamond-config" 4 | import { handleFetch } from "../../lib/utils/handle-fetch" 5 | 6 | export const submitArenaChoice = async ( 7 | session_id: string, 8 | preferred: { provider: string; model: string }, 9 | rejected: { provider: string; model: string } 10 | ) => { 11 | const payload = { 12 | session_id, 13 | preferred_provider: preferred, 14 | rejected_provider: rejected 15 | } 16 | 17 | await handleFetch(`${ndBaseUrl}/v2/chat/arena/arenaChoice`, { 18 | method: "POST", 19 | headers: ndHeaders, 20 | body: JSON.stringify(payload) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /actions/not-diamond/submit-feedback.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { 4 | ndBaseUrl, 5 | ndHeaders, 6 | ndLLMProviders 7 | } from "../../lib/not-diamond/not-diamond-config" 8 | import { NDLLMProvider } from "../../lib/not-diamond/select-random-model" 9 | import { handleFetch } from "../../lib/utils/handle-fetch" 10 | 11 | export const submitFeedback = async ( 12 | session_id: string, 13 | thumbs: number, 14 | provider: NDLLMProvider 15 | ) => { 16 | const providerData = ndLLMProviders.find( 17 | currentProvider => currentProvider.providerModelId === provider?.model 18 | ) 19 | 20 | await handleFetch(`${ndBaseUrl}/v2/chat/report/thumbsUpDown`, { 21 | method: "POST", 22 | headers: ndHeaders, 23 | body: JSON.stringify({ 24 | session_id, 25 | thumbs, 26 | provider: { 27 | model: providerData?.ndModelId, 28 | provider: providerData?.ndProvider 29 | } 30 | }) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /actions/stream-message.tsx: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { StreamMessagePayload } from "@/types/chat-data" 4 | import { CoreMessage, streamText } from "ai" 5 | import { createStreamableValue } from "ai/rsc" 6 | import { ndLLMProviders } from "../lib/not-diamond/not-diamond-config" 7 | import { providers } from "../lib/not-diamond/select-random-model" 8 | 9 | const MAX_RETRIES = ndLLMProviders.length 10 | 11 | export const streamMessage = async (payload: StreamMessagePayload) => { 12 | const stream = createStreamableValue("") 13 | 14 | const handleStreamText = async ({ 15 | messages, 16 | temperature, 17 | model, 18 | retryCount = 0 19 | }: StreamMessagePayload & { retryCount?: number }) => { 20 | if (retryCount >= MAX_RETRIES) { 21 | stream.update("\n\nMax retries reached. Unable to complete the request.") 22 | stream.done() 23 | return 24 | } 25 | 26 | const regenerationProvider = ndLLMProviders.find( 27 | item => item.providerModelId === model 28 | )?.cloudProvider 29 | const modelWithProvider = 30 | providers[regenerationProvider as keyof typeof providers](model) 31 | 32 | try { 33 | const result = await streamText({ 34 | model: modelWithProvider, 35 | messages: messages as CoreMessage[], 36 | temperature 37 | }) 38 | 39 | const { textStream } = result 40 | 41 | for await (const chunk of textStream) { 42 | stream.update(chunk) 43 | } 44 | 45 | stream.done() 46 | } catch (e) { 47 | const models = ndLLMProviders.filter( 48 | item => item.providerModelId !== model 49 | ) 50 | const randomModel = 51 | models[Math.floor(Math.random() * models.length)].providerModelId 52 | console.info( 53 | `\n\nEncountered an error. Retrying with a different model (${randomModel})...\n\n` 54 | ) 55 | 56 | await handleStreamText({ 57 | model: randomModel, 58 | temperature, 59 | messages, 60 | retryCount: retryCount + 1 61 | }) 62 | } 63 | } 64 | 65 | handleStreamText(payload) 66 | 67 | return { output: stream.value } 68 | } 69 | -------------------------------------------------------------------------------- /app/[chatid]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chats/chat" 2 | import { getChatById } from "@/db/queries/chats" 3 | import { getMessagesByChatId } from "@/db/queries/messages" 4 | 5 | export const dynamic = "force-dynamic" 6 | 7 | export default async function ChatIdPage({ 8 | params 9 | }: { 10 | params: { chatid: string } 11 | }) { 12 | const chat = await getChatById(params.chatid) 13 | const messages = await getMessagesByChatId(params.chatid) 14 | 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import NextError from "next/error"; 5 | import { useEffect } from "react"; 6 | 7 | export default function GlobalError({ error }: { error: Error & { digest?: string } }) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 | 15 | {/* `NextError` is the default Next.js error page component. Its type 16 | definition requires a `statusCode` prop. However, since the App Router 17 | does not expose status codes for errors, we simply pass 0 to render a 18 | generic error message. */} 19 | 20 | 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { createPreferenceId } from "@/actions/not-diamond/create-preference-id" 2 | import { Dashboard } from "@/components/dashboard" 3 | import { OnboardingMessage } from "@/components/onboarding-message" 4 | import { Providers } from "@/components/utility/providers" 5 | import { getAllChatsByProfileId } from "@/db/queries/chats" 6 | import { createProfile, getProfile } from "@/db/queries/profiles" 7 | import { ndLLMProviders } from "@/lib/not-diamond/not-diamond-config" 8 | import type { Metadata } from "next" 9 | import { Inter } from "next/font/google" 10 | import "./globals.css" 11 | 12 | const inter = Inter({ subsets: ["latin"] }) 13 | 14 | export const metadata: Metadata = { 15 | title: "AI Router Chat", 16 | description: 17 | "A template for building an AI chat app that utilizes advanced LLM model routing powered by Not Diamond." 18 | } 19 | 20 | export const dynamic = "force-dynamic" 21 | 22 | export default async function RootLayout({ 23 | children 24 | }: Readonly<{ 25 | children: React.ReactNode 26 | }>) { 27 | let profile = await getProfile() 28 | 29 | if (!profile) { 30 | const preferenceId = await createPreferenceId("not-diamond-template") 31 | profile = await createProfile({ 32 | preferenceId, 33 | activeModels: [...ndLLMProviders.map(model => model.providerModelId)] 34 | }) 35 | } 36 | 37 | const chats = await getAllChatsByProfileId(profile.id) 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | {children} 45 | 46 | 47 | 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chats/chat" 2 | 3 | export const dynamic = "force-dynamic" 4 | 5 | export default async function Home() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/arena-mode-indicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AppContext } from "@/lib/context/app-context"; 4 | import { Swords } from "lucide-react"; 5 | import { FC, useContext } from "react"; 6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; 7 | 8 | interface ArenaModeIndicatorProps {} 9 | 10 | export const ArenaModeIndicator: FC = ({}) => { 11 | const { isArenaModeActive } = useContext(AppContext); 12 | 13 | if (isArenaModeActive) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | Arena Mode Active 22 | 23 | 24 | ); 25 | } else { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | Arena Mode Inactive 34 | 35 | 36 | ); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /components/chats-bar/chats-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SelectChat } from "@/db/schema/chats"; 4 | import { cn } from "@/lib/utils"; 5 | import { SidebarClose, SidebarOpen } from "lucide-react"; 6 | import { HTMLAttributes } from "react"; 7 | import { ChatsList } from "../chats/chats-list"; 8 | import { CreateChatButton } from "../chats/create-chat-button"; 9 | import { Button } from "../ui/button"; 10 | 11 | interface ChatsBarProps extends HTMLAttributes { 12 | chats: SelectChat[]; 13 | isOpen: boolean; 14 | onOpenChange: (isOpen: boolean) => void; 15 | } 16 | 17 | export const ChatsBar = ({ chats, isOpen, onOpenChange, ...props }: ChatsBarProps) => { 18 | return ( 19 | <> 20 |
21 |
22 | 23 | 30 |
31 | 32 | 36 |
37 | 38 | {!isOpen && ( 39 | 46 | )} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /components/chats/chat-list-item.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { deleteChatAction } from "@/actions/chats" 4 | import { SelectChat } from "@/db/schema/chats" 5 | import { cn } from "@/lib/utils" 6 | import { Ellipsis, Trash } from "lucide-react" 7 | import Link from "next/link" 8 | import { usePathname } from "next/navigation" 9 | import { FC, HTMLAttributes, useState } from "react" 10 | import { Button } from "../ui/button" 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuTrigger 15 | } from "../ui/dropdown-menu" 16 | 17 | interface ChatListItemProps extends HTMLAttributes { 18 | chat: SelectChat 19 | } 20 | export const ChatListItem: FC = ({ chat, ...props }) => { 21 | const pathname = usePathname() 22 | 23 | const [isHovered, setIsHovered] = useState(false) 24 | const [isSelected, setIsSelected] = useState(false) 25 | 26 | const handleDeleteChat = async () => { 27 | await deleteChatAction(chat.id, pathname === `/${chat.id}`) 28 | } 29 | 30 | const isCurrentPath = pathname === `/${chat.id}` 31 | 32 | return ( 33 | setIsHovered(true)} 41 | onMouseLeave={() => !isSelected && setIsHovered(false)} 42 | > 43 |
{chat.name}
44 | 45 | setIsSelected(open)}> 46 | 54 | 55 | 56 | 57 | { 60 | e.stopPropagation() 61 | }} 62 | > 63 | 71 | 72 | 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /components/chats/chats-list.tsx: -------------------------------------------------------------------------------- 1 | import { SelectChat } from "@/db/schema/chats"; 2 | import { cn } from "@/lib/utils"; 3 | import { FC, HTMLAttributes } from "react"; 4 | import { ChatListItem } from "./chat-list-item"; 5 | 6 | interface ChatsListProps extends HTMLAttributes { 7 | chats: SelectChat[]; 8 | } 9 | 10 | export const ChatsList: FC = ({ chats, ...props }) => { 11 | return ( 12 | <> 13 | {chats.length > 0 ? ( 14 |
15 | {chats.map((chat) => ( 16 | 20 | ))} 21 |
22 | ) : ( 23 |
24 |
No chats found
25 |
26 | )} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /components/chats/create-chat-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AppContext } from "@/lib/context/app-context"; 4 | import { cn } from "@/lib/utils"; 5 | import { Plus } from "lucide-react"; 6 | import { usePathname, useRouter } from "next/navigation"; 7 | import { FC, HTMLAttributes, useContext } from "react"; 8 | import { Button } from "../ui/button"; 9 | 10 | interface CreateChatButtonProps extends HTMLAttributes {} 11 | 12 | export const CreateChatButton: FC = ({ ...props }) => { 13 | const { setSelectedChatId } = useContext(AppContext); 14 | 15 | const router = useRouter(); 16 | const pathname = usePathname(); 17 | 18 | return ( 19 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /components/dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SelectChat } from "@/db/schema/chats" 4 | import { SelectProfile } from "@/db/schema/profiles" 5 | import { AppContext } from "@/lib/context/app-context" 6 | import { cn } from "@/lib/utils" 7 | import { HTMLAttributes, useContext, useEffect, useState } from "react" 8 | import { ArenaModeIndicator } from "./arena-mode-indicator" 9 | import { ChatsBar } from "./chats-bar/chats-bar" 10 | import { SettingsBar } from "./settings-bar/settings-bar" 11 | 12 | interface DashboardProps extends HTMLAttributes { 13 | profile: SelectProfile 14 | chats: SelectChat[] 15 | } 16 | 17 | export const Dashboard = ({ profile, chats, ...props }: DashboardProps) => { 18 | const { setProfile } = useContext(AppContext) 19 | 20 | const [isChatsOpen, setIsChatsOpen] = useState(true) 21 | 22 | useEffect(() => { 23 | setProfile(profile) 24 | }, [profile]) 25 | 26 | return ( 27 |
28 | 33 | 34 |
35 | {props.children} 36 |
37 | 38 |
44 | 45 | 46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/messages/assistant-message.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { updateTurnPreferenceAction } from "@/actions/messages" 4 | import { updateProfile } from "@/db/queries/profiles" 5 | import { SelectMessage } from "@/db/schema/messages" 6 | import { AppContext } from "@/lib/context/app-context" 7 | import { cn } from "@/lib/utils" 8 | import { BotIcon } from "lucide-react" 9 | import { FC, HTMLAttributes, useContext } from "react" 10 | import { CopyMessage } from "./copy-message" 11 | import { MessageFeedback } from "./message-feedback" 12 | import { MessageMarkdown } from "./message-markdown" 13 | import { MessagePreference } from "./message-preference" 14 | import { MessageStats } from "./message-stats" 15 | import { RegenerateMessage } from "./regenerate-message" 16 | import { RegenerateMessageFromModel } from "./regenerate-message-from-model" 17 | 18 | interface AssistantMessageProps extends HTMLAttributes { 19 | message: SelectMessage 20 | isLast: boolean 21 | isGenerating: boolean 22 | turnHasPreferenceSelection: boolean 23 | sessionId: string | null 24 | onFeedbackUpdate: (messageId: string, thumbsUp: boolean) => void 25 | onPreferenceUpdate: (messageId: string) => void 26 | onRegenerate: (modelId: string) => void 27 | } 28 | 29 | export const AssistantMessage: FC = ({ 30 | message, 31 | isLast, 32 | isGenerating, 33 | turnHasPreferenceSelection, 34 | sessionId, 35 | onFeedbackUpdate, 36 | onPreferenceUpdate, 37 | onRegenerate, 38 | ...props 39 | }) => { 40 | const isArenaMessage = message.arenaMessage 41 | const isPreferred = message.isPreferred 42 | const needsPreferenceSelection = isArenaMessage && !turnHasPreferenceSelection 43 | 44 | const { profile, setProfile } = useContext(AppContext) 45 | 46 | const handlePreference = async () => { 47 | if (!profile) return 48 | 49 | if (needsPreferenceSelection && profile.routerProgress < 10) { 50 | setProfile({ ...profile, routerProgress: profile.routerProgress + 1 }) 51 | await updateProfile(profile.id, { 52 | ...profile, 53 | routerProgress: profile.routerProgress + 1 54 | }) 55 | } 56 | 57 | onPreferenceUpdate(message.id) 58 | await updateTurnPreferenceAction(message.chatId, message.id, message.turn) 59 | } 60 | 61 | return ( 62 |
{ 72 | if (needsPreferenceSelection) { 73 | handlePreference() 74 | } 75 | }} 76 | > 77 | {!isArenaMessage && ( 78 | 79 | )} 80 | 81 |
82 | 83 | 84 |
85 | {isGenerating || needsPreferenceSelection ? ( 86 |
87 | ) : ( 88 |
89 | 90 | {isLast && !isArenaMessage && ( 91 | onRegenerate(message.model)} 93 | /> 94 | )} 95 | {isLast && !isArenaMessage && ( 96 | 100 | )} 101 | {!isArenaMessage && ( 102 | 112 | )} 113 | 114 | {isArenaMessage && !needsPreferenceSelection && !isPreferred && ( 115 | 119 | )} 120 |
121 | )} 122 |
123 | 124 | {(!isGenerating || !isLast) && 125 | (!isArenaMessage || 126 | (isArenaMessage && turnHasPreferenceSelection)) && ( 127 | <> 128 |
129 | 134 | 135 | )} 136 |
137 |
138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /components/messages/copy-message.tsx: -------------------------------------------------------------------------------- 1 | import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; 2 | import { Check, Copy } from "lucide-react"; 3 | import { FC, HTMLAttributes, useState } from "react"; 4 | import { ACTION_ICON_SIZE, MessageActionButton } from "./message-action-button"; 5 | 6 | interface CopyMessageProps extends HTMLAttributes { 7 | content: string; 8 | } 9 | 10 | export const CopyMessage: FC = ({ content, ...props }) => { 11 | const { copyToClipboard } = useCopyToClipboard({}); 12 | const [copied, setCopied] = useState(false); 13 | 14 | const handleCopy = () => { 15 | copyToClipboard(content); 16 | setCopied(true); 17 | setTimeout(() => setCopied(false), 2000); 18 | }; 19 | 20 | return ( 21 | : } 24 | className={props.className} 25 | /> 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /components/messages/message-action-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { motion } from "framer-motion"; 3 | import { FC, HTMLAttributes, ReactNode } from "react"; 4 | import { Button } from "../ui/button"; 5 | 6 | export const ACTION_ICON_SIZE = 4; 7 | 8 | interface MessageActionButtonProps extends HTMLAttributes { 9 | onClick: () => void; 10 | icon: ReactNode; 11 | isActive?: boolean; 12 | } 13 | 14 | export const MessageActionButton: FC = ({ onClick, icon, isActive, ...props }) => { 15 | return ( 16 | 21 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /components/messages/message-feedback.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { updateMessageAction } from "@/actions/messages" 4 | import { submitFeedback } from "@/actions/not-diamond/submit-feedback" 5 | import { NDLLMProvider } from "@/lib/not-diamond/select-random-model" 6 | import { cn } from "@/lib/utils" 7 | import { ThumbsDown, ThumbsUp } from "lucide-react" 8 | import { FC, HTMLAttributes } from "react" 9 | import { ACTION_ICON_SIZE, MessageActionButton } from "./message-action-button" 10 | 11 | interface MessageFeedbackProps extends HTMLAttributes { 12 | messageId: string 13 | thumbsUp: boolean | null 14 | sessionId: string | null 15 | provider: NDLLMProvider 16 | onFeedbackUpdate: (messageId: string, thumbsUp: boolean) => void 17 | } 18 | 19 | export const MessageFeedback: FC = ({ 20 | messageId, 21 | thumbsUp, 22 | sessionId, 23 | provider, 24 | onFeedbackUpdate, 25 | ...props 26 | }) => { 27 | const handleFeedback = async (thumbsUp: boolean) => { 28 | const thumbs = thumbsUp ? 1 : -1 29 | 30 | onFeedbackUpdate(messageId, thumbsUp) 31 | 32 | if (sessionId) { 33 | await submitFeedback(sessionId, thumbs, provider) 34 | } 35 | 36 | await updateMessageAction(messageId, { thumbsUp }) 37 | } 38 | 39 | return ( 40 |
41 | handleFeedback(true)} 43 | icon={ 44 | 47 | } 48 | isActive={thumbsUp === true} 49 | /> 50 | handleFeedback(false)} 52 | icon={ 53 | 56 | } 57 | isActive={thumbsUp === false} 58 | /> 59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/messages/message-markdown-memoized.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from "react"; 2 | import ReactMarkdown, { Options } from "react-markdown"; 3 | 4 | export const MessageMarkdownMemoized: FC = memo(ReactMarkdown, (prevProps, nextProps) => prevProps.children === nextProps.children && prevProps.className === nextProps.className); 5 | -------------------------------------------------------------------------------- /components/messages/message-markdown.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React, { FC, HTMLAttributes } from "react"; 3 | import remarkGfm from "remark-gfm"; 4 | import remarkMath from "remark-math"; 5 | import { MessageMarkdownMemoized } from "./message-markdown-memoized"; 6 | import { MessageCodeBlock } from "./messsage-codeblock"; 7 | 8 | interface MessageMarkdownProps extends HTMLAttributes { 9 | content: string; 10 | } 11 | 12 | export const MessageMarkdown: FC = ({ content, ...props }) => { 13 | return ( 14 | {children}

; 20 | }, 21 | img({ node, ...props }) { 22 | return ( 23 | 27 | ); 28 | }, 29 | code({ node, className, children, ...props }) { 30 | const childArray = React.Children.toArray(children); 31 | const firstChild = childArray[0] as React.ReactElement; 32 | const firstChildAsString = React.isValidElement(firstChild) ? (firstChild as React.ReactElement).props.children : firstChild; 33 | 34 | if (firstChildAsString === "▍") { 35 | return ; 36 | } 37 | 38 | if (typeof firstChildAsString === "string") { 39 | childArray[0] = firstChildAsString.replace("`▍`", "▍"); 40 | } 41 | 42 | const match = /language-(\w+)/.exec(className || ""); 43 | 44 | if (typeof firstChildAsString === "string" && !firstChildAsString.includes("\n")) { 45 | return ( 46 | 50 | {childArray} 51 | 52 | ); 53 | } 54 | 55 | return ( 56 | 62 | ); 63 | } 64 | }} 65 | > 66 | {content} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /components/messages/message-preference.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Star } from "lucide-react"; 4 | import { FC, HTMLAttributes } from "react"; 5 | import { ACTION_ICON_SIZE, MessageActionButton } from "./message-action-button"; 6 | 7 | interface MessagePreferenceProps extends HTMLAttributes { 8 | messageId: string; 9 | onChangePreference: () => void; 10 | } 11 | 12 | export const MessagePreference: FC = ({ messageId, onChangePreference, ...props }) => { 13 | return ( 14 | } 17 | className={props.className} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /components/messages/message-stats.tsx: -------------------------------------------------------------------------------- 1 | import { ndLLMProviders } from "@/lib/not-diamond/not-diamond-config" 2 | import { cn } from "@/lib/utils" 3 | import { FC, HTMLAttributes } from "react" 4 | 5 | interface MessageStatsProps extends HTMLAttributes { 6 | modelId: string 7 | latency: number | null 8 | cost: number | null 9 | } 10 | 11 | export const MessageStats: FC = ({ 12 | modelId, 13 | latency, 14 | cost, 15 | ...props 16 | }) => { 17 | const stats = [ 18 | { 19 | label: "model", 20 | value: 21 | ndLLMProviders.find(item => item.providerModelId === modelId)?.label || 22 | modelId 23 | }, 24 | { label: "latency", value: latency ? `${latency}ms` : "N/A" }, 25 | { label: "cost", value: cost ? `$${(cost / 1000000).toFixed(4)}` : "N/A" } 26 | ] 27 | 28 | return ( 29 |
30 | {stats.map(({ label, value }) => ( 31 |
32 | {label}:{" "} 33 | {value} 34 |
35 | ))} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/messages/messsage-codeblock.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; 3 | import { cn } from "@/lib/utils"; 4 | import { Check, Copy, Download } from "lucide-react"; 5 | import { FC, HTMLAttributes, memo } from "react"; 6 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 7 | import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; 8 | 9 | interface MessageCodeBlockProps extends HTMLAttributes { 10 | language: string; 11 | value: string; 12 | } 13 | 14 | interface languageMap { 15 | [key: string]: string | undefined; 16 | } 17 | 18 | export const programmingLanguages: languageMap = { 19 | javascript: ".js", 20 | python: ".py", 21 | java: ".java", 22 | c: ".c", 23 | cpp: ".cpp", 24 | "c++": ".cpp", 25 | "c#": ".cs", 26 | ruby: ".rb", 27 | php: ".php", 28 | swift: ".swift", 29 | "objective-c": ".m", 30 | kotlin: ".kt", 31 | typescript: ".ts", 32 | go: ".go", 33 | perl: ".pl", 34 | rust: ".rs", 35 | scala: ".scala", 36 | haskell: ".hs", 37 | lua: ".lua", 38 | shell: ".sh", 39 | sql: ".sql", 40 | html: ".html", 41 | css: ".css" 42 | }; 43 | 44 | export const generateRandomString = (length: number, lowercase = false) => { 45 | const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0 46 | let result = ""; 47 | for (let i = 0; i < length; i++) { 48 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 49 | } 50 | return lowercase ? result.toLowerCase() : result; 51 | }; 52 | 53 | export const MessageCodeBlock: FC = memo(({ language, value, ...props }) => { 54 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); 55 | 56 | const downloadAsFile = () => { 57 | if (typeof window === "undefined") { 58 | return; 59 | } 60 | const fileExtension = programmingLanguages[language] || ".file"; 61 | const suggestedFileName = `file-${generateRandomString(3, true)}${fileExtension}`; 62 | const fileName = window.prompt("Enter file name" || "", suggestedFileName); 63 | 64 | if (!fileName) { 65 | return; 66 | } 67 | 68 | const blob = new Blob([value], { type: "text/plain" }); 69 | const url = URL.createObjectURL(blob); 70 | const link = document.createElement("a"); 71 | link.download = fileName; 72 | link.href = url; 73 | link.style.display = "none"; 74 | document.body.appendChild(link); 75 | link.click(); 76 | document.body.removeChild(link); 77 | URL.revokeObjectURL(url); 78 | }; 79 | 80 | const onCopy = () => { 81 | if (isCopied) return; 82 | copyToClipboard(value); 83 | }; 84 | 85 | return ( 86 |
87 |
88 | {language} 89 |
90 | 98 | 99 | 107 |
108 |
109 | 125 | {value} 126 | 127 |
128 | ); 129 | }); 130 | 131 | MessageCodeBlock.displayName = "MessageCodeBlock"; 132 | -------------------------------------------------------------------------------- /components/messages/regenerate-message-from-model.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AppContext } from "@/lib/context/app-context" 4 | import { ndLLMProviders } from "@/lib/not-diamond/not-diamond-config" 5 | import { Check, ChevronDown, Repeat, Sparkles } from "lucide-react" 6 | import { FC, HTMLAttributes, useContext, useState } from "react" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger 12 | } from "../ui/dropdown-menu" 13 | import { ACTION_ICON_SIZE, MessageActionButton } from "./message-action-button" 14 | 15 | interface RegenerateMessageFromModelProps 16 | extends HTMLAttributes { 17 | currentModel: string 18 | onRegenerateFromModel: (modelId: string) => void 19 | } 20 | 21 | export const RegenerateMessageFromModel: FC< 22 | RegenerateMessageFromModelProps 23 | > = ({ currentModel, onRegenerateFromModel, ...props }) => { 24 | const { profile } = useContext(AppContext) 25 | 26 | const [hoveredModel, setHoveredModel] = useState(null) 27 | 28 | if (!profile) return null 29 | 30 | // Sort models by provider and then by model 31 | const sortedModels = profile.activeModels 32 | .map(modelId => ndLLMProviders.find(m => m.providerModelId === modelId)) 33 | .filter(model => model !== undefined) 34 | .sort((a, b) => { 35 | if (!a || !b) return 0 36 | 37 | if (a.cloudProvider < b.cloudProvider) return -1 38 | if (a.cloudProvider > b.cloudProvider) return 1 39 | if (a.providerModelId < b.providerModelId) return -1 40 | if (a.providerModelId > b.providerModelId) return 1 41 | return 0 42 | }) 43 | 44 | return ( 45 | 46 | 47 | {}} 49 | icon={ 50 |
51 | 52 | 53 |
54 | } 55 | className={props.className} 56 | /> 57 |
58 | 59 | 60 |
61 | {sortedModels.map( 62 | item => 63 | item && ( 64 | onRegenerateFromModel(item.providerModelId)} 68 | onMouseEnter={() => setHoveredModel(item.providerModelId)} 69 | onMouseLeave={() => setHoveredModel(null)} 70 | > 71 |
72 |
{item.label}
73 |
74 | {item.cloudProvider} 75 |
76 |
77 | 78 | {hoveredModel === item.providerModelId && ( 79 | 80 | )} 81 | {currentModel === item.providerModelId && 82 | hoveredModel !== item.providerModelId && ( 83 | 84 | )} 85 |
86 | ) 87 | )} 88 |
89 |
90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /components/messages/regenerate-message.tsx: -------------------------------------------------------------------------------- 1 | import { Repeat } from "lucide-react"; 2 | import { FC, HTMLAttributes } from "react"; 3 | import { ACTION_ICON_SIZE, MessageActionButton } from "./message-action-button"; 4 | 5 | interface RegenerateMessageProps extends HTMLAttributes { 6 | onRegenerate: () => void; 7 | } 8 | 9 | export const RegenerateMessage: FC = ({ onRegenerate, ...props }) => { 10 | return ( 11 | } 14 | className={props.className} 15 | /> 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/messages/user-message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SelectMessage } from "@/db/schema/messages"; 4 | import { cn } from "@/lib/utils"; 5 | import { Pencil } from "lucide-react"; 6 | import { FC, HTMLAttributes, useEffect, useRef, useState } from "react"; 7 | import ReactTextareaAutosize from "react-textarea-autosize"; 8 | import { Button } from "../ui/button"; 9 | import { MessageMarkdown } from "./message-markdown"; 10 | 11 | interface UserMessageProps extends HTMLAttributes { 12 | message: SelectMessage; 13 | onSubmitEdit: (editedMessage: SelectMessage) => void; 14 | } 15 | 16 | export const UserMessage: FC = ({ message, onSubmitEdit, ...props }) => { 17 | const inputRef = useRef(null); 18 | 19 | const [isHovering, setIsHovering] = useState(false); 20 | const [isEditing, setIsEditing] = useState(false); 21 | const [inputValue, setInputValue] = useState(message.content); 22 | 23 | const handleSubmitEdit = () => { 24 | onSubmitEdit({ ...message, content: inputValue }); 25 | setIsEditing(false); 26 | }; 27 | 28 | const handleKeyDown = (event: React.KeyboardEvent) => { 29 | if (!event.shiftKey && event.key === "Enter") { 30 | event.preventDefault(); 31 | onSubmitEdit({ ...message, content: inputValue }); 32 | } else if (event.key === "Escape") { 33 | setIsEditing(false); 34 | } 35 | }; 36 | 37 | useEffect(() => { 38 | if (isEditing) { 39 | inputRef.current?.focus(); 40 | inputRef.current?.setSelectionRange(inputRef.current.value.length, inputRef.current.value.length); 41 | } 42 | }, [isEditing]); 43 | 44 | return ( 45 |
setIsHovering(true)} 48 | onMouseLeave={() => setIsHovering(false)} 49 | > 50 | {isHovering && !isEditing && ( 51 | { 54 | setInputValue(message.content); 55 | setIsEditing(true); 56 | }} 57 | /> 58 | )} 59 | 60 | {isEditing ? ( 61 |
62 | setInputValue(e.target.value)} 68 | minRows={3} 69 | onKeyDown={handleKeyDown} 70 | /> 71 | 72 |
73 | 80 | 81 | 87 |
88 |
89 | ) : ( 90 |
91 | 92 |
93 | )} 94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /components/onboarding-message.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { updateProfile } from "@/db/queries/profiles" 4 | import { AppContext } from "@/lib/context/app-context" 5 | import { FC, useContext } from "react" 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogHeader, 11 | DialogTitle 12 | } from "./ui/dialog" 13 | 14 | interface OnboardingMessageProps {} 15 | 16 | export const OnboardingMessage: FC = () => { 17 | const { profile, setProfile } = useContext(AppContext) 18 | 19 | const handleOnboarding = async () => { 20 | if (!profile) return 21 | setProfile({ ...profile, hasOnboarded: true }) 22 | await updateProfile(profile.id, { hasOnboarded: true }) 23 | } 24 | 25 | if (!profile) return null 26 | 27 | return ( 28 | 29 | 30 | 31 | Welcome 32 | 33 | To start, you will select 10 arena messages to adapt your router to 34 | your preferences. 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/router-progress-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AppContext } from "@/lib/context/app-context" 4 | import { cn } from "@/lib/utils" 5 | import { FC, HTMLAttributes, useContext } from "react" 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogHeader, 11 | DialogTitle 12 | } from "./ui/dialog" 13 | import { Progress } from "./ui/progress" 14 | 15 | export const MAX_ROUTER_PROGRESS = 10 16 | 17 | interface RouterProgressBarProps extends HTMLAttributes {} 18 | 19 | export const RouterProgressBar: FC = ({ ...props }) => { 20 | const { profile, setProfile, setIsArenaModeActive } = useContext(AppContext) 21 | 22 | if (!profile) return null 23 | 24 | return ( 25 | <> 26 |
27 | {profile.routerProgress < 10 && ( 28 | <> 29 | 0 ? profile.routerProgress * 10 : 1 32 | } 33 | /> 34 |
35 | Remaining Arena Selections:{" "} 36 | {MAX_ROUTER_PROGRESS - profile.routerProgress} 37 |
38 | 39 | )} 40 |
41 | 42 | { 45 | setProfile({ ...profile, routerProgress: profile.routerProgress + 1 }) 46 | setIsArenaModeActive(false) 47 | }} 48 | > 49 | 50 | 51 | Router Complete 52 | 53 | You have completed the router. You can now start chatting with 54 | your router. 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/settings-bar/settings-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { updateProfile } from "@/db/queries/profiles" 4 | import { AppContext } from "@/lib/context/app-context" 5 | import { ndLLMProviders } from "@/lib/not-diamond/not-diamond-config" 6 | import { cn } from "@/lib/utils" 7 | import { Settings } from "lucide-react" 8 | import { FC, HTMLAttributes, useContext } from "react" 9 | import { Button } from "../ui/button" 10 | import { Checkbox } from "../ui/checkbox" 11 | import { 12 | Sheet, 13 | SheetContent, 14 | SheetDescription, 15 | SheetHeader, 16 | SheetTitle, 17 | SheetTrigger 18 | } from "../ui/sheet" 19 | import { Switch } from "../ui/switch" 20 | import { 21 | Tooltip, 22 | TooltipContent, 23 | TooltipProvider, 24 | TooltipTrigger 25 | } from "../ui/tooltip" 26 | 27 | interface SettingsBarProps extends HTMLAttributes {} 28 | 29 | export const SettingsBar: FC = ({ ...props }) => { 30 | const { isArenaModeActive, setIsArenaModeActive, profile, setProfile } = 31 | useContext(AppContext) 32 | 33 | const handleArenaModeChange = () => { 34 | setIsArenaModeActive(!isArenaModeActive) 35 | } 36 | 37 | const handleModelChange = async (model: string) => { 38 | if (!profile) return 39 | 40 | const newModels = profile.activeModels.includes(model) 41 | ? profile.activeModels.filter(m => m !== model) 42 | : [...profile.activeModels, model] 43 | setProfile({ ...profile, activeModels: newModels }) 44 | await updateProfile(profile.id, { 45 | ...profile, 46 | activeModels: newModels 47 | }) 48 | } 49 | 50 | if (!profile) return 51 | 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | Open sidepanel 63 | 64 | 65 | 66 | 67 | 68 | 69 | Settings 70 | Customize your experience. 71 | 72 | 73 |
74 |
75 | 79 |
Arena Mode
80 |
81 | 82 |
83 |
Active Models
84 | 85 | {ndLLMProviders.map(item => ( 86 |
90 | 93 | handleModelChange(item.providerModelId) 94 | } 95 | /> 96 |
{item.label}
97 |
98 | ))} 99 |
100 |
101 |
102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", 13 | secondary: 14 | "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", 15 | destructive: 16 | "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>