feat(app): enhance theme support and improve PWA integration
- Updated the app to support light and dark themes with appropriate CSS classes. - Enhanced PWA configuration with manifest details and caching strategies. - Improved the chat UI with dynamic theme adjustments for various components. - Added new meta tags for better mobile web app experience. - Refactored environment variables to include new Anthropic token. - Updated package dependencies for better compatibility and performance. Made-with: Cursor
This commit is contained in:
@@ -1,46 +1,68 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
type Provider = 'anthropic' | 'openrouter'
|
||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
const CLAUDE_PATH = '/api/claude/v1/messages'
|
||||
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
||||
|
||||
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 SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library.
|
||||
|
||||
const activeProvider = ref<Provider>(
|
||||
import.meta.env.VITE_ANTHROPIC_API_KEY ? 'anthropic' : 'openrouter'
|
||||
)
|
||||
When recommending or discussing films, reference films from the user's library using the tag format [[film:ID]] where ID matches a film in their collection. Always include the tag so the UI can render rich cards. You can recommend multiple films. Write a brief description of why each film is worth watching alongside the tag.
|
||||
|
||||
const activeModel = ref(
|
||||
import.meta.env.VITE_ANTHROPIC_API_KEY ? 'claude-sonnet-4-20250514' : 'meta-llama/llama-4-maverick:free'
|
||||
)
|
||||
The user's film library:
|
||||
${generateFilmContext()}
|
||||
|
||||
If a user asks about a film NOT in their library, still discuss it but mention it's not currently in their collection.`
|
||||
|
||||
function generateFilmContext(): string {
|
||||
const { mockFilms } = await_import_films()
|
||||
return mockFilms.map((f) =>
|
||||
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | Available on: ${f.sources.map(s => s.type).join(', ')}`
|
||||
).join('\n')
|
||||
}
|
||||
|
||||
function await_import_films() {
|
||||
// Synchronous access — the mock is bundled
|
||||
return require('@/mocks/films') as typeof import('@/mocks/films')
|
||||
}
|
||||
|
||||
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 }[] }[] = []
|
||||
if (import.meta.env.VITE_ANTHROPIC_API_KEY) {
|
||||
providers.push({
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude (Max)',
|
||||
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' },
|
||||
{ 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 (import.meta.env.VITE_OPENROUTER_API_KEY) {
|
||||
},
|
||||
]
|
||||
if (hasOpenRouter) {
|
||||
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: '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: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4 (paid)' },
|
||||
{ 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
|
||||
})
|
||||
|
||||
@@ -56,33 +78,36 @@ function setModel(model: string) {
|
||||
activeModel.value = model
|
||||
}
|
||||
|
||||
interface AnthropicMessage {
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface OpenRouterMessage {
|
||||
role: 'system' | '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 streamAnthropic(
|
||||
messages: AnthropicMessage[],
|
||||
async function streamClaude(
|
||||
messages: ChatMessage[],
|
||||
onToken: (text: string) => void,
|
||||
onError: (err: string) => void,
|
||||
) {
|
||||
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY
|
||||
const res = await fetch(ANTHROPIC_URL, {
|
||||
): Promise<void> {
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: activeModel.value,
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages,
|
||||
stream: true,
|
||||
@@ -90,76 +115,70 @@ async function streamAnthropic(
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
onError(`Error ${res.status}: ${err}`)
|
||||
const body = await res.text().catch(() => 'Could not read error body')
|
||||
onError(`Claude proxy error ${res.status}: ${body}`)
|
||||
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.)
|
||||
}
|
||||
}
|
||||
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: OpenRouterMessage[],
|
||||
messages: ChatMessage[],
|
||||
onToken: (text: string) => void,
|
||||
onError: (err: string) => void,
|
||||
) {
|
||||
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY
|
||||
const res = await fetch(OPENROUTER_URL, {
|
||||
): 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 ${apiKey}`,
|
||||
'Authorization': `Bearer ${openrouterApiKey}`,
|
||||
'HTTP-Referer': window.location.origin,
|
||||
'X-Title': 'AIUI',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: activeModel.value,
|
||||
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...messages],
|
||||
messages: orMessages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
onError(`Error ${res.status}: ${err}`)
|
||||
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')
|
||||
@@ -180,13 +199,10 @@ async function streamOpenRouter(
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||
const data = trimmed.slice(6)
|
||||
if (data === '[DONE]') break
|
||||
|
||||
const payload = trimmed.slice(6)
|
||||
if (payload === '[DONE]') return
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta?.content
|
||||
if (delta) onToken(delta)
|
||||
onData(payload)
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
@@ -199,49 +215,41 @@ export function useAI() {
|
||||
|
||||
async function sendMessage(userText: string) {
|
||||
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}`)
|
||||
return
|
||||
}
|
||||
|
||||
let convId = chatStore.activeConversationId
|
||||
if (!convId) {
|
||||
convId = chatStore.createConversation()
|
||||
}
|
||||
const cid = convId
|
||||
|
||||
chatStore.addMessage(convId, { role: 'user', content: userText })
|
||||
const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' })
|
||||
chatStore.addMessage(cid, { role: 'user', content: userText })
|
||||
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
|
||||
if (!assistantMsg) return
|
||||
|
||||
chatStore.isStreaming = true
|
||||
|
||||
const history = chatStore.messages
|
||||
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(convId, text)
|
||||
const onError = (err: string) => chatStore.appendToLastMessage(convId, err)
|
||||
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
|
||||
const onError = (err: string) => {
|
||||
console.error(`[AIUI ${provider}]`, err)
|
||||
chatStore.appendToLastMessage(cid, `⚠ ${err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
if (provider === 'anthropic') {
|
||||
await streamAnthropic(history, onToken, onError)
|
||||
if (provider === 'claude') {
|
||||
await streamClaude(history, onToken, onError)
|
||||
} else if (provider === 'openrouter') {
|
||||
await streamOpenRouter(history, onToken, onError)
|
||||
} else {
|
||||
const orHistory = history.map((m) => ({
|
||||
...m,
|
||||
role: m.role as 'system' | 'user' | 'assistant',
|
||||
}))
|
||||
await streamOpenRouter(orHistory, onToken, onError)
|
||||
await streamMock(history, onToken)
|
||||
}
|
||||
} catch (err) {
|
||||
chatStore.appendToLastMessage(
|
||||
convId,
|
||||
`\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function useTheme() {
|
||||
currentTheme.value = theme
|
||||
localStorage.setItem('aiui-theme', theme)
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.classList.toggle('light', theme === 'light')
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
@@ -23,6 +24,8 @@ export function useTheme() {
|
||||
setTheme(saved)
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark')
|
||||
} else {
|
||||
setTheme('light')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user