feat: 前端搭建
This commit is contained in:
31
Yi.Ai.Vue3/src/pages/chat/index.vue
Normal file
31
Yi.Ai.Vue3/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
import ChatDefaul from '@/pages/chat/layouts/chatDefaul/index.vue';
|
||||
import ChatWithId from '@/pages/chat/layouts/chatWithId/index.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const sessionId = computed(() => route.params?.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<!-- 默认聊天页面 -->
|
||||
<ChatDefaul v-if="!sessionId" />
|
||||
<!-- 带id的聊天页面 -->
|
||||
<ChatWithId v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(100% - 32px);
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
</style>
|
||||
121
Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue
Normal file
121
Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<!-- 默认消息列表页 -->
|
||||
<script setup lang="ts">
|
||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
import FilesSelect from '@/components/FilesSelect/index.vue';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import WelecomeText from '@/components/WelecomeText/index.vue';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
import { useSessionStore } from '@/stores/modules/session';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const filesStore = useFilesStore();
|
||||
|
||||
const senderValue = ref('');
|
||||
const senderRef = ref();
|
||||
|
||||
async function handleSend() {
|
||||
localStorage.setItem('chatContent', senderValue.value);
|
||||
await sessionStore.createSessionList({
|
||||
userId: userStore.userInfo?.userId as number,
|
||||
sessionContent: senderValue.value,
|
||||
sessionTitle: senderValue.value.slice(0, 10),
|
||||
remark: senderValue.value.slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
||||
filesStore.deleteFileByIndex(index);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filesStore.filesList.length,
|
||||
(val) => {
|
||||
if (val > 0) {
|
||||
nextTick(() => {
|
||||
senderRef.value.openHeader();
|
||||
});
|
||||
}
|
||||
else {
|
||||
nextTick(() => {
|
||||
senderRef.value.closeHeader();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-defaul-wrap">
|
||||
<WelecomeText />
|
||||
<Sender
|
||||
ref="senderRef"
|
||||
v-model="senderValue"
|
||||
class="chat-defaul-sender"
|
||||
:auto-size="{
|
||||
maxRows: 9,
|
||||
minRows: 3,
|
||||
}"
|
||||
variant="updown"
|
||||
clearable
|
||||
allow-speech
|
||||
@submit="handleSend"
|
||||
>
|
||||
<template #header>
|
||||
<div class="sender-header p-12px pt-6px pb-0px">
|
||||
<Attachments
|
||||
:items="filesStore.filesList"
|
||||
:hide-upload="true"
|
||||
@delete-card="handleDeleteCard"
|
||||
>
|
||||
<template #prev-button="{ show, onScrollLeft }">
|
||||
<div
|
||||
v-if="show"
|
||||
class="prev-next-btn left-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||||
@click="onScrollLeft"
|
||||
>
|
||||
<el-icon>
|
||||
<ArrowLeftBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #next-button="{ show, onScrollRight }">
|
||||
<div
|
||||
v-if="show"
|
||||
class="prev-next-btn right-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||||
@click="onScrollRight"
|
||||
>
|
||||
<el-icon>
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</Attachments>
|
||||
</div>
|
||||
</template>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<FilesSelect />
|
||||
<ModelSelect />
|
||||
</div>
|
||||
</template>
|
||||
</Sender>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-defaul-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
min-height: 450px;
|
||||
.chat-defaul-sender {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
345
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
Normal file
345
Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<!-- 每个回话对应的聊天内容 -->
|
||||
<script setup lang="ts">
|
||||
import type { AnyObject } from 'typescript-api-pro';
|
||||
import type { Sender } from 'vue-element-plus-x';
|
||||
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
||||
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import { useHookFetch } from 'hook-fetch/vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { send } from '@/api';
|
||||
import FilesSelect from '@/components/FilesSelect/index.vue';
|
||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||
import { useChatStore } from '@/stores/modules/chat';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
import { useModelStore } from '@/stores/modules/model';
|
||||
import { useUserStore } from '@/stores/modules/user';
|
||||
|
||||
type MessageItem = BubbleProps & {
|
||||
key: number;
|
||||
role: 'ai' | 'user' | 'system';
|
||||
avatar: string;
|
||||
thinkingStatus?: ThinkingStatus;
|
||||
thinlCollapse?: boolean;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const modelStore = useModelStore();
|
||||
const filesStore = useFilesStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 用户头像
|
||||
const avatar = computed(() => {
|
||||
const userInfo = userStore.userInfo;
|
||||
return userInfo?.avatar || 'https://avatars.githubusercontent.com/u/76239030?v=4';
|
||||
});
|
||||
|
||||
const inputValue = ref('');
|
||||
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
|
||||
const bubbleItems = ref<MessageItem[]>([]);
|
||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||
|
||||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||||
request: send,
|
||||
onError: (err) => {
|
||||
console.warn('测试错误拦截', err);
|
||||
},
|
||||
});
|
||||
// 记录进入思考中
|
||||
let isThinking = false;
|
||||
|
||||
watch(
|
||||
() => route.params?.id,
|
||||
async (_id_) => {
|
||||
if (_id_) {
|
||||
if (_id_ !== 'not_login') {
|
||||
// 判断的当前会话id是否有聊天记录,有缓存则直接赋值展示
|
||||
if (chatStore.chatMap[`${_id_}`] && chatStore.chatMap[`${_id_}`].length) {
|
||||
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
bubbleListRef.value!.scrollToBottom();
|
||||
}, 350);
|
||||
return;
|
||||
}
|
||||
|
||||
// 无缓存则请求聊天记录
|
||||
await chatStore.requestChatList(`${_id_}`);
|
||||
// 请求聊天记录后,赋值回显,并滚动到底部
|
||||
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
|
||||
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
bubbleListRef.value!.scrollToBottom();
|
||||
}, 350);
|
||||
}
|
||||
|
||||
// 如果本地有发送内容 ,则直接发送
|
||||
const v = localStorage.getItem('chatContent');
|
||||
if (v) {
|
||||
// 发送消息
|
||||
console.log('发送消息 v', v);
|
||||
setTimeout(() => {
|
||||
startSSE(v);
|
||||
}, 350);
|
||||
|
||||
localStorage.removeItem('chatContent');
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// 封装数据处理逻辑
|
||||
function handleDataChunk(chunk: AnyObject) {
|
||||
try {
|
||||
const reasoningChunk = chunk.choices?.[0].delta.reasoning_content;
|
||||
if (reasoningChunk) {
|
||||
// 开始思考链状态
|
||||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
|
||||
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
|
||||
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
|
||||
if (bubbleItems.value.length) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += reasoningChunk;
|
||||
}
|
||||
}
|
||||
|
||||
// 另一种思考中形式,content中有 <think></think> 的格式
|
||||
// 一开始匹配到 <think> 开始,匹配到 </think> 结束,并处理标签中的内容为思考内容
|
||||
const parsedChunk = chunk.choices?.[0].delta.content;
|
||||
if (parsedChunk) {
|
||||
const thinkStart = parsedChunk.includes('<think>');
|
||||
const thinkEnd = parsedChunk.includes('</think>');
|
||||
if (thinkStart) {
|
||||
isThinking = true;
|
||||
}
|
||||
if (thinkEnd) {
|
||||
isThinking = false;
|
||||
}
|
||||
if (isThinking) {
|
||||
// 开始思考链状态
|
||||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
|
||||
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
|
||||
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
|
||||
if (bubbleItems.value.length) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += parsedChunk
|
||||
.replace('<think>', '')
|
||||
.replace('</think>', '');
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 结束 思考链状态
|
||||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'end';
|
||||
bubbleItems.value[bubbleItems.value.length - 1].loading = false;
|
||||
if (bubbleItems.value.length) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1].content += parsedChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
// 这里如果使用了中断,会有报错,可以忽略不管
|
||||
console.error('解析数据时出错:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 封装错误处理逻辑
|
||||
function handleError(err: any) {
|
||||
console.error('Fetch error:', err);
|
||||
}
|
||||
|
||||
async function startSSE(chatContent: string) {
|
||||
try {
|
||||
// 添加用户输入的消息
|
||||
// console.log('chatContent', chatContent);
|
||||
// 清空输入框
|
||||
inputValue.value = '';
|
||||
addMessage(chatContent, true);
|
||||
addMessage('', false);
|
||||
|
||||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||||
bubbleListRef.value?.scrollToBottom();
|
||||
|
||||
for await (const chunk of stream({
|
||||
messages: bubbleItems.value
|
||||
.filter((item: any) => item.role === 'user')
|
||||
.map((item: any) => ({
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
})),
|
||||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : undefined,
|
||||
userId: userStore.userInfo?.userId,
|
||||
model: modelStore.currentModelInfo.modelName ?? '',
|
||||
})) {
|
||||
handleDataChunk(chunk.result as AnyObject);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
finally {
|
||||
console.log('数据接收完毕');
|
||||
// 停止打字器状态
|
||||
if (bubbleItems.value.length) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中断请求
|
||||
async function cancelSSE() {
|
||||
cancel();
|
||||
// 结束最后一条消息打字状态
|
||||
if (bubbleItems.value.length) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加消息 - 维护聊天记录
|
||||
function addMessage(message: string, isUser: boolean) {
|
||||
const i = bubbleItems.value.length;
|
||||
const obj: MessageItem = {
|
||||
key: i,
|
||||
avatar: isUser
|
||||
? avatar.value
|
||||
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||||
avatarSize: '32px',
|
||||
role: isUser ? 'user' : 'system',
|
||||
placement: isUser ? 'end' : 'start',
|
||||
isMarkdown: !isUser,
|
||||
loading: !isUser,
|
||||
content: message || '',
|
||||
reasoning_content: '',
|
||||
thinkingStatus: 'start',
|
||||
thinlCollapse: false,
|
||||
};
|
||||
bubbleItems.value.push(obj);
|
||||
}
|
||||
|
||||
// 展开收起 事件展示
|
||||
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
||||
console.log('value', payload.value, 'status', payload.status);
|
||||
}
|
||||
|
||||
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
||||
filesStore.deleteFileByIndex(index);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filesStore.filesList.length,
|
||||
(val) => {
|
||||
if (val > 0) {
|
||||
nextTick(() => {
|
||||
senderRef.value?.openHeader();
|
||||
});
|
||||
}
|
||||
else {
|
||||
nextTick(() => {
|
||||
senderRef.value?.closeHeader();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-with-id-container">
|
||||
<div class="chat-warp">
|
||||
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
|
||||
<template #header="{ item }">
|
||||
<Thinking
|
||||
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
||||
:status="item.thinkingStatus" class="thinking-chain-warp" @change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
</BubbleList>
|
||||
|
||||
<Sender
|
||||
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" :auto-size="{
|
||||
maxRows: 6,
|
||||
minRows: 2,
|
||||
}" variant="updown" clearable allow-speech :loading="isLoading" @submit="startSSE" @cancel="cancelSSE"
|
||||
>
|
||||
<template #header>
|
||||
<div class="sender-header p-12px pt-6px pb-0px">
|
||||
<Attachments :items="filesStore.filesList" :hide-upload="true" @delete-card="handleDeleteCard">
|
||||
<template #prev-button="{ show, onScrollLeft }">
|
||||
<div
|
||||
v-if="show"
|
||||
class="prev-next-btn left-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||||
@click="onScrollLeft"
|
||||
>
|
||||
<el-icon>
|
||||
<ArrowLeftBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #next-button="{ show, onScrollRight }">
|
||||
<div
|
||||
v-if="show"
|
||||
class="prev-next-btn right-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||||
@click="onScrollRight"
|
||||
>
|
||||
<el-icon>
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</Attachments>
|
||||
</div>
|
||||
</template>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<FilesSelect />
|
||||
<ModelSelect />
|
||||
</div>
|
||||
</template>
|
||||
</Sender>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-with-id-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
height: 100%;
|
||||
.chat-warp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: calc(100vh - 60px);
|
||||
.thinking-chain-warp {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
:deep() {
|
||||
.el-bubble-list {
|
||||
padding-top: 24px;
|
||||
}
|
||||
.el-bubble {
|
||||
padding: 0 12px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.el-typewriter {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.markdown-body {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.chat-defaul-sender {
|
||||
width: 100%;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user