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 16:34:44 +00:00
type Provider = 'claude' | 'openrouter' | 'mock'
2026-03-02 14:30:19 +00:00
2026-03-02 16:34:44 +00:00
const CLAUDE_PATH = '/api/claude/v1/messages'
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
2026-03-02 14:20:34 +00:00
2026-03-02 16:48:17 +00:00
import { mockFilms } from '@/mocks/films'
2026-03-02 18:16:04 +00:00
import { mockSongs } from '@/mocks/songs'
2026-03-02 16:48:17 +00:00
const filmContext = mockFilms . map (( f ) =>
`- [ ${ f . id } ] " ${ f . title } " ( ${ f . year } ) dir. ${ f . director } | ${ f . genres . join ( ', ' ) } | ${ f . rating } /10 | On: ${ f . sources . map ( s => s . type ). join ( ', ' ) } `
). join ( '\n' )
2026-03-02 18:16:04 +00:00
const songContext = mockSongs . map (( s ) =>
`- [ ${ s . id } ] " ${ s . title } " by ${ s . artist }${ s . album ? ` ( ${ s . album } )` : '' }${ s . year ? ` ( ${ s . year } )` : '' } | ${ ( s . genres ?? []). join ( ', ' ) } | On: ${ ( s . sources ?? []). map ( x => x . type ). join ( ', ' ) } `
). join ( '\n' )
2026-03-02 14:30:19 +00:00
2026-03-02 18:16:04 +00:00
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films and songs).
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
**Songs:** When recommending or discussing songs from the user's library, use [[song:ID]] where ID is the song's id. For songs NOT in the library, use [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
Always include these tags so the UI can render rich cards. You may recommend multiple items. Write a brief reason why each is worth checking out.
2026-03-02 14:30:19 +00:00
2026-03-02 16:34:44 +00:00
The user's film library:
2026-03-02 16:48:17 +00:00
${ filmContext }
2026-03-02 16:34:44 +00:00
2026-03-02 18:16:04 +00:00
The user's song library:
${ songContext } `
2026-03-02 16:34:44 +00:00
const openrouterApiKey = import . meta . env . VITE_OPENROUTER_API_KEY ?? ''
const hasOpenRouter = !! openrouterApiKey
const activeProvider = ref < Provider >( 'claude' )
const activeModel = ref ( 'claude-sonnet-4' )
2026-03-02 14:30:19 +00:00
const availableProviders = computed (() => {
2026-03-02 16:34:44 +00:00
const providers : { id : Provider ; name : string ; models : { id : string ; name : string }[] }[] = [
{
id : 'claude' ,
name : 'Claude (Max)' ,
2026-03-02 14:30:19 +00:00
models : [
2026-03-02 16:34:44 +00:00
{ id : 'claude-sonnet-4' , name : 'Claude Sonnet 4' },
{ id : 'claude-opus-4' , name : 'Claude Opus 4' },
{ id : 'claude-haiku-3.5' , name : 'Claude 3.5 Haiku' },
2026-03-02 14:30:19 +00:00
],
2026-03-02 16:34:44 +00:00
},
]
if ( hasOpenRouter ) {
2026-03-02 14:30:19 +00:00
providers . push ({
id : 'openrouter' ,
name : 'OpenRouter' ,
models : [
2026-03-02 16:34:44 +00:00
{ id : 'meta-llama/llama-4-maverick' , name : 'Llama 4 Maverick' },
{ id : 'qwen/qwen3-235b-a22b-thinking-2507' , name : 'Qwen3 235B Thinking' },
2026-03-02 14:30:19 +00:00
{ id : 'mistralai/mistral-small-3.1-24b-instruct:free' , name : 'Mistral Small 3.1 (free)' },
2026-03-02 16:34:44 +00:00
{ id : 'google/gemma-3-27b-it:free' , name : 'Gemma 3 27B (free)' },
2026-03-02 14:30:19 +00:00
],
})
}
2026-03-02 16:34:44 +00:00
providers . push ({
id : 'mock' ,
name : 'Local (no API)' ,
models : [{ id : 'echo' , name : 'Echo (mirror input)' }],
})
2026-03-02 14:30:19 +00:00
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
}
2026-03-02 16:34:44 +00:00
interface ChatMessage {
2026-03-02 14:30:19 +00:00
role : 'user' | 'assistant'
content : string
}
2026-03-02 16:34:44 +00:00
async function streamMock (
messages : ChatMessage [],
onToken : ( text : string ) => void ,
) : Promise < void > {
const lastUser = messages . filter (( m ) => m . role === 'user' ). pop ()
const text = lastUser
? `You said: " ${ lastUser . content } " \ n \ nThis is AIUI in echo mode. Select Claude or OpenRouter from the model picker.`
: 'Hello! I am AIUI running in mock mode.'
for ( const char of text ) {
onToken ( char )
await new Promise (( r ) => setTimeout ( r , 12 ))
}
2026-03-02 14:20:34 +00:00
}
2026-03-02 16:34:44 +00:00
async function streamClaude (
messages : ChatMessage [],
2026-03-02 14:30:19 +00:00
onToken : ( text : string ) => void ,
onError : ( err : string ) => void ,
2026-03-02 16:34:44 +00:00
) : Promise < void > {
const res = await fetch ( CLAUDE_PATH , {
2026-03-02 14:30:19 +00:00
method : 'POST' ,
2026-03-02 16:34:44 +00:00
headers : { 'Content-Type' : 'application/json' },
2026-03-02 14:30:19 +00:00
body : JSON.stringify ({
model : activeModel.value ,
system : SYSTEM_PROMPT ,
messages ,
stream : true ,
}),
})
if ( ! res . ok ) {
2026-03-02 16:34:44 +00:00
const body = await res . text (). catch (() => 'Could not read error body' )
onError ( `Claude proxy error ${ res . status } : ${ body } ` )
2026-03-02 14:30:19 +00:00
return
}
2026-03-02 16:34:44 +00:00
await readSSE ( res , ( data ) => {
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 ?? 'Claude stream error' )
2026-03-02 14:30:19 +00:00
}
2026-03-02 16:34:44 +00:00
}, onError )
2026-03-02 14:30:19 +00:00
}
async function streamOpenRouter (
2026-03-02 16:34:44 +00:00
messages : ChatMessage [],
2026-03-02 14:30:19 +00:00
onToken : ( text : string ) => void ,
onError : ( err : string ) => void ,
2026-03-02 16:34:44 +00:00
) : Promise < void > {
if ( ! openrouterApiKey ) {
onError ( 'Missing VITE_OPENROUTER_API_KEY in .env.local' )
return
}
const orMessages = [
{ role : 'system' as const , content : SYSTEM_PROMPT },
... messages . map (( m ) => ({ role : m.role as 'user' | 'assistant' , content : m.content })),
]
const res = await fetch ( OPENROUTER_PATH , {
2026-03-02 14:30:19 +00:00
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
2026-03-02 16:34:44 +00:00
'Authorization' : `Bearer ${ openrouterApiKey } ` ,
2026-03-02 14:30:19 +00:00
'HTTP-Referer' : window . location . origin ,
'X-Title' : 'AIUI' ,
},
body : JSON.stringify ({
model : activeModel.value ,
2026-03-02 16:34:44 +00:00
messages : orMessages ,
2026-03-02 14:30:19 +00:00
stream : true ,
}),
})
if ( ! res . ok ) {
2026-03-02 16:34:44 +00:00
const body = await res . text (). catch (() => 'Could not read error body' )
onError ( `OpenRouter error ${ res . status } : ${ body } ` )
2026-03-02 14:30:19 +00:00
return
}
2026-03-02 16:34:44 +00:00
await readSSE ( res , ( data ) => {
if ( data === '[DONE]' ) return
const parsed = JSON . parse ( data )
const delta = parsed . choices ? .[ 0 ] ? . delta ? . content
if ( delta ) onToken ( delta )
}, onError )
}
async function readSSE (
res : Response ,
onData : ( data : string ) => void ,
onError : ( err : string ) => void ,
) : Promise < void > {
2026-03-02 14:30:19 +00:00
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
2026-03-02 16:34:44 +00:00
const payload = trimmed . slice ( 6 )
if ( payload === '[DONE]' ) return
2026-03-02 14:30:19 +00:00
try {
2026-03-02 16:34:44 +00:00
onData ( payload )
2026-03-02 14:30:19 +00:00
} 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
2026-03-02 14:20:34 +00:00
let convId = chatStore . activeConversationId
if ( ! convId ) {
convId = chatStore . createConversation ()
}
2026-03-02 16:34:44 +00:00
const cid = convId
2026-03-02 14:20:34 +00:00
2026-03-02 16:34:44 +00:00
chatStore . addMessage ( cid , { role : 'user' , content : userText })
const assistantMsg = chatStore . addMessage ( cid , { role : 'assistant' , content : '' })
2026-03-02 14:20:34 +00:00
if ( ! assistantMsg ) return
chatStore . isStreaming = true
2026-03-02 16:34:44 +00:00
const history : ChatMessage [] = 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 16:34:44 +00:00
const onToken = ( text : string ) => chatStore . appendToLastMessage ( cid , text )
const onError = ( err : string ) => {
console . error ( `[AIUI ${ provider } ]` , err )
chatStore . appendToLastMessage ( cid , `⚠ ${ err } ` )
}
2026-03-02 14:30:19 +00:00
2026-03-02 14:20:34 +00:00
try {
2026-03-02 16:34:44 +00:00
if ( provider === 'claude' ) {
await streamClaude ( history , onToken , onError )
} else if ( provider === 'openrouter' ) {
await streamOpenRouter ( history , onToken , onError )
2026-03-02 14:30:19 +00:00
} else {
2026-03-02 16:34:44 +00:00
await streamMock ( history , onToken )
2026-03-02 14:20:34 +00:00
}
} catch ( err ) {
2026-03-02 16:34:44 +00:00
const msg = err instanceof Error ? err.message : String ( err )
console . error ( `[AIUI] Connection error:` , err )
chatStore . appendToLastMessage ( cid , ` \ n \ n⚠ Connection error: ${ msg } ` )
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
}