test(api): ✅ 测试流式接口返回
This commit is contained in:
parent
165a4748bb
commit
1d5e519fb4
@ -28,6 +28,10 @@ const popoverStyle = ref({
|
||||
width: '200px',
|
||||
padding: '4px',
|
||||
height: 'fit-content',
|
||||
background: 'var(--el-bg-color, #fff)',
|
||||
border: '1px solid var(--el-border-color-light)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
const popoverRef = ref();
|
||||
|
||||
@ -45,25 +49,25 @@ function handleClick(item: GetSessionListVO) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-switch">
|
||||
<div class="model-select">
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
position="top-start"
|
||||
:offset="[-14, 10]"
|
||||
:trigger-style="{ cursor: 'pointer' }"
|
||||
placement="top-start"
|
||||
:offset="[4, 0]"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
trigger="clickTarget"
|
||||
@show="showPopover"
|
||||
>
|
||||
<!-- 触发元素插槽 -->
|
||||
<template #trigger>
|
||||
<div
|
||||
class="model-switch-box select-none flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-12px border-[rgba()]"
|
||||
class="model-select-box select-none flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-12px border-[rgba()]"
|
||||
>
|
||||
<div class="model-switch-box-icon">
|
||||
<div class="model-select-box-icon">
|
||||
<SvgIcon name="models" size="12" />
|
||||
</div>
|
||||
<div class="model-switch-box-text font-size-12px">
|
||||
<div class="model-select-box-text font-size-12px">
|
||||
{{ currentModelName }}
|
||||
</div>
|
||||
</div>
|
||||
@ -73,30 +77,30 @@ function handleClick(item: GetSessionListVO) {
|
||||
<div
|
||||
v-for="item in popoverList"
|
||||
:key="item.id"
|
||||
class="popover-content-box-items flex rounded-8px select-none transition-all transition-duration-300 flex items-center gap-8px p-4px hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
|
||||
class="popover-content-box-items w-full rounded-8px select-none transition-all transition-duration-300 flex items-center hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<el-tooltip
|
||||
popper-class="rounded-tooltip"
|
||||
effect="dark"
|
||||
<Popover
|
||||
trigger-class="popover-trigger-item-text"
|
||||
popover-class="rounded-tooltip"
|
||||
placement="right"
|
||||
trigger="hover"
|
||||
:offset="10"
|
||||
:show-arrow="false"
|
||||
transition="zoom-fade"
|
||||
:offset="[12, 0]"
|
||||
>
|
||||
<template #content>
|
||||
<div class="popover-content-box-item-text text-wrap max-w-200px rounded-lg">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="popover-content-box-item font-size-12px text-overflow w-full line-height-16px"
|
||||
class="popover-content-box-item p-4px font-size-12px text-overflow line-height-16px"
|
||||
:class="{ 'bg-[rgba(0,0,0,.04)] is-select': item.modelName === currentModelName }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
{{ item.modelName }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div
|
||||
class="popover-content-box-item-text text-wrap max-w-200px rounded-lg p-8px font-size-12px line-height-tight"
|
||||
>
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
@ -104,7 +108,7 @@ function handleClick(item: GetSessionListVO) {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-switch-box {
|
||||
.model-select-box {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
border: 1px solid var(--el-color-primary, #409eff);
|
||||
border-radius: 10px;
|
||||
@ -116,20 +120,35 @@ function handleClick(item: GetSessionListVO) {
|
||||
}
|
||||
|
||||
.popover-content-box {
|
||||
// background-color: red;
|
||||
height: 200px;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.popover-content-box-items {
|
||||
:deep() {
|
||||
.popover-trigger-item-text {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-content-box-item-text {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
|
||||
@ -1,31 +1,9 @@
|
||||
<!-- Popover 弹框 -->
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { flip, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
position: 'bottom',
|
||||
offset: () => [8, 8],
|
||||
boundary: 'viewport',
|
||||
closeOnContentClick: false,
|
||||
closeOnTriggerClick: false,
|
||||
triggerStyle: () => ({}),
|
||||
popoverStyle: () => ({}),
|
||||
popoverClass: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'show'): void;
|
||||
(e: 'hide'): void;
|
||||
(e: 'positionChange', pos: PopoverPosition): void;
|
||||
}>();
|
||||
const reference = ref();
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(reference, floating, {
|
||||
placement: props.position,
|
||||
middleware: [offset(props.offset[0]), flip(), shift()],
|
||||
});
|
||||
|
||||
type PopoverPosition =
|
||||
export type PopoverPlacement =
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
@ -39,54 +17,189 @@ type PopoverPosition =
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
|
||||
type Offset = [number, number];
|
||||
interface PopoverProps {
|
||||
position?: PopoverPosition;
|
||||
export type Offset = [number, number];
|
||||
|
||||
export interface PopoverProps {
|
||||
placement?: PopoverPlacement;
|
||||
offset?: Offset;
|
||||
triggerStyle?: CSSProperties;
|
||||
popoverStyle?: CSSProperties;
|
||||
popoverClass?: string;
|
||||
boundary?: 'viewport' | HTMLElement;
|
||||
closeOnContentClick?: boolean;
|
||||
closeOnTriggerClick?: boolean;
|
||||
trigger?: 'hover' | 'click' | 'clickTarget';
|
||||
triggerStyle?: CSSProperties;
|
||||
triggerClass?: string;
|
||||
hoverDelay?: number; // 悬停延迟关闭时间(ms)
|
||||
}
|
||||
|
||||
const showPoperContent = ref(false);
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
placement: 'bottom',
|
||||
offset: () => [0, 0],
|
||||
trigger: 'hover',
|
||||
hoverDelay: 0, // 默认300ms延迟关闭
|
||||
});
|
||||
|
||||
function handleTriggerClick() {
|
||||
showPoperContent.value = !showPoperContent.value;
|
||||
if (showPoperContent.value) {
|
||||
const emits = defineEmits<{
|
||||
(e: 'show'): void;
|
||||
(e: 'hide'): void;
|
||||
}>();
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const popoverRef = ref<HTMLElement | null>(null);
|
||||
const floatingArrow = ref<HTMLElement | null>(null);
|
||||
const showPopover = ref(false);
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// 新增:记录鼠标是否在触发元素或内容区域内
|
||||
const isHovering = ref(false);
|
||||
|
||||
const { floatingStyles } = useFloating(triggerRef, popoverRef, {
|
||||
placement: props.placement,
|
||||
transform: false,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
shift(),
|
||||
flip(),
|
||||
arrow({ element: floatingArrow }),
|
||||
offset({
|
||||
mainAxis: props.offset[0],
|
||||
crossAxis: props.offset[1],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
function show() {
|
||||
if (!showPopover.value) {
|
||||
showPopover.value = true;
|
||||
emits('show');
|
||||
}
|
||||
else {
|
||||
// 显示时强制清除定时器(无论是否在悬停)
|
||||
if (hideTimeout)
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (showPopover.value) {
|
||||
showPopover.value = false;
|
||||
emits('hide');
|
||||
}
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
defineExpose({ show, hide });
|
||||
|
||||
watch(showPopover, (newValue) => {
|
||||
if (newValue && props.trigger !== 'hover') {
|
||||
onClickOutside(popoverRef, () => hide(), {
|
||||
ignore: [triggerRef] as any[],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 触发元素鼠标事件调整(同步isHovering状态)
|
||||
function handleTriggerMouseEnter() {
|
||||
if (props.trigger === 'hover') {
|
||||
isHovering.value = true; // 进入触发元素
|
||||
show();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTriggerMouseLeave() {
|
||||
if (props.trigger === 'hover') {
|
||||
isHovering.value = false; // 离开触发元素
|
||||
// 仅当鼠标不在内容区域时,才设置延迟关闭
|
||||
scheduleHideIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域鼠标事件调整(同步isHovering状态)
|
||||
function handlePopoverMouseEnter() {
|
||||
if (props.trigger === 'hover') {
|
||||
isHovering.value = true; // 进入内容区域
|
||||
if (hideTimeout)
|
||||
clearTimeout(hideTimeout); // 取消关闭
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopoverMouseLeave() {
|
||||
if (props.trigger === 'hover') {
|
||||
isHovering.value = false; // 离开内容区域
|
||||
// 仅当鼠标不在触发元素时,才设置延迟关闭
|
||||
scheduleHideIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:统一延迟关闭逻辑(仅当完全离开两个区域时触发)
|
||||
function scheduleHideIfNeeded() {
|
||||
// 如果鼠标仍在任一区域(isHovering为true),不关闭
|
||||
if (isHovering.value)
|
||||
return;
|
||||
|
||||
// 否则设置延迟关闭
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (!isHovering.value) {
|
||||
// 再次确认是否仍离开
|
||||
hide();
|
||||
}
|
||||
}, props.hoverDelay);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (props.trigger === 'click') {
|
||||
showPopover.value ? hide() : show();
|
||||
}
|
||||
else if (props.trigger === 'clickTarget' && !showPopover.value) {
|
||||
show();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div ref="reference" :style="props.triggerStyle" @click.stop="handleTriggerClick">
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="popover-trigger"
|
||||
:class="[props.triggerClass]"
|
||||
:style="[props.triggerStyle]"
|
||||
@mouseenter="handleTriggerMouseEnter"
|
||||
@mouseleave="handleTriggerMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="popover-fade">
|
||||
<div
|
||||
v-if="showPoperContent"
|
||||
ref="floating"
|
||||
class="popover-content"
|
||||
:style="{
|
||||
...floatingStyles,
|
||||
...props.popoverStyle,
|
||||
}"
|
||||
v-if="showPopover"
|
||||
ref="popoverRef"
|
||||
:style="[floatingStyles, props.popoverStyle]"
|
||||
class="popover-content-box"
|
||||
:class="[props.popoverClass]"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.popover-fade-enter-active,
|
||||
.popover-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.popover-fade-enter-from,
|
||||
.popover-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.popover-fade-enter-to,
|
||||
.popover-fade-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -28,10 +28,16 @@ onMounted(async () => {
|
||||
const currentSessionRes = await get_session(`${sessionId.value}`);
|
||||
// 通过 ID 查询详情,设置当前会话 (因为有分页)
|
||||
sessionStore.setCurrentSession(currentSessionRes.data);
|
||||
active.value = `${sessionId.value}`;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sessionStore.currentSession,
|
||||
(newValue) => {
|
||||
active.value = newValue ? `${newValue.id}` : undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// 创建会话
|
||||
function handleCreatChat() {
|
||||
// 创建会话, 跳转到默认聊天
|
||||
|
||||
@ -94,8 +94,8 @@ function handleClick(item: any) {
|
||||
<div class="avatar-container">
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
position="bottom-end"
|
||||
:offset="[0, 8]"
|
||||
placement="bottom-end"
|
||||
trigger="clickTarget"
|
||||
:trigger-style="{ cursor: 'pointer' }"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
@ -105,7 +105,7 @@ function handleClick(item: any) {
|
||||
<el-avatar :src="src" :size="28" fit="fit" shape="circle" />
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div class="popover-content-box shadow-lg">
|
||||
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
|
||||
<div
|
||||
v-if="!item.divider"
|
||||
@ -130,4 +130,12 @@ function handleClick(item: any) {
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.popover-content-box {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
<!-- 默认消息列表页 -->
|
||||
<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);
|
||||
@ -18,12 +24,33 @@ async function handleSend() {
|
||||
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="{
|
||||
@ -35,16 +62,43 @@ async function handleSend() {
|
||||
allow-speech
|
||||
@submit="handleSend"
|
||||
>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<ModelSelect />
|
||||
<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
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
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>
|
||||
<Paperclip />
|
||||
<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>
|
||||
|
||||
@ -2,12 +2,15 @@
|
||||
<script setup lang="ts">
|
||||
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 { Loading, Position } from '@element-plus/icons-vue';
|
||||
import { useXStream } from 'vue-element-plus-x';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { send } from '@/api/chat/index';
|
||||
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';
|
||||
|
||||
type MessageItem = BubbleProps & {
|
||||
@ -18,7 +21,7 @@ type MessageItem = BubbleProps & {
|
||||
expanded?: boolean;
|
||||
};
|
||||
|
||||
const { startStream: _, cancel, data, error, isLoading } = useXStream();
|
||||
const { cancel, error, isLoading } = useXStream();
|
||||
|
||||
// const BASE_URL = 'https://api.siliconflow.cn/v1/chat/completions';
|
||||
// 仅供测试,请勿拿去测试其他付费模型
|
||||
@ -28,18 +31,22 @@ const { startStream: _, cancel, data, error, isLoading } = useXStream();
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const modelStore = useModelStore();
|
||||
const inputValue = ref('帮我写一篇小米手机介绍');
|
||||
const filesStore = useFilesStore();
|
||||
|
||||
const inputValue = ref('');
|
||||
const senderRef = ref<any>(null);
|
||||
const bubbleItems = ref<MessageItem[]>([]);
|
||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||
const processedIndex = ref(0);
|
||||
// const processedIndex = ref(0);
|
||||
|
||||
watch(
|
||||
() => route.params?.id,
|
||||
async (_id_) => {
|
||||
if (_id_) {
|
||||
await chatStore.requestChatList(`${_id_}`);
|
||||
// 判断的当前会话id是否有聊天记录
|
||||
// 清空输入框
|
||||
inputValue.value = '';
|
||||
|
||||
// 判断的当前会话id是否有聊天记录,有缓存则直接赋值展示
|
||||
if (chatStore.chatMap[`${_id_}`] && chatStore.chatMap[`${_id_}`].length) {
|
||||
bubbleItems.value = chatStore.chatMap[`${_id_}`].map((item: any) => ({
|
||||
key: item.id,
|
||||
@ -57,17 +64,39 @@ watch(
|
||||
setTimeout(() => {
|
||||
bubbleListRef.value!.scrollToBottom();
|
||||
}, 350);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 无缓存则请求聊天记录
|
||||
await chatStore.requestChatList(`${_id_}`);
|
||||
// 请求聊天记录后,赋值回显,并滚动到底部
|
||||
bubbleItems.value = chatStore.chatMap[`${_id_}`].map((item: any) => ({
|
||||
key: item.id,
|
||||
avatar:
|
||||
item.role === 'user'
|
||||
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
|
||||
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||||
content: item.content,
|
||||
avatarSize: '32px',
|
||||
role: item.role,
|
||||
typing: false,
|
||||
}));
|
||||
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
bubbleListRef.value!.scrollToBottom();
|
||||
}, 350);
|
||||
|
||||
// 如果本地有发送内容 ,则直接发送
|
||||
const v = localStorage.getItem('chatContent');
|
||||
if (v) {
|
||||
inputValue.value = v;
|
||||
localStorage.removeItem('chatContent');
|
||||
// 发送消息
|
||||
console.log('发送消息 v', v);
|
||||
setTimeout(() => {
|
||||
startSSE();
|
||||
}, 350);
|
||||
// setTimeout(() => {
|
||||
// startSSE();
|
||||
// }, 350);
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -113,28 +142,88 @@ function handleDataChunk(chunk: string) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
data,
|
||||
() => {
|
||||
for (let i = processedIndex.value; i < data.value.length; i++) {
|
||||
const chunk = data.value[i].data;
|
||||
handleDataChunk(chunk);
|
||||
processedIndex.value++;
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
// watch(
|
||||
// data,
|
||||
// () => {
|
||||
// for (let i = processedIndex.value; i < data.value.length; i++) {
|
||||
// const chunk = data.value[i].data;
|
||||
// handleDataChunk(chunk);
|
||||
// processedIndex.value++;
|
||||
// }
|
||||
// },
|
||||
// { deep: true },
|
||||
// );
|
||||
|
||||
// 封装错误处理逻辑
|
||||
function handleError(err: any) {
|
||||
console.error('Fetch error:', err);
|
||||
}
|
||||
|
||||
async function startSSE() {
|
||||
function checkJsonSerializable(data: any) {
|
||||
const visited = new WeakSet();
|
||||
let error: any = null;
|
||||
|
||||
function check(value: any, currentPath = 'root') {
|
||||
if (error)
|
||||
return;
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
// 处理基本类型(支持 JSON 的类型)
|
||||
if (type === 'string' || type === 'number' || type === 'boolean' || value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 undefined(JSON 不支持)
|
||||
if (value === undefined) {
|
||||
error = { path: currentPath, value, type: 'undefined' };
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理函数、Symbol(JSON 不支持)
|
||||
if (type === 'function' || type === 'symbol') {
|
||||
error = { path: currentPath, value, type };
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数组:递归检查每个元素
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
check(item, `${currentPath}[${index}]`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理对象:检查是否为普通对象(非特殊对象)
|
||||
if (type === 'object' && value !== null) {
|
||||
// 检测循环引用
|
||||
if (visited.has(value)) {
|
||||
error = { path: currentPath, value, type: 'circular_reference' };
|
||||
return;
|
||||
}
|
||||
visited.add(value);
|
||||
|
||||
// 递归检查对象的每个属性
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
check(val, `${currentPath}.${key}`);
|
||||
}
|
||||
visited.delete(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他不支持的类型(如 Date、RegExp 等)
|
||||
error = { path: currentPath, value, type };
|
||||
}
|
||||
|
||||
check(data);
|
||||
return { valid: !error, error };
|
||||
}
|
||||
|
||||
async function startSSE(chatContent: string) {
|
||||
try {
|
||||
// 添加用户输入的消息
|
||||
console.log('inputValue.value', inputValue.value);
|
||||
addMessage(inputValue.value, true);
|
||||
console.log('chatContent', chatContent);
|
||||
addMessage(chatContent, true);
|
||||
addMessage('', false);
|
||||
|
||||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||||
@ -154,6 +243,22 @@ async function startSSE() {
|
||||
|
||||
console.log('res', res);
|
||||
|
||||
for await (const chunk of res) {
|
||||
console.log('chunk', chunk);
|
||||
const resError = checkJsonSerializable(chunk.result);
|
||||
console.log('resError', resError);
|
||||
|
||||
// 判断 json 序列化失败的情况
|
||||
if (chunk.result && typeof chunk.result !== 'object') {
|
||||
console.log('json 序列化失败');
|
||||
handleDataChunk(chunk.result as string);
|
||||
}
|
||||
else if (chunk.result) {
|
||||
const strChunk = JSON.stringify(chunk.result);
|
||||
handleDataChunk(strChunk);
|
||||
}
|
||||
}
|
||||
|
||||
// const response = await fetch(BASE_URL, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
@ -190,7 +295,7 @@ function addMessage(message: string, isUser: boolean) {
|
||||
avatar: isUser
|
||||
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
|
||||
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||||
avatarSize: '48px',
|
||||
avatarSize: '32px',
|
||||
role: isUser ? 'user' : 'system',
|
||||
placement: isUser ? 'end' : 'start',
|
||||
isMarkdown: !isUser,
|
||||
@ -211,6 +316,26 @@ function addMessage(message: string, isUser: boolean) {
|
||||
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>
|
||||
@ -243,32 +368,46 @@ function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
||||
variant="updown"
|
||||
clearable
|
||||
allow-speech
|
||||
:loading="isLoading"
|
||||
@submit="startSSE"
|
||||
>
|
||||
<template #prefix>
|
||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||
<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
|
||||
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
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>
|
||||
<Paperclip />
|
||||
<ArrowLeftBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action-list>
|
||||
<div class="footer-container">
|
||||
<el-button v-if="!isLoading" type="danger" circle @click="senderRef.submit()">
|
||||
<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>
|
||||
<Position />
|
||||
<ArrowRightBold />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="isLoading" type="primary" @click="cancel">
|
||||
<el-icon class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</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>
|
||||
|
||||
2
types/components.d.ts
vendored
2
types/components.d.ts
vendored
@ -36,7 +36,7 @@ declare module 'vue' {
|
||||
VerificationCode: typeof import('./../src/components/LoginDialog/components/FormLogin/VerificationCode.vue')['default']
|
||||
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user