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:
@@ -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>
|
||||
Reference in New Issue
Block a user