import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useAIChatTools } from '@/hooks/useAIChatTools'; import { TOOLS, type DisplayMessage, type ToolCall } from '@/lib/aiChatTools'; import { SYSTEM_PROMPT } from '@/lib/aiChatSystemPrompt'; import type { NostrEvent } from '@nostrify/nostrify'; // ─── Persistence ─── const CHAT_STORAGE_KEY = 'ditto:ai-chat-messages'; /** Serialized shape stored in localStorage (Date → ISO string). */ interface StoredMessage extends Omit { timestamp: string; } function loadMessages(): DisplayMessage[] { try { const raw = localStorage.getItem(CHAT_STORAGE_KEY); if (!raw) return []; const stored: StoredMessage[] = JSON.parse(raw); return stored.map((m) => ({ ...m, timestamp: new Date(m.timestamp) })); } catch { return []; } } function saveMessages(messages: DisplayMessage[]): void { try { const stored: StoredMessage[] = messages.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() })); localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(stored)); } catch { // Storage full or unavailable — silently ignore } } // ─── Hook ─── export function useAIChatSession() { const { user } = useCurrentUser(); const { config } = useAppContext(); const { sendChatMessage, getAvailableModels, getCredits, isLoading: apiLoading, error: apiError, clearError } = useShakespeare(); const { executeToolCall } = useAIChatTools(); const [messages, setMessages] = useState(loadMessages); const [input, setInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); // Resolve the effective model: config value, or fetch the cheapest as default const [defaultModel, setDefaultModel] = useState(''); const selectedModel = config.aiModel || defaultModel; const messagesEndRef = useRef(null); const abortRef = useRef(null); // Persist messages to localStorage useEffect(() => { saveMessages(messages); }, [messages]); // Scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Fetch cheapest model as fallback when no model is configured useEffect(() => { if (!user || config.aiModel) return; let cancelled = false; getAvailableModels() .then((response) => { if (cancelled) return; const sorted = response.data.sort((a, b) => { const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion); const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion); return costA - costB; }); if (sorted.length > 0) { setDefaultModel(sorted[0].id); } }) .catch(() => {}); return () => { cancelled = true; }; }, [user, config.aiModel, getAvailableModels]); // Build the chat messages array for the API const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => { const apiMessages: ChatMessage[] = [SYSTEM_PROMPT]; for (const msg of displayMsgs) { if (msg.role === 'tool_result') { apiMessages.push({ role: 'tool', content: msg.content, tool_call_id: msg.toolCallId, }); } else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) { apiMessages.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.toolCalls.map((tc) => ({ id: tc.id, type: 'function' as const, function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, })), }); } else { apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content }); } } return apiMessages; }, []); // Handle sending a message. Pass `override` to send arbitrary text (e.g. suggestion chips). const handleSend = useCallback(async (override?: string) => { const trimmed = (override ?? input).trim(); if (!trimmed || !selectedModel || isStreaming) return; clearError(); setInput(''); const controller = new AbortController(); abortRef.current = controller; const userMessage: DisplayMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed, timestamp: new Date(), }; const newMessages = [...messages, userMessage]; setMessages(newMessages); setIsStreaming(true); try { const MAX_TOOL_ROUNDS = 10; let apiMessages = buildApiMessages(newMessages); let currentMessages = newMessages; for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { if (controller.signal.aborted) break; const response = await sendChatMessage(apiMessages, selectedModel, { tools: TOOLS, } as Partial>, controller.signal); const choice = response.choices[0]; const assistantMsg = choice.message; // Check for tool calls const rawMessage = assistantMsg as unknown as { content?: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string }; }>; }; if (!rawMessage.tool_calls || rawMessage.tool_calls.length === 0) { const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : ''; const assistantMessage: DisplayMessage = { id: crypto.randomUUID(), role: 'assistant', content, timestamp: new Date(), }; setMessages((prev) => [...prev, assistantMessage]); break; } // Execute tool calls let nostrEvent: NostrEvent | undefined; const toolCalls: ToolCall[] = []; for (const tc of rawMessage.tool_calls) { if (controller.signal.aborted) break; let args: Record = {}; try { args = JSON.parse(tc.function.arguments); } catch { // If parsing fails, pass empty args } const execResult = await executeToolCall(tc.function.name, args); if (execResult.nostrEvent) { nostrEvent = execResult.nostrEvent; } toolCalls.push({ id: tc.id, name: tc.function.name, arguments: args, result: execResult.result, }); } if (controller.signal.aborted) break; // Add assistant message with tool calls to display const toolMsg: DisplayMessage = { id: crypto.randomUUID(), role: 'assistant', content: rawMessage.content || '', timestamp: new Date(), toolCalls, nostrEvent, }; // Add tool result display messages (hidden in UI, used by buildApiMessages) const toolResultMsgs: DisplayMessage[] = toolCalls.map((tc) => ({ id: crypto.randomUUID(), role: 'tool_result' as const, content: tc.result ?? '', toolCallId: tc.id, timestamp: new Date(), })); currentMessages = [...currentMessages, toolMsg, ...toolResultMsgs]; setMessages(currentMessages); // Rebuild API messages apiMessages = buildApiMessages(currentMessages); } } catch (err) { // Silently handle user-initiated abort if (err instanceof DOMException && err.name === 'AbortError') return; console.error('Chat error:', err); } finally { abortRef.current = null; setIsStreaming(false); } }, [input, selectedModel, isStreaming, messages, buildApiMessages, sendChatMessage, executeToolCall, clearError]); // Stop an in-flight generation const handleStop = useCallback(() => { abortRef.current?.abort(); }, []); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend]); // Clear conversation const handleClear = useCallback(() => { setMessages([]); localStorage.removeItem(CHAT_STORAGE_KEY); clearError(); }, [clearError]); return { // State messages, input, setInput, isStreaming, selectedModel, apiLoading, apiError, messagesEndRef, // Actions handleSend, handleStop, handleKeyDown, handleClear, getCredits, }; }