Copying chat example across
This commit is contained in:
parent
de9e20bf89
commit
f5f1943ac5
24 changed files with 7675 additions and 0 deletions
11
examples/chat-ui/src/App.tsx
Normal file
11
examples/chat-ui/src/App.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import ChatApp from "./components/ChatApp";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<ChatApp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
122
examples/chat-ui/src/components/ApiKeyModal.tsx
Normal file
122
examples/chat-ui/src/components/ApiKeyModal.tsx
Normal 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
|
||||
138
examples/chat-ui/src/components/ChatApp.tsx
Normal file
138
examples/chat-ui/src/components/ChatApp.tsx
Normal 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)] lg:w-[calc(100%-32rem)]">
|
||||
<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;
|
||||
63
examples/chat-ui/src/components/ChatInput.tsx
Normal file
63
examples/chat-ui/src/components/ChatInput.tsx
Normal 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
|
||||
46
examples/chat-ui/src/components/ChatMessage.tsx
Normal file
46
examples/chat-ui/src/components/ChatMessage.tsx
Normal 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;
|
||||
39
examples/chat-ui/src/components/ChatNavbar.tsx
Normal file
39
examples/chat-ui/src/components/ChatNavbar.tsx
Normal 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;
|
||||
174
examples/chat-ui/src/components/ChatSidebar.tsx
Normal file
174
examples/chat-ui/src/components/ChatSidebar.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
SquarePen,
|
||||
} from "lucide-react";
|
||||
import ModelSelector from "./ModelSelector";
|
||||
import { McpServers } from "./McpServers";
|
||||
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 lg:w-[32rem] 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 and MCP Servers at bottom */}
|
||||
<div className="p-2 border-t border-zinc-200 space-y-3">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
||||
/>
|
||||
<McpServers />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatSidebar;
|
||||
219
examples/chat-ui/src/components/ConversationThread.tsx
Normal file
219
examples/chat-ui/src/components/ConversationThread.tsx
Normal 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
|
||||
341
examples/chat-ui/src/components/McpServers.tsx
Normal file
341
examples/chat-ui/src/components/McpServers.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useMcp, type Tool } from 'use-mcp/react'
|
||||
import { Settings, Info } from 'lucide-react'
|
||||
|
||||
// MCP Connection wrapper that only renders when active
|
||||
function McpConnection({
|
||||
serverUrl,
|
||||
onConnectionUpdate,
|
||||
}: {
|
||||
serverUrl: string
|
||||
onConnectionUpdate: (data: any) => void
|
||||
}) {
|
||||
// Use the MCP hook with the server URL
|
||||
const connection = useMcp({
|
||||
url: serverUrl,
|
||||
debug: true,
|
||||
autoRetry: false,
|
||||
popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes',
|
||||
})
|
||||
|
||||
// Update parent component with connection data
|
||||
useEffect(() => {
|
||||
onConnectionUpdate(connection)
|
||||
}, [
|
||||
connection.state,
|
||||
connection.tools,
|
||||
connection.error,
|
||||
connection.log.length,
|
||||
connection.authUrl,
|
||||
])
|
||||
|
||||
// Return null as this is just a hook wrapper
|
||||
return null
|
||||
}
|
||||
|
||||
export function McpServers({
|
||||
onToolsUpdate,
|
||||
}: {
|
||||
onToolsUpdate?: (tools: Tool[]) => void
|
||||
}) {
|
||||
const [serverUrl, setServerUrl] = useState(() => {
|
||||
return sessionStorage.getItem('mcpServerUrl') || ''
|
||||
})
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [connectionData, setConnectionData] = useState<any>({
|
||||
state: 'not-connected',
|
||||
tools: [],
|
||||
error: undefined,
|
||||
log: [],
|
||||
authUrl: undefined,
|
||||
retry: () => {},
|
||||
disconnect: () => {},
|
||||
authenticate: () => Promise.resolve(undefined),
|
||||
callTool: (_name: string, _args?: Record<string, unknown>) =>
|
||||
Promise.resolve(undefined),
|
||||
clearStorage: () => {},
|
||||
})
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Extract connection properties
|
||||
const { state, tools, error, log, authUrl, disconnect, authenticate } =
|
||||
connectionData
|
||||
|
||||
// Notify parent component when tools change
|
||||
useEffect(() => {
|
||||
if (onToolsUpdate && tools.length > 0) {
|
||||
onToolsUpdate(
|
||||
tools.map((t: Tool) => ({
|
||||
...t,
|
||||
callTool: (args: Record<string, unknown>) =>
|
||||
connectionData.callTool(t.name, args),
|
||||
})),
|
||||
)
|
||||
}
|
||||
}, [tools, onToolsUpdate])
|
||||
|
||||
// Handle connection
|
||||
const handleConnect = () => {
|
||||
if (!serverUrl.trim()) return
|
||||
setIsActive(true)
|
||||
}
|
||||
|
||||
// Handle disconnection
|
||||
const handleDisconnect = () => {
|
||||
disconnect()
|
||||
setIsActive(false)
|
||||
setConnectionData({
|
||||
state: 'not-connected',
|
||||
tools: [],
|
||||
error: undefined,
|
||||
log: [],
|
||||
authUrl: undefined,
|
||||
retry: () => {},
|
||||
disconnect: () => {},
|
||||
authenticate: () => Promise.resolve(undefined),
|
||||
callTool: (_name: string, _args?: Record<string, unknown>) =>
|
||||
Promise.resolve(undefined),
|
||||
clearStorage: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle authentication if popup was blocked
|
||||
const handleManualAuth = async () => {
|
||||
try {
|
||||
await authenticate()
|
||||
} catch (err) {
|
||||
console.error('Authentication error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll log to bottom
|
||||
useEffect(() => {
|
||||
if (logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight
|
||||
}
|
||||
}, [log])
|
||||
|
||||
// Generate status badge based on connection state
|
||||
const getStatusBadge = () => {
|
||||
const baseClasses = 'px-2 py-1 rounded-full text-xs font-medium'
|
||||
|
||||
switch (state) {
|
||||
case 'discovering':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-blue-100 text-blue-800`}>
|
||||
Discovering
|
||||
</span>
|
||||
)
|
||||
case 'authenticating':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-purple-100 text-purple-800`}>
|
||||
Authenticating
|
||||
</span>
|
||||
)
|
||||
case 'connecting':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>
|
||||
Connecting
|
||||
</span>
|
||||
)
|
||||
case 'loading':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-orange-100 text-orange-800`}>
|
||||
Loading
|
||||
</span>
|
||||
)
|
||||
case 'ready':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-green-100 text-green-800`}>
|
||||
Connected
|
||||
</span>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<span className={`${baseClasses} bg-red-100 text-red-800`}>
|
||||
Failed
|
||||
</span>
|
||||
)
|
||||
case 'not-connected':
|
||||
default:
|
||||
return (
|
||||
<span className={`${baseClasses} bg-gray-100 text-gray-800`}>
|
||||
Not Connected
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the tools to console when they change (for debugging)
|
||||
useEffect(() => {
|
||||
if (tools.length > 0) {
|
||||
console.log('MCP Tools available:', tools)
|
||||
}
|
||||
}, [tools])
|
||||
|
||||
return (
|
||||
<section className="rounded-lg bg-white p-4 border border-zinc-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-semibold">MCP Servers</span>
|
||||
<div className="ml-2">
|
||||
<Info size={14} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md border border-gray-200 p-1 hover:bg-gray-50"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<Settings size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-xs mt-1 mb-3">
|
||||
Connect to Model Context Protocol (MCP) servers to access additional AI
|
||||
capabilities.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-xs">Status:</label>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
{error && state === 'failed' && (
|
||||
<div className="text-xs text-red-600 p-2 bg-red-50 rounded border">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border border-gray-200 rounded text-sm placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
|
||||
placeholder="Enter MCP server URL"
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
setServerUrl(newValue)
|
||||
sessionStorage.setItem('mcpServerUrl', newValue)
|
||||
}}
|
||||
disabled={isActive && state !== 'failed'}
|
||||
/>
|
||||
|
||||
{state === 'ready' ||
|
||||
(isActive && state !== 'not-connected' && state !== 'failed') ? (
|
||||
<button
|
||||
className="w-full px-3 py-2 bg-orange-100 hover:bg-orange-200 text-orange-900 rounded text-sm font-medium"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 px-4 text-sm font-medium disabled:opacity-50"
|
||||
onClick={handleConnect}
|
||||
disabled={isActive || !serverUrl.trim()}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication Link if needed */}
|
||||
{authUrl && (
|
||||
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
|
||||
<p className="text-xs mb-2">
|
||||
Authentication required. Please click the link below:
|
||||
</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-orange-700 hover:text-orange-800 underline"
|
||||
onClick={handleManualAuth}
|
||||
>
|
||||
Authenticate in new window
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools display when connected */}
|
||||
{state === 'ready' && tools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-xs mb-2">
|
||||
Available Tools ({tools.length})
|
||||
</h3>
|
||||
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-32 overflow-y-auto space-y-2">
|
||||
{tools.map((tool: Tool, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs pb-2 border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<p className="text-gray-500 mt-1 text-xs">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Log */}
|
||||
{showSettings && (
|
||||
<div>
|
||||
<label className="font-medium text-xs block mb-2">Debug Log</label>
|
||||
<div
|
||||
ref={logRef}
|
||||
className="border border-gray-200 rounded p-2 bg-gray-50 h-32 overflow-y-auto font-mono text-xs"
|
||||
>
|
||||
{log.length > 0 ? (
|
||||
log.map((entry: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`py-0.5 ${
|
||||
entry.level === 'debug'
|
||||
? 'text-gray-500'
|
||||
: entry.level === 'info'
|
||||
? 'text-blue-600'
|
||||
: entry.level === 'warn'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
[{entry.level}] {entry.message}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-400">No log entries yet</div>
|
||||
)}
|
||||
</div>
|
||||
{connectionData?.state !== 'not-connected' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
connectionData?.clearStorage()
|
||||
if (isActive) {
|
||||
handleDisconnect()
|
||||
}
|
||||
}}
|
||||
className="text-xs text-orange-600 hover:text-orange-800 hover:underline mt-2"
|
||||
>
|
||||
Clear stored authentication
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Only render the actual MCP connection when active */}
|
||||
{isActive && (
|
||||
<McpConnection
|
||||
serverUrl={serverUrl}
|
||||
onConnectionUpdate={setConnectionData}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
146
examples/chat-ui/src/components/ModelSelector.tsx
Normal file
146
examples/chat-ui/src/components/ModelSelector.tsx
Normal 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
examples/chat-ui/src/consts.ts
Normal file
2
examples/chat-ui/src/consts.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const storeName = "conversations";
|
||||
export const dbName = "localgpt";
|
||||
36
examples/chat-ui/src/hooks/useAutoscroll.ts
Normal file
36
examples/chat-ui/src/hooks/useAutoscroll.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
24
examples/chat-ui/src/hooks/useIndexedDB.ts
Normal file
24
examples/chat-ui/src/hooks/useIndexedDB.ts
Normal 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;
|
||||
}
|
||||
153
examples/chat-ui/src/hooks/useStreamResponse.ts
Normal file
153
examples/chat-ui/src/hooks/useStreamResponse.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
29
examples/chat-ui/src/hooks/useTheme.ts
Normal file
29
examples/chat-ui/src/hooks/useTheme.ts
Normal 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
examples/chat-ui/src/index.css
Normal file
3
examples/chat-ui/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@config "../tailwind.config.js";
|
||||
10
examples/chat-ui/src/main.tsx
Normal file
10
examples/chat-ui/src/main.tsx
Normal 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
examples/chat-ui/src/styles/github.css
Normal file
119
examples/chat-ui/src/styles/github.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
37
examples/chat-ui/src/styles/scrollbar.css
Normal file
37
examples/chat-ui/src/styles/scrollbar.css
Normal 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
examples/chat-ui/src/types/index.ts
Normal file
12
examples/chat-ui/src/types/index.ts
Normal 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";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue