-
Notifications
You must be signed in to change notification settings - Fork 11
refactor vector search to chat widget #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| 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 MAX_MESSAGE_CONTENT_LENGTH = 2000; | ||
|
|
||
| const conversationMessageSchema = z.object({ | ||
| id: z.string(), | ||
| role: z.enum(["user", "assistant"]), | ||
| content: z | ||
| .string() | ||
| .max(MAX_MESSAGE_CONTENT_LENGTH, "Message content too long"), | ||
| timestamp: z.string(), | ||
| sources: z.array(videoSourceSchema).optional(), | ||
| }); | ||
|
|
||
| const ragChatInputSchema = z.object({ | ||
| userMessage: z | ||
| .string() | ||
| .min(1, "Message cannot be empty") | ||
| .max(MAX_MESSAGE_CONTENT_LENGTH, "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, | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,204 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { useState, useCallback, useEffect, useRef } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { useMutation } from "@tanstack/react-query"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| 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"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_MESSAGE_LENGTH = 2000; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_HISTORY_LENGTH = 20000; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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 conversationMessagesSchema = z.array(conversationMessageSchema); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const videoSourcesSchema = z.array(videoSourceSchema); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| function loadFromStorage<T>( | ||||||||||||||||||||||||||||||||||||||||||||||||
| key: string, | ||||||||||||||||||||||||||||||||||||||||||||||||
| fallback: T, | ||||||||||||||||||||||||||||||||||||||||||||||||
| schema: z.ZodType<T> | ||||||||||||||||||||||||||||||||||||||||||||||||
| ): T { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof window === "undefined") return fallback; | ||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const stored = sessionStorage.getItem(key); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (stored) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const parsed = JSON.parse(stored); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const result = schema.safeParse(parsed); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (result.success) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return result.data; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(`[RAG Chat] Validation failed for ${key}:`, result.error); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.error(`[RAG Chat] Failed to load from sessionStorage:`, error); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fallback; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| function saveToStorage<T>(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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| function trimConversationHistory( | ||||||||||||||||||||||||||||||||||||||||||||||||
| history: ConversationMessage[], | ||||||||||||||||||||||||||||||||||||||||||||||||
| maxLength: number | ||||||||||||||||||||||||||||||||||||||||||||||||
| ): ConversationMessage[] { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (history.length === 0) return history; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const truncateMessage = (msg: ConversationMessage): ConversationMessage => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| ...msg, | ||||||||||||||||||||||||||||||||||||||||||||||||
| content: msg.content.slice(0, MAX_MESSAGE_LENGTH), | ||||||||||||||||||||||||||||||||||||||||||||||||
| sources: undefined, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| let trimmed = history.map(truncateMessage); | ||||||||||||||||||||||||||||||||||||||||||||||||
| let serialized = JSON.stringify(trimmed); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| while (serialized.length > maxLength && trimmed.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| trimmed = trimmed.slice(1); | ||||||||||||||||||||||||||||||||||||||||||||||||
| serialized = JSON.stringify(trimmed); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return trimmed; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export interface UseRagChatReturn { | ||||||||||||||||||||||||||||||||||||||||||||||||
| messages: ConversationMessage[]; | ||||||||||||||||||||||||||||||||||||||||||||||||
| isLoading: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||
| error: Error | null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| sendMessage: (content: string) => Promise<void>; | ||||||||||||||||||||||||||||||||||||||||||||||||
| clearChat: () => void; | ||||||||||||||||||||||||||||||||||||||||||||||||
| currentSources: VideoSource[]; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function useRagChat(): UseRagChatReturn { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const [messages, setMessages] = useState<ConversationMessage[]>(() => | ||||||||||||||||||||||||||||||||||||||||||||||||
| loadFromStorage<ConversationMessage[]>(STORAGE_KEY, [], conversationMessagesSchema) | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const [currentSources, setCurrentSources] = useState<VideoSource[]>(() => | ||||||||||||||||||||||||||||||||||||||||||||||||
| loadFromStorage<VideoSource[]>(SOURCES_STORAGE_KEY, [], videoSourcesSchema) | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const messagesRef = useRef<ConversationMessage[]>(messages); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| messagesRef.current = messages; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, [messages]); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| saveToStorage(STORAGE_KEY, messages); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, [messages]); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| saveToStorage(SOURCES_STORAGE_KEY, currentSources); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, [currentSources]); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const mutation = useMutation({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| mutationFn: async (userMessage: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const trimmedHistory = trimConversationHistory( | ||||||||||||||||||||||||||||||||||||||||||||||||
| messagesRef.current, | ||||||||||||||||||||||||||||||||||||||||||||||||
| MAX_HISTORY_LENGTH | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await ragChatFn({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| userMessage, | ||||||||||||||||||||||||||||||||||||||||||||||||
| conversationHistory: trimmedHistory, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| onMutate: (userMessage) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const userMsg: ConversationMessage = { | ||||||||||||||||||||||||||||||||||||||||||||||||
| id: crypto.randomUUID(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| role: "user", | ||||||||||||||||||||||||||||||||||||||||||||||||
| content: userMessage, | ||||||||||||||||||||||||||||||||||||||||||||||||
| timestamp: new Date().toISOString(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| setMessages((prev) => [...prev, userMsg]); | ||||||||||||||||||||||||||||||||||||||||||||||||
| setCurrentSources([]); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sync messagesRef when rolling back optimistic updates. When the error handler rolls back the optimistic user message (lines 109-115), it doesn't update 🔧 Suggested fix onError: (error) => {
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];
if (lastMessage?.role === "user") {
- return prev.slice(0, -1);
+ const rolled = prev.slice(0, -1);
+ messagesRef.current = rolled;
+ return rolled;
}
+ messagesRef.current = prev;
return prev;
});
console.error("[RAG Chat] Error:", error);
},📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const sendMessage = useCallback( | ||||||||||||||||||||||||||||||||||||||||||||||||
| async (content: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const trimmed = content.trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!trimmed || mutation.isPending) return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (trimmed.length > MAX_MESSAGE_LENGTH) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||
| `Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters` | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| await mutation.mutateAsync(trimmed); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| [mutation] | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const clearChat = useCallback(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| setMessages([]); | ||||||||||||||||||||||||||||||||||||||||||||||||
| setCurrentSources([]); | ||||||||||||||||||||||||||||||||||||||||||||||||
| clearStorage(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| mutation.reset(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, [mutation]); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear chat does not prevent orphaned response from in-flight requestLow Severity When a user clicks "Clear Chat" while an API request is in flight, 🔬 Verification TestWhy verification test was not possible: This race condition requires mocking React Query's mutation lifecycle timing and simulating user interaction mid-request. The bug manifests when: (1) a mutation is pending, (2) clearChat is called, (3) the mutation completes. Testing would require a full React rendering environment with React Query and precise timing control, which cannot be done through simple unit tests without the complete application context. Additional Locations (1) |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||
| messages, | ||||||||||||||||||||||||||||||||||||||||||||||||
| isLoading: mutation.isPending, | ||||||||||||||||||||||||||||||||||||||||||||||||
| error: mutation.error, | ||||||||||||||||||||||||||||||||||||||||||||||||
| sendMessage, | ||||||||||||||||||||||||||||||||||||||||||||||||
| clearChat, | ||||||||||||||||||||||||||||||||||||||||||||||||
| currentSources, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> | ||
| ) { | ||
| super(message); | ||
| this.name = "ChatCompletionError"; | ||
| } | ||
| } | ||
|
|
||
| async function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| async function withRetry<T>( | ||
| fn: () => Promise<T>, | ||
| context: Record<string, unknown> | ||
| ): Promise<T> { | ||
| 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<ChatCompletionResult> { | ||
| 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 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: webdevcody/agentic-jumpstart
Length of output: 656
🏁 Script executed:
Repository: webdevcody/agentic-jumpstart
Length of output: 2626
🏁 Script executed:
Repository: webdevcody/agentic-jumpstart
Length of output: 2337
🏁 Script executed:
Repository: webdevcody/agentic-jumpstart
Length of output: 1609
Fix error type mismatch in UseRagChatReturn interface.
The
errorproperty is typed asError | null, butmutation.errorfrom React Query'suseMutationis actuallyunknownsince no TError type parameter is specified. Either declareerror: unknownin the interface or specify the error type in the mutation configuration:🤖 Prompt for AI Agents