Added the start of a demo

This commit is contained in:
Glen Maddern 2025-06-06 21:05:51 +10:00
commit 4b9a0a26e6
23 changed files with 7328 additions and 0 deletions

11
demo/src/App.tsx Normal file
View file

@ -0,0 +1,11 @@
import ChatApp from "./components/ChatApp";
function App() {
return (
<>
<ChatApp />
</>
);
}
export default App;

View file

@ -0,0 +1,122 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X, Eye, EyeOff } from 'lucide-react'
import { type ModelProvider } from '../types/models'
interface ApiKeyModalProps {
isOpen: boolean
onClose: () => void
provider: ModelProvider
onSave: (apiKey: string) => void
}
const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
isOpen,
onClose,
provider,
onSave,
}) => {
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey.trim()) {
onSave(apiKey.trim())
setApiKey('')
onClose()
}
}
const handleClose = () => {
setApiKey('')
onClose()
}
const modalContent = (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleClose}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-zinc-900">
{provider.logo} {provider.name} API Key
</h2>
<button
onClick={handleClose}
className="text-zinc-400 hover:text-zinc-600 p-1"
>
<X size={20} />
</button>
</div>
<p className="text-sm text-zinc-600 mb-4">
Enter your {provider.name} API key to use this model. Your key will be stored locally in your browser.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="apiKey" className="block text-sm font-medium text-zinc-700 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
id="apiKey"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent pr-10"
placeholder={`Enter your ${provider.name} API key`}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<a
href={provider.documentationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800"
>
Get API key →
</a>
<div className="flex gap-2">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm text-zinc-600 hover:text-zinc-800"
>
Cancel
</button>
<button
type="submit"
disabled={!apiKey.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-zinc-300 disabled:cursor-not-allowed"
>
Save
</button>
</div>
</div>
</form>
</div>
</div>
)
return createPortal(modalContent, document.body)
}
export default ApiKeyModal

View file

@ -0,0 +1,138 @@
import React, { useState, useEffect } from "react";
import ChatSidebar from "./ChatSidebar.tsx";
import ChatNavbar from "./ChatNavbar.tsx";
import ConversationThread from "./ConversationThread.tsx";
import { storeName } from "../consts.ts";
import { type Conversation } from "../types";
import { useIndexedDB } from "../hooks/useIndexedDB";
import { type Model } from "../types/models";
import { getSelectedModel, setSelectedModel as saveSelectedModel } from "../utils/modelPreferences";
import { type IDBPDatabase } from "idb";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ChatAppProps {}
const ChatApp: React.FC<ChatAppProps> = () => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [conversationId, setConversationId] = useState<number | undefined>(
undefined
);
const [sidebarVisible, setSidebarVisible] = useState<boolean>(true);
const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel());
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0);
const db = useIndexedDB();
const handleApiKeyUpdate = () => {
setApiKeyUpdateTrigger(prev => prev + 1);
};
const handleModelChange = (model: Model) => {
setSelectedModel(model);
saveSelectedModel(model);
};
// set up conversations on app load
useEffect(() => {
getConversations();
deleteUnusedConversations();
startNewConversation();
}, [db]);
// Initialize sidebar visibility based on screen size
useEffect(() => {
const isMobile = window.matchMedia("(max-width: 768px)").matches;
setSidebarVisible(!isMobile);
}, []);
const getConversations = async () => {
if (!db) return;
const conversations = (await db.getAll(storeName)) as Conversation[];
const inverseConversations = conversations.reverse();
setConversations(inverseConversations);
};
const deleteConversation = async (id: number, showPromptToUser = true) => {
try {
if (
showPromptToUser &&
!window.confirm("Are you sure you want to delete this conversation?")
) {
return;
}
await db?.delete(storeName, id);
setConversations((prev) => prev.filter((conv) => conv.id !== id));
setConversationId(conversations[0]?.id);
} catch (error) {
console.error("Failed to delete conversation:", error);
}
};
const editConversationTitle = async (id: number, newTitle: string) => {
const conversation = (await db!.get(storeName, id)) as Conversation;
conversation.title = newTitle;
await db!.put(storeName, conversation);
setConversations((prev) =>
prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv))
);
};
const startNewConversation = async () => {
//create unique id for new conversation
setConversationId(Date.now() + Math.floor(Math.random() * 1000));
if (window.matchMedia("(max-width: 768px)").matches) {
setSidebarVisible(false);
}
};
// delete conversations with no messages
const deleteUnusedConversations = async () => {
if (!db) return;
const conversations = (await db.getAll(storeName)) as Conversation[];
const unusedConversations = conversations.filter(
(conversation) => conversation.messages.length === 0
);
for (const conversation of unusedConversations) {
deleteConversation(conversation.id as number, false);
}
};
return (
<div className="flex h-dvh w-screen overflow-clip bg-white">
<div className="flex flex-row flex-grow flex-1 overflow-hidden relative">
<ChatSidebar
sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible}
conversations={conversations}
conversationId={conversationId}
setConversationId={setConversationId}
deleteConversation={deleteConversation}
editConversationTitle={editConversationTitle}
startNewConversation={startNewConversation}
selectedModel={selectedModel}
onModelChange={handleModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/>
<div className="flex flex-col flex-grow h-full w-[calc(100%-16rem)]">
<ChatNavbar
sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible}
/>
<ConversationThread
conversations={conversations}
conversationId={conversationId}
setConversationId={setConversationId}
setConversations={setConversations}
db={db as IDBPDatabase}
selectedModel={selectedModel}
onApiKeyUpdate={handleApiKeyUpdate}
/>
</div>
</div>
</div>
);
};
export default ChatApp;

