All files / src/utils ai-chat.ts

5.26% Statements 4/76
0% Branches 0/43
0% Functions 0/10
5.63% Lines 4/71

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 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191              2x                                   2x                                                                                                                     2x                                                                                                                                                                                                         2x          
/**
 * AI Chat utilities — Anthropic API client, token estimation, context building.
 */
 
import type { VaultEntry } from '../types'
 
// --- localStorage key for API key ---
const API_KEY_STORAGE_KEY = 'laputa:anthropic-api-key'
 
export function getApiKey(): string {
  return localStorage.getItem(API_KEY_STORAGE_KEY) ?? ''
}
 
export function setApiKey(key: string): void {
  localStorage.setItem(API_KEY_STORAGE_KEY, key)
}
 
// --- Token estimation ---
 
/** Rough token estimate: ~4 chars per token for English text. */
export function estimateTokens(text: string | number): number {
  const len = typeof text === 'number' ? text : text.length
  return Math.ceil(len / 4)
}
 
const DEFAULT_CONTEXT_LIMIT = 180_000
 
export function getContextLimit(_model: string): number {
  return DEFAULT_CONTEXT_LIMIT
}
 
// --- Context building ---
 
/** Build system prompt from selected context notes. */
export function buildSystemPrompt(
  notes: VaultEntry[],
  allContent: Record<string, string>,
  model: string,
): { prompt: string; totalTokens: number; truncated: boolean } {
  if (notes.length === 0) {
    return { prompt: '', totalTokens: 0, truncated: false }
  }
 
  const contextBudget = Math.floor(getContextLimit(model) * 0.6)
  const preamble = [
    'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
    'The user has selected the following notes as context. Use them to answer questions accurately.',
    '',
  ].join('\n')
 
  const parts: string[] = [preamble]
  let totalChars = preamble.length
  let truncated = false
 
  for (const note of notes) {
    const content = allContent[note.path] ?? ''
    const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---`
    const noteText = `${header}\n${content}\n`
 
    if (estimateTokens(totalChars + noteText.length) > contextBudget) {
      const remaining = (contextBudget - estimateTokens(totalChars)) * 4
      if (remaining > 200) {
        parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`)
      }
      truncated = true
      break
    }
 
    parts.push(noteText)
    totalChars += noteText.length
  }
 
  const prompt = parts.join('\n')
  return { prompt, totalTokens: estimateTokens(prompt), truncated }
}
 
// --- API types ---
 
export interface ChatMessage {
  role: 'user' | 'assistant'
  content: string
  id: string
}
 
let msgIdCounter = 0
export function nextMessageId(): string {
  return `msg-${++msgIdCounter}-${Date.now()}`
}
 
// --- SSE parsing ---
 
function parseSseEvent(line: string, onChunk: (text: string) => void): boolean {
  if (!line.startsWith('data: ')) return false
  const data = line.slice(6)
  if (data === '[DONE]') return true
 
  try {
    const event = JSON.parse(data)
    if (event.type === 'content_block_delta' && event.delta?.text) {
      onChunk(event.delta.text)
    }
    if (event.type === 'message_stop') return true
  } catch {
    // skip malformed events
  }
  return false
}
 
async function readSseStream(
  reader: ReadableStreamDefaultReader<Uint8Array>,
  onChunk: (text: string) => void,
): Promise<void> {
  const decoder = new TextDecoder()
  let buffer = ''
 
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
 
    buffer += decoder.decode(value, { stream: true })
    const lines = buffer.split('\n')
    buffer = lines.pop() ?? ''
 
    for (const line of lines) {
      if (parseSseEvent(line, onChunk)) return
    }
  }
}
 
async function parseApiError(response: Response): Promise<string> {
  const errText = await response.text()
  try {
    const errJson = JSON.parse(errText)
    return errJson.error?.message || errJson.error || `API error (${response.status})`
  } catch {
    return `API error (${response.status})`
  }
}
 
// --- Streaming API call ---
 
export async function streamChat(
  messages: { role: 'user' | 'assistant'; content: string }[],
  systemPrompt: string,
  model: string,
  onChunk: (text: string) => void,
  onDone: () => void,
  onError: (error: string) => void,
): Promise<void> {
  const apiKey = getApiKey()
  if (!apiKey) {
    onError('No API key configured. Click the key icon to set your Anthropic API key.')
    return
  }
 
  try {
    const response = await fetch('/api/ai/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        apiKey, model, messages,
        system: systemPrompt || undefined,
        maxTokens: 4096,
      }),
    })
 
    if (!response.ok) {
      onError(await parseApiError(response))
      return
    }
 
    const reader = response.body?.getReader()
    if (!reader) {
      onError('No response body')
      return
    }
 
    await readSseStream(reader, onChunk)
    onDone()
  } catch (err: any) {
    onError(err.message || 'Network error')
  }
}
 
// --- Model options ---
export const MODEL_OPTIONS = [
  { value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' },
  { value: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
  { value: 'claude-opus-4-20250514', label: 'Opus 4' },
] as const