use-slide-chat.ts
1 import { useReducer, useCallback, useEffect, useRef } from 'react'; 2 import type { ReviewGuide, Provider, ModelId, SendSlideChatRequest } from './types'; 3 4 export interface ChatMessage { 5 id: string; 6 role: 'user' | 'assistant'; 7 content: string; 8 isStreaming?: boolean; 9 toolCalls?: string[]; 10 } 11 12 type MessagesMap = Map<number, ChatMessage[]>; 13 14 type Action = 15 | { type: 'SEND'; slideIndex: number; userMessage: ChatMessage; assistantMessage: ChatMessage } 16 | { type: 'APPEND_CHUNK'; slideIndex: number; assistantId: string; chunk: string } 17 | { type: 'TOOL_USE'; slideIndex: number; assistantId: string; toolName: string } 18 | { type: 'FINALIZE'; slideIndex: number; assistantId: string } 19 | { type: 'ERROR'; slideIndex: number; assistantId: string; error: string } 20 | { type: 'CLEAR'; slideIndex: number }; 21 22 function reducer(state: MessagesMap, action: Action): MessagesMap { 23 const next = new Map(state); 24 25 switch (action.type) { 26 case 'SEND': { 27 const existing = next.get(action.slideIndex) ?? []; 28 next.set(action.slideIndex, [...existing, action.userMessage, action.assistantMessage]); 29 return next; 30 } 31 case 'APPEND_CHUNK': { 32 const msgs = next.get(action.slideIndex); 33 if (!msgs) return state; 34 next.set( 35 action.slideIndex, 36 msgs.map((m) => (m.id === action.assistantId ? { ...m, content: m.content + action.chunk } : m)) 37 ); 38 return next; 39 } 40 case 'TOOL_USE': { 41 const msgs = next.get(action.slideIndex); 42 if (!msgs) return state; 43 next.set( 44 action.slideIndex, 45 msgs.map((m) => { 46 if (m.id !== action.assistantId) return m; 47 const existing = m.toolCalls ?? []; 48 if (existing.includes(action.toolName)) return m; 49 return { ...m, toolCalls: [...existing, action.toolName] }; 50 }) 51 ); 52 return next; 53 } 54 case 'FINALIZE': { 55 const msgs = next.get(action.slideIndex); 56 if (!msgs) return state; 57 next.set( 58 action.slideIndex, 59 msgs.map((m) => (m.id === action.assistantId ? { ...m, isStreaming: false } : m)) 60 ); 61 return next; 62 } 63 case 'ERROR': { 64 const msgs = next.get(action.slideIndex); 65 if (!msgs) return state; 66 next.set( 67 action.slideIndex, 68 msgs.map((m) => 69 m.id === action.assistantId ? { ...m, content: m.content || action.error, isStreaming: false } : m 70 ) 71 ); 72 return next; 73 } 74 case 'CLEAR': { 75 next.delete(action.slideIndex); 76 return next; 77 } 78 } 79 } 80 81 export function useSlideChat(review: ReviewGuide, provider: Provider, model: ModelId) { 82 const [messages, dispatch] = useReducer(reducer, new Map<number, ChatMessage[]>()); 83 const streamingRef = useRef<{ slideIndex: number; assistantId: string } | null>(null); 84 const isStreamingRef = useRef(false); 85 86 useEffect(() => { 87 const handler = (chunk: string) => { 88 const current = streamingRef.current; 89 if (!current) return; 90 dispatch({ type: 'APPEND_CHUNK', slideIndex: current.slideIndex, assistantId: current.assistantId, chunk }); 91 }; 92 93 const toolHandler = (toolName: string) => { 94 const current = streamingRef.current; 95 if (!current) return; 96 dispatch({ type: 'TOOL_USE', slideIndex: current.slideIndex, assistantId: current.assistantId, toolName }); 97 }; 98 99 window.electronAPI.onChatProgress(handler); 100 window.electronAPI.onChatToolUse(toolHandler); 101 return () => { 102 window.electronAPI.offChatProgress(); 103 window.electronAPI.offChatToolUse(); 104 }; 105 }, []); 106 107 const getMessages = useCallback( 108 (slideIndex: number): ChatMessage[] => { 109 return messages.get(slideIndex) ?? []; 110 }, 111 [messages] 112 ); 113 114 const send = useCallback( 115 async (slideIndex: number, question: string) => { 116 if (isStreamingRef.current) return; 117 118 const slide = review.slides[slideIndex - 1]; 119 120 const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: question }; 121 const assistantMsg: ChatMessage = { id: crypto.randomUUID(), role: 'assistant', content: '', isStreaming: true }; 122 123 dispatch({ type: 'SEND', slideIndex, userMessage: userMsg, assistantMessage: assistantMsg }); 124 streamingRef.current = { slideIndex, assistantId: assistantMsg.id }; 125 isStreamingRef.current = true; 126 127 const existingMessages = messages.get(slideIndex) ?? []; 128 const history = existingMessages 129 .filter((m) => !m.isStreaming && m.content) 130 .map((m) => ({ role: m.role, content: m.content })); 131 132 const diffContent = slide.diffHunks.map((h) => `--- ${h.filePath}\n${h.hunkHeader}\n${h.content}`).join('\n\n'); 133 134 const req: SendSlideChatRequest = { 135 prTitle: review.prTitle, 136 prDescription: review.prDescription, 137 summary: review.summary, 138 slideTitle: slide.title, 139 slideNarrative: slide.narrative, 140 slideReviewFocus: slide.reviewFocus, 141 affectedFiles: slide.affectedFiles, 142 diffContent, 143 history, 144 question, 145 provider, 146 model, 147 }; 148 149 try { 150 await window.electronAPI.sendSlideChat(req); 151 dispatch({ type: 'FINALIZE', slideIndex, assistantId: assistantMsg.id }); 152 } catch (err) { 153 const errorMsg = err instanceof Error ? err.message : 'Failed to get response'; 154 dispatch({ type: 'ERROR', slideIndex, assistantId: assistantMsg.id, error: errorMsg }); 155 } finally { 156 streamingRef.current = null; 157 isStreamingRef.current = false; 158 } 159 }, 160 [review, provider, model, messages] 161 ); 162 163 const clearSlide = useCallback((slideIndex: number) => { 164 dispatch({ type: 'CLEAR', slideIndex }); 165 }, []); 166 167 const isStreaming = isStreamingRef.current; 168 169 return { getMessages, send, isStreaming, clearSlide }; 170 }