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:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ const router = createRouter({
|
|||||||
name: 'chat',
|
name: 'chat',
|
||||||
component: () => import('./pages/ChatPage.vue'),
|
component: () => import('./pages/ChatPage.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/widget-demo',
|
||||||
|
name: 'widget-demo',
|
||||||
|
component: () => import('./pages/WidgetDemoPage.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full">
|
<div class="h-full flex flex-col bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 relative overflow-hidden">
|
||||||
<main class="flex-1 flex flex-col min-w-0">
|
<div class="absolute inset-0 pointer-events-none">
|
||||||
<header class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
|
||||||
<h1 class="text-lg font-semibold">AIUI</h1>
|
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
|
||||||
<span class="text-xs text-gray-400">Phase 0 — Foundation</span>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="relative z-10 flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="layoutClasses">
|
||||||
<div class="max-w-3xl mx-auto space-y-4">
|
<aside
|
||||||
<p class="text-sm text-gray-500 text-center py-12">
|
v-if="showSidebar"
|
||||||
Start a conversation. Ask about films, code, or anything else.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-800 p-4">
|
<div class="p-3 border-t border-white/5">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
|
||||||
<div
|
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
||||||
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"
|
<span>OpenRouter connected</span>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</main>
|
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|||||||
@@ -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><script src="https://cdn.example.com/aiui-widget.js"></script>
|
||||||
|
<script>
|
||||||
|
AIUI.init({
|
||||||
|
apiKey: 'your-openrouter-key',
|
||||||
|
position: 'bottom-right', // or 'bottom-left'
|
||||||
|
theme: 'dark',
|
||||||
|
})
|
||||||
|
</script></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><!-- Load the custom element -->
|
||||||
|
<script type="module" src="https://cdn.example.com/aiui-element.js"></script>
|
||||||
|
|
||||||
|
<!-- Use anywhere in your HTML -->
|
||||||
|
<aiui-chat
|
||||||
|
api-key="your-key"
|
||||||
|
position="bottom-right"
|
||||||
|
theme="dark"
|
||||||
|
></aiui-chat></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'
|
||||||
|
<AIUIProvider apiKey="your-key" position="bottom-right" /></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><iframe
|
||||||
|
src="https://app.aiui.dev/embed?key=your-key&theme=dark"
|
||||||
|
style="position:fixed;bottom:80px;right:24px;width:420px;height:640px;
|
||||||
|
border:none;border-radius:16px;z-index:9999;"
|
||||||
|
allow="microphone"
|
||||||
|
></iframe></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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
--color-warning: #F59E0B;
|
--color-warning: #F59E0B;
|
||||||
--color-info: #3B82F6;
|
--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-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
|
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
@@ -25,6 +33,111 @@ body {
|
|||||||
overscroll-behavior: none;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user