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
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="h-dvh flex flex-col bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div class="h-dvh flex flex-col">
<RouterView />
</div>
</template>
@@ -0,0 +1,63 @@
<template>
<div class="glass-subtle rounded-t-2xl flex items-center justify-between px-4 py-3">
<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">
<span class="text-accent text-sm font-bold">AI</span>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-white truncate">{{ title }}</h2>
<p class="text-[10px] text-white/40 truncate">{{ conversationId }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
aria-label="Switch panel side"
@click="$emit('switchSide')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="side === 'right'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<button
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
aria-label="New conversation"
@click="$emit('newChat')"
>
<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>
<button
v-if="showClose"
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
aria-label="Close"
@click="$emit('close')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
conversationId: string
side: 'left' | 'right'
showClose?: boolean
}>()
defineEmits<{
switchSide: []
newChat: []
close: []
}>()
</script>
@@ -0,0 +1,72 @@
<template>
<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 inner-glow"
:class="isFocused ? 'border-white/20 glow-soft' : ''"
>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-sm text-white outline-none placeholder:text-white/30 min-h-[24px] max-h-[120px]"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.exact.prevent="send"
@input="autoResize"
/>
<button
:disabled="!canSend"
class="shrink-0 w-9 h-9 flex items-center justify-center rounded-xl transition-all duration-200"
:class="canSend
? 'bg-accent text-white hover:bg-accent-hover active:scale-90'
: 'bg-white/5 text-white/20 cursor-not-allowed'"
aria-label="Send message"
@click="send"
>
<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="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
const props = withDefaults(
defineProps<{
disabled?: boolean
placeholder?: string
}>(),
{
disabled: false,
placeholder: 'Message AIUI...',
}
)
const emit = defineEmits<{
send: [text: string]
}>()
const text = ref('')
const isFocused = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
function send() {
if (!canSend.value) return
emit('send', text.value.trim())
text.value = ''
nextTick(autoResize)
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
</script>
@@ -0,0 +1,43 @@
<template>
<div
class="flex animate-fade-up"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
>
<div
class="max-w-[85%] md:max-w-[70%] rounded-2xl px-4 py-3 transition-all duration-200"
:class="bubbleClasses"
>
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{{ message.content }}</p>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] opacity-40 select-none">{{ formattedTime }}</span>
<span v-if="isUser && message.status" class="text-[10px] opacity-30">
{{ message.status === 'sent' ? '' : message.status === 'delivered' ? '' : '' }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
message: Message
index: number
}>()
const isUser = computed(() => props.message.role === 'user')
const bubbleClasses = computed(() =>
isUser.value
? 'glass-strong text-white rounded-br-md'
: 'glass-dark text-gray-100 rounded-bl-md'
)
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
</script>
@@ -0,0 +1,122 @@
<template>
<div
class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300"
:class="variant === 'modal' ? 'glass-dark' : ''"
>
<ChatHeader
:title="title"
:conversation-id="displayId"
:side="side"
:show-close="showClose"
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
/>
<div
ref="messageListRef"
class="flex-1 overflow-y-auto scrollbar-thin 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>
</div>
</div>
<ChatMessage
v-for="(msg, i) in messages"
:key="msg.id"
:message="msg"
:index="i"
/>
<StreamingDots v-if="isStreaming && lastMessageEmpty" />
</div>
<ChatInput
:disabled="isStreaming"
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
@send="handleSend"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
withDefaults(
defineProps<{
variant?: 'standalone' | 'modal'
side?: 'left' | 'right'
showClose?: boolean
}>(),
{
variant: 'standalone',
side: 'right',
showClose: false,
}
)
defineEmits<{
switchSide: []
close: []
}>()
const chatStore = useChatStore()
const { sendMessage } = useAI()
const messageListRef = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
const isStreaming = computed(() => chatStore.isStreaming)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
)
const displayId = computed(
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
)
const lastMessageEmpty = computed(() => {
const msgs = messages.value
if (msgs.length === 0) return true
return msgs[msgs.length - 1].content === ''
})
function handleNewChat() {
chatStore.createConversation()
}
async function handleSend(text: string) {
await sendMessage(text)
}
watch(
() => messages.value.length,
() => {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
)
watch(
() => messages.value[messages.value.length - 1]?.content,
() => {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
)
</script>
@@ -0,0 +1,14 @@
<template>
<div class="flex justify-start animate-fade-up">
<div class="glass-dark rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<template>
<div>
<WidgetFab
:is-open="isOpen"
:position="fabPosition"
@toggle="isOpen = !isOpen"
/>
<WidgetModal
:is-open="isOpen"
:side="side"
@close="isOpen = false"
@switch-side="toggleSide"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import WidgetFab from './WidgetFab.vue'
import WidgetModal from './WidgetModal.vue'
const isOpen = ref(false)
const side = ref<'left' | 'right'>('right')
const fabPosition = computed(() =>
side.value === 'left' ? 'bottom-left' : 'bottom-right'
)
function toggleSide() {
side.value = side.value === 'right' ? 'left' : 'right'
}
</script>
@@ -0,0 +1,53 @@
<template>
<button
class="fixed z-50 w-14 h-14 rounded-full glass-strong flex items-center justify-center
shadow-lg hover:shadow-xl transition-all duration-300
hover:scale-105 active:scale-95 glow-accent"
:class="positionClasses"
:aria-label="isOpen ? 'Close AIUI' : 'Open AIUI'"
@click="$emit('toggle')"
>
<svg
class="w-6 h-6 text-accent transition-transform duration-300"
:class="isOpen ? 'rotate-45' : ''"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="!isOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
isOpen: boolean
position?: 'bottom-right' | 'bottom-left'
}>(),
{ position: 'bottom-right' }
)
defineEmits<{ toggle: [] }>()
const positionClasses = computed(() =>
props.position === 'bottom-left'
? 'bottom-6 left-6'
: 'bottom-6 right-6'
)
</script>
@@ -0,0 +1,65 @@
<template>
<Teleport to="body">
<Transition name="widget">
<div
v-if="isOpen"
class="fixed z-40 animate-scale-in"
:class="positionClasses"
>
<div
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] rounded-2xl overflow-hidden shadow-2xl"
:style="{ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 40px rgba(247, 147, 26, 0.08)' }"
>
<ChatWindow
variant="modal"
:side="side"
show-close
@switch-side="$emit('switchSide')"
@close="$emit('close')"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ChatWindow from '@/components/chat/ChatWindow.vue'
const props = withDefaults(
defineProps<{
isOpen: boolean
side?: 'left' | 'right'
}>(),
{ side: 'right' }
)
defineEmits<{
close: []
switchSide: []
}>()
const positionClasses = computed(() =>
props.side === 'left'
? 'bottom-24 left-6'
: 'bottom-24 right-6'
)
</script>
<style scoped>
.widget-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.widget-leave-active {
transition: all 0.2s ease-in;
}
.widget-enter-from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
.widget-leave-to {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
</style>
+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 }
}
+30
View File
@@ -0,0 +1,30 @@
import { ref, computed } from 'vue'
type ThemeName = 'dark' | 'light'
const currentTheme = ref<ThemeName>('dark')
export function useTheme() {
const isDark = computed(() => currentTheme.value === 'dark')
const setTheme = (theme: ThemeName) => {
currentTheme.value = theme
localStorage.setItem('aiui-theme', theme)
document.documentElement.classList.toggle('dark', theme === 'dark')
}
const toggleTheme = () => {
setTheme(isDark.value ? 'light' : 'dark')
}
const initTheme = () => {
const saved = localStorage.getItem('aiui-theme') as ThemeName | null
if (saved) {
setTheme(saved)
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark')
}
}
return { currentTheme, isDark, setTheme, toggleTheme, initTheme }
}
+5
View File
@@ -12,6 +12,11 @@ const router = createRouter({
name: 'chat',
component: () => import('./pages/ChatPage.vue'),
},
{
path: '/widget-demo',
name: 'widget-demo',
component: () => import('./pages/WidgetDemoPage.vue'),
},
],
})
+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>
+95
View File
@@ -0,0 +1,95 @@
import { defineStore } from 'pinia'
import { ref, computed } 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 activeConversation = computed(() => {
if (!activeConversationId.value) return null
return conversations.value.get(activeConversationId.value) ?? null
})
const messages = computed(() => activeConversation.value?.messages ?? [])
const conversationList = computed(() =>
Array.from(conversations.value.values()).sort((a, b) => b.updatedAt - a.updatedAt)
)
function createConversation(title = 'New Chat'): string {
const id = crypto.randomUUID()
const conversation: Conversation = {
id,
title,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
conversations.value.set(id, conversation)
activeConversationId.value = id
return id
}
function addMessage(conversationId: string, message: Omit<Message, 'id' | 'timestamp'>) {
const conv = conversations.value.get(conversationId)
if (!conv) return
const msg: Message = {
...message,
id: crypto.randomUUID(),
timestamp: Date.now(),
}
conv.messages.push(msg)
conv.updatedAt = Date.now()
if (conv.messages.length === 1 && message.role === 'user') {
conv.title = message.content.slice(0, 60) + (message.content.length > 60 ? '...' : '')
}
return msg
}
function appendToLastMessage(conversationId: string, text: string) {
const conv = conversations.value.get(conversationId)
if (!conv || conv.messages.length === 0) return
const last = conv.messages[conv.messages.length - 1]
last.content += text
}
function switchSide() {
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
}
function setActiveConversation(id: string) {
if (conversations.value.has(id)) {
activeConversationId.value = id
}
}
function deleteConversation(id: string) {
conversations.value.delete(id)
if (activeConversationId.value === id) {
const remaining = conversationList.value
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
}
}
return {
conversations,
activeConversationId,
activeConversation,
messages,
conversationList,
isStreaming,
panelSide,
createConversation,
addMessage,
appendToLastMessage,
switchSide,
setActiveConversation,
deleteConversation,
}
})
+113
View File
@@ -13,6 +13,14 @@
--color-warning: #F59E0B;
--color-info: #3B82F6;
--color-glass-bg: rgba(255, 255, 255, 0.08);
--color-glass-border: rgba(255, 255, 255, 0.12);
--color-glass-bg-hover: rgba(255, 255, 255, 0.14);
--color-glass-border-hover: rgba(255, 255, 255, 0.22);
--color-glass-bg-light: rgba(255, 255, 255, 0.65);
--color-glass-border-light: rgba(255, 255, 255, 0.45);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
}
@@ -25,6 +33,111 @@ body {
overscroll-behavior: none;
}
.glass {
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-glass-border);
}
.glass-subtle {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-strong {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.glass-light {
background: var(--color-glass-bg-light);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-glass-border-light);
}
.glass-dark {
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass:hover {
background: var(--color-glass-bg-hover);
border-color: var(--color-glass-border-hover);
}
.glow-accent {
box-shadow: 0 0 20px rgba(247, 147, 26, 0.15),
0 0 40px rgba(247, 147, 26, 0.05);
}
.glow-soft {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.06);
}
.inner-glow {
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.06);
}
.gradient-text {
background: linear-gradient(to right, #ffffff, #9ca3af);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulseGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.animate-fade-up {
animation: fadeSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-scale-in {
animation: scaleIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-pulse-glow {
animation: pulseGlow 2s ease-in-out infinite;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;