diff --git a/.env.example b/.env.example index dc0cad9d..cdae9449 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ # Get your key at: https://openrouter.ai/keys VITE_OPENROUTER_API_KEY=sk-or-your-key-here +# Anthropic Claude (direct API access) +# Get your key at: https://console.anthropic.com/settings/keys +VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here + # TMDB API (free, for real film poster images) # Get your key at: https://www.themoviedb.org/settings/api VITE_TMDB_API_KEY=your-tmdb-key-here diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index e1f4d0fd..a0cee5b8 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -7,7 +7,16 @@

{{ title }}

-

{{ conversationId }}

+
+

{{ conversationId }}

+ · + +
@@ -45,10 +54,39 @@ + + +
+
+

+ {{ provider.name }} +

+
+ +
+
+
+
+ + diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 13028651..dca0f3a4 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -1,19 +1,211 @@ +import { ref, computed } from 'vue' import { useChatStore } from '@/stores/chat' +type Provider = 'anthropic' | 'openrouter' + +const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages' const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' +const SYSTEM_PROMPT = 'You are AIUI, a helpful AI assistant. Be concise and helpful. When discussing films, provide rich details including genre, year, director, and rating.' + +const activeProvider = ref( + import.meta.env.VITE_ANTHROPIC_API_KEY ? 'anthropic' : 'openrouter' +) + +const activeModel = ref( + import.meta.env.VITE_ANTHROPIC_API_KEY ? 'claude-sonnet-4-20250514' : 'meta-llama/llama-4-maverick:free' +) + +const availableProviders = computed(() => { + const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [] + if (import.meta.env.VITE_ANTHROPIC_API_KEY) { + providers.push({ + id: 'anthropic', + name: 'Anthropic', + models: [ + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' }, + { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, + { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, + ], + }) + } + if (import.meta.env.VITE_OPENROUTER_API_KEY) { + providers.push({ + id: 'openrouter', + name: 'OpenRouter', + models: [ + { id: 'meta-llama/llama-4-maverick:free', name: 'Llama 4 Maverick (free)' }, + { id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)' }, + { id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' }, + { id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4 (paid)' }, + ], + }) + } + 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 AnthropicMessage { + role: 'user' | 'assistant' + content: string +} + interface OpenRouterMessage { role: 'system' | 'user' | 'assistant' content: string } +async function streamAnthropic( + messages: AnthropicMessage[], + onToken: (text: string) => void, + onError: (err: string) => void, +) { + const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY + const res = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify({ + model: activeModel.value, + max_tokens: 4096, + system: SYSTEM_PROMPT, + messages, + stream: true, + }), + }) + + if (!res.ok) { + const err = await res.text() + onError(`Error ${res.status}: ${err}`) + return + } + + 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 === 'event: ping') continue + + if (trimmed.startsWith('data: ')) { + const data = trimmed.slice(6) + try { + 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 ?? 'Stream error') + } + } catch { + // skip non-JSON lines (event type headers etc.) + } + } + } + } +} + +async function streamOpenRouter( + messages: OpenRouterMessage[], + onToken: (text: string) => void, + onError: (err: string) => void, +) { + const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY + const res = await fetch(OPENROUTER_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'HTTP-Referer': window.location.origin, + 'X-Title': 'AIUI', + }, + body: JSON.stringify({ + model: activeModel.value, + messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...messages], + stream: true, + }), + }) + + if (!res.ok) { + const err = await res.text() + onError(`Error ${res.status}: ${err}`) + return + } + + 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 data = trimmed.slice(6) + if (data === '[DONE]') break + + try { + const parsed = JSON.parse(data) + const delta = parsed.choices?.[0]?.delta?.content + if (delta) onToken(delta) + } catch { + // skip malformed chunks + } + } + } +} + export function useAI() { const chatStore = useChatStore() async function sendMessage(userText: string) { - const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY - if (!apiKey) { - console.error('VITE_OPENROUTER_API_KEY not set in .env.local') + const provider = activeProvider.value + const hasKey = + provider === 'anthropic' + ? !!import.meta.env.VITE_ANTHROPIC_API_KEY + : !!import.meta.env.VITE_OPENROUTER_API_KEY + + if (!hasKey) { + console.error(`No API key set for ${provider}`) return } @@ -23,86 +215,44 @@ export function useAI() { } chatStore.addMessage(convId, { role: 'user', content: userText }) - const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' }) if (!assistantMsg) return chatStore.isStreaming = true - const history: OpenRouterMessage[] = chatStore.messages + const history = 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(convId, text) + const onError = (err: string) => chatStore.appendToLastMessage(convId, err) + try { - const res = await fetch(OPENROUTER_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - 'HTTP-Referer': window.location.origin, - 'X-Title': 'AIUI', - }, - body: JSON.stringify({ - model: 'meta-llama/llama-4-maverick:free', - messages: [ - { - role: 'system', - content: 'You are AIUI, a helpful AI assistant. Be concise and helpful. When discussing films, provide rich details including genre, year, director, and rating.', - }, - ...history, - ], - stream: true, - }), - }) - - if (!res.ok) { - const err = await res.text() - chatStore.appendToLastMessage(convId, `Error: ${res.status} — ${err}`) - chatStore.isStreaming = false - return - } - - const reader = res.body?.getReader() - const decoder = new TextDecoder() - - if (!reader) { - chatStore.appendToLastMessage(convId, 'Error: No response body') - chatStore.isStreaming = false - return - } - - 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 data = trimmed.slice(6) - if (data === '[DONE]') break - - try { - const parsed = JSON.parse(data) - const delta = parsed.choices?.[0]?.delta?.content - if (delta) { - chatStore.appendToLastMessage(convId, delta) - } - } catch { - // skip malformed chunks - } - } + if (provider === 'anthropic') { + await streamAnthropic(history, onToken, onError) + } else { + const orHistory = history.map((m) => ({ + ...m, + role: m.role as 'system' | 'user' | 'assistant', + })) + await streamOpenRouter(orHistory, onToken, onError) } } catch (err) { - chatStore.appendToLastMessage(convId, `\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}`) + chatStore.appendToLastMessage( + convId, + `\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}` + ) } finally { chatStore.isStreaming = false } } - return { sendMessage } + return { + sendMessage, + activeProvider, + activeModel, + availableProviders, + setProvider, + setModel, + } } diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 9cfd7483..e76e6db1 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -8,6 +8,7 @@ declare module '*.vue' { interface ImportMetaEnv { readonly VITE_OPENROUTER_API_KEY: string + readonly VITE_ANTHROPIC_API_KEY: string readonly VITE_TMDB_API_KEY: string readonly VITE_DEV_MODE: string readonly VITE_MOCK_MEDIA_SOURCES: string diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index 7fb43d45..4e6aa433 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -48,7 +48,7 @@
- OpenRouter connected + {{ providerLabel }}
@@ -67,10 +67,20 @@ import { computed, onMounted } from 'vue' import { useChatStore } from '@/stores/chat' import { useTheme } from '@/composables/useTheme' +import { useAI } from '@/composables/useAI' import ChatWindow from '@/components/chat/ChatWindow.vue' const chatStore = useChatStore() const { initTheme } = useTheme() +const { activeProvider } = useAI() + +const providerLabel = computed(() => { + const labels: Record = { + anthropic: 'Claude connected', + openrouter: 'OpenRouter connected', + } + return labels[activeProvider.value] ?? 'Connected' +}) const panelSide = computed(() => chatStore.panelSide) const conversations = computed(() => chatStore.conversationList)