Files
nuxt-claude/app/pages/ai-chat/index.vue
NEW_GIL_HOME\hyeon e66321386a
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 25m52s
feat: nuxt-claude 프로젝트 초기 커밋
Made-with: Cursor
2026-03-08 16:36:13 +09:00

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>