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
+82 -31
View File
@@ -1,43 +1,94 @@
<template>
<div class="flex h-full">
<main class="flex-1 flex flex-col min-w-0">
<header class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
<h1 class="text-lg font-semibold">AIUI</h1>
<span class="text-xs text-gray-400">Phase 0 Foundation</span>
</header>
<div class="h-full flex flex-col bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 relative overflow-hidden">
<div 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>
<div class="flex-1 overflow-y-auto p-4">
<div class="max-w-3xl mx-auto space-y-4">
<p class="text-sm text-gray-500 text-center py-12">
Start a conversation. Ask about films, code, or anything else.
<div class="relative z-10 flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="layoutClasses">
<aside
v-if="showSidebar"
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">
<h1 class="text-lg font-bold gradient-text">AIUI</h1>
<button
class="p-2 rounded-lg text-white/40 hover:text-white hover:bg-white/5 transition-all"
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 class="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2 space-y-1">
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 rounded-xl text-sm transition-all duration-200 truncate"
:class="conv.id === chatStore.activeConversationId
? 'glass-strong text-white'
: 'text-white/50 hover:text-white/80 hover:bg-white/5'"
@click="chatStore.setActiveConversation(conv.id)"
>
{{ conv.title }}
</button>
<p
v-if="conversations.length === 0"
class="text-xs text-white/20 text-center py-8"
>
No conversations yet
</p>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-800 p-4">
<div class="max-w-3xl mx-auto">
<div
class="flex items-end gap-2 rounded-2xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-colors"
>
<textarea
rows="1"
placeholder="Message AIUI..."
class="flex-1 resize-none bg-transparent text-base outline-none placeholder:text-gray-400 min-h-[24px] max-h-[200px]"
/>
<button
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-primary text-white hover:bg-primary-dark transition-colors"
aria-label="Send message"
>
<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="M5 12h14m-7-7l7 7-7 7" />
</svg>
</button>
<div class="p-3 border-t border-white/5">
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
<span>OpenRouter connected</span>
</div>
</div>
</div>
</main>
</aside>
<main class="flex-1 min-w-0 flex flex-col glass rounded-2xl overflow-hidden">
<ChatWindow
:side="panelSide"
@switch-side="chatStore.switchSide()"
/>
</main>
<aside
v-if="false"
class="hidden xl:flex w-80 shrink-0 flex-col glass rounded-2xl overflow-hidden"
>
<div class="p-4 text-center text-white/20 text-sm">
Content panel (coming soon)
</div>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme'
import ChatWindow from '@/components/chat/ChatWindow.vue'
const chatStore = useChatStore()
const { initTheme } = useTheme()
const panelSide = computed(() => chatStore.panelSide)
const conversations = computed(() => chatStore.conversationList)
const showSidebar = true
const layoutClasses = computed(() =>
panelSide.value === 'left' ? 'flex-row-reverse' : 'flex-row'
)
onMounted(() => {
initTheme()
})
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="min-h-full bg-white text-gray-900 font-sans">
<nav class="border-b border-gray-200 bg-white sticky top-0 z-30">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<span class="text-white text-sm font-bold">A</span>
</div>
<span class="text-lg font-semibold">Acme App</span>
</div>
<div class="flex items-center gap-6 text-sm text-gray-600">
<a href="#" class="hover:text-gray-900">Dashboard</a>
<a href="#" class="hover:text-gray-900">Projects</a>
<a href="#" class="hover:text-gray-900">Settings</a>
<RouterLink to="/" class="text-accent font-medium hover:underline">
Back to AIUI
</RouterLink>
</div>
</div>
</nav>
<div class="max-w-6xl mx-auto px-6 py-12">
<div class="mb-12">
<h1 class="text-3xl font-bold mb-2">Widget Integration Demo</h1>
<p class="text-gray-500 max-w-2xl">
This page simulates a third-party web application with the AIUI chat widget embedded.
Click the floating button in the bottom corner to open the AI assistant.
You can also switch which side it appears on.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16">
<div v-for="n in 6" :key="n" class="bg-gray-50 rounded-2xl p-6 border border-gray-100">
<div class="w-10 h-10 rounded-xl bg-blue-100 text-blue-600 flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="font-semibold mb-1">Project {{ n }}</h3>
<p class="text-sm text-gray-500 mb-3">
Sample content card demonstrating how the widget overlays existing UI without disruption.
</p>
<div class="flex items-center gap-2">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700">Active</span>
<span class="text-xs text-gray-400">Updated 2h ago</span>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8 mb-16">
<h2 class="text-xl font-bold mb-4">How to embed AIUI in your app</h2>
<div class="space-y-6 text-sm text-gray-700">
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 1: Script Tag (simplest)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;script src="https://cdn.example.com/aiui-widget.js"&gt;&lt;/script&gt;
&lt;script&gt;
AIUI.init({
apiKey: 'your-openrouter-key',
position: 'bottom-right', // or 'bottom-left'
theme: 'dark',
})
&lt;/script&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 2: Web Component</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;!-- Load the custom element --&gt;
&lt;script type="module" src="https://cdn.example.com/aiui-element.js"&gt;&lt;/script&gt;
&lt;!-- Use anywhere in your HTML --&gt;
&lt;aiui-chat
api-key="your-key"
position="bottom-right"
theme="dark"
&gt;&lt;/aiui-chat&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 3: npm package (Vue/React/Svelte)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>npm install @aiui/widget
// Vue 3
import { AIUIWidget } from '@aiui/widget'
app.use(AIUIWidget, {
apiKey: 'your-key',
position: 'bottom-right',
})
// React
import { AIUIProvider } from '@aiui/widget/react'
&lt;AIUIProvider apiKey="your-key" position="bottom-right" /&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 4: iframe (maximum isolation)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;iframe
src="https://app.aiui.dev/embed?key=your-key&amp;theme=dark"
style="position:fixed;bottom:80px;right:24px;width:420px;height:640px;
border:none;border-radius:16px;z-index:9999;"
allow="microphone"
&gt;&lt;/iframe&gt;</code></pre>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8">
<h2 class="text-xl font-bold mb-4">Technical Architecture</h2>
<div class="grid md:grid-cols-2 gap-8 text-sm text-gray-700">
<div>
<h3 class="font-semibold text-gray-900 mb-2">Script Tag / npm</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Injects a Vue micro-app into a shadow DOM container</li>
<li>Styles are encapsulated no CSS conflicts with host</li>
<li>Communicates via CustomEvents or a global AIUI API object</li>
<li>Host app can pass context (current page, user info)</li>
<li>Smallest footprint: ~80KB gzipped</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Web Component</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Framework-agnostic Custom Element (works in any HTML)</li>
<li>Shadow DOM encapsulation by default</li>
<li>Attributes for configuration, events for callbacks</li>
<li>Can be lazy-loaded with dynamic import()</li>
<li>Works in React, Angular, Svelte, plain HTML</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">iframe</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Maximum isolation separate browsing context</li>
<li>Zero risk of CSS/JS conflicts</li>
<li>Communication via postMessage API</li>
<li>API key stays on embedded origin (more secure)</li>
<li>Slightly larger overhead, but simplest integration</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Host Communication</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Host can send page context to AI for better answers</li>
<li>Widget can trigger actions in host app via callbacks</li>
<li>Conversation history syncs via encrypted IndexedDB</li>
<li>Deep-link support: <code class="bg-gray-200 px-1 rounded">?aiui=open&q=help</code></li>
</ul>
</div>
</div>
</div>
</div>
<AIUIWidget />
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import AIUIWidget from '@/components/widget/AIUIWidget.vue'
</script>