260 lines
8.1 KiB
Vue
260 lines
8.1 KiB
Vue
<script setup lang="ts">
|
||
import type { SelectGroupOption, SelectOption } from "naive-ui";
|
||
import type { Message } from "@/interfaces";
|
||
import { throttle } from "lodash-es";
|
||
import AIAvatar from "@/assets/ai_avatar.png";
|
||
import { ExclamationTriangleIcon, microphone, TrashIcon } from "@/assets/Icons";
|
||
import UserAvatar from "@/assets/user_avatar.jpg";
|
||
import markdown from "@/components/markdown.vue";
|
||
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
|
||
|
||
const chatStore = useChatStore();
|
||
const { historyMessages, completing, modelList, modelInfo, thinking } =
|
||
storeToRefs(chatStore);
|
||
const asrStore = useAsrStore();
|
||
const { isRecording } = storeToRefs(asrStore);
|
||
const layoutStore = useLayoutStore();
|
||
const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
|
||
|
||
const scrollbarRef = ref<HTMLElement | null>(null);
|
||
const options = ref<Array<SelectGroupOption | SelectOption>>([]);
|
||
// NCollapse 组件的折叠状态
|
||
const collapseActive = ref<string[]>(
|
||
historyMessages.value.map((msg, idx) => String(msg.id ?? idx))
|
||
);
|
||
|
||
// 过滤出type为voice的聊天消息
|
||
const filteredMessages = computed(() =>
|
||
historyMessages.value.filter((msg) => msg.type === "voice")
|
||
);
|
||
|
||
const getName = (msg: Message, idx: number) => String(msg.id ?? idx);
|
||
|
||
// TODO: bugfix: 未能正确展开
|
||
watch(
|
||
historyMessages,
|
||
(newVal, oldVal) => {
|
||
// 取所有name
|
||
const newNames = newVal.map((msg, idx) => getName(msg, idx));
|
||
const oldNames = oldVal ? oldVal.map((msg, idx) => getName(msg, idx)) : [];
|
||
// 找出新增的name
|
||
const addedNames = newNames.filter((name) => !oldNames.includes(name));
|
||
// 保留原有已展开项
|
||
const currentActive = collapseActive.value.filter((name) =>
|
||
newNames.includes(name)
|
||
);
|
||
// 新增的默认展开
|
||
collapseActive.value = [...currentActive, ...addedNames];
|
||
},
|
||
{ immediate: true, deep: true }
|
||
);
|
||
|
||
// 处理折叠项的点击事件,切换折叠状态
|
||
const handleItemHeaderClick = (name: string) => {
|
||
if (collapseActive.value.includes(name)) {
|
||
collapseActive.value = collapseActive.value.filter((n) => n !== name);
|
||
} else {
|
||
collapseActive.value.push(name);
|
||
}
|
||
};
|
||
|
||
// 处理选中模型的 ID
|
||
const selectedModelId = computed({
|
||
get: () => modelInfo.value?.model_id ?? null,
|
||
set: (id: string | null) => {
|
||
for (const vendor of modelList.value) {
|
||
const found = vendor.models.find((model) => model.model_id === id);
|
||
if (found) {
|
||
modelInfo.value = found;
|
||
return;
|
||
}
|
||
}
|
||
modelInfo.value = null;
|
||
}
|
||
});
|
||
|
||
// 监听模型列表变化,更新选项
|
||
watch(
|
||
() => modelList.value,
|
||
(newVal) => {
|
||
if (newVal) {
|
||
options.value = newVal.map((vendor) => ({
|
||
type: "group",
|
||
label: vendor.vendor,
|
||
key: vendor.vendor,
|
||
children: vendor.models.map((model) => ({
|
||
label: model.model_name,
|
||
value: model.model_id,
|
||
type: model.model_type
|
||
}))
|
||
}));
|
||
|
||
if (newVal.length > 0 && newVal[0].models.length > 0) {
|
||
modelInfo.value = newVal[0].models[0];
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true, deep: true }
|
||
);
|
||
|
||
// 开关语音输入
|
||
const toggleRecording = throttle(() => {
|
||
if (isRecording.value) {
|
||
asrStore.stopRecording();
|
||
} else {
|
||
asrStore.startRecording();
|
||
}
|
||
}, 500);
|
||
|
||
watch(completing, (newVal) => {
|
||
if (newVal) {
|
||
nextTick(() => {
|
||
scrollbarRef.value?.scrollTo({ top: 99999, behavior: "smooth" });
|
||
});
|
||
}
|
||
});
|
||
|
||
onMounted(() => {
|
||
chatStore.getModelList();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="p-8 !pr-4 h-full w-full flex flex-col gap-4 border-l-[24px] border-l-[#FAFAFA] transition-all ease-in-out text-base"
|
||
:class="{ '!border-l-0': hiddenLeftSidebar || simpleMode }"
|
||
>
|
||
<!-- 历史消息区 -->
|
||
<NScrollbar ref="scrollbarRef" class="flex-1 pr-4 relative">
|
||
<div class="flex items-start mb-4">
|
||
<span class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16">
|
||
<avatar :avatar="AIAvatar" />
|
||
</span>
|
||
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
||
<span class="text-base font-bold mb-4">助手:</span>
|
||
<span class="text-base"
|
||
>你好,我是你的智能助手,请问有什么可以帮助你的吗?</span
|
||
>
|
||
<NDivider />
|
||
</div>
|
||
</div>
|
||
<!-- 默认消息↑ 历史消息↓ -->
|
||
<div
|
||
v-for="(msg, idx) in filteredMessages"
|
||
:key="idx"
|
||
class="flex items-start mb-4"
|
||
>
|
||
<!-- 头像↓ -->
|
||
<span
|
||
v-if="msg.role === 'user'"
|
||
class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16"
|
||
>
|
||
<avatar :avatar="UserAvatar" />
|
||
</span>
|
||
<span v-else class="rounded-lg overflow-hidden">
|
||
<avatar :avatar="AIAvatar" />
|
||
</span>
|
||
<!-- 头像↑ 名称↓ -->
|
||
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
||
<span class="text-base font-bold">{{
|
||
msg.role === "user" ? "你:" : "助手:"
|
||
}}</span>
|
||
<!-- 使用信息 -->
|
||
<div
|
||
v-if="msg.role !== 'user'"
|
||
class="text-[12px] text-[#7A7A7A] mb-[2px]"
|
||
>
|
||
Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span>
|
||
</div>
|
||
<div class="w-full max-w-full">
|
||
<NCollapse
|
||
v-if="msg.thinking?.trim()"
|
||
:expanded-names="collapseActive[idx]"
|
||
>
|
||
<NCollapseItem
|
||
:title="
|
||
thinking && idx === historyMessages.length - 1
|
||
? '思考中...'
|
||
: '已深度思考'
|
||
"
|
||
:name="getName(msg, idx)"
|
||
@item-header-click="
|
||
() => handleItemHeaderClick(getName(msg, idx))
|
||
"
|
||
>
|
||
<div
|
||
class="text-[#7A7A7A] mb-4 border-l-2 border-[#E5E5E5] ml-2 pl-2"
|
||
>
|
||
<markdown :content="msg.thinking || ''" />
|
||
</div>
|
||
</NCollapseItem>
|
||
</NCollapse>
|
||
<!-- 内容↓ 思维链↑ -->
|
||
<markdown :content="msg.content || ''" />
|
||
<div v-if="msg.role !== 'user'" class="mt-2">
|
||
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
||
</div>
|
||
<NDivider />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="isRecording"
|
||
class="absolute inset-0 pointer-events-none flex items-center justify-center text-[#7A7A7A] text-2xl bg-white/80"
|
||
>
|
||
正在语音输入...
|
||
</div>
|
||
</NScrollbar>
|
||
<!-- 操作区 -->
|
||
<div class="flex justify-between items-center gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<NSelect
|
||
v-model:value="selectedModelId"
|
||
label-field="label"
|
||
value-field="value"
|
||
children-field="children"
|
||
filterable
|
||
:options="options"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<NPopconfirm
|
||
:positive-button-props="{ type: 'error' }"
|
||
positive-text="清除"
|
||
negative-text="取消"
|
||
@positive-click="chatStore.clearHistoryMessages('voice')"
|
||
@negative-click="() => {}"
|
||
>
|
||
<template #icon>
|
||
<ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" />
|
||
</template>
|
||
<template #trigger>
|
||
<NButton :disabled="isRecording || completing" type="warning">
|
||
<template v-if="!simpleMode"> 清除历史 </template>
|
||
<TrashIcon
|
||
class="!w-4 !h-4"
|
||
:class="{
|
||
'ml-1': !simpleMode
|
||
}"
|
||
/>
|
||
</NButton>
|
||
</template>
|
||
<span>确定要清除历史消息吗?</span>
|
||
</NPopconfirm>
|
||
|
||
<NButton :disabled="completing" @click="toggleRecording">
|
||
<template v-if="!simpleMode">
|
||
{{ isRecording ? "停止输入" : "语音输入" }}
|
||
</template>
|
||
<microphone
|
||
class="!w-4 !h-4"
|
||
:class="{
|
||
'ml-1': !simpleMode
|
||
}"
|
||
/>
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|