This commit is contained in:
吴红兵
2025-12-02 10:37:49 +08:00
commit 1f645dad3e
1183 changed files with 147673 additions and 0 deletions

View File

@@ -0,0 +1,448 @@
<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>