feat(ai): add Anthropic Claude adapter as primary AI provider
Wire up Claude Messages API with SSE streaming, supporting the different event format (content_block_delta) vs OpenRouter's OpenAI-compatible format. Claude is the default when its API key is present. - Dual provider system: Anthropic (direct) and OpenRouter - Claude streams via content_block_delta events, OpenRouter via choices[0].delta.content - Model picker dropdown in chat header (click model name to switch) - Available models: Claude Sonnet 4, Opus 4, Haiku 3.5, Llama 4 Maverick/Scout (free), Mistral Small 3.1 (free) - Sidebar status shows active provider name - anthropic-dangerous-direct-browser-access header for browser use Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold text-white/96 truncate">{{ title }}</h2>
|
||||
<p class="text-[10px] text-white/40 truncate font-mono">{{ conversationId }}</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-[10px] text-white/40 truncate font-mono">{{ conversationId }}</p>
|
||||
<span class="text-[10px] text-white/20">·</span>
|
||||
<button
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
|
||||
@click="showModelPicker = !showModelPicker"
|
||||
>
|
||||
{{ modelDisplayName }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,10 +54,39 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showModelPicker"
|
||||
class="absolute left-0 right-0 top-full z-20 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast"
|
||||
>
|
||||
<div v-for="provider in availableProviders" :key="provider.id">
|
||||
<p class="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-1.5 px-1">
|
||||
{{ provider.name }}
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
<button
|
||||
v-for="model in provider.models"
|
||||
:key="model.id"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200"
|
||||
:class="model.id === activeModel && provider.id === activeProvider
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'"
|
||||
@click="selectModel(provider.id, model.id)"
|
||||
>
|
||||
{{ model.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAI } from '@/composables/useAI'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
conversationId: string
|
||||
@@ -61,4 +99,35 @@ defineEmits<{
|
||||
newChat: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
||||
const showModelPicker = ref(false)
|
||||
|
||||
const modelDisplayName = computed(() => {
|
||||
for (const p of availableProviders.value) {
|
||||
const m = p.models.find((mm) => mm.id === activeModel.value)
|
||||
if (m) return m.name
|
||||
}
|
||||
return activeModel.value
|
||||
})
|
||||
|
||||
function selectModel(providerId: string, modelId: string) {
|
||||
setProvider(providerId as 'anthropic' | 'openrouter')
|
||||
setModel(modelId)
|
||||
showModelPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-enter-active {
|
||||
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.picker-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
.picker-enter-from,
|
||||
.picker-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<Provider>(
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="p-3" style="border-top: 1px solid rgba(255, 255, 255, 0.06);">
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
||||
<span>OpenRouter connected</span>
|
||||
<span>{{ providerLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -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<string, string> = {
|
||||
anthropic: 'Claude connected',
|
||||
openrouter: 'OpenRouter connected',
|
||||
}
|
||||
return labels[activeProvider.value] ?? 'Connected'
|
||||
})
|
||||
|
||||
const panelSide = computed(() => chatStore.panelSide)
|
||||
const conversations = computed(() => chatStore.conversationList)
|
||||
|
||||
Reference in New Issue
Block a user