View file

@ -0,0 +1,63 @@
import React, { FormEvent, KeyboardEvent, useRef, useEffect, Dispatch, SetStateAction } from 'react'
import { Send, Pause } from 'lucide-react'
interface ChatInputProps {
input: string
setInput: Dispatch<SetStateAction<string>>
handleSubmit: (e: FormEvent) => void
isLoading: boolean
streamStarted: boolean
controller: AbortController
messagesCount: number
}
const ChatInput: React.FC<ChatInputProps> = ({ input, setInput, handleSubmit, isLoading, streamStarted, controller, messagesCount }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e as unknown as FormEvent)
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
setInput((prev: string) => prev + '\n')
}
}
return (
<div className="relative rounded-lg border border-zinc-200 bg-white shadow-sm">
<form onSubmit={handleSubmit} className={`w-full items-center`}>
<div className="flex justify-end items-center">
<textarea
ref={textareaRef}
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full flex-1 p-3 bg-transparent resize-none focus:outline-none"
placeholder={messagesCount === 0 ? 'Ask anything...' : 'Type your message...'}
style={{ maxHeight: '200px' }}
/>
<button
type={isLoading && streamStarted ? 'button' : 'submit'}
onClick={isLoading && streamStarted ? () => controller.abort() : undefined}
className="m-2 hover:bg-zinc-100 rounded-lg p-2 text-zinc-600 hover:text-zinc-900"
disabled={!isLoading && !input.trim()}
>
{isLoading && streamStarted ? <Pause size={20} /> : <Send size={20} />}
</button>
</div>
</form>
</div>
)
}
export default ChatInput

View file

@ -0,0 +1,46 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { type Message } from "../types";
interface ChatMessageProps {
message: Message;
}
const ChatMessage: React.FC<ChatMessageProps> = ({
message,
}) => {
return (
<div
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`
${
message.role === "user"
? "max-w-[80%] rounded-2xl px-3 py-2 border border-zinc-200/10 bg-zinc-100 text-black"
: " text-zinc-900 w-full"
}`}
>
<div className="prose prose-zinc">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
table: ({ children }) => (
<div className="overflow-x-scroll text-sm">{children}</div>
),
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
</div>
);
};
export default ChatMessage;

