feat(app): glassmorphism chat UI with widget embed system

- Glassmorphism chat window matching Proux design rules (glass layers,
  gradient backgrounds, glow effects, blur intensities, inner glows)
- Conversation ID displayed in header, side-switching (left/right layout)
- Chat store with Pinia: conversations, messages, streaming state
- OpenRouter AI integration with SSE streaming (Llama 4 Maverick free model)
- Chat components: ChatWindow, ChatHeader, ChatInput, ChatMessage,
  StreamingDots (typing indicator)
- Embeddable widget system: floating action button (FAB) + modal popup
  with scale-in animation, side-aware positioning
- Widget demo page (/widget-demo) showing AIUI embedded in a mock "Acme App"
  with documentation of 4 integration methods: script tag, web component,
  npm package, and iframe
- Theme composable with dark/light mode, system preference detection
- Custom CSS: glass variants (subtle/medium/strong/light/dark), glow effects,
  gradient text, animation keyframes, thin scrollbar
- Responsive: mobile-first, 44px touch targets, dvh viewport

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 14:20:34 +00:00
parent c28e6dd811
commit 27864cf92e
16 changed files with 1057 additions and 32 deletions
+108
View File
@@ -0,0 +1,108 @@
import { useChatStore } from '@/stores/chat'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
interface OpenRouterMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export function useAI() {
const chatStore = useChatStore()
async function sendMessage(userText: string) {
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY
if (!apiKey) {
console.error('VITE_OPENROUTER_API_KEY not set in .env.local')
return
}
let convId = chatStore.activeConversationId
if (!convId) {
convId = chatStore.createConversation()
}
chatStore.addMessage(convId, { role: 'user', content: userText })
const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
const history: OpenRouterMessage[] = chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
try {
const res = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'AIUI',
},
body: JSON.stringify({
model: 'meta-llama/llama-4-maverick:free',
messages: [
{
role: 'system',
content: 'You are AIUI, a helpful AI assistant. Be concise and helpful. When discussing films, provide rich details including genre, year, director, and rating.',
},
...history,
],
stream: true,
}),
})
if (!res.ok) {
const err = await res.text()
chatStore.appendToLastMessage(convId, `Error: ${res.status}${err}`)
chatStore.isStreaming = false
return
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
chatStore.appendToLastMessage(convId, 'Error: No response body')
chatStore.isStreaming = false
return
}
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 data = trimmed.slice(6)
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) {
chatStore.appendToLastMessage(convId, delta)
}
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
chatStore.appendToLastMessage(convId, `\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
chatStore.isStreaming = false
}
}
return { sendMessage }
}