Compare commits

...

13 Commits

60 changed files with 2851 additions and 483 deletions

2
.gitignore vendored
View File

@@ -114,3 +114,5 @@ node_modules/
*.njsproj
*.sln
*.sw?
*.mp3

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.constants.tts import SPEAKER_DATA
from app.schemas import SpeakerResponse
router = APIRouter()
@router.get("/list", response_model=SpeakerResponse)
async def get_model_vendors():
return SpeakerResponse(data=SPEAKER_DATA)

View File

@@ -0,0 +1,496 @@
import uuid
import websockets
import time
import fastrand
import json
import asyncio
import os
import aiofiles
from datetime import datetime
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
AUDIO_ONLY_RESPONSE = 0b1011
FULL_SERVER_RESPONSE = 0b1001
ERROR_INFORMATION = 0b1111
MsgTypeFlagWithEvent = 0b100
NO_SERIALIZATION = 0b0000
JSON = 0b0001
COMPRESSION_NO = 0b0000
# 事件类型
EVENT_NONE = 0
EVENT_Start_Connection = 1
EVENT_FinishConnection = 2
EVENT_ConnectionStarted = 50
EVENT_StartSession = 100
EVENT_FinishSession = 102
EVENT_SessionStarted = 150
EVENT_SessionFinished = 152
EVENT_TaskRequest = 200
EVENT_TTSSentenceEnd = 351
EVENT_TTSResponse = 352
# 音频文件保存目录
TEMP_AUDIO_DIR = "./temp_audio"
# 确保音频目录存在
async def ensure_audio_dir():
"""异步创建音频目录"""
if not os.path.exists(TEMP_AUDIO_DIR):
os.makedirs(TEMP_AUDIO_DIR, exist_ok=True)
# 生成时间戳文件名
def generate_audio_filename() -> str:
"""生成基于时间戳的音频文件名"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] # 精确到毫秒
return f"{timestamp}.mp3"
# ... 保留所有原有的类定义和工具函数 ...
class Header:
def __init__(self,
protocol_version=PROTOCOL_VERSION,
header_size=DEFAULT_HEADER_SIZE,
message_type=0,
message_type_specific_flags=0,
serial_method=NO_SERIALIZATION,
compression_type=COMPRESSION_NO,
reserved_data=0):
self.header_size = header_size
self.protocol_version = protocol_version
self.message_type = message_type
self.message_type_specific_flags = message_type_specific_flags
self.serial_method = serial_method
self.compression_type = compression_type
self.reserved_data = reserved_data
def as_bytes(self) -> bytes:
return bytes([
(self.protocol_version << 4) | self.header_size,
(self.message_type << 4) | self.message_type_specific_flags,
(self.serial_method << 4) | self.compression_type,
self.reserved_data
])
class Optional:
def __init__(self, event=EVENT_NONE, sessionId=None, sequence=None):
self.event = event
self.sessionId = sessionId
self.errorCode = 0
self.connectionId = None
self.response_meta_json = None
self.sequence = sequence
def as_bytes(self) -> bytes:
option_bytes = bytearray()
if self.event != EVENT_NONE:
option_bytes.extend(self.event.to_bytes(4, "big", signed=True))
if self.sessionId is not None:
session_id_bytes = str.encode(self.sessionId)
size = len(session_id_bytes).to_bytes(4, "big", signed=True)
option_bytes.extend(size)
option_bytes.extend(session_id_bytes)
if self.sequence is not None:
option_bytes.extend(self.sequence.to_bytes(4, "big", signed=True))
return option_bytes
class Response:
def __init__(self, header, optional):
self.optional = optional
self.header = header
self.payload = None
self.payload_json = None
# 工具函数
def gen_log_id():
"""生成logID"""
ts = int(time.time() * 1000)
r = fastrand.pcg32bounded(1 << 24) + (1 << 20)
local_ip = "00000000000000000000000000000000"
return f"02{ts}{local_ip}{r:08x}"
def get_payload_bytes(uid='1234', event=EVENT_NONE, text='', speaker='', audio_format='mp3',
audio_sample_rate=24000):
return str.encode(json.dumps({
"user": {"uid": uid},
"event": event,
"namespace": "BidirectionalTTS",
"req_params": {
"text": text,
"speaker": speaker,
"audio_params": {
"format": audio_format,
"sample_rate": audio_sample_rate,
"enable_timestamp": True,
}
}
}))
def read_res_content(res, offset):
content_size = int.from_bytes(res[offset: offset + 4], "big", signed=True)
offset += 4
content = str(res[offset: offset + content_size], encoding='utf8')
offset += content_size
return content, offset
def read_res_payload(res, offset):
payload_size = int.from_bytes(res[offset: offset + 4], "big", signed=True)
offset += 4
payload = res[offset: offset + payload_size]
offset += payload_size
return payload, offset
def parser_response(res) -> Response:
if isinstance(res, str):
raise RuntimeError(res)
response = Response(Header(), Optional())
# 解析结果
header = response.header
num = 0b00001111
header.protocol_version = res[0] >> 4 & num
header.header_size = res[0] & 0x0f
header.message_type = (res[1] >> 4) & num
header.message_type_specific_flags = res[1] & 0x0f
header.serial_method = res[2] >> num
header.message_compression = res[2] & 0x0f
header.reserved_data = res[3]
offset = 4
optional = response.optional
if header.message_type == FULL_SERVER_RESPONSE or AUDIO_ONLY_RESPONSE:
# read event
if header.message_type_specific_flags == MsgTypeFlagWithEvent:
optional.event = int.from_bytes(res[offset:offset + 4], "big", signed=True)
offset += 4
if optional.event == EVENT_NONE:
return response
# read connectionId
elif optional.event == EVENT_ConnectionStarted:
optional.connectionId, offset = read_res_content(res, offset)
elif optional.event == EVENT_SessionStarted or optional.event == EVENT_SessionFinished:
optional.sessionId, offset = read_res_content(res, offset)
optional.response_meta_json, offset = read_res_content(res, offset)
elif optional.event == EVENT_TTSResponse:
optional.sessionId, offset = read_res_content(res, offset)
response.payload, offset = read_res_payload(res, offset)
elif optional.event == EVENT_TTSSentenceEnd:
optional.sessionId, offset = read_res_content(res, offset)
response.payload_json, offset = read_res_content(res, offset)
elif header.message_type == ERROR_INFORMATION:
optional.errorCode = int.from_bytes(res[offset:offset + 4], "big", signed=True)
offset += 4
response.payload, offset = read_res_payload(res, offset)
return response
async def send_event(ws, header, optional=None, payload=None):
full_client_request = bytearray(header)
if optional is not None:
full_client_request.extend(optional)
if payload is not None:
payload_size = len(payload).to_bytes(4, 'big', signed=True)
full_client_request.extend(payload_size)
full_client_request.extend(payload)
await ws.send(full_client_request)
# TTS状态管理类添加消息ID和任务追踪
class TTSState:
def __init__(self, message_id: str):
self.message_id = message_id
self.volc_ws: OptionalType[websockets.WebSocketServerProtocol] = None
self.session_id: OptionalType[str] = None
self.task: OptionalType[asyncio.Task] = None # 用于追踪异步任务
self.is_processing = False
self.audio_data = bytearray() # 用于收集音频数据
self.audio_filename = None # 保存的文件名
# 全局状态管理
class TTSManager:
def __init__(self):
# WebSocket -> 消息ID -> TTS状态
self.connections: Dict[any, Dict[str, TTSState]] = {}
# 会话ID -> 消息ID 的映射,用于路由响应
self.session_to_message: Dict[str, str] = {}
# 消息ID -> WebSocket 的映射
self.message_to_websocket: Dict[str, any] = {}
def get_connection_states(self, websocket) -> Dict[str, TTSState]:
"""获取WebSocket连接的所有TTS状态"""
if websocket not in self.connections:
self.connections[websocket] = {}
return self.connections[websocket]
def add_tts_state(self, websocket, message_id: str) -> TTSState:
"""添加新的TTS状态"""
states = self.get_connection_states(websocket)
if message_id in states:
# 如果已存在,先清理旧的
self.cleanup_message_state(websocket, message_id)
tts_state = TTSState(message_id)
states[message_id] = tts_state
self.message_to_websocket[message_id] = websocket
return tts_state
def get_tts_state(self, websocket, message_id: str) -> OptionalType[TTSState]:
"""获取指定的TTS状态"""
states = self.get_connection_states(websocket)
return states.get(message_id)
def register_session(self, session_id: str, message_id: str):
"""注册会话ID和消息ID的映射"""
self.session_to_message[session_id] = message_id
def get_message_by_session(self, session_id: str) -> OptionalType[str]:
"""根据会话ID获取消息ID"""
return self.session_to_message.get(session_id)
def get_websocket_by_message(self, message_id: str):
"""根据消息ID获取WebSocket"""
return self.message_to_websocket.get(message_id)
def cleanup_message_state(self, websocket, message_id: str):
"""清理指定消息的状态"""
states = self.get_connection_states(websocket)
if message_id in states:
tts_state = states[message_id]
# 取消任务
if tts_state.task and not tts_state.task.done():
tts_state.task.cancel()
# 清理映射
if tts_state.session_id and tts_state.session_id in self.session_to_message:
del self.session_to_message[tts_state.session_id]
if message_id in self.message_to_websocket:
del self.message_to_websocket[message_id]
# 删除状态
del states[message_id]
def cleanup_connection(self, websocket):
"""清理整个连接的状态"""
if websocket in self.connections:
states = self.connections[websocket]
for message_id in list(states.keys()):
self.cleanup_message_state(websocket, message_id)
del self.connections[websocket]
# 全局TTS管理器实例
tts_manager = TTSManager()
# 初始化独立的TTS连接
async def create_tts_connection() -> websockets.WebSocketServerProtocol:
"""创建独立的TTS连接"""
log_id = gen_log_id()
ws_header = {
"X-Api-App-Key": APP_ID,
"X-Api-Access-Key": TOKEN,
"X-Api-Resource-Id": 'volc.service_type.10029',
"X-Api-Connect-Id": str(uuid.uuid4()),
"X-Tt-Logid": log_id,
}
url = 'wss://openspeech.bytedance.com/api/v3/tts/bidirection'
volc_ws = await websockets.connect(url, additional_headers=ws_header, max_size=1000000000)
# 启动连接
header = Header(message_type=FULL_CLIENT_REQUEST,
message_type_specific_flags=MsgTypeFlagWithEvent).as_bytes()
optional = Optional(event=EVENT_Start_Connection).as_bytes()
payload = str.encode("{}")
await send_event(volc_ws, header, optional, payload)
# 等待连接确认
raw_data = await volc_ws.recv()
res = parser_response(raw_data)
if res.optional.event != EVENT_ConnectionStarted:
raise Exception("TTS连接失败")
return volc_ws
# 异步保存音频文件
async def save_audio_file(audio_data: bytes, filename: str) -> str:
"""异步保存音频文件"""
await ensure_audio_dir()
file_path = os.path.join(TEMP_AUDIO_DIR, filename)
async with aiofiles.open(file_path, 'wb') as f:
await f.write(audio_data)
return file_path
# 处理单个TTS任务
async def process_tts_task(websocket, message_id: str, text: str, speaker: str = None):
"""处理单个TTS任务独立协程"""
tts_state = None
# 使用传入的speaker如果没有则使用默认的
selected_speaker = speaker if speaker else SPEAKER
try:
print(f"开始处理TTS任务 [{message_id}]: {text}, 使用说话人: {selected_speaker}")
# 获取TTS状态
tts_state = tts_manager.get_tts_state(websocket, message_id)
if not tts_state:
raise Exception(f"找不到TTS状态: {message_id}")
tts_state.is_processing = True
# 生成音频文件名
tts_state.audio_filename = generate_audio_filename()
# 创建独立的TTS连接
tts_state.volc_ws = await create_tts_connection()
# 创建会话
tts_state.session_id = uuid.uuid4().__str__().replace('-', '')
tts_manager.register_session(tts_state.session_id, message_id)
print(f"创建TTS会话 [{message_id}]: {tts_state.session_id}")
header = Header(message_type=FULL_CLIENT_REQUEST,
message_type_specific_flags=MsgTypeFlagWithEvent,
serial_method=JSON).as_bytes()
optional = Optional(event=EVENT_StartSession, sessionId=tts_state.session_id).as_bytes()
# 使用选择的speaker
payload = get_payload_bytes(event=EVENT_StartSession, speaker=selected_speaker)
await send_event(tts_state.volc_ws, header, optional, payload)
raw_data = await tts_state.volc_ws.recv()
res = parser_response(raw_data)
if res.optional.event != EVENT_SessionStarted:
raise Exception("TTS会话启动失败")
print(f"TTS会话创建成功 [{message_id}]: {tts_state.session_id}")
# 发送文本到TTS服务
print(f"发送文本到TTS服务 [{message_id}]...")
header = Header(message_type=FULL_CLIENT_REQUEST,
message_type_specific_flags=MsgTypeFlagWithEvent,
serial_method=JSON).as_bytes()
optional = Optional(event=EVENT_TaskRequest, sessionId=tts_state.session_id).as_bytes()
# 使用选择的speaker
payload = get_payload_bytes(event=EVENT_TaskRequest, text=text, speaker=selected_speaker)
await send_event(tts_state.volc_ws, header, optional, payload)
# 接收TTS响应并发送到前端
print(f"开始接收TTS响应 [{message_id}]...")
audio_count = 0
try:
while True:
raw_data = await asyncio.wait_for(
tts_state.volc_ws.recv(),
timeout=30
)
res = parser_response(raw_data)
print(f"收到TTS事件 [{message_id}]: {res.optional.event}")
if res.optional.event == EVENT_TTSSentenceEnd:
print(f"句子结束事件 [{message_id}] - 直接完成")
break
elif res.optional.event == EVENT_SessionFinished:
print(f"收到会话结束事件 [{message_id}]")
break
elif res.optional.event == EVENT_TTSResponse:
audio_count += 1
print(f"收到音频数据 [{message_id}] #{audio_count},大小: {len(res.payload)}")
# 收集音频数据
tts_state.audio_data.extend(res.payload)
# 发送音频数据到前端
await websocket.send_json({
"id": audio_count,
"type": "tts_audio_data",
"messageId": message_id,
"audioData": res.payload.hex() # 转为hex字符串
})
else:
print(f"未知TTS事件 [{message_id}]: {res.optional.event}")
except asyncio.TimeoutError:
print(f"TTS响应超时 [{message_id}],强制结束")
# 异步保存音频文件
if tts_state.audio_data:
file_path = await save_audio_file(
bytes(tts_state.audio_data),
tts_state.audio_filename
)
print(f"音频文件已保存 [{message_id}]: {file_path}")
# 发送完成消息包含文件路径和使用的speaker
await websocket.send_json({
"type": "tts_audio_complete",
"messageId": message_id,
"audioFile": tts_state.audio_filename,
"audioPath": os.path.join(TEMP_AUDIO_DIR, tts_state.audio_filename) if tts_state.audio_data else None,
"speaker": selected_speaker
})
print(f"TTS处理完成 [{message_id}],共发送 {audio_count} 个音频包,使用说话人: {selected_speaker}")
except asyncio.CancelledError:
print(f"TTS任务被取消 [{message_id}]")
await websocket.send_json({
"type": "tts_error",
"messageId": message_id,
"message": "TTS任务被取消"
})
except Exception as e:
print(f"TTS处理异常 [{message_id}]: {e}")
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "tts_error",
"messageId": message_id,
"message": f"TTS处理失败: {str(e)}"
})
finally:
# 清理资源
if tts_state:
tts_state.is_processing = False
if tts_state.volc_ws:
try:
await tts_state.volc_ws.close()
except:
pass
# 清理状态
tts_manager.cleanup_message_state(websocket, message_id)
# 启动TTS文本转换
async def handle_tts_text(websocket, message_id: str, text: str, speaker: str = None):
"""启动TTS文本转换"""
# 创建新的TTS状态
print(speaker)
tts_state = tts_manager.add_tts_state(websocket, message_id)
# 启动异步任务传入speaker参数
tts_state.task = asyncio.create_task(
process_tts_task(websocket, message_id, text, speaker)
)
# 取消TTS任务
async def handle_tts_cancel(websocket, message_id: str):
"""取消TTS任务"""
tts_state = tts_manager.get_tts_state(websocket, message_id)
if tts_state and tts_state.task and not tts_state.task.done():
tts_state.task.cancel()
await websocket.send_json({
"type": "tts_complete",
"messageId": message_id
})
tts_manager.cleanup_message_state(websocket, message_id)
# 清理连接的所有TTS资源
async def cleanup_connection_tts(websocket):
"""清理连接的所有TTS资源"""
print(f"清理连接的TTS资源...")
tts_manager.cleanup_connection(websocket)
print("TTS资源清理完成")

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, speaker: 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, speaker)
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,14 +1,21 @@
# 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
from . import tts
from .voice_conversation import process_voice_conversation
router = APIRouter()
active_connections: Set[WebSocket] = set()
asr_client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
async def asr_buffer(buffer_data: bytes) -> str:
result = asr_client.asr(buffer_data, 'pcm', 16000, {'dev_pid': 1537})
if result.get('err_msg') == 'success.':
@@ -16,6 +23,7 @@ async def asr_buffer(buffer_data: bytes) -> str:
else:
return '语音转换失败'
async def broadcast_online_count():
data = {"online_count": len(active_connections), 'type': 'count'}
to_remove = set()
@@ -27,12 +35,14 @@ async def broadcast_online_count():
for ws in to_remove:
active_connections.remove(ws)
@router.websocket("/websocket")
async def websocket_online_count(websocket: WebSocket):
await websocket.accept()
active_connections.add(websocket)
await broadcast_online_count()
temp_buffer = bytes()
try:
while True:
message = await websocket.receive()
@@ -45,15 +55,66 @@ async def websocket_online_count(websocket: WebSocket):
except Exception:
continue
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"online_count": len(active_connections), "type": "count"})
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"):
speaker = data.get("speaker")
await process_voice_conversation(websocket, asr_text, message_id, speaker)
else:
await websocket.send_json({"type": "asr_result", "result": asr_text})
temp_buffer = bytes()
# TTS处理
elif msg_type == "tts_text":
message_id = data.get("messageId")
text = data.get("text", "")
speaker = data.get("speaker")
if not message_id:
await websocket.send_json({
"type": "tts_error",
"message": "缺少messageId参数"
})
continue
print(f"收到TTS文本请求 [{message_id}]: {text}")
try:
await tts.handle_tts_text(websocket, message_id, text, speaker)
except Exception as e:
print(f"TTS文本处理异常 [{message_id}]: {e}")
await websocket.send_json({
"type": "tts_error",
"messageId": message_id,
"message": f"TTS处理失败: {str(e)}"
})
elif msg_type == "tts_cancel":
message_id = data.get("messageId")
if message_id:
print(f"收到TTS取消请求 [{message_id}]")
try:
await tts.handle_tts_cancel(websocket, message_id)
except Exception as e:
print(f"TTS取消处理异常 [{message_id}]: {e}")
except WebSocketDisconnect:
active_connections.remove(websocket)
await broadcast_online_count()
except Exception:
active_connections.remove(websocket)
pass
except Exception as e:
print(f"WebSocket异常: {e}")
finally:
# 清理资源
active_connections.discard(websocket)
# 清理所有TTS资源
try:
await tts.cleanup_connection_tts(websocket)
except:
pass
await broadcast_online_count()

View File

@@ -19,7 +19,8 @@ MODEL_DATA = [
{
"vendor": "Anthropic",
"models": [
{"model_id": "claude-sonnet-4-thinking", "model_name": "Claude Sonnet 4 thinking", "model_type": "reasoning"},
{"model_id": "claude-sonnet-4-thinking", "model_name": "Claude Sonnet 4 thinking",
"model_type": "reasoning"},
{"model_id": "claude-sonnet-4", "model_name": "Claude Sonnet 4", "model_type": "text"},
]
},
@@ -27,6 +28,7 @@ MODEL_DATA = [
"vendor": "硅基流动",
"models": [
{"model_id": "deepseek-v3", "model_name": "DeepSeek V3", "model_type": "text"},
{"model_id": "deepseek-r1", "model_name": "DeepSeek R1", "model_type": "reasoning"},
]
}
]

View File

@@ -0,0 +1,104 @@
# APP_ID = '1142362958'
# TOKEN = 'O-a4JkyLFrYkME9no11DxFkOY-UnAoFF'
# SPEAKER = 'zh_male_beijingxiaoye_moon_bigtts'
APP_ID = '2138450044'
TOKEN = 'V04_QumeQZhJrQ_In1Z0VBQm7n0ttMNO'
SPEAKER = 'zh_male_beijingxiaoye_moon_bigtts'
SPEAKER_DATA = [
{
"category": "趣味口音",
"speakers": [
{
"speaker_id": "zh_male_jingqiangkanye_moon_bigtts",
"speaker_name": "京腔侃爷/Harmony",
"language": "中文-北京口音、英文",
"platforms": ["豆包", "Cici", "web demo"]
},
{
"speaker_id": "zh_female_wanwanxiaohe_moon_bigtts",
"speaker_name": "湾湾小何",
"language": "中文-台湾口音",
"platforms": ["豆包", "Cici"]
},
{
"speaker_id": "zh_female_wanqudashu_moon_bigtts",
"speaker_name": "湾区大叔",
"language": "中文-广东口音",
"platforms": ["豆包", "Cici"]
},
{
"speaker_id": "zh_female_daimengchuanmei_moon_bigtts",
"speaker_name": "呆萌川妹",
"language": "中文-四川口音",
"platforms": ["豆包", "Cici"]
},
{
"speaker_id": "zh_male_guozhoudege_moon_bigtts",
"speaker_name": "广州德哥",
"language": "中文-广东口音",
"platforms": ["豆包", "Cici"]
},
{
"speaker_id": "zh_male_beijingxiaoye_moon_bigtts",
"speaker_name": "北京小爷",
"language": "中文-北京口音",
"platforms": ["豆包"]
},
{
"speaker_id": "zh_male_haoyuxiaoge_moon_bigtts",
"speaker_name": "浩宇小哥",
"language": "中文-青岛口音",
"platforms": ["豆包"]
},
{
"speaker_id": "zh_male_guangxiyuanzhou_moon_bigtts",
"speaker_name": "广西远舟",
"language": "中文-广西口音",
"platforms": ["豆包"]
},
{
"speaker_id": "zh_female_meituojieer_moon_bigtts",
"speaker_name": "妹坨洁儿",
"language": "中文-长沙口音",
"platforms": ["豆包", "剪映"]
},
{
"speaker_id": "zh_male_yuzhouzixuan_moon_bigtts",
"speaker_name": "豫州子轩",
"language": "中文-河南口音",
"platforms": ["豆包"]
}
]
},
{
"category": "角色扮演",
"speakers": [
{
"speaker_id": "zh_male_naiqimengwa_mars_bigtts",
"speaker_name": "奶气萌娃",
"language": "中文",
"platforms": ["剪映", "豆包"]
},
{
"speaker_id": "zh_female_popo_mars_bigtts",
"speaker_name": "婆婆",
"language": "中文",
"platforms": ["剪映C端", "抖音", "豆包"]
},
{
"speaker_id": "zh_female_gaolengyujie_moon_bigtts",
"speaker_name": "高冷御姐",
"language": "中文",
"platforms": ["豆包", "Cici"]
},
{
"speaker_id": "zh_male_aojiaobazong_moon_bigtts",
"speaker_name": "傲娇霸总",
"language": "中文",
"platforms": ["豆包"]
}
]
}
]

View File

@@ -1,5 +1,5 @@
from fastapi import FastAPI
from app.api.v1.endpoints import chat, model, websocket_service
from app.api.v1.endpoints import chat, model, websocket_service,speaker
app = FastAPI()
@@ -9,6 +9,7 @@ app.include_router(websocket_service.router, prefix="", tags=["websocket_service
app.include_router(chat.router, prefix="/v1/chat", tags=["chat"])
# 获取模型列表服务
app.include_router(model.router, prefix="/v1/model", tags=["model_list"])
app.include_router(speaker.router, prefix="/v1/speaker", tags=["speaker_list"])
if __name__ == "__main__":
import uvicorn

View File

@@ -5,4 +5,5 @@ from .chat import (
ModelInfo,
VendorModelList,
VendorModelResponse,
SpeakerResponse
)

View File

@@ -33,3 +33,20 @@ class VendorModelList(BaseModel):
class VendorModelResponse(BaseModel):
data: List[VendorModelList]
# Speaker相关模型
class Speaker(BaseModel):
speaker_id: str
speaker_name: str
language: str
platforms: List[str]
class CategorySpeakers(BaseModel):
category: str
speakers: List[Speaker]
class SpeakerResponse(BaseModel):
data: List[CategorySpeakers]

BIN
backend/requirement.txt Normal file

Binary file not shown.

3
web/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# .prettierignore
auto-imports.d.ts
components.d.ts

8
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "none",
"ignorePath": ".prettierignore"
}

10
web/components.d.ts vendored
View File

@@ -7,16 +7,26 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Avatar: typeof import('./src/components/avatar.vue')['default']
Markdown: typeof import('./src/components/markdown.vue')['default']
Message_tools: typeof import('./src/components/MessageTools.vue')['default']
MessageTools: typeof import('./src/components/MessageTools.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
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']
Tts: typeof import('./src/components/tts.vue')['default']
}
}

View File

@@ -1,39 +1,38 @@
import antfu from "@antfu/eslint-config"
import antfu from "@antfu/eslint-config";
export default antfu(
{
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
/**
* Format Markdown files
* Supports Prettier and dprint
* By default uses Prettier
*/
markdown: "prettier",
},
stylistic: {
indent: 2,
quotes: "double",
semi: true,
},
vue: true,
ignores: ["node_modules", "dist"],
rules: {
"vue/html-self-closing": "off",
"antfu/top-level-function": "off",
"ts/no-unsafe-function-type": "off",
"no-console": "off",
"unused-imports/no-unused-vars": "warn",
},
export default antfu({
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
/**
* Format Markdown files
* Supports Prettier and dprint
* By default uses Prettier
*/
markdown: "prettier"
},
)
stylistic: {
indent: 2,
quotes: "double",
semi: true
},
vue: true,
ignores: ["node_modules", "dist"],
rules: {
"vue/html-self-closing": "off",
"antfu/top-level-function": "off",
"ts/no-unsafe-function-type": "off",
"no-console": "off",
"unused-imports/no-unused-vars": "warn",
"ts/no-use-before-define": "off",
"vue/operator-linebreak": "off",
}
});

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>chat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>chat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -15,6 +15,7 @@
"@vueuse/core": "^13.4.0",
"axios": "^1.10.0",
"highlight.js": "^11.11.1",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
@@ -27,13 +28,17 @@
},
"devDependencies": {
"@antfu/eslint-config": "^4.16.1",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.0.4",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-prettier": "^5.5.1",
"naive-ui": "^2.42.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"unplugin-vue-components": "^0.28.0",
"vite": "^7.0.0",

56
web/pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
lodash-es:
specifier: ^4.17.21
version: 4.17.21
markdown-it:
specifier: ^14.1.0
version: 14.1.0
@@ -51,6 +54,9 @@ importers:
'@antfu/eslint-config':
specifier: ^4.16.1
version: 4.16.1(@vue/compiler-sfc@3.5.17)(eslint-plugin-format@1.0.1(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
@@ -66,12 +72,21 @@ importers:
eslint:
specifier: ^9.29.0
version: 9.29.0(jiti@2.4.2)
eslint-config-prettier:
specifier: ^10.1.5
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-format:
specifier: ^1.0.1
version: 1.0.1(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-prettier:
specifier: ^5.5.1
version: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.6.2)
naive-ui:
specifier: ^2.42.0
version: 2.42.0(vue@3.5.17(typescript@5.8.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
typescript:
specifier: ~5.8.3
version: 5.8.3
@@ -1447,6 +1462,12 @@ packages:
peerDependencies:
eslint: ^9.5.0
eslint-config-prettier@10.1.5:
resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-flat-config-utils@2.1.0:
resolution: {integrity: sha512-6fjOJ9tS0k28ketkUcQ+kKptB4dBZY2VijMZ9rGn8Cwnn1SH0cZBoPXT8AHBFHxmHcLFQK9zbELDinZ2Mr1rng==}
@@ -1538,6 +1559,20 @@ packages:
peerDependencies:
eslint: ^9.0.0
eslint-plugin-prettier@5.5.1:
resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-regexp@2.9.0:
resolution: {integrity: sha512-9WqJMnOq8VlE/cK+YAo9C9YHhkOtcEtEk9d12a+H7OSZFwlpI6stiHmYPGa2VE0QhTzodJyhlyprUaXDZLgHBw==}
engines: {node: ^18 || >=20}
@@ -2427,8 +2462,8 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.6.1:
resolution: {integrity: sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
@@ -4276,6 +4311,10 @@ snapshots:
'@eslint/compat': 1.3.1(eslint@9.29.0(jiti@2.4.2))
eslint: 9.29.0(jiti@2.4.2)
eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)):
dependencies:
eslint: 9.29.0(jiti@2.4.2)
eslint-flat-config-utils@2.1.0:
dependencies:
pathe: 2.0.3
@@ -4321,7 +4360,7 @@ snapshots:
eslint: 9.29.0(jiti@2.4.2)
eslint-formatting-reporter: 0.0.0(eslint@9.29.0(jiti@2.4.2))
eslint-parser-plain: 0.1.1
prettier: 3.6.1
prettier: 3.6.2
synckit: 0.9.3
eslint-plugin-import-lite@0.3.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3):
@@ -4401,6 +4440,15 @@ snapshots:
tinyglobby: 0.2.14
yaml-eslint-parser: 1.3.0
eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.6.2):
dependencies:
eslint: 9.29.0(jiti@2.4.2)
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.8
optionalDependencies:
eslint-config-prettier: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-regexp@2.9.0(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
@@ -5459,7 +5507,7 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier@3.6.1: {}
prettier@3.6.2: {}
pretty-ms@9.2.0:
dependencies:

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { GlobalThemeOverrides } from "naive-ui"
import { zhCN } from "naive-ui"
import { useWebSocketStore } from "@/services"
import type { GlobalThemeOverrides } from "naive-ui";
import { zhCN } from "naive-ui";
import { useWebSocketStore } from "@/services";
const websocketStore = useWebSocketStore()
const websocketStore = useWebSocketStore();
onMounted(() => {
websocketStore.connect()
})
websocketStore.connect();
});
const themeOverrides: GlobalThemeOverrides = {
common: {
@@ -17,12 +17,12 @@ const themeOverrides: GlobalThemeOverrides = {
primaryColorSuppl: "#00bfff",
fontWeightStrong: "600",
borderRadius: "8px",
borderRadiusSmall: "5px",
borderRadiusSmall: "5px"
},
Button: {
textColor: "#0094c5",
},
}
textColor: "#0094c5"
}
};
</script>
<template>

View File

@@ -1,4 +1,7 @@
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"
export { default as TrashIcon } from "./svg/heroicons/TrashIcon.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 microphone } from "./svg/heroicons/MicrophoneIcon.svg?component";
export { default as PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.svg?component";
export { default as SpeakerWaveIcon } from "./svg/heroicons/SpeakerWaveIcon.svg?component";
export { default as TrashIcon } from "./svg/heroicons/TrashIcon.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 19.5 8.25 12l7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 226 B

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

@@ -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="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Message } from "@/interfaces";
import { DocumentDuplicateIcon } from "@/assets/Icons";
import { copy } from "@/utils";
const { msg } = defineProps<{
msg: Message;
}>();
</script>
<template>
<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>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
const { avatar } = defineProps<{
avatar: string;
}>();
</script>
<template>
<NImage
:src="avatar"
width="64"
height="64"
:preview-disabled="true"
object-fit="cover"
class="!block !w-16 !min-w-16 !h-16"
:img-props="{
class: 'rounded-lg !block !w-16 !min-w-16 !h-16'
}"
/>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import hljs from "highlight.js"
import markdownit from "markdown-it"
import hljs from "highlight.js";
import markdownit from "markdown-it";
import { useWindowWidth } from "@/utils";
const { content } = defineProps<{
content: string
}>()
content: string;
}>();
const windowWidth = useWindowWidth();
const md = markdownit({
html: true,
@@ -13,24 +16,34 @@ const md = markdownit({
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
return hljs.highlight(str, { language: lang }).value;
// eslint-disable-next-line unused-imports/no-unused-vars
} catch (__) {
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (__) { }
}
return "" // use external default escaping
},
})
return ""; // use external default escaping
}
});
// // 计算代码块宽度
const codeWidth = computed(() => {
return windowWidth.value - 160 - 200; // 减去左右边距和其他元素的宽度
});
</script>
<template>
<div class="markdown-body w-full text-base break-words whitespace-normal" v-html="md.render(content)">
</div>
<div
class="markdown-body w-full text-base break-words whitespace-normal"
:style="{ '--code-width': `${codeWidth}px` }"
v-html="md.render(content)"
></div>
</template>
<style scoped>
.markdown-body :deep(pre) {
width: var(--code-width, 100%);
max-width: 100%;
background: #fafafacc;
border-radius: 6px;
padding: 16px;
@@ -38,4 +51,20 @@ const md = markdownit({
overflow-x: auto;
margin: 8px 0;
}
.markdown-body :deep(pre)::-webkit-scrollbar {
height: 8px;
background: #f0f0f0;
border-radius: 4px;
}
.markdown-body :deep(pre)::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 4px;
transition: all 0.3s ease-in-out;
}
.markdown-body :deep(pre)::-webkit-scrollbar-thumb:hover {
background: #999;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { SpeakerWaveIcon } from "@/assets/Icons";
import { useLayoutStore, useTtsStore } from "@/stores";
const { text, messageId } = defineProps<{
text: string;
messageId: string;
}>();
const ttsStore = useTtsStore();
const layoutStore = useLayoutStore();
const { simpleMode } = storeToRefs(layoutStore);
// 获取当前消息的状态
const isPlaying = computed(() => ttsStore.isPlaying(messageId));
const isLoading = computed(() => ttsStore.isLoading(messageId));
const hasAudio = computed(() => ttsStore.hasAudio(messageId));
// 处理按钮点击
const handleClick = () => {
if (isLoading.value) {
return; // 合成中不响应点击
}
if (hasAudio.value) {
// 如果音频已准备好,切换播放/暂停
if (isPlaying.value) {
ttsStore.pause(messageId);
} else {
ttsStore.play(messageId);
}
} else {
// 如果没有音频开始TTS转换
ttsStore.convertText(text, messageId);
}
};
// 文本改变清理之前的音频
watch(
() => text,
() => {
ttsStore.clearAudio(messageId);
}
);
onUnmounted(() => {
ttsStore.clearAudio(messageId);
});
</script>
<template>
<NPopover trigger="hover">
<template #trigger>
<NButton
:loading="isLoading"
@click="handleClick"
quaternary
circle
:disabled="!text.trim()"
>
<SpeakerWaveIcon
v-if="!isLoading"
class="!w-4 !h-4"
:class="{
'': !simpleMode,
'animate-pulse': isPlaying
}"
/>
</NButton>
</template>
<span>
{{
isLoading
? "合成中..."
: isPlaying
? "点击暂停"
: hasAudio
? "点击播放"
: "语音合成"
}}
</span>
</NPopover>
</template>

View File

@@ -1,24 +1,128 @@
export interface IChatWithLLMRequest {
messages: Message[]
messages: Message[];
/**
* 要使用的模型的 ID
*/
model: string
model: string;
}
export interface Message {
content?: string
role?: string
[property: string]: any
content?: string;
thinking?: string;
role?: string;
usage?: UsageInfo;
id?: string;
type?: "chat" | "voice";
[property: string]: any;
}
export interface ModelInfo {
model_id: string
model_name: string
model_type: string
model_id: string;
model_name: string;
model_type: string;
}
export interface ModelListInfo {
vendor: string
models: ModelInfo[]
vendor: string;
models: ModelInfo[];
}
export interface UsageInfo {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
/**
* Speaker 语音合成器基本信息
*/
export interface Speaker {
/** speaker唯一标识ID */
speaker_id: string;
/** speaker显示名称 */
speaker_name: string;
/** 支持的语言/口音 */
language: string;
/** 支持的平台列表 */
platforms: string[];
}
/**
* Speaker分类信息
*/
export interface CategorySpeakers {
/** 分类名称 */
category: string;
/** 该分类下的speaker列表 */
speakers: Speaker[];
}
/**
* Speaker分类枚举
*/
export enum SpeakerCategory {
/** 趣味口音 */
ACCENT = "趣味口音",
/** 角色扮演 */
ROLE_PLAY = "角色扮演"
}
/**
* 常用平台枚举
*/
export enum SpeakerPlatform {
DOUYIN = "抖音",
DOUBAO = "豆包",
CICI = "Cici",
JIANYING = "剪映",
JIANYING_C = "剪映C端",
WEB_DEMO = "web demo",
STORY_AI = "StoryAi",
MAOXIANG = "猫箱"
}
/**
* Speaker选择器组件Props
*/
export interface SpeakerSelectorProps {
/** 当前选中的speaker */
selectedSpeaker?: Speaker;
/** speaker选择回调 */
onSpeakerChange: (speaker: Speaker) => void;
/** 是否禁用 */
disabled?: boolean;
/** 过滤特定分类 */
filterCategories?: SpeakerCategory[];
/** 过滤特定平台 */
filterPlatforms?: SpeakerPlatform[];
}
/**
* 语音合成参数
*/
export interface VoiceSynthesisParams {
/** 使用的speaker */
speaker: Speaker;
/** 要合成的文本 */
text: string;
/** 语速 (0.5-2.0) */
speed?: number;
/** 音调 (0.5-2.0) */
pitch?: number;
/** 音量 (0.0-1.0) */
volume?: number;
}
/**
* 语音合成响应
*/
export interface VoiceSynthesisResponse {
/** 音频文件URL */
audio_url: string;
/** 音频时长(秒) */
duration: number;
/** 合成状态 */
status: "success" | "error";
/** 错误信息 */
error_message?: string;
}

View File

@@ -1,9 +1,9 @@
export interface ICommonResponse<T> {
code: number
msg: string
data: T
code: number;
msg: string;
data: T;
}
export type IMsgOnlyResponse = ICommonResponse<{ msg: string }>
export type IMsgOnlyResponse = ICommonResponse<{ msg: string }>;
export * from "./chat_service"
export * from "./chat_service";

View File

@@ -1,27 +1,64 @@
<script setup lang="ts">
import { NImage } from "naive-ui";
import { ChevronLeftIcon } from "@/assets/Icons";
import logo from "@/assets/logo.png";
import { useChatStore } from "@/stores";
import { useChatStore, useLayoutStore } from "@/stores";
const chatStore = useChatStore();
const { onlineCount } = storeToRefs(chatStore);
const layoutStore = useLayoutStore();
const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
</script>
<template>
<div class="h-screen flex overflow-hidden">
<div class="flex-none w-[200px] h-full flex flex-col">
<div class="relative h-screen flex overflow-hidden">
<div
class="absolute left-0 top-0 bottom-0 z-10 flex-none w-[200px] h-full flex flex-col bg-white transition-all ease-in-out"
:class="{
'-translate-x-[200px]': hiddenLeftSidebar
}"
>
<div
@click="hiddenLeftSidebar = !hiddenLeftSidebar"
class="absolute -right-3 translate-y-1/2 top-1/2 z-20 w-[24px] h-[24px] bg-[#0094c526] rounded-full flex items-center justify-center cursor-pointer"
:class="{
'rotate-180 -right-8': hiddenLeftSidebar
}"
>
<ChevronLeftIcon class="!w-4 !h-4 text-[#777]" />
</div>
<router-link class="w-full my-6 cursor-pointer" to="/">
<NImage class="w-full object-cover" :src="logo" alt="logo" :preview-disabled="true" />
<NImage
class="w-full object-cover"
:src="logo"
alt="logo"
:preview-disabled="true"
/>
</router-link>
<router-link
class="w-full h-[52px] px-8 flex items-center cursor-pointer" :class="$route.path === '/'
? [
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]',
]
: []
" to="/"
class="w-full h-[52px] px-8 flex items-center cursor-pointer"
:class="
$route.path === '/'
? [
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]'
]
: []
"
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]">
@@ -32,7 +69,10 @@ const { onlineCount } = storeToRefs(chatStore);
</div>
</div>
</div>
<div class="flex-1 relative">
<div
class="flex-1 relative"
:class="{ 'ml-[200px]': !hiddenLeftSidebar && !simpleMode }"
>
<RouterView />
</div>
</div>

View File

@@ -1,14 +1,14 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import router from './router'
import './style.css'
import router from "./router";
import "./style.css";
const pinia = createPinia()
const app = createApp(App)
const pinia = createPinia();
const app = createApp(App);
app.use(pinia)
app.use(router)
app.use(pinia);
app.use(router);
app.mount('#app')
app.mount("#app");

View File

@@ -1,28 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory } from "vue-router";
import BasicLayout from '@/layouts/BasicLayout.vue'
import { resetDescription, setTitle } from '@/utils'
import community from '@/views/CommunityView.vue'
import BasicLayout from "@/layouts/BasicLayout.vue";
import { resetDescription, setTitle } from "@/utils";
import ChatLLM from "@/views/ChatLLMView.vue";
import VoiceView from "@/views/VoiceView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
path: "/",
component: BasicLayout,
children: [
{
path: '',
name: 'community',
component: community,
path: "",
name: "ChatLLM",
component: ChatLLM,
meta: {
title: '社区',
},
title: "对话"
}
},
],
},
],
})
{
path: "/voice",
name: "Voice",
component: VoiceView,
meta: {
title: "语音对话"
}
}
]
}
]
});
// // 权限检查函数,检查并决定是否允许访问
// const checkPermission: NavigationGuard = (to, from, next) => {
@@ -64,17 +73,17 @@ const router = createRouter({
// // 添加导航守卫
router.beforeEach((to, from, next) => {
setTitle(to.meta.title as string)
resetDescription()
setTitle(to.meta.title as string);
resetDescription();
// context.loadingBar?.start();
// 在每个路由导航前执行权限检查
// checkPermission(to, from, next);
next()
})
next();
});
// router.afterEach(() => {
// context.loadingBar?.finish();
// });
export default router
export default router;

View File

@@ -0,0 +1,30 @@
import { useWebSocketStore } from "@/services";
export const useAudioWebSocket = () => {
const webSocketStore = useWebSocketStore();
const sendMessage = (data: string | Uint8Array) => {
if (webSocketStore.connected) {
if (typeof data === "string") {
webSocketStore.send(data);
} else {
webSocketStore.websocket?.send(data);
}
}
};
const ensureConnection = async (): Promise<void> => {
if (!webSocketStore.connected) {
webSocketStore.connect();
await new Promise<void>((resolve) => {
const check = () => {
if (webSocketStore.connected) resolve();
else setTimeout(check, 100);
};
check();
});
}
};
return { sendMessage, ensureConnection };
};

View File

@@ -5,27 +5,33 @@ import { context } from "@/utils";
const BaseClientService = axios.create();
// 添加请求拦截器
BaseClientService.interceptors.request.use((config) => {
// 在发送请求之前做些什么
return config;
}, (e) => {
// 对请求错误做些什么
return Promise.reject(e);
});
// 添加响应拦截器
BaseClientService.interceptors.response.use((response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response;
}, (e) => {
// “50”开头统一处理
if (e.response?.status >= 500 && e.response?.status < 600) {
context.message?.error("服务器开小差了,请稍后再试");
BaseClientService.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
return config;
},
(e) => {
// 对请求错误做些什么
return Promise.reject(e);
}
return Promise.reject(e);
});
);
// 添加响应拦截器
BaseClientService.interceptors.response.use(
(response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response;
},
(e) => {
// “50”开头统一处理
if (e.response?.status >= 500 && e.response?.status < 600) {
context.message?.error("服务器开小差了,请稍后再试");
return Promise.reject(e);
}
return Promise.reject(e);
}
);
/** 基础URL */
export const BaseUrl = "/v1";

View File

@@ -1,91 +1,145 @@
import type { AxiosRequestConfig } from "axios"
import type { AxiosRequestConfig } from "axios";
import type { IChatWithLLMRequest } from "@/interfaces"
import BaseClientService, { BaseUrl } from "./base_service.ts"
import type { IChatWithLLMRequest, UsageInfo } from "@/interfaces";
import BaseClientService, { BaseUrl } from "./base_service.ts";
export class ChatService {
public static basePath = BaseUrl
public static basePath = BaseUrl;
/** Chat with LLM */
public static async ChatWithLLM(
accessToken: string,
request: IChatWithLLMRequest,
onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => {},
getThinking: (thinkingContent: string) => void
) {
let response
let buffer = ""
let accumulatedContent = ""
let response;
let buffer = "";
let accumulatedContent = "";
let thinking = false;
let thinkingContent = "";
try {
response = await fetch("/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(request),
})
body: JSON.stringify(request)
});
if (!response.ok) {
// eslint-disable-next-line unicorn/error-message
throw new Error()
throw new Error();
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read()
const { done, value } = await reader!.read();
if (done)
break
if (done) break;
// 将二进制数据转为字符串并存入缓冲区
buffer += decoder.decode(value)
buffer += decoder.decode(value);
// 查找换行符分割数据
const lines = buffer.split("\n")
const lines = buffer.split("\n");
// 保留未处理完的部分
buffer = lines.pop() || ""
buffer = lines.pop() || "";
// 处理每一行
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine)
continue
const trimmedLine = line.trim();
if (!trimmedLine) continue;
if (trimmedLine.startsWith("data: ")) {
const jsonStr = trimmedLine.slice(6)
const jsonStr = trimmedLine.slice(6);
// 处理结束标记
if (jsonStr === "[DONE]") {
onProgress(accumulatedContent) // 最终更新
return
onProgress(accumulatedContent); // 最终更新
return;
}
try {
const data = JSON.parse(jsonStr)
const data = JSON.parse(jsonStr);
// 处理内容
if (data.choices?.[0]?.delta?.content) {
// 累积内容
accumulatedContent += data.choices[0].delta.content
// 触发回调
onProgress(accumulatedContent)
const content = data.choices[0].delta.content;
if (content.includes("<think>")) {
thinking = true;
// 只处理<think>前的内容
const [beforeThink, afterThink] = content.split("<think>");
if (beforeThink) {
accumulatedContent += beforeThink;
onProgress(accumulatedContent);
}
thinkingContent = "";
if (afterThink) {
thinkingContent += afterThink;
getThinking(thinkingContent);
}
continue;
}
if (content.includes("</think>")) {
thinking = false;
// 只处理</think>后的内容
const [beforeEndThink, afterEndThink] =
content.split("</think>");
thinkingContent += beforeEndThink;
getThinking(thinkingContent);
if (afterEndThink) {
accumulatedContent += afterEndThink;
onProgress(accumulatedContent);
}
thinkingContent = "";
continue;
}
if (thinking) {
thinkingContent += content;
getThinking(thinkingContent);
} else {
accumulatedContent += content;
onProgress(accumulatedContent);
}
}
}
catch (err) {
console.error("JSON解析失败:", err)
// 处理使用信息
if (data.usage) {
const { prompt_tokens, completion_tokens, total_tokens } =
data.usage;
getUsageInfo({
prompt_tokens,
completion_tokens,
total_tokens
});
}
} catch (err) {
console.error("JSON解析失败:", err);
}
}
}
}
}
catch (err) {
console.error("Error:", err)
} catch (err) {
console.error("Error:", err);
}
}
// 获取模型列表
public static GetModelList(config?: AxiosRequestConfig<any>) {
return BaseClientService.get(`${this.basePath}/model/list`, config)
return BaseClientService.get(`${this.basePath}/model/list`, config);
}
// 获取音色列表
public static GetSpeakerList(config?: AxiosRequestConfig<any>) {
return BaseClientService.get(`${this.basePath}/speaker/list`, config);
}
}

View File

@@ -1,3 +1,4 @@
export * from "./base_service"
export * from "./chat_service"
export * from "./websocket"
export * from "./audio_websocket";
export * from "./base_service";
export * from "./chat_service";
export * from "./websocket";

View File

@@ -1,28 +1,145 @@
import { useChatStore } from "@/stores";
import { useChatStore, useTtsStore } from "@/stores";
// WebSocket
export const useWebSocketStore = defineStore("websocket", () => {
const websocket = ref<WebSocket>();
const connected = ref(false);
const chatStore = useChatStore();
const ttsStore = useTtsStore();
const router = useRouter();
const { onlineCount } = storeToRefs(chatStore);
const onmessage = (e: MessageEvent) => {
const data = JSON.parse(e.data);
switch (data.type) {
case "count":
onlineCount.value = data.online_count;
break;
case "asr_result":
chatStore.addMessageToHistory(data.result);
// 检查消息类型
if (e.data instanceof ArrayBuffer) {
// 处理二进制音频数据(兜底处理,新版本应该不会用到)
console.log("收到二进制音频数据,大小:", e.data.byteLength);
// 可以选择忽略或者作为兜底处理
} else if (e.data instanceof Blob) {
// 如果是Blob转换为ArrayBuffer兜底处理
e.data.arrayBuffer().then((buffer: ArrayBuffer) => {
console.log("收到Blob音频数据大小:", buffer.byteLength);
});
} else if (typeof e.data === "string") {
// 处理文本JSON消息
try {
const data = JSON.parse(e.data);
switch (data.type) {
case "count":
onlineCount.value = data.online_count;
break;
case "asr_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消息格式处理
case "tts_audio_data":
// 新的音频数据格式包含messageId和hex格式的音频数据
if (data.messageId && data.audioData) {
console.log(
`收到TTS音频数据 [${data.messageId}]hex长度:`,
data.audioData.length
);
try {
// 将hex字符串转换为ArrayBuffer
const bytes = data.audioData
.match(/.{1,2}/g)
?.map((byte: string) => Number.parseInt(byte, 16));
if (bytes) {
const buffer = new Uint8Array(bytes).buffer;
console.log(
`转换后的音频数据大小 [${data.messageId}]:`,
buffer.byteLength
);
ttsStore.handleAudioData(buffer, data.messageId);
} else {
console.error(`音频数据格式错误 [${data.messageId}]`);
}
} catch (error) {
console.error(`音频数据转换失败 [${data.messageId}]:`, error);
ttsStore.handleError(
`音频数据转换失败: ${error}`,
data.messageId
);
}
} else {
console.error("tts_audio_data消息格式错误:", data);
}
break;
case "tts_audio_complete":
// TTS音频传输完成
if (data.messageId) {
console.log(`TTS音频传输完成 [${data.messageId}]`);
ttsStore.finishConversion(data.messageId);
} else {
console.log("TTS音频传输完成无messageId");
ttsStore.finishConversion(data.messageId);
}
break;
case "tts_complete":
// TTS会话结束
if (data.messageId) {
console.log(`TTS会话结束 [${data.messageId}]`);
} else {
console.log("TTS会话结束");
}
break;
case "tts_error":
// TTS错误
if (data.messageId) {
console.error(`TTS错误 [${data.messageId}]:`, data.message);
ttsStore.handleError(data.message, data.messageId);
} else {
console.error("TTS错误:", data.message);
ttsStore.handleError(data.message, data.messageId || "unknown");
}
break;
case "llm_complete_response":
// LLM部分响应
if (router.currentRoute.value.path === "/voice") {
chatStore.addMessageToHistory(data.content, "assistant", "voice");
}
break;
default:
console.log("未知消息类型:", data.type, data);
}
} catch (error) {
console.error("JSON解析错误:", error, "原始数据:", e.data);
}
} else {
console.warn("收到未知格式的消息:", typeof e.data, e.data);
}
};
const send = (data: string) => {
if (websocket.value && websocket.value.readyState === WebSocket.OPEN)
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
websocket.value?.send(data);
} else {
console.warn("WebSocket未连接无法发送消息:", data);
}
};
const sendBinary = (data: ArrayBuffer | Uint8Array) => {
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
websocket.value?.send(data);
} else {
console.warn("WebSocket未连接无法发送二进制数据");
}
};
const close = () => {
websocket.value?.close();
};
@@ -33,12 +150,15 @@ export const useWebSocketStore = defineStore("websocket", () => {
websocket.value.onopen = () => {
connected.value = true;
console.log("WebSocket连接成功");
let pingIntervalId: NodeJS.Timeout | undefined;
if (pingIntervalId)
clearInterval(pingIntervalId);
pingIntervalId = setInterval(() => send("ping"), 30 * 1000);
if (pingIntervalId) clearInterval(pingIntervalId);
pingIntervalId = setInterval(() => {
// 修改ping格式为JSON格式与后端保持一致
send(JSON.stringify({ type: "ping" }));
}, 30 * 1000);
if (websocket.value) {
websocket.value.onmessage = onmessage;
@@ -46,21 +166,29 @@ export const useWebSocketStore = defineStore("websocket", () => {
websocket.value.onerror = (e: Event) => {
console.error(`WebSocket错误:${(e as ErrorEvent).message}`);
};
websocket.value.onclose = () => {
websocket.value.onclose = (e: CloseEvent) => {
connected.value = false;
console.log(`WebSocket连接关闭: ${e.code} ${e.reason}`);
setTimeout(() => {
console.log("尝试重新连接WebSocket...");
connect(); // 尝试重新连接
}, 1000); // 1秒后重试连接
};
}
};
websocket.value.onerror = (e: Event) => {
console.error("WebSocket连接错误:", e);
};
};
return {
websocket,
connected,
send,
sendBinary,
close,
connect,
connect
};
});

View File

@@ -1,5 +1,6 @@
import { useWebSocketStore } from "@/services";
import { convertToPCM16 } from "@/utils";
import { useChatStore } from "./chat_store";
export const useAsrStore = defineStore("asr", () => {
// 是否正在录音
@@ -11,8 +12,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
@@ -23,8 +24,7 @@ export const useAsrStore = defineStore("asr", () => {
if (webSocketStore.connected) {
if (typeof data === "string") {
webSocketStore.send(data);
}
else {
} else {
webSocketStore.websocket?.send(data);
}
}
@@ -53,8 +53,7 @@ export const useAsrStore = defineStore("asr", () => {
* 开始录音
*/
const startRecording = async () => {
if (isRecording.value)
return;
if (isRecording.value) return;
messages.value = [];
// 确保 WebSocket 已连接
if (!webSocketStore.connected) {
@@ -62,8 +61,7 @@ export const useAsrStore = defineStore("asr", () => {
// 等待连接建立
await new Promise<void>((resolve) => {
const check = () => {
if (webSocketStore.connected)
resolve();
if (webSocketStore.connected) resolve();
else setTimeout(check, 100);
};
check();
@@ -73,24 +71,30 @@ export const useAsrStore = defineStore("asr", () => {
// 获取麦克风音频流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 创建音频上下文采样率16kHz
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({
sampleRate: 16000,
audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)({
sampleRate: 16000
});
// 用Blob方式创建AudioWorklet模块的URL
const blob = new Blob([audioProcessorCode], { type: "application/javascript" });
const blob = new Blob([audioProcessorCode], {
type: "application/javascript"
});
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,
channelCount: 1
});
// 监听来自AudioWorklet的音频数据
workletNode.port.onmessage = (event) => {
if (event.data.type === "audiodata") {
@@ -104,8 +108,7 @@ export const useAsrStore = defineStore("asr", () => {
mediaStreamSource.connect(workletNode);
workletNode.connect(audioContext.destination);
isRecording.value = true;
}
catch (err) {
} catch (err) {
// 麦克风权限失败或AudioWorklet加载失败
console.error("需要麦克风权限才能录音", err);
}
@@ -115,16 +118,22 @@ export const useAsrStore = defineStore("asr", () => {
* 停止录音
*/
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;
msg.speaker = useChatStore().speakerInfo?.speaker_id;
}
sendMessage(JSON.stringify(msg));
// 停止所有音轨
if (mediaStreamSource?.mediaStream) {
const tracks = mediaStreamSource.mediaStream.getTracks();
tracks.forEach(track => track.stop());
tracks.forEach((track) => track.stop());
}
// 断开音频节点
@@ -149,6 +158,6 @@ export const useAsrStore = defineStore("asr", () => {
messages,
startRecording,
stopRecording,
sendMessage,
sendMessage
};
});

View File

@@ -1,94 +1,186 @@
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo } from "@/interfaces";
import type {
CategorySpeakers,
IChatWithLLMRequest,
ModelInfo,
ModelListInfo,
Speaker,
UsageInfo
} from "@/interfaces";
import { ChatService } from "@/services";
export const useChatStore = defineStore("chat", () => {
const token = ("sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee");
const router = useRouter();
const token = "sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee";
// 默认模型
const modelInfo = ref<ModelInfo | null>(null);
// 历史消息
const historyMessages = ref<IChatWithLLMRequest["messages"]>([]);
// 是否正在响应
const completing = ref<boolean>(false);
// 是否正在思考
const thinking = ref<boolean>(false);
// 模型列表
const modelList = ref<ModelListInfo[]>([]);
// 音色列表
const speakerList = ref<CategorySpeakers[]>([]);
// 当前音色信息
const speakerInfo = ref<Speaker | null>(null);
// 在线人数
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, // 接收进度回调
onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => {},
getThinking: (thinkingContent: string) => void = () => {}
) => {
if (completing.value)
throw new Error("正在响应中");
if (completing.value) throw new Error("正在响应中");
completing.value = true; // 开始请求
completing.value = true;
try {
await ChatService.ChatWithLLM(token, request, (content) => {
onProgress(content);
});
}
catch (error) {
await ChatService.ChatWithLLM(
token,
request,
onProgress,
getUsageInfo,
getThinking
);
} catch (error) {
console.error("请求失败:", error);
}
finally {
} finally {
completing.value = false;
}
};
// 添加消息到历史记录
const addMessageToHistory = (message: string) => {
const addMessageToHistory = (
message: string,
role: "user" | "assistant" = "user",
type: "chat" | "voice" = "chat"
) => {
const content = message.trim();
if (!content)
return;
if (!content) return;
historyMessages.value.push({
role: "user",
role,
content,
type,
id: generateMessageId()
});
};
// 清除历史消息
const clearHistoryMessages = () => {
historyMessages.value = [];
const clearHistoryMessages = (type?: "chat" | "voice") => {
historyMessages.value = type
? historyMessages.value.filter((msg) => msg.type !== type)
: [];
};
watch(historyMessages, (newVal) => {
// 当历史消息变化时,发送请求
if (newVal.length > 0) {
const lastMessage = newVal[newVal.length - 1];
if (lastMessage.role === "user" && modelInfo.value) {
chatWithLLM({
messages: newVal,
model: modelInfo.value?.model_id,
}, (content) => {
// 处理进度回调
if (
historyMessages.value.length === 0
|| historyMessages.value[historyMessages.value.length - 1].role !== "assistant"
) {
historyMessages.value.push({
role: "assistant",
content: "",
});
}
historyMessages.value[historyMessages.value.length - 1].content = content;
});
}
// 确保最后一条消息是助手消息
const ensureAssistantMessage = () => {
const lastMessage = getLastMessage();
if (!lastMessage || lastMessage.role !== "assistant") {
historyMessages.value.push({
role: "assistant",
content: ""
});
}
}, { deep: true });
};
// 模型列表
const modelList = ref<ModelListInfo[]>([]);
// 处理聊天响应的逻辑
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 && router.currentRoute.value.path === "/") {
const lastMessage = newVal[newVal.length - 1];
if (lastMessage.role === "user") {
handleChatResponse(newVal);
}
}
},
{ deep: true }
);
// 获取模型列表
const getModelList = async () => {
try {
const response = await ChatService.GetModelList();
modelList.value = response.data.data;
}
catch (error) {
} catch (error) {
console.error("获取模型列表失败:", error);
}
};
return { token, completing, chatWithLLM, historyMessages, addMessageToHistory, clearHistoryMessages, getModelList, modelList, modelInfo, onlineCount };
// 获取音色列表
const getSpeakerList = async () => {
try {
const response = await ChatService.GetSpeakerList();
speakerList.value = response.data.data;
} catch (error) {
console.error("获取音色·列表失败:", error);
}
};
return {
token,
completing,
thinking,
modelInfo,
modelList,
historyMessages,
chatWithLLM,
addMessageToHistory,
clearHistoryMessages,
getModelList,
onlineCount,
speakerList,
getSpeakerList,
speakerInfo
};
});

View File

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

View File

@@ -0,0 +1,44 @@
import { matchMedia } from "@/utils";
export const useLayoutStore = defineStore("layout", () => {
// 侧边栏状态
const hiddenLeftSidebar = ref(false);
// 简洁按钮
const simpleMode = ref(false);
const handleResize = () => {
matchMedia(
"sm",
() => {
hiddenLeftSidebar.value = true;
},
() => {
hiddenLeftSidebar.value = false;
}
);
matchMedia(
"536",
() => {
simpleMode.value = true;
},
() => {
simpleMode.value = false;
}
);
};
const toggleLeftSidebar = () => {
hiddenLeftSidebar.value = !hiddenLeftSidebar.value;
};
window.addEventListener("resize", handleResize);
onMounted(() => {
handleResize();
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
return { hiddenLeftSidebar, toggleLeftSidebar, simpleMode };
});

311
web/src/stores/tts_store.ts Normal file
View File

@@ -0,0 +1,311 @@
import { useAudioWebSocket } from "@/services";
import { createAudioUrl, mergeAudioChunks } from "@/utils";
import { useChatStore } from "./chat_store";
interface AudioState {
isPlaying: boolean;
isLoading: boolean;
audioElement: HTMLAudioElement | null;
audioUrl: string | null;
audioChunks: ArrayBuffer[];
hasError: boolean;
errorMessage: string;
}
export const useTtsStore = defineStore("tts", () => {
const chatStore = useChatStore();
// 多音频状态管理 - 以消息ID为key
const audioStates = ref<Map<string, AudioState>>(new Map());
// 当前活跃的转换请求(保留用于兼容性)
const activeConversion = ref<string | null>(null);
// 会话状态
const hasActiveSession = ref(false);
// WebSocket连接
const { sendMessage, ensureConnection } = useAudioWebSocket();
/**
* 获取或创建音频状态
*/
const getAudioState = (messageId: string): AudioState => {
if (!audioStates.value.has(messageId)) {
audioStates.value.set(messageId, {
isPlaying: false,
isLoading: false,
audioElement: null,
audioUrl: null,
audioChunks: [],
hasError: false,
errorMessage: ""
});
}
return audioStates.value.get(messageId)!;
};
/**
* 发送文本进行TTS转换
*/
const convertText = async (text: string, messageId: string) => {
try {
await ensureConnection();
// 暂停其他正在播放的音频
pauseAll();
// 获取当前消息的状态
const state = getAudioState(messageId);
// 清理之前的音频和错误状态
clearAudioState(state);
state.isLoading = true;
state.audioChunks = [];
// 设置当前活跃转换
activeConversion.value = messageId;
hasActiveSession.value = true;
// 发送文本到TTS服务
sendMessage(
JSON.stringify({
type: "tts_text",
text,
messageId,
speaker: chatStore.speakerInfo?.speaker_id
})
);
} catch (error) {
handleError(`连接失败: ${error}`, messageId);
}
};
/**
* 处理接收到的音频数据 - 修改为支持messageId参数
*/
const handleAudioData = (data: ArrayBuffer, messageId?: string) => {
// 如果传递了messageId就使用它否则使用activeConversion
const targetMessageId = messageId || activeConversion.value;
if (!targetMessageId) {
console.warn("handleAudioData: 没有有效的messageId");
return;
}
console.log(`接收音频数据 [${targetMessageId}],大小:`, data.byteLength);
const state = getAudioState(targetMessageId);
state.audioChunks.push(data);
};
/**
* 完成TTS转换创建播放器并自动播放 - 修改为支持messageId参数
*/
const finishConversion = async (messageId?: string) => {
// 如果传递了messageId就使用它否则使用activeConversion
const targetMessageId = messageId || activeConversion.value;
if (!targetMessageId) {
console.warn("finishConversion: 没有有效的messageId");
return;
}
const state = getAudioState(targetMessageId);
console.log(
`完成TTS转换 [${targetMessageId}],音频片段数量:`,
state.audioChunks.length
);
if (state.audioChunks.length === 0) {
handleError("没有接收到音频数据", targetMessageId);
return;
}
try {
// 合并音频片段
const mergedAudio = mergeAudioChunks(state.audioChunks);
console.log(
`合并后音频大小 [${targetMessageId}]:`,
mergedAudio.byteLength
);
// 创建音频URL和元素
state.audioUrl = createAudioUrl(mergedAudio);
state.audioElement = new Audio(state.audioUrl);
// 设置音频事件
setupAudioEvents(state, targetMessageId);
state.isLoading = false;
// 清除activeConversion如果是当前活跃的
if (activeConversion.value === targetMessageId) {
activeConversion.value = null;
}
console.log(`TTS音频准备完成 [${targetMessageId}],开始自动播放`);
// 自动播放
await play(targetMessageId);
} catch (error) {
handleError(`音频处理失败: ${error}`, targetMessageId);
}
};
/**
* 设置音频事件监听
*/
const setupAudioEvents = (state: AudioState, messageId: string) => {
if (!state.audioElement) return;
const audio = state.audioElement;
audio.addEventListener("ended", () => {
state.isPlaying = false;
console.log(`音频播放结束 [${messageId}]`);
});
audio.addEventListener("error", (e) => {
console.error(`音频播放错误 [${messageId}]:`, e);
handleError("音频播放失败", messageId);
});
audio.addEventListener("canplaythrough", () => {
console.log(`音频可以播放 [${messageId}]`);
});
};
/**
* 播放指定消息的音频
*/
const play = async (messageId: string) => {
const state = getAudioState(messageId);
if (!state.audioElement) {
handleError("音频未准备好", messageId);
return;
}
try {
// 暂停其他正在播放的音频
pauseAll(messageId);
await state.audioElement.play();
state.isPlaying = true;
state.hasError = false;
state.errorMessage = "";
console.log(`开始播放音频 [${messageId}]`);
} catch (error) {
handleError(`播放失败: ${error}`, messageId);
}
};
/**
* 暂停指定消息的音频
*/
const pause = (messageId: string) => {
const state = getAudioState(messageId);
if (!state.audioElement) return;
state.audioElement.pause();
state.isPlaying = false;
console.log(`暂停音频 [${messageId}]`);
};
/**
* 暂停所有音频
*/
const pauseAll = (excludeMessageId?: string) => {
audioStates.value.forEach((state, messageId) => {
if (excludeMessageId && messageId === excludeMessageId) return;
if (state.isPlaying && state.audioElement) {
state.audioElement.pause();
state.isPlaying = false;
}
});
};
/**
* 处理TTS错误 - 修改为支持messageId参数
*/
const handleError = (errorMsg: string, messageId?: string) => {
// 如果传递了messageId就使用它否则使用activeConversion
const targetMessageId = messageId || activeConversion.value;
if (!targetMessageId) {
console.error(`TTS错误 (无messageId): ${errorMsg}`);
return;
}
console.error(`TTS错误 [${targetMessageId}]: ${errorMsg}`);
const state = getAudioState(targetMessageId);
state.hasError = true;
state.errorMessage = errorMsg;
state.isLoading = false;
if (activeConversion.value === targetMessageId) {
activeConversion.value = null;
hasActiveSession.value = false;
}
};
/**
* 清理指定消息的音频资源
*/
const clearAudio = (messageId: string) => {
const state = getAudioState(messageId);
clearAudioState(state);
audioStates.value.delete(messageId);
};
/**
* 清理音频状态
*/
const clearAudioState = (state: AudioState) => {
if (state.audioElement) {
state.audioElement.pause();
state.audioElement = null;
}
if (state.audioUrl) {
URL.revokeObjectURL(state.audioUrl);
state.audioUrl = null;
}
state.isPlaying = false;
state.audioChunks = [];
state.hasError = false;
state.errorMessage = "";
};
// 状态查询方法
const isPlaying = (messageId: string) => getAudioState(messageId).isPlaying;
const isLoading = (messageId: string) => getAudioState(messageId).isLoading;
const hasAudio = (messageId: string) =>
!!getAudioState(messageId).audioElement;
const hasError = (messageId: string) => getAudioState(messageId).hasError;
const getErrorMessage = (messageId: string) =>
getAudioState(messageId).errorMessage;
// 组件卸载时清理所有资源
onUnmounted(() => {
audioStates.value.forEach((state) => clearAudioState(state));
audioStates.value.clear();
});
return {
// 状态查询方法
isPlaying,
isLoading,
hasAudio,
hasError,
getErrorMessage,
// 核心方法
convertText,
handleAudioData,
finishConversion,
play,
pause,
pauseAll,
clearAudio,
handleError
};
});

View File

@@ -1,5 +1,3 @@
export const useUserStore = defineStore("user", () => {
return {
};
return {};
});

View File

@@ -1,2 +1,2 @@
@import 'tailwindcss';
@import 'highlight.js/styles/github.css';
@import "tailwindcss";
@import "highlight.js/styles/github.css";

20
web/src/utils/audio.ts Normal file
View File

@@ -0,0 +1,20 @@
// 合并音频片段
export const mergeAudioChunks = (chunks: ArrayBuffer[]): Uint8Array => {
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
chunks.forEach((chunk) => {
merged.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
});
return merged;
};
// 创建音频播放URL
export const createAudioUrl = (
audioData: Uint8Array,
mimeType = "audio/mp3"
): string => {
const blob = new Blob([audioData as BlobPart], { type: mimeType });
return URL.createObjectURL(blob);
};

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,9 +1,9 @@
import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider"
import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider"
import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider"
import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider";
import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider";
import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider";
export const context: {
message?: MessageApiInjection
notification?: NotificationApiInjection
loadingBar?: LoadingBarApiInjection
} = {}
message?: MessageApiInjection;
notification?: NotificationApiInjection;
loadingBar?: LoadingBarApiInjection;
} = {};

7
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,7 @@
export const formatTime = (seconds: number): string => {
if (Number.isNaN(seconds) || !Number.isFinite(seconds)) return "00:00";
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};

View File

@@ -1,6 +1,9 @@
export * from "./context"
export * from "./media"
export * from "./pcm"
export * from "./title"
export * from "./title"
export * from "./url"
export * from "./audio";
export * from "./clipboard";
export * from "./context";
export * from "./format";
export * from "./media";
export * from "./pcm";
export * from "./title";
export * from "./title";
export * from "./url";

View File

@@ -2,42 +2,54 @@
export const matchMedia = (
type: "sm" | "md" | "lg" | string,
matchFunc?: Function,
mismatchFunc?: Function,
mismatchFunc?: Function
) => {
if (type === "sm") {
if (window.matchMedia("(max-width: 767.98px)").matches) {
/* 窗口小于或等于 */
matchFunc?.()
matchFunc?.();
} else {
mismatchFunc?.();
}
else {
mismatchFunc?.()
}
}
else if (type === "md") {
} else if (type === "md") {
if (window.matchMedia("(max-width: 992px)").matches) {
/* 窗口小于或等于 */
matchFunc?.()
matchFunc?.();
} else {
mismatchFunc?.();
}
else {
mismatchFunc?.()
}
}
else if (type === "lg") {
} else if (type === "lg") {
if (window.matchMedia("(max-width: 1200px)").matches) {
/* 窗口小于或等于 */
matchFunc?.()
matchFunc?.();
} else {
mismatchFunc?.();
}
else {
mismatchFunc?.()
}
}
else {
} else {
if (window.matchMedia(`(max-width: ${type}px)`).matches) {
/* 窗口小于或等于 */
matchFunc?.()
}
else {
mismatchFunc?.()
matchFunc?.();
} else {
mismatchFunc?.();
}
}
}
};
/** 获取视窗宽度 */
export const useWindowWidth = () => {
const width = ref(window.innerWidth);
const updateWidth = () => {
width.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener("resize", updateWidth);
});
onUnmounted(() => {
window.removeEventListener("resize", updateWidth);
});
return width;
};

View File

@@ -1,12 +1,12 @@
export const convertToPCM16 = (float32Array: Float32Array): Uint8Array => {
const int16Buffer = new Int16Array(float32Array.length)
const int16Buffer = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
int16Buffer[i] = Math.max(-1, Math.min(1, float32Array[i])) * 0x7FFF
int16Buffer[i] = Math.max(-1, Math.min(1, float32Array[i])) * 0x7FFF;
}
const buffer = new ArrayBuffer(int16Buffer.length * 2)
const view = new DataView(buffer)
const buffer = new ArrayBuffer(int16Buffer.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < int16Buffer.length; i++) {
view.setInt16(i * 2, int16Buffer[i], true)
view.setInt16(i * 2, int16Buffer[i], true);
}
return new Uint8Array(buffer)
}
return new Uint8Array(buffer);
};

View File

@@ -1,25 +1,24 @@
import { useTitle } from "@vueuse/core"
import { useTitle } from "@vueuse/core";
const DEFAULT_TITLE = "Agent"
const DEFAULT_TITLE = "Agent";
const DEFAULT_DESCRIPTION = document
.querySelector("meta[name='description']")
?.getAttribute("content")
?.getAttribute("content");
export function setTitle(title?: string) {
useTitle().value = (title ? `${title} | ` : "") + DEFAULT_TITLE
useTitle().value = (title ? `${title} | ` : "") + DEFAULT_TITLE;
}
export function resetDescription() {
document
.querySelector("meta[name='description']")
?.setAttribute("content", DEFAULT_DESCRIPTION!)
?.setAttribute("content", DEFAULT_DESCRIPTION!);
}
export function setDescription(description?: string) {
if (!description)
return
if (!description) return;
document
.querySelector("meta[name='description']")
?.setAttribute("content", `${description} | ${DEFAULT_TITLE}`)
?.setAttribute("content", `${description} | ${DEFAULT_TITLE}`);
}

View File

@@ -1,15 +1,15 @@
/** 直接当前页面跳转到指定url */
export const jump = (url: string) => {
window.location.href = url
}
window.location.href = url;
};
/** 在新标签页中跳转到指定url */
export const jumpBlank = (url: string) => {
window.open(url, "_blank")
}
window.open(url, "_blank");
};
/** 将对象转换为url查询字符串 */
export const queryFormat = (query: Record<string, any>) => {
const params = new URLSearchParams(query)
return params.toString() ? `?${params}` : ""
}
const params = new URLSearchParams(query);
return params.toString() ? `?${params}` : "";
};

View File

@@ -0,0 +1,289 @@
<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,
PaperAirplaneIcon,
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 inputData = ref("");
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为chat的聊天消息
const filteredMessages = computed(() =>
historyMessages.value.filter((msg) => msg.type === "chat" || !msg.type)
);
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 handleSendMessage = () => {
if (inputData.value.trim() === "") return;
chatStore.addMessageToHistory(inputData.value);
inputData.value = "";
};
// 开关语音输入
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 || ''" />
<MessageTools :msg="msg" />
<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>
<!-- 输入框 -->
<NInput
v-model:value="inputData"
type="textarea"
placeholder="输入内容Enter发送Shift+Enter换行"
:autosize="{
minRows: 3,
maxRows: 15
}"
@keyup.enter="handleSendMessage"
/>
<!-- 操作区 -->
<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('chat')"
@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>
<NButton
:disabled="isRecording"
:loading="completing"
@click="handleSendMessage"
>
发送
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
</NButton>
</div>
</div>
</div>
</template>

View File

@@ -1,130 +0,0 @@
<script setup lang="ts">
import type { SelectGroupOption, SelectOption } from "naive-ui";
import { ExclamationTriangleIcon, microphone, PaperAirplaneIcon, TrashIcon } from "@/assets/Icons";
import markdown from "@/components/markdown.vue";
import { useAsrStore, useChatStore } from "@/stores";
const chatStore = useChatStore();
const asrStore = useAsrStore();
const { historyMessages, completing, modelList, modelInfo } = storeToRefs(chatStore);
const { isRecording } = storeToRefs(asrStore);
const inputData = ref("");
const options = ref<Array<SelectGroupOption | SelectOption>>([]);
// 处理选中模型的 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];
}
console.log("Options updated:", options.value);
}
}, { immediate: true, deep: true });
// 发送消息
const handleSendMessage = () => {
if (inputData.value.trim() === "")
return;
chatStore.addMessageToHistory(inputData.value);
inputData.value = "";
};
// 开关语音输入
const toggleRecording = () => {
if (isRecording.value) {
asrStore.stopRecording();
}
else {
asrStore.startRecording();
}
};
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] text-base">
<NScrollbar class="flex-1 pr-4">
<div class="flex items-start mb-4">
<span class="text-base w-14 min-w-14">助手</span>
<NTag type="success" class="text-base max-w-full !h-auto">
<span class="text-base">你好我是你的智能助手请问有什么可以帮助你的吗</span>
</NTag>
</div>
<div v-for="(msg, idx) in historyMessages" :key="idx" class="flex items-start mb-4">
<span v-if="msg.role === 'user'" class="text-base w-14 min-w-14"></span>
<span v-else class="text-base w-14 min-w-14">助手</span>
<NTag :type="msg.role === 'user' ? 'info' : 'success'" class="max-w-full !h-auto">
<markdown :content="msg.content || ''" />
</NTag>
</div>
</NScrollbar>
<NInput v-model:value="inputData" type="textarea" placeholder="在这里输入消息" @keyup.enter="handleSendMessage" />
<!-- 操作区 -->
<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" @negative-click="() => { }"
>
<template #icon>
<ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" />
</template>
<template #trigger>
<NButton :disabled="isRecording || completing" type="warning">
清除历史
<TrashIcon class="!w-4 !h-4 ml-1" />
</NButton>
</template>
<span>确定要清除历史消息吗?</span>
</NPopconfirm>
<NButton :disabled="completing" @click="toggleRecording">
{{ isRecording ? "停止输入" : "语音输入" }}
<microphone class="!w-4 !h-4 ml-1" />
</NButton>
<NButton :disabled="isRecording" :loading="completing" @click="handleSendMessage">
发送
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
</NButton>
</div>
</div>
</div>
</template>

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

@@ -0,0 +1,261 @@
<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, speakerList, speakerInfo, 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);
}
};
// 处理选中speaker的 ID
const selectedSpeakerId = computed({
get: () => speakerInfo.value?.speaker_id ?? null,
set: (id: string | null) => {
for (const category of speakerList.value) {
const found = category.speakers.find(
(speaker) => speaker.speaker_id === id
);
if (found) {
speakerInfo.value = found;
return;
}
}
speakerInfo.value = null;
}
});
// 监听speaker列表变化更新选项
watch(
() => speakerList.value,
(newVal) => {
if (newVal) {
options.value = newVal.map((category) => ({
type: "group",
label: category.category,
key: category.category,
children: category.speakers.map((speaker) => ({
label: speaker.speaker_name,
value: speaker.speaker_id,
language: speaker.language,
platforms: speaker.platforms
}))
}));
// 默认选择第一个speaker
if (newVal.length > 0 && newVal[0].speakers.length > 0) {
speakerInfo.value = newVal[0].speakers[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.getSpeakerList();
});
</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 || ''" />
<MessageTools :msg="msg" />
<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="selectedSpeakerId"
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>

View File

@@ -2,13 +2,22 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"types": ["vite-svg-loader"]
"types": [
"vite-svg-loader",
"lodash"
]
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"files": []
}

View File

@@ -48,4 +48,7 @@ export default defineConfig({
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
esbuild: {
treeShaking: true,
},
});