wip: 聊天 UI
This commit is contained in:
@@ -33,7 +33,7 @@ const dayjs = useDayjs()
|
|||||||
<div>{{ dayjs(chatSession.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
<div>{{ dayjs(chatSession.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@click="emit('remove', chatSession)"
|
@click.stop="emit('remove', chatSession)"
|
||||||
class="chat-card-remove-btn text-neutral-400 group-hover:opacity-100 group-hover:-translate-x-0.5"
|
class="chat-card-remove-btn text-neutral-400 group-hover:opacity-100 group-hover:-translate-x-0.5"
|
||||||
>
|
>
|
||||||
<UIcon name="i-tabler-trash"/>
|
<UIcon name="i-tabler-trash"/>
|
||||||
@@ -44,7 +44,7 @@ const dayjs = useDayjs()
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.chat-card {
|
.chat-card {
|
||||||
@apply flex flex-col gap-2 bg-white dark:bg-neutral-800 px-4 py-3 rounded-lg relative border-2 border-transparent shadow-card;
|
@apply flex flex-col gap-2 bg-white dark:bg-neutral-800 px-4 py-3 rounded-lg relative border-2 border-transparent shadow-card;
|
||||||
@apply transition duration-150 hover:bg-cyan-300/5;
|
@apply transition-none duration-150 hover:bg-cyan-300/5;
|
||||||
@apply select-none;
|
@apply select-none;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
|||||||
78
components/aigc/chat/Message.vue
Normal file
78
components/aigc/chat/Message.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {PropType} from "vue";
|
||||||
|
import type {ChatMessage} from "~/components/aigc/chat/index";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ChatMessage>,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const dayjs = useDayjs()
|
||||||
|
|
||||||
|
const message_place_end = computed(() => props.message?.role !== 'assistant')
|
||||||
|
const message_avatar = computed(() => {
|
||||||
|
switch (props.message?.role) {
|
||||||
|
case 'user':
|
||||||
|
return 'i-fluent-emoji-slightly-smiling-face'
|
||||||
|
case 'assistant':
|
||||||
|
return 'i-fluent-emoji-robot'
|
||||||
|
case 'system':
|
||||||
|
return 'i-fluent-emoji-receipt'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const message_background = computed(() => {
|
||||||
|
switch (props.message?.role) {
|
||||||
|
case 'user':
|
||||||
|
return 'bg-primary-100 dark:bg-primary-800'
|
||||||
|
case 'assistant':
|
||||||
|
case 'system':
|
||||||
|
return 'bg-neutral-100 dark:bg-neutral-800'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat" :class="{'justify-end': message_place_end}">
|
||||||
|
<div class="chat-inside" :class="{'items-end': message_place_end}">
|
||||||
|
<div class="chat-inside-avatar">
|
||||||
|
<Icon :name="message_avatar" class="text-lg"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col" :class="{'items-end': message_place_end}">
|
||||||
|
<div class="chat-inside-content" :class="message_background">
|
||||||
|
{{ message.content }}
|
||||||
|
</div>
|
||||||
|
<div class="chat-inside-extra">
|
||||||
|
{{ dayjs(message.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.chat {
|
||||||
|
@apply w-full flex;
|
||||||
|
|
||||||
|
&-inside {
|
||||||
|
@apply w-fit flex flex-col gap-2;
|
||||||
|
@apply md:max-w-[80%];
|
||||||
|
|
||||||
|
&-avatar {
|
||||||
|
@apply w-8 h-8 flex justify-center items-center rounded-xl;
|
||||||
|
@apply bg-white border shadow-card;
|
||||||
|
@apply dark:bg-neutral-800 dark:border-neutral-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
@apply px-2 py-2.5 rounded-xl text-sm w-fit;
|
||||||
|
@apply border dark:border-neutral-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-extra {
|
||||||
|
@apply px-1 text-xs text-neutral-300 dark:text-neutral-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
components/aigc/chat/index.d.ts
vendored
1
components/aigc/chat/index.d.ts
vendored
@@ -6,6 +6,7 @@ export interface ChatSession {
|
|||||||
subject: string
|
subject: string
|
||||||
create_at: number
|
create_at: number
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
|
last_input?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageRole = 'user' | 'assistant' | 'system'
|
export type MessageRole = 'user' | 'assistant' | 'system'
|
||||||
|
|||||||
@@ -1,46 +1,139 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import ChatItem from "~/components/aigc/chat/ChatItem.vue";
|
import ChatItem from "~/components/aigc/chat/ChatItem.vue";
|
||||||
import type {ChatSession, ChatSessionId} from "~/components/aigc/chat";
|
import type {ChatSession, ChatSessionId} from "~/components/aigc/chat";
|
||||||
|
import Message from "~/components/aigc/chat/Message.vue";
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: '聊天 | XSH AI'
|
title: '聊天 | XSH AI'
|
||||||
})
|
})
|
||||||
|
|
||||||
const dayjs = useDayjs()
|
const dayjs = useDayjs()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const sessions = ref<ChatSession[]>([
|
const sessions = ref<ChatSession[]>([])
|
||||||
{
|
const currentSessionId = ref<ChatSessionId | null>(null)
|
||||||
id: Math.random().toString(36).slice(2),
|
|
||||||
subject: '测试聊天',
|
const user_input = ref('')
|
||||||
messages: [
|
|
||||||
{
|
const getSessionCopyById = (chatSessionId: ChatSessionId) => sessions.value.find(s => s.id === chatSessionId)
|
||||||
id: Math.random().toString(36).slice(2),
|
const selectCurrentSessionId = (chatSessionId?: ChatSessionId) => {
|
||||||
role: 'user',
|
if (sessions.value.length > 0) {
|
||||||
content: '你好',
|
if (chatSessionId) { // 切换到指定 ID
|
||||||
create_at: dayjs().unix(),
|
// 保存当前输入并清空输入框
|
||||||
|
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||||
|
...s,
|
||||||
|
last_input: user_input.value,
|
||||||
|
} : s)
|
||||||
|
user_input.value = ''
|
||||||
|
// 切换到指定 ID 会话
|
||||||
|
currentSessionId.value = chatSessionId
|
||||||
|
// 恢复输入
|
||||||
|
user_input.value = getSessionCopyById(chatSessionId)?.last_input || ''
|
||||||
|
// 清除已恢复的输入
|
||||||
|
sessions.value = sessions.value.map(s => s.id === chatSessionId ? {
|
||||||
|
...s,
|
||||||
|
last_input: '',
|
||||||
|
} : s)
|
||||||
|
} else { // 切换到第一个会话
|
||||||
|
currentSessionId.value = sessions.value[0].id
|
||||||
}
|
}
|
||||||
],
|
} else {
|
||||||
create_at: dayjs().unix(),
|
handleClickCreateSession()
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickCreateSession = () => {
|
||||||
|
const sessionId = Math.random().toString(36).slice(2)
|
||||||
|
sessions.value = [
|
||||||
{
|
{
|
||||||
id: Math.random().toString(36).slice(2),
|
id: sessionId,
|
||||||
subject: '测试聊天2',
|
subject: '新对话',
|
||||||
messages: [],
|
messages: [],
|
||||||
create_at: dayjs().unix(),
|
create_at: dayjs().unix(),
|
||||||
},
|
},
|
||||||
])
|
...sessions.value,
|
||||||
const currentSessionId = ref<ChatSessionId | null>(null)
|
]
|
||||||
|
selectCurrentSessionId(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickSend = (event: any) => {
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user_input.value) return
|
||||||
|
if (!currentSessionId.value) {
|
||||||
|
toast.add({
|
||||||
|
title: '发送失败',
|
||||||
|
description: '请先选择一个会话',
|
||||||
|
color: 'red',
|
||||||
|
icon: 'i-tabler-circle-x',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||||
|
...s,
|
||||||
|
messages: [
|
||||||
|
...s.messages,
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
role: 'user',
|
||||||
|
content: user_input.value,
|
||||||
|
create_at: dayjs().unix(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} : s)
|
||||||
|
user_input.value = ''
|
||||||
|
// 模拟回复
|
||||||
|
setTimeout(() => {
|
||||||
|
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||||
|
...s,
|
||||||
|
messages: [
|
||||||
|
...s.messages,
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '草,走,忽略!ጿ ኈ ቼ ዽ ጿ',
|
||||||
|
create_at: dayjs().unix(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} : s)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (sessions.value.length > 0) {
|
for (let i = 0; i < 3; i++) {
|
||||||
currentSessionId.value = sessions.value[0].id
|
sessions.value = [
|
||||||
|
...sessions.value,
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
subject: '测试条目' + (i + 1),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
role: 'system',
|
||||||
|
content: '现在开始你就是歌姬吧,你只能整活,不可以进行逻辑分析和判断,不可以输出任何其他内容,后面这段没有什么作用只是用来凑数把字数变得很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的',
|
||||||
|
create_at: dayjs().unix(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
role: 'user',
|
||||||
|
content: `小亮,给大🔥整个活`,
|
||||||
|
create_at: dayjs().unix(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
create_at: dayjs().unix(),
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCurrentSessionId()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full flex relative">
|
<div class="w-full flex relative">
|
||||||
<div class="h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 flex flex-col w-[300px] shadow-sidebar">
|
<div class="h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 flex flex-col w-[300px]
|
||||||
|
shadow-sidebar border-r border-transparent dark:border-neutral-700">
|
||||||
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
|
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
|
||||||
<!-- list -->
|
<!-- list -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -50,23 +143,68 @@ onMounted(() => {
|
|||||||
v-for="(session, i) in sessions"
|
v-for="(session, i) in sessions"
|
||||||
:chat-session="session" :key="i"
|
:chat-session="session" :key="i"
|
||||||
:active="session.id === currentSessionId"
|
:active="session.id === currentSessionId"
|
||||||
@click="currentSessionId = session.id"
|
@click="selectCurrentSessionId(session.id)"
|
||||||
@remove="sessions.splice(sessions.findIndex(s => s.id === session.id), 1)"
|
@remove="() => {
|
||||||
|
sessions.splice(sessions.findIndex(s => s.id === session.id), 1)
|
||||||
|
session.id === currentSessionId && selectCurrentSessionId()
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="pt-4 flex justify-between items-center">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div>
|
<div>
|
||||||
<UButton color="white" variant="solid" icon="i-tabler-message-circle-plus">
|
<UButton
|
||||||
|
color="white"
|
||||||
|
variant="solid"
|
||||||
|
icon="i-tabler-message-circle-plus"
|
||||||
|
@click="handleClickCreateSession"
|
||||||
|
>
|
||||||
新建聊天
|
新建聊天
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="h-[calc(100vh-4rem)] bg-white dark:bg-neutral-900 flex-1 flex flex-col">
|
||||||
content
|
<div class="w-full p-4 bg-neutral-50 dark:bg-neutral-800/50 border-b dark:border-neutral-700/50">
|
||||||
|
{{ getSessionCopyById(currentSessionId!)?.subject || '新对话' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
|
||||||
|
<div class="flex flex-col gap-8 px-4 py-8">
|
||||||
|
<Message
|
||||||
|
v-for="(message, i) in getSessionCopyById(currentSessionId!)?.messages || []"
|
||||||
|
:message="message"
|
||||||
|
:key="message.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full p-4 pt-2 flex flex-col gap-2 bg-neutral-50 dark:bg-neutral-800/50 border-t dark:border-neutral-700/50">
|
||||||
|
<div>action bar</div>
|
||||||
|
<div class="relative">
|
||||||
|
<UTextarea
|
||||||
|
v-model="user_input"
|
||||||
|
size="lg"
|
||||||
|
autoresize
|
||||||
|
:rows="5"
|
||||||
|
:maxrows="12"
|
||||||
|
class="font-sans"
|
||||||
|
placeholder="Enter 发送, Ctrl + Enter 换行"
|
||||||
|
@keydown.ctrl.enter="user_input += '\n'"
|
||||||
|
@keydown.enter.prevent="handleClickSend"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
color="black"
|
||||||
|
variant="solid"
|
||||||
|
icon="i-tabler-send-2"
|
||||||
|
class="absolute bottom-2.5 right-3"
|
||||||
|
@click.stop="handleClickSend"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ export default <Partial<Config>>{
|
|||||||
boxShadow: {
|
boxShadow: {
|
||||||
card: '0 2px 4px 0 rgba(0, 0, 0, .05)',
|
card: '0 2px 4px 0 rgba(0, 0, 0, .05)',
|
||||||
sidebar: 'inset -2px 0 2px 0 rgba(0, 0, 0, .05)',
|
sidebar: 'inset -2px 0 2px 0 rgba(0, 0, 0, .05)',
|
||||||
|
sidebar_dark: 'inset -2px 0 2px 0 rgba(255, 255, 255, .05)',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
safelist: [
|
||||||
|
{
|
||||||
|
pattern: /^bg-/,
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user