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 }}
+
·
+
+ {{ modelDisplayName }}
+
+
@@ -45,10 +54,39 @@
+
+
+
+
+
+ {{ provider.name }}
+
+
+
+ {{ model.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)