Files
archy/packages/app/src/composables/useAI.ts
T
DorianandClaude Opus 4.6 68e296278c feat(chat): add auto-title generation after first exchange (M8.5)
After the first AI response, sends a background request to generate
a 3-5 word title using Claude Haiku. Silently replaces the default
title derived from the first user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:10:46 +00:00

525 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
import { getApiKey } from '@/utils/key-vault'
type Provider = 'claude' | 'openrouter' | 'mock'
const CLAUDE_PATH = '/api/claude/v1/messages'
const OPENROUTER_PATH = '/api/openrouter'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
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 podcastContext = mockPodcasts.map((p) =>
`- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.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, songs, and podcasts).
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
**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]]. Write a brief reason why the film is worth watching on the same line as the tag.
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
- Library songs: [[song:ID]] where ID is the song's id (e.g. [[song:s1]]).
- Other songs: [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
Never list songs in plain text only—each recommendation must have a tag so the UI can show playable cards.
**Podcasts:** When recommending or discussing podcasts, use tags:
- Library podcasts: [[podcast:ID]] where ID is the podcast's id (e.g. [[podcast:p1]]).
- Other podcasts: [[podcast_ext:Title|Host|Year]] (year optional), e.g. [[podcast_ext:What Bitcoin Did|Peter McCormack|2018]].
Prioritize Podcasting 2.0friendly platforms: Fountain.fm, Podcast Index, Castopod, Odysee, Rumble, YouTube, Podverse.
**Books:** When recommending or discussing books, use [[book_ext:Title|Author|Year]], e.g. [[book_ext:Neuromancer|William Gibson|1984]]. Write a brief reason why the book is worth reading on the same line.
**TV Series:** When recommending or discussing TV series/shows, use [[tv_ext:Title|Year|Creator]], e.g. [[tv_ext:Breaking Bad|2008|Vince Gilligan]]. Do NOT use [[film_ext:...]] for TV series — use [[tv_ext:...]] instead. Write a brief reason why the show is worth watching on the same line.
**Places/Restaurants:** When recommending restaurants, cafes, bars, or other places to visit, use [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]], e.g. [[place_ext:Sushi Nakazawa|Japanese|New York|4.7|3|23 Commerce St]]. Rating is out of 5, PriceLevel is 1-4 ($ to $$$$). Omit fields you don't know. Write a brief description on the same line.
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
The user's film library:
${filmContext}
The user's song library:
${songContext}
The user's podcast library:
${podcastContext}`
const activeProvider = ref<Provider>('claude')
const activeModel = ref('claude-haiku-4.5')
const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'claude',
name: 'Claude (Max)',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
{ id: 'claude-opus-4', name: 'Claude Opus 4' },
],
},
]
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,
signal?: AbortSignal,
): 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) {
if (signal?.aborted) return
onToken(char)
await new Promise((r) => setTimeout(r, 12))
}
}
async function streamClaude(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
webSearch: boolean,
signal?: AbortSignal,
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
// Use vault key if available, proxy uses its own key as fallback
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
}
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: activeModel.value,
system: systemPrompt,
messages,
stream: true,
webSearch,
}),
signal,
})
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, signal)
}
async function streamOpenRouter(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const orMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'AIUI',
}
// Use vault key if available, otherwise proxy handles auth
const vaultKey = await getApiKey('openrouter')
if (vaultKey) {
headers['Authorization'] = `Bearer ${vaultKey}`
}
const res = await fetch(OPENROUTER_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: activeModel.value,
messages: orMessages,
stream: true,
}),
signal,
})
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, signal)
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
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
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}
function formatWebSearchContext(results: { title: string; url: string; content?: string }[]): string {
if (results.length === 0) return ''
const lines = results.map((r, i) => {
const snippet = r.content ? ` — ${r.content.slice(0, 200)}${r.content.length > 200 ? '…' : ''}` : ''
return `${i + 1}. [${r.title}](${r.url})${snippet}`
})
return `\n\n**Web search results (PRIORITIZE these):**
- Answer the user's question using these results. Cite sources.
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
let currentAbort: AbortController | null = null
/** Background title generation after first exchange */
async function generateAutoTitle(conversationId: string) {
const chatStore = useChatStore()
const conv = chatStore.conversations.get(conversationId)
if (!conv) return
// Only auto-title after first exchange (1 user + 1 assistant message)
if (conv.messages.length !== 2) return
const userMsg = conv.messages[0]
if (userMsg.role !== 'user') return
// Skip if title was manually set (not auto-generated from first message)
const autoTitle = userMsg.content.slice(0, 60) + (userMsg.content.length > 60 ? '...' : '')
if (conv.title !== autoTitle) return
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const vaultKey = await getApiKey('claude')
if (vaultKey) headers['x-api-key'] = vaultKey
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'claude-haiku-4.5',
system: 'You generate very short conversation titles. Respond with ONLY a 3-5 word title, no quotes, no punctuation at the end.',
messages: [{ role: 'user', content: `Title this conversation: "${userMsg.content.slice(0, 200)}"` }],
max_tokens: 20,
stream: false,
}),
})
if (!res.ok) return
const data = await res.json()
const title = data?.content?.[0]?.text?.trim()
if (title && title.length > 0 && title.length < 60) {
conv.title = title
conv.updatedAt = Date.now()
}
} catch {
// Silent fail — title stays as default
}
}
export function useAI() {
const chatStore = useChatStore()
function stopGeneration() {
if (currentAbort) {
currentAbort.abort()
currentAbort = null
}
chatStore.isStreaming = false
}
async function sendMessage(userText: string) {
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
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
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
if (chatStore.webSearchEnabled && userText.trim()) {
const results = await searchWeb(userText)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
console.log('[AIUI] Injected', results.length, 'web search results into context')
} else {
console.warn('[AIUI] Web search enabled but 0 results — check browser console for [AIUI web-search] logs')
}
}
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, systemPrompt, chatStore.webSearchEnabled, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
console.error(`[AIUI] Connection error:`, err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
} finally {
currentAbort = null
chatStore.isStreaming = false
}
// Auto-title: generate a short title after first exchange
generateAutoTitle(cid)
}
/**
* Edit a user message and regenerate the AI response.
* Clears all messages after the edited message, then re-sends.
*/
async function editAndResend(messageId: string, newContent: string) {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv) return
const msgIndex = conv.messages.findIndex((m) => m.id === messageId)
if (msgIndex === -1) return
// Update the message content
chatStore.updateMessageContent(convId, messageId, newContent)
// Delete all messages after this one
chatStore.deleteMessagesAfter(convId, msgIndex + 1)
// Re-send (creates new assistant message and streams)
await resendLastUserMessage()
}
/**
* Regenerate the last assistant response.
* Deletes the last assistant message and re-sends with the same user message.
*/
async function regenerateLastResponse() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
// Find the last assistant message and remove it
const lastIndex = conv.messages.length - 1
if (conv.messages[lastIndex].role === 'assistant') {
chatStore.deleteMessagesAfter(convId, lastIndex)
}
await resendLastUserMessage()
}
/** Internal: re-send from the current last user message */
async function resendLastUserMessage() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
const lastUserMsg = [...conv.messages].reverse().find((m) => m.role === 'user')
if (!lastUserMsg) return
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
const cid = convId
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) {
const results = await searchWeb(lastUserMsg.content)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
}
}
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, systemPrompt, chatStore.webSearchEnabled, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
} finally {
currentAbort = null
chatStore.isStreaming = false
}
}
return {
sendMessage,
stopGeneration,
editAndResend,
regenerateLastResponse,
activeProvider,
activeModel,
availableProviders,
setProvider,
setModel,
}
}