- Updated ChatHeader and ChatMessage components to support song selection and display. - Enhanced useContentPanel to handle both film and song content, allowing for richer interactions. - Improved FilmCard and added SongCard components for better media representation. - Refactored environment variables for better configuration management. - Updated .gitignore to include development chat history. Made-with: Cursor
271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
import { ref, computed } from 'vue'
|
|
import { useChatStore } from '@/stores/chat'
|
|
|
|
type Provider = 'claude' | 'openrouter' | 'mock'
|
|
|
|
const CLAUDE_PATH = '/api/claude/v1/messages'
|
|
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
|
|
|
import { mockFilms } from '@/mocks/films'
|
|
import { mockSongs } from '@/mocks/songs'
|
|
|
|
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')
|
|
|
|
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')
|
|
|
|
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.
|
|
|
|
The user's film library:
|
|
${filmContext}
|
|
|
|
The user's song library:
|
|
${songContext}`
|
|
|
|
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
|
const hasOpenRouter = !!openrouterApiKey
|
|
|
|
const activeProvider = ref<Provider>('claude')
|
|
|
|
const activeModel = ref('claude-sonnet-4')
|
|
|
|
const availableProviders = computed(() => {
|
|
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
|
|
{
|
|
id: 'claude',
|
|
name: 'Claude (Max)',
|
|
models: [
|
|
{ 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' },
|
|
],
|
|
},
|
|
]
|
|
if (hasOpenRouter) {
|
|
providers.push({
|
|
id: 'openrouter',
|
|
name: 'OpenRouter',
|
|
models: [
|
|
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
|
|
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
|
|
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
|
|
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
|
|
],
|
|
})
|
|
}
|
|
providers.push({
|
|
id: 'mock',
|
|
name: 'Local (no API)',
|
|
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
|
|
})
|
|
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 ChatMessage {
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
async function streamClaude(
|
|
messages: ChatMessage[],
|
|
onToken: (text: string) => void,
|
|
onError: (err: string) => void,
|
|
): Promise<void> {
|
|
const res = await fetch(CLAUDE_PATH, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: activeModel.value,
|
|
system: SYSTEM_PROMPT,
|
|
messages,
|
|
stream: true,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => 'Could not read error body')
|
|
onError(`Claude proxy error ${res.status}: ${body}`)
|
|
return
|
|
}
|
|
|
|
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')
|
|
}
|
|
}, onError)
|
|
}
|
|
|
|
async function streamOpenRouter(
|
|
messages: ChatMessage[],
|
|
onToken: (text: string) => void,
|
|
onError: (err: string) => void,
|
|
): 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, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${openrouterApiKey}`,
|
|
'HTTP-Referer': window.location.origin,
|
|
'X-Title': 'AIUI',
|
|
},
|
|
body: JSON.stringify({
|
|
model: activeModel.value,
|
|
messages: orMessages,
|
|
stream: true,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => 'Could not read error body')
|
|
onError(`OpenRouter error ${res.status}: ${body}`)
|
|
return
|
|
}
|
|
|
|
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> {
|
|
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 payload = trimmed.slice(6)
|
|
if (payload === '[DONE]') return
|
|
try {
|
|
onData(payload)
|
|
} catch {
|
|
// skip malformed chunks
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function useAI() {
|
|
const chatStore = useChatStore()
|
|
|
|
async function sendMessage(userText: string) {
|
|
const provider = activeProvider.value
|
|
|
|
let convId = chatStore.activeConversationId
|
|
if (!convId) {
|
|
convId = chatStore.createConversation()
|
|
}
|
|
const cid = convId
|
|
|
|
chatStore.addMessage(cid, { role: 'user', content: userText })
|
|
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
|
if (!assistantMsg) return
|
|
|
|
chatStore.isStreaming = true
|
|
|
|
const history: ChatMessage[] = 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(cid, text)
|
|
const onError = (err: string) => {
|
|
console.error(`[AIUI ${provider}]`, err)
|
|
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
|
}
|
|
|
|
try {
|
|
if (provider === 'claude') {
|
|
await streamClaude(history, onToken, onError)
|
|
} else if (provider === 'openrouter') {
|
|
await streamOpenRouter(history, onToken, onError)
|
|
} else {
|
|
await streamMock(history, onToken)
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
console.error(`[AIUI] Connection error:`, err)
|
|
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
|
|
} finally {
|
|
chatStore.isStreaming = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
sendMessage,
|
|
activeProvider,
|
|
activeModel,
|
|
availableProviders,
|
|
setProvider,
|
|
setModel,
|
|
}
|
|
}
|