Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 25m52s
Made-with: Cursor
194 lines
6.6 KiB
Vue
194 lines
6.6 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'auth' })
|
|
|
|
const {
|
|
conversations,
|
|
currentConversation,
|
|
messages,
|
|
streamingContent,
|
|
isStreaming,
|
|
fetchConversations,
|
|
createConversation,
|
|
selectConversation,
|
|
deleteConversation,
|
|
sendMessage
|
|
} = useAiChat()
|
|
|
|
await fetchConversations()
|
|
|
|
const inputText = ref('')
|
|
const messagesContainer = ref<HTMLElement | null>(null)
|
|
|
|
async function handleNewConversation() {
|
|
const conv = await createConversation()
|
|
if (conv) await selectConversation(conv)
|
|
}
|
|
|
|
async function handleSend() {
|
|
const text = inputText.value.trim()
|
|
if (!text || isStreaming.value) return
|
|
if (!currentConversation.value) {
|
|
const conv = await createConversation()
|
|
if (!conv) return
|
|
await selectConversation(conv)
|
|
}
|
|
inputText.value = ''
|
|
await sendMessage(text)
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
// Auto scroll to bottom
|
|
watch([messages, streamingContent], async () => {
|
|
await nextTick()
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
}, { deep: true })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full">
|
|
<!-- Conversation Sidebar -->
|
|
<aside class="w-72 border-r border-gray-200 dark:border-gray-800 flex flex-col bg-gray-50 dark:bg-gray-900">
|
|
<div class="p-4 border-b border-gray-200 dark:border-gray-800">
|
|
<UButton
|
|
icon="i-lucide-plus"
|
|
label="새 대화"
|
|
class="w-full"
|
|
@click="handleNewConversation"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
|
<div
|
|
v-for="conv in conversations"
|
|
:key="conv.id"
|
|
class="flex items-center gap-2 rounded-lg px-3 py-2.5 cursor-pointer group transition-colors"
|
|
:class="currentConversation?.id === conv.id
|
|
? 'bg-primary-100 dark:bg-primary-950 text-primary-700 dark:text-primary-300'
|
|
: 'hover:bg-gray-200 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
|
@click="selectConversation(conv)"
|
|
>
|
|
<UIcon name="i-lucide-message-square" class="shrink-0 text-sm" />
|
|
<span class="flex-1 text-sm truncate">{{ conv.title }}</span>
|
|
<UButton
|
|
icon="i-lucide-trash-2"
|
|
size="xs"
|
|
color="error"
|
|
variant="ghost"
|
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
@click.stop="deleteConversation(conv.id)"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="conversations.length === 0" class="text-center py-8 text-sm text-gray-400">
|
|
대화가 없습니다
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Chat Area -->
|
|
<div class="flex-1 flex flex-col">
|
|
<!-- Header -->
|
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
<div class="flex items-center gap-3">
|
|
<UIcon name="i-lucide-bot" class="text-xl text-primary-500" />
|
|
<div>
|
|
<h2 class="font-semibold text-gray-900 dark:text-white">
|
|
{{ currentConversation?.title ?? 'AI 캠핑 장비 추천' }}
|
|
</h2>
|
|
<p class="text-xs text-gray-500">Claude Sonnet으로 구동</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<div
|
|
ref="messagesContainer"
|
|
class="flex-1 overflow-y-auto p-6 space-y-4"
|
|
>
|
|
<!-- Welcome message when no conversation -->
|
|
<div v-if="!currentConversation" class="flex flex-col items-center justify-center h-full text-center">
|
|
<UIcon name="i-lucide-tent" class="text-6xl text-gray-300 dark:text-gray-700 mb-4" />
|
|
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">AI 캠핑 장비 전문 어시스턴트</h3>
|
|
<p class="text-gray-500 dark:text-gray-400 max-w-md">
|
|
캠핑 장비 추천, 브랜드 비교, 중고 구매 팁 등 모든 것을 물어보세요!
|
|
</p>
|
|
<div class="mt-6 grid grid-cols-2 gap-3 w-full max-w-lg">
|
|
<UButton
|
|
v-for="prompt in ['입문자용 텐트 추천해주세요', '4계절 침낭 비교해주세요', '백패킹 장비 리스트 알려주세요', '중고 장비 구매 시 주의사항']"
|
|
:key="prompt"
|
|
:label="prompt"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
class="text-left text-xs"
|
|
@click="inputText = prompt"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<template v-if="currentConversation">
|
|
<AiChatMessage
|
|
v-for="msg in messages"
|
|
:key="msg.id"
|
|
:role="msg.role"
|
|
:content="msg.content"
|
|
/>
|
|
|
|
<!-- Streaming message -->
|
|
<AiChatMessage
|
|
v-if="isStreaming && streamingContent"
|
|
role="assistant"
|
|
:content="streamingContent"
|
|
:streaming="true"
|
|
/>
|
|
|
|
<!-- Thinking indicator -->
|
|
<div v-if="isStreaming && !streamingContent" class="flex gap-3">
|
|
<div class="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center">
|
|
<UIcon name="i-lucide-bot" class="text-white text-base" />
|
|
</div>
|
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl rounded-tl-sm px-4 py-3">
|
|
<div class="flex gap-1">
|
|
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0ms" />
|
|
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 150ms" />
|
|
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 300ms" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Input Area -->
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
<div class="flex gap-3 items-end">
|
|
<UTextarea
|
|
v-model="inputText"
|
|
placeholder="캠핑 장비에 대해 질문하세요... (Shift+Enter: 줄바꿈)"
|
|
:rows="1"
|
|
autoresize
|
|
:max-rows="5"
|
|
class="flex-1"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
<UButton
|
|
icon="i-lucide-send"
|
|
:loading="isStreaming"
|
|
:disabled="!inputText.trim() || isStreaming"
|
|
@click="handleSend"
|
|
/>
|
|
</div>
|
|
<p class="text-xs text-gray-400 mt-2">Enter로 전송 · Shift+Enter로 줄바꿈</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|