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