2026-03-02 14:30:19 +00:00
import { ref , computed } from 'vue'
2026-03-02 14:20:34 +00:00
import { useChatStore } from '@/stores/chat'
2026-03-02 14:30:19 +00:00
type Provider = 'anthropic' | 'openrouter'
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'
2026-03-02 14:20:34 +00:00
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
2026-03-02 14:30:19 +00:00
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
}
2026-03-02 14:20:34 +00:00
interface OpenRouterMessage {
role : 'system' | 'user' | 'assistant'
content : string
}
2026-03-02 14:30:19 +00:00
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
}
}
}
}
2026-03-02 14:20:34 +00:00
export function useAI() {
const chatStore = useChatStore ()
async function sendMessage ( userText : string ) {
2026-03-02 14:30:19 +00:00
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 } ` )
2026-03-02 14:20:34 +00:00
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
2026-03-02 14:30:19 +00:00
const history = chatStore . messages
2026-03-02 14:20:34 +00:00
. filter (( m ) => m . id !== assistantMsg . id )
. map (( m ) => ({ role : m.role as 'user' | 'assistant' , content : m.content }))
2026-03-02 14:30:19 +00:00
const onToken = ( text : string ) => chatStore . appendToLastMessage ( convId , text )
const onError = ( err : string ) => chatStore . appendToLastMessage ( convId , err )
2026-03-02 14:20:34 +00:00
try {
2026-03-02 14:30:19 +00:00
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 )
2026-03-02 14:20:34 +00:00
}
} catch ( err ) {
2026-03-02 14:30:19 +00:00
chatStore . appendToLastMessage (
convId ,
` \ n \ nConnection error: ${ err instanceof Error ? err . message : 'Unknown error' } `
)
2026-03-02 14:20:34 +00:00
} finally {
chatStore . isStreaming = false
}
}
2026-03-02 14:30:19 +00:00
return {
sendMessage ,
activeProvider ,
activeModel ,
availableProviders ,
setProvider ,
setModel ,
}
2026-03-02 14:20:34 +00:00
}