- Fix dev.sh unbound variable crash with ${VITE_DEV_API_TOKEN:-}
- Kill stale proxy on startup instead of skipping (token mismatch)
- Fix RSS middleware blocking all GET requests (check path before auth)
- Read dev auth token lazily from process.env (not cached at import)
- Restore network binding (host: true) for Vite dev server
- Add macOS keychain lookup for Claude Code OAuth token in proxy
- Rewrite proxy streaming to pipe SSE directly instead of await json()
- Prevent double web search (client-side + proxy) in useAI
- Reduce SearXNG timeout 6s→3s and max tries 8→3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
720 lines
25 KiB
TypeScript
720 lines
25 KiB
TypeScript
import { ref, computed } from 'vue'
|
||
import { useChatStore } from '@/stores/chat'
|
||
import { searchWeb } from '@/composables/useWebSearch'
|
||
import { getApiKey } from '@/utils/key-vault'
|
||
import type { ImageAttachment } from '@aiui/core/types/message'
|
||
import { usePersonaStore } from '@/stores/personas'
|
||
import { useMemoryStore } from '@/stores/memory'
|
||
import { useArchy } from '@/composables/useArchy'
|
||
import { useCodeContext } from '@/composables/useCodeContext'
|
||
import { apiFetch } from '@/utils/api-fetch'
|
||
|
||
type Provider = 'claude' | 'openrouter' | 'mock'
|
||
|
||
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
|
||
const BASE = import.meta.env.BASE_URL || '/'
|
||
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
|
||
const OPENROUTER_PATH = `${BASE}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')
|
||
|
||
// ─── Wavlake catalog context (fetched at runtime) ────────────
|
||
interface WavlakeCatalogTrack {
|
||
title?: string
|
||
artist?: string
|
||
albumTitle?: string
|
||
duration?: number
|
||
}
|
||
|
||
const wavlakeCatalog = ref<WavlakeCatalogTrack[]>([])
|
||
let wavlakeFetchedAt = 0
|
||
const WAVLAKE_REFRESH_INTERVAL = 30 * 60 * 1000 // 30 minutes
|
||
|
||
async function refreshWavlakeCatalog() {
|
||
if (Date.now() - wavlakeFetchedAt < WAVLAKE_REFRESH_INTERVAL && wavlakeCatalog.value.length > 0) return
|
||
try {
|
||
const BASE = import.meta.env.BASE_URL || '/'
|
||
const res = await apiFetch(`${BASE}api/music/rankings?days=30&limit=40`)
|
||
if (!res.ok) return
|
||
const data = await res.json()
|
||
if (Array.isArray(data)) {
|
||
wavlakeCatalog.value = data.map((t: Record<string, unknown>) => ({
|
||
title: t.title as string,
|
||
artist: t.artist as string,
|
||
albumTitle: t.albumTitle as string | undefined,
|
||
duration: t.duration as number | undefined,
|
||
}))
|
||
wavlakeFetchedAt = Date.now()
|
||
}
|
||
} catch {
|
||
// Silently fail — catalog is optional context
|
||
}
|
||
}
|
||
|
||
function buildWavlakeContext(): string {
|
||
if (wavlakeCatalog.value.length === 0) return ''
|
||
const lines = wavlakeCatalog.value.map((t) =>
|
||
`- "${t.title}" by ${t.artist}${t.albumTitle ? ` (${t.albumTitle})` : ''}`
|
||
)
|
||
return `\n\n**Wavlake trending tracks** (these are confirmed playable — prefer recommending from this list when relevant):\n${lines.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.0–friendly 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:** All music plays from **Wavlake** — a Lightning-powered, Nostr-native music platform. When recommending songs, prefer tracks from the Wavlake trending list (provided below) since those are confirmed playable. For genre requests, use [[song_ext:Title|Artist]] tags — the UI will search Wavlake automatically. Songs not on Wavlake won't play, so stick to Wavlake artists when you can. The user can zap (tip) artists with Lightning directly through the platform.
|
||
|
||
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
|
||
images?: ImageAttachment[]
|
||
}
|
||
|
||
/** Build Claude API content array for a message (multimodal when images present) */
|
||
function buildClaudeContent(msg: ChatMessage): string | Array<Record<string, unknown>> {
|
||
if (!msg.images || msg.images.length === 0) return msg.content
|
||
const blocks: Array<Record<string, unknown>> = []
|
||
for (const img of msg.images) {
|
||
blocks.push({
|
||
type: 'image',
|
||
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
||
})
|
||
}
|
||
if (msg.content) {
|
||
blocks.push({ type: 'text', text: msg.content })
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
interface GenerationParams {
|
||
temperature?: number
|
||
maxTokens?: number
|
||
topP?: number
|
||
stopSequences?: string[]
|
||
}
|
||
|
||
async function streamClaude(
|
||
messages: ChatMessage[],
|
||
onToken: (text: string) => void,
|
||
onError: (err: string) => void,
|
||
systemPrompt: string,
|
||
webSearch: boolean,
|
||
signal?: AbortSignal,
|
||
params?: GenerationParams,
|
||
): 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
|
||
}
|
||
|
||
// Build API messages with multimodal content arrays when images present
|
||
const apiMessages = messages.map(m => ({
|
||
role: m.role,
|
||
content: buildClaudeContent(m),
|
||
}))
|
||
|
||
const body: Record<string, unknown> = {
|
||
model: activeModel.value,
|
||
system: systemPrompt,
|
||
messages: apiMessages,
|
||
stream: true,
|
||
webSearch,
|
||
}
|
||
if (params?.temperature !== undefined) body.temperature = params.temperature
|
||
if (params?.maxTokens !== undefined) body.max_tokens = params.maxTokens
|
||
if (params?.topP !== undefined) body.top_p = params.topP
|
||
if (params?.stopSequences && params.stopSequences.length > 0) body.stop_sequences = params.stopSequences
|
||
|
||
const res = await apiFetch(CLAUDE_PATH, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
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) => {
|
||
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 ?? 'Claude stream error')
|
||
}
|
||
} catch { /* malformed SSE chunk */ }
|
||
}, 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 apiFetch(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
|
||
try {
|
||
const parsed = JSON.parse(data)
|
||
const delta = parsed.choices?.[0]?.delta?.content
|
||
if (delta) onToken(delta)
|
||
} catch { /* malformed SSE chunk */ }
|
||
}, 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
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (signal?.aborted) return
|
||
onError(err instanceof Error ? err.message : 'Stream read error')
|
||
} 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')}`
|
||
}
|
||
|
||
/** Build the system prompt, incorporating persona and memory */
|
||
function buildSystemPrompt(chatStore: ReturnType<typeof useChatStore>): string {
|
||
let prompt = SYSTEM_PROMPT
|
||
|
||
// Prepend persona system prompt if active
|
||
const personaId = chatStore.activeConversation?.personaId
|
||
if (personaId) {
|
||
const personaStore = usePersonaStore()
|
||
const persona = personaStore.getPersona(personaId)
|
||
if (persona?.systemPrompt) {
|
||
prompt = persona.systemPrompt + '\n\n' + prompt
|
||
}
|
||
}
|
||
|
||
// Append Wavlake catalog context
|
||
prompt += buildWavlakeContext()
|
||
|
||
// Append memory facts
|
||
const memoryStore = useMemoryStore()
|
||
prompt += memoryStore.buildMemoryContext()
|
||
|
||
if (chatStore.webSearchEnabled) {
|
||
prompt += `
|
||
|
||
**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.`
|
||
}
|
||
|
||
// Append Archy node context when running embedded in Archipelago
|
||
const archy = useArchy()
|
||
prompt += archy.buildArchyContext()
|
||
|
||
// Append code context when in code mode
|
||
const code = useCodeContext()
|
||
if (code.isCodeMode.value) {
|
||
const sections: string[] = []
|
||
if (code.activeProject.value) {
|
||
sections.push(`**Active project:** ${code.activeProject.value.name} (${code.activeProject.value.language ?? 'Unknown'})`)
|
||
}
|
||
if (code.selectedDesignTokens.value.length > 0) {
|
||
sections.push(`**Selected design tokens:** ${code.selectedDesignTokens.value.join(', ')}`)
|
||
}
|
||
if (code.selectedFiles.value.length > 0) {
|
||
sections.push(`**Selected files:** ${code.selectedFiles.value.join(', ')}`)
|
||
}
|
||
if (code.activeFileContent.value && code.activeFile.value) {
|
||
const content = code.activeFileContent.value.slice(0, 2000)
|
||
sections.push(`**Open file (${code.activeFile.value}):**\n\`\`\`${code.activeFileLanguage.value}\n${content}\n\`\`\``)
|
||
}
|
||
if (sections.length > 0) {
|
||
prompt += `\n\n**Code Context:**\n${sections.join('\n')}`
|
||
}
|
||
}
|
||
|
||
return prompt
|
||
}
|
||
|
||
function getConversationParams(chatStore: ReturnType<typeof useChatStore>): GenerationParams {
|
||
const conv = chatStore.activeConversation
|
||
if (!conv) return {}
|
||
return {
|
||
temperature: conv.temperature,
|
||
maxTokens: conv.maxTokens,
|
||
topP: conv.topP,
|
||
stopSequences: conv.stopSequences,
|
||
}
|
||
}
|
||
|
||
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 apiFetch(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
|
||
}
|
||
}
|
||
|
||
/** Stream a specific provider/model — used by comparison mode */
|
||
export async function streamWithModel(
|
||
provider: string,
|
||
model: string,
|
||
messages: { role: string; content: string }[],
|
||
onToken: (text: string) => void,
|
||
onError: (err: string) => void,
|
||
signal: AbortSignal,
|
||
): Promise<void> {
|
||
const history = messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
||
const savedModel = activeModel.value
|
||
activeModel.value = model
|
||
|
||
try {
|
||
if (provider === 'claude') {
|
||
await streamClaude(history, onToken, onError, 'You are a helpful assistant.', false, signal)
|
||
} else if (provider === 'openrouter') {
|
||
await streamOpenRouter(history, onToken, onError, 'You are a helpful assistant.', signal)
|
||
} else {
|
||
await streamMock(history, onToken, signal)
|
||
}
|
||
} finally {
|
||
activeModel.value = savedModel
|
||
}
|
||
}
|
||
|
||
export function useAI() {
|
||
const chatStore = useChatStore()
|
||
|
||
// Fetch Wavlake catalog on first use (non-blocking)
|
||
refreshWavlakeCatalog()
|
||
|
||
function stopGeneration() {
|
||
if (currentAbort) {
|
||
currentAbort.abort()
|
||
currentAbort = null
|
||
}
|
||
chatStore.isStreaming = false
|
||
}
|
||
|
||
async function sendMessage(userText: string, images?: ImageAttachment[]) {
|
||
// Refresh Wavlake catalog if stale (non-blocking, fire-and-forget)
|
||
refreshWavlakeCatalog()
|
||
|
||
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,
|
||
images: images && images.length > 0 ? images : undefined,
|
||
})
|
||
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
||
if (!assistantMsg) return
|
||
|
||
chatStore.isStreaming = true
|
||
|
||
let systemPrompt = buildSystemPrompt(chatStore)
|
||
let clientSearchSucceeded = false
|
||
if (chatStore.webSearchEnabled && userText.trim()) {
|
||
const results = await searchWeb(userText)
|
||
if (results.length > 0) {
|
||
systemPrompt += formatWebSearchContext(results)
|
||
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
|
||
clientSearchSucceeded = true
|
||
console.log('[AIUI] Injected', results.length, 'web search results into context')
|
||
} else {
|
||
console.warn('[AIUI] Web search enabled but 0 results — proxy will handle search')
|
||
}
|
||
}
|
||
|
||
// If client-side search succeeded, don't ask the proxy to search again
|
||
const proxyWebSearch = chatStore.webSearchEnabled && !clientSearchSucceeded
|
||
|
||
const history: ChatMessage[] = chatStore.messages
|
||
.filter((m) => m.id !== assistantMsg.id)
|
||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
|
||
|
||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||
const onError = (err: string) => {
|
||
console.error(`[AIUI ${provider}]`, err)
|
||
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
||
}
|
||
|
||
const genParams = getConversationParams(chatStore)
|
||
|
||
try {
|
||
if (provider === 'claude') {
|
||
await streamClaude(history, onToken, onError, systemPrompt, proxyWebSearch, signal, genParams)
|
||
} 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 = buildSystemPrompt(chatStore)
|
||
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, images: m.images }))
|
||
|
||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||
const onError = (err: string) => {
|
||
console.error(`[AIUI ${provider}]`, err)
|
||
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
||
}
|
||
|
||
const genParams = getConversationParams(chatStore)
|
||
|
||
try {
|
||
if (provider === 'claude') {
|
||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal, genParams)
|
||
} 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,
|
||
}
|
||
}
|