Merge pull request 'feat/1.0.1' (#2) from feat/1.0.1 into main
Reviewed-on: #2
This commit is contained in:
455
backend/app/api/v1/endpoints/tts.py
Normal file
455
backend/app/api/v1/endpoints/tts.py
Normal file
@@ -0,0 +1,455 @@
|
||||
# tts.py
|
||||
import uuid
|
||||
import websockets
|
||||
import time
|
||||
import fastrand
|
||||
import json
|
||||
import asyncio
|
||||
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
|
||||
|
||||
|
||||
# 所有类定义保持不变...
|
||||
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
|
||||
|
||||
|
||||
# 全局状态管理
|
||||
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
|
||||
|
||||
|
||||
# 处理单个TTS任务
|
||||
async def process_tts_task(websocket, message_id: str, text: str):
|
||||
"""处理单个TTS任务(独立协程)"""
|
||||
tts_state = None
|
||||
try:
|
||||
print(f"开始处理TTS任务 [{message_id}]: {text}")
|
||||
|
||||
# 获取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连接
|
||||
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()
|
||||
payload = get_payload_bytes(event=EVENT_StartSession, speaker=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()
|
||||
payload = get_payload_bytes(event=EVENT_TaskRequest, text=text, speaker=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)}")
|
||||
# 发送音频数据,包含消息ID
|
||||
await websocket.send_json({
|
||||
"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}],强制结束")
|
||||
|
||||
# 发送完成消息
|
||||
await websocket.send_json({
|
||||
"type": "tts_audio_complete",
|
||||
"messageId": message_id
|
||||
})
|
||||
print(f"TTS处理完成 [{message_id}],共发送 {audio_count} 个音频包")
|
||||
|
||||
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):
|
||||
"""启动TTS文本转换"""
|
||||
# 创建新的TTS状态
|
||||
tts_state = tts_manager.add_tts_state(websocket, message_id)
|
||||
|
||||
# 启动异步任务
|
||||
tts_state.task = asyncio.create_task(
|
||||
process_tts_task(websocket, message_id, text)
|
||||
)
|
||||
|
||||
|
||||
# 取消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资源清理完成")
|
||||
@@ -1,14 +1,19 @@
|
||||
# websocket_service.py
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import Set
|
||||
from aip import AipSpeech
|
||||
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
|
||||
import json
|
||||
|
||||
# 导入修改后的TTS模块
|
||||
from . import tts
|
||||
|
||||
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 +21,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 +33,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 +53,59 @@ 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})
|
||||
temp_buffer = bytes()
|
||||
|
||||
# 修改:TTS处理支持消息ID
|
||||
elif msg_type == "tts_text":
|
||||
message_id = data.get("messageId")
|
||||
text = data.get("text", "")
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -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"},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
7
backend/app/constants/tts.py
Normal file
7
backend/app/constants/tts.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# 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'
|
||||
3
web/.prettierignore
Normal file
3
web/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# .prettierignore
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
8
web/.prettierrc.json
Normal file
8
web/.prettierrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"trailingComma": "none",
|
||||
"ignorePath": ".prettierignore"
|
||||
}
|
||||
7
web/components.d.ts
vendored
7
web/components.d.ts
vendored
@@ -7,16 +7,23 @@ 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']
|
||||
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']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
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"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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
56
web/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 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";
|
||||
|
||||
3
web/src/assets/Icons/svg/heroicons/ChevronLeftIcon.svg
Normal file
3
web/src/assets/Icons/svg/heroicons/ChevronLeftIcon.svg
Normal 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 |
3
web/src/assets/Icons/svg/heroicons/SpeakerWaveIcon.svg
Normal file
3
web/src/assets/Icons/svg/heroicons/SpeakerWaveIcon.svg
Normal 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 |
BIN
web/src/assets/ai_avatar.png
Normal file
BIN
web/src/assets/ai_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
BIN
web/src/assets/user_avatar.jpg
Normal file
BIN
web/src/assets/user_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
19
web/src/components/avatar.vue
Normal file
19
web/src/components/avatar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
81
web/src/components/tts.vue
Normal file
81
web/src/components/tts.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<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>
|
||||
@@ -1,24 +1,33 @@
|
||||
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;
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
<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>
|
||||
@@ -32,7 +56,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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
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 community from "@/views/CommunityView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
path: "/",
|
||||
component: BasicLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'community',
|
||||
path: "",
|
||||
name: "community",
|
||||
component: community,
|
||||
meta: {
|
||||
title: '社区',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
title: "对话"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// // 权限检查函数,检查并决定是否允许访问
|
||||
// const checkPermission: NavigationGuard = (to, from, next) => {
|
||||
@@ -64,17 +64,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;
|
||||
|
||||
30
web/src/services/audio_websocket.ts
Normal file
30
web/src/services/audio_websocket.ts
Normal 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 };
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -1,91 +1,140 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,28 +1,143 @@
|
||||
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 { 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);
|
||||
console.warn("收到旧格式的二进制数据,无法确定messageId");
|
||||
// 可以选择忽略或者作为兜底处理
|
||||
} else if (e.data instanceof Blob) {
|
||||
// 如果是Blob,转换为ArrayBuffer(兜底处理)
|
||||
e.data.arrayBuffer().then((buffer: ArrayBuffer) => {
|
||||
console.log("收到Blob音频数据,大小:", buffer.byteLength);
|
||||
console.warn("收到旧格式的Blob数据,无法确定messageId");
|
||||
});
|
||||
} else if (typeof e.data === "string") {
|
||||
// 处理文本JSON消息
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
switch (data.type) {
|
||||
case "count":
|
||||
onlineCount.value = data.online_count;
|
||||
break;
|
||||
case "asr_result":
|
||||
chatStore.addMessageToHistory(data.result);
|
||||
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 "tts_audio_complete_legacy":
|
||||
case "tts_complete_legacy":
|
||||
case "tts_error_legacy":
|
||||
console.log("收到旧格式TTS消息:", data.type);
|
||||
// 可以选择处理或忽略
|
||||
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 +148,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 +164,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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -23,8 +23,7 @@ export const useAsrStore = defineStore("asr", () => {
|
||||
if (webSocketStore.connected) {
|
||||
if (typeof data === "string") {
|
||||
webSocketStore.send(data);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
webSocketStore.websocket?.send(data);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +52,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 +60,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,11 +70,14 @@ 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);
|
||||
@@ -89,7 +89,7 @@ export const useAsrStore = defineStore("asr", () => {
|
||||
workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
channelCount: 1,
|
||||
channelCount: 1
|
||||
});
|
||||
// 监听来自AudioWorklet的音频数据
|
||||
workletNode.port.onmessage = (event) => {
|
||||
@@ -104,8 +104,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,8 +114,7 @@ export const useAsrStore = defineStore("asr", () => {
|
||||
* 停止录音
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value)
|
||||
return;
|
||||
if (!isRecording.value) return;
|
||||
|
||||
// 通知后端录音结束
|
||||
sendMessage(JSON.stringify({ type: "asr_end" }));
|
||||
@@ -124,7 +122,7 @@ export const useAsrStore = defineStore("asr", () => {
|
||||
// 停止所有音轨
|
||||
if (mediaStreamSource?.mediaStream) {
|
||||
const tracks = mediaStreamSource.mediaStream.getTracks();
|
||||
tracks.forEach(track => track.stop());
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
// 断开音频节点
|
||||
@@ -149,6 +147,6 @@ export const useAsrStore = defineStore("asr", () => {
|
||||
messages,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
sendMessage,
|
||||
sendMessage
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo } from "@/interfaces";
|
||||
import type {
|
||||
IChatWithLLMRequest,
|
||||
ModelInfo,
|
||||
ModelListInfo,
|
||||
UsageInfo
|
||||
} from "@/interfaces";
|
||||
import { ChatService } from "@/services";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
const token = ("sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee");
|
||||
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 onlineCount = ref<number>(0);
|
||||
|
||||
// 与 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; // 开始请求
|
||||
try {
|
||||
await ChatService.ChatWithLLM(token, request, (content) => {
|
||||
onProgress(content);
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
await ChatService.ChatWithLLM(
|
||||
token,
|
||||
request,
|
||||
(content) => {
|
||||
onProgress(content);
|
||||
},
|
||||
(object: UsageInfo) => {
|
||||
getUsageInfo(object);
|
||||
},
|
||||
(thinkingContent: string) => {
|
||||
getThinking(thinkingContent);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("请求失败:", error);
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
completing.value = false;
|
||||
}
|
||||
};
|
||||
@@ -37,12 +53,11 @@ export const useChatStore = defineStore("chat", () => {
|
||||
// 添加消息到历史记录
|
||||
const addMessageToHistory = (message: string) => {
|
||||
const content = message.trim();
|
||||
if (!content)
|
||||
return;
|
||||
if (!content) return;
|
||||
|
||||
historyMessages.value.push({
|
||||
role: "user",
|
||||
content,
|
||||
content
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,30 +66,67 @@ export const useChatStore = defineStore("chat", () => {
|
||||
historyMessages.value = [];
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (
|
||||
historyMessages.value.length === 0 ||
|
||||
historyMessages.value[historyMessages.value.length - 1].role !==
|
||||
"assistant"
|
||||
) {
|
||||
historyMessages.value.push({
|
||||
role: "assistant",
|
||||
content: ""
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
};
|
||||
|
||||
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) => {
|
||||
ensureAssistantMessage();
|
||||
thinking.value = false;
|
||||
historyMessages.value[historyMessages.value.length - 1].content =
|
||||
content;
|
||||
},
|
||||
// 处理使用usage信息回调
|
||||
(usageInfo: UsageInfo) => {
|
||||
// 如果最后一条消息是助手的回复,则更新使用信息
|
||||
if (
|
||||
historyMessages.value.length > 0 &&
|
||||
historyMessages.value[historyMessages.value.length - 1].role ===
|
||||
"assistant"
|
||||
) {
|
||||
historyMessages.value[historyMessages.value.length - 1].usage =
|
||||
usageInfo;
|
||||
}
|
||||
},
|
||||
// 处理思维链内容回调
|
||||
(thinkingContent: string) => {
|
||||
ensureAssistantMessage();
|
||||
thinking.value = true;
|
||||
historyMessages.value[historyMessages.value.length - 1].thinking =
|
||||
thinkingContent;
|
||||
}
|
||||
).then(() => {
|
||||
historyMessages.value[historyMessages.value.length - 1].id =
|
||||
new Date().getTime().toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 模型列表
|
||||
const modelList = ref<ModelListInfo[]>([]);
|
||||
@@ -84,11 +136,22 @@ export const useChatStore = defineStore("chat", () => {
|
||||
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 };
|
||||
return {
|
||||
token,
|
||||
completing,
|
||||
chatWithLLM,
|
||||
historyMessages,
|
||||
addMessageToHistory,
|
||||
clearHistoryMessages,
|
||||
getModelList,
|
||||
modelList,
|
||||
modelInfo,
|
||||
onlineCount,
|
||||
thinking
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
44
web/src/stores/layout_store.ts
Normal file
44
web/src/stores/layout_store.ts
Normal 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 };
|
||||
});
|
||||
302
web/src/stores/tts_store.ts
Normal file
302
web/src/stores/tts_store.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useAudioWebSocket } from "@/services";
|
||||
import { createAudioUrl, mergeAudioChunks } from "@/utils";
|
||||
|
||||
interface AudioState {
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
audioElement: HTMLAudioElement | null;
|
||||
audioUrl: string | null;
|
||||
audioChunks: ArrayBuffer[];
|
||||
hasError: boolean;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export const useTtsStore = defineStore("tts", () => {
|
||||
// 多音频状态管理 - 以消息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 }));
|
||||
} 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
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
return {
|
||||
|
||||
};
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -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
20
web/src/utils/audio.ts
Normal 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);
|
||||
};
|
||||
@@ -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
7
web/src/utils/format.ts
Normal 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")}`;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from "./context"
|
||||
export * from "./media"
|
||||
export * from "./pcm"
|
||||
export * from "./title"
|
||||
export * from "./title"
|
||||
export * from "./url"
|
||||
export * from "./audio";
|
||||
export * from "./context";
|
||||
export * from "./format";
|
||||
export * from "./media";
|
||||
export * from "./pcm";
|
||||
export * from "./title";
|
||||
export * from "./title";
|
||||
export * from "./url";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}` : "";
|
||||
};
|
||||
|
||||
@@ -1,72 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupOption, SelectOption } from "naive-ui";
|
||||
import { ExclamationTriangleIcon, microphone, PaperAirplaneIcon, TrashIcon } from "@/assets/Icons";
|
||||
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 } from "@/stores";
|
||||
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { historyMessages, completing, modelList, modelInfo, thinking } =
|
||||
storeToRefs(chatStore);
|
||||
const asrStore = useAsrStore();
|
||||
|
||||
const { historyMessages, completing, modelList, modelInfo } = storeToRefs(chatStore);
|
||||
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))
|
||||
);
|
||||
|
||||
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);
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
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];
|
||||
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 });
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = () => {
|
||||
if (inputData.value.trim() === "")
|
||||
return;
|
||||
if (inputData.value.trim() === "") return;
|
||||
chatStore.addMessageToHistory(inputData.value);
|
||||
inputData.value = "";
|
||||
};
|
||||
|
||||
// 开关语音输入
|
||||
const toggleRecording = () => {
|
||||
const toggleRecording = throttle(() => {
|
||||
if (isRecording.value) {
|
||||
asrStore.stopRecording();
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
asrStore.startRecording();
|
||||
}
|
||||
};
|
||||
}, 500);
|
||||
|
||||
watch(completing, (newVal) => {
|
||||
if (newVal) {
|
||||
nextTick(() => {
|
||||
scrollbarRef.value?.scrollTo({ top: 99999, behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.getModelList();
|
||||
@@ -74,53 +128,155 @@ onMounted(() => {
|
||||
</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="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="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>
|
||||
<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 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
|
||||
v-for="(msg, idx) in historyMessages"
|
||||
:key="idx"
|
||||
class="flex items-start mb-4"
|
||||
>
|
||||
<!-- 头像↓ -->
|
||||
<span
|
||||
v-if="msg.role === 'user'"
|
||||
class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16"
|
||||
>
|
||||
<avatar :avatar="UserAvatar" />
|
||||
</span>
|
||||
<span v-else class="rounded-lg overflow-hidden">
|
||||
<avatar :avatar="AIAvatar" />
|
||||
</span>
|
||||
<!-- 头像↑ 名称↓ -->
|
||||
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
||||
<span class="text-base font-bold">{{
|
||||
msg.role === "user" ? "你:" : "助手:"
|
||||
}}</span>
|
||||
<!-- 使用信息 -->
|
||||
<div
|
||||
v-if="msg.role !== 'user'"
|
||||
class="text-[12px] text-[#7A7A7A] mb-[2px]"
|
||||
>
|
||||
Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span>
|
||||
</div>
|
||||
<div class="w-full max-w-full">
|
||||
<NCollapse
|
||||
v-if="msg.thinking?.trim()"
|
||||
:expanded-names="collapseActive[idx]"
|
||||
>
|
||||
<NCollapseItem
|
||||
:title="
|
||||
thinking && idx === historyMessages.length - 1
|
||||
? '思考中...'
|
||||
: '已深度思考'
|
||||
"
|
||||
:name="getName(msg, idx)"
|
||||
@item-header-click="
|
||||
() => handleItemHeaderClick(getName(msg, idx))
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="text-[#7A7A7A] mb-4 border-l-2 border-[#E5E5E5] ml-2 pl-2"
|
||||
>
|
||||
<markdown :content="msg.thinking || ''" />
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
<!-- 内容↓ 思维链↑ -->
|
||||
<markdown :content="msg.content || ''" />
|
||||
<div v-if="msg.role !== 'user'" class="mt-2">
|
||||
<tts :text="msg.content || ''" :message-id="msg.id!" />
|
||||
</div>
|
||||
<NDivider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isRecording"
|
||||
class="absolute inset-0 pointer-events-none flex items-center justify-center text-[#7A7A7A] text-2xl bg-white/80"
|
||||
>
|
||||
正在语音输入...
|
||||
</div>
|
||||
</NScrollbar>
|
||||
<NInput v-model:value="inputData" type="textarea" placeholder="在这里输入消息" @keyup.enter="handleSendMessage" />
|
||||
<!-- 输入框 -->
|
||||
<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"
|
||||
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="() => { }"
|
||||
: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" />
|
||||
<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">
|
||||
{{ isRecording ? "停止输入" : "语音输入" }}
|
||||
<microphone class="!w-4 !h-4 ml-1" />
|
||||
<template v-if="!simpleMode">
|
||||
{{ isRecording ? "停止输入" : "语音输入" }}
|
||||
</template>
|
||||
<microphone
|
||||
class="!w-4 !h-4"
|
||||
:class="{
|
||||
'ml-1': !simpleMode
|
||||
}"
|
||||
/>
|
||||
</NButton>
|
||||
<NButton :disabled="isRecording" :loading="completing" @click="handleSendMessage">
|
||||
<NButton
|
||||
:disabled="isRecording"
|
||||
:loading="completing"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
发送
|
||||
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
|
||||
</NButton>
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -48,4 +48,7 @@ export default defineConfig({
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
treeShaking: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user