From 10441ba3347ddfded86af4766371da89ce6fe650 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 8 Jan 2026 17:14:15 -0500 Subject: [PATCH 1/3] refactor vector search to chat widget --- src/fn/rag-chat.ts | 36 ++++ src/hooks/use-rag-chat.ts | 134 +++++++++++++ src/lib/openai-chat.ts | 146 ++++++++++++++ src/routes/admin/vector-search.tsx | 189 +++--------------- .../-components/chat-container.tsx | 75 +++++++ .../vector-search/-components/chat-input.tsx | 63 ++++++ .../-components/chat-message.tsx | 57 ++++++ .../-components/source-videos-panel.tsx | 96 +++++++++ src/use-cases/rag-chat.ts | 178 +++++++++++++++++ 9 files changed, 818 insertions(+), 156 deletions(-) create mode 100644 src/fn/rag-chat.ts create mode 100644 src/hooks/use-rag-chat.ts create mode 100644 src/lib/openai-chat.ts create mode 100644 src/routes/admin/vector-search/-components/chat-container.tsx create mode 100644 src/routes/admin/vector-search/-components/chat-input.tsx create mode 100644 src/routes/admin/vector-search/-components/chat-message.tsx create mode 100644 src/routes/admin/vector-search/-components/source-videos-panel.tsx create mode 100644 src/use-cases/rag-chat.ts diff --git a/src/fn/rag-chat.ts b/src/fn/rag-chat.ts new file mode 100644 index 00000000..5ecf14a5 --- /dev/null +++ b/src/fn/rag-chat.ts @@ -0,0 +1,36 @@ +import { createServerFn } from "@tanstack/react-start"; +import { adminMiddleware } from "~/lib/auth"; +import { z } from "zod"; +import { ragChatUseCase } from "~/use-cases/rag-chat"; + +const videoSourceSchema = z.object({ + segmentId: z.number(), + segmentTitle: z.string(), + segmentSlug: z.string(), + moduleTitle: z.string(), + chunkText: z.string(), + similarity: z.number(), +}); + +const conversationMessageSchema = z.object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + content: z.string(), + timestamp: z.string(), + sources: z.array(videoSourceSchema).optional(), +}); + +const ragChatInputSchema = z.object({ + userMessage: z.string().min(1, "Message cannot be empty").max(2000, "Message too long"), + conversationHistory: z.array(conversationMessageSchema).max(20, "Too many messages in history"), +}); + +export const ragChatFn = createServerFn({ method: "POST" }) + .middleware([adminMiddleware]) + .inputValidator(ragChatInputSchema) + .handler(async ({ data }) => { + return ragChatUseCase({ + userMessage: data.userMessage, + conversationHistory: data.conversationHistory, + }); + }); diff --git a/src/hooks/use-rag-chat.ts b/src/hooks/use-rag-chat.ts new file mode 100644 index 00000000..1bfcb61e --- /dev/null +++ b/src/hooks/use-rag-chat.ts @@ -0,0 +1,134 @@ +import { useState, useCallback, useEffect } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { ragChatFn } from "~/fn/rag-chat"; +import type { VideoSource, ConversationMessage } from "~/use-cases/rag-chat"; + +export type { VideoSource, ConversationMessage }; + +const STORAGE_KEY = "rag-chat-history"; +const SOURCES_STORAGE_KEY = "rag-chat-sources"; + +function loadFromStorage(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const stored = sessionStorage.getItem(key); + if (stored) { + return JSON.parse(stored) as T; + } + } catch (error) { + console.error(`[RAG Chat] Failed to load from sessionStorage:`, error); + } + return fallback; +} + +function saveToStorage(key: string, value: T): void { + if (typeof window === "undefined") return; + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`[RAG Chat] Failed to save to sessionStorage:`, error); + } +} + +function clearStorage(): void { + if (typeof window === "undefined") return; + try { + sessionStorage.removeItem(STORAGE_KEY); + sessionStorage.removeItem(SOURCES_STORAGE_KEY); + } catch (error) { + console.error(`[RAG Chat] Failed to clear sessionStorage:`, error); + } +} + +export interface UseRagChatReturn { + messages: ConversationMessage[]; + isLoading: boolean; + error: Error | null; + sendMessage: (content: string) => Promise; + clearChat: () => void; + currentSources: VideoSource[]; +} + +export function useRagChat(): UseRagChatReturn { + const [messages, setMessages] = useState(() => + loadFromStorage(STORAGE_KEY, []) + ); + const [currentSources, setCurrentSources] = useState(() => + loadFromStorage(SOURCES_STORAGE_KEY, []) + ); + + useEffect(() => { + saveToStorage(STORAGE_KEY, messages); + }, [messages]); + + useEffect(() => { + saveToStorage(SOURCES_STORAGE_KEY, currentSources); + }, [currentSources]); + + const mutation = useMutation({ + mutationFn: async (userMessage: string) => { + const result = await ragChatFn({ + data: { + userMessage, + conversationHistory: messages, + }, + }); + return result; + }, + onMutate: (userMessage) => { + const userMsg: ConversationMessage = { + id: crypto.randomUUID(), + role: "user", + content: userMessage, + timestamp: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMsg]); + setCurrentSources([]); + }, + onSuccess: (result) => { + const assistantMsg: ConversationMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: result.response, + timestamp: new Date().toISOString(), + sources: result.sources, + }; + setMessages((prev) => [...prev, assistantMsg]); + setCurrentSources(result.sources); + }, + onError: (error) => { + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if (lastMessage?.role === "user") { + return prev.slice(0, -1); + } + return prev; + }); + console.error("[RAG Chat] Error:", error); + }, + }); + + const sendMessage = useCallback( + async (content: string) => { + if (!content.trim() || mutation.isPending) return; + await mutation.mutateAsync(content.trim()); + }, + [mutation] + ); + + const clearChat = useCallback(() => { + setMessages([]); + setCurrentSources([]); + clearStorage(); + mutation.reset(); + }, [mutation]); + + return { + messages, + isLoading: mutation.isPending, + error: mutation.error, + sendMessage, + clearChat, + currentSources, + }; +} diff --git a/src/lib/openai-chat.ts b/src/lib/openai-chat.ts new file mode 100644 index 00000000..abb268d5 --- /dev/null +++ b/src/lib/openai-chat.ts @@ -0,0 +1,146 @@ +import OpenAI from "openai"; +import { env } from "~/utils/env"; + +const openai = new OpenAI({ + apiKey: env.OPENAI_API_KEY, +}); + +const CHAT_MODEL = "gpt-4o"; +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY_MS = 1000; + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface ChatCompletionOptions { + model?: "gpt-4o-mini" | "gpt-4o"; + temperature?: number; + maxTokens?: number; +} + +export interface ChatCompletionResult { + content: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +class ChatCompletionError extends Error { + constructor( + message: string, + public readonly code?: string, + public readonly status?: number, + public readonly context?: Record + ) { + super(message); + this.name = "ChatCompletionError"; + } +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withRetry( + fn: () => Promise, + context: Record +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + const isRetryable = + error instanceof OpenAI.APIError && + (error.status === 429 || + error.status === 500 || + error.status === 502 || + error.status === 503); + + if (!isRetryable || attempt === MAX_RETRIES - 1) { + break; + } + + const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt); + console.warn( + `Chat completion API call failed (attempt ${attempt + 1}/${MAX_RETRIES}), retrying in ${delay}ms...`, + { error: lastError.message, ...context } + ); + await sleep(delay); + } + } + + if (lastError instanceof OpenAI.APIError) { + throw new ChatCompletionError( + `OpenAI API error: ${lastError.message}`, + lastError.code ?? undefined, + lastError.status, + context + ); + } + + throw new ChatCompletionError( + `Chat completion failed: ${lastError?.message ?? "Unknown error"}`, + undefined, + undefined, + context + ); +} + +export async function createChatCompletion( + messages: ChatMessage[], + options?: ChatCompletionOptions +): Promise { + if (!messages || !Array.isArray(messages) || messages.length === 0) { + throw new ChatCompletionError( + "Messages must be a non-empty array", + undefined, + undefined, + { messagesLength: messages?.length ?? 0 } + ); + } + + const model = options?.model ?? CHAT_MODEL; + const temperature = options?.temperature ?? 0.7; + const maxTokens = options?.maxTokens ?? 2048; + + return withRetry( + async () => { + const response = await openai.chat.completions.create({ + model, + messages, + temperature, + max_tokens: maxTokens, + }); + + const choice = response.choices[0]; + if (!choice?.message?.content) { + throw new ChatCompletionError( + "Invalid API response: missing message content", + undefined, + undefined, + { choicesLength: response.choices.length } + ); + } + + return { + content: choice.message.content, + usage: response.usage + ? { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + } + : undefined, + }; + }, + { model, messagesCount: messages.length } + ); +} diff --git a/src/routes/admin/vector-search.tsx b/src/routes/admin/vector-search.tsx index e792f3b1..adb338d2 100644 --- a/src/routes/admin/vector-search.tsx +++ b/src/routes/admin/vector-search.tsx @@ -1,176 +1,53 @@ -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useState, useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Search, Loader2, Video, BookOpen } from "lucide-react"; -import { Input } from "~/components/ui/input"; +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { Trash2 } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { Badge } from "~/components/ui/badge"; +import { Card } from "~/components/ui/card"; import { PageHeader } from "./-components/page-header"; import { Page } from "./-components/page"; -import { searchTranscriptsFn } from "~/fn/vector-search"; import { assertIsAdminFn } from "~/fn/auth"; -import { z } from "zod"; - -const searchParamsSchema = z.object({ - q: z.string().optional(), -}); +import { useRagChat } from "~/hooks/use-rag-chat"; +import { ChatContainer } from "./vector-search/-components/chat-container"; +import { ChatInput } from "./vector-search/-components/chat-input"; +import { SourceVideosPanel } from "./vector-search/-components/source-videos-panel"; export const Route = createFileRoute("/admin/vector-search")({ beforeLoad: () => assertIsAdminFn(), - component: VectorSearchPage, - validateSearch: searchParamsSchema, + component: CourseAssistantPage, }); -function VectorSearchPage() { - const { q: searchTerm = "" } = Route.useSearch(); - const navigate = useNavigate({ from: Route.fullPath }); - const [query, setQuery] = useState(searchTerm); - - useEffect(() => { - setQuery(searchTerm); - }, [searchTerm]); - - const { data, isLoading, isFetching } = useQuery({ - queryKey: ["admin", "vector-search", searchTerm], - queryFn: () => - searchTranscriptsFn({ data: { query: searchTerm, limit: 20 } }), - enabled: searchTerm.length > 0, - }); - - const handleSearch = () => { - if (query.trim()) { - navigate({ search: { q: query.trim() } }); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleSearch(); - } - }; +function CourseAssistantPage() { + const [inputValue, setInputValue] = useState(""); + const { messages, isLoading, sendMessage, clearChat, currentSources } = + useRagChat(); return ( 0 ? ( + + ) : null + } /> -
-
- - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - className="pl-12 h-12 text-lg" - /> +
+
+ + + +
- -
- - {searchTerm && ( -
- Showing results for: {searchTerm} +
+
- )} - - {isLoading && ( -
- -
- )} - - {data && data.length > 0 && ( -
- {data.map((result, index) => ( - - -
-
- - - - - {result.moduleTitle} - -
- 0.8 - ? "default" - : result.similarity > 0.6 - ? "secondary" - : "outline" - } - > - {(result.similarity * 100).toFixed(1)}% match - -
-
- -

- {result.chunkText} -

- - Go to video → - -
-
- ))} -
- )} - - {data && data.length === 0 && searchTerm && ( -
- -

No results found

-

- No transcripts matched your search for "{searchTerm}". Try a - different query or check that transcripts have been vectorized. -

-
- )} - - {!searchTerm && ( -
- -

- Search course transcripts -

-

- Enter a topic or keyword to find videos that discuss it. This uses - semantic search to find relevant content even if the exact words - don't match. -

-
- )} +
); } diff --git a/src/routes/admin/vector-search/-components/chat-container.tsx b/src/routes/admin/vector-search/-components/chat-container.tsx new file mode 100644 index 00000000..7126f74d --- /dev/null +++ b/src/routes/admin/vector-search/-components/chat-container.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from "react"; +import { MessageSquare, Loader2 } from "lucide-react"; +import { ChatMessage } from "./chat-message"; +import type { ConversationMessage } from "~/hooks/use-rag-chat"; + +interface ChatContainerProps { + messages: ConversationMessage[]; + isLoading: boolean; + onSelectPrompt: (prompt: string) => void; +} + +const EXAMPLE_PROMPTS = [ + "What topics are covered in this course?", + "How do I get started with the first module?", + "Can you explain the main concepts?", + "What prerequisites do I need?", +]; + +export function ChatContainer({ messages, isLoading, onSelectPrompt }: ChatContainerProps) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, isLoading]); + + if (messages.length === 0) { + return ( +
+ +

Course Assistant

+

+ Ask questions about the course content. I'll search through video + transcripts to find relevant information and help answer your + questions. +

+
+ {EXAMPLE_PROMPTS.map((prompt) => ( + + ))} +
+
+ ); + } + + return ( +
+ {messages.map((message) => ( + + ))} + {isLoading && ( +
+
+ +
+
+ + Searching transcripts and generating response... + +
+
+ )} +
+ ); +} diff --git a/src/routes/admin/vector-search/-components/chat-input.tsx b/src/routes/admin/vector-search/-components/chat-input.tsx new file mode 100644 index 00000000..3f47657a --- /dev/null +++ b/src/routes/admin/vector-search/-components/chat-input.tsx @@ -0,0 +1,63 @@ +import { useRef, useEffect, KeyboardEvent } from "react"; +import { Send, Loader2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Textarea } from "~/components/ui/textarea"; + +interface ChatInputProps { + onSend: (message: string) => void; + isLoading: boolean; + value: string; + onChange: (value: string) => void; +} + +export function ChatInput({ onSend, isLoading, value, onChange }: ChatInputProps) { + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`; + } + }, [value]); + + const handleSubmit = () => { + if (!value.trim() || isLoading) return; + onSend(value.trim()); + onChange(""); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+