import { ref, computed } from 'vue' import { useChatStore } from '@/stores/chat' type Provider = 'claude' | 'openrouter' | 'mock' const CLAUDE_PATH = '/api/claude/v1/messages' const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions' import { mockFilms } from '@/mocks/films' import { mockSongs } from '@/mocks/songs' const filmContext = mockFilms.map((f) => `- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | On: ${f.sources.map(s => s.type).join(', ')}` ).join('\n') const songContext = mockSongs.map((s) => `- [${s.id}] "${s.title}" by ${s.artist}${s.album ? ` (${s.album})` : ''}${s.year ? ` (${s.year})` : ''} | ${(s.genres ?? []).join(', ')} | On: ${(s.sources ?? []).map(x => x.type).join(', ')}` ).join('\n') const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films and songs). **Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]]. **Songs:** When recommending or discussing songs from the user's library, use [[song:ID]] where ID is the song's id. For songs NOT in the library, use [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]]. Always include these tags so the UI can render rich cards. You may recommend multiple items. Write a brief reason why each is worth checking out. The user's film library: ${filmContext} The user's song library: ${songContext}` const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? '' const hasOpenRouter = !!openrouterApiKey const activeProvider = ref('claude') const activeModel = ref('claude-sonnet-4') const availableProviders = computed(() => { const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ { id: 'claude', name: 'Claude (Max)', models: [ { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, { id: 'claude-opus-4', name: 'Claude Opus 4' }, { id: 'claude-haiku-3.5', name: 'Claude 3.5 Haiku' }, ], }, ] if (hasOpenRouter) { providers.push({ id: 'openrouter', name: 'OpenRouter', models: [ { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, { id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' }, { id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' }, { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' }, ], }) } providers.push({ id: 'mock', name: 'Local (no API)', models: [{ id: 'echo', name: 'Echo (mirror input)' }], }) return providers }) function setProvider(provider: Provider) { activeProvider.value = provider const p = availableProviders.value.find((pp) => pp.id === provider) if (p && p.models.length > 0) { activeModel.value = p.models[0].id } } function setModel(model: string) { activeModel.value = model } interface ChatMessage { role: 'user' | 'assistant' content: string } async function streamMock( messages: ChatMessage[], onToken: (text: string) => void, ): Promise { const lastUser = messages.filter((m) => m.role === 'user').pop() const text = lastUser ? `You said: "${lastUser.content}"\n\nThis is AIUI in echo mode. Select Claude or OpenRouter from the model picker.` : 'Hello! I am AIUI running in mock mode.' for (const char of text) { onToken(char) await new Promise((r) => setTimeout(r, 12)) } } async function streamClaude( messages: ChatMessage[], onToken: (text: string) => void, onError: (err: string) => void, ): Promise { const res = await fetch(CLAUDE_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: activeModel.value, system: SYSTEM_PROMPT, messages, stream: true, }), }) if (!res.ok) { const body = await res.text().catch(() => 'Could not read error body') onError(`Claude proxy error ${res.status}: ${body}`) return } await readSSE(res, (data) => { const parsed = JSON.parse(data) if (parsed.type === 'content_block_delta' && parsed.delta?.text) { onToken(parsed.delta.text) } else if (parsed.type === 'error') { onError(parsed.error?.message ?? 'Claude stream error') } }, onError) } async function streamOpenRouter( messages: ChatMessage[], onToken: (text: string) => void, onError: (err: string) => void, ): Promise { if (!openrouterApiKey) { onError('Missing VITE_OPENROUTER_API_KEY in .env.local') return } const orMessages = [ { role: 'system' as const, content: SYSTEM_PROMPT }, ...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })), ] const res = await fetch(OPENROUTER_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openrouterApiKey}`, 'HTTP-Referer': window.location.origin, 'X-Title': 'AIUI', }, body: JSON.stringify({ model: activeModel.value, messages: orMessages, stream: true, }), }) if (!res.ok) { const body = await res.text().catch(() => 'Could not read error body') onError(`OpenRouter error ${res.status}: ${body}`) return } await readSSE(res, (data) => { if (data === '[DONE]') return const parsed = JSON.parse(data) const delta = parsed.choices?.[0]?.delta?.content if (delta) onToken(delta) }, onError) } async function readSSE( res: Response, onData: (data: string) => void, onError: (err: string) => void, ): Promise { const reader = res.body?.getReader() if (!reader) { onError('No response body') return } const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = 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) { const trimmed = line.trim() if (!trimmed || !trimmed.startsWith('data: ')) continue const payload = trimmed.slice(6) if (payload === '[DONE]') return try { onData(payload) } catch { // skip malformed chunks } } } } export function useAI() { const chatStore = useChatStore() async function sendMessage(userText: string) { const provider = activeProvider.value let convId = chatStore.activeConversationId if (!convId) { convId = chatStore.createConversation() } const cid = convId chatStore.addMessage(cid, { role: 'user', content: userText }) const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' }) if (!assistantMsg) return chatStore.isStreaming = true const history: ChatMessage[] = chatStore.messages .filter((m) => m.id !== assistantMsg.id) .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })) const onToken = (text: string) => chatStore.appendToLastMessage(cid, text) const onError = (err: string) => { console.error(`[AIUI ${provider}]`, err) chatStore.appendToLastMessage(cid, `⚠ ${err}`) } try { if (provider === 'claude') { await streamClaude(history, onToken, onError) } else if (provider === 'openrouter') { await streamOpenRouter(history, onToken, onError) } else { await streamMock(history, onToken) } } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.error(`[AIUI] Connection error:`, err) chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`) } finally { chatStore.isStreaming = false } } return { sendMessage, activeProvider, activeModel, availableProviders, setProvider, setModel, } }