新增AI聊天界面,包括消息气泡、输入组件、聊天页面和类型定义
This commit is contained in:
parent
0cff0fda69
commit
3f8689f7ad
|
|
@ -24,3 +24,5 @@ stats.html
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.history
|
.history
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"release": "bumpp --commit --push --tag",
|
"release": "bumpp --commit --push --tag",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"prepare": "simple-git-hooks"
|
"prepare": ""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@unhead/vue": "2.0.19",
|
"@unhead/vue": "2.0.19",
|
||||||
|
|
@ -95,9 +95,6 @@
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"vite": "^7.2.7"
|
"vite": "^7.2.7"
|
||||||
},
|
},
|
||||||
"simple-git-hooks": {
|
|
||||||
"pre-commit": "pnpm lint-staged"
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "eslint --fix"
|
"*": "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",
|
"Register": "🧑💻 Register",
|
||||||
"ForgotPassword": "❓ Forgot Password",
|
"ForgotPassword": "❓ Forgot Password",
|
||||||
"Settings": "⚙️ Settings",
|
"Settings": "⚙️ Settings",
|
||||||
|
"AIChat": "🤖 AI Chat",
|
||||||
"404": "⚠️ Page 404",
|
"404": "⚠️ Page 404",
|
||||||
"Undefined": "🤷 Undefined title"
|
"Undefined": "🤷 Undefined title"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"Register": "🧑💻 注册",
|
"Register": "🧑💻 注册",
|
||||||
"ForgotPassword": "❓ 忘记密码",
|
"ForgotPassword": "❓ 忘记密码",
|
||||||
"Settings": "⚙️ 设置",
|
"Settings": "⚙️ 设置",
|
||||||
|
"AIChat": "🤖 AI聊天",
|
||||||
"404": "⚠️ 404 页面",
|
"404": "⚠️ 404 页面",
|
||||||
"Undefined": "🤷 未定义标题"
|
"Undefined": "🤷 未定义标题"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const menuItems = computed(() => ([
|
||||||
{ title: t('navbar.Counter'), route: 'counter' },
|
{ title: t('navbar.Counter'), route: 'counter' },
|
||||||
{ title: t('navbar.KeepAlive'), route: 'keepalive' },
|
{ title: t('navbar.KeepAlive'), route: 'keepalive' },
|
||||||
{ title: t('navbar.ScrollCache'), route: 'scroll-cache' },
|
{ title: t('navbar.ScrollCache'), route: 'scroll-cache' },
|
||||||
|
{ title: t('navbar.AIChat'), route: 'llm-chat' },
|
||||||
{ title: t('navbar.404'), route: 'unknown' },
|
{ 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' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Chart: typeof import('./../components/Chart/index.vue')['default']
|
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']
|
GhostButton: typeof import('./../components/GhostButton.vue')['default']
|
||||||
NavBar: typeof import('./../components/NavBar.vue')['default']
|
NavBar: typeof import('./../components/NavBar.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ declare module 'vue-router/auto-routes' {
|
||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'LLMChat': RouteRecordInfo<
|
||||||
|
'LLMChat',
|
||||||
|
'/llm-chat',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'Login': RouteRecordInfo<
|
'Login': RouteRecordInfo<
|
||||||
'Login',
|
'Login',
|
||||||
'/login',
|
'/login',
|
||||||
|
|
@ -163,6 +170,12 @@ declare module 'vue-router/auto-routes' {
|
||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/llm-chat/index.vue': {
|
||||||
|
routes:
|
||||||
|
| 'LLMChat'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/login/index.vue': {
|
'src/pages/login/index.vue': {
|
||||||
routes:
|
routes:
|
||||||
| 'Login'
|
| 'Login'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue