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:
Dorian
2026-03-02 16:34:44 +00:00
parent a5a32e3566
commit ece4b8256f
28 changed files with 7315 additions and 212 deletions
+9 -1
View File
@@ -1,9 +1,17 @@
<template>
<div class="h-dvh flex flex-col">
<div class="h-dvh flex flex-col" :class="currentTheme">
<RouterView />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
const { currentTheme, initTheme } = useTheme()
onMounted(() => {
initTheme()
})
</script>
+33 -12
View File
@@ -1,15 +1,22 @@
<template>
<div class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3"
style="border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);">
<div
class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3 relative"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06); box-shadow: inset 0 1px 0 rgba(254, 253, 249, 1)'"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center shrink-0">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="isDark ? 'bg-accent/20' : 'bg-accent/10'">
<span class="text-accent text-sm font-bold">AI</span>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-white/96 truncate">{{ title }}</h2>
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<div class="flex items-center gap-1.5">
<p class="text-[10px] text-white/40 truncate font-mono">{{ conversationId }}</p>
<span class="text-[10px] text-white/20">·</span>
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
<button
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
@click="showModelPicker = !showModelPicker"
@@ -22,7 +29,10 @@
<div class="flex items-center gap-1">
<button
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
aria-label="Switch panel side"
@click="$emit('switchSide')"
@@ -34,7 +44,10 @@
</button>
<button
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
aria-label="New conversation"
@click="$emit('newChat')"
>
@@ -45,7 +58,10 @@
<button
v-if="showClose"
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
aria-label="Close"
@click="$emit('close')"
>
@@ -61,7 +77,8 @@
class="absolute left-0 right-0 top-full z-20 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast"
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-1.5 px-1">
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ provider.name }}
</p>
<div class="space-y-0.5">
@@ -71,7 +88,9 @@
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200"
:class="model.id === activeModel && provider.id === activeProvider
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="selectModel(provider.id, model.id)"
>
{{ model.name }}
@@ -86,6 +105,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
defineProps<{
title: string
@@ -101,6 +121,7 @@ defineEmits<{
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const { isDark } = useTheme()
const showModelPicker = ref(false)
const modelDisplayName = computed(() => {
@@ -112,7 +133,7 @@ const modelDisplayName = computed(() => {
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'anthropic' | 'openrouter')
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
@@ -2,17 +2,16 @@
<div class="p-3 md:p-4">
<div
class="glass rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
:class="isFocused ? 'border-glass-highlight' : ''"
:style="isFocused ? 'box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.22)' : ''"
>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-sm text-white/90 outline-none placeholder:text-white/25 min-h-[24px] max-h-[120px]"
@focus="isFocused = true"
@blur="isFocused = false"
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px]"
:class="isDark
? 'text-white/90 placeholder:text-white/25'
: 'text-gray-800 placeholder:text-gray-400'"
@keydown.enter.exact.prevent="send"
@input="autoResize"
/>
@@ -20,7 +19,7 @@
:disabled="!canSend"
class="shrink-0 glass-button glass-button-sm rounded-xl px-3 transition-all duration-200"
:class="canSend
? 'hover:bg-white/15 active:scale-95'
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed'"
aria-label="Send message"
@click="send"
@@ -35,6 +34,7 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useTheme } from '@/composables/useTheme'
const props = withDefaults(
defineProps<{
@@ -51,8 +51,8 @@ const emit = defineEmits<{
send: [text: string]
}>()
const { isDark } = useTheme()
const text = ref('')
const isFocused = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
@@ -8,12 +8,11 @@
class="max-w-[85%] md:max-w-[70%] rounded-2xl px-4 py-3 transition-all duration-300"
:class="bubbleClasses"
>
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90">{{ message.content }}</p>
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'">{{ message.content }}</p>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] text-white/30 select-none">{{ formattedTime }}</span>
<span v-if="isUser && message.status" class="text-[10px] text-white/20">
{{ message.status === 'sent' ? '' : message.status === 'delivered' ? '' : '' }}
</span>
<span class="text-[10px] select-none"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
</div>
</div>
</div>
@@ -22,12 +21,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
message: Message
index: number
}>()
const { isDark } = useTheme()
const isUser = computed(() => props.message.role === 'user')
const bubbleClasses = computed(() =>
@@ -1,8 +1,5 @@
<template>
<div
class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300"
:class="variant === 'modal' ? 'glass-dark' : ''"
>
<div class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300">
<ChatHeader
:title="title"
:conversation-id="displayId"
@@ -15,14 +12,16 @@
<div
ref="messageListRef"
class="flex-1 overflow-y-auto scrollbar-thin p-4 space-y-3"
class="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-3"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-3 animate-fade-up">
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
<span class="text-2xl"></span>
</div>
<p class="text-sm text-white/30">Start a conversation</p>
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Start a conversation
</p>
</div>
</div>
@@ -48,6 +47,7 @@
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
@@ -73,6 +73,7 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage } = useAI()
const { isDark } = useTheme()
const messageListRef = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
@@ -5,10 +5,16 @@
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
class="w-1.5 h-1.5 rounded-full animate-pulse-glow"
:class="isDark ? 'bg-white/40' : 'bg-gray-400'"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
</script>
@@ -1,5 +1,5 @@
<template>
<div>
<div :class="currentTheme">
<WidgetFab
:is-open="isOpen"
:position="fabPosition"
@@ -16,9 +16,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import WidgetFab from './WidgetFab.vue'
import WidgetModal from './WidgetModal.vue'
const { currentTheme } = useTheme()
const isOpen = ref(false)
const side = ref<'left' | 'right'>('right')
@@ -4,11 +4,13 @@
<div
v-if="isOpen"
class="fixed z-40 animate-scale-in"
:class="positionClasses"
:class="[positionClasses, currentTheme]"
>
<div
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] glass-card overflow-hidden"
:style="{ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(247, 147, 26, 0.06)' }"
:style="isDark
? 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(247, 147, 26, 0.06)'
: 'box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12), 0 0 40px rgba(247, 147, 26, 0.04)'"
>
<ChatWindow
variant="modal"
@@ -25,6 +27,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import ChatWindow from '@/components/chat/ChatWindow.vue'
const props = withDefaults(
@@ -40,6 +43,8 @@ defineEmits<{
switchSide: []
}>()
const { isDark, currentTheme } = useTheme()
const positionClasses = computed(() =>
props.side === 'left'
? 'bottom-24 left-6'
+124 -116
View File
@@ -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\nConnection error: ${msg}`)
} finally {
chatStore.isStreaming = false
}
+3
View File
@@ -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')
}
}
+1
View File
@@ -9,6 +9,7 @@ declare module '*.vue' {
interface ImportMetaEnv {
readonly VITE_OPENROUTER_API_KEY: string
readonly VITE_ANTHROPIC_API_KEY: string
readonly VITE_ANTHROPIC_TOKEN: string
readonly VITE_TMDB_API_KEY: string
readonly VITE_DEV_MODE: string
readonly VITE_MOCK_MEDIA_SOURCES: string
+58 -28
View File
@@ -1,6 +1,9 @@
<template>
<div class="h-full flex flex-col bg-[#0a0a0a] relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none">
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none">
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
</div>
@@ -11,17 +14,40 @@
class="hidden lg:flex w-64 xl:w-72 shrink-0 flex-col glass rounded-2xl overflow-hidden"
>
<div class="p-4 flex items-center justify-between"
style="border-bottom: 1px solid rgba(255, 255, 255, 0.08);">
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<h1 class="text-lg font-bold gradient-text">AIUI</h1>
<button
class="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
aria-label="New chat"
@click="chatStore.createConversation()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
<div class="flex items-center gap-1">
<button
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
aria-label="Toggle theme"
@click="toggleTheme()"
>
<svg v-if="isDark" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<button
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
aria-label="New chat"
@click="chatStore.createConversation()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto scrollbar-hide px-2 pb-2 space-y-1 pt-2">
@@ -31,7 +57,9 @@
class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 truncate"
:class="conv.id === chatStore.activeConversationId
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="chatStore.setActiveConversation(conv.id)"
>
{{ conv.title }}
@@ -39,14 +67,19 @@
<p
v-if="conversations.length === 0"
class="text-xs text-white/20 text-center py-8"
class="text-xs text-center py-8"
:class="isDark ? 'text-white/20' : 'text-gray-400'"
>
No conversations yet
</p>
</div>
<div class="p-3" style="border-top: 1px solid rgba(255, 255, 255, 0.06);">
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
<div class="p-3" :style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.06)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
<span>{{ providerLabel }}</span>
</div>
@@ -64,24 +97,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme'
import { useAI } from '@/composables/useAI'
import ChatWindow from '@/components/chat/ChatWindow.vue'
const chatStore = useChatStore()
const { initTheme } = useTheme()
const { isDark, toggleTheme } = useTheme()
const { activeProvider } = useAI()
const providerLabel = computed(() => {
const labels: Record<string, string> = {
anthropic: 'Claude connected',
openrouter: 'OpenRouter connected',
}
return labels[activeProvider.value] ?? 'Connected'
})
const panelSide = computed(() => chatStore.panelSide)
const conversations = computed(() => chatStore.conversationList)
const showSidebar = true
@@ -90,7 +115,12 @@ const layoutClasses = computed(() =>
panelSide.value === 'left' ? 'flex-row-reverse' : 'flex-row'
)
onMounted(() => {
initTheme()
const providerLabel = computed(() => {
const labels: Record<string, string> = {
claude: 'Claude Max connected',
openrouter: 'OpenRouter connected',
mock: 'Echo mode',
}
return labels[activeProvider.value] ?? 'Connected'
})
</script>
+8 -2
View File
@@ -1,12 +1,18 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import type { Message, Conversation } from '@aiui/core/types/message'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Map<string, Conversation>>(new Map())
const activeConversationId = ref<string | null>(null)
const isStreaming = ref(false)
const panelSide = ref<'left' | 'right'>('right')
const savedSide = localStorage.getItem('aiui-panel-side') as 'left' | 'right' | null
const panelSide = ref<'left' | 'right'>(savedSide ?? 'right')
watch(panelSide, (val) => {
localStorage.setItem('aiui-panel-side', val)
})
const activeConversation = computed(() => {
if (!activeConversationId.value) return null
+99 -1
View File
@@ -35,9 +35,12 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* ===== GLASSMORPHISM — ported from Archy ===== */
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
@layer components {
/* --- Dark mode (default) --- */
.glass {
background-color: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
@@ -169,6 +172,85 @@ body {
mask-composite: exclude;
pointer-events: none;
}
/* --- Light mode overrides — from Angor ultra-modern-light --- */
.light .glass {
background-color: rgba(250, 249, 246, 0.7);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.02);
}
.light .glass-strong {
background-color: rgba(250, 249, 246, 0.7);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.02);
}
.light .glass-card {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
border: 1px solid #e8e8e8;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.02);
}
.light .glass-button {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
border: 1px solid #0a0a0a;
color: #fafafa;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.light .glass-button:hover {
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.light .gradient-button {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
border: 1px solid #0a0a0a;
color: #fafafa;
}
.light .gradient-button:hover {
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
border-color: #1a1a1a;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.light .gradient-card {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
border: 1px solid #e8e8e8;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.02);
}
.light .toast-glass {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
border: 1px solid #e8e8e8;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.light .nav-tab-active {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%) !important;
color: #0a0a0a !important;
border: 1px solid #e8e8e8;
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(254, 253, 249, 1) !important;
}
.light .nav-tab-active::before {
background: linear-gradient(135deg, rgba(0, 0, 0, 0.08), transparent);
}
}
/* ===== FOCUS STATES — gamepad/keyboard glow ===== */
@@ -188,6 +270,13 @@ body {
color: transparent;
}
.light .gradient-text {
background: linear-gradient(to right, #0a0a0a, #6b7280);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ===== SCROLLBAR — Archy custom gradient scrollbar ===== */
.custom-scrollbar::-webkit-scrollbar {
@@ -209,6 +298,15 @@ body {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
}
.light .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.04);
}
.light .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.08) 100%);
border: 2px solid rgba(255, 255, 255, 0.5);
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;