Files
archy/packages/app/src/composables/useAI.ts
T

259 lines
6.9 KiB
TypeScript
Raw Normal View History

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 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
}
let convId = chatStore.activeConversationId
if (!convId) {
convId = chatStore.createConversation()
}
chatStore.addMessage(convId, { role: 'user', content: userText })
const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
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 {
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'}`
)
} finally {
chatStore.isStreaming = false
}
}
return {
sendMessage,
activeProvider,
activeModel,
availableProviders,
setProvider,
setModel,
}
}