新增AI聊天界面,包括消息气泡、输入组件、聊天页面和类型定义
This commit is contained in:
parent
0cff0fda69
commit
3f8689f7ad
|
|
@ -24,3 +24,5 @@ stats.html
|
|||
# Misc
|
||||
.DS_Store
|
||||
.history
|
||||
|
||||
.claude
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
"Register": "🧑💻 Register",
|
||||
"ForgotPassword": "❓ Forgot Password",
|
||||
"Settings": "⚙️ Settings",
|
||||
"AIChat": "🤖 AI Chat",
|
||||
"404": "⚠️ Page 404",
|
||||
"Undefined": "🤷 Undefined title"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"Register": "🧑💻 注册",
|
||||
"ForgotPassword": "❓ 忘记密码",
|
||||
"Settings": "⚙️ 设置",
|
||||
"AIChat": "🤖 AI聊天",
|
||||
"404": "⚠️ 404 页面",
|
||||
"Undefined": "🤷 未定义标题"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]))
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue