Compare commits

...

3 Commits

Author SHA1 Message Date
f85f5b4b6c Merge pull request 'feat/1.0.1' (#3) from feat/1.0.1 into main
Reviewed-on: #3
2025-07-01 00:01:48 +08:00
ac549bd939 feat: 完成大部分功能开发 2025-07-01 00:00:31 +08:00
ac5e68f5a5 feat: 部分语音聊天功能 2025-06-30 10:49:24 +08:00
19 changed files with 569 additions and 104 deletions

View File

@@ -9,7 +9,7 @@ from typing import Dict, Any, Optional as OptionalType
from app.constants.tts import APP_ID, TOKEN, SPEAKER
# 协议常量保持不变...
# 协议常量
PROTOCOL_VERSION = 0b0001
DEFAULT_HEADER_SIZE = 0b0001
FULL_CLIENT_REQUEST = 0b0001
@@ -35,7 +35,7 @@ EVENT_TTSSentenceEnd = 351
EVENT_TTSResponse = 352
# 所有类定义保持不变...
# 所有类定义
class Header:
def __init__(self,
protocol_version=PROTOCOL_VERSION,
@@ -93,7 +93,7 @@ class Response:
self.payload_json = None
# 工具函数保持不变...
# 工具函数
def gen_log_id():
"""生成logID"""
ts = int(time.time() * 1000)
@@ -191,7 +191,7 @@ async def send_event(ws, header, optional=None, payload=None):
await ws.send(full_client_request)
# 修改:TTS状态管理类添加消息ID和任务追踪
# TTS状态管理类添加消息ID和任务追踪
class TTSState:
def __init__(self, message_id: str):
self.message_id = message_id
@@ -374,8 +374,9 @@ async def process_tts_task(websocket, message_id: str, text: str):
elif res.optional.event == EVENT_TTSResponse:
audio_count += 1
print(f"发送音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}")
# 发送音频数据包含消息ID
# 发送音频数据
await websocket.send_json({
"id": audio_count,
"type": "tts_audio_data",
"messageId": message_id,
"audioData": res.payload.hex() # 转为hex字符串

View File

@@ -0,0 +1,105 @@
import json
import aiohttp
import asyncio
from fastapi.encoders import jsonable_encoder
from starlette.websockets import WebSocket
from . import tts
from app.constants.model_data import tip_message, base_url, headers
async def process_voice_conversation(websocket: WebSocket, asr_text: str, message_id: str):
try:
print(f"开始处理语音对话 [{message_id}]: {asr_text}")
# 1. 发送ASR识别结果到前端
await websocket.send_json({
"type": "asr_result",
"messageId": message_id,
"result": asr_text
})
# 2. 构建LLM请求
messages = [
tip_message,
{"role": "user", "content": asr_text}
]
payload = {
"model": "gpt-4o",
"messages": messages,
"stream": True
}
print(f"发送LLM请求 [{message_id}]: {json.dumps(payload, ensure_ascii=False)}")
# 3. 流式处理LLM响应
full_response = ""
llm_completed = False
async with aiohttp.ClientSession() as session:
async with session.post(
base_url,
headers=headers,
json=jsonable_encoder(payload)
) as resp:
if resp.status != 200:
error_text = await resp.text()
raise Exception(f"LLM API请求失败: {resp.status} - {error_text}")
# 读取流式响应
async for line in resp.content:
if line:
line = line.decode('utf-8').strip()
if line.startswith('data: '):
data = line[6:].strip()
if data == '[DONE]':
llm_completed = True
print(f"LLM响应完成 [{message_id}]")
break
try:
result = json.loads(data)
# 提取内容
choices = result.get("choices", [])
if not choices:
# 跳过空choices数据包
continue
delta = choices[0].get("delta", {})
content = delta.get("content")
if content:
full_response += content
except json.JSONDecodeError as e:
print(f"JSON解析错误 [{message_id}]: {e}, 数据: {data}")
continue
except Exception as e:
print(f"处理数据包异常 [{message_id}]: {e}, 数据: {data}")
continue
# 4. LLM生成完成后启动完整的TTS处理
if llm_completed and full_response:
print(f"LLM生成完成 [{message_id}], 总内容长度: {len(full_response)}")
print(f"完整内容: {full_response}")
# 发送完成消息
await websocket.send_json({
"type": "llm_complete_response",
"messageId": message_id,
"content": full_response
})
# 启动TTS处理完整内容
print(f"启动完整TTS处理 [{message_id}]: {full_response}")
await tts.handle_tts_text(websocket, message_id, full_response)
except Exception as e:
print(f"语音对话处理异常 [{message_id}]: {e}")
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "voice_conversation_error",
"messageId": message_id,
"message": f"处理失败: {str(e)}"
})

View File

@@ -1,12 +1,14 @@
# websocket_service.py
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Set
from aip import AipSpeech
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
import json
# 导入修改后的TTS模块
from . import tts
from .voice_conversation import process_voice_conversation
router = APIRouter()
active_connections: Set[WebSocket] = set()
@@ -59,10 +61,15 @@ async def websocket_online_count(websocket: WebSocket):
elif msg_type == "asr_end":
asr_text = await asr_buffer(temp_buffer)
await websocket.send_json({"type": "asr_result", "result": asr_text})
# 从data中获取messageId如果不存在则生成一个新的ID
message_id = data.get("messageId", "voice_" + str(uuid.uuid4()))
if data.get("voiceConversation"):
await process_voice_conversation(websocket, asr_text, message_id)
else:
await websocket.send_json({"type": "asr_result", "result": asr_text})
temp_buffer = bytes()
# 修改:TTS处理支持消息ID
# TTS处理
elif msg_type == "tts_text":
message_id = data.get("messageId")
text = data.get("text", "")

1
web/components.d.ts vendored
View File

@@ -21,6 +21,7 @@ declare module 'vue' {
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpin: typeof import('naive-ui')['NSpin']
NTag: typeof import('naive-ui')['NTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -32,6 +32,7 @@ export default antfu({
"ts/no-unsafe-function-type": "off",
"no-console": "off",
"unused-imports/no-unused-vars": "warn",
"ts/no-use-before-define": "off"
"ts/no-use-before-define": "off",
"vue/operator-linebreak": "off",
}
});

View File

@@ -1,4 +1,5 @@
export { default as ChevronLeftIcon } from "./svg/heroicons/ChevronLeftIcon.svg?component";
export { default as DocumentDuplicateIcon } from "./svg/heroicons/DocumentDuplicateIcon.svg?component";
export { default as ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component";
export { default as microphone } from "./svg/heroicons/MicrophoneIcon.svg?component";
export { default as PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.svg?component";

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>

After

Width:  |  Height:  |  Size: 668 B

View File

@@ -34,13 +34,15 @@ const handleClick = () => {
ttsStore.convertText(text, messageId);
}
};
// 当文本改变时清理之前的音频
// 文本改变清理之前的音频
watch(
() => text,
() => {
ttsStore.clearAudio(messageId);
}
);
onUnmounted(() => {
ttsStore.clearAudio(messageId);
});

View File

@@ -12,6 +12,7 @@ export interface Message {
role?: string;
usage?: UsageInfo;
id?: string;
type?: 'chat' | 'voice';
[property: string]: any;
}

View File

@@ -45,7 +45,20 @@ const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
"
to="/"
>
聊天
对话
</router-link>
<router-link
class="w-full h-[52px] px-8 flex items-center cursor-pointer"
:class="
$route.path === '/voice'
? [
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]'
]
: []
"
to="/voice"
>
语音聊天
</router-link>
<div class="w-full h-full flex flex-col items-center text-[#0094c5]">

View File

@@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { resetDescription, setTitle } from "@/utils";
import community from "@/views/CommunityView.vue";
import ChatLLM from "@/views/ChatLLMView.vue";
import VoiceView from "@/views/VoiceView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -13,11 +14,19 @@ const router = createRouter({
children: [
{
path: "",
name: "community",
component: community,
name: "ChatLLM",
component: ChatLLM,
meta: {
title: "对话"
}
},
{
path: "/voice",
name: "Voice",
component: VoiceView,
meta: {
title: "语音对话"
}
}
]
}

View File

@@ -6,6 +6,7 @@ export const useWebSocketStore = defineStore("websocket", () => {
const connected = ref(false);
const chatStore = useChatStore();
const ttsStore = useTtsStore();
const router = useRouter();
const { onlineCount } = storeToRefs(chatStore);
@@ -14,13 +15,11 @@ export const useWebSocketStore = defineStore("websocket", () => {
if (e.data instanceof ArrayBuffer) {
// 处理二进制音频数据(兜底处理,新版本应该不会用到)
console.log("收到二进制音频数据,大小:", e.data.byteLength);
console.warn("收到旧格式的二进制数据无法确定messageId");
// 可以选择忽略或者作为兜底处理
} else if (e.data instanceof Blob) {
// 如果是Blob转换为ArrayBuffer兜底处理
e.data.arrayBuffer().then((buffer: ArrayBuffer) => {
console.log("收到Blob音频数据大小:", buffer.byteLength);
console.warn("收到旧格式的Blob数据无法确定messageId");
});
} else if (typeof e.data === "string") {
// 处理文本JSON消息
@@ -31,7 +30,14 @@ export const useWebSocketStore = defineStore("websocket", () => {
onlineCount.value = data.online_count;
break;
case "asr_result":
chatStore.addMessageToHistory(data.result);
if (router.currentRoute.value.path === "/") {
chatStore.addMessageToHistory(data.result);
} else if (router.currentRoute.value.path === "/voice") {
// 在语音页面的处理
chatStore.addMessageToHistory(data.result, "user", "voice");
} else {
console.warn(data);
}
break;
// 新的TTS消息格式处理
@@ -76,7 +82,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
ttsStore.finishConversion(data.messageId);
} else {
console.log("TTS音频传输完成无messageId");
// 兜底处理,可能是旧格式
ttsStore.finishConversion(data.messageId);
}
break;
@@ -85,7 +90,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
// TTS会话结束
if (data.messageId) {
console.log(`TTS会话结束 [${data.messageId}]`);
// 可以添加额外的清理逻辑
} else {
console.log("TTS会话结束");
}
@@ -98,17 +102,15 @@ export const useWebSocketStore = defineStore("websocket", () => {
ttsStore.handleError(data.message, data.messageId);
} else {
console.error("TTS错误:", data.message);
// 兜底处理,可能是旧格式
ttsStore.handleError(data.message, data.messageId || "unknown");
}
break;
// 保留旧的消息类型作为兜底处理
case "tts_audio_complete_legacy":
case "tts_complete_legacy":
case "tts_error_legacy":
console.log("收到旧格式TTS消息:", data.type);
// 可以选择处理或忽略
case "llm_complete_response":
// LLM部分响应
if (router.currentRoute.value.path === "/voice") {
chatStore.addMessageToHistory(data.content, "assistant", "voice");
}
break;
default:

View File

@@ -11,8 +11,8 @@ export const useAsrStore = defineStore("asr", () => {
let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
let workletNode: AudioWorkletNode | null = null;
// 获取 WebSocket store 实例
const webSocketStore = useWebSocketStore();
const router = useRouter();
/**
* 发送消息到 WebSocket
@@ -81,16 +81,19 @@ export const useAsrStore = defineStore("asr", () => {
const processorUrl = URL.createObjectURL(blob);
// 加载AudioWorklet模块
await audioContext.audioWorklet.addModule(processorUrl);
// 释放URL对象防止内存泄漏
// 释放URL对象防止内存泄漏
URL.revokeObjectURL(processorUrl);
// 创建音频源节点
mediaStreamSource = audioContext.createMediaStreamSource(stream);
// 创建AudioWorkletNode
workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
channelCount: 1
});
// 监听来自AudioWorklet的音频数据
workletNode.port.onmessage = (event) => {
if (event.data.type === "audiodata") {
@@ -116,8 +119,14 @@ export const useAsrStore = defineStore("asr", () => {
const stopRecording = () => {
if (!isRecording.value) return;
const messageId = `voice_${Date.now()}`;
// 通知后端录音结束
sendMessage(JSON.stringify({ type: "asr_end" }));
const msg: Record<string, any> = { type: "asr_end" };
if (router.currentRoute.value.path === "/voice") {
msg.messageId = messageId;
msg.voiceConversation = true;
}
sendMessage(JSON.stringify(msg));
// 停止所有音轨
if (mediaStreamSource?.mediaStream) {

View File

@@ -7,7 +7,9 @@ import type {
import { ChatService } from "@/services";
export const useChatStore = defineStore("chat", () => {
const router = useRouter();
const token = "sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee";
// 默认模型
const modelInfo = ref<ModelInfo | null>(null);
// 历史消息
@@ -16,32 +18,35 @@ export const useChatStore = defineStore("chat", () => {
const completing = ref<boolean>(false);
// 是否正在思考
const thinking = ref<boolean>(false);
// 模型列表
const modelList = ref<ModelListInfo[]>([]);
// 在线人数
const onlineCount = ref<number>(0);
// 生成消息ID方法
const generateMessageId = () => new Date().getTime().toString();
// 获取最后一条消息
const getLastMessage = () =>
historyMessages.value[historyMessages.value.length - 1];
// 与 LLM 聊天
const chatWithLLM = async (
request: IChatWithLLMRequest,
onProgress: (content: string) => void, // 接收内容进度回调
getUsageInfo: (object: UsageInfo) => void = () => {}, // 接收使用信息回调
getThinking: (thinkingContent: string) => void = () => {} // 接收思维链内容回调
onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => {},
getThinking: (thinkingContent: string) => void = () => {}
) => {
if (completing.value) throw new Error("正在响应中");
completing.value = true; // 开始请求
completing.value = true;
try {
await ChatService.ChatWithLLM(
token,
request,
(content) => {
onProgress(content);
},
(object: UsageInfo) => {
getUsageInfo(object);
},
(thinkingContent: string) => {
getThinking(thinkingContent);
}
onProgress,
getUsageInfo,
getThinking
);
} catch (error) {
console.error("请求失败:", error);
@@ -51,28 +56,33 @@ export const useChatStore = defineStore("chat", () => {
};
// 添加消息到历史记录
const addMessageToHistory = (message: string) => {
const addMessageToHistory = (
message: string,
role: "user" | "assistant" = "user",
type: "chat" | "voice" = "chat"
) => {
const content = message.trim();
if (!content) return;
historyMessages.value.push({
role: "user",
content
role,
content,
type,
id: generateMessageId()
});
};
// 清除历史消息
const clearHistoryMessages = () => {
historyMessages.value = [];
const clearHistoryMessages = (type?: "chat" | "voice") => {
historyMessages.value = type
? historyMessages.value.filter((msg) => msg.type !== type)
: [];
};
// 确保最后一条消息是助手消息,如果最后一条消息不是,就加一条空的占位,不然后面的思维链会丢失
// 确保最后一条消息是助手消息
const ensureAssistantMessage = () => {
if (
historyMessages.value.length === 0 ||
historyMessages.value[historyMessages.value.length - 1].role !==
"assistant"
) {
const lastMessage = getLastMessage();
if (!lastMessage || lastMessage.role !== "assistant") {
historyMessages.value.push({
role: "assistant",
content: ""
@@ -80,57 +90,57 @@ export const useChatStore = defineStore("chat", () => {
}
};
// 处理聊天响应的逻辑
const handleChatResponse = async (
messages: IChatWithLLMRequest["messages"]
) => {
if (!modelInfo.value) return;
// 过滤出type为chat的聊天消息
const filteredMessages = computed(() =>
messages.filter((msg) => msg.type === "chat" || !msg.type)
);
await chatWithLLM(
{ messages: filteredMessages.value, model: modelInfo.value.model_id },
// 处理文本内容
(content) => {
ensureAssistantMessage();
thinking.value = false;
getLastMessage().content = content;
},
// 处理使用信息
(usageInfo: UsageInfo) => {
const lastMessage = getLastMessage();
if (lastMessage?.role === "assistant") {
lastMessage.usage = usageInfo;
}
},
// 处理思维链
(thinkingContent: string) => {
ensureAssistantMessage();
thinking.value = true;
getLastMessage().thinking = thinkingContent;
}
);
// 设置消息ID
getLastMessage().id = generateMessageId();
};
watch(
historyMessages,
(newVal) => {
// 当历史消息变化时,发送请求
if (newVal.length > 0) {
if (newVal.length > 0 && router.currentRoute.value.path === "/") {
const lastMessage = newVal[newVal.length - 1];
if (lastMessage.role === "user" && modelInfo.value) {
chatWithLLM(
{
messages: newVal,
model: modelInfo.value?.model_id
},
// 处理进度回调,文本
(content) => {
ensureAssistantMessage();
thinking.value = false;
historyMessages.value[historyMessages.value.length - 1].content =
content;
},
// 处理使用usage信息回调
(usageInfo: UsageInfo) => {
// 如果最后一条消息是助手的回复,则更新使用信息
if (
historyMessages.value.length > 0 &&
historyMessages.value[historyMessages.value.length - 1].role ===
"assistant"
) {
historyMessages.value[historyMessages.value.length - 1].usage =
usageInfo;
}
},
// 处理思维链内容回调
(thinkingContent: string) => {
ensureAssistantMessage();
thinking.value = true;
historyMessages.value[historyMessages.value.length - 1].thinking =
thinkingContent;
}
).then(() => {
historyMessages.value[historyMessages.value.length - 1].id =
new Date().getTime().toString();
});
if (lastMessage.role === "user") {
handleChatResponse(newVal);
}
}
},
{ deep: true }
);
// 模型列表
const modelList = ref<ModelListInfo[]>([]);
// 获取模型列表
const getModelList = async () => {
try {
@@ -144,14 +154,14 @@ export const useChatStore = defineStore("chat", () => {
return {
token,
completing,
chatWithLLM,
thinking,
modelInfo,
modelList,
historyMessages,
chatWithLLM,
addMessageToHistory,
clearHistoryMessages,
getModelList,
modelList,
modelInfo,
onlineCount,
thinking
onlineCount
};
});

View File

@@ -1,4 +1,4 @@
export * from "./asr_store";
export * from "./chat_store";
export * from "./layout_store";
export * from "./tts_store";
export * from "./tts_store";

View File

@@ -0,0 +1,23 @@
const leagacyCopy = (text: string) => {
const input = document.createElement("input");
input.value = text;
document.body.appendChild(input);
input.select();
try {
document.execCommand("copy");
} catch (err) {
console.error(err);
}
document.body.removeChild(input);
};
export const copy = (text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch((err) => {
console.error(err);
leagacyCopy(text); // 如果现代API失败使用旧方法
});
} else {
leagacyCopy(text); // 如果没有现代API使用旧方法
}
};

View File

@@ -1,4 +1,5 @@
export * from "./audio";
export * from "./clipboard";
export * from "./context";
export * from "./format";
export * from "./media";

View File

@@ -4,6 +4,7 @@ import type { Message } from "@/interfaces";
import { throttle } from "lodash-es";
import AIAvatar from "@/assets/ai_avatar.png";
import {
DocumentDuplicateIcon,
ExclamationTriangleIcon,
microphone,
PaperAirplaneIcon,
@@ -12,6 +13,7 @@ import {
import UserAvatar from "@/assets/user_avatar.jpg";
import markdown from "@/components/markdown.vue";
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
import { copy } from "@/utils";
const chatStore = useChatStore();
const { historyMessages, completing, modelList, modelInfo, thinking } =
@@ -29,6 +31,11 @@ const collapseActive = ref<string[]>(
historyMessages.value.map((msg, idx) => String(msg.id ?? idx))
);
// typechat
const filteredMessages = computed(() =>
historyMessages.value.filter((msg) => msg.type === "chat" || !msg.type)
);
const getName = (msg: Message, idx: number) => String(msg.id ?? idx);
// TODO: bugfix:
@@ -148,7 +155,7 @@ onMounted(() => {
</div>
<!-- 默认消息 历史消息 -->
<div
v-for="(msg, idx) in historyMessages"
v-for="(msg, idx) in filteredMessages"
:key="idx"
class="flex items-start mb-4"
>
@@ -199,8 +206,18 @@ onMounted(() => {
</NCollapse>
<!-- 内容 思维链 -->
<markdown :content="msg.content || ''" />
<div v-if="msg.role !== 'user'" class="mt-2">
<tts :text="msg.content || ''" :message-id="msg.id!" />
<div class="flex items-center gap-2 justify-end mt-2">
<div v-if="msg.role !== 'user'">
<tts :text="msg.content || ''" :message-id="msg.id!" />
</div>
<NPopover trigger="hover">
<template #trigger>
<NButton quaternary circle @click="copy(msg.content || '')">
<DocumentDuplicateIcon class="!w-4 !h-4" />
</NButton>
</template>
<span>复制内容</span>
</NPopover>
</div>
<NDivider />
</div>
@@ -241,7 +258,7 @@ onMounted(() => {
:positive-button-props="{ type: 'error' }"
positive-text="清除"
negative-text="取消"
@positive-click="chatStore.clearHistoryMessages"
@positive-click="chatStore.clearHistoryMessages('chat')"
@negative-click="() => {}"
>
<template #icon>

259
web/src/views/VoiceView.vue Normal file
View File

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