Copying chat example across
This commit is contained in:
parent
de9e20bf89
commit
f5f1943ac5
4 changed files with 7675 additions and 0 deletions
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;
|
||||
}
|
||||
Loading…
Reference in a new issue