View file

@ -0,0 +1,39 @@
import React from "react";
import { PanelLeftOpen } from "lucide-react";
interface ChatNavbarProps {
sidebarVisible: boolean;
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
const ChatNavbar: React.FC<ChatNavbarProps> = ({
sidebarVisible,
setSidebarVisible,
}) => {
return (
<div className="sticky top-0 bg-white border-b border-zinc-200 z-10">
<div className="my-3 mx-2 flex items-center justify-between">
<div className="flex items-center">
{!sidebarVisible && (
<div>
<button
onClick={() => setSidebarVisible(!sidebarVisible)}
className="
rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
mr-2 transition-colors text-zinc-600 hover:text-zinc-800"
>
<PanelLeftOpen size={20} />
</button>
</div>
)}
<h1 className="text-base font-semibold text-zinc-600 ml-2">
AI Chat Template
</h1>
</div>
</div>
</div>
);
};
export default ChatNavbar;

View file

@ -0,0 +1,172 @@
import React from "react";
import {
Edit,
Trash2,
PanelLeftClose,
PanelLeftOpen,
SquarePen,
} from "lucide-react";
import ModelSelector from "./ModelSelector";
import { type Model } from "../types/models";
import { type Message } from "../types";
interface Conversation {
id?: number;
title: string;
messages: Message[];
}
interface ChatSidebarProps {
sidebarVisible: boolean;
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
conversations: Conversation[];
conversationId: number | undefined;
setConversationId: React.Dispatch<React.SetStateAction<number | undefined>>;
deleteConversation: (id: number) => void;
editConversationTitle: (id: number, newTitle: string) => void;
startNewConversation: () => void;
selectedModel: Model;
onModelChange: (model: Model) => void;
apiKeyUpdateTrigger: number;
}
const ChatSidebar: React.FC<ChatSidebarProps> = ({
sidebarVisible,
setSidebarVisible,
conversations,
conversationId,
setConversationId,
deleteConversation,
editConversationTitle,
startNewConversation,
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
}) => {
const handleConversationClick = (id: number | undefined) => {
setConversationId(id);
if (window.matchMedia("(max-width: 768px)").matches) {
setSidebarVisible(false);
}
};
return (
<>
{/* Mobile overlay */}
{sidebarVisible && (
<div
className="md:hidden fixed inset-0 bg-black/30 z-20"
onClick={() => setSidebarVisible(false)}
/>
)}
<div
className={`
fixed md:relative
z-30 md:z-auto
h-full
bg-zinc-50
transition-all duration-300
${
sidebarVisible
? "w-64 translate-x-0"
: "w-0 -translate-x-full md:translate-x-0"
}
`}
>
<div
className={`m-2 flex items-center justify-between
${sidebarVisible ? "sticky" : "hidden"}
`}
>
<button
onClick={() => setSidebarVisible(!sidebarVisible)}
className="
rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
transition-colors text-zinc-600 hover:text-zinc-800"
>
{sidebarVisible ? (
<PanelLeftClose size={20} />
) : (
<PanelLeftOpen size={20} />
)}
</button>
<button
className="rounded-lg p-[0.4em]
hover:bg-zinc-100 hover:cursor-pointer
transition-colors text-zinc-600 hover:text-zinc-800"
onClick={startNewConversation}
>
<SquarePen size={20} />
</button>
</div>
<div
className=" h-[calc(100%-3rem)]
overflow-y-scroll scrollbar-thin
flex flex-col justify-between border-r border-zinc-200 transition-all duration-300"
>
<div className="flex flex-col">
<ul className="p-2 space-y-1">
{conversations.map((conversation) => (
<li
key={conversation.id}
className={`cursor-pointer p-2 transition-colors rounded-lg ${
conversation.id === conversationId ||
(!conversationId && !conversation.id)
? "bg-zinc-200 text-zinc-900"
: "hover:bg-zinc-200 text-zinc-600"
}`}
onClick={() => handleConversationClick(conversation.id)}
>
<div className="flex items-center justify-between">
<span className="truncate flex-grow text-sm">
{conversation.title}
</span>
<div className="flex space-x-2 ml-2">
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
const newTitle = prompt(
"Enter new title:",
conversation.title
);
if (newTitle)
editConversationTitle(conversation.id!, newTitle);
}}
>
<Edit size={14} />
</button>
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteConversation(conversation.id!);
setConversationId(undefined);
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</div>
{/* Model Selector at bottom */}
<div className="p-2 border-t border-zinc-200">
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/>
</div>
</div>
</div>
</>
);
};
export default ChatSidebar;

View file

@ -0,0 +1,219 @@
import React, { useState, useEffect, FormEvent } from 'react'
import { storeName } from '../consts'
import '../styles/scrollbar.css'
import '../styles/github.css'
import { type Conversation, type Message } from '../types'
import { type Model } from '../types/models'
import { type IDBPDatabase } from 'idb'
import ChatMessage from './ChatMessage'
import ChatInput from './ChatInput'
import { useAutoscroll } from '../hooks/useAutoscroll'
import { useStreamResponse } from '../hooks/useStreamResponse'
import { setApiKey } from '../utils/apiKeys'
import ApiKeyModal from './ApiKeyModal'
interface ConversationThreadProps {
conversations: Conversation[]
conversationId?: number
setConversationId: (id: number) => void
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
db: IDBPDatabase
selectedModel: Model
onApiKeyUpdate: () => void
}
const ConversationThread: React.FC<ConversationThreadProps> = ({
conversations,
conversationId,
setConversationId,
setConversations,
db,
selectedModel,
onApiKeyUpdate,
}) => {
const [input, setInput] = useState<string>('')
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const { messagesEndRef, messagesContainerRef, scrollToBottom } = useAutoscroll()
const handleApiKeyRequired = async (model: Model): Promise<boolean> => {
return new Promise((resolve) => {
setApiKeyModal({
isOpen: true,
model,
})
// Store the resolve function to call when modal is closed
window.apiKeyModalResolve = resolve
})
}
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyModal({ isOpen: false, model: null })
// Notify parent that API key was updated
onApiKeyUpdate()
// Resolve the promise to continue with the request
if (window.apiKeyModalResolve) {
window.apiKeyModalResolve(true)
delete window.apiKeyModalResolve
}
}
}
const handleApiKeyCancel = () => {
setApiKeyModal({ isOpen: false, model: null })
// Resolve the promise with false to cancel the request
if (window.apiKeyModalResolve) {
window.apiKeyModalResolve(false)
delete window.apiKeyModalResolve
}
}
const { isLoading, setIsLoading, streamStarted, controller, streamResponse, aiResponseRef } = useStreamResponse({
conversationId,
setConversations,
scrollToBottom,
selectedModel,
onApiKeyRequired: handleApiKeyRequired,
})
const currentConversation = conversations.find((conv) => conv.id === conversationId) || { messages: [], title: '' }
//when new message chunks are streamed in, scroll to bottom
useEffect(() => {
scrollToBottom()
}, [aiResponseRef.current])
//when conversation changes, scroll to bottom
useEffect(scrollToBottom, [conversationId])
//when conversation changes, reset input
useEffect(() => {
setInput('')
}, [conversationId])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!input.trim()) return
if (currentConversation.messages.length === 0) {
setConversations((prev) => {
const updated = [...prev]
updated.unshift({
id: conversationId,
title: 'New conversation',
messages: [],
})
return updated
})
}
const userMessage: Message = { role: 'user', content: input }
setInput('')
setIsLoading(true)
setConversations((prev) => {
const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId)
if (conv && conv.messages?.[conv.messages.length - 1] !== userMessage) {
conv.messages.push(userMessage)
}
return updated
})
await streamResponse([...currentConversation.messages, userMessage])
setIsLoading(false)
}
const storeMessages = async () => {
if (!currentConversation.messages || currentConversation.messages.length === 0) {
return
}
const store = db.transaction(storeName, 'readwrite').objectStore(storeName)
const objectData = {
id: conversationId,
title: currentConversation.title,
messages: currentConversation.messages,
}
const value = await store.put(objectData)
setConversationId(Number(value))
}
useEffect(() => {
if (db && conversationId) {
storeMessages()
}
}, [conversations])
console.log({ currentConversation })
return (
<div className={`flex flex-col h-[calc(100%-3rem)] w-full ${currentConversation.messages.length === 0 ? 'justify-center' : ''}`}>
<div
ref={messagesContainerRef}
className={`
overflow-x-hidden
${currentConversation.messages.length === 0 ? 'flex items-center justify-center pb-6' : 'flex-1 overflow-y-scroll'}`}
>
<div className="max-w-2xl mx-auto w-full px-4">
{currentConversation.messages.length === 0 ? (
<div className="text-center">
<h1 className="text-4xl font-semibold text-zinc-800">What do you want to know?</h1>
<div className="mt-4">
<h2 className="mt-2 text-md opacity-70">
AI chat template built with React, Vite and Cloudflare Workers AI.
<div className="mt-1 w-full">
Find the source code on{' '}
<a className="text-blue-700" href="https://github.com/thomasgauvin/ai-chat-template">
GitHub
</a>
.
</div>
</h2>
</div>
</div>
) : (
<div className="py-4 px-4 space-y-4">
{currentConversation.messages.map((message, index) => (
<ChatMessage key={index} message={message} />
))}
{isLoading && !streamStarted && <div className="text-center text-sm text-zinc-600">Thinking...</div>}
<div ref={messagesEndRef} />
</div>
)}
</div>
</div>
<div className={`p-4 ${currentConversation.messages.length === 0 ? 'pb-35' : ''}`}>
<div className="max-w-2xl mx-auto">
<ChatInput
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
isLoading={isLoading}
streamStarted={streamStarted}
controller={controller}
messagesCount={currentConversation.messages.length}
/>
</div>
</div>
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={handleApiKeyCancel}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
onSave={handleApiKeySave}
/>
</div>
)
}
export default ConversationThread

