feat: 完成大部分功能开发

This commit is contained in:
2025-07-01 00:00:31 +08:00
parent ac5e68f5a5
commit ac549bd939
17 changed files with 278 additions and 382 deletions

View File

@@ -374,8 +374,9 @@ async def process_tts_task(websocket, message_id: str, text: str):
elif res.optional.event == EVENT_TTSResponse: elif res.optional.event == EVENT_TTSResponse:
audio_count += 1 audio_count += 1
print(f"发送音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}") print(f"发送音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}")
# 发送音频数据包含消息ID # 发送音频数据
await websocket.send_json({ await websocket.send_json({
"id": audio_count,
"type": "tts_audio_data", "type": "tts_audio_data",
"messageId": message_id, "messageId": message_id,
"audioData": res.payload.hex() # 转为hex字符串 "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,4 +1,6 @@
# websocket_service.py # websocket_service.py
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Set from typing import Set
from aip import AipSpeech from aip import AipSpeech
@@ -6,6 +8,7 @@ from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
import json import json
from . import tts from . import tts
from .voice_conversation import process_voice_conversation
router = APIRouter() router = APIRouter()
active_connections: Set[WebSocket] = set() active_connections: Set[WebSocket] = set()
@@ -58,7 +61,12 @@ async def websocket_online_count(websocket: WebSocket):
elif msg_type == "asr_end": elif msg_type == "asr_end":
asr_text = await asr_buffer(temp_buffer) 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() temp_buffer = bytes()
# TTS处理 # TTS处理

1
web/components.d.ts vendored
View File

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

View File

@@ -32,6 +32,7 @@ export default antfu({
"ts/no-unsafe-function-type": "off", "ts/no-unsafe-function-type": "off",
"no-console": "off", "no-console": "off",
"unused-imports/no-unused-vars": "warn", "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 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 ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component";
export { default as microphone } from "./svg/heroicons/MicrophoneIcon.svg?component"; export { default as microphone } from "./svg/heroicons/MicrophoneIcon.svg?component";
export { default as PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.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

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

View File

@@ -1,4 +1,4 @@
import { useChatStore, useTtsStore,useVoiceStore } from "@/stores"; import { useChatStore, useTtsStore } from "@/stores";
// WebSocket // WebSocket
export const useWebSocketStore = defineStore("websocket", () => { export const useWebSocketStore = defineStore("websocket", () => {
@@ -6,7 +6,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
const connected = ref(false); const connected = ref(false);
const chatStore = useChatStore(); const chatStore = useChatStore();
const ttsStore = useTtsStore(); const ttsStore = useTtsStore();
const voiceStore = useVoiceStore();
const router = useRouter(); const router = useRouter();
const { onlineCount } = storeToRefs(chatStore); const { onlineCount } = storeToRefs(chatStore);
@@ -16,13 +15,11 @@ export const useWebSocketStore = defineStore("websocket", () => {
if (e.data instanceof ArrayBuffer) { if (e.data instanceof ArrayBuffer) {
// 处理二进制音频数据(兜底处理,新版本应该不会用到) // 处理二进制音频数据(兜底处理,新版本应该不会用到)
console.log("收到二进制音频数据,大小:", e.data.byteLength); console.log("收到二进制音频数据,大小:", e.data.byteLength);
console.warn("收到旧格式的二进制数据无法确定messageId");
// 可以选择忽略或者作为兜底处理 // 可以选择忽略或者作为兜底处理
} else if (e.data instanceof Blob) { } else if (e.data instanceof Blob) {
// 如果是Blob转换为ArrayBuffer兜底处理 // 如果是Blob转换为ArrayBuffer兜底处理
e.data.arrayBuffer().then((buffer: ArrayBuffer) => { e.data.arrayBuffer().then((buffer: ArrayBuffer) => {
console.log("收到Blob音频数据大小:", buffer.byteLength); console.log("收到Blob音频数据大小:", buffer.byteLength);
console.warn("收到旧格式的Blob数据无法确定messageId");
}); });
} else if (typeof e.data === "string") { } else if (typeof e.data === "string") {
// 处理文本JSON消息 // 处理文本JSON消息
@@ -36,8 +33,8 @@ export const useWebSocketStore = defineStore("websocket", () => {
if (router.currentRoute.value.path === "/") { if (router.currentRoute.value.path === "/") {
chatStore.addMessageToHistory(data.result); chatStore.addMessageToHistory(data.result);
} else if (router.currentRoute.value.path === "/voice") { } else if (router.currentRoute.value.path === "/voice") {
// 在语音页面使用VoiceStore处理 // 在语音页面处理
voiceStore.handleASRResult(data.result); chatStore.addMessageToHistory(data.result, "user", "voice");
} else { } else {
console.warn(data); console.warn(data);
} }
@@ -109,6 +106,13 @@ export const useWebSocketStore = defineStore("websocket", () => {
} }
break; break;
case "llm_complete_response":
// LLM部分响应
if (router.currentRoute.value.path === "/voice") {
chatStore.addMessageToHistory(data.content, "assistant", "voice");
}
break;
default: default:
console.log("未知消息类型:", data.type, data); console.log("未知消息类型:", data.type, data);
} }

View File

@@ -11,8 +11,8 @@ export const useAsrStore = defineStore("asr", () => {
let mediaStreamSource: MediaStreamAudioSourceNode | null = null; let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
let workletNode: AudioWorkletNode | null = null; let workletNode: AudioWorkletNode | null = null;
// 获取 WebSocket store 实例
const webSocketStore = useWebSocketStore(); const webSocketStore = useWebSocketStore();
const router = useRouter();
/** /**
* 发送消息到 WebSocket * 发送消息到 WebSocket
@@ -81,16 +81,19 @@ export const useAsrStore = defineStore("asr", () => {
const processorUrl = URL.createObjectURL(blob); const processorUrl = URL.createObjectURL(blob);
// 加载AudioWorklet模块 // 加载AudioWorklet模块
await audioContext.audioWorklet.addModule(processorUrl); await audioContext.audioWorklet.addModule(processorUrl);
// 释放URL对象防止内存泄漏 // 释放URL对象防止内存泄漏
URL.revokeObjectURL(processorUrl); URL.revokeObjectURL(processorUrl);
// 创建音频源节点 // 创建音频源节点
mediaStreamSource = audioContext.createMediaStreamSource(stream); mediaStreamSource = audioContext.createMediaStreamSource(stream);
// 创建AudioWorkletNode // 创建AudioWorkletNode
workletNode = new AudioWorkletNode(audioContext, "audio-processor", { workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
numberOfInputs: 1, numberOfInputs: 1,
numberOfOutputs: 1, numberOfOutputs: 1,
channelCount: 1 channelCount: 1
}); });
// 监听来自AudioWorklet的音频数据 // 监听来自AudioWorklet的音频数据
workletNode.port.onmessage = (event) => { workletNode.port.onmessage = (event) => {
if (event.data.type === "audiodata") { if (event.data.type === "audiodata") {
@@ -116,8 +119,14 @@ export const useAsrStore = defineStore("asr", () => {
const stopRecording = () => { const stopRecording = () => {
if (!isRecording.value) return; 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) { if (mediaStreamSource?.mediaStream) {

View File

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

View File

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

View File

@@ -1,293 +0,0 @@
import { useWebSocketStore } from "@/services";
import { useChatStore, useTtsStore } from "@/stores";
export const useVoiceStore = defineStore("voice", () => {
// 状态管理
const isListening = ref(false); // 是否正在监听语音输入
const isProcessing = ref(false); // 是否正在处理包括ASR、LLM、TTS全流程
const currentSessionId = ref<string | null>(null); // 当前会话ID
// 依赖的其他store
const chatStore = useChatStore();
const ttsStore = useTtsStore();
const wsStore = useWebSocketStore();
// 语音消息历史
const voiceMessages = ref<
{
id: string;
type: "user" | "assistant";
text: string;
audioId?: string;
timestamp: number;
isProcessing?: boolean;
}[]
>([]);
// ASR缓冲区状态
const isRecording = ref(false);
const recordingStartTime = ref<number | null>(null);
const recordingMaxDuration = 60 * 1000; // 最大录音时长 60 秒
/**
* 开始语音输入
*/
const startListening = async () => {
if (isListening.value) return;
try {
await wsStore.connect();
// 创建新的会话ID
currentSessionId.value = new Date().getTime().toString();
isListening.value = true;
isRecording.value = true;
recordingStartTime.value = Date.now();
// 开始录音 - 假设我们有一个 startRecording 方法
// 这里通常会调用浏览器的 MediaRecorder API
await startRecording();
console.log("开始语音输入");
} catch (error) {
console.error("启动语音输入失败:", error);
stopListening();
}
};
/**
* 停止语音输入
*/
const stopListening = async () => {
if (!isListening.value) return;
try {
// 停止录音
if (isRecording.value) {
await stopRecording();
isRecording.value = false;
}
isListening.value = false;
recordingStartTime.value = null;
// 发送结束信号
wsStore.send(JSON.stringify({ type: "asr_end" }));
console.log("停止语音输入等待ASR结果");
} catch (error) {
console.error("停止语音输入失败:", error);
}
};
/**
* 录音时间检查
*/
const checkRecordingTime = () => {
if (isRecording.value && recordingStartTime.value) {
const currentTime = Date.now();
const duration = currentTime - recordingStartTime.value;
if (duration >= recordingMaxDuration) {
console.log("录音达到最大时长,自动停止");
stopListening();
}
}
};
// 定时检查录音时间
let recordingTimer: any = null;
watch(isRecording, (newVal) => {
if (newVal) {
recordingTimer = setInterval(checkRecordingTime, 1000);
} else if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
});
/**
* 处理ASR结果
*/
const handleASRResult = async (text: string) => {
if (!text.trim()) return;
console.log("收到ASR结果:", text);
isProcessing.value = true;
// 添加用户消息
const userMessageId = new Date().getTime().toString();
voiceMessages.value.push({
id: userMessageId,
type: "user",
text,
timestamp: Date.now()
});
// 添加助手消息占位
const assistantMessageId = new Date().getTime().toString();
voiceMessages.value.push({
id: assistantMessageId,
type: "assistant",
text: "",
timestamp: Date.now(),
isProcessing: true
});
// 调用LLM生成回复
await generateLLMResponse(text, assistantMessageId);
};
/**
* 生成LLM回复
*/
const generateLLMResponse = async (userInput: string, responseId: string) => {
try {
console.log("生成LLM回复...");
// 构建消息历史
const messages = [
...voiceMessages.value
.filter((msg) => !msg.isProcessing)
.map((msg) => ({
role: msg.type === "user" ? "user" : "assistant",
content: msg.text
})),
{ role: "user", content: userInput }
];
let responseText = "";
// 调用ChatStore的聊天方法
await chatStore.chatWithLLM(
{
messages,
model: chatStore.modelInfo?.model_id || ""
},
// 处理流式回复
(content) => {
responseText = content;
// 更新助手消息
const index = voiceMessages.value.findIndex(
(msg) => msg.id === responseId
);
if (index !== -1) {
voiceMessages.value[index].text = content;
}
}
);
// LLM生成完成转换为语音
console.log("LLM回复生成完成:", responseText);
await synthesizeSpeech(responseText, responseId);
} catch (error) {
console.error("生成LLM回复失败:", error);
const index = voiceMessages.value.findIndex(
(msg) => msg.id === responseId
);
if (index !== -1) {
voiceMessages.value[index].text = "抱歉,生成回复时出错";
voiceMessages.value[index].isProcessing = false;
}
isProcessing.value = false;
}
};
/**
* 转换文本为语音
*/
const synthesizeSpeech = async (text: string, messageId: string) => {
try {
console.log("转换文本为语音...");
// 调用TTS生成语音
await ttsStore.convertText(text, messageId);
// 注意TTS音频生成完成后会自动播放
// 这部分逻辑在TTS Store的finishConversion方法中处理
// 更新消息状态
const index = voiceMessages.value.findIndex(
(msg) => msg.id === messageId
);
if (index !== -1) {
voiceMessages.value[index].audioId = messageId;
voiceMessages.value[index].isProcessing = false;
}
} catch (error) {
console.error("转换文本为语音失败:", error);
const index = voiceMessages.value.findIndex(
(msg) => msg.id === messageId
);
if (index !== -1) {
voiceMessages.value[index].isProcessing = false;
}
} finally {
isProcessing.value = false;
}
};
/**
* 清除所有消息
*/
const clearMessages = () => {
voiceMessages.value = [];
};
/**
* 播放指定消息的语音
*/
const playMessageAudio = async (messageId: string) => {
const message = voiceMessages.value.find((msg) => msg.id === messageId);
if (message && message.audioId) {
await ttsStore.play(message.audioId);
}
};
/**
* 暂停当前播放的语音
*/
const pauseAudio = () => {
ttsStore.pauseAll();
};
// 录音相关方法 - 这里需要根据实际情况实现
// 通常会使用MediaRecorder API
const startRecording = async () => {
// 实现录音开始逻辑
// 1. 获取麦克风权限
// 2. 创建MediaRecorder
// 3. 监听数据可用事件发送到WebSocket
console.log("开始录音...");
};
const stopRecording = async () => {
// 实现录音停止逻辑
console.log("停止录音...");
};
// 在组件卸载时清理资源
onUnmounted(() => {
if (isRecording.value) {
stopRecording();
}
if (recordingTimer) {
clearInterval(recordingTimer);
}
});
return {
// 状态
isListening,
isProcessing,
isRecording,
voiceMessages,
// 方法
startListening,
stopListening,
handleASRResult,
clearMessages,
playMessageAudio,
pauseAudio
};
});

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 "./audio";
export * from "./clipboard";
export * from "./context"; export * from "./context";
export * from "./format"; export * from "./format";
export * from "./media"; export * from "./media";

View File

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

View File

@@ -23,6 +23,11 @@ const collapseActive = ref<string[]>(
historyMessages.value.map((msg, idx) => String(msg.id ?? idx)) 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); const getName = (msg: Message, idx: number) => String(msg.id ?? idx);
// TODO: bugfix: 未能正确展开 // TODO: bugfix: 未能正确展开
@@ -135,7 +140,7 @@ onMounted(() => {
</div> </div>
<!-- 默认消息 历史消息 --> <!-- 默认消息 历史消息 -->
<div <div
v-for="(msg, idx) in historyMessages" v-for="(msg, idx) in filteredMessages"
:key="idx" :key="idx"
class="flex items-start mb-4" class="flex items-start mb-4"
> >
@@ -217,7 +222,7 @@ onMounted(() => {
:positive-button-props="{ type: 'error' }" :positive-button-props="{ type: 'error' }"
positive-text="清除" positive-text="清除"
negative-text="取消" negative-text="取消"
@positive-click="chatStore.clearHistoryMessages" @positive-click="chatStore.clearHistoryMessages('voice')"
@negative-click="() => {}" @negative-click="() => {}"
> >
<template #icon> <template #icon>