Merge pull request 'feat/1.0.1' (#3) from feat/1.0.1 into main

Reviewed-on: #3
This commit is contained in:
2025-07-01 00:01:48 +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 from app.constants.tts import APP_ID, TOKEN, SPEAKER
# 协议常量保持不变... # 协议常量
PROTOCOL_VERSION = 0b0001 PROTOCOL_VERSION = 0b0001
DEFAULT_HEADER_SIZE = 0b0001 DEFAULT_HEADER_SIZE = 0b0001
FULL_CLIENT_REQUEST = 0b0001 FULL_CLIENT_REQUEST = 0b0001
@@ -35,7 +35,7 @@ EVENT_TTSSentenceEnd = 351
EVENT_TTSResponse = 352 EVENT_TTSResponse = 352
# 所有类定义保持不变... # 所有类定义
class Header: class Header:
def __init__(self, def __init__(self,
protocol_version=PROTOCOL_VERSION, protocol_version=PROTOCOL_VERSION,
@@ -93,7 +93,7 @@ class Response:
self.payload_json = None self.payload_json = None
# 工具函数保持不变... # 工具函数
def gen_log_id(): def gen_log_id():
"""生成logID""" """生成logID"""
ts = int(time.time() * 1000) 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) await ws.send(full_client_request)
# 修改:TTS状态管理类添加消息ID和任务追踪 # TTS状态管理类添加消息ID和任务追踪
class TTSState: class TTSState:
def __init__(self, message_id: str): def __init__(self, message_id: str):
self.message_id = message_id 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: 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,12 +1,14 @@
# 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
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
import json import json
# 导入修改后的TTS模块
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()
@@ -59,10 +61,15 @@ 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处理支持消息ID # TTS处理
elif msg_type == "tts_text": elif msg_type == "tts_text":
message_id = data.get("messageId") message_id = data.get("messageId")
text = data.get("text", "") 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'] 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

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

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

@@ -45,7 +45,20 @@ const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
" "
to="/" 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> </router-link>
<div class="w-full h-full flex flex-col items-center text-[#0094c5]"> <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 BasicLayout from "@/layouts/BasicLayout.vue";
import { resetDescription, setTitle } from "@/utils"; 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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -13,11 +14,19 @@ const router = createRouter({
children: [ children: [
{ {
path: "", path: "",
name: "community", name: "ChatLLM",
component: community, component: ChatLLM,
meta: { meta: {
title: "对话" 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 connected = ref(false);
const chatStore = useChatStore(); const chatStore = useChatStore();
const ttsStore = useTtsStore(); const ttsStore = useTtsStore();
const router = useRouter();
const { onlineCount } = storeToRefs(chatStore); const { onlineCount } = storeToRefs(chatStore);
@@ -14,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消息
@@ -31,7 +30,14 @@ export const useWebSocketStore = defineStore("websocket", () => {
onlineCount.value = data.online_count; onlineCount.value = data.online_count;
break; break;
case "asr_result": 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; break;
// 新的TTS消息格式处理 // 新的TTS消息格式处理
@@ -76,7 +82,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
ttsStore.finishConversion(data.messageId); ttsStore.finishConversion(data.messageId);
} else { } else {
console.log("TTS音频传输完成无messageId"); console.log("TTS音频传输完成无messageId");
// 兜底处理,可能是旧格式
ttsStore.finishConversion(data.messageId); ttsStore.finishConversion(data.messageId);
} }
break; break;
@@ -85,7 +90,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
// TTS会话结束 // TTS会话结束
if (data.messageId) { if (data.messageId) {
console.log(`TTS会话结束 [${data.messageId}]`); console.log(`TTS会话结束 [${data.messageId}]`);
// 可以添加额外的清理逻辑
} else { } else {
console.log("TTS会话结束"); console.log("TTS会话结束");
} }
@@ -98,17 +102,15 @@ export const useWebSocketStore = defineStore("websocket", () => {
ttsStore.handleError(data.message, data.messageId); ttsStore.handleError(data.message, data.messageId);
} else { } else {
console.error("TTS错误:", data.message); console.error("TTS错误:", data.message);
// 兜底处理,可能是旧格式
ttsStore.handleError(data.message, data.messageId || "unknown"); ttsStore.handleError(data.message, data.messageId || "unknown");
} }
break; break;
// 保留旧的消息类型作为兜底处理 case "llm_complete_response":
case "tts_audio_complete_legacy": // LLM部分响应
case "tts_complete_legacy": if (router.currentRoute.value.path === "/voice") {
case "tts_error_legacy": chatStore.addMessageToHistory(data.content, "assistant", "voice");
console.log("收到旧格式TTS消息:", data.type); }
// 可以选择处理或忽略
break; break;
default: default:

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

@@ -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))
); );
// typechat
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>

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>