View file

@ -0,0 +1,146 @@
import React, { useState, useEffect, useRef } from 'react'
import { ChevronDown, AlertCircle, X } from 'lucide-react'
import { availableModels, type Model } from '../types/models'
import { hasApiKey, setApiKey, clearApiKey } from '../utils/apiKeys'
import ApiKeyModal from './ApiKeyModal'
interface ModelSelectorProps {
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
}
const ModelSelector: React.FC<ModelSelectorProps> = ({
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach(model => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
}, [apiKeyUpdateTrigger])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const handleModelSelect = (model: Model) => {
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
} else {
onModelChange(model)
}
setIsOpen(false)
}
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses(prev => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
}
}
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
setApiKeyStatuses(prev => ({ ...prev, [providerId]: false }))
}
const getApiKeyIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
if (hasKey) {
return (
<button
onClick={(e) => handleClearApiKey(providerId, e)}
className="text-zinc-400 hover:text-red-500 p-1"
title="Clear token"
>
<X size={14} />
</button>
)
} else {
return (
<div className="text-red-500 p-1" title="API Key not found">
<AlertCircle size={14} />
</div>
)
}
}
return (
<>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-3 text-left bg-white border border-zinc-200 rounded-lg hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<div className="flex items-center gap-2">
<span className="text-lg">{selectedModel.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{selectedModel.name}</div>
<div className="text-xs text-zinc-500">{selectedModel.provider.name}</div>
</div>
</div>
<div className="flex items-center gap-1">
{getApiKeyIcon(selectedModel.provider.id)}
<ChevronDown size={16} className={`text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</div>
</button>
{isOpen && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white border border-zinc-200 rounded-lg shadow-lg z-50">
{availableModels.map((model) => (
<button
key={model.id}
onClick={() => handleModelSelect(model)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-zinc-50 first:rounded-t-lg last:rounded-b-lg"
>
<div className="flex items-center gap-2">
<span className="text-lg">{model.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-xs text-zinc-500">{model.provider.name}</div>
</div>
</div>
{getApiKeyIcon(model.provider.id)}
</button>
))}
</div>
)}
</div>
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
onSave={handleApiKeySave}
/>
</>
)
}
export default ModelSelector

2
demo/src/consts.ts Normal file
View file

@ -0,0 +1,2 @@
export const storeName = "conversations";
export const dbName = "localgpt";

View file

@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
export const useAutoscroll = () => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
const isAtBottom =
container.scrollHeight - container.clientHeight - container.scrollTop <=
200;
shouldAutoScrollRef.current = isAtBottom;
};
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
const scrollToBottom = () => {
if (shouldAutoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
};
return {
messagesEndRef,
messagesContainerRef,
scrollToBottom,
};
};

View file

@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
import { openDB, IDBPDatabase } from "idb";
import { dbName, storeName } from "../consts";
export function useIndexedDB() {
const [db, setDb] = useState<IDBPDatabase | null>(null);
useEffect(() => {
const initDb = async () => {
const db = await openDB(dbName, 1, {
upgrade(db) {
db.createObjectStore(storeName, {
keyPath: "id",
autoIncrement: true,
});
},
});
setDb(db);
};
initDb();
}, []);
return db;
}

View file

@ -0,0 +1,153 @@
import { useState, useRef } from 'react'
import { streamText } from 'ai'
import { createGroq } from '@ai-sdk/groq'
import { createAnthropic } from '@ai-sdk/anthropic'
import { type Message, type Conversation } from '../types'
import { type Model } from '../types/models'
import { getApiKey } from '../utils/apiKeys'
interface UseStreamResponseProps {
conversationId?: number
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
scrollToBottom: () => void
selectedModel: Model
onApiKeyRequired: (model: Model) => Promise<boolean>
}
export const useStreamResponse = ({
conversationId,
setConversations,
scrollToBottom,
selectedModel,
onApiKeyRequired,
}: UseStreamResponseProps) => {
const [isLoading, setIsLoading] = useState(false)
const [streamStarted, setStreamStarted] = useState(false)
const [controller, setController] = useState(new AbortController())
const aiResponseRef = useRef<string>('')
const getModelInstance = (model: Model, apiKey: string) => {
switch (model.provider.id) {
case 'groq': {
const groqProvider = createGroq({ apiKey })
return groqProvider(model.modelId)
}
case 'anthropic': {
const anthropicProvider = createAnthropic({
apiKey,
headers: {
'anthropic-dangerous-direct-browser-access': 'true',
},
})
return anthropicProvider(model.modelId)
}
default:
throw new Error(`Unsupported provider: ${model.provider.id}`)
}
}
const streamResponse = async (messages: Message[]) => {
let aiResponse = ''
console.log({ messages })
// Check if API key is available
let apiKey = getApiKey(selectedModel.provider.id)
if (!apiKey) {
const keyProvided = await onApiKeyRequired(selectedModel)
if (!keyProvided) {
return // User cancelled
}
apiKey = getApiKey(selectedModel.provider.id)
if (!apiKey) {
throw new Error('No API key provided')
}
}
try {
const modelInstance = getModelInstance(selectedModel, apiKey)
setConversations((prev) => {
const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId)
if (conv) {
console.log('POPPING')
conv.messages.push({ role: 'assistant', content: '' })
}
return updated
})
setStreamStarted(true)
const result = await streamText({
model: modelInstance,
messages: messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
abortSignal: controller.signal,
})
for await (const chunk of result.textStream) {
try {
aiResponse += chunk
aiResponseRef.current = aiResponse
//custom extraction of <chat-title> tag
const titleMatch = aiResponse.match(/<chat-title>(.*?)<\/chat-title>/)
if (titleMatch) {
const title = titleMatch[1].trim()
setConversations((prev) => {
const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId)
if (conv) {
conv.title = title
}
return updated
})
aiResponse = aiResponse.replace(/<chat-title>.*?<\/chat-title>/, '').trim()
}
setConversations((prev) => {
const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId)
if (conv) {
conv.messages[conv.messages.length - 1].content = aiResponse
}
return updated
})
scrollToBottom()
} catch (e) {
console.log('Error in text chunk', e)
}
}
} catch (error: unknown) {
if (controller.signal.aborted) {
console.log('Stream aborted')
} else {
console.error('Error generating response:', error)
// Remove the assistant message if there was an error
setConversations((prev) => {
const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId)
if (conv && conv.messages[conv.messages.length - 1].role === 'assistant') {
conv.messages.pop()
}
return updated
})
}
} finally {
setStreamStarted(false)
setController(new AbortController())
}
return aiResponse
}
return {
isLoading,
setIsLoading,
streamStarted,
controller,
streamResponse,
aiResponseRef,
}
}

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from "react";
import type { Theme } from "../types";
export function useTheme() {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem("theme") as Theme) || "system"
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
const effectiveTheme =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
root.classList.add(effectiveTheme);
if (theme === "system") {
localStorage.removeItem("theme")
} else {
localStorage.setItem("theme", theme)
}
}, [theme]);
return [theme, setTheme] as const;
}

3
demo/src/index.css Normal file
View file

@ -0,0 +1,3 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@config "../tailwind.config.js";

10
demo/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

119
demo/src/styles/github.css Normal file
View file

@ -0,0 +1,119 @@
.light {
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
code.hljs {
padding: 3px 5px;
}
/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/
.hljs {
color: #24292e;
background: #ffffff;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #d73a49;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #005cc5;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #032f62;
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #e36209;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #6a737d;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #22863a;
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #24292e;
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #005cc5;
font-weight: bold;
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #735c0f;
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #24292e;
font-style: italic;
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #24292e;
font-weight: bold;
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #22863a;
background-color: #f0fff4;
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #b31d28;
background-color: #ffeef0;
}
.hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
/* purposely ignored */
}
}

View file

@ -0,0 +1,37 @@
/* Light theme scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Dark theme scrollbar */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: #27272a; /* zinc-800 */
}
.dark ::-webkit-scrollbar-thumb {
background: #52525b; /* zinc-600 */
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #71717a; /* zinc-500 */
}

12
demo/src/types/index.ts Normal file
View file

@ -0,0 +1,12 @@
export interface Message {
role: "user" | "system" | "assistant";
content: string;
}
export interface Conversation {
id?: number;
title: string;
messages: Message[];
}
export type Theme = "light" | "dark" | "system";

49
demo/src/types/models.ts Normal file
View file

@ -0,0 +1,49 @@
export interface ModelProvider {
id: string
name: string
baseUrl: string
apiKeyHeader: string
documentationUrl: string
logo?: string
}
export interface Model {
id: string
name: string
provider: ModelProvider
modelId: string
}
export const providers: Record<string, ModelProvider> = {
groq: {
id: 'groq',
name: 'Groq',
baseUrl: 'https://api.groq.com/openai/v1',
apiKeyHeader: 'Authorization',
documentationUrl: 'https://console.groq.com/docs',
logo: '🚀'
},
anthropic: {
id: 'anthropic',
name: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
apiKeyHeader: 'x-api-key',
documentationUrl: 'https://docs.anthropic.com/',
logo: '🤖'
}
}
export const availableModels: Model[] = [
{
id: 'llama-3.3-70b-versatile',
name: 'Llama 3.3 70B Versatile',
provider: providers.groq,
modelId: 'llama-3.3-70b-versatile'
},
{
id: 'claude-3-5-sonnet-20241022',
name: 'Claude 4 Sonnet',
provider: providers.anthropic,
modelId: 'claude-3-5-sonnet-20241022'
}
]

Some files were not shown because too many files have changed in this diff Show more