新增AI聊天界面,包括消息气泡、输入组件、聊天页面和类型定义

This commit is contained in:
dengquanzhang 2025-12-18 01:00:25 +08:00
parent 0cff0fda69
commit 3f8689f7ad
11 changed files with 329 additions and 4 deletions

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ stats.html
# Misc
.DS_Store
.history
.claude

View File

@ -17,7 +17,7 @@
"lint:fix": "eslint . --fix",
"release": "bumpp --commit --push --tag",
"typecheck": "vue-tsc --noEmit",
"prepare": "simple-git-hooks"
"prepare": ""
},
"dependencies": {
"@unhead/vue": "2.0.19",
@ -95,9 +95,6 @@
"resolutions": {
"vite": "^7.2.7"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { ChatBubbleProps } from '@/types/chat'
const props = defineProps<ChatBubbleProps>()
const messageClasses = computed(() => {
const base = 'rounded-2xl p-3 max-w-80'
const sender = props.message.sender === 'user'
? 'bg-blue-100 dark:bg-blue-900 ml-auto text-right'
: 'bg-gray-100 dark:bg-gray-800 mr-auto text-left'
return `${base} ${sender}`
})
const formattedTime = computed(() => {
if (!props.message.timestamp) return ''
const date = typeof props.message.timestamp === 'string'
? new Date(props.message.timestamp)
: props.message.timestamp
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
</script>
<template>
<div class="flex items-end gap-2 mb-4" :class="{ 'flex-row-reverse': message.sender === 'user' }">
<!-- Avatar -->
<van-image
v-if="avatar"
:src="avatar"
class="w-8 h-8 rounded-full flex-shrink-0"
round
/>
<div v-else class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center"
:class="message.sender === 'user' ? 'bg-blue-100 dark:bg-blue-900' : 'bg-gray-100 dark:bg-gray-800'">
<van-icon :name="message.sender === 'user' ? 'user-circle-o' : 'robot-o'" />
</div>
<!-- Message bubble -->
<div class="flex flex-col" :class="{ 'items-end': message.sender === 'user', 'items-start': message.sender === 'ai' }">
<div :class="messageClasses">
<p class="text-sm">{{ message.content }}</p>
<!-- Status indicator -->
<div v-if="message.status === 'sending'" class="mt-1">
<van-icon name="clock-o" class="animate-pulse" />
</div>
<div v-else-if="message.status === 'error'" class="mt-1 text-red-500">
<van-icon name="warning-o" />
</div>
</div>
<!-- Timestamp -->
<div v-if="showTimestamp && formattedTime" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formattedTime }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import type { ChatInputProps, ChatInputEmits } from '@/types/chat'
const props = defineProps<ChatInputProps>()
const emit = defineEmits<ChatInputEmits>()
const inputRef = ref<HTMLTextAreaElement>()
const isComposing = ref(false)
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
emit('update:modelValue', target.value)
}
function handleKeydown(e: KeyboardEvent) {
emit('keydown', e)
// Send on Enter, but allow Shift+Enter for newline
if (e.key === 'Enter' && !e.shiftKey && !isComposing.value) {
e.preventDefault()
handleSend()
}
}
function handleSend() {
if (props.modelValue.trim() && !props.disabled && !props.loading) {
emit('send', props.modelValue.trim())
}
}
function focus() {
inputRef.value?.focus()
}
defineExpose({
focus,
})
</script>
<template>
<div class="flex items-center gap-2 p-3 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<van-field
ref="inputRef"
v-model="props.modelValue"
type="textarea"
rows="1"
autosize
:placeholder="placeholder || 'Type a message...'"
:disabled="disabled"
:readonly="loading"
class="flex-1"
@input="handleInput"
@keydown="handleKeydown"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<van-button
type="primary"
round
:disabled="!props.modelValue.trim() || disabled"
:loading="loading"
@click="handleSend"
class="flex-shrink-0"
>
<template #icon>
<van-icon name="send" />
</template>
</van-button>
</div>
</template>

View File

@ -17,6 +17,7 @@
"Register": "🧑‍💻 Register",
"ForgotPassword": "❓ Forgot Password",
"Settings": "⚙️ Settings",
"AIChat": "🤖 AI Chat",
"404": "⚠️ Page 404",
"Undefined": "🤷 Undefined title"
},

View File

@ -17,6 +17,7 @@
"Register": "🧑‍💻 注册",
"ForgotPassword": "❓ 忘记密码",
"Settings": "⚙️ 设置",
"AIChat": "🤖 AI聊天",
"404": "⚠️ 404 页面",
"Undefined": "🤷 未定义标题"
},

View File

@ -16,6 +16,7 @@ const menuItems = computed(() => ([
{ title: t('navbar.Counter'), route: 'counter' },
{ title: t('navbar.KeepAlive'), route: 'keepalive' },
{ title: t('navbar.ScrollCache'), route: 'scroll-cache' },
{ title: t('navbar.AIChat'), route: 'llm-chat' },
{ title: t('navbar.404'), route: 'unknown' },
]))

View File

@ -0,0 +1,154 @@
<script setup lang="ts">
import type { ChatMessage } from '@/types/chat'
import { useScroll } from '@vueuse/core'
import { isDark, toggleDark } from '@/composables/dark'
const { t } = useI18n()
// Sample chat data for demonstration
const messages = ref<ChatMessage[]>([
{
id: 1,
content: 'Hello! I\'m your AI assistant. How can I help you today?',
sender: 'ai',
timestamp: new Date('2025-12-17T10:00:00'),
status: 'sent',
},
{
id: 2,
content: 'Can you explain how to use Vue 3 with TypeScript?',
sender: 'user',
timestamp: new Date('2025-12-17T10:01:00'),
status: 'sent',
},
{
id: 3,
content: 'Vue 3 with TypeScript provides excellent type safety and developer experience. You can use the Composition API with typed refs and reactive objects.',
sender: 'ai',
timestamp: new Date('2025-12-17T10:02:00'),
status: 'sent',
},
])
const inputText = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
const { arrivedState } = useScroll(messagesContainer, {
offset: { bottom: 100 },
})
// Auto-scroll to bottom when new messages arrive
watch(messages, () => {
nextTick(() => {
if (messagesContainer.value && arrivedState.bottom) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}, { deep: true })
function handleSend(text: string) {
if (!text.trim()) return
// Add user message
const userMessage: ChatMessage = {
id: Date.now(),
content: text,
sender: 'user',
timestamp: new Date(),
status: 'sending',
}
messages.value.push(userMessage)
// Clear input
inputText.value = ''
// Simulate AI response after delay
isLoading.value = true
setTimeout(() => {
userMessage.status = 'sent'
const aiMessage: ChatMessage = {
id: Date.now() + 1,
content: `I received your message: "${text}". This is a simulated response.`,
sender: 'ai',
timestamp: new Date(),
status: 'sent',
}
messages.value.push(aiMessage)
isLoading.value = false
}, 1000)
}
function handleKeydown(e: KeyboardEvent) {
// You can add additional keyboard shortcuts here
console.log('Keydown event:', e.key)
}
</script>
<template>
<div class="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
<!-- Header -->
<van-nav-bar
:title="t('llmChat.title', 'AI Chat')"
fixed
placeholder
safe-area-inset-top
>
<template #right>
<van-icon
:name="isDark ? 'sun-o' : 'moon-o'"
class="text-lg"
@click="toggleDark()"
/>
</template>
</van-nav-bar>
<!-- Messages container -->
<van-list
ref="messagesContainer"
class="flex-1 p-4 pt-16 pb-24"
:finished="true"
:loading="false"
finished-text=""
loading-text=""
>
<van-empty
v-if="messages.length === 0"
description="No messages yet. Start a conversation!"
class="mt-20"
/>
<div v-else>
<ChatBubble
v-for="message in messages"
:key="message.id"
:message="message"
:show-timestamp="true"
/>
</div>
</van-list>
<!-- Input area -->
<div class="fixed bottom-0 left-0 right-0 z-10">
<ChatInput
v-model="inputText"
:disabled="isLoading"
:loading="isLoading"
placeholder="Type your message..."
@send="handleSend"
@keydown="handleKeydown"
/>
</div>
</div>
</template>
<route lang="json5">
{
name: 'LLMChat',
meta: {
title: 'AI Chat',
requiresAuth: false
}
}
</route>

26
src/types/chat.ts Normal file
View File

@ -0,0 +1,26 @@
export interface ChatMessage {
id: string | number
content: string
sender: 'user' | 'ai'
timestamp?: Date | string
status?: 'sending' | 'sent' | 'error'
}
export interface ChatBubbleProps {
message: ChatMessage
showTimestamp?: boolean
avatar?: string
}
export interface ChatInputProps {
modelValue: string
placeholder?: string
disabled?: boolean
loading?: boolean
}
export interface ChatInputEmits {
(e: 'update:modelValue', value: string): void
(e: 'send', value: string): void
(e: 'keydown', event: KeyboardEvent): void
}

View File

@ -12,6 +12,8 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
Chart: typeof import('./../components/Chart/index.vue')['default']
ChatBubble: typeof import('./../components/ChatBubble.vue')['default']
ChatInput: typeof import('./../components/ChatInput.vue')['default']
GhostButton: typeof import('./../components/GhostButton.vue')['default']
NavBar: typeof import('./../components/NavBar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -65,6 +65,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'LLMChat': RouteRecordInfo<
'LLMChat',
'/llm-chat',
Record<never, never>,
Record<never, never>,
| never
>,
'Login': RouteRecordInfo<
'Login',
'/login',
@ -163,6 +170,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/llm-chat/index.vue': {
routes:
| 'LLMChat'
views:
| never
}
'src/pages/login/index.vue': {
routes:
| 'Login'