Compare commits

...

9 Commits

48 changed files with 1981 additions and 393 deletions

View 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资源清理完成")

View File

@@ -1,14 +1,19 @@
# websocket_service.py
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Set from typing import Set
from aip import AipSpeech from aip import AipSpeech
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
import json import json
# 导入修改后的TTS模块
from . import tts
router = APIRouter() router = APIRouter()
active_connections: Set[WebSocket] = set() active_connections: Set[WebSocket] = set()
asr_client = AipSpeech(APP_ID, API_KEY, SECRET_KEY) asr_client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
async def asr_buffer(buffer_data: bytes) -> str: async def asr_buffer(buffer_data: bytes) -> str:
result = asr_client.asr(buffer_data, 'pcm', 16000, {'dev_pid': 1537}) result = asr_client.asr(buffer_data, 'pcm', 16000, {'dev_pid': 1537})
if result.get('err_msg') == 'success.': if result.get('err_msg') == 'success.':
@@ -16,6 +21,7 @@ async def asr_buffer(buffer_data: bytes) -> str:
else: else:
return '语音转换失败' return '语音转换失败'
async def broadcast_online_count(): async def broadcast_online_count():
data = {"online_count": len(active_connections), 'type': 'count'} data = {"online_count": len(active_connections), 'type': 'count'}
to_remove = set() to_remove = set()
@@ -27,12 +33,14 @@ async def broadcast_online_count():
for ws in to_remove: for ws in to_remove:
active_connections.remove(ws) active_connections.remove(ws)
@router.websocket("/websocket") @router.websocket("/websocket")
async def websocket_online_count(websocket: WebSocket): async def websocket_online_count(websocket: WebSocket):
await websocket.accept() await websocket.accept()
active_connections.add(websocket) active_connections.add(websocket)
await broadcast_online_count() await broadcast_online_count()
temp_buffer = bytes() temp_buffer = bytes()
try: try:
while True: while True:
message = await websocket.receive() message = await websocket.receive()
@@ -45,15 +53,59 @@ async def websocket_online_count(websocket: WebSocket):
except Exception: except Exception:
continue continue
msg_type = data.get("type") msg_type = data.get("type")
if msg_type == "ping": if msg_type == "ping":
await websocket.send_json({"online_count": len(active_connections), "type": "count"}) await websocket.send_json({"online_count": len(active_connections), "type": "count"})
elif msg_type == "asr_end": elif msg_type == "asr_end":
asr_text = await asr_buffer(temp_buffer) asr_text = await asr_buffer(temp_buffer)
await websocket.send_json({"type": "asr_result", "result": asr_text}) await websocket.send_json({"type": "asr_result", "result": asr_text})
temp_buffer = bytes() 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: except WebSocketDisconnect:
active_connections.remove(websocket) pass
await broadcast_online_count() except Exception as e:
except Exception: print(f"WebSocket异常: {e}")
active_connections.remove(websocket) finally:
# 清理资源
active_connections.discard(websocket)
# 清理所有TTS资源
try:
await tts.cleanup_connection_tts(websocket)
except:
pass
await broadcast_online_count() await broadcast_online_count()

View File

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

View File

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

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

8
web/.prettierrc.json Normal file
View File

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

7
web/components.d.ts vendored
View File

@@ -7,16 +7,23 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
Avatar: typeof import('./src/components/avatar.vue')['default']
Markdown: typeof import('./src/components/markdown.vue')['default'] Markdown: typeof import('./src/components/markdown.vue')['default']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect'] NSelect: typeof import('naive-ui')['NSelect']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
Tts: typeof import('./src/components/tts.vue')['default']
} }
} }

View File

@@ -1,39 +1,37 @@
import antfu from "@antfu/eslint-config" import antfu from "@antfu/eslint-config";
export default antfu( export default antfu({
{ formatters: {
formatters: { /**
/** * Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue * By default uses Prettier
* By default uses Prettier */
*/ css: true,
css: true, /**
/** * Format HTML files
* Format HTML files * By default uses Prettier
* By default uses Prettier */
*/ html: true,
html: true, /**
/** * Format Markdown files
* Format Markdown files * Supports Prettier and dprint
* Supports Prettier and dprint * By default uses Prettier
* By default uses Prettier */
*/ markdown: "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",
},
}, },
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"
}
});

View File

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

View File

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

56
web/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

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

View File

@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import hljs from "highlight.js" import hljs from "highlight.js";
import markdownit from "markdown-it" import markdownit from "markdown-it";
import { useWindowWidth } from "@/utils";
const { content } = defineProps<{ const { content } = defineProps<{
content: string content: string;
}>() }>();
const windowWidth = useWindowWidth();
const md = markdownit({ const md = markdownit({
html: true, html: true,
@@ -13,24 +16,34 @@ const md = markdownit({
highlight(str, lang) { highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
try { 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> </script>
<template> <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> </template>
<style scoped> <style scoped>
.markdown-body :deep(pre) { .markdown-body :deep(pre) {
width: var(--code-width, 100%);
max-width: 100%;
background: #fafafacc; background: #fafafacc;
border-radius: 6px; border-radius: 6px;
padding: 16px; padding: 16px;
@@ -38,4 +51,20 @@ const md = markdownit({
overflow-x: auto; overflow-x: auto;
margin: 8px 0; 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> </style>

View 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>

View File

@@ -1,24 +1,33 @@
export interface IChatWithLLMRequest { export interface IChatWithLLMRequest {
messages: Message[] messages: Message[];
/** /**
* 要使用的模型的 ID * 要使用的模型的 ID
*/ */
model: string model: string;
} }
export interface Message { export interface Message {
content?: string content?: string;
role?: string thinking?: string;
[property: string]: any role?: string;
usage?: UsageInfo;
id?: string;
[property: string]: any;
} }
export interface ModelInfo { export interface ModelInfo {
model_id: string model_id: string;
model_name: string model_name: string;
model_type: string model_type: string;
} }
export interface ModelListInfo { export interface ModelListInfo {
vendor: string vendor: string;
models: ModelInfo[] models: ModelInfo[];
}
export interface UsageInfo {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
} }

View File

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

View File

@@ -1,25 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { NImage } from "naive-ui"; import { ChevronLeftIcon } from "@/assets/Icons";
import logo from "@/assets/logo.png"; import logo from "@/assets/logo.png";
import { useChatStore } from "@/stores"; import { useChatStore, useLayoutStore } from "@/stores";
const chatStore = useChatStore(); const chatStore = useChatStore();
const { onlineCount } = storeToRefs(chatStore); const { onlineCount } = storeToRefs(chatStore);
const layoutStore = useLayoutStore();
const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
</script> </script>
<template> <template>
<div class="h-screen flex overflow-hidden"> <div class="relative h-screen flex overflow-hidden">
<div class="flex-none w-[200px] h-full flex flex-col"> <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="/"> <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>
<router-link <router-link
class="w-full h-[52px] px-8 flex items-center cursor-pointer" :class="$route.path === '/' class="w-full h-[52px] px-8 flex items-center cursor-pointer"
? [ :class="
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]', $route.path === '/'
] ? [
: [] 'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]'
" to="/" ]
: []
"
to="/"
> >
聊天 聊天
</router-link> </router-link>
@@ -32,7 +56,10 @@ const { onlineCount } = storeToRefs(chatStore);
</div> </div>
</div> </div>
</div> </div>
<div class="flex-1 relative"> <div
class="flex-1 relative"
:class="{ 'ml-[200px]': !hiddenLeftSidebar && !simpleMode }"
>
<RouterView /> <RouterView />
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -5,27 +5,33 @@ import { context } from "@/utils";
const BaseClientService = axios.create(); const BaseClientService = axios.create();
// 添加请求拦截器 // 添加请求拦截器
BaseClientService.interceptors.request.use((config) => { BaseClientService.interceptors.request.use(
// 在发送请求之前做些什么 (config) => {
return config; // 在发送请求之前做些什么
}, (e) => { return config;
// 对请求错误做些什么 },
return Promise.reject(e); (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);
} }
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 */ /** 基础URL */
export const BaseUrl = "/v1"; export const BaseUrl = "/v1";

View File

@@ -1,91 +1,140 @@
import type { AxiosRequestConfig } from "axios" import type { AxiosRequestConfig } from "axios";
import type { IChatWithLLMRequest } from "@/interfaces" import type { IChatWithLLMRequest, UsageInfo } from "@/interfaces";
import BaseClientService, { BaseUrl } from "./base_service.ts" import BaseClientService, { BaseUrl } from "./base_service.ts";
export class ChatService { export class ChatService {
public static basePath = BaseUrl public static basePath = BaseUrl;
/** Chat with LLM */ /** Chat with LLM */
public static async ChatWithLLM( public static async ChatWithLLM(
accessToken: string, accessToken: string,
request: IChatWithLLMRequest, request: IChatWithLLMRequest,
onProgress: (content: string) => void, onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => {},
getThinking: (thinkingContent: string) => void
) { ) {
let response let response;
let buffer = "" let buffer = "";
let accumulatedContent = "" let accumulatedContent = "";
let thinking = false;
let thinkingContent = "";
try { try {
response = await fetch("/v1/chat/completions", { response = await fetch("/v1/chat/completions", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
}, },
body: JSON.stringify(request), body: JSON.stringify(request)
}) });
if (!response.ok) { if (!response.ok) {
// eslint-disable-next-line unicorn/error-message // eslint-disable-next-line unicorn/error-message
throw new Error() throw new Error();
} }
const reader = response.body?.getReader() const reader = response.body?.getReader();
const decoder = new TextDecoder() const decoder = new TextDecoder();
while (true) { while (true) {
const { done, value } = await reader!.read() const { done, value } = await reader!.read();
if (done) if (done) break;
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) { for (const line of lines) {
const trimmedLine = line.trim() const trimmedLine = line.trim();
if (!trimmedLine) if (!trimmedLine) continue;
continue
if (trimmedLine.startsWith("data: ")) { if (trimmedLine.startsWith("data: ")) {
const jsonStr = trimmedLine.slice(6) const jsonStr = trimmedLine.slice(6);
// 处理结束标记 // 处理结束标记
if (jsonStr === "[DONE]") { if (jsonStr === "[DONE]") {
onProgress(accumulatedContent) // 最终更新 onProgress(accumulatedContent); // 最终更新
return return;
} }
try { try {
const data = JSON.parse(jsonStr) const data = JSON.parse(jsonStr);
// 处理内容
if (data.choices?.[0]?.delta?.content) { if (data.choices?.[0]?.delta?.content) {
// 累积内容 const content = data.choices[0].delta.content;
accumulatedContent += data.choices[0].delta.content
// 触发回调 if (content.includes("<think>")) {
onProgress(accumulatedContent) 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) {
catch (err) { console.error("Error:", err);
console.error("Error:", err)
} }
} }
// 获取模型列表 // 获取模型列表
public static GetModelList(config?: AxiosRequestConfig<any>) { public static GetModelList(config?: AxiosRequestConfig<any>) {
return BaseClientService.get(`${this.basePath}/model/list`, config) return BaseClientService.get(`${this.basePath}/model/list`, config);
} }
} }

View File

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

View File

@@ -1,28 +1,143 @@
import { useChatStore } from "@/stores"; import { useChatStore, useTtsStore } from "@/stores";
// WebSocket // WebSocket
export const useWebSocketStore = defineStore("websocket", () => { export const useWebSocketStore = defineStore("websocket", () => {
const websocket = ref<WebSocket>(); const websocket = ref<WebSocket>();
const connected = ref(false); const connected = ref(false);
const chatStore = useChatStore(); const chatStore = useChatStore();
const ttsStore = useTtsStore();
const { onlineCount } = storeToRefs(chatStore); const { onlineCount } = storeToRefs(chatStore);
const onmessage = (e: MessageEvent) => { const onmessage = (e: MessageEvent) => {
const data = JSON.parse(e.data); // 检查消息类型
switch (data.type) { if (e.data instanceof ArrayBuffer) {
case "count": // 处理二进制音频数据(兜底处理,新版本应该不会用到)
onlineCount.value = data.online_count; console.log("收到二进制音频数据,大小:", e.data.byteLength);
break; console.warn("收到旧格式的二进制数据无法确定messageId");
case "asr_result": // 可以选择忽略或者作为兜底处理
chatStore.addMessageToHistory(data.result); } 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) => { const send = (data: string) => {
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
websocket.value?.send(data); 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 = () => { const close = () => {
websocket.value?.close(); websocket.value?.close();
}; };
@@ -33,12 +148,15 @@ export const useWebSocketStore = defineStore("websocket", () => {
websocket.value.onopen = () => { websocket.value.onopen = () => {
connected.value = true; connected.value = true;
console.log("WebSocket连接成功");
let pingIntervalId: NodeJS.Timeout | undefined; let pingIntervalId: NodeJS.Timeout | undefined;
if (pingIntervalId) if (pingIntervalId) clearInterval(pingIntervalId);
clearInterval(pingIntervalId); pingIntervalId = setInterval(() => {
pingIntervalId = setInterval(() => send("ping"), 30 * 1000); // 修改ping格式为JSON格式与后端保持一致
send(JSON.stringify({ type: "ping" }));
}, 30 * 1000);
if (websocket.value) { if (websocket.value) {
websocket.value.onmessage = onmessage; websocket.value.onmessage = onmessage;
@@ -46,21 +164,29 @@ export const useWebSocketStore = defineStore("websocket", () => {
websocket.value.onerror = (e: Event) => { websocket.value.onerror = (e: Event) => {
console.error(`WebSocket错误:${(e as ErrorEvent).message}`); console.error(`WebSocket错误:${(e as ErrorEvent).message}`);
}; };
websocket.value.onclose = () => {
websocket.value.onclose = (e: CloseEvent) => {
connected.value = false; connected.value = false;
console.log(`WebSocket连接关闭: ${e.code} ${e.reason}`);
setTimeout(() => { setTimeout(() => {
console.log("尝试重新连接WebSocket...");
connect(); // 尝试重新连接 connect(); // 尝试重新连接
}, 1000); // 1秒后重试连接 }, 1000); // 1秒后重试连接
}; };
} }
}; };
websocket.value.onerror = (e: Event) => {
console.error("WebSocket连接错误:", e);
};
}; };
return { return {
websocket, websocket,
connected, connected,
send, send,
sendBinary,
close, close,
connect, connect
}; };
}); });

View File

@@ -23,8 +23,7 @@ export const useAsrStore = defineStore("asr", () => {
if (webSocketStore.connected) { if (webSocketStore.connected) {
if (typeof data === "string") { if (typeof data === "string") {
webSocketStore.send(data); webSocketStore.send(data);
} } else {
else {
webSocketStore.websocket?.send(data); webSocketStore.websocket?.send(data);
} }
} }
@@ -53,8 +52,7 @@ export const useAsrStore = defineStore("asr", () => {
* 开始录音 * 开始录音
*/ */
const startRecording = async () => { const startRecording = async () => {
if (isRecording.value) if (isRecording.value) return;
return;
messages.value = []; messages.value = [];
// 确保 WebSocket 已连接 // 确保 WebSocket 已连接
if (!webSocketStore.connected) { if (!webSocketStore.connected) {
@@ -62,8 +60,7 @@ export const useAsrStore = defineStore("asr", () => {
// 等待连接建立 // 等待连接建立
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const check = () => { const check = () => {
if (webSocketStore.connected) if (webSocketStore.connected) resolve();
resolve();
else setTimeout(check, 100); else setTimeout(check, 100);
}; };
check(); check();
@@ -73,11 +70,14 @@ export const useAsrStore = defineStore("asr", () => {
// 获取麦克风音频流 // 获取麦克风音频流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 创建音频上下文采样率16kHz // 创建音频上下文采样率16kHz
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({ audioContext = new (window.AudioContext ||
sampleRate: 16000, (window as any).webkitAudioContext)({
sampleRate: 16000
}); });
// 用Blob方式创建AudioWorklet模块的URL // 用Blob方式创建AudioWorklet模块的URL
const blob = new Blob([audioProcessorCode], { type: "application/javascript" }); const blob = new Blob([audioProcessorCode], {
type: "application/javascript"
});
const processorUrl = URL.createObjectURL(blob); const processorUrl = URL.createObjectURL(blob);
// 加载AudioWorklet模块 // 加载AudioWorklet模块
await audioContext.audioWorklet.addModule(processorUrl); await audioContext.audioWorklet.addModule(processorUrl);
@@ -89,7 +89,7 @@ export const useAsrStore = defineStore("asr", () => {
workletNode = new AudioWorkletNode(audioContext, "audio-processor", { workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
numberOfInputs: 1, numberOfInputs: 1,
numberOfOutputs: 1, numberOfOutputs: 1,
channelCount: 1, channelCount: 1
}); });
// 监听来自AudioWorklet的音频数据 // 监听来自AudioWorklet的音频数据
workletNode.port.onmessage = (event) => { workletNode.port.onmessage = (event) => {
@@ -104,8 +104,7 @@ export const useAsrStore = defineStore("asr", () => {
mediaStreamSource.connect(workletNode); mediaStreamSource.connect(workletNode);
workletNode.connect(audioContext.destination); workletNode.connect(audioContext.destination);
isRecording.value = true; isRecording.value = true;
} } catch (err) {
catch (err) {
// 麦克风权限失败或AudioWorklet加载失败 // 麦克风权限失败或AudioWorklet加载失败
console.error("需要麦克风权限才能录音", err); console.error("需要麦克风权限才能录音", err);
} }
@@ -115,8 +114,7 @@ export const useAsrStore = defineStore("asr", () => {
* 停止录音 * 停止录音
*/ */
const stopRecording = () => { const stopRecording = () => {
if (!isRecording.value) if (!isRecording.value) return;
return;
// 通知后端录音结束 // 通知后端录音结束
sendMessage(JSON.stringify({ type: "asr_end" })); sendMessage(JSON.stringify({ type: "asr_end" }));
@@ -124,7 +122,7 @@ export const useAsrStore = defineStore("asr", () => {
// 停止所有音轨 // 停止所有音轨
if (mediaStreamSource?.mediaStream) { if (mediaStreamSource?.mediaStream) {
const tracks = mediaStreamSource.mediaStream.getTracks(); 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, messages,
startRecording, startRecording,
stopRecording, stopRecording,
sendMessage, sendMessage
}; };
}); });

View File

@@ -1,35 +1,51 @@
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo } from "@/interfaces"; import type {
IChatWithLLMRequest,
ModelInfo,
ModelListInfo,
UsageInfo
} from "@/interfaces";
import { ChatService } from "@/services"; import { ChatService } from "@/services";
export const useChatStore = defineStore("chat", () => { export const useChatStore = defineStore("chat", () => {
const token = ("sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee"); const token = "sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee";
// 默认模型 // 默认模型
const modelInfo = ref<ModelInfo | null>(null); const modelInfo = ref<ModelInfo | null>(null);
// 历史消息 // 历史消息
const historyMessages = ref<IChatWithLLMRequest["messages"]>([]); const historyMessages = ref<IChatWithLLMRequest["messages"]>([]);
// 是否正在响应 // 是否正在响应
const completing = ref<boolean>(false); const completing = ref<boolean>(false);
// 是否正在思考
const thinking = ref<boolean>(false);
// 在线人数 // 在线人数
const onlineCount = ref<number>(0); const onlineCount = ref<number>(0);
// 与 LLM 聊天 // 与 LLM 聊天
const chatWithLLM = async ( const chatWithLLM = async (
request: IChatWithLLMRequest, request: IChatWithLLMRequest,
onProgress: (content: string) => void, // 接收进度回调 onProgress: (content: string) => void, // 接收内容进度回调
getUsageInfo: (object: UsageInfo) => void = () => {}, // 接收使用信息回调
getThinking: (thinkingContent: string) => void = () => {} // 接收思维链内容回调
) => { ) => {
if (completing.value) if (completing.value) throw new Error("正在响应中");
throw new Error("正在响应中");
completing.value = true; // 开始请求 completing.value = true; // 开始请求
try { try {
await ChatService.ChatWithLLM(token, request, (content) => { await ChatService.ChatWithLLM(
onProgress(content); token,
}); request,
} (content) => {
catch (error) { onProgress(content);
},
(object: UsageInfo) => {
getUsageInfo(object);
},
(thinkingContent: string) => {
getThinking(thinkingContent);
}
);
} catch (error) {
console.error("请求失败:", error); console.error("请求失败:", error);
} } finally {
finally {
completing.value = false; completing.value = false;
} }
}; };
@@ -37,12 +53,11 @@ export const useChatStore = defineStore("chat", () => {
// 添加消息到历史记录 // 添加消息到历史记录
const addMessageToHistory = (message: string) => { const addMessageToHistory = (message: string) => {
const content = message.trim(); const content = message.trim();
if (!content) if (!content) return;
return;
historyMessages.value.push({ historyMessages.value.push({
role: "user", role: "user",
content, content
}); });
}; };
@@ -51,30 +66,67 @@ export const useChatStore = defineStore("chat", () => {
historyMessages.value = []; historyMessages.value = [];
}; };
watch(historyMessages, (newVal) => { // 确保最后一条消息是助手消息,如果最后一条消息不是,就加一条空的占位,不然后面的思维链会丢失
// 当历史消息变化时,发送请求 const ensureAssistantMessage = () => {
if (newVal.length > 0) { if (
const lastMessage = newVal[newVal.length - 1]; historyMessages.value.length === 0 ||
if (lastMessage.role === "user" && modelInfo.value) { historyMessages.value[historyMessages.value.length - 1].role !==
chatWithLLM({ "assistant"
messages: newVal, ) {
model: modelInfo.value?.model_id, historyMessages.value.push({
}, (content) => { role: "assistant",
// 处理进度回调 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;
});
}
} }
}, { 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[]>([]); const modelList = ref<ModelListInfo[]>([]);
@@ -84,11 +136,22 @@ export const useChatStore = defineStore("chat", () => {
try { try {
const response = await ChatService.GetModelList(); const response = await ChatService.GetModelList();
modelList.value = response.data.data; modelList.value = response.data.data;
} } catch (error) {
catch (error) {
console.error("获取模型列表失败:", 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
};
}); });

View File

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

View File

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

302
web/src/stores/tts_store.ts Normal file
View 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
};
});

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,126 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectGroupOption, SelectOption } from "naive-ui"; 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 markdown from "@/components/markdown.vue";
import { useAsrStore, useChatStore } from "@/stores"; import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
const chatStore = useChatStore(); const chatStore = useChatStore();
const { historyMessages, completing, modelList, modelInfo, thinking } =
storeToRefs(chatStore);
const asrStore = useAsrStore(); const asrStore = useAsrStore();
const { historyMessages, completing, modelList, modelInfo } = storeToRefs(chatStore);
const { isRecording } = storeToRefs(asrStore); const { isRecording } = storeToRefs(asrStore);
const layoutStore = useLayoutStore();
const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
const inputData = ref(""); const inputData = ref("");
const scrollbarRef = ref<HTMLElement | null>(null);
const options = ref<Array<SelectGroupOption | SelectOption>>([]); 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 // 处理选中模型的 ID
const selectedModelId = computed({ const selectedModelId = computed({
get: () => modelInfo.value?.model_id ?? null, get: () => modelInfo.value?.model_id ?? null,
set: (id: string | null) => { set: (id: string | null) => {
for (const vendor of modelList.value) { 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) { if (found) {
modelInfo.value = found; modelInfo.value = found;
return; return;
} }
} }
modelInfo.value = null; modelInfo.value = null;
}, }
}); });
// 监听模型列表变化,更新选项 // 监听模型列表变化,更新选项
watch(() => modelList.value, (newVal) => { watch(
if (newVal) { () => modelList.value,
options.value = newVal.map(vendor => ({ (newVal) => {
type: "group", if (newVal) {
label: vendor.vendor, options.value = newVal.map((vendor) => ({
key: vendor.vendor, type: "group",
children: vendor.models.map(model => ({ label: vendor.vendor,
label: model.model_name, key: vendor.vendor,
value: model.model_id, children: vendor.models.map((model) => ({
type: model.model_type, label: model.model_name,
})), value: model.model_id,
})); type: model.model_type
}))
}));
if (newVal.length > 0 && newVal[0].models.length > 0) { if (newVal.length > 0 && newVal[0].models.length > 0) {
modelInfo.value = newVal[0].models[0]; modelInfo.value = newVal[0].models[0];
}
} }
console.log("Options updated:", options.value); },
} { immediate: true, deep: true }
}, { immediate: true, deep: true }); );
// 发送消息 // 发送消息
const handleSendMessage = () => { const handleSendMessage = () => {
if (inputData.value.trim() === "") if (inputData.value.trim() === "") return;
return;
chatStore.addMessageToHistory(inputData.value); chatStore.addMessageToHistory(inputData.value);
inputData.value = ""; inputData.value = "";
}; };
// 开关语音输入 // 开关语音输入
const toggleRecording = () => { const toggleRecording = throttle(() => {
if (isRecording.value) { if (isRecording.value) {
asrStore.stopRecording(); asrStore.stopRecording();
} } else {
else {
asrStore.startRecording(); asrStore.startRecording();
} }
}; }, 500);
watch(completing, (newVal) => {
if (newVal) {
nextTick(() => {
scrollbarRef.value?.scrollTo({ top: 99999, behavior: "smooth" });
});
}
});
onMounted(() => { onMounted(() => {
chatStore.getModelList(); chatStore.getModelList();
@@ -74,53 +128,155 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-8 !pr-4 h-full w-full flex flex-col gap-4 border-l-[24px] border-l-[#FAFAFA] text-base"> <div
<NScrollbar class="flex-1 pr-4"> 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"> <div class="flex items-start mb-4">
<span class="text-base w-14 min-w-14">助手</span> <span class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16">
<NTag type="success" class="text-base max-w-full !h-auto"> <avatar :avatar="AIAvatar" />
<span class="text-base">你好我是你的智能助手请问有什么可以帮助你的吗</span> </span>
</NTag> <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>
<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> <div
<span v-else class="text-base w-14 min-w-14">助手</span> v-for="(msg, idx) in historyMessages"
<NTag :type="msg.role === 'user' ? 'info' : 'success'" class="max-w-full !h-auto"> :key="idx"
<markdown :content="msg.content || ''" /> class="flex items-start mb-4"
</NTag> >
<!-- 头像 -->
<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> </div>
</NScrollbar> </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 justify-between items-center gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NSelect <NSelect
v-model:value="selectedModelId" label-field="label" value-field="value" children-field="children" v-model:value="selectedModelId"
filterable :options="options" label-field="label"
value-field="value"
children-field="children"
filterable
:options="options"
/> />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NPopconfirm <NPopconfirm
:positive-button-props="{ type: 'error' }" positive-text="清除" negative-text="取消" :positive-button-props="{ type: 'error' }"
@positive-click="chatStore.clearHistoryMessages" @negative-click="() => { }" positive-text="清除"
negative-text="取消"
@positive-click="chatStore.clearHistoryMessages"
@negative-click="() => {}"
> >
<template #icon> <template #icon>
<ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" /> <ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" />
</template> </template>
<template #trigger> <template #trigger>
<NButton :disabled="isRecording || completing" type="warning"> <NButton :disabled="isRecording || completing" type="warning">
清除历史 <template v-if="!simpleMode"> 清除历史 </template>
<TrashIcon class="!w-4 !h-4 ml-1" /> <TrashIcon
class="!w-4 !h-4"
:class="{
'ml-1': !simpleMode
}"
/>
</NButton> </NButton>
</template> </template>
<span>确定要清除历史消息吗?</span> <span>确定要清除历史消息吗?</span>
</NPopconfirm> </NPopconfirm>
<NButton :disabled="completing" @click="toggleRecording"> <NButton :disabled="completing" @click="toggleRecording">
{{ isRecording ? "停止输入" : "语音输入" }} <template v-if="!simpleMode">
<microphone class="!w-4 !h-4 ml-1" /> {{ isRecording ? "停止输入" : "语音输入" }}
</template>
<microphone
class="!w-4 !h-4"
:class="{
'ml-1': !simpleMode
}"
/>
</NButton> </NButton>
<NButton :disabled="isRecording" :loading="completing" @click="handleSendMessage"> <NButton
:disabled="isRecording"
:loading="completing"
@click="handleSendMessage"
>
发送 发送
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" /> <PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
</NButton> </NButton>

View File

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

View File

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