449 lines
16 KiB
Vue
449 lines
16 KiB
Vue
<template>
|
||
<div class="flex flex-col py-3 rounded-lg group">
|
||
<template v-if="(message.role as string) === 'assistant'">
|
||
<!-- Assistant message -->
|
||
<div class="relative chat chat-start" v-if="message.content || message.reasoning_content">
|
||
<div class="chat-image avatar">
|
||
<div v-if="selectedKnowledge?.avatarUrl?.includes('svg')" v-html="selectedKnowledge?.avatarUrl"></div>
|
||
<div class="w-[40px] h-[40px] rounded-full overflow-hidden" v-else>
|
||
<img
|
||
:src="selectedKnowledge?.avatarUrl?.includes('http') ? selectedKnowledge?.avatarUrl : baseURL + selectedKnowledge?.avatarUrl"
|
||
class="object-cover w-full h-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center mb-1 ml-6 space-x-2 chat-header">
|
||
<span class="font-medium text-gray-800 dark:text-gray-200">AI 助手</span>
|
||
<time class="text-xs opacity-70">{{ message.time }}</time>
|
||
<div class="flex items-center space-x-1">
|
||
<el-button
|
||
@click="handleCopyText(message.content)"
|
||
class="!p-1 h-6"
|
||
text
|
||
icon="CopyDocument"
|
||
v-if="message.content"
|
||
type="primary"
|
||
></el-button>
|
||
<audio-player
|
||
ref="audioPlayerRef"
|
||
:text="message.content"
|
||
v-if="message === messageList[messageList.length - 1] && message.content && messageList.length > 1"
|
||
/>
|
||
<el-button
|
||
@click="handleRegenerate()"
|
||
class="!p-1 h-6"
|
||
text
|
||
icon="Refresh"
|
||
v-if="message === messageList[messageList.length - 1] && message.content && messageList.length > 1"
|
||
type="primary"
|
||
></el-button>
|
||
</div>
|
||
</div>
|
||
<div class="max-w-3xl bg-white rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||
<div class="flex flex-col gap-3 p-4" :class="{ 'md:flex-row': message.content && message.chartId && isWideScreen }">
|
||
<!-- Reasoning content collapsible panel -->
|
||
<div
|
||
v-if="message.reasoning_content"
|
||
:class="[
|
||
'bg-gray-50 rounded-lg border border-gray-200 collapse collapse-arrow dark:bg-gray-800 dark:border-gray-700',
|
||
collapseStates[message.time || ''] === false ? 'collapse-close' : 'collapse-open',
|
||
]"
|
||
>
|
||
<input type="checkbox" class="peer" @change="collapseStates[message.time || ''] = !collapseStates[message.time || '']" />
|
||
<div
|
||
class="text-sm font-medium text-gray-700 collapse-title dark:text-gray-300 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||
>
|
||
<div class="flex gap-2 items-center">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
class="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||
/>
|
||
</svg>
|
||
已深度思考(用时 {{ message.thinking_time || '0' }} 秒)
|
||
</div>
|
||
</div>
|
||
<div class="collapse-content bg-gray-50/50 dark:bg-gray-800/50 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50">
|
||
<div class="pt-4 text-gray-600 whitespace-pre-wrap dark:text-gray-300">
|
||
{{ message.reasoning_content }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tool info collapsible panel -->
|
||
<div
|
||
v-if="message.toolInfo"
|
||
:class="[
|
||
'bg-gray-50 rounded-lg border border-gray-200 collapse collapse-arrow dark:bg-gray-800 dark:border-gray-700',
|
||
collapseStates['tool_' + index] === true ? 'collapse-open' : 'collapse-close',
|
||
]"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
class="peer"
|
||
@change="collapseStates['tool_' + index] = !collapseStates['tool_' + index]"
|
||
/>
|
||
<div
|
||
class="text-sm font-medium text-gray-700 collapse-title dark:text-gray-300 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||
>
|
||
<div class="flex gap-2 items-center">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
class="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||
/>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||
/>
|
||
</svg>
|
||
工具调用:{{ message.toolInfo.name }}
|
||
</div>
|
||
</div>
|
||
<div class="collapse-content bg-gray-50/50 dark:bg-gray-800/50 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50">
|
||
<div class="pt-4">
|
||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">工具名称:</div>
|
||
<div class="mb-4 px-3 py-2 bg-white rounded border text-gray-800 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200">
|
||
{{ message.toolInfo.name }}
|
||
</div>
|
||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">调用参数:</div>
|
||
<div class="px-3 py-2 bg-white rounded border text-gray-800 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200">
|
||
<pre class="whitespace-pre-wrap text-sm">{{ getFormattedToolParams(message.toolInfo.params) }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div
|
||
ref="contentContainer"
|
||
class="relative text-gray-800 dark:text-gray-200"
|
||
v-if="message.content"
|
||
:class="{ 'md:flex-1 md:max-w-[60%]': message.chartId && isWideScreen }"
|
||
>
|
||
<MdRenderer v-if="selectedKnowledge.id !== '-4'" :source="message.content" class="w-full" />
|
||
<MindMap v-else :text="message.content" :id="message.time || ''" />
|
||
<router-link
|
||
v-if="message.path"
|
||
:to="message.path"
|
||
class="inline-flex absolute top-1 right-1 items-center text-sm text-blue-600 transition-colors duration-200 shrink-0 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||
/>
|
||
</svg>
|
||
一键跳转
|
||
</router-link>
|
||
</div>
|
||
|
||
<!-- Chart display for chartId messages -->
|
||
<div
|
||
v-if="message.chartId"
|
||
class="overflow-hidden relative flex-1 mt-6 w-full bg-white rounded-lg border border-gray-200 shadow-sm dark:bg-gray-800 dark:border-gray-700 chart-container"
|
||
:class="{ 'md:mt-0 md:min-w-[500px] md:max-w-[40%]': message.content && isWideScreen }"
|
||
style="min-height: 300px; height: 50vh; max-height: 500px"
|
||
>
|
||
<!-- 图表标题栏 -->
|
||
<div class="flex justify-between items-center p-3 border-b border-gray-200 dark:border-gray-700">
|
||
<div class="flex items-center">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||
/>
|
||
</svg>
|
||
<span class="font-medium text-gray-700 dark:text-gray-300">数据可视化</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表内容 -->
|
||
<div class="p-3 h-[calc(100%-48px)]">
|
||
<v-chart v-if="chartOption" class="w-[500px] h-full" :option="chartOption" autoresize />
|
||
|
||
<!-- 图表加载状态 -->
|
||
<div v-else class="flex justify-center items-center h-full">
|
||
<div class="flex flex-col items-center">
|
||
<span class="mb-2 text-gray-600 dark:text-gray-300">图表加载中...</span>
|
||
<div class="flex space-x-2">
|
||
<span class="text-blue-500 loading loading-spinner loading-md"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flex gap-2 items-center ml-6 chat-footer"
|
||
v-if="
|
||
(message.extLinks && message.extLinks.length > 0) ||
|
||
(message === messageList[messageList.length - 1] && messageList.length > 1 && !isFinish)
|
||
"
|
||
>
|
||
<!-- Stop button -->
|
||
<el-button
|
||
v-if="message === messageList[messageList.length - 1] && messageList.length > 1 && !isFinish"
|
||
@click="handleStopGenerate()"
|
||
text
|
||
icon="VideoPause"
|
||
type="primary"
|
||
class="!bg-white dark:!bg-gray-700 !shadow-md !rounded-full !p-2"
|
||
></el-button>
|
||
|
||
<!-- Reference materials -->
|
||
<div v-if="message.extLinks && message.extLinks.length > 0" class="flex-1 px-3 rounded-md dark:bg-gray-700/40">
|
||
<div class="flex flex-wrap gap-2 items-center text-xs">
|
||
<span class="font-medium text-gray-600 dark:text-gray-300">参考资料</span>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<el-tag
|
||
v-for="(link, linkIndex) in message.extLinks"
|
||
:key="linkIndex"
|
||
size="small"
|
||
effect="plain"
|
||
class="!border-0 !bg-gray-100 dark:!bg-gray-600 !text-gray-700 dark:!text-gray-300"
|
||
>
|
||
{{ link.name }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- Rating -->
|
||
<div v-if="message.extLinks[0].distance" class="flex items-center ml-auto space-x-1">
|
||
<div class="tooltip tooltip-left" :data-tip="`相关度: ${Number(message.extLinks[0].distance).toFixed(2)}`">
|
||
<div class="rating rating-xs">
|
||
<input
|
||
v-for="n in 5"
|
||
:key="n"
|
||
type="radio"
|
||
:name="'rating' + index"
|
||
class="mask mask-star-2 bg-blue-400/80 dark:bg-blue-500/80"
|
||
:checked="Math.round(message.extLinks[0].distance * 5) === n"
|
||
disabled
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading indicator for LLM responses -->
|
||
<div class="flex" v-if="!message.content && !message.reasoning_content">
|
||
<div class="flex justify-center items-center mt-2 ml-12 text-center">
|
||
<span class="mx-0.5 loading loading-ball loading-xs dark:bg-blue-500"></span>
|
||
<span class="mx-0.5 loading loading-ball loading-sm dark:bg-blue-500"></span>
|
||
<span class="mx-0.5 loading loading-ball loading-md dark:bg-blue-500"></span>
|
||
<span class="mx-0.5 loading loading-ball loading-lg dark:bg-blue-500"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="(message.role as string) === 'user'">
|
||
<!-- User message -->
|
||
<div class="chat chat-end">
|
||
<div class="chat-image avatar">
|
||
<div class="w-[40px] h-[40px] rounded-full overflow-hidden">
|
||
<img
|
||
alt="User avatar"
|
||
:src="roleAlias[message.role]?.includes('http') ? roleAlias[message.role] : baseURL + roleAlias[message.role]"
|
||
class="object-cover w-full h-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex justify-end items-center mb-1 space-x-2 chat-header">
|
||
<time class="text-xs opacity-70">{{ message.time }}</time>
|
||
<span class="font-medium text-gray-800 dark:text-gray-200">{{ userInfos.user.username }}</span>
|
||
</div>
|
||
<div class="max-w-3xl bg-white rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||
<div class="p-4 text-gray-800 dark:text-gray-200">
|
||
<MdRenderer v-if="message.content" :source="message.content || ''" class="w-full" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { storeToRefs } from 'pinia';
|
||
import MdRenderer from '/@/components/MdRenderer/MdRenderer.vue';
|
||
import MindMap from '/@/components/MindMap/index.vue';
|
||
import AudioPlayer from './widgets/audio-player.vue';
|
||
import VChart from 'vue-echarts';
|
||
import { useIntervalFn, useMediaQuery } from '@vueuse/core';
|
||
import type { ChatMessage, Dataset } from '../ts/index';
|
||
import { useUserInfo } from '/@/stores/userInfo';
|
||
import { getChartData } from '../ts/gpt';
|
||
|
||
import { use } from 'echarts/core';
|
||
import { BarChart } from 'echarts/charts';
|
||
import { LineChart } from 'echarts/charts';
|
||
import { PieChart } from 'echarts/charts';
|
||
import { GridComponent } from 'echarts/components';
|
||
import { CanvasRenderer } from 'echarts/renderers';
|
||
|
||
use([GridComponent, BarChart, LineChart, PieChart, CanvasRenderer]);
|
||
|
||
const stores = useUserInfo();
|
||
const { userInfos } = storeToRefs(stores);
|
||
const baseURL = import.meta.env.VITE_API_URL || '';
|
||
|
||
const props = defineProps({
|
||
message: {
|
||
type: Object as () => ChatMessage,
|
||
required: true,
|
||
},
|
||
index: {
|
||
type: Number,
|
||
required: true,
|
||
},
|
||
selectedKnowledge: {
|
||
type: Object as () => Dataset,
|
||
required: true,
|
||
},
|
||
messageList: {
|
||
type: Array as () => ChatMessage[],
|
||
required: true,
|
||
},
|
||
isFinish: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
roleAlias: {
|
||
type: Object,
|
||
required: true,
|
||
},
|
||
collapseStates: {
|
||
type: Object,
|
||
required: true,
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['copyText', 'regenerate', 'stopGenerate']);
|
||
const audioPlayerRef = ref();
|
||
const chartOption = ref(null);
|
||
const isPolling = ref(false); // 是否正在轮询中
|
||
const isWideScreen = ref(useMediaQuery('(min-width: 1024px)')); // 判断是否为宽屏
|
||
const pollAttempts = ref(0); // 轮询尝试次数计数器
|
||
|
||
// Make content and chart display side by side on wide screens
|
||
// 使内容和图表在宽屏上并排显示
|
||
const contentContainer = ref<HTMLElement | null>(null);
|
||
|
||
// Function to fetch chart data
|
||
// 获取图表数据的函数
|
||
const fetchChartData = async (chartId: string) => {
|
||
try {
|
||
const { data } = await getChartData(chartId);
|
||
if (data) {
|
||
chartOption.value = JSON.parse(data);
|
||
|
||
// 如果成功获取到有效数据,停止轮询
|
||
if (chartOption.value) {
|
||
isPolling.value = false;
|
||
pause();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 静默处理错误
|
||
}
|
||
|
||
// 限制最多轮询10次
|
||
pollAttempts.value++;
|
||
if (pollAttempts.value >= 10) {
|
||
isPolling.value = false;
|
||
pause(); // 达到最大轮询次数后停止
|
||
}
|
||
};
|
||
|
||
// Setup interval to poll for chart data
|
||
// 设置轮询图表数据的定时器
|
||
const { pause, resume } = useIntervalFn(
|
||
() => {
|
||
if (props.message.chartId) {
|
||
fetchChartData(props.message.chartId);
|
||
}
|
||
},
|
||
2000, // Poll every 2 seconds 每2秒轮询一次
|
||
{ immediate: false, immediateCallback: false }
|
||
);
|
||
|
||
// Watch for chartId changes to start/stop polling
|
||
// 监听chartId变化以开始/停止轮询
|
||
watch(
|
||
() => props.message.chartId,
|
||
(newChartId) => {
|
||
if (newChartId && !isPolling.value) {
|
||
isPolling.value = true;
|
||
pollAttempts.value = 0; // 重置计数器
|
||
// Fetch immediately and then start polling
|
||
// 立即获取数据然后开始轮询
|
||
fetchChartData(newChartId);
|
||
resume();
|
||
} else if (!newChartId && isPolling.value) {
|
||
isPolling.value = false;
|
||
pause(); // 当没有chartId时停止轮询
|
||
}
|
||
},
|
||
{ immediate: true } // 立即执行
|
||
);
|
||
|
||
// Stop polling when component is unmounted
|
||
// 组件卸载时停止轮询
|
||
onUnmounted(() => {
|
||
pause();
|
||
});
|
||
|
||
const handleCopyText = (content: string) => {
|
||
emit('copyText', content);
|
||
};
|
||
|
||
const handleRegenerate = () => {
|
||
emit('regenerate');
|
||
};
|
||
|
||
const handleStopGenerate = () => {
|
||
// 停止生成的同时也停止轮询
|
||
if (isPolling.value) {
|
||
isPolling.value = false;
|
||
pause();
|
||
}
|
||
emit('stopGenerate');
|
||
};
|
||
|
||
// 格式化工具参数,使 JSON 更易读
|
||
const getFormattedToolParams = (params: string): string => {
|
||
try {
|
||
const parsed = JSON.parse(params);
|
||
return JSON.stringify(parsed, null, 2);
|
||
} catch (error) {
|
||
return params; // 如果不是有效的 JSON,则返回原始字符串
|
||
}
|
||
};
|
||
</script>
|