All files / src/hooks useAIChat.ts

0% Statements 0/74
0% Branches 0/30
0% Functions 0/15
0% Lines 0/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                                                                                                                                                                                                         
/**
 * Custom hook encapsulating AI chat state and message handling.
 */
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
import {
  type ChatMessage, nextMessageId, getApiKey,
  buildSystemPrompt, streamChat,
} from '../utils/ai-chat'
import { countWords } from '../utils/wikilinks'
 
function generateMockResponse(message: string, entry: VaultEntry | null, content: string): string {
  const title = entry?.title ?? 'Untitled'
  const words = countWords(content)
  const lower = message.toLowerCase()
 
  if (lower.includes('summarize')) {
    return `This note is about **${title}**. It contains ${words} words covering the main concepts documented in your vault.`
  }
  if (lower.includes('expand')) {
    return `Here are some ways to expand this note:\n\n1. Add more detail to the introduction\n2. Include related examples\n3. Connect it to your quarterly goals\n4. Add a summary section at the end`
  }
  if (lower.includes('grammar')) {
    return `I reviewed the document for grammar issues. The writing looks clean overall — no major errors found.`
  }
 
  return `Based on **${title}**, I can help with analysis, summarization, or expansion. What would you like to focus on?`
}
 
export function useAIChat(
  entry: VaultEntry | null,
  allContent: Record<string, string>,
  contextNotes: VaultEntry[],
  model: string,
) {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [isStreaming, setIsStreaming] = useState(false)
  const [streamingContent, setStreamingContent] = useState('')
  const abortRef = useRef(false)
 
  const sendMessage = useCallback((text: string) => {
    if (!text.trim() || isStreaming) return
 
    const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
    setMessages(prev => [...prev, userMsg])
    setIsStreaming(true)
    setStreamingContent('')
    abortRef.current = false
 
    const allMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
    const hasApiKey = !!getApiKey()
 
    if (!hasApiKey) {
      const content = entry ? (allContent[entry.path] ?? '') : ''
      setTimeout(() => {
        if (abortRef.current) return
        const response = generateMockResponse(text, entry, content)
        setMessages(prev => [...prev, { role: 'assistant', content: response, id: nextMessageId() }])
        setIsStreaming(false)
      }, 800)
      return
    }
 
    const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent, model)
    let accumulated = ''
 
    const onChunk = (chunk: string) => {
      if (abortRef.current) return
      accumulated += chunk
      setStreamingContent(accumulated)
    }
 
    const onDone = () => {
      if (abortRef.current) return
      setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
      setStreamingContent('')
      setIsStreaming(false)
    }
 
    const onError = (error: string) => {
      if (abortRef.current) return
      setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
      setStreamingContent('')
      setIsStreaming(false)
    }
 
    streamChat(allMessages, systemPrompt, model, onChunk, onDone, onError)
  }, [isStreaming, entry, allContent, contextNotes, model, messages])
 
  const clearConversation = useCallback(() => {
    abortRef.current = true
    setMessages([])
    setIsStreaming(false)
    setStreamingContent('')
  }, [])
 
  const retryMessage = useCallback((msgIndex: number) => {
    const userMsgIndex = msgIndex - 1
    if (userMsgIndex < 0) return
    const userMsg = messages[userMsgIndex]
    if (userMsg.role !== 'user') return
 
    setMessages(prev => prev.slice(0, msgIndex))
    sendMessage(userMsg.content)
  }, [messages, sendMessage])
 
  return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
}