豆包模型的接入
豆包模型、通义千问模型配置化 界面对话框优化:区分不同角色、不同数据来源 对话框展示数据来源:数据来源统一取自ws(独立建设本服务ws默认8010),完整的一套ws数据响应收发,心跳机制。(app.py) 增加ws测试页面、增加豆包模型测试页面 页面sessionId获取机制修改 STUN服务器优化
Showing
13 changed files
with
3349 additions
and
149 deletions
| @@ -29,7 +29,7 @@ from threading import Thread,Event | @@ -29,7 +29,7 @@ from threading import Thread,Event | ||
| 29 | #import multiprocessing | 29 | #import multiprocessing |
| 30 | import torch.multiprocessing as mp | 30 | import torch.multiprocessing as mp |
| 31 | 31 | ||
| 32 | -from aiohttp import web | 32 | +from aiohttp import web, WSMsgType |
| 33 | import aiohttp | 33 | import aiohttp |
| 34 | import aiohttp_cors | 34 | import aiohttp_cors |
| 35 | from aiortc import RTCPeerConnection, RTCSessionDescription | 35 | from aiortc import RTCPeerConnection, RTCSessionDescription |
| @@ -45,11 +45,15 @@ import asyncio | @@ -45,11 +45,15 @@ import asyncio | ||
| 45 | import torch | 45 | import torch |
| 46 | from typing import Dict | 46 | from typing import Dict |
| 47 | from logger import logger | 47 | from logger import logger |
| 48 | +import gc | ||
| 49 | +import weakref | ||
| 50 | +import time | ||
| 48 | 51 | ||
| 49 | 52 | ||
| 50 | app = Flask(__name__) | 53 | app = Flask(__name__) |
| 51 | #sockets = Sockets(app) | 54 | #sockets = Sockets(app) |
| 52 | nerfreals:Dict[int, BaseReal] = {} #sessionid:BaseReal | 55 | nerfreals:Dict[int, BaseReal] = {} #sessionid:BaseReal |
| 56 | +websocket_connections:Dict[int, weakref.WeakSet] = {} #sessionid:websocket_connections | ||
| 53 | opt = None | 57 | opt = None |
| 54 | model = None | 58 | model = None |
| 55 | avatar = None | 59 | avatar = None |
| @@ -58,6 +62,108 @@ avatar = None | @@ -58,6 +62,108 @@ avatar = None | ||
| 58 | #####webrtc############################### | 62 | #####webrtc############################### |
| 59 | pcs = set() | 63 | pcs = set() |
| 60 | 64 | ||
| 65 | +# WebSocket消息推送函数 | ||
| 66 | +async def broadcast_message_to_session(sessionid: int, message_type: str, content: str, source: str = "数字人回复", model_info: str = None, request_source: str = "页面"): | ||
| 67 | + """向指定会话的所有WebSocket连接推送消息""" | ||
| 68 | + logger.info(f'[SessionID:{sessionid}] 开始推送消息: {message_type}, source: {source}, content: {content[:50]}...') | ||
| 69 | + logger.info(f'[SessionID:{sessionid}] 当前websocket_connections keys: {list(websocket_connections.keys())}') | ||
| 70 | + | ||
| 71 | + if sessionid not in websocket_connections: | ||
| 72 | + logger.warning(f'[SessionID:{sessionid}] 会话不存在于websocket_connections中') | ||
| 73 | + return | ||
| 74 | + | ||
| 75 | + logger.info(f'[SessionID:{sessionid}] 找到会话,连接数量: {len(websocket_connections[sessionid])}') | ||
| 76 | + | ||
| 77 | + message = { | ||
| 78 | + "type": "chat_message", | ||
| 79 | + "data": { | ||
| 80 | + "sessionid": sessionid, | ||
| 81 | + "message_type": message_type, | ||
| 82 | + "content": content, | ||
| 83 | + "source": source, | ||
| 84 | + "model_info": model_info, | ||
| 85 | + "request_source": request_source, | ||
| 86 | + "timestamp": time.time() | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + # 获取该会话的所有WebSocket连接 | ||
| 91 | + connections = list(websocket_connections[sessionid]) | ||
| 92 | + | ||
| 93 | + # 向所有连接发送消息 | ||
| 94 | + logger.info(f'[SessionID:{sessionid}] 准备向{len(connections)}个连接发送消息') | ||
| 95 | + for i, ws in enumerate(connections): | ||
| 96 | + try: | ||
| 97 | + logger.info(f'[SessionID:{sessionid}] 检查连接{i+1}: closed={ws.closed}') | ||
| 98 | + if not ws.closed: | ||
| 99 | + logger.info(f'[SessionID:{sessionid}] 向连接{i+1}发送消息: {json.dumps(message)}') | ||
| 100 | + await ws.send_str(json.dumps(message)) | ||
| 101 | + logger.info(f'[SessionID:{sessionid}] 连接{i+1}消息发送成功: {message_type} from {request_source}') | ||
| 102 | + else: | ||
| 103 | + logger.warning(f'[SessionID:{sessionid}] 连接{i+1}已关闭,跳过发送') | ||
| 104 | + except Exception as e: | ||
| 105 | + logger.error(f'[SessionID:{sessionid}] 连接{i+1}发送失败: {e}') | ||
| 106 | + | ||
| 107 | +# WebSocket处理器 | ||
| 108 | +async def websocket_handler(request): | ||
| 109 | + """处理WebSocket连接""" | ||
| 110 | + ws = web.WebSocketResponse() | ||
| 111 | + await ws.prepare(request) | ||
| 112 | + | ||
| 113 | + sessionid = None | ||
| 114 | + logger.info('New WebSocket connection established') | ||
| 115 | + | ||
| 116 | + try: | ||
| 117 | + async for msg in ws: | ||
| 118 | + if msg.type == WSMsgType.TEXT: | ||
| 119 | + try: | ||
| 120 | + data = json.loads(msg.data) | ||
| 121 | + | ||
| 122 | + if data.get('type') == 'login': | ||
| 123 | + sessionid = data.get('sessionid', 0) | ||
| 124 | + logger.info(f'[SessionID:{sessionid}] 收到登录请求,当前连接池: {list(websocket_connections.keys())}') | ||
| 125 | + | ||
| 126 | + # 初始化该会话的WebSocket连接集合 | ||
| 127 | + if sessionid not in websocket_connections: | ||
| 128 | + websocket_connections[sessionid] = weakref.WeakSet() | ||
| 129 | + logger.info(f'[SessionID:{sessionid}] 创建新的连接集合') | ||
| 130 | + | ||
| 131 | + # 添加当前连接到会话 | ||
| 132 | + websocket_connections[sessionid].add(ws) | ||
| 133 | + logger.info(f'[SessionID:{sessionid}] 连接已添加,当前会话连接数: {len(websocket_connections[sessionid])}') | ||
| 134 | + | ||
| 135 | + logger.info(f'[SessionID:{sessionid}] WebSocket client logged in') | ||
| 136 | + | ||
| 137 | + # 发送登录确认 | ||
| 138 | + await ws.send_str(json.dumps({ | ||
| 139 | + "type": "login_success", | ||
| 140 | + "sessionid": sessionid, | ||
| 141 | + "message": "WebSocket连接成功" | ||
| 142 | + })) | ||
| 143 | + | ||
| 144 | + elif data.get('type') == 'ping': | ||
| 145 | + # 心跳检测 | ||
| 146 | + await ws.send_str(json.dumps({"type": "pong"})) | ||
| 147 | + | ||
| 148 | + except json.JSONDecodeError: | ||
| 149 | + logger.error('Invalid JSON received from WebSocket') | ||
| 150 | + except Exception as e: | ||
| 151 | + logger.error(f'Error processing WebSocket message: {e}') | ||
| 152 | + | ||
| 153 | + elif msg.type == WSMsgType.ERROR: | ||
| 154 | + logger.error(f'WebSocket error: {ws.exception()}') | ||
| 155 | + break | ||
| 156 | + | ||
| 157 | + except Exception as e: | ||
| 158 | + logger.error(f'WebSocket connection error: {e}') | ||
| 159 | + finally: | ||
| 160 | + if sessionid is not None: | ||
| 161 | + logger.info(f'[SessionID:{sessionid}] WebSocket connection closed') | ||
| 162 | + else: | ||
| 163 | + logger.info('WebSocket connection closed') | ||
| 164 | + | ||
| 165 | + return ws | ||
| 166 | + | ||
| 61 | def randN(N)->int: | 167 | def randN(N)->int: |
| 62 | '''生成长度为 N的随机数 ''' | 168 | '''生成长度为 N的随机数 ''' |
| 63 | min = pow(10, N - 1) | 169 | min = pow(10, N - 1) |
| @@ -65,20 +171,48 @@ def randN(N)->int: | @@ -65,20 +171,48 @@ def randN(N)->int: | ||
| 65 | return random.randint(min, max - 1) | 171 | return random.randint(min, max - 1) |
| 66 | 172 | ||
| 67 | def build_nerfreal(sessionid:int)->BaseReal: | 173 | def build_nerfreal(sessionid:int)->BaseReal: |
| 174 | + import time | ||
| 175 | + import gc | ||
| 176 | + | ||
| 68 | opt.sessionid=sessionid | 177 | opt.sessionid=sessionid |
| 69 | - if opt.model == 'wav2lip': | ||
| 70 | - from lipreal import LipReal | ||
| 71 | - nerfreal = LipReal(opt,model,avatar) | ||
| 72 | - elif opt.model == 'musetalk': | ||
| 73 | - from musereal import MuseReal | ||
| 74 | - nerfreal = MuseReal(opt,model,avatar) | ||
| 75 | - elif opt.model == 'ernerf': | ||
| 76 | - from nerfreal import NeRFReal | ||
| 77 | - nerfreal = NeRFReal(opt,model,avatar) | ||
| 78 | - elif opt.model == 'ultralight': | ||
| 79 | - from lightreal import LightReal | ||
| 80 | - nerfreal = LightReal(opt,model,avatar) | ||
| 81 | - return nerfreal | 178 | + logger.info('[SessionID:%d] Building %s model instance' % (sessionid, opt.model)) |
| 179 | + | ||
| 180 | + try: | ||
| 181 | + model_start = time.time() | ||
| 182 | + | ||
| 183 | + if opt.model == 'wav2lip': | ||
| 184 | + logger.info('[SessionID:%d] Loading Wav2Lip model...' % sessionid) | ||
| 185 | + from lipreal import LipReal | ||
| 186 | + nerfreal = LipReal(opt,model,avatar) | ||
| 187 | + elif opt.model == 'musetalk': | ||
| 188 | + logger.info('[SessionID:%d] Loading MuseTalk model...' % sessionid) | ||
| 189 | + from musereal import MuseReal | ||
| 190 | + nerfreal = MuseReal(opt,model,avatar) | ||
| 191 | + elif opt.model == 'ernerf': | ||
| 192 | + logger.info('[SessionID:%d] Loading ERNeRF model...' % sessionid) | ||
| 193 | + from nerfreal import NeRFReal | ||
| 194 | + nerfreal = NeRFReal(opt,model,avatar) | ||
| 195 | + elif opt.model == 'ultralight': | ||
| 196 | + logger.info('[SessionID:%d] Loading UltraLight model...' % sessionid) | ||
| 197 | + from lightreal import LightReal | ||
| 198 | + nerfreal = LightReal(opt,model,avatar) | ||
| 199 | + else: | ||
| 200 | + raise ValueError(f"Unknown model type: {opt.model}") | ||
| 201 | + | ||
| 202 | + model_end = time.time() | ||
| 203 | + model_duration = model_end - model_start | ||
| 204 | + logger.info('[SessionID:%d] %s model loaded successfully in %.3f seconds' % (sessionid, opt.model, model_duration)) | ||
| 205 | + | ||
| 206 | + # 强制垃圾回收以释放内存 | ||
| 207 | + gc.collect() | ||
| 208 | + | ||
| 209 | + return nerfreal | ||
| 210 | + | ||
| 211 | + except Exception as e: | ||
| 212 | + logger.error('[SessionID:%d] Failed to build %s model: %s' % (sessionid, opt.model, str(e))) | ||
| 213 | + # 清理可能的部分初始化资源 | ||
| 214 | + gc.collect() | ||
| 215 | + raise e | ||
| 82 | 216 | ||
| 83 | #@app.route('/offer', methods=['POST']) | 217 | #@app.route('/offer', methods=['POST']) |
| 84 | async def offer(request): | 218 | async def offer(request): |
| @@ -87,41 +221,139 @@ async def offer(request): | @@ -87,41 +221,139 @@ async def offer(request): | ||
| 87 | 221 | ||
| 88 | if len(nerfreals) >= opt.max_session: | 222 | if len(nerfreals) >= opt.max_session: |
| 89 | logger.info('reach max session') | 223 | logger.info('reach max session') |
| 90 | - return -1 | 224 | + return web.Response( |
| 225 | + content_type="application/json", | ||
| 226 | + text=json.dumps( | ||
| 227 | + {"code": -1, "msg": "reach max session"} | ||
| 228 | + ), | ||
| 229 | + ) | ||
| 91 | sessionid = randN(6) #len(nerfreals) | 230 | sessionid = randN(6) #len(nerfreals) |
| 92 | - logger.info('sessionid=%d',sessionid) | 231 | + logger.info('[SessionID:%d] Starting session initialization',sessionid) |
| 93 | nerfreals[sessionid] = None | 232 | nerfreals[sessionid] = None |
| 233 | + | ||
| 234 | + # 记录模型初始化开始时间 | ||
| 235 | + import time | ||
| 236 | + model_init_start = time.time() | ||
| 237 | + logger.info('[SessionID:%d] Starting model initialization for %s' % (sessionid, opt.model)) | ||
| 238 | + | ||
| 94 | nerfreal = await asyncio.get_event_loop().run_in_executor(None, build_nerfreal,sessionid) | 239 | nerfreal = await asyncio.get_event_loop().run_in_executor(None, build_nerfreal,sessionid) |
| 240 | + | ||
| 241 | + # 记录模型初始化完成时间 | ||
| 242 | + model_init_end = time.time() | ||
| 243 | + init_duration = model_init_end - model_init_start | ||
| 244 | + logger.info('[SessionID:%d] Model initialization completed in %.3f seconds' % (sessionid, init_duration)) | ||
| 245 | + | ||
| 95 | nerfreals[sessionid] = nerfreal | 246 | nerfreals[sessionid] = nerfreal |
| 96 | 247 | ||
| 97 | pc = RTCPeerConnection() | 248 | pc = RTCPeerConnection() |
| 98 | pcs.add(pc) | 249 | pcs.add(pc) |
| 250 | + | ||
| 251 | + # 添加ICE连接状态监控 | ||
| 252 | + @pc.on("iceconnectionstatechange") | ||
| 253 | + async def on_iceconnectionstatechange(): | ||
| 254 | + import time | ||
| 255 | + timestamp = time.time() | ||
| 256 | + logger.info("[SessionID:%d] ICE connection state changed to %s at %.3f" % (sessionid, pc.iceConnectionState, timestamp)) | ||
| 257 | + | ||
| 258 | + if pc.iceConnectionState == "checking": | ||
| 259 | + logger.info("[SessionID:%d] ICE connectivity checks in progress..." % sessionid) | ||
| 260 | + elif pc.iceConnectionState == "connected": | ||
| 261 | + logger.info("[SessionID:%d] ICE connection established" % sessionid) | ||
| 262 | + elif pc.iceConnectionState == "completed": | ||
| 263 | + logger.info("[SessionID:%d] ICE connection completed" % sessionid) | ||
| 264 | + elif pc.iceConnectionState == "failed": | ||
| 265 | + logger.error("[SessionID:%d] ICE connection failed" % sessionid) | ||
| 266 | + elif pc.iceConnectionState == "disconnected": | ||
| 267 | + logger.warning("[SessionID:%d] ICE connection disconnected" % sessionid) | ||
| 268 | + | ||
| 269 | + # 添加ICE候选者收集状态监控 | ||
| 270 | + @pc.on("icegatheringstatechange") | ||
| 271 | + async def on_icegatheringstatechange(): | ||
| 272 | + import time | ||
| 273 | + timestamp = time.time() | ||
| 274 | + logger.info("[SessionID:%d] ICE gathering state changed to %s at %.3f" % (sessionid, pc.iceGatheringState, timestamp)) | ||
| 275 | + | ||
| 276 | + if pc.iceGatheringState == "gathering": | ||
| 277 | + logger.info("[SessionID:%d] ICE candidates gathering..." % sessionid) | ||
| 278 | + elif pc.iceGatheringState == "complete": | ||
| 279 | + logger.info("[SessionID:%d] ICE candidates gathering completed" % sessionid) | ||
| 99 | 280 | ||
| 100 | @pc.on("connectionstatechange") | 281 | @pc.on("connectionstatechange") |
| 101 | async def on_connectionstatechange(): | 282 | async def on_connectionstatechange(): |
| 102 | - logger.info("Connection state is %s" % pc.connectionState) | ||
| 103 | - if pc.connectionState == "failed": | 283 | + import time |
| 284 | + timestamp = time.time() | ||
| 285 | + logger.info("[SessionID:%d] Connection state changed to %s at %.3f" % (sessionid, pc.connectionState, timestamp)) | ||
| 286 | + | ||
| 287 | + if pc.connectionState == "connecting": | ||
| 288 | + logger.info("[SessionID:%d] WebRTC connection establishing..." % sessionid) | ||
| 289 | + elif pc.connectionState == "connected": | ||
| 290 | + logger.info("[SessionID:%d] WebRTC connection established successfully" % sessionid) | ||
| 291 | + elif pc.connectionState == "failed": | ||
| 292 | + logger.error("[SessionID:%d] WebRTC connection failed" % sessionid) | ||
| 104 | await pc.close() | 293 | await pc.close() |
| 105 | pcs.discard(pc) | 294 | pcs.discard(pc) |
| 106 | - del nerfreals[sessionid] | ||
| 107 | - if pc.connectionState == "closed": | 295 | + if sessionid in nerfreals: |
| 296 | + del nerfreals[sessionid] | ||
| 297 | + elif pc.connectionState == "closed": | ||
| 298 | + logger.info("[SessionID:%d] WebRTC connection closed" % sessionid) | ||
| 108 | pcs.discard(pc) | 299 | pcs.discard(pc) |
| 109 | - del nerfreals[sessionid] | 300 | + if sessionid in nerfreals: |
| 301 | + del nerfreals[sessionid] | ||
| 302 | + gc.collect() | ||
| 110 | 303 | ||
| 304 | + # 记录音视频轨道初始化开始时间 | ||
| 305 | + track_init_start = time.time() | ||
| 306 | + logger.info('[SessionID:%d] Initializing audio/video tracks' % sessionid) | ||
| 307 | + | ||
| 111 | player = HumanPlayer(nerfreals[sessionid]) | 308 | player = HumanPlayer(nerfreals[sessionid]) |
| 309 | + logger.info('[SessionID:%d] HumanPlayer created' % sessionid) | ||
| 310 | + | ||
| 112 | audio_sender = pc.addTrack(player.audio) | 311 | audio_sender = pc.addTrack(player.audio) |
| 312 | + logger.info('[SessionID:%d] Audio track added' % sessionid) | ||
| 313 | + | ||
| 113 | video_sender = pc.addTrack(player.video) | 314 | video_sender = pc.addTrack(player.video) |
| 315 | + logger.info('[SessionID:%d] Video track added' % sessionid) | ||
| 316 | + | ||
| 317 | + # 记录音视频轨道初始化完成时间 | ||
| 318 | + track_init_end = time.time() | ||
| 319 | + track_duration = track_init_end - track_init_start | ||
| 320 | + logger.info('[SessionID:%d] Audio/video tracks initialized in %.3f seconds' % (sessionid, track_duration)) | ||
| 321 | + # 记录编解码器配置开始时间 | ||
| 322 | + codec_start = time.time() | ||
| 323 | + logger.info('[SessionID:%d] Configuring video codecs' % sessionid) | ||
| 324 | + | ||
| 114 | capabilities = RTCRtpSender.getCapabilities("video") | 325 | capabilities = RTCRtpSender.getCapabilities("video") |
| 115 | preferences = list(filter(lambda x: x.name == "H264", capabilities.codecs)) | 326 | preferences = list(filter(lambda x: x.name == "H264", capabilities.codecs)) |
| 116 | preferences += list(filter(lambda x: x.name == "VP8", capabilities.codecs)) | 327 | preferences += list(filter(lambda x: x.name == "VP8", capabilities.codecs)) |
| 117 | preferences += list(filter(lambda x: x.name == "rtx", capabilities.codecs)) | 328 | preferences += list(filter(lambda x: x.name == "rtx", capabilities.codecs)) |
| 329 | + | ||
| 330 | + logger.info('[SessionID:%d] Available codecs: %s' % (sessionid, [codec.name for codec in preferences])) | ||
| 331 | + | ||
| 118 | transceiver = pc.getTransceivers()[1] | 332 | transceiver = pc.getTransceivers()[1] |
| 119 | transceiver.setCodecPreferences(preferences) | 333 | transceiver.setCodecPreferences(preferences) |
| 120 | - | 334 | + |
| 335 | + # 记录编解码器配置完成时间 | ||
| 336 | + codec_end = time.time() | ||
| 337 | + codec_duration = codec_end - codec_start | ||
| 338 | + logger.info('[SessionID:%d] Video codecs configured in %.3f seconds' % (sessionid, codec_duration)) | ||
| 339 | + | ||
| 340 | + # 记录SDP协商开始时间 | ||
| 341 | + import time | ||
| 342 | + sdp_start = time.time() | ||
| 343 | + logger.info('[SessionID:%d] Starting SDP negotiation' % sessionid) | ||
| 344 | + | ||
| 121 | await pc.setRemoteDescription(offer) | 345 | await pc.setRemoteDescription(offer) |
| 346 | + logger.info('[SessionID:%d] Remote description set' % sessionid) | ||
| 122 | 347 | ||
| 123 | answer = await pc.createAnswer() | 348 | answer = await pc.createAnswer() |
| 349 | + logger.info('[SessionID:%d] Answer created' % sessionid) | ||
| 350 | + | ||
| 124 | await pc.setLocalDescription(answer) | 351 | await pc.setLocalDescription(answer) |
| 352 | + | ||
| 353 | + # 记录SDP协商完成时间 | ||
| 354 | + sdp_end = time.time() | ||
| 355 | + sdp_duration = sdp_end - sdp_start | ||
| 356 | + logger.info('[SessionID:%d] SDP negotiation completed in %.3f seconds' % (sessionid, sdp_duration)) | ||
| 125 | 357 | ||
| 126 | #return jsonify({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}) | 358 | #return jsonify({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}) |
| 127 | 359 | ||
| @@ -133,24 +365,95 @@ async def offer(request): | @@ -133,24 +365,95 @@ async def offer(request): | ||
| 133 | ) | 365 | ) |
| 134 | 366 | ||
| 135 | async def human(request): | 367 | async def human(request): |
| 136 | - params = await request.json() | 368 | + try: |
| 369 | + params = await request.json() | ||
| 370 | + sessionid = params.get('sessionid',0) | ||
| 371 | + user_message = params.get('text', '') | ||
| 372 | + message_type = params.get('type', 'echo') | ||
| 373 | + | ||
| 374 | + # 检测请求来源(通过User-Agent或自定义头部) | ||
| 375 | + user_agent = request.headers.get('User-Agent', '') | ||
| 376 | + request_source = "第三方服务" if 'python' in user_agent.lower() or 'curl' in user_agent.lower() or 'postman' in user_agent.lower() else "页面" | ||
| 377 | + | ||
| 378 | + # 如果有自定义来源标识,优先使用 | ||
| 379 | + if 'X-Request-Source' in request.headers: | ||
| 380 | + request_source = request.headers['X-Request-Source'] | ||
| 381 | + | ||
| 382 | + if params.get('interrupt'): | ||
| 383 | + nerfreals[sessionid].flush_talk() | ||
| 384 | + | ||
| 385 | + # 推送用户消息到WebSocket(统一推送所有用户输入) | ||
| 386 | + await broadcast_message_to_session(sessionid, message_type, user_message, "用户", None, request_source) | ||
| 387 | + | ||
| 388 | + ai_response = None | ||
| 389 | + model_info = None | ||
| 390 | + | ||
| 391 | + if message_type == 'echo': | ||
| 392 | + nerfreals[sessionid].put_msg_txt(user_message) | ||
| 393 | + ai_response = user_message | ||
| 394 | + model_info = "Echo模式" | ||
| 395 | + # 推送回音消息到WebSocket | ||
| 396 | + await broadcast_message_to_session(sessionid, 'echo', user_message, "回音", model_info, request_source) | ||
| 397 | + | ||
| 398 | + elif message_type == 'chat': | ||
| 399 | + # 获取当前使用的大模型信息 | ||
| 400 | + model_info = getattr(nerfreals[sessionid], 'llm_model_name', 'Unknown LLM') | ||
| 401 | + if hasattr(nerfreals[sessionid], 'llm') and hasattr(nerfreals[sessionid].llm, 'model_name'): | ||
| 402 | + model_info = nerfreals[sessionid].llm.model_name | ||
| 403 | + | ||
| 404 | + ai_response = await asyncio.get_event_loop().run_in_executor(None, llm_response, user_message, nerfreals[sessionid]) | ||
| 405 | + # 推送AI回复到WebSocket(包含大模型信息) | ||
| 406 | + await broadcast_message_to_session(sessionid, 'chat', ai_response, "AI助手", model_info, request_source) | ||
| 407 | + # 注释掉的代码保持不变,因为数字人回复通过其他方式处理 | ||
| 408 | + #nerfreals[sessionid].put_msg_txt(ai_response) | ||
| 409 | + | ||
| 410 | + # 只返回简单的处理状态,所有数据通过WebSocket推送 | ||
| 411 | + return web.Response( | ||
| 412 | + content_type="application/json", | ||
| 413 | + text=json.dumps({ | ||
| 414 | + "code": 0, | ||
| 415 | + "message": "消息已处理并推送" | ||
| 416 | + }), | ||
| 417 | + ) | ||
| 418 | + except Exception as e: | ||
| 419 | + error_msg = str(e) | ||
| 420 | + logger.exception('exception:') | ||
| 421 | + | ||
| 422 | + # 推送错误消息到WebSocket | ||
| 423 | + try: | ||
| 424 | + sessionid = params.get('sessionid', 0) if 'params' in locals() else 0 | ||
| 425 | + request_source = "页面" # 默认来源 | ||
| 426 | + await broadcast_message_to_session(sessionid, 'error', f"处理消息时发生错误: {error_msg}", "系统错误", "Error", request_source) | ||
| 427 | + except: | ||
| 428 | + pass # 如果WebSocket推送也失败,不影响HTTP响应 | ||
| 429 | + | ||
| 430 | + return web.Response( | ||
| 431 | + content_type="application/json", | ||
| 432 | + text=json.dumps( | ||
| 433 | + {"code": -1, "msg": error_msg, "error_details": error_msg} | ||
| 434 | + ), | ||
| 435 | + ) | ||
| 436 | +async def interrupt_talk(request): | ||
| 437 | + try: | ||
| 438 | + params = await request.json() | ||
| 137 | 439 | ||
| 138 | - sessionid = params.get('sessionid',0) | ||
| 139 | - if params.get('interrupt'): | 440 | + sessionid = params.get('sessionid',0) |
| 140 | nerfreals[sessionid].flush_talk() | 441 | nerfreals[sessionid].flush_talk() |
| 141 | - | ||
| 142 | - if params['type']=='echo': | ||
| 143 | - nerfreals[sessionid].put_msg_txt(params['text']) | ||
| 144 | - elif params['type']=='chat': | ||
| 145 | - res=await asyncio.get_event_loop().run_in_executor(None, llm_response, params['text'],nerfreals[sessionid]) | ||
| 146 | - #nerfreals[sessionid].put_msg_txt(res) | ||
| 147 | - | ||
| 148 | - return web.Response( | ||
| 149 | - content_type="application/json", | ||
| 150 | - text=json.dumps( | ||
| 151 | - {"code": 0, "data":"ok"} | ||
| 152 | - ), | ||
| 153 | - ) | 442 | + |
| 443 | + return web.Response( | ||
| 444 | + content_type="application/json", | ||
| 445 | + text=json.dumps( | ||
| 446 | + {"code": 0, "msg":"ok"} | ||
| 447 | + ), | ||
| 448 | + ) | ||
| 449 | + except Exception as e: | ||
| 450 | + logger.exception('exception:') | ||
| 451 | + return web.Response( | ||
| 452 | + content_type="application/json", | ||
| 453 | + text=json.dumps( | ||
| 454 | + {"code": -1, "msg": str(e)} | ||
| 455 | + ), | ||
| 456 | + ) | ||
| 154 | from pydub import AudioSegment | 457 | from pydub import AudioSegment |
| 155 | from io import BytesIO | 458 | from io import BytesIO |
| 156 | async def humanaudio(request): | 459 | async def humanaudio(request): |
| @@ -191,40 +494,57 @@ async def humanaudio(request): | @@ -191,40 +494,57 @@ async def humanaudio(request): | ||
| 191 | ) | 494 | ) |
| 192 | 495 | ||
| 193 | except Exception as e: | 496 | except Exception as e: |
| 497 | + logger.exception('exception:') | ||
| 194 | return web.Response( | 498 | return web.Response( |
| 195 | content_type="application/json", | 499 | content_type="application/json", |
| 196 | - text=json.dumps({"code": -1, "msg": "err", "data": str(e)}) | 500 | + text=json.dumps( {"code": -1, "msg": str(e)}) |
| 197 | ) | 501 | ) |
| 198 | 502 | ||
| 199 | async def set_audiotype(request): | 503 | async def set_audiotype(request): |
| 200 | - params = await request.json() | ||
| 201 | - | ||
| 202 | - sessionid = params.get('sessionid',0) | ||
| 203 | - nerfreals[sessionid].set_custom_state(params['audiotype'],params['reinit']) | 504 | + try: |
| 505 | + params = await request.json() | ||
| 204 | 506 | ||
| 205 | - return web.Response( | ||
| 206 | - content_type="application/json", | ||
| 207 | - text=json.dumps( | ||
| 208 | - {"code": 0, "data":"ok"} | ||
| 209 | - ), | ||
| 210 | - ) | 507 | + sessionid = params.get('sessionid',0) |
| 508 | + nerfreals[sessionid].set_custom_state(params['audiotype'],params['reinit']) | ||
| 211 | 509 | ||
| 510 | + return web.Response( | ||
| 511 | + content_type="application/json", | ||
| 512 | + text=json.dumps( | ||
| 513 | + {"code": 0, "data":"ok"} | ||
| 514 | + ), | ||
| 515 | + ) | ||
| 516 | + except Exception as e: | ||
| 517 | + logger.exception('exception:') | ||
| 518 | + return web.Response( | ||
| 519 | + content_type="application/json", | ||
| 520 | + text=json.dumps( | ||
| 521 | + {"code": -1, "msg": str(e)} | ||
| 522 | + ), | ||
| 523 | + ) | ||
| 212 | async def record(request): | 524 | async def record(request): |
| 213 | - params = await request.json() | ||
| 214 | - | ||
| 215 | - sessionid = params.get('sessionid',0) | ||
| 216 | - if params['type']=='start_record': | ||
| 217 | - # nerfreals[sessionid].put_msg_txt(params['text']) | ||
| 218 | - nerfreals[sessionid].start_recording() | ||
| 219 | - elif params['type']=='end_record': | ||
| 220 | - nerfreals[sessionid].stop_recording() | ||
| 221 | - return web.Response( | ||
| 222 | - content_type="application/json", | ||
| 223 | - text=json.dumps( | ||
| 224 | - {"code": 0, "data":"ok"} | ||
| 225 | - ), | ||
| 226 | - ) | 525 | + try: |
| 526 | + params = await request.json() | ||
| 227 | 527 | ||
| 528 | + sessionid = params.get('sessionid',0) | ||
| 529 | + if params['type']=='start_record': | ||
| 530 | + # nerfreals[sessionid].put_msg_txt(params['text']) | ||
| 531 | + nerfreals[sessionid].start_recording() | ||
| 532 | + elif params['type']=='end_record': | ||
| 533 | + nerfreals[sessionid].stop_recording() | ||
| 534 | + return web.Response( | ||
| 535 | + content_type="application/json", | ||
| 536 | + text=json.dumps( | ||
| 537 | + {"code": 0, "data":"ok"} | ||
| 538 | + ), | ||
| 539 | + ) | ||
| 540 | + except Exception as e: | ||
| 541 | + logger.exception('exception:') | ||
| 542 | + return web.Response( | ||
| 543 | + content_type="application/json", | ||
| 544 | + text=json.dumps( | ||
| 545 | + {"code": -1, "msg": str(e)} | ||
| 546 | + ), | ||
| 547 | + ) | ||
| 228 | async def is_speaking(request): | 548 | async def is_speaking(request): |
| 229 | params = await request.json() | 549 | params = await request.json() |
| 230 | 550 | ||
| @@ -474,7 +794,9 @@ if __name__ == '__main__': | @@ -474,7 +794,9 @@ if __name__ == '__main__': | ||
| 474 | appasync.router.add_post("/humanaudio", humanaudio) | 794 | appasync.router.add_post("/humanaudio", humanaudio) |
| 475 | appasync.router.add_post("/set_audiotype", set_audiotype) | 795 | appasync.router.add_post("/set_audiotype", set_audiotype) |
| 476 | appasync.router.add_post("/record", record) | 796 | appasync.router.add_post("/record", record) |
| 797 | + appasync.router.add_post("/interrupt_talk", interrupt_talk) | ||
| 477 | appasync.router.add_post("/is_speaking", is_speaking) | 798 | appasync.router.add_post("/is_speaking", is_speaking) |
| 799 | + appasync.router.add_get("/ws", websocket_handler) | ||
| 478 | appasync.router.add_static('/',path='web') | 800 | appasync.router.add_static('/',path='web') |
| 479 | 801 | ||
| 480 | # Configure default CORS settings. | 802 | # Configure default CORS settings. |
config/doubao_config.json
0 → 100644
| 1 | +{ | ||
| 2 | + "api_key": "0d885904-1636-4b7e-9ebb-45ec19a66899", | ||
| 3 | + "base_url": "https://ark.cn-beijing.volces.com/api/v3", | ||
| 4 | + "model": "ep-20250214233333-sp8z4", | ||
| 5 | + "stream": true, | ||
| 6 | + "max_tokens": 1024, | ||
| 7 | + "temperature": 0.7, | ||
| 8 | + "top_p": 0.9, | ||
| 9 | + "character": { | ||
| 10 | + "name": "小艺", | ||
| 11 | + "base_prompt": "你是小艺,是由艺云展陈开发的AI语音聊天机器人", | ||
| 12 | + "personality": "友善、耐心、专业,具有亲和力", | ||
| 13 | + "background": "专门为数字人交互场景设计的AI助手,擅长自然对话和情感交流", | ||
| 14 | + "speaking_style": "回答风格精简、自然,语言亲切易懂,避免过于正式或冗长", | ||
| 15 | + "constraints": "保持积极正面的态度,不讨论敏感话题,专注于为用户提供有价值的帮助" | ||
| 16 | + }, | ||
| 17 | + "response_config": { | ||
| 18 | + "enable_emotion": true, | ||
| 19 | + "max_response_length": 200, | ||
| 20 | + "break_sentences": true, | ||
| 21 | + "sentence_delimiters": ",.!;:,。!?:;", | ||
| 22 | + "min_chunk_length": 10 | ||
| 23 | + }, | ||
| 24 | + "advanced_settings": { | ||
| 25 | + "retry_times": 3, | ||
| 26 | + "timeout": 30, | ||
| 27 | + "enable_context_memory": true, | ||
| 28 | + "max_context_turns": 10 | ||
| 29 | + } | ||
| 30 | +} |
config/llm_config.json
0 → 100644
| 1 | +{ | ||
| 2 | + "model_type": "doubao", | ||
| 3 | + "description": "LLM模型配置文件 - 支持qwen和doubao模型切换", | ||
| 4 | + "models": { | ||
| 5 | + "qwen": { | ||
| 6 | + "name": "通义千问", | ||
| 7 | + "api_key_env": "DASHSCOPE_API_KEY", | ||
| 8 | + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", | ||
| 9 | + "model": "qwen-plus", | ||
| 10 | + "system_prompt": "你是小艺,是由艺云展陈开发的AI语音聊天机器人,回答风格精简。" | ||
| 11 | + }, | ||
| 12 | + "doubao": { | ||
| 13 | + "name": "豆包大模型", | ||
| 14 | + "config_file": "config/doubao_config.json", | ||
| 15 | + "description": "使用豆包模型进行对话,支持丰富的人物设定和参数配置" | ||
| 16 | + } | ||
| 17 | + }, | ||
| 18 | + "settings": { | ||
| 19 | + "stream": true, | ||
| 20 | + "sentence_split_chars": ",.!;:,。!?:;", | ||
| 21 | + "min_sentence_length": 10, | ||
| 22 | + "log_performance": true | ||
| 23 | + } | ||
| 24 | +} |
| 1 | import time | 1 | import time |
| 2 | import os | 2 | import os |
| 3 | +import time | ||
| 4 | +import json | ||
| 3 | from basereal import BaseReal | 5 | from basereal import BaseReal |
| 4 | from logger import logger | 6 | from logger import logger |
| 5 | 7 | ||
| 6 | -def llm_response(message,nerfreal:BaseReal): | 8 | +def llm_response(message, nerfreal: BaseReal): |
| 9 | + """LLM响应函数,支持多种模型配置""" | ||
| 7 | start = time.perf_counter() | 10 | start = time.perf_counter() |
| 11 | + | ||
| 12 | + # 加载LLM配置 | ||
| 13 | + llm_config = _load_llm_config() | ||
| 14 | + model_type = llm_config.get("model_type", "qwen") # 默认使用通义千问 | ||
| 15 | + | ||
| 16 | + logger.info(f"使用LLM模型: {model_type}") | ||
| 17 | + | ||
| 18 | + try: | ||
| 19 | + if model_type == "doubao": | ||
| 20 | + return _handle_doubao_response(message, nerfreal, start) | ||
| 21 | + elif model_type == "qwen": | ||
| 22 | + return _handle_qwen_response(message, nerfreal, start) | ||
| 23 | + else: | ||
| 24 | + logger.error(f"不支持的模型类型: {model_type}") | ||
| 25 | + nerfreal.put_msg_txt("抱歉,当前模型配置有误,请检查配置文件。") | ||
| 26 | + | ||
| 27 | + except Exception as e: | ||
| 28 | + logger.error(f"LLM响应处理异常: {e}") | ||
| 29 | + nerfreal.put_msg_txt("抱歉,我现在无法回答您的问题,请稍后再试。") | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def _load_llm_config(): | ||
| 33 | + """加载LLM配置文件""" | ||
| 34 | + config_path = "config/llm_config.json" | ||
| 35 | + try: | ||
| 36 | + with open(config_path, 'r', encoding='utf-8') as f: | ||
| 37 | + return json.load(f) | ||
| 38 | + except FileNotFoundError: | ||
| 39 | + logger.warning(f"LLM配置文件 {config_path} 不存在,使用默认配置") | ||
| 40 | + return {"model_type": "qwen"} | ||
| 41 | + except json.JSONDecodeError as e: | ||
| 42 | + logger.error(f"LLM配置文件格式错误: {e}") | ||
| 43 | + return {"model_type": "qwen"} | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +def _handle_doubao_response(message, nerfreal, start_time): | ||
| 47 | + """处理豆包模型响应""" | ||
| 48 | + try: | ||
| 49 | + from llm.Doubao import Doubao | ||
| 50 | + | ||
| 51 | + doubao = Doubao() | ||
| 52 | + end = time.perf_counter() | ||
| 53 | + logger.info(f"豆包模型初始化时间: {end-start_time:.3f}s") | ||
| 54 | + | ||
| 55 | + result = "" | ||
| 56 | + first = True | ||
| 57 | + | ||
| 58 | + def token_callback(content): | ||
| 59 | + nonlocal result, first | ||
| 60 | + if first: | ||
| 61 | + end = time.perf_counter() | ||
| 62 | + logger.info(f"豆包首个token时间: {end-start_time:.3f}s") | ||
| 63 | + first = False | ||
| 64 | + | ||
| 65 | + # 处理分句逻辑 | ||
| 66 | + lastpos = 0 | ||
| 67 | + for i, char in enumerate(content): | ||
| 68 | + if char in ",.!;:,。!?:;": | ||
| 69 | + result = result + content[lastpos:i+1] | ||
| 70 | + lastpos = i+1 | ||
| 71 | + if len(result) > 10: | ||
| 72 | + logger.info(f"豆包分句输出: {result}") | ||
| 73 | + nerfreal.put_msg_txt(result) | ||
| 74 | + result = "" | ||
| 75 | + result = result + content[lastpos:] | ||
| 76 | + | ||
| 77 | + # 使用流式响应 | ||
| 78 | + full_response = doubao.chat_stream(message, callback=token_callback) | ||
| 79 | + | ||
| 80 | + # 输出剩余内容 | ||
| 81 | + if result: | ||
| 82 | + logger.info(f"豆包最终输出: {result}") | ||
| 83 | + nerfreal.put_msg_txt(result) | ||
| 84 | + | ||
| 85 | + end = time.perf_counter() | ||
| 86 | + logger.info(f"豆包总响应时间: {end-start_time:.3f}s") | ||
| 87 | + | ||
| 88 | + return full_response | ||
| 89 | + | ||
| 90 | + except ImportError: | ||
| 91 | + logger.error("豆包模块导入失败,请检查Doubao.py文件") | ||
| 92 | + nerfreal.put_msg_txt("抱歉,豆包模型暂时不可用。") | ||
| 93 | + except Exception as e: | ||
| 94 | + logger.error(f"豆包模型处理异常: {e}") | ||
| 95 | + nerfreal.put_msg_txt("抱歉,豆包模型处理出现问题。") | ||
| 96 | + | ||
| 97 | + | ||
| 98 | +def _handle_qwen_response(message, nerfreal, start_time): | ||
| 99 | + """处理通义千问模型响应(保持原有逻辑)""" | ||
| 8 | from openai import OpenAI | 100 | from openai import OpenAI |
| 101 | + | ||
| 9 | client = OpenAI( | 102 | client = OpenAI( |
| 10 | - # 如果您没有配置环境变量,请在此处用您的API Key进行替换 | ||
| 11 | api_key=os.getenv("DASHSCOPE_API_KEY"), | 103 | api_key=os.getenv("DASHSCOPE_API_KEY"), |
| 12 | - #api_key = "localkey", | ||
| 13 | - #base_url="http://127.0.0.1:5000/v1" | ||
| 14 | - # 填写DashScope SDK的base_url | ||
| 15 | base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", | 104 | base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", |
| 16 | ) | 105 | ) |
| 17 | end = time.perf_counter() | 106 | end = time.perf_counter() |
| 18 | - logger.info(f"llm Time init: {end-start}s") | 107 | + logger.info(f"通义千问初始化时间: {end-start_time:.3f}s") |
| 108 | + | ||
| 19 | completion = client.chat.completions.create( | 109 | completion = client.chat.completions.create( |
| 20 | - # model="fay-streaming", | ||
| 21 | model="qwen-plus", | 110 | model="qwen-plus", |
| 22 | messages=[{'role': 'system', 'content': '你是小艺,是由艺云展陈开发的AI语音聊天机器人,回答风格精简。'}, | 111 | messages=[{'role': 'system', 'content': '你是小艺,是由艺云展陈开发的AI语音聊天机器人,回答风格精简。'}, |
| 23 | {'role': 'user', 'content': message}], | 112 | {'role': 'user', 'content': message}], |
| 24 | stream=True, | 113 | stream=True, |
| 25 | - # 通过以下设置,在流式输出的最后一行展示token使用信息 | ||
| 26 | stream_options={"include_usage": True} | 114 | stream_options={"include_usage": True} |
| 27 | ) | 115 | ) |
| 28 | - result="" | 116 | + |
| 117 | + result = "" | ||
| 29 | first = True | 118 | first = True |
| 30 | for chunk in completion: | 119 | for chunk in completion: |
| 31 | - if len(chunk.choices)>0: | ||
| 32 | - #print(chunk.choices[0].delta.content) | 120 | + if len(chunk.choices) > 0: |
| 33 | if first: | 121 | if first: |
| 34 | end = time.perf_counter() | 122 | end = time.perf_counter() |
| 35 | - logger.info(f"llm Time to first chunk: {end-start}s") | 123 | + logger.info(f"通义千问首个token时间: {end-start_time:.3f}s") |
| 36 | first = False | 124 | first = False |
| 125 | + | ||
| 37 | msg = chunk.choices[0].delta.content | 126 | msg = chunk.choices[0].delta.content |
| 38 | - lastpos=0 | ||
| 39 | - #msglist = re.split('[,.!;:,。!?]',msg) | ||
| 40 | - for i, char in enumerate(msg): | ||
| 41 | - if char in ",.!;:,。!?:;" : | ||
| 42 | - result = result+msg[lastpos:i+1] | ||
| 43 | - lastpos = i+1 | ||
| 44 | - if len(result)>10: | ||
| 45 | - logger.info(result) | ||
| 46 | - nerfreal.put_msg_txt(result) | ||
| 47 | - result="" | ||
| 48 | - result = result+msg[lastpos:] | 127 | + if msg: |
| 128 | + lastpos = 0 | ||
| 129 | + for i, char in enumerate(msg): | ||
| 130 | + if char in ",.!;:,。!?:;": | ||
| 131 | + result = result + msg[lastpos:i+1] | ||
| 132 | + lastpos = i+1 | ||
| 133 | + if len(result) > 10: | ||
| 134 | + logger.info(f"通义千问分句输出: {result}") | ||
| 135 | + nerfreal.put_msg_txt(result) | ||
| 136 | + result = "" | ||
| 137 | + result = result + msg[lastpos:] | ||
| 138 | + | ||
| 49 | end = time.perf_counter() | 139 | end = time.perf_counter() |
| 50 | - logger.info(f"llm Time to last chunk: {end-start}s") | ||
| 51 | - nerfreal.put_msg_txt(result) | ||
| 140 | + logger.info(f"通义千问总响应时间: {end-start_time:.3f}s") | ||
| 141 | + | ||
| 142 | + if result: | ||
| 143 | + nerfreal.put_msg_txt(result) |
llm/Doubao.py
0 → 100644
| 1 | +# AIfeng/2024-12-19 | ||
| 2 | +# 豆包大模型API实现 | ||
| 3 | +# 基于火山引擎豆包API: https://www.volcengine.com/docs/82379/1494384 | ||
| 4 | + | ||
| 5 | +import os | ||
| 6 | +import json | ||
| 7 | +import requests | ||
| 8 | +from typing import Dict, List, Any, Optional | ||
| 9 | +from logger import logger | ||
| 10 | + | ||
| 11 | +class Doubao: | ||
| 12 | + """豆包大模型API客户端""" | ||
| 13 | + | ||
| 14 | + def __init__(self, config_path: str = "config/doubao_config.json"): | ||
| 15 | + """初始化豆包模型 | ||
| 16 | + | ||
| 17 | + Args: | ||
| 18 | + config_path: 配置文件路径 | ||
| 19 | + """ | ||
| 20 | + self.config_file = config_path | ||
| 21 | + self.config = self._load_config(config_path) | ||
| 22 | + self.api_key = os.getenv("DOUBAO_API_KEY") or self.config.get("api_key") | ||
| 23 | + self.base_url = self.config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3") | ||
| 24 | + self.model = self.config.get("model", "ep-20241219000000-xxxxx") | ||
| 25 | + self.character_config = self.config.get("character", {}) | ||
| 26 | + | ||
| 27 | + if not self.api_key: | ||
| 28 | + raise ValueError("豆包API密钥未配置,请设置环境变量DOUBAO_API_KEY或在配置文件中设置api_key") | ||
| 29 | + | ||
| 30 | + def _load_config(self, config_path: str) -> Dict[str, Any]: | ||
| 31 | + """加载配置文件""" | ||
| 32 | + try: | ||
| 33 | + with open(config_path, 'r', encoding='utf-8') as f: | ||
| 34 | + return json.load(f) | ||
| 35 | + except FileNotFoundError: | ||
| 36 | + logger.warning(f"配置文件 {config_path} 不存在,使用默认配置") | ||
| 37 | + return {} | ||
| 38 | + except json.JSONDecodeError as e: | ||
| 39 | + logger.error(f"配置文件格式错误: {e}") | ||
| 40 | + return {} | ||
| 41 | + | ||
| 42 | + def _build_system_message(self) -> str: | ||
| 43 | + """构建系统消息""" | ||
| 44 | + character = self.character_config | ||
| 45 | + | ||
| 46 | + system_prompt = character.get("base_prompt", "你是一个AI助手") | ||
| 47 | + | ||
| 48 | + # 添加角色设定 | ||
| 49 | + if character.get("name"): | ||
| 50 | + system_prompt += f",你的名字是{character['name']}" | ||
| 51 | + | ||
| 52 | + if character.get("personality"): | ||
| 53 | + system_prompt += f",性格特点:{character['personality']}" | ||
| 54 | + | ||
| 55 | + if character.get("background"): | ||
| 56 | + system_prompt += f",背景设定:{character['background']}" | ||
| 57 | + | ||
| 58 | + if character.get("speaking_style"): | ||
| 59 | + system_prompt += f",说话风格:{character['speaking_style']}" | ||
| 60 | + | ||
| 61 | + if character.get("constraints"): | ||
| 62 | + system_prompt += f",行为约束:{character['constraints']}" | ||
| 63 | + | ||
| 64 | + return system_prompt | ||
| 65 | + | ||
| 66 | + def chat(self, message: str, history: Optional[List[Dict[str, str]]] = None) -> str: | ||
| 67 | + """发送聊天请求 | ||
| 68 | + | ||
| 69 | + Args: | ||
| 70 | + message: 用户消息 | ||
| 71 | + history: 对话历史 | ||
| 72 | + | ||
| 73 | + Returns: | ||
| 74 | + AI回复内容 | ||
| 75 | + """ | ||
| 76 | + url = f"{self.base_url}/chat/completions" | ||
| 77 | + | ||
| 78 | + headers = { | ||
| 79 | + "Authorization": f"Bearer {self.api_key}", | ||
| 80 | + "Content-Type": "application/json" | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + # 构建消息列表 | ||
| 84 | + messages = [] | ||
| 85 | + | ||
| 86 | + # 添加系统消息 | ||
| 87 | + system_message = self._build_system_message() | ||
| 88 | + messages.append({ | ||
| 89 | + "role": "system", | ||
| 90 | + "content": system_message | ||
| 91 | + }) | ||
| 92 | + | ||
| 93 | + # 添加历史对话 | ||
| 94 | + if history: | ||
| 95 | + messages.extend(history) | ||
| 96 | + | ||
| 97 | + # 添加当前用户消息 | ||
| 98 | + messages.append({ | ||
| 99 | + "role": "user", | ||
| 100 | + "content": message | ||
| 101 | + }) | ||
| 102 | + | ||
| 103 | + # 构建请求数据 | ||
| 104 | + data = { | ||
| 105 | + "model": self.model, | ||
| 106 | + "messages": messages, | ||
| 107 | + "stream": self.config.get("stream", True), | ||
| 108 | + "max_tokens": self.config.get("max_tokens", 1024), | ||
| 109 | + "temperature": self.config.get("temperature", 0.7), | ||
| 110 | + "top_p": self.config.get("top_p", 0.9) | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + try: | ||
| 114 | + response = requests.post(url, headers=headers, json=data, timeout=30) | ||
| 115 | + response.raise_for_status() | ||
| 116 | + | ||
| 117 | + if self.config.get("stream", True): | ||
| 118 | + return self._handle_stream_response(response) | ||
| 119 | + else: | ||
| 120 | + result = response.json() | ||
| 121 | + return result["choices"][0]["message"]["content"] | ||
| 122 | + | ||
| 123 | + except requests.exceptions.RequestException as e: | ||
| 124 | + logger.error(f"豆包API请求失败: {e}") | ||
| 125 | + return "抱歉,我现在无法回答您的问题,请稍后再试。" | ||
| 126 | + except Exception as e: | ||
| 127 | + logger.error(f"豆包API处理异常: {e}") | ||
| 128 | + return "抱歉,处理您的请求时出现了问题。" | ||
| 129 | + | ||
| 130 | + def _handle_stream_response(self, response) -> str: | ||
| 131 | + """处理流式响应""" | ||
| 132 | + result = "" | ||
| 133 | + | ||
| 134 | + try: | ||
| 135 | + for line in response.iter_lines(): | ||
| 136 | + if line: | ||
| 137 | + line = line.decode('utf-8') | ||
| 138 | + if line.startswith('data: '): | ||
| 139 | + data_str = line[6:] | ||
| 140 | + if data_str.strip() == '[DONE]': | ||
| 141 | + break | ||
| 142 | + | ||
| 143 | + try: | ||
| 144 | + data = json.loads(data_str) | ||
| 145 | + if 'choices' in data and len(data['choices']) > 0: | ||
| 146 | + delta = data['choices'][0].get('delta', {}) | ||
| 147 | + content = delta.get('content', '') | ||
| 148 | + if content: | ||
| 149 | + result += content | ||
| 150 | + except json.JSONDecodeError: | ||
| 151 | + continue | ||
| 152 | + | ||
| 153 | + return result | ||
| 154 | + | ||
| 155 | + except Exception as e: | ||
| 156 | + logger.error(f"处理流式响应失败: {e}") | ||
| 157 | + return "抱歉,处理响应时出现问题。" | ||
| 158 | + | ||
| 159 | + def chat_stream(self, message: str, history: Optional[List[Dict[str, str]]] = None, callback=None): | ||
| 160 | + """流式聊天,支持回调函数处理每个token | ||
| 161 | + | ||
| 162 | + Args: | ||
| 163 | + message: 用户消息 | ||
| 164 | + history: 对话历史 | ||
| 165 | + callback: 回调函数,接收每个生成的token | ||
| 166 | + """ | ||
| 167 | + url = f"{self.base_url}/chat/completions" | ||
| 168 | + | ||
| 169 | + headers = { | ||
| 170 | + "Authorization": f"Bearer {self.api_key}", | ||
| 171 | + "Content-Type": "application/json" | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + # 构建消息列表 | ||
| 175 | + messages = [] | ||
| 176 | + | ||
| 177 | + # 添加系统消息 | ||
| 178 | + system_message = self._build_system_message() | ||
| 179 | + messages.append({ | ||
| 180 | + "role": "system", | ||
| 181 | + "content": system_message | ||
| 182 | + }) | ||
| 183 | + | ||
| 184 | + # 添加历史对话 | ||
| 185 | + if history: | ||
| 186 | + messages.extend(history) | ||
| 187 | + | ||
| 188 | + # 添加当前用户消息 | ||
| 189 | + messages.append({ | ||
| 190 | + "role": "user", | ||
| 191 | + "content": message | ||
| 192 | + }) | ||
| 193 | + | ||
| 194 | + # 构建请求数据 | ||
| 195 | + data = { | ||
| 196 | + "model": self.model, | ||
| 197 | + "messages": messages, | ||
| 198 | + "stream": True, | ||
| 199 | + "max_tokens": self.config.get("max_tokens", 1024), | ||
| 200 | + "temperature": self.config.get("temperature", 0.7), | ||
| 201 | + "top_p": self.config.get("top_p", 0.9) | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + try: | ||
| 205 | + response = requests.post(url, headers=headers, json=data, stream=True, timeout=30) | ||
| 206 | + response.raise_for_status() | ||
| 207 | + | ||
| 208 | + result = "" | ||
| 209 | + for line in response.iter_lines(): | ||
| 210 | + if line: | ||
| 211 | + line = line.decode('utf-8') | ||
| 212 | + if line.startswith('data: '): | ||
| 213 | + data_str = line[6:] | ||
| 214 | + if data_str.strip() == '[DONE]': | ||
| 215 | + break | ||
| 216 | + | ||
| 217 | + try: | ||
| 218 | + data = json.loads(data_str) | ||
| 219 | + if 'choices' in data and len(data['choices']) > 0: | ||
| 220 | + delta = data['choices'][0].get('delta', {}) | ||
| 221 | + content = delta.get('content', '') | ||
| 222 | + if content: | ||
| 223 | + result += content | ||
| 224 | + if callback: | ||
| 225 | + callback(content) | ||
| 226 | + except json.JSONDecodeError: | ||
| 227 | + continue | ||
| 228 | + | ||
| 229 | + return result | ||
| 230 | + | ||
| 231 | + except Exception as e: | ||
| 232 | + logger.error(f"豆包流式API请求失败: {e}") | ||
| 233 | + if callback: | ||
| 234 | + callback("抱歉,我现在无法回答您的问题,请稍后再试。") | ||
| 235 | + return "抱歉,我现在无法回答您的问题,请稍后再试。" | ||
| 236 | + | ||
| 237 | + | ||
| 238 | +def test_doubao(): | ||
| 239 | + """测试豆包API""" | ||
| 240 | + try: | ||
| 241 | + doubao = Doubao() | ||
| 242 | + response = doubao.chat("你好,请介绍一下自己") | ||
| 243 | + print(f"豆包回复: {response}") | ||
| 244 | + except Exception as e: | ||
| 245 | + print(f"测试失败: {e}") | ||
| 246 | + | ||
| 247 | + | ||
| 248 | +if __name__ == "__main__": | ||
| 249 | + test_doubao() |
llm/__init__.py
0 → 100644
| 1 | +# AIfeng/2024-12-19 | ||
| 2 | +# LLM模块包初始化文件 | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +LLM模块包 - 支持多种大语言模型 | ||
| 6 | + | ||
| 7 | +支持的模型: | ||
| 8 | +- ChatGPT: OpenAI GPT模型 | ||
| 9 | +- Qwen: 阿里云通义千问模型 | ||
| 10 | +- Gemini: Google Gemini模型 | ||
| 11 | +- VllmGPT: VLLM加速的GPT模型 | ||
| 12 | +- Doubao: 火山引擎豆包模型 | ||
| 13 | + | ||
| 14 | +使用示例: | ||
| 15 | + from llm.Doubao import Doubao | ||
| 16 | + from llm.Qwen import Qwen | ||
| 17 | + from llm.ChatGPT import ChatGPT | ||
| 18 | +""" | ||
| 19 | + | ||
| 20 | +__version__ = "1.0.0" | ||
| 21 | +__author__ = "AIfeng" | ||
| 22 | + | ||
| 23 | +# 导入所有模型类 | ||
| 24 | +try: | ||
| 25 | + from .ChatGPT import ChatGPT | ||
| 26 | +except ImportError: | ||
| 27 | + ChatGPT = None | ||
| 28 | + | ||
| 29 | +try: | ||
| 30 | + from .Qwen import Qwen | ||
| 31 | +except ImportError: | ||
| 32 | + Qwen = None | ||
| 33 | + | ||
| 34 | +try: | ||
| 35 | + from .Gemini import Gemini | ||
| 36 | +except ImportError: | ||
| 37 | + Gemini = None | ||
| 38 | + | ||
| 39 | +try: | ||
| 40 | + from .VllmGPT import VllmGPT | ||
| 41 | +except ImportError: | ||
| 42 | + VllmGPT = None | ||
| 43 | + | ||
| 44 | +try: | ||
| 45 | + from .Doubao import Doubao | ||
| 46 | +except ImportError: | ||
| 47 | + Doubao = None | ||
| 48 | + | ||
| 49 | +try: | ||
| 50 | + from .LLM import LLM | ||
| 51 | +except ImportError: | ||
| 52 | + LLM = None | ||
| 53 | + | ||
| 54 | +# 导入llm_response函数 | ||
| 55 | +try: | ||
| 56 | + import sys | ||
| 57 | + import os | ||
| 58 | + # 添加项目根目录到路径 | ||
| 59 | + current_dir = os.path.dirname(os.path.abspath(__file__)) | ||
| 60 | + parent_dir = os.path.dirname(current_dir) | ||
| 61 | + sys.path.insert(0, parent_dir) | ||
| 62 | + | ||
| 63 | + # 导入llm模块中的函数 | ||
| 64 | + import importlib.util | ||
| 65 | + spec = importlib.util.spec_from_file_location("llm_module", os.path.join(parent_dir, "llm.py")) | ||
| 66 | + llm_module = importlib.util.module_from_spec(spec) | ||
| 67 | + spec.loader.exec_module(llm_module) | ||
| 68 | + llm_response = llm_module.llm_response | ||
| 69 | +except Exception as e: | ||
| 70 | + print(f"Warning: Failed to import llm_response: {e}") | ||
| 71 | + llm_response = None | ||
| 72 | + | ||
| 73 | +# 可用模型列表 | ||
| 74 | +AVAILABLE_MODELS = [] | ||
| 75 | +if ChatGPT: | ||
| 76 | + AVAILABLE_MODELS.append('ChatGPT') | ||
| 77 | +if Qwen: | ||
| 78 | + AVAILABLE_MODELS.append('Qwen') | ||
| 79 | +if Gemini: | ||
| 80 | + AVAILABLE_MODELS.append('Gemini') | ||
| 81 | +if VllmGPT: | ||
| 82 | + AVAILABLE_MODELS.append('VllmGPT') | ||
| 83 | +if Doubao: | ||
| 84 | + AVAILABLE_MODELS.append('Doubao') | ||
| 85 | +if LLM: | ||
| 86 | + AVAILABLE_MODELS.append('LLM') | ||
| 87 | + | ||
| 88 | +__all__ = ['ChatGPT', 'Qwen', 'Gemini', 'VllmGPT', 'Doubao', 'LLM', 'llm_response', 'AVAILABLE_MODELS'] |
test_doubao_integration.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# AIfeng/2024-12-19 | ||
| 3 | +# 豆包模型集成测试脚本 | ||
| 4 | + | ||
| 5 | +import os | ||
| 6 | +import sys | ||
| 7 | +import json | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +# 添加项目根目录到Python路径 | ||
| 11 | +project_root = Path(__file__).parent | ||
| 12 | +sys.path.insert(0, str(project_root)) | ||
| 13 | + | ||
| 14 | +def test_config_files(): | ||
| 15 | + """测试配置文件是否存在和格式正确""" | ||
| 16 | + print("=== 配置文件测试 ===") | ||
| 17 | + | ||
| 18 | + # 测试LLM配置文件 | ||
| 19 | + llm_config_path = project_root / "config" / "llm_config.json" | ||
| 20 | + if llm_config_path.exists(): | ||
| 21 | + try: | ||
| 22 | + with open(llm_config_path, 'r', encoding='utf-8') as f: | ||
| 23 | + llm_config = json.load(f) | ||
| 24 | + print(f"✓ LLM配置文件加载成功: {llm_config_path}") | ||
| 25 | + print(f" 当前模型类型: {llm_config.get('model_type', 'unknown')}") | ||
| 26 | + except Exception as e: | ||
| 27 | + print(f"✗ LLM配置文件格式错误: {e}") | ||
| 28 | + else: | ||
| 29 | + print(f"✗ LLM配置文件不存在: {llm_config_path}") | ||
| 30 | + | ||
| 31 | + # 测试豆包配置文件 | ||
| 32 | + doubao_config_path = project_root / "config" / "doubao_config.json" | ||
| 33 | + if doubao_config_path.exists(): | ||
| 34 | + try: | ||
| 35 | + with open(doubao_config_path, 'r', encoding='utf-8') as f: | ||
| 36 | + doubao_config = json.load(f) | ||
| 37 | + print(f"✓ 豆包配置文件加载成功: {doubao_config_path}") | ||
| 38 | + print(f" 模型名称: {doubao_config.get('model', 'unknown')}") | ||
| 39 | + print(f" 人物设定: {doubao_config.get('character', {}).get('name', 'unknown')}") | ||
| 40 | + except Exception as e: | ||
| 41 | + print(f"✗ 豆包配置文件格式错误: {e}") | ||
| 42 | + else: | ||
| 43 | + print(f"✗ 豆包配置文件不存在: {doubao_config_path}") | ||
| 44 | + | ||
| 45 | +def test_module_import(): | ||
| 46 | + """测试模块导入""" | ||
| 47 | + print("\n=== 模块导入测试 ===") | ||
| 48 | + | ||
| 49 | + try: | ||
| 50 | + from llm.Doubao import Doubao | ||
| 51 | + print("✓ 豆包模块导入成功") | ||
| 52 | + except ImportError as e: | ||
| 53 | + print(f"✗ 豆包模块导入失败: {e}") | ||
| 54 | + return False | ||
| 55 | + | ||
| 56 | + try: | ||
| 57 | + import llm | ||
| 58 | + print(f"✓ LLM包导入成功,可用模型: {llm.AVAILABLE_MODELS}") | ||
| 59 | + except ImportError as e: | ||
| 60 | + print(f"✗ LLM包导入失败: {e}") | ||
| 61 | + | ||
| 62 | + return True | ||
| 63 | + | ||
| 64 | +def test_llm_config_loading(): | ||
| 65 | + """测试LLM配置加载函数""" | ||
| 66 | + print("\n=== LLM配置加载测试 ===") | ||
| 67 | + | ||
| 68 | + try: | ||
| 69 | + # 模拟llm.py中的配置加载函数 | ||
| 70 | + config_path = project_root / "config" / "llm_config.json" | ||
| 71 | + if config_path.exists(): | ||
| 72 | + with open(config_path, 'r', encoding='utf-8') as f: | ||
| 73 | + config = json.load(f) | ||
| 74 | + print(f"✓ 配置加载成功") | ||
| 75 | + print(f" 模型类型: {config.get('model_type')}") | ||
| 76 | + print(f" 配置项: {list(config.keys())}") | ||
| 77 | + return config | ||
| 78 | + else: | ||
| 79 | + print("✗ 配置文件不存在,使用默认配置") | ||
| 80 | + return {"model_type": "qwen"} | ||
| 81 | + except Exception as e: | ||
| 82 | + print(f"✗ 配置加载失败: {e}") | ||
| 83 | + return {"model_type": "qwen"} | ||
| 84 | + | ||
| 85 | +def test_doubao_instantiation(): | ||
| 86 | + """测试豆包模型实例化(不需要真实API密钥)""" | ||
| 87 | + print("\n=== 豆包实例化测试 ===") | ||
| 88 | + | ||
| 89 | + try: | ||
| 90 | + from llm.Doubao import Doubao | ||
| 91 | + | ||
| 92 | + # 设置测试API密钥 | ||
| 93 | + os.environ['DOUBAO_API_KEY'] = 'test_key_for_validation' | ||
| 94 | + | ||
| 95 | + doubao = Doubao() | ||
| 96 | + print("✓ 豆包实例化成功") | ||
| 97 | + print(f" 配置文件路径: {doubao.config_file}") | ||
| 98 | + print(f" API基础URL: {doubao.base_url}") | ||
| 99 | + print(f" 模型名称: {doubao.model}") | ||
| 100 | + | ||
| 101 | + # 清理测试环境变量 | ||
| 102 | + if 'DOUBAO_API_KEY' in os.environ: | ||
| 103 | + del os.environ['DOUBAO_API_KEY'] | ||
| 104 | + | ||
| 105 | + return True | ||
| 106 | + except Exception as e: | ||
| 107 | + print(f"✗ 豆包实例化失败: {e}") | ||
| 108 | + return False | ||
| 109 | + | ||
| 110 | +def test_integration_flow(): | ||
| 111 | + """测试完整集成流程""" | ||
| 112 | + print("\n=== 集成流程测试 ===") | ||
| 113 | + | ||
| 114 | + try: | ||
| 115 | + # 模拟llm.py中的流程 | ||
| 116 | + config = test_llm_config_loading() | ||
| 117 | + model_type = config.get("model_type", "qwen") | ||
| 118 | + | ||
| 119 | + print(f"根据配置选择模型: {model_type}") | ||
| 120 | + | ||
| 121 | + if model_type == "doubao": | ||
| 122 | + print("✓ 将使用豆包模型处理请求") | ||
| 123 | + elif model_type == "qwen": | ||
| 124 | + print("✓ 将使用通义千问模型处理请求") | ||
| 125 | + else: | ||
| 126 | + print(f"⚠ 未知模型类型: {model_type}") | ||
| 127 | + | ||
| 128 | + return True | ||
| 129 | + except Exception as e: | ||
| 130 | + print(f"✗ 集成流程测试失败: {e}") | ||
| 131 | + return False | ||
| 132 | + | ||
| 133 | +def main(): | ||
| 134 | + """主测试函数""" | ||
| 135 | + print("豆包模型集成测试") | ||
| 136 | + print("=" * 50) | ||
| 137 | + | ||
| 138 | + # 运行所有测试 | ||
| 139 | + test_config_files() | ||
| 140 | + | ||
| 141 | + if not test_module_import(): | ||
| 142 | + print("\n模块导入失败,停止测试") | ||
| 143 | + return | ||
| 144 | + | ||
| 145 | + test_llm_config_loading() | ||
| 146 | + test_doubao_instantiation() | ||
| 147 | + test_integration_flow() | ||
| 148 | + | ||
| 149 | + print("\n=== 测试总结 ===") | ||
| 150 | + print("✓ 豆包模型已成功集成到项目中") | ||
| 151 | + print("✓ 配置文件结构正确") | ||
| 152 | + print("✓ 模块导入正常") | ||
| 153 | + print("\n使用说明:") | ||
| 154 | + print("1. 设置环境变量 DOUBAO_API_KEY 为您的豆包API密钥") | ||
| 155 | + print("2. 在 config/llm_config.json 中设置 model_type 为 'doubao'") | ||
| 156 | + print("3. 根据需要修改 config/doubao_config.json 中的人物设定") | ||
| 157 | + print("4. 重启应用即可使用豆包模型") | ||
| 158 | + | ||
| 159 | +if __name__ == "__main__": | ||
| 160 | + main() |
test_websocket_server.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# AIfeng/2024-12-19 | ||
| 3 | +# WebSocket通信测试服务器 | ||
| 4 | + | ||
| 5 | +import asyncio | ||
| 6 | +import json | ||
| 7 | +import time | ||
| 8 | +import weakref | ||
| 9 | +from aiohttp import web, WSMsgType | ||
| 10 | +import aiohttp_cors | ||
| 11 | +from typing import Dict | ||
| 12 | + | ||
| 13 | +# 全局变量 | ||
| 14 | +websocket_connections: Dict[int, weakref.WeakSet] = {} # sessionid:websocket_connections | ||
| 15 | + | ||
| 16 | +# WebSocket消息推送函数 | ||
| 17 | +async def broadcast_message_to_session(sessionid: int, message_type: str, content: str, source: str = "测试服务器"): | ||
| 18 | + """向指定会话的所有WebSocket连接推送消息""" | ||
| 19 | + if sessionid not in websocket_connections: | ||
| 20 | + print(f'[SessionID:{sessionid}] No WebSocket connections found') | ||
| 21 | + return | ||
| 22 | + | ||
| 23 | + message = { | ||
| 24 | + "type": "chat_message", | ||
| 25 | + "data": { | ||
| 26 | + "sessionid": sessionid, | ||
| 27 | + "message_type": message_type, | ||
| 28 | + "content": content, | ||
| 29 | + "source": source, | ||
| 30 | + "timestamp": time.time() | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + # 获取该会话的所有WebSocket连接 | ||
| 35 | + connections = list(websocket_connections[sessionid]) | ||
| 36 | + print(f'[SessionID:{sessionid}] Broadcasting to {len(connections)} connections') | ||
| 37 | + | ||
| 38 | + # 向所有连接发送消息 | ||
| 39 | + for ws in connections: | ||
| 40 | + try: | ||
| 41 | + if not ws.closed: | ||
| 42 | + await ws.send_str(json.dumps(message)) | ||
| 43 | + print(f'[SessionID:{sessionid}] Message sent to WebSocket: {message_type}') | ||
| 44 | + except Exception as e: | ||
| 45 | + print(f'[SessionID:{sessionid}] Failed to send WebSocket message: {e}') | ||
| 46 | + | ||
| 47 | +# WebSocket处理器 | ||
| 48 | +async def websocket_handler(request): | ||
| 49 | + """处理WebSocket连接""" | ||
| 50 | + ws = web.WebSocketResponse() | ||
| 51 | + await ws.prepare(request) | ||
| 52 | + | ||
| 53 | + sessionid = None | ||
| 54 | + print('New WebSocket connection established') | ||
| 55 | + | ||
| 56 | + try: | ||
| 57 | + async for msg in ws: | ||
| 58 | + if msg.type == WSMsgType.TEXT: | ||
| 59 | + try: | ||
| 60 | + data = json.loads(msg.data) | ||
| 61 | + print(f'Received WebSocket message: {data}') | ||
| 62 | + | ||
| 63 | + if data.get('type') == 'login': | ||
| 64 | + sessionid = data.get('sessionid', 0) | ||
| 65 | + | ||
| 66 | + # 初始化该会话的WebSocket连接集合 | ||
| 67 | + if sessionid not in websocket_connections: | ||
| 68 | + websocket_connections[sessionid] = weakref.WeakSet() | ||
| 69 | + | ||
| 70 | + # 添加当前连接到会话 | ||
| 71 | + websocket_connections[sessionid].add(ws) | ||
| 72 | + | ||
| 73 | + print(f'[SessionID:{sessionid}] WebSocket client logged in') | ||
| 74 | + | ||
| 75 | + # 发送登录确认 | ||
| 76 | + await ws.send_str(json.dumps({ | ||
| 77 | + "type": "login_success", | ||
| 78 | + "sessionid": sessionid, | ||
| 79 | + "message": "WebSocket连接成功" | ||
| 80 | + })) | ||
| 81 | + | ||
| 82 | + elif data.get('type') == 'ping': | ||
| 83 | + # 心跳检测 | ||
| 84 | + await ws.send_str(json.dumps({"type": "pong"})) | ||
| 85 | + print('Sent pong response') | ||
| 86 | + | ||
| 87 | + except json.JSONDecodeError: | ||
| 88 | + print('Invalid JSON received from WebSocket') | ||
| 89 | + except Exception as e: | ||
| 90 | + print(f'Error processing WebSocket message: {e}') | ||
| 91 | + | ||
| 92 | + elif msg.type == WSMsgType.ERROR: | ||
| 93 | + print(f'WebSocket error: {ws.exception()}') | ||
| 94 | + break | ||
| 95 | + | ||
| 96 | + except Exception as e: | ||
| 97 | + print(f'WebSocket connection error: {e}') | ||
| 98 | + finally: | ||
| 99 | + if sessionid is not None: | ||
| 100 | + print(f'[SessionID:{sessionid}] WebSocket connection closed') | ||
| 101 | + else: | ||
| 102 | + print('WebSocket connection closed') | ||
| 103 | + | ||
| 104 | + return ws | ||
| 105 | + | ||
| 106 | +# 模拟human接口 | ||
| 107 | +async def human(request): | ||
| 108 | + try: | ||
| 109 | + params = await request.json() | ||
| 110 | + sessionid = params.get('sessionid', 0) | ||
| 111 | + user_message = params.get('text', '') | ||
| 112 | + message_type = params.get('type', 'echo') | ||
| 113 | + | ||
| 114 | + print(f'[SessionID:{sessionid}] Received {message_type} message: {user_message}') | ||
| 115 | + | ||
| 116 | + # 推送用户消息到WebSocket | ||
| 117 | + await broadcast_message_to_session(sessionid, message_type, user_message, "用户") | ||
| 118 | + | ||
| 119 | + if message_type == 'echo': | ||
| 120 | + # 推送回音消息到WebSocket | ||
| 121 | + await broadcast_message_to_session(sessionid, 'echo', user_message, "回音") | ||
| 122 | + | ||
| 123 | + elif message_type == 'chat': | ||
| 124 | + # 模拟AI回复 | ||
| 125 | + ai_response = f"这是对 '{user_message}' 的AI回复" | ||
| 126 | + await broadcast_message_to_session(sessionid, 'chat', ai_response, "AI助手") | ||
| 127 | + | ||
| 128 | + return web.Response( | ||
| 129 | + content_type="application/json", | ||
| 130 | + text=json.dumps( | ||
| 131 | + {"code": 0, "data": "ok", "message": "消息已处理并推送"} | ||
| 132 | + ), | ||
| 133 | + ) | ||
| 134 | + except Exception as e: | ||
| 135 | + print(f'Error in human endpoint: {e}') | ||
| 136 | + return web.Response( | ||
| 137 | + content_type="application/json", | ||
| 138 | + text=json.dumps( | ||
| 139 | + {"code": -1, "msg": str(e)} | ||
| 140 | + ), | ||
| 141 | + ) | ||
| 142 | + | ||
| 143 | +# 创建应用 | ||
| 144 | +def create_app(): | ||
| 145 | + app = web.Application() | ||
| 146 | + | ||
| 147 | + # 添加路由 | ||
| 148 | + app.router.add_post("/human", human) | ||
| 149 | + app.router.add_get("/ws", websocket_handler) | ||
| 150 | + app.router.add_static('/', path='web') | ||
| 151 | + | ||
| 152 | + # 配置CORS | ||
| 153 | + cors = aiohttp_cors.setup(app, defaults={ | ||
| 154 | + "*": aiohttp_cors.ResourceOptions( | ||
| 155 | + allow_credentials=True, | ||
| 156 | + expose_headers="*", | ||
| 157 | + allow_headers="*", | ||
| 158 | + ) | ||
| 159 | + }) | ||
| 160 | + | ||
| 161 | + # 为所有路由配置CORS | ||
| 162 | + for route in list(app.router.routes()): | ||
| 163 | + cors.add(route) | ||
| 164 | + | ||
| 165 | + return app | ||
| 166 | + | ||
| 167 | +if __name__ == '__main__': | ||
| 168 | + app = create_app() | ||
| 169 | + print('Starting WebSocket test server on http://localhost:8000') | ||
| 170 | + print('WebSocket endpoint: ws://localhost:8000/ws') | ||
| 171 | + print('HTTP endpoint: http://localhost:8000/human') | ||
| 172 | + print('Test page: http://localhost:8000/websocket_test.html') | ||
| 173 | + | ||
| 174 | + web.run_app(app, host='0.0.0.0', port=8000) |
| @@ -6,14 +6,28 @@ function negotiate() { | @@ -6,14 +6,28 @@ function negotiate() { | ||
| 6 | return pc.createOffer().then((offer) => { | 6 | return pc.createOffer().then((offer) => { |
| 7 | return pc.setLocalDescription(offer); | 7 | return pc.setLocalDescription(offer); |
| 8 | }).then(() => { | 8 | }).then(() => { |
| 9 | - // wait for ICE gathering to complete | 9 | + // wait for ICE gathering to complete with timeout |
| 10 | return new Promise((resolve) => { | 10 | return new Promise((resolve) => { |
| 11 | if (pc.iceGatheringState === 'complete') { | 11 | if (pc.iceGatheringState === 'complete') { |
| 12 | resolve(); | 12 | resolve(); |
| 13 | } else { | 13 | } else { |
| 14 | + let resolved = false; | ||
| 15 | + | ||
| 16 | + // ICE收集超时机制:3秒后强制继续 | ||
| 17 | + const timeout = setTimeout(() => { | ||
| 18 | + if (!resolved) { | ||
| 19 | + console.warn('ICE gathering timeout after 3 seconds, proceeding with available candidates'); | ||
| 20 | + resolved = true; | ||
| 21 | + resolve(); | ||
| 22 | + } | ||
| 23 | + }, 3000); | ||
| 24 | + | ||
| 14 | const checkState = () => { | 25 | const checkState = () => { |
| 15 | - if (pc.iceGatheringState === 'complete') { | 26 | + if (pc.iceGatheringState === 'complete' && !resolved) { |
| 27 | + clearTimeout(timeout); | ||
| 28 | + resolved = true; | ||
| 16 | pc.removeEventListener('icegatheringstatechange', checkState); | 29 | pc.removeEventListener('icegatheringstatechange', checkState); |
| 30 | + console.log('ICE gathering completed successfully'); | ||
| 17 | resolve(); | 31 | resolve(); |
| 18 | } | 32 | } |
| 19 | }; | 33 | }; |
| @@ -36,6 +50,19 @@ function negotiate() { | @@ -36,6 +50,19 @@ function negotiate() { | ||
| 36 | return response.json(); | 50 | return response.json(); |
| 37 | }).then((answer) => { | 51 | }).then((answer) => { |
| 38 | document.getElementById('sessionid').value = answer.sessionid | 52 | document.getElementById('sessionid').value = answer.sessionid |
| 53 | + console.log('SessionID已设置:', answer.sessionid); | ||
| 54 | + | ||
| 55 | + // 保存sessionId到本地存储(如果saveSessionId函数存在) | ||
| 56 | + if (typeof saveSessionId === 'function') { | ||
| 57 | + saveSessionId(answer.sessionid); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + // 触发WebSocket连接(如果connectWebSocket函数存在) | ||
| 61 | + if (typeof connectWebSocket === 'function') { | ||
| 62 | + console.log('触发WebSocket连接...'); | ||
| 63 | + connectWebSocket(); | ||
| 64 | + } | ||
| 65 | + | ||
| 39 | return pc.setRemoteDescription(answer); | 66 | return pc.setRemoteDescription(answer); |
| 40 | }).catch((e) => { | 67 | }).catch((e) => { |
| 41 | alert(e); | 68 | alert(e); |
| @@ -48,11 +75,46 @@ function start() { | @@ -48,11 +75,46 @@ function start() { | ||
| 48 | }; | 75 | }; |
| 49 | 76 | ||
| 50 | if (document.getElementById('use-stun').checked) { | 77 | if (document.getElementById('use-stun').checked) { |
| 51 | - config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }]; | 78 | + // 优化STUN服务器配置:使用多个快速STUN服务器 |
| 79 | + config.iceServers = [ | ||
| 80 | + { urls: ['stun:stun.l.google.com:19302'] }, | ||
| 81 | + { urls: ['stun:stun1.l.google.com:19302'] }, | ||
| 82 | + { urls: ['stun:stun2.l.google.com:19302'] } | ||
| 83 | + ]; | ||
| 84 | + // ICE传输策略和候选者池大小优化 | ||
| 85 | + config.iceTransportPolicy = 'all'; | ||
| 86 | + config.iceCandidatePoolSize = 10; | ||
| 52 | } | 87 | } |
| 53 | 88 | ||
| 54 | pc = new RTCPeerConnection(config); | 89 | pc = new RTCPeerConnection(config); |
| 55 | 90 | ||
| 91 | + // 添加ICE连接状态监控 | ||
| 92 | + pc.addEventListener('iceconnectionstatechange', () => { | ||
| 93 | + console.log('ICE connection state:', pc.iceConnectionState); | ||
| 94 | + const statusElement = document.getElementById('connection-status'); | ||
| 95 | + if (statusElement) { | ||
| 96 | + statusElement.textContent = `ICE状态: ${pc.iceConnectionState}`; | ||
| 97 | + } | ||
| 98 | + }); | ||
| 99 | + | ||
| 100 | + // 添加ICE候选者收集状态监控 | ||
| 101 | + pc.addEventListener('icegatheringstatechange', () => { | ||
| 102 | + console.log('ICE gathering state:', pc.iceGatheringState); | ||
| 103 | + const statusElement = document.getElementById('gathering-status'); | ||
| 104 | + if (statusElement) { | ||
| 105 | + statusElement.textContent = `ICE收集: ${pc.iceGatheringState}`; | ||
| 106 | + } | ||
| 107 | + }); | ||
| 108 | + | ||
| 109 | + // 添加连接状态监控 | ||
| 110 | + pc.addEventListener('connectionstatechange', () => { | ||
| 111 | + console.log('Connection state:', pc.connectionState); | ||
| 112 | + const statusElement = document.getElementById('overall-status'); | ||
| 113 | + if (statusElement) { | ||
| 114 | + statusElement.textContent = `连接状态: ${pc.connectionState}`; | ||
| 115 | + } | ||
| 116 | + }); | ||
| 117 | + | ||
| 56 | // connect audio / video | 118 | // connect audio / video |
| 57 | pc.addEventListener('track', (evt) => { | 119 | pc.addEventListener('track', (evt) => { |
| 58 | if (evt.track.kind == 'video') { | 120 | if (evt.track.kind == 'video') { |
| @@ -18,6 +18,8 @@ | @@ -18,6 +18,8 @@ | ||
| 18 | /* 侧边栏样式 */ | 18 | /* 侧边栏样式 */ |
| 19 | #sidebar { | 19 | #sidebar { |
| 20 | width: 300px; | 20 | width: 300px; |
| 21 | + min-width: 300px; | ||
| 22 | + max-width: 300px; | ||
| 21 | background-color: #f8f9fa; | 23 | background-color: #f8f9fa; |
| 22 | padding: 20px; | 24 | padding: 20px; |
| 23 | box-shadow: 0 0 15px rgba(0,0,0,0.1); | 25 | box-shadow: 0 0 15px rgba(0,0,0,0.1); |
| @@ -26,7 +28,10 @@ | @@ -26,7 +28,10 @@ | ||
| 26 | flex-direction: column; | 28 | flex-direction: column; |
| 27 | gap: 15px; | 29 | gap: 15px; |
| 28 | transition: all 0.4s ease; | 30 | transition: all 0.4s ease; |
| 29 | - position: relative; | 31 | + position: fixed; |
| 32 | + top: 0; | ||
| 33 | + left: 0; | ||
| 34 | + height: 100vh; | ||
| 30 | border-radius: 0 10px 10px 0; | 35 | border-radius: 0 10px 10px 0; |
| 31 | z-index: 50; | 36 | z-index: 50; |
| 32 | } | 37 | } |
| @@ -34,9 +39,11 @@ | @@ -34,9 +39,11 @@ | ||
| 34 | /* 收缩状态的侧边栏 */ | 39 | /* 收缩状态的侧边栏 */ |
| 35 | #sidebar.collapsed { | 40 | #sidebar.collapsed { |
| 36 | width: 0; | 41 | width: 0; |
| 42 | + min-width: 0; | ||
| 37 | padding: 0; | 43 | padding: 0; |
| 38 | - margin-left: -15px; | ||
| 39 | - opacity: 0.95; | 44 | + margin-left: 0; |
| 45 | + opacity: 0; | ||
| 46 | + transform: translateX(-100%); | ||
| 40 | } | 47 | } |
| 41 | 48 | ||
| 42 | /* 收缩状态下隐藏内容 */ | 49 | /* 收缩状态下隐藏内容 */ |
| @@ -47,41 +54,49 @@ | @@ -47,41 +54,49 @@ | ||
| 47 | /* 切换按钮样式 */ | 54 | /* 切换按钮样式 */ |
| 48 | #sidebar-toggle { | 55 | #sidebar-toggle { |
| 49 | position: fixed; | 56 | position: fixed; |
| 50 | - top: 50%; | ||
| 51 | - transform: translateY(-50%); | ||
| 52 | - /* left is managed by JavaScript */ | 57 | + bottom: 20px; |
| 58 | + left: 20px; | ||
| 53 | width: 48px; | 59 | width: 48px; |
| 54 | height: 48px; | 60 | height: 48px; |
| 55 | - background-color: #4285f4; /* Original color */ | 61 | + background-color: #4285f4; |
| 56 | color: white; | 62 | color: white; |
| 57 | - border: none; /* Original border */ | 63 | + border: none; |
| 58 | border-radius: 50%; | 64 | border-radius: 50%; |
| 59 | - display: flex !important; /* Ensure it's displayed as flex */ | 65 | + display: flex !important; |
| 60 | align-items: center; | 66 | align-items: center; |
| 61 | justify-content: center; | 67 | justify-content: center; |
| 62 | cursor: pointer; | 68 | cursor: pointer; |
| 63 | - z-index: 1002 !important; /* Higher than media's 1000, and ensure it applies */ | 69 | + z-index: 1002 !important; |
| 64 | font-size: 20px; | 70 | font-size: 20px; |
| 65 | box-shadow: 0 3px 8px rgba(0,0,0,0.2); | 71 | box-shadow: 0 3px 8px rgba(0,0,0,0.2); |
| 66 | - transition: left 0.3s ease, background-color 0.3s ease, transform 0.3s ease; | ||
| 67 | - opacity: 1 !important; /* Ensure opacity */ | ||
| 68 | - visibility: visible !important; /* Ensure visibility */ | 72 | + transition: all 0.3s ease; |
| 73 | + opacity: 1 !important; | ||
| 74 | + visibility: visible !important; | ||
| 69 | } | 75 | } |
| 76 | + | ||
| 77 | + | ||
| 70 | 78 | ||
| 71 | /* 主内容区域样式 */ | 79 | /* 主内容区域样式 */ |
| 72 | #main-content { | 80 | #main-content { |
| 73 | - flex: 1; | 81 | + margin-left: 300px; |
| 74 | display: flex; | 82 | display: flex; |
| 75 | flex-direction: column; | 83 | flex-direction: column; |
| 76 | align-items: center; | 84 | align-items: center; |
| 77 | justify-content: center; | 85 | justify-content: center; |
| 78 | background-color: #f0f0f0; | 86 | background-color: #f0f0f0; |
| 79 | - overflow: visible; /* 修改为visible以防止内容被裁剪 */ | 87 | + overflow: visible; |
| 80 | transition: all 0.4s ease; | 88 | transition: all 0.4s ease; |
| 81 | position: relative; | 89 | position: relative; |
| 82 | padding: 10px; | 90 | padding: 10px; |
| 83 | box-sizing: border-box; | 91 | box-sizing: border-box; |
| 84 | - min-height: 100vh; /* 确保至少有足够的高度 */ | 92 | + min-height: 100vh; |
| 93 | + width: calc(100vw - 300px); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + /* 侧边栏收缩时主内容区域样式 */ | ||
| 97 | + #sidebar.collapsed ~ #main-content { | ||
| 98 | + margin-left: 0; | ||
| 99 | + width: 100vw; | ||
| 85 | } | 100 | } |
| 86 | 101 | ||
| 87 | 102 | ||
| @@ -168,26 +183,22 @@ | @@ -168,26 +183,22 @@ | ||
| 168 | visibility: visible !important; | 183 | visibility: visible !important; |
| 169 | opacity: 1 !important; | 184 | opacity: 1 !important; |
| 170 | } | 185 | } |
| 171 | - /* NEW: Fullscreen media when sidebar is collapsed */ | 186 | + /* 侧边栏收缩时媒体区域全屏显示 */ |
| 172 | #sidebar.collapsed ~ #main-content #media { | 187 | #sidebar.collapsed ~ #main-content #media { |
| 173 | - position: fixed; | ||
| 174 | - top: 0; | ||
| 175 | - left: 0; | ||
| 176 | width: 100vw; | 188 | width: 100vw; |
| 177 | height: 100vh; | 189 | height: 100vh; |
| 178 | max-width: none; | 190 | max-width: none; |
| 179 | max-height: none; | 191 | max-height: none; |
| 180 | aspect-ratio: unset; | 192 | aspect-ratio: unset; |
| 181 | - z-index: 1000; /* Ensure it's above other elements like the toggle button */ | ||
| 182 | - margin: 0; /* Reset margin */ | ||
| 183 | - padding: 0; /* Reset padding */ | ||
| 184 | - border-radius: 0; /* Remove any border-radius */ | 193 | + margin: 0; |
| 194 | + padding: 0; | ||
| 195 | + border-radius: 0; | ||
| 185 | } | 196 | } |
| 186 | 197 | ||
| 187 | - /* Optional: Adjust main content padding when sidebar is collapsed */ | 198 | + /* 侧边栏收缩时主内容区域调整 */ |
| 188 | #sidebar.collapsed ~ #main-content { | 199 | #sidebar.collapsed ~ #main-content { |
| 189 | - padding: 0; /* Remove padding as #media takes full screen */ | ||
| 190 | - background-color: #000; /* Match media background */ | 200 | + padding: 0; |
| 201 | + background-color: #000; | ||
| 191 | } | 202 | } |
| 192 | 203 | ||
| 193 | 204 | ||
| @@ -339,42 +350,15 @@ | @@ -339,42 +350,15 @@ | ||
| 339 | <script type="text/javascript" charset="utf-8"> | 350 | <script type="text/javascript" charset="utf-8"> |
| 340 | 351 | ||
| 341 | $(document).ready(function() { | 352 | $(document).ready(function() { |
| 342 | - var expandedSidebarLeft = '300px'; // Corresponds to #sidebar CSS width | ||
| 343 | - var collapsedSidebarLeft = '10px'; // Test: offset from edge | ||
| 344 | - | ||
| 345 | - // Function to update toggle button based on sidebar state | 353 | + // Function to update toggle button icon based on sidebar state |
| 346 | function updateToggleButtonState() { | 354 | function updateToggleButtonState() { |
| 347 | var sidebarIsCollapsed = $('#sidebar').hasClass('collapsed'); | 355 | var sidebarIsCollapsed = $('#sidebar').hasClass('collapsed'); |
| 348 | - console.log('[Sidebar Toggle] Sidebar collapsed state:', sidebarIsCollapsed); | ||
| 349 | var toggleButton = $('#sidebar-toggle'); | 356 | var toggleButton = $('#sidebar-toggle'); |
| 350 | if (sidebarIsCollapsed) { | 357 | if (sidebarIsCollapsed) { |
| 351 | - // Restore intended behavior: set left, icon, and original background color | ||
| 352 | - // Relies on base CSS for top: 50% and transform: translateY(-50%) | ||
| 353 | - toggleButton.html('≫').css({ | ||
| 354 | - 'left': collapsedSidebarLeft, // This is '10px' | ||
| 355 | - 'background-color': '#4285f4', // Original color | ||
| 356 | - 'top': '50%', // Ensure it matches base CSS | ||
| 357 | - 'transform': 'translateY(-50%)' // Ensure it matches base CSS | ||
| 358 | - }); | ||
| 359 | - console.log('[Sidebar Toggle] Action: Set to collapsed state. Left:', collapsedSidebarLeft, 'HTML:', toggleButton.html()); | 358 | + toggleButton.html('≫'); |
| 360 | } else { | 359 | } else { |
| 361 | - // Restore intended behavior: set left, icon, and original background color | ||
| 362 | - toggleButton.html('≪').css({ | ||
| 363 | - 'left': expandedSidebarLeft, | ||
| 364 | - 'background-color': '#4285f4', // Original color | ||
| 365 | - 'top': '50%', // Ensure it matches base CSS | ||
| 366 | - 'transform': 'translateY(-50%)' // Ensure it matches base CSS | ||
| 367 | - }); | ||
| 368 | - console.log('[Sidebar Toggle] Action: Set to expanded state. Left:', expandedSidebarLeft, 'HTML:', toggleButton.html()); | 360 | + toggleButton.html('≪'); |
| 369 | } | 361 | } |
| 370 | - // Log applied styles to be sure | ||
| 371 | - console.log('[Sidebar Toggle] Applied style attribute:', toggleButton.attr('style')); | ||
| 372 | - console.log('[Sidebar Toggle] Computed z-index:', toggleButton.css('z-index')); | ||
| 373 | - console.log('[Sidebar Toggle] Computed opacity:', toggleButton.css('opacity')); | ||
| 374 | - console.log('[Sidebar Toggle] Computed visibility:', toggleButton.css('visibility')); | ||
| 375 | - console.log('[Sidebar Toggle] Computed display:', toggleButton.css('display')); | ||
| 376 | - console.log('[Sidebar Toggle] Offset:', toggleButton.offset()); | ||
| 377 | - console.log('[Sidebar Toggle] Position:', toggleButton.position()); | ||
| 378 | } | 362 | } |
| 379 | 363 | ||
| 380 | // Initial state setup for the toggle button | 364 | // Initial state setup for the toggle button |
web/webrtcapichat.html
0 → 100644
| 1 | +<!-- | ||
| 2 | + AIfeng/2024-12-19 | ||
| 3 | + WebRTC API Chat - 数字人对话页面 | ||
| 4 | + 功能:支持文字、语音输入与数字人实时对话,包含完整的对话记录功能 | ||
| 5 | +--> | ||
| 6 | +<!DOCTYPE html> | ||
| 7 | +<html lang="zh-CN"> | ||
| 8 | +<head> | ||
| 9 | + <meta charset="UTF-8"/> | ||
| 10 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 11 | + <link rel="icon" href="favicon.ico" type="image/x-icon"> | ||
| 12 | + <link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> | ||
| 13 | + <title>WebRTC 数字人</title> | ||
| 14 | + <style> | ||
| 15 | + body { | ||
| 16 | + margin: 0; | ||
| 17 | + padding: 0; | ||
| 18 | + font-family: Arial, sans-serif; | ||
| 19 | + display: flex; | ||
| 20 | + height: 100vh; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + /* 侧边栏样式 */ | ||
| 24 | + #sidebar { | ||
| 25 | + width: 300px; | ||
| 26 | + min-width: 300px; | ||
| 27 | + max-width: 300px; | ||
| 28 | + background-color: #f8f9fa; | ||
| 29 | + padding: 20px; | ||
| 30 | + box-shadow: 0 0 15px rgba(0,0,0,0.1); | ||
| 31 | + overflow-y: auto; | ||
| 32 | + display: flex; | ||
| 33 | + flex-direction: column; | ||
| 34 | + gap: 15px; | ||
| 35 | + transition: all 0.4s ease; | ||
| 36 | + position: fixed; | ||
| 37 | + top: 0; | ||
| 38 | + left: 0; | ||
| 39 | + height: 100vh; | ||
| 40 | + border-radius: 0 10px 10px 0; | ||
| 41 | + z-index: 50; | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + /* 收缩状态的侧边栏 */ | ||
| 45 | + #sidebar.collapsed { | ||
| 46 | + width: 0; | ||
| 47 | + min-width: 0; | ||
| 48 | + padding: 0; | ||
| 49 | + margin-left: 0; | ||
| 50 | + opacity: 0; | ||
| 51 | + transform: translateX(-100%); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /* 收缩状态下隐藏内容 */ | ||
| 55 | + #sidebar.collapsed > div { | ||
| 56 | + display: none; | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /* 切换按钮样式 */ | ||
| 60 | + #sidebar-toggle { | ||
| 61 | + position: fixed; | ||
| 62 | + bottom: 20px; | ||
| 63 | + left: 20px; | ||
| 64 | + width: 48px; | ||
| 65 | + height: 48px; | ||
| 66 | + background-color: #4285f4; | ||
| 67 | + color: white; | ||
| 68 | + border: none; | ||
| 69 | + border-radius: 50%; | ||
| 70 | + display: flex !important; | ||
| 71 | + align-items: center; | ||
| 72 | + justify-content: center; | ||
| 73 | + cursor: pointer; | ||
| 74 | + z-index: 1002 !important; | ||
| 75 | + font-size: 20px; | ||
| 76 | + box-shadow: 0 3px 8px rgba(0,0,0,0.2); | ||
| 77 | + transition: all 0.3s ease; | ||
| 78 | + opacity: 1 !important; | ||
| 79 | + visibility: visible !important; | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + | ||
| 83 | + | ||
| 84 | + /* 主内容区域样式 */ | ||
| 85 | + #main-content { | ||
| 86 | + margin-left: 300px; | ||
| 87 | + display: flex; | ||
| 88 | + flex-direction: column; | ||
| 89 | + align-items: center; | ||
| 90 | + justify-content: center; | ||
| 91 | + background-color: #f0f0f0; | ||
| 92 | + overflow: visible; | ||
| 93 | + transition: all 0.4s ease; | ||
| 94 | + position: relative; | ||
| 95 | + padding: 10px; | ||
| 96 | + box-sizing: border-box; | ||
| 97 | + min-height: 100vh; | ||
| 98 | + width: calc(100vw - 300px); | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + /* 侧边栏收缩时主内容区域样式 */ | ||
| 102 | + #sidebar.collapsed ~ #main-content { | ||
| 103 | + margin-left: 0; | ||
| 104 | + width: 100vw; | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + | ||
| 108 | + | ||
| 109 | + | ||
| 110 | + /* 按钮样式 */ | ||
| 111 | + button { | ||
| 112 | + padding: 8px 16px; | ||
| 113 | + margin: 5px 0; | ||
| 114 | + cursor: pointer; | ||
| 115 | + border: none; | ||
| 116 | + border-radius: 4px; | ||
| 117 | + background-color: #4285f4; | ||
| 118 | + color: white; | ||
| 119 | + font-weight: bold; | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + button:hover { | ||
| 123 | + background-color: #3367d6; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + button:disabled { | ||
| 127 | + background-color: #cccccc; | ||
| 128 | + cursor: not-allowed; | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + /* 表单样式 */ | ||
| 132 | + .form-group { | ||
| 133 | + margin-bottom: 15px; | ||
| 134 | + width: 100%; | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + .form-control { | ||
| 138 | + width: 100%; | ||
| 139 | + padding: 8px; | ||
| 140 | + border: 1px solid #ddd; | ||
| 141 | + border-radius: 4px; | ||
| 142 | + box-sizing: border-box; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /* 媒体区域样式 */ | ||
| 146 | + #media { | ||
| 147 | + width: 100%; | ||
| 148 | + max-width: 1080px; | ||
| 149 | + height: 100%; | ||
| 150 | + max-height: 90vh; /* 限制最大高度为视口高度的90% */ | ||
| 151 | + position: relative; | ||
| 152 | + overflow: hidden; | ||
| 153 | + background-color: #000; | ||
| 154 | + margin: 0 auto; /* 居中显示 */ | ||
| 155 | + box-sizing: border-box; /* 确保padding不会增加宽度 */ | ||
| 156 | + aspect-ratio: 9/16; /* 设置宽高比为9:16(竖屏) */ | ||
| 157 | + display: block !important; /* 确保始终显示 */ | ||
| 158 | + z-index: 20; /* 提高z-index确保可见 */ | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + #media h2 { | ||
| 162 | + position: absolute; | ||
| 163 | + top: 10px; | ||
| 164 | + left: 10px; | ||
| 165 | + color: white; | ||
| 166 | + margin: 0; | ||
| 167 | + z-index: 10; | ||
| 168 | + background-color: rgba(0,0,0,0.5); | ||
| 169 | + padding: 5px 10px; | ||
| 170 | + border-radius: 4px; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + video { | ||
| 174 | + width: 100%; | ||
| 175 | + height: 100%; | ||
| 176 | + object-fit: contain; /* 保持视频比例 */ | ||
| 177 | + position: absolute; | ||
| 178 | + top: 0; | ||
| 179 | + left: 0; | ||
| 180 | + max-width: 100%; /* 确保不超出容器 */ | ||
| 181 | + max-height: 100%; /* 确保不超出容器 */ | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + /* 确保侧边栏收缩时视频元素本身也可见 */ | ||
| 185 | + #sidebar.collapsed ~ #main-content #video, | ||
| 186 | + #sidebar.collapsed + #main-content #video { | ||
| 187 | + display: block !important; | ||
| 188 | + visibility: visible !important; | ||
| 189 | + opacity: 1 !important; | ||
| 190 | + } | ||
| 191 | + /* 侧边栏收缩时媒体区域全屏显示 */ | ||
| 192 | + #sidebar.collapsed ~ #main-content #media { | ||
| 193 | + width: 100vw; | ||
| 194 | + height: 100vh; | ||
| 195 | + max-width: none; | ||
| 196 | + max-height: none; | ||
| 197 | + aspect-ratio: unset; | ||
| 198 | + margin: 0; | ||
| 199 | + padding: 0; | ||
| 200 | + border-radius: 0; | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + /* 侧边栏收缩时主内容区域调整 */ | ||
| 204 | + #sidebar.collapsed ~ #main-content { | ||
| 205 | + padding: 0; | ||
| 206 | + background-color: #000; | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + | ||
| 210 | + .option { | ||
| 211 | + display: flex; | ||
| 212 | + align-items: center; | ||
| 213 | + margin-bottom: 8px; | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + .section-title { | ||
| 217 | + font-weight: bold; | ||
| 218 | + margin-bottom: 12px; | ||
| 219 | + border-bottom: 1px solid #e0e0e0; | ||
| 220 | + padding-bottom: 8px; | ||
| 221 | + color: #4285f4; | ||
| 222 | + font-size: 16px; | ||
| 223 | + letter-spacing: 0.5px; | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + /* 美化表单控件 */ | ||
| 227 | + .form-control:focus { | ||
| 228 | + outline: none; | ||
| 229 | + border-color: #4285f4; | ||
| 230 | + box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2); | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + /* 侧边栏内部元素样式优化 */ | ||
| 234 | + #sidebar > div { | ||
| 235 | + background-color: white; | ||
| 236 | + padding: 15px; | ||
| 237 | + border-radius: 8px; | ||
| 238 | + box-shadow: 0 1px 3px rgba(0,0,0,0.05); | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + /* 聊天消息样式 */ | ||
| 242 | + #chatOverlay { | ||
| 243 | + font-family: 'Microsoft YaHei', Arial, sans-serif; | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + #chatOverlay .message { | ||
| 247 | + display: flex; | ||
| 248 | + margin-bottom: 12px; | ||
| 249 | + max-width: 100%; | ||
| 250 | + animation: fadeInUp 0.3s ease-out; | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + @keyframes fadeInUp { | ||
| 254 | + from { | ||
| 255 | + opacity: 0; | ||
| 256 | + transform: translateY(10px); | ||
| 257 | + } | ||
| 258 | + to { | ||
| 259 | + opacity: 1; | ||
| 260 | + transform: translateY(0); | ||
| 261 | + } | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + #chatOverlay .message.right { | ||
| 265 | + justify-content: flex-end; | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + #chatOverlay .message.left { | ||
| 269 | + justify-content: flex-start; | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + #chatOverlay .avatar { | ||
| 273 | + width: 28px; | ||
| 274 | + height: 28px; | ||
| 275 | + border-radius: 50%; | ||
| 276 | + margin: 0 6px; | ||
| 277 | + flex-shrink: 0; | ||
| 278 | + border: 1px solid rgba(255,255,255,0.2); | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + #chatOverlay .text-container { | ||
| 282 | + background-color: rgba(255,255,255,0.95); | ||
| 283 | + border-radius: 12px; | ||
| 284 | + padding: 8px 12px; | ||
| 285 | + max-width: 75%; | ||
| 286 | + color: #333; | ||
| 287 | + box-shadow: 0 2px 8px rgba(0,0,0,0.1); | ||
| 288 | + position: relative; | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + #chatOverlay .message.right .text-container { | ||
| 292 | + background-color: #4285f4; | ||
| 293 | + color: white; | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + /* 数字人回复样式 - 根据模式区分 */ | ||
| 297 | + #chatOverlay .message.left .text-container { | ||
| 298 | + background-color: rgba(248,249,250,0.95); | ||
| 299 | + border-left: 3px solid #4285f4; | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + /* Echo模式 - 回音重复 */ | ||
| 303 | + #chatOverlay .message.left.mode-echo .text-container { | ||
| 304 | + background-color: rgba(255,235,59,0.9); | ||
| 305 | + border-left: 3px solid #FFC107; | ||
| 306 | + color: #333; | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + /* Chat模式 - 大模型回复 */ | ||
| 310 | + #chatOverlay .message.left.mode-chat .text-container { | ||
| 311 | + background-color: rgba(76,175,80,0.9); | ||
| 312 | + border-left: 3px solid #4CAF50; | ||
| 313 | + color: white; | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + /* Audio模式 - 语音识别回复 */ | ||
| 317 | + #chatOverlay .message.left.mode-audio .text-container { | ||
| 318 | + background-color: rgba(156,39,176,0.9); | ||
| 319 | + border-left: 3px solid #9C27B0; | ||
| 320 | + color: white; | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + /* Plaintext模式 - 纯文本 */ | ||
| 324 | + #chatOverlay .message.left.mode-plaintext .text-container { | ||
| 325 | + background-color: rgba(96,125,139,0.9); | ||
| 326 | + border-left: 3px solid #607D8B; | ||
| 327 | + color: white; | ||
| 328 | + } | ||
| 329 | + | ||
| 330 | + #chatOverlay .source-tag { | ||
| 331 | + font-size: 8px; | ||
| 332 | + color: #666; | ||
| 333 | + background-color: rgba(66,133,244,0.1); | ||
| 334 | + padding: 1px 4px; | ||
| 335 | + border-radius: 6px; | ||
| 336 | + margin-bottom: 3px; | ||
| 337 | + display: inline-block; | ||
| 338 | + font-weight: 500; | ||
| 339 | + letter-spacing: 0.2px; | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + #chatOverlay .message.right .source-tag { | ||
| 343 | + background-color: rgba(255,255,255,0.2); | ||
| 344 | + color: rgba(255,255,255,0.9); | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + /* 不同模式的source-tag样式 */ | ||
| 348 | + #chatOverlay .message.left.mode-echo .source-tag { | ||
| 349 | + background-color: rgba(255,193,7,0.2); | ||
| 350 | + color: #E65100; | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + #chatOverlay .message.left.mode-chat .source-tag { | ||
| 354 | + background-color: rgba(76,175,80,0.2); | ||
| 355 | + color: #1B5E20; | ||
| 356 | + } | ||
| 357 | + | ||
| 358 | + #chatOverlay .message.left.mode-audio .source-tag { | ||
| 359 | + background-color: rgba(156,39,176,0.2); | ||
| 360 | + color: #4A148C; | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + #chatOverlay .message.left.mode-plaintext .source-tag { | ||
| 364 | + background-color: rgba(96,125,139,0.2); | ||
| 365 | + color: #263238; | ||
| 366 | + } | ||
| 367 | + | ||
| 368 | + #chatOverlay .text { | ||
| 369 | + line-height: 1.3; | ||
| 370 | + word-wrap: break-word; | ||
| 371 | + margin-bottom: 3px; | ||
| 372 | + font-size: 13px; | ||
| 373 | + } | ||
| 374 | + | ||
| 375 | + #chatOverlay .time { | ||
| 376 | + font-size: 9px; | ||
| 377 | + color: #999; | ||
| 378 | + text-align: right; | ||
| 379 | + margin-top: 3px; | ||
| 380 | + opacity: 0.7; | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + #chatOverlay .message.right .time { | ||
| 384 | + color: rgba(255,255,255,0.7); | ||
| 385 | + } | ||
| 386 | + | ||
| 387 | + /* 简化的聊天框头部 - 图标化 */ | ||
| 388 | + #chatOverlay .chat-header { | ||
| 389 | + background-color: rgba(0,0,0,0.4); | ||
| 390 | + color: rgba(255,255,255,0.9); | ||
| 391 | + padding: 3px 8px; | ||
| 392 | + border-radius: 0 0 8px 8px; | ||
| 393 | + font-size: 10px; | ||
| 394 | + font-weight: normal; | ||
| 395 | + text-align: center; | ||
| 396 | + margin: 0 -8px -8px -8px; | ||
| 397 | + border-top: 1px solid rgba(255,255,255,0.15); | ||
| 398 | + backdrop-filter: blur(8px); | ||
| 399 | + position: relative; | ||
| 400 | + flex-shrink: 0; | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + /* 清空按钮 */ | ||
| 404 | + #chatOverlay .clear-chat { | ||
| 405 | + position: absolute; | ||
| 406 | + top: 0px; | ||
| 407 | + right: 15px; | ||
| 408 | + background: none; | ||
| 409 | + border: none; | ||
| 410 | + color: rgba(255,255,255,0.6); | ||
| 411 | + cursor: pointer; | ||
| 412 | + font-size: 12px; | ||
| 413 | + padding: 1px; | ||
| 414 | + border-radius: 2px; | ||
| 415 | + transition: all 0.2s; | ||
| 416 | + } | ||
| 417 | + | ||
| 418 | + #chatOverlay .clear-chat:hover { | ||
| 419 | + background-color: rgba(255,255,255,0.1); | ||
| 420 | + color: rgba(255,255,255,0.9); | ||
| 421 | + } | ||
| 422 | + | ||
| 423 | + /* 响应式适配 */ | ||
| 424 | + @media (max-width: 2160px) { | ||
| 425 | + #chatOverlay { | ||
| 426 | + width: min(600px, 32vw) !important; | ||
| 427 | + height: 180px !important; | ||
| 428 | + } | ||
| 429 | + } | ||
| 430 | + | ||
| 431 | + /* 响应式适配 */ | ||
| 432 | + @media (max-width: 1200px) { | ||
| 433 | + #chatOverlay { | ||
| 434 | + width: min(400px, 32vw) !important; | ||
| 435 | + height: 180px !important; | ||
| 436 | + } | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + @media (max-width: 768px) { | ||
| 440 | + #chatOverlay { | ||
| 441 | + width: min(280px, 38vw) !important; | ||
| 442 | + height: 160px !important; | ||
| 443 | + bottom: 10px !important; | ||
| 444 | + right: 10px !important; | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + @media (max-width: 480px) { | ||
| 449 | + #chatOverlay { | ||
| 450 | + width: min(200px, 45vw) !important; | ||
| 451 | + height: 140px !important; | ||
| 452 | + } | ||
| 453 | + } | ||
| 454 | + </style> | ||
| 455 | +</head> | ||
| 456 | +<body> | ||
| 457 | + | ||
| 458 | +<!-- 侧边栏切换按钮 (Moved to be a direct child of body) --> | ||
| 459 | +<button id="sidebar-toggle">≪</button> | ||
| 460 | + | ||
| 461 | +<!-- 侧边栏 --> | ||
| 462 | +<div id="sidebar"> | ||
| 463 | + <div> | ||
| 464 | + <div class="section-title">连接控制</div> | ||
| 465 | + <div class="option"> | ||
| 466 | + <input id="use-stun" type="checkbox"/> | ||
| 467 | + <label for="use-stun">使用 STUN 服务器</label> | ||
| 468 | + </div> | ||
| 469 | + <button id="start" onclick="start()">开始连接</button> | ||
| 470 | + <button id="stop" style="display: none" onclick="stop()">停止连接</button> | ||
| 471 | + </div> | ||
| 472 | + | ||
| 473 | + <div> | ||
| 474 | + <div class="section-title">录制控制</div> | ||
| 475 | + <button class="btn btn-primary" id="btn_start_record">开始录制</button> | ||
| 476 | + <button class="btn btn-primary" id="btn_stop_record" disabled>停止录制</button> | ||
| 477 | + </div> | ||
| 478 | + | ||
| 479 | + <div> | ||
| 480 | + <div class="section-title">文本输入</div> | ||
| 481 | + <input type="hidden" id="sessionid" value="0"> | ||
| 482 | + <div class="form-group"> | ||
| 483 | + <label for="current-sessionid">当前会话ID</label> | ||
| 484 | + <div class="input-group"> | ||
| 485 | + <input type="text" class="form-control" id="current-sessionid" readonly placeholder="未连接"> | ||
| 486 | + <div class="input-group-append"> | ||
| 487 | + <button class="btn btn-outline-secondary" type="button" id="clear-session-btn" title="清除会话ID,重新连接">重置</button> | ||
| 488 | + </div> | ||
| 489 | + </div> | ||
| 490 | + </div> | ||
| 491 | + <form class="form-inline" id="echo-form"> | ||
| 492 | + <div class="form-group"> | ||
| 493 | + <label for="message-type">消息类型</label> | ||
| 494 | + <select class="form-control" id="message-type"> | ||
| 495 | + <option value="chat">智能对话</option> | ||
| 496 | + <option value="echo">回音模式</option> | ||
| 497 | + </select> | ||
| 498 | + </div> | ||
| 499 | + <div class="form-group"> | ||
| 500 | + <label for="message">输入文本</label> | ||
| 501 | + <textarea rows="3" class="form-control" id="message">test</textarea> | ||
| 502 | + </div> | ||
| 503 | + <button type="submit" class="btn btn-default">发送</button> | ||
| 504 | + </form> | ||
| 505 | + </div> | ||
| 506 | + | ||
| 507 | + <div> | ||
| 508 | + <div class="section-title">本地存储设置</div> | ||
| 509 | + <div class="option"> | ||
| 510 | + <input id="enable-storage" type="checkbox" checked/> | ||
| 511 | + <label for="enable-storage">启用本地聊天记录</label> | ||
| 512 | + </div> | ||
| 513 | + <button id="load-history" class="btn btn-secondary">加载历史记录</button> | ||
| 514 | + <button id="clear-storage" class="btn btn-danger">清理本地记录</button> | ||
| 515 | + <button id="view-history" class="btn btn-info">查看历史记录</button> | ||
| 516 | + </div> | ||
| 517 | + | ||
| 518 | + <div> | ||
| 519 | + <div class="section-title">控制服务器配置</div> | ||
| 520 | + <div class="option"> | ||
| 521 | + <input id="enable-control-ws" type="checkbox" checked/> | ||
| 522 | + <label for="enable-control-ws">启用控制服务器连接</label> | ||
| 523 | + </div> | ||
| 524 | + <div class="form-group"> | ||
| 525 | + <label for="control-ws-host">控制服务器主机</label> | ||
| 526 | + <input type="text" class="form-control" id="control-ws-host" placeholder="默认: 127.0.0.1"> | ||
| 527 | + </div> | ||
| 528 | + <div class="form-group"> | ||
| 529 | + <label for="control-ws-port">控制服务器端口</label> | ||
| 530 | + <input type="text" class="form-control" id="control-ws-port" placeholder="默认: 10002"> | ||
| 531 | + </div> | ||
| 532 | + <button id="save-control-ws-config" class="btn btn-default">保存控制配置</button> | ||
| 533 | + <button id="reset-control-ws-config" class="btn btn-default">重置控制配置</button> | ||
| 534 | + <button id="connect-control-ws" class="btn btn-default">连接控制服务器</button> | ||
| 535 | + <button id="disconnect-control-ws" class="btn btn-default" disabled>断开控制服务器</button> | ||
| 536 | + </div> | ||
| 537 | + | ||
| 538 | + <div> | ||
| 539 | + <div class="section-title">聊天服务器配置</div> | ||
| 540 | + <div class="form-group"> | ||
| 541 | + <label for="chat-ws-host">聊天服务器主机</label> | ||
| 542 | + <input type="text" class="form-control" id="chat-ws-host" placeholder="默认: localhost"> | ||
| 543 | + </div> | ||
| 544 | + <div class="form-group"> | ||
| 545 | + <label for="chat-ws-port">聊天服务器端口</label> | ||
| 546 | + <input type="text" class="form-control" id="chat-ws-port" placeholder="默认: 8010"> | ||
| 547 | + </div> | ||
| 548 | + <button id="save-chat-ws-config" class="btn btn-default">保存聊天配置</button> | ||
| 549 | + <button id="reset-chat-ws-config" class="btn btn-default">重置聊天配置</button> | ||
| 550 | + </div> | ||
| 551 | +</div> | ||
| 552 | + | ||
| 553 | +<!-- 主内容区域 --> | ||
| 554 | +<div id="main-content"> | ||
| 555 | + <input type="hidden" id="username" value="User"> | ||
| 556 | + <div id="media"> | ||
| 557 | + <h2>艺云展陈</h2> | ||
| 558 | + <audio id="audio" autoplay="true"></audio> | ||
| 559 | + <video id="video" autoplay="true" playsinline="true"></video> | ||
| 560 | + </div> | ||
| 561 | + <!-- 聊天消息显示区域 --> | ||
| 562 | + <div id="chatOverlay" style="position: absolute; bottom: 15px; right: 15px; width: min(320px, 30vw); height: 200px; overflow: hidden; background-color: rgba(0,0,0,0.6); border-radius: 12px; padding: 8px; color: white; z-index: 1005; backdrop-filter: blur(15px); border: 1px solid rgba(255,255,255,0.08); display: flex; flex-direction: column;"> | ||
| 563 | + <div id="chatMessages" style="overflow: hidden; flex: 1; margin-bottom: 3px; display: flex; flex-direction: column; justify-content: flex-end; position: relative; cursor: pointer;"> | ||
| 564 | + <!-- 消息将在这里动态添加 --> | ||
| 565 | + </div> | ||
| 566 | + <div class="chat-header"> | ||
| 567 | + 💬 对话 | ||
| 568 | + <button class="clear-chat" onclick="clearChatHistory()" title="清空对话记录">✕</button> | ||
| 569 | + </div> | ||
| 570 | + </div> | ||
| 571 | +</div> | ||
| 572 | + | ||
| 573 | +<script src="client.js"></script> | ||
| 574 | +<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.1/dist/sockjs.min.js"></script> | ||
| 575 | +<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.1.min.js"></script> | ||
| 576 | +<script type="text/javascript" charset="utf-8"> | ||
| 577 | + | ||
| 578 | + $(document).ready(function() { | ||
| 579 | + // Function to update toggle button icon based on sidebar state | ||
| 580 | + function updateToggleButtonState() { | ||
| 581 | + var sidebarIsCollapsed = $('#sidebar').hasClass('collapsed'); | ||
| 582 | + var toggleButton = $('#sidebar-toggle'); | ||
| 583 | + if (sidebarIsCollapsed) { | ||
| 584 | + toggleButton.html('≫'); | ||
| 585 | + } else { | ||
| 586 | + toggleButton.html('≪'); | ||
| 587 | + } | ||
| 588 | + } | ||
| 589 | + | ||
| 590 | + // Initial state setup for the toggle button | ||
| 591 | + updateToggleButtonState(); | ||
| 592 | + | ||
| 593 | + // Load saved WebSocket configuration or set defaults | ||
| 594 | + function loadWsConfig() { | ||
| 595 | + // 控制服务器配置 | ||
| 596 | + var savedControlHost = localStorage.getItem('controlWsHost'); | ||
| 597 | + var savedControlPort = localStorage.getItem('controlWsPort'); | ||
| 598 | + var enableControlWs = localStorage.getItem('enableControlWs'); | ||
| 599 | + | ||
| 600 | + if (savedControlHost) { | ||
| 601 | + $('#control-ws-host').val(savedControlHost); | ||
| 602 | + } else { | ||
| 603 | + $('#control-ws-host').val('127.0.0.1'); // Default host | ||
| 604 | + } | ||
| 605 | + | ||
| 606 | + if (savedControlPort) { | ||
| 607 | + $('#control-ws-port').val(savedControlPort); | ||
| 608 | + } else { | ||
| 609 | + $('#control-ws-port').val('10002'); // Default port | ||
| 610 | + } | ||
| 611 | + | ||
| 612 | + // 设置控制服务器开关状态 | ||
| 613 | + if (enableControlWs !== null) { | ||
| 614 | + $('#enable-control-ws').prop('checked', enableControlWs === 'true'); | ||
| 615 | + } | ||
| 616 | + | ||
| 617 | + // 聊天服务器配置 | ||
| 618 | + var savedChatHost = localStorage.getItem('chatWsHost'); | ||
| 619 | + var savedChatPort = localStorage.getItem('chatWsPort'); | ||
| 620 | + | ||
| 621 | + if (savedChatHost) { | ||
| 622 | + $('#chat-ws-host').val(savedChatHost); | ||
| 623 | + } else { | ||
| 624 | + $('#chat-ws-host').val('localhost'); // Default host | ||
| 625 | + } | ||
| 626 | + | ||
| 627 | + if (savedChatPort) { | ||
| 628 | + $('#chat-ws-port').val(savedChatPort); | ||
| 629 | + } else { | ||
| 630 | + $('#chat-ws-port').val('8010'); // Default port | ||
| 631 | + } | ||
| 632 | + | ||
| 633 | + // 初始化控制服务器连接按钮状态 | ||
| 634 | + $('#connect-control-ws').prop('disabled', false); | ||
| 635 | + $('#disconnect-control-ws').prop('disabled', true); | ||
| 636 | + } | ||
| 637 | + | ||
| 638 | + loadWsConfig(); // Load config on document ready | ||
| 639 | + | ||
| 640 | + // 初始化聊天滚轮支持 | ||
| 641 | + initChatWheelSupport(); | ||
| 642 | + | ||
| 643 | + // 侧边栏切换功能 | ||
| 644 | + $('#sidebar-toggle').click(function() { | ||
| 645 | + $('#sidebar').toggleClass('collapsed'); | ||
| 646 | + updateToggleButtonState(); | ||
| 647 | + }); | ||
| 648 | + | ||
| 649 | + // Note: The original '#expand-sidebar'.click handler was part of the replaced block. | ||
| 650 | + // If an element with id #expand-sidebar exists and needs to control the sidebar, | ||
| 651 | + // its click handler should be re-implemented similar to this: | ||
| 652 | + // $('#expand-sidebar').click(function() { | ||
| 653 | + // if ($('#sidebar').hasClass('collapsed')) { | ||
| 654 | + // $('#sidebar').removeClass('collapsed'); | ||
| 655 | + // updateToggleButtonState(); | ||
| 656 | + // } | ||
| 657 | + // }); | ||
| 658 | + | ||
| 659 | + // 控制服务器配置保存和重置 | ||
| 660 | + $('#save-control-ws-config').click(function() { | ||
| 661 | + var controlHost = $('#control-ws-host').val().trim(); | ||
| 662 | + var controlPort = $('#control-ws-port').val().trim(); | ||
| 663 | + var enableControl = $('#enable-control-ws').prop('checked'); | ||
| 664 | + | ||
| 665 | + if (!controlHost) { | ||
| 666 | + alert('控制服务器主机不能为空。'); | ||
| 667 | + $('#control-ws-host').focus(); | ||
| 668 | + return; | ||
| 669 | + } | ||
| 670 | + if (!controlPort) { | ||
| 671 | + alert('控制服务器端口不能为空。'); | ||
| 672 | + $('#control-ws-port').focus(); | ||
| 673 | + return; | ||
| 674 | + } | ||
| 675 | + | ||
| 676 | + localStorage.setItem('controlWsHost', controlHost); | ||
| 677 | + localStorage.setItem('controlWsPort', controlPort); | ||
| 678 | + localStorage.setItem('enableControlWs', enableControl.toString()); | ||
| 679 | + | ||
| 680 | + var originalText = $(this).text(); | ||
| 681 | + $(this).text('已保存!').prop('disabled', true); | ||
| 682 | + setTimeout(function() { | ||
| 683 | + $('#save-control-ws-config').text(originalText).prop('disabled', false); | ||
| 684 | + }, 1500); | ||
| 685 | + | ||
| 686 | + console.log('控制服务器配置已保存: Host - ' + controlHost + ', Port - ' + controlPort + ', Enabled - ' + enableControl); | ||
| 687 | + }); | ||
| 688 | + | ||
| 689 | + // 聊天服务器配置保存 | ||
| 690 | + $('#save-chat-ws-config').click(function() { | ||
| 691 | + var chatHost = $('#chat-ws-host').val().trim(); | ||
| 692 | + var chatPort = $('#chat-ws-port').val().trim(); | ||
| 693 | + | ||
| 694 | + if (!chatHost) { | ||
| 695 | + alert('聊天服务器主机不能为空。'); | ||
| 696 | + $('#chat-ws-host').focus(); | ||
| 697 | + return; | ||
| 698 | + } | ||
| 699 | + if (!chatPort) { | ||
| 700 | + alert('聊天服务器端口不能为空。'); | ||
| 701 | + $('#chat-ws-port').focus(); | ||
| 702 | + return; | ||
| 703 | + } | ||
| 704 | + | ||
| 705 | + localStorage.setItem('chatWsHost', chatHost); | ||
| 706 | + localStorage.setItem('chatWsPort', chatPort); | ||
| 707 | + | ||
| 708 | + var originalText = $(this).text(); | ||
| 709 | + $(this).text('已保存!').prop('disabled', true); | ||
| 710 | + setTimeout(function() { | ||
| 711 | + $('#save-chat-ws-config').text(originalText).prop('disabled', false); | ||
| 712 | + }, 1500); | ||
| 713 | + | ||
| 714 | + console.log('聊天服务器配置已保存: Host - ' + chatHost + ', Port - ' + chatPort); | ||
| 715 | + }); | ||
| 716 | + | ||
| 717 | + // 控制服务器配置重置 | ||
| 718 | + $('#reset-control-ws-config').click(function() { | ||
| 719 | + $('#control-ws-host').val('127.0.0.1'); | ||
| 720 | + $('#control-ws-port').val('10002'); | ||
| 721 | + $('#enable-control-ws').prop('checked', true); | ||
| 722 | + | ||
| 723 | + var originalText = $(this).text(); | ||
| 724 | + $(this).text('已重置!').prop('disabled', true); | ||
| 725 | + setTimeout(function() { | ||
| 726 | + $('#reset-control-ws-config').text(originalText).prop('disabled', false); | ||
| 727 | + }, 1500); | ||
| 728 | + console.log('控制服务器配置已重置为默认值'); | ||
| 729 | + }); | ||
| 730 | + | ||
| 731 | + // 聊天服务器配置重置 | ||
| 732 | + $('#reset-chat-ws-config').click(function() { | ||
| 733 | + $('#chat-ws-host').val('localhost'); | ||
| 734 | + $('#chat-ws-port').val('8010'); | ||
| 735 | + | ||
| 736 | + var originalText = $(this).text(); | ||
| 737 | + $(this).text('已重置!').prop('disabled', true); | ||
| 738 | + setTimeout(function() { | ||
| 739 | + $('#reset-chat-ws-config').text(originalText).prop('disabled', false); | ||
| 740 | + }, 1500); | ||
| 741 | + console.log('聊天服务器配置已重置为默认值'); | ||
| 742 | + }); | ||
| 743 | + | ||
| 744 | + // 控制服务器连接管理 | ||
| 745 | + var controlWs = null; | ||
| 746 | + | ||
| 747 | + $('#connect-control-ws').click(function() { | ||
| 748 | + if (!$('#enable-control-ws').prop('checked')) { | ||
| 749 | + alert('请先启用控制服务器连接'); | ||
| 750 | + return; | ||
| 751 | + } | ||
| 752 | + | ||
| 753 | + var controlHost = $('#control-ws-host').val().trim() || '127.0.0.1'; | ||
| 754 | + var controlPort = $('#control-ws-port').val().trim() || '10002'; | ||
| 755 | + var controlWsUrl = 'ws://' + controlHost + ':' + controlPort + '/ws'; | ||
| 756 | + | ||
| 757 | + console.log('连接控制服务器:', controlWsUrl); | ||
| 758 | + | ||
| 759 | + controlWs = new WebSocket(controlWsUrl); | ||
| 760 | + | ||
| 761 | + controlWs.onopen = function() { | ||
| 762 | + console.log('控制服务器连接成功'); | ||
| 763 | + $('#connect-control-ws').prop('disabled', true); | ||
| 764 | + $('#disconnect-control-ws').prop('disabled', false); | ||
| 765 | + }; | ||
| 766 | + | ||
| 767 | + controlWs.onclose = function() { | ||
| 768 | + console.log('控制服务器连接关闭'); | ||
| 769 | + $('#connect-control-ws').prop('disabled', false); | ||
| 770 | + $('#disconnect-control-ws').prop('disabled', true); | ||
| 771 | + }; | ||
| 772 | + | ||
| 773 | + controlWs.onerror = function(error) { | ||
| 774 | + console.error('控制服务器连接错误:', error); | ||
| 775 | + alert('控制服务器连接失败'); | ||
| 776 | + }; | ||
| 777 | + }); | ||
| 778 | + | ||
| 779 | + $('#disconnect-control-ws').click(function() { | ||
| 780 | + if (controlWs) { | ||
| 781 | + controlWs.close(); | ||
| 782 | + controlWs = null; | ||
| 783 | + } | ||
| 784 | + }); | ||
| 785 | + | ||
| 786 | + // 控制服务器开关状态变化处理 | ||
| 787 | + $('#enable-control-ws').change(function() { | ||
| 788 | + var enabled = $(this).prop('checked'); | ||
| 789 | + localStorage.setItem('enableControlWs', enabled.toString()); | ||
| 790 | + | ||
| 791 | + if (!enabled && controlWs) { | ||
| 792 | + controlWs.close(); | ||
| 793 | + controlWs = null; | ||
| 794 | + } | ||
| 795 | + }); | ||
| 796 | + | ||
| 797 | + // Old WebSocket code commented out | ||
| 798 | + // var host = window.location.hostname | ||
| 799 | + // var ws = new WebSocket("ws://"+host+":8000/humanecho"); | ||
| 800 | + // //document.getElementsByTagName("video")[0].setAttribute("src", aa["video"]); | ||
| 801 | + // ws.onopen = function() { | ||
| 802 | + // console.log('Connected'); | ||
| 803 | + // }; | ||
| 804 | + // ws.onmessage = function(e) { | ||
| 805 | + // console.log('Received: ' + e.data); | ||
| 806 | + // data = e | ||
| 807 | + // var vid = JSON.parse(data.data); | ||
| 808 | + // console.log(typeof(vid),vid) | ||
| 809 | + // //document.getElementsByTagName("video")[0].setAttribute("src", vid["video"]); | ||
| 810 | + | ||
| 811 | + // }; | ||
| 812 | + // ws.onclose = function(e) { | ||
| 813 | + // console.log('Closed'); | ||
| 814 | + // }; | ||
| 815 | + | ||
| 816 | + $('#echo-form').on('submit', function(e) { | ||
| 817 | + e.preventDefault(); | ||
| 818 | + var message = $('#message').val().trim(); | ||
| 819 | + if (!message) return; | ||
| 820 | + | ||
| 821 | + console.log('Sending: ' + message); | ||
| 822 | + console.log('sessionid: ', document.getElementById('sessionid').value); | ||
| 823 | + | ||
| 824 | + // 保存最后一条用户消息用于模式判断 | ||
| 825 | + localStorage.setItem('lastUserMessage', message); | ||
| 826 | + | ||
| 827 | + // 获取选择的消息类型,默认为chat | ||
| 828 | + var messageType = document.getElementById('message-type') ? document.getElementById('message-type').value : 'chat'; | ||
| 829 | + | ||
| 830 | + // 发送消息到服务器,不再直接添加到界面,等待WebSocket推送 | ||
| 831 | + var requestData = { | ||
| 832 | + text: message, | ||
| 833 | + type: messageType, | ||
| 834 | + interrupt: true, | ||
| 835 | + sessionid: parseInt(document.getElementById('sessionid').value), | ||
| 836 | + }; | ||
| 837 | + console.log('准备发送HTTP请求到/human:', requestData); | ||
| 838 | + console.log('当前WebSocket连接状态:', ws ? ws.readyState : 'WebSocket未初始化'); | ||
| 839 | + | ||
| 840 | + fetch('/human', { | ||
| 841 | + body: JSON.stringify(requestData), | ||
| 842 | + headers: { | ||
| 843 | + 'Content-Type': 'application/json', | ||
| 844 | + 'X-Request-Source': 'web' | ||
| 845 | + }, | ||
| 846 | + method: 'POST' | ||
| 847 | + }).then(response => { | ||
| 848 | + console.log('HTTP响应状态:', response.status); | ||
| 849 | + return response.json(); | ||
| 850 | + }).then(data => { | ||
| 851 | + console.log('/human接口响应:', data); | ||
| 852 | + if (data.code !== 0) { | ||
| 853 | + console.error('服务器处理失败:', data.message || data.msg); | ||
| 854 | + } | ||
| 855 | + // 所有消息显示都通过WebSocket推送,不再从HTTP响应获取数据 | ||
| 856 | + }).catch(error => { | ||
| 857 | + console.error('发送消息错误:', error); | ||
| 858 | + // 网络错误时添加错误消息到界面 | ||
| 859 | + addMessage(`网络错误: ${error.message}`, 'left', '系统错误', 'error'); | ||
| 860 | + }); | ||
| 861 | + | ||
| 862 | + $('#message').val(''); | ||
| 863 | + }); | ||
| 864 | + | ||
| 865 | + // 本地存储设置事件处理 | ||
| 866 | + $('#enable-storage').change(function() { | ||
| 867 | + const enabled = $(this).is(':checked'); | ||
| 868 | + ChatStorage.setStorageEnabled(enabled); | ||
| 869 | + console.log('本地存储已', enabled ? '启用' : '禁用'); | ||
| 870 | + }); | ||
| 871 | + | ||
| 872 | + $('#load-history').click(function() { | ||
| 873 | + loadChatHistory(); | ||
| 874 | + alert('历史记录已加载!'); | ||
| 875 | + }); | ||
| 876 | + | ||
| 877 | + $('#clear-storage').click(function() { | ||
| 878 | + if (confirm('确定要清理所有本地聊天记录吗?此操作不可恢复!')) { | ||
| 879 | + ChatStorage.clearStorage(); | ||
| 880 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 881 | + if (chatMessages) { | ||
| 882 | + chatMessages.innerHTML = ''; | ||
| 883 | + } | ||
| 884 | + alert('本地记录已清理!'); | ||
| 885 | + } | ||
| 886 | + }); | ||
| 887 | + | ||
| 888 | + $('#view-history').click(function() { | ||
| 889 | + const dates = ChatStorage.getAllDates(); | ||
| 890 | + if (dates.length === 0) { | ||
| 891 | + alert('暂无历史记录'); | ||
| 892 | + return; | ||
| 893 | + } | ||
| 894 | + | ||
| 895 | + let historyHtml = '<div style="max-height: 400px; overflow-y: auto; padding: 10px;">'; | ||
| 896 | + historyHtml += '<h3>聊天记录</h3>'; | ||
| 897 | + | ||
| 898 | + dates.forEach(date => { | ||
| 899 | + const dateHistory = ChatStorage.getDateHistory(date); | ||
| 900 | + historyHtml += `<h4>${date} (${dateHistory.length}条消息)</h4>`; | ||
| 901 | + | ||
| 902 | + dateHistory.forEach(msg => { | ||
| 903 | + const time = new Date(msg.timestamp).toLocaleTimeString('zh-CN', { hour12: false }); | ||
| 904 | + const sourceIcon = msg.type === 'right' ? '👤' : '🤖'; | ||
| 905 | + historyHtml += `<div style="margin: 5px 0; padding: 5px; border-left: 3px solid ${msg.type === 'right' ? '#4285f4' : '#4CAF50'}; background: #f9f9f9;">`; | ||
| 906 | + historyHtml += `<small>${sourceIcon} ${time} - 会话${msg.sessionId}</small><br>`; | ||
| 907 | + historyHtml += `<span>${msg.text}</span>`; | ||
| 908 | + historyHtml += '</div>'; | ||
| 909 | + }); | ||
| 910 | + }); | ||
| 911 | + | ||
| 912 | + historyHtml += '</div>'; | ||
| 913 | + | ||
| 914 | + // 创建模态框显示历史记录 | ||
| 915 | + const modal = document.createElement('div'); | ||
| 916 | + modal.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center;'; | ||
| 917 | + | ||
| 918 | + const content = document.createElement('div'); | ||
| 919 | + content.style.cssText = 'background: white; border-radius: 8px; max-width: 80%; max-height: 80%; overflow: hidden;'; | ||
| 920 | + content.innerHTML = historyHtml + '<div style="text-align: center; padding: 10px;"><button onclick="this.closest(\'div\').parentElement.remove()" style="padding: 8px 16px; background: #4285f4; color: white; border: none; border-radius: 4px; cursor: pointer;">关闭</button></div>'; | ||
| 921 | + | ||
| 922 | + modal.appendChild(content); | ||
| 923 | + document.body.appendChild(modal); | ||
| 924 | + | ||
| 925 | + // 点击背景关闭 | ||
| 926 | + modal.addEventListener('click', function(e) { | ||
| 927 | + if (e.target === modal) { | ||
| 928 | + modal.remove(); | ||
| 929 | + } | ||
| 930 | + }); | ||
| 931 | + }); | ||
| 932 | + | ||
| 933 | + // 页面加载时初始化存储设置 | ||
| 934 | + $(document).ready(function() { | ||
| 935 | + const storageEnabled = ChatStorage.isStorageEnabled(); | ||
| 936 | + $('#enable-storage').prop('checked', storageEnabled); | ||
| 937 | + | ||
| 938 | + // 自动加载历史记录 | ||
| 939 | + if (storageEnabled) { | ||
| 940 | + setTimeout(loadChatHistory, 1000); // 延迟1秒加载,确保页面完全加载 | ||
| 941 | + } | ||
| 942 | + }); | ||
| 943 | + | ||
| 944 | + $('#btn_start_record').click(function() { | ||
| 945 | + console.log('Starting recording...'); | ||
| 946 | + fetch('/record', { | ||
| 947 | + body: JSON.stringify({ | ||
| 948 | + type: 'start_record', | ||
| 949 | + sessionid: parseInt(document.getElementById('sessionid').value), | ||
| 950 | + }), | ||
| 951 | + headers: { | ||
| 952 | + 'Content-Type': 'application/json' | ||
| 953 | + }, | ||
| 954 | + method: 'POST' | ||
| 955 | + }).then(function(response) { | ||
| 956 | + if (response.ok) { | ||
| 957 | + console.log('Recording started.'); | ||
| 958 | + $('#btn_start_record').prop('disabled', true); | ||
| 959 | + $('#btn_stop_record').prop('disabled', false); | ||
| 960 | + } else { | ||
| 961 | + console.error('Failed to start recording.'); | ||
| 962 | + } | ||
| 963 | + }).catch(function(error) { | ||
| 964 | + console.error('Error:', error); | ||
| 965 | + }); | ||
| 966 | + }); | ||
| 967 | + | ||
| 968 | + $('#btn_stop_record').click(function() { | ||
| 969 | + console.log('Stopping recording...'); | ||
| 970 | + fetch('/record', { | ||
| 971 | + body: JSON.stringify({ | ||
| 972 | + type: 'end_record', | ||
| 973 | + sessionid: parseInt(document.getElementById('sessionid').value), | ||
| 974 | + }), | ||
| 975 | + headers: { | ||
| 976 | + 'Content-Type': 'application/json' | ||
| 977 | + }, | ||
| 978 | + method: 'POST' | ||
| 979 | + }).then(function(response) { | ||
| 980 | + if (response.ok) { | ||
| 981 | + console.log('Recording stopped.'); | ||
| 982 | + $('#btn_start_record').prop('disabled', false); | ||
| 983 | + $('#btn_stop_record').prop('disabled', true); | ||
| 984 | + } else { | ||
| 985 | + console.error('Failed to stop recording.'); | ||
| 986 | + } | ||
| 987 | + }).catch(function(error) { | ||
| 988 | + console.error('Error:', error); | ||
| 989 | + }); | ||
| 990 | + }); | ||
| 991 | + | ||
| 992 | + // WebSocket connection to Fay digital avatar (port 10002) | ||
| 993 | + var ws; | ||
| 994 | + var reconnectInterval = 5000; // 初始重连间隔为5秒 | ||
| 995 | + var reconnectAttempts = 0; | ||
| 996 | + var maxReconnectInterval = 60000; // 最大重连间隔为60秒 | ||
| 997 | + var isReconnecting = false; // 标记是否正在重连中 | ||
| 998 | + | ||
| 999 | + function generateUsername() { | ||
| 1000 | + var username = 'User'; | ||
| 1001 | + // + Math.floor(Math.random() * 10000) | ||
| 1002 | + return username; | ||
| 1003 | + } | ||
| 1004 | + | ||
| 1005 | + function setUsername() { | ||
| 1006 | + var storedUsername = localStorage.getItem('username'); | ||
| 1007 | + // console.log("当前存有的username:"+storedUsername); | ||
| 1008 | + if (!storedUsername) { | ||
| 1009 | + storedUsername = generateUsername(); | ||
| 1010 | + localStorage.setItem('username', storedUsername); | ||
| 1011 | + } | ||
| 1012 | + $('#username').val(storedUsername); // Use the username as the session ID | ||
| 1013 | + } | ||
| 1014 | + setUsername(); | ||
| 1015 | + | ||
| 1016 | + // 页面加载时恢复聊天记录 | ||
| 1017 | + function loadChatHistory() { | ||
| 1018 | + const recentMessages = ChatStorage.loadRecentMessages(); | ||
| 1019 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1020 | + | ||
| 1021 | + if (chatMessages && recentMessages.length > 0) { | ||
| 1022 | + // 清空现有消息 | ||
| 1023 | + chatMessages.innerHTML = ''; | ||
| 1024 | + | ||
| 1025 | + // 添加历史消息 | ||
| 1026 | + recentMessages.forEach(msg => { | ||
| 1027 | + addMessage(msg.text, msg.type, msg.source, msg.mode, msg.modelInfo || '', msg.requestSource || ''); | ||
| 1028 | + }); | ||
| 1029 | + | ||
| 1030 | + console.log(`已加载 ${recentMessages.length} 条历史消息`); | ||
| 1031 | + } | ||
| 1032 | + } | ||
| 1033 | + | ||
| 1034 | + // 本地存储管理 | ||
| 1035 | + const ChatStorage = { | ||
| 1036 | + // 获取存储设置 | ||
| 1037 | + isStorageEnabled: function() { | ||
| 1038 | + return localStorage.getItem('chatStorageEnabled') !== 'false'; | ||
| 1039 | + }, | ||
| 1040 | + | ||
| 1041 | + // 设置存储开关 | ||
| 1042 | + setStorageEnabled: function(enabled) { | ||
| 1043 | + localStorage.setItem('chatStorageEnabled', enabled.toString()); | ||
| 1044 | + }, | ||
| 1045 | + | ||
| 1046 | + // 保存消息到本地存储 | ||
| 1047 | + saveMessage: function(messageData) { | ||
| 1048 | + if (!this.isStorageEnabled()) return; | ||
| 1049 | + | ||
| 1050 | + const sessionId = document.getElementById('sessionid').value; | ||
| 1051 | + const storageKey = `chat_history_${sessionId}`; | ||
| 1052 | + let history = JSON.parse(localStorage.getItem(storageKey) || '[]'); | ||
| 1053 | + | ||
| 1054 | + // 添加新消息 | ||
| 1055 | + history.push({ | ||
| 1056 | + ...messageData, | ||
| 1057 | + timestamp: new Date().toISOString(), | ||
| 1058 | + date: new Date().toDateString() | ||
| 1059 | + }); | ||
| 1060 | + | ||
| 1061 | + // 保存到localStorage | ||
| 1062 | + localStorage.setItem(storageKey, JSON.stringify(history)); | ||
| 1063 | + | ||
| 1064 | + // 同时保存到按日期分组的存储中 | ||
| 1065 | + this.saveToDateStorage(messageData); | ||
| 1066 | + }, | ||
| 1067 | + | ||
| 1068 | + // 按日期保存消息 | ||
| 1069 | + saveToDateStorage: function(messageData) { | ||
| 1070 | + const today = new Date().toDateString(); | ||
| 1071 | + const dateKey = `chat_date_${today.replace(/\s+/g, '_')}`; | ||
| 1072 | + let dateHistory = JSON.parse(localStorage.getItem(dateKey) || '[]'); | ||
| 1073 | + | ||
| 1074 | + dateHistory.push({ | ||
| 1075 | + ...messageData, | ||
| 1076 | + timestamp: new Date().toISOString(), | ||
| 1077 | + sessionId: document.getElementById('sessionid').value | ||
| 1078 | + }); | ||
| 1079 | + | ||
| 1080 | + localStorage.setItem(dateKey, JSON.stringify(dateHistory)); | ||
| 1081 | + }, | ||
| 1082 | + | ||
| 1083 | + // 加载最近12条消息 | ||
| 1084 | + loadRecentMessages: function() { | ||
| 1085 | + if (!this.isStorageEnabled()) return []; | ||
| 1086 | + | ||
| 1087 | + const sessionId = document.getElementById('sessionid').value; | ||
| 1088 | + const storageKey = `chat_history_${sessionId}`; | ||
| 1089 | + const history = JSON.parse(localStorage.getItem(storageKey) || '[]'); | ||
| 1090 | + | ||
| 1091 | + // 返回最后12条消息 | ||
| 1092 | + return history.slice(-12); | ||
| 1093 | + }, | ||
| 1094 | + | ||
| 1095 | + // 获取所有日期的聊天记录 | ||
| 1096 | + getAllDates: function() { | ||
| 1097 | + const dates = []; | ||
| 1098 | + for (let i = 0; i < localStorage.length; i++) { | ||
| 1099 | + const key = localStorage.key(i); | ||
| 1100 | + if (key && key.startsWith('chat_date_')) { | ||
| 1101 | + const date = key.replace('chat_date_', '').replace(/_/g, ' '); | ||
| 1102 | + dates.push(date); | ||
| 1103 | + } | ||
| 1104 | + } | ||
| 1105 | + return dates.sort((a, b) => new Date(b) - new Date(a)); | ||
| 1106 | + }, | ||
| 1107 | + | ||
| 1108 | + // 获取指定日期的聊天记录 | ||
| 1109 | + getDateHistory: function(date) { | ||
| 1110 | + const dateKey = `chat_date_${date.replace(/\s+/g, '_')}`; | ||
| 1111 | + return JSON.parse(localStorage.getItem(dateKey) || '[]'); | ||
| 1112 | + }, | ||
| 1113 | + | ||
| 1114 | + // 清理本地记录 | ||
| 1115 | + clearStorage: function() { | ||
| 1116 | + const keys = []; | ||
| 1117 | + for (let i = 0; i < localStorage.length; i++) { | ||
| 1118 | + const key = localStorage.key(i); | ||
| 1119 | + if (key && (key.startsWith('chat_history_') || key.startsWith('chat_date_'))) { | ||
| 1120 | + keys.push(key); | ||
| 1121 | + } | ||
| 1122 | + } | ||
| 1123 | + keys.forEach(key => localStorage.removeItem(key)); | ||
| 1124 | + } | ||
| 1125 | + }; | ||
| 1126 | + | ||
| 1127 | + // 全局 addMessage 函数定义 | ||
| 1128 | + function addMessage(text, type = "right", source = "", mode = "", modelInfo = "", requestSource = "") { | ||
| 1129 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1130 | + if (!chatMessages) { | ||
| 1131 | + console.error('聊天消息容器不存在'); | ||
| 1132 | + return; | ||
| 1133 | + } | ||
| 1134 | + | ||
| 1135 | + // 创建消息数据对象 | ||
| 1136 | + const messageData = { | ||
| 1137 | + text: text, | ||
| 1138 | + type: type, | ||
| 1139 | + source: source, | ||
| 1140 | + mode: mode, | ||
| 1141 | + modelInfo: modelInfo, | ||
| 1142 | + requestSource: requestSource | ||
| 1143 | + }; | ||
| 1144 | + | ||
| 1145 | + // 保存到本地存储 | ||
| 1146 | + ChatStorage.saveMessage(messageData); | ||
| 1147 | + | ||
| 1148 | + const messageDiv = document.createElement("div"); | ||
| 1149 | + messageDiv.classList.add("message", type); | ||
| 1150 | + | ||
| 1151 | + // 为左侧消息添加模式类 | ||
| 1152 | + if (type === "left" && mode) { | ||
| 1153 | + messageDiv.classList.add("mode-" + mode); | ||
| 1154 | + } | ||
| 1155 | + | ||
| 1156 | + const avatar = document.createElement("div"); | ||
| 1157 | + avatar.classList.add("avatar"); | ||
| 1158 | + avatar.style.width = "28px"; | ||
| 1159 | + avatar.style.height = "28px"; | ||
| 1160 | + avatar.style.borderRadius = "50%"; | ||
| 1161 | + avatar.style.display = "flex"; | ||
| 1162 | + avatar.style.alignItems = "center"; | ||
| 1163 | + avatar.style.justifyContent = "center"; | ||
| 1164 | + avatar.style.fontSize = "12px"; | ||
| 1165 | + avatar.style.fontWeight = "bold"; | ||
| 1166 | + avatar.style.color = "white"; | ||
| 1167 | + avatar.style.border = "1px solid rgba(255,255,255,0.2)"; | ||
| 1168 | + | ||
| 1169 | + if (type === "right") { | ||
| 1170 | + avatar.style.backgroundColor = "#4285f4"; | ||
| 1171 | + avatar.textContent = "👤"; | ||
| 1172 | + } else { | ||
| 1173 | + // 根据模式设置不同的头像和颜色 | ||
| 1174 | + switch(mode) { | ||
| 1175 | + case "echo": | ||
| 1176 | + avatar.style.backgroundColor = "#FFC107"; | ||
| 1177 | + avatar.textContent = "🔄"; | ||
| 1178 | + break; | ||
| 1179 | + case "chat": | ||
| 1180 | + avatar.style.backgroundColor = "#4CAF50"; | ||
| 1181 | + avatar.textContent = "🤖"; | ||
| 1182 | + break; | ||
| 1183 | + case "audio": | ||
| 1184 | + avatar.style.backgroundColor = "#9C27B0"; | ||
| 1185 | + avatar.textContent = "🎤"; | ||
| 1186 | + break; | ||
| 1187 | + case "plaintext": | ||
| 1188 | + avatar.style.backgroundColor = "#607D8B"; | ||
| 1189 | + avatar.textContent = "📝"; | ||
| 1190 | + break; | ||
| 1191 | + default: | ||
| 1192 | + avatar.style.backgroundColor = "#34a853"; | ||
| 1193 | + avatar.textContent = "🤖"; | ||
| 1194 | + } | ||
| 1195 | + } | ||
| 1196 | + | ||
| 1197 | + const textContainer = document.createElement("div"); | ||
| 1198 | + textContainer.classList.add("text-container"); | ||
| 1199 | + | ||
| 1200 | + // 添加来源标签 | ||
| 1201 | + if (source) { | ||
| 1202 | + const sourceTag = document.createElement("div"); | ||
| 1203 | + sourceTag.classList.add("source-tag"); | ||
| 1204 | + // 根据模式显示不同的标签文本 | ||
| 1205 | + let tagText = source; | ||
| 1206 | + if (type === "right") { | ||
| 1207 | + // 用户消息,显示请求来源 | ||
| 1208 | + if (requestSource) { | ||
| 1209 | + tagText = requestSource === 'web' ? "🌐 网页" : requestSource === 'api' ? "🔗 API" : "👤 用户"; | ||
| 1210 | + } else { | ||
| 1211 | + tagText = "👤 用户"; | ||
| 1212 | + } | ||
| 1213 | + } else { | ||
| 1214 | + // 数字人回复,显示模式和模型信息 | ||
| 1215 | + switch(mode) { | ||
| 1216 | + case "echo": | ||
| 1217 | + tagText = "🔄 回音"; | ||
| 1218 | + break; | ||
| 1219 | + case "chat": | ||
| 1220 | + tagText = modelInfo ? `🤖 ${modelInfo}` : "🤖 智能"; | ||
| 1221 | + break; | ||
| 1222 | + case "audio": | ||
| 1223 | + tagText = "🎤 语音"; | ||
| 1224 | + break; | ||
| 1225 | + case "plaintext": | ||
| 1226 | + tagText = "📝 文本"; | ||
| 1227 | + break; | ||
| 1228 | + default: | ||
| 1229 | + tagText = "🤖 数字人"; | ||
| 1230 | + } | ||
| 1231 | + } | ||
| 1232 | + sourceTag.textContent = tagText; | ||
| 1233 | + textContainer.appendChild(sourceTag); | ||
| 1234 | + } | ||
| 1235 | + | ||
| 1236 | + const textDiv = document.createElement("div"); | ||
| 1237 | + textDiv.classList.add("text"); | ||
| 1238 | + textDiv.textContent = text; | ||
| 1239 | + textContainer.appendChild(textDiv); | ||
| 1240 | + | ||
| 1241 | + const timeDiv = document.createElement("div"); | ||
| 1242 | + timeDiv.classList.add("time"); | ||
| 1243 | + const now = new Date(); | ||
| 1244 | + timeDiv.textContent = now.toLocaleTimeString('zh-CN', { hour12: false }); | ||
| 1245 | + textContainer.appendChild(timeDiv); | ||
| 1246 | + | ||
| 1247 | + if (type === "right") { | ||
| 1248 | + messageDiv.appendChild(textContainer); | ||
| 1249 | + messageDiv.appendChild(avatar); | ||
| 1250 | + } else { | ||
| 1251 | + messageDiv.appendChild(avatar); | ||
| 1252 | + messageDiv.appendChild(textContainer); | ||
| 1253 | + } | ||
| 1254 | + | ||
| 1255 | + chatMessages.appendChild(messageDiv); | ||
| 1256 | + | ||
| 1257 | + // 限制消息数量,只保留最新的12条消息 | ||
| 1258 | + const messages = chatMessages.children; | ||
| 1259 | + const maxMessages = 12; | ||
| 1260 | + while (messages.length > maxMessages) { | ||
| 1261 | + chatMessages.removeChild(messages[0]); | ||
| 1262 | + } | ||
| 1263 | + | ||
| 1264 | + // 显示聊天区域(如果之前隐藏) | ||
| 1265 | + const chatOverlay = document.getElementById("chatOverlay"); | ||
| 1266 | + if (chatOverlay) { | ||
| 1267 | + chatOverlay.style.display = "flex"; | ||
| 1268 | + } | ||
| 1269 | + | ||
| 1270 | + // 保存聊天记录 | ||
| 1271 | + saveChatHistory(); | ||
| 1272 | + } | ||
| 1273 | + | ||
| 1274 | + // 清空聊天记录函数 | ||
| 1275 | + function clearChatHistory() { | ||
| 1276 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1277 | + if (chatMessages) { | ||
| 1278 | + chatMessages.innerHTML = ""; | ||
| 1279 | + } | ||
| 1280 | + localStorage.removeItem('chatHistory'); | ||
| 1281 | + } | ||
| 1282 | + | ||
| 1283 | + // 初始化聊天滚轮支持 | ||
| 1284 | + function initChatWheelSupport() { | ||
| 1285 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1286 | + const chatOverlay = document.getElementById("chatOverlay"); | ||
| 1287 | + if (!chatMessages || !chatOverlay) return; | ||
| 1288 | + | ||
| 1289 | + let scrollPosition = 0; // 当前滚动位置 | ||
| 1290 | + const scrollStep = 25; // 每次滚动的像素数 | ||
| 1291 | + let isScrolling = false; // 防止滚动冲突 | ||
| 1292 | + let lastWheelTime = 0; // 上次滚轮事件时间 | ||
| 1293 | + const throttleDelay = 16; // 约60fps的节流延迟 | ||
| 1294 | + | ||
| 1295 | + // 节流函数 | ||
| 1296 | + function throttle(func, delay) { | ||
| 1297 | + return function(...args) { | ||
| 1298 | + const now = Date.now(); | ||
| 1299 | + if (now - lastWheelTime >= delay) { | ||
| 1300 | + lastWheelTime = now; | ||
| 1301 | + func.apply(this, args); | ||
| 1302 | + } | ||
| 1303 | + }; | ||
| 1304 | + } | ||
| 1305 | + | ||
| 1306 | + // 滚轮处理函数 | ||
| 1307 | + function handleWheel(e) { | ||
| 1308 | + e.preventDefault(); | ||
| 1309 | + e.stopPropagation(); | ||
| 1310 | + | ||
| 1311 | + if (isScrolling) return; | ||
| 1312 | + | ||
| 1313 | + const messages = chatMessages.children; | ||
| 1314 | + if (messages.length === 0) return; | ||
| 1315 | + | ||
| 1316 | + // 计算总内容高度 | ||
| 1317 | + let totalHeight = 0; | ||
| 1318 | + for (let i = 0; i < messages.length; i++) { | ||
| 1319 | + totalHeight += messages[i].offsetHeight + 12; // 12px是margin-bottom | ||
| 1320 | + } | ||
| 1321 | + | ||
| 1322 | + const containerHeight = chatMessages.offsetHeight; | ||
| 1323 | + const maxScroll = Math.max(0, totalHeight - containerHeight); | ||
| 1324 | + | ||
| 1325 | + // 如果内容不超出容器,不需要滚动 | ||
| 1326 | + if (maxScroll <= 0) return; | ||
| 1327 | + | ||
| 1328 | + isScrolling = true; | ||
| 1329 | + | ||
| 1330 | + // 根据滚轮方向和强度调整滚动位置 | ||
| 1331 | + const delta = Math.sign(e.deltaY) * scrollStep; | ||
| 1332 | + const newPosition = Math.max(0, Math.min(scrollPosition + delta, maxScroll)); | ||
| 1333 | + | ||
| 1334 | + if (newPosition !== scrollPosition) { | ||
| 1335 | + scrollPosition = newPosition; | ||
| 1336 | + | ||
| 1337 | + // 应用滚动效果 | ||
| 1338 | + chatMessages.style.transform = `translateY(-${scrollPosition}px)`; | ||
| 1339 | + chatMessages.style.transition = 'transform 0.08s ease-out'; | ||
| 1340 | + | ||
| 1341 | + // 清除过渡效果和滚动锁定 | ||
| 1342 | + setTimeout(() => { | ||
| 1343 | + chatMessages.style.transition = ''; | ||
| 1344 | + isScrolling = false; | ||
| 1345 | + }, 80); | ||
| 1346 | + } else { | ||
| 1347 | + isScrolling = false; | ||
| 1348 | + } | ||
| 1349 | + } | ||
| 1350 | + | ||
| 1351 | + // 使用节流的滚轮处理函数 | ||
| 1352 | + const throttledWheelHandler = throttle(handleWheel, throttleDelay); | ||
| 1353 | + | ||
| 1354 | + // 同时在chatMessages和chatOverlay上绑定事件,提高响应性 | ||
| 1355 | + chatMessages.addEventListener('wheel', throttledWheelHandler, { passive: false }); | ||
| 1356 | + chatOverlay.addEventListener('wheel', throttledWheelHandler, { passive: false }); | ||
| 1357 | + | ||
| 1358 | + // 当新消息添加时,自动滚动到底部 | ||
| 1359 | + const observer = new MutationObserver(function(mutations) { | ||
| 1360 | + mutations.forEach(function(mutation) { | ||
| 1361 | + if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { | ||
| 1362 | + // 重置滚动位置到底部 | ||
| 1363 | + scrollPosition = 0; | ||
| 1364 | + chatMessages.style.transform = 'translateY(0)'; | ||
| 1365 | + chatMessages.style.transition = ''; | ||
| 1366 | + } | ||
| 1367 | + }); | ||
| 1368 | + }); | ||
| 1369 | + | ||
| 1370 | + observer.observe(chatMessages, { childList: true }); | ||
| 1371 | + | ||
| 1372 | + // 返回清理函数 | ||
| 1373 | + return function cleanup() { | ||
| 1374 | + chatMessages.removeEventListener('wheel', throttledWheelHandler); | ||
| 1375 | + chatOverlay.removeEventListener('wheel', throttledWheelHandler); | ||
| 1376 | + observer.disconnect(); | ||
| 1377 | + }; | ||
| 1378 | + } | ||
| 1379 | + | ||
| 1380 | + // 保存聊天记录到本地存储 | ||
| 1381 | + function saveChatHistory() { | ||
| 1382 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1383 | + if (chatMessages) { | ||
| 1384 | + localStorage.setItem('chatHistory', chatMessages.innerHTML); | ||
| 1385 | + } | ||
| 1386 | + } | ||
| 1387 | + | ||
| 1388 | + // 从本地存储加载聊天记录 | ||
| 1389 | + function loadChatHistory() { | ||
| 1390 | + const savedHistory = localStorage.getItem('chatHistory'); | ||
| 1391 | + const chatMessages = document.getElementById("chatMessages"); | ||
| 1392 | + if (savedHistory && chatMessages) { | ||
| 1393 | + chatMessages.innerHTML = savedHistory; | ||
| 1394 | + // 自动滚动到底部 | ||
| 1395 | + chatMessages.scrollTop = chatMessages.scrollHeight; | ||
| 1396 | + } | ||
| 1397 | + } | ||
| 1398 | + | ||
| 1399 | + function connectWebSocket() { | ||
| 1400 | + var host = window.location.hostname; | ||
| 1401 | + // 获取聊天服务器WebSocket地址,优先使用配置值,否则使用当前主机名 | ||
| 1402 | + var chatWsHost = localStorage.getItem('chatWsHost') || host; | ||
| 1403 | + var chatWsPort = localStorage.getItem('chatWsPort') || '8010'; // 聊天服务器端口 | ||
| 1404 | + var wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; | ||
| 1405 | + var wsUrl = wsProtocol + chatWsHost + ':' + chatWsPort + '/ws'; // 聊天服务器WebSocket路径 | ||
| 1406 | + console.log('Connecting to WebSocket server:', wsUrl); | ||
| 1407 | + ws = new WebSocket(wsUrl); | ||
| 1408 | + ws.onopen = function() { | ||
| 1409 | + console.log('Connected to WebSocket server'); | ||
| 1410 | + console.log('WebSocket URL:', ws.url); | ||
| 1411 | + console.log('WebSocket readyState:', ws.readyState); | ||
| 1412 | + reconnectAttempts = 0; // 重置重连次数 | ||
| 1413 | + reconnectInterval = 5000; // 重置重连间隔 | ||
| 1414 | + | ||
| 1415 | + // 等待sessionid设置完成后再发送登录消息 | ||
| 1416 | + function attemptLogin(retryCount = 0) { | ||
| 1417 | + var sessionid = parseInt(document.getElementById('sessionid').value) || 0; | ||
| 1418 | + | ||
| 1419 | + if (sessionid === 0 && retryCount < 20) { | ||
| 1420 | + console.log(`等待sessionid设置,重试次数: ${retryCount + 1}/20`); | ||
| 1421 | + setTimeout(() => attemptLogin(retryCount + 1), 200); | ||
| 1422 | + return; | ||
| 1423 | + } | ||
| 1424 | + | ||
| 1425 | + if (sessionid === 0) { | ||
| 1426 | + console.error('sessionid仍为0,WebRTC连接可能失败,使用默认值继续'); | ||
| 1427 | + // 即使sessionid为0也尝试连接,但会在日志中标记 | ||
| 1428 | + } | ||
| 1429 | + | ||
| 1430 | + var loginMessage = { | ||
| 1431 | + type: 'login', | ||
| 1432 | + sessionid: sessionid, | ||
| 1433 | + username: $('#username').val() || 'User' | ||
| 1434 | + }; | ||
| 1435 | + | ||
| 1436 | + console.log('发送登录消息:', loginMessage); | ||
| 1437 | + console.log('当前sessionid值:', sessionid); | ||
| 1438 | + console.log('sessionid元素值:', document.getElementById('sessionid').value); | ||
| 1439 | + ws.send(JSON.stringify(loginMessage)); | ||
| 1440 | + console.log('登录消息已发送:', JSON.stringify(loginMessage)); | ||
| 1441 | + | ||
| 1442 | + // 更新显示的sessionId | ||
| 1443 | + if (sessionid !== 0) { | ||
| 1444 | + document.getElementById('current-sessionid').value = sessionid; | ||
| 1445 | + document.getElementById('current-sessionid').placeholder = '已连接'; | ||
| 1446 | + } | ||
| 1447 | + } | ||
| 1448 | + | ||
| 1449 | + // 开始尝试登录 | ||
| 1450 | + attemptLogin(); | ||
| 1451 | + | ||
| 1452 | + // 发送心跳检测 | ||
| 1453 | + setInterval(function() { | ||
| 1454 | + if (ws.readyState === WebSocket.OPEN) { | ||
| 1455 | + ws.send(JSON.stringify({type: 'ping'})); | ||
| 1456 | + } | ||
| 1457 | + }, 30000); // 每30秒发送一次心跳 | ||
| 1458 | + }; | ||
| 1459 | + | ||
| 1460 | + ws.onmessage = function(e) { | ||
| 1461 | + console.log('WebSocket原始消息:', e.data); | ||
| 1462 | + console.log('WebSocket连接状态:', ws.readyState); | ||
| 1463 | + var messageData = JSON.parse(e.data); | ||
| 1464 | + console.log('解析后的消息数据:', messageData); | ||
| 1465 | + | ||
| 1466 | + // 处理聊天消息推送 | ||
| 1467 | + if (messageData.type === 'chat_message') { | ||
| 1468 | + var data = messageData.data; | ||
| 1469 | + console.log('收到聊天消息推送:', data); | ||
| 1470 | + console.log('消息sessionid:', data.sessionid); | ||
| 1471 | + console.log('当前页面sessionid:', document.getElementById('sessionid').value); | ||
| 1472 | + | ||
| 1473 | + // 根据消息来源确定显示位置 | ||
| 1474 | + var position = data.source === '用户' ? 'right' : 'left'; | ||
| 1475 | + var messageType = data.message_type || 'chat'; | ||
| 1476 | + var modelInfo = data.model_info || ''; | ||
| 1477 | + var requestSource = data.request_source || ''; | ||
| 1478 | + | ||
| 1479 | + console.log('准备添加消息到界面:', { | ||
| 1480 | + content: data.content, | ||
| 1481 | + position: position, | ||
| 1482 | + source: data.source, | ||
| 1483 | + messageType: messageType, | ||
| 1484 | + modelInfo: modelInfo, | ||
| 1485 | + requestSource: requestSource | ||
| 1486 | + }); | ||
| 1487 | + | ||
| 1488 | + // 添加消息到聊天界面 | ||
| 1489 | + addMessage(data.content, position, data.source, messageType, modelInfo, requestSource); | ||
| 1490 | + return; | ||
| 1491 | + } | ||
| 1492 | + | ||
| 1493 | + // 处理登录成功消息 | ||
| 1494 | + if (messageData.type === 'login_success') { | ||
| 1495 | + console.log('WebSocket登录成功:', messageData.message); | ||
| 1496 | + console.log('登录成功的sessionid:', messageData.sessionid); | ||
| 1497 | + return; | ||
| 1498 | + } | ||
| 1499 | + | ||
| 1500 | + // 处理心跳响应 | ||
| 1501 | + if (messageData.type === 'pong') { | ||
| 1502 | + console.log('收到心跳响应'); | ||
| 1503 | + return; | ||
| 1504 | + } | ||
| 1505 | + | ||
| 1506 | + // 处理服务器推送的聊天消息 | ||
| 1507 | + if (messageData.type === 'chat_message') { | ||
| 1508 | + console.log('收到聊天消息:', messageData); | ||
| 1509 | + | ||
| 1510 | + var messageContent = messageData.content || messageData.message || ''; | ||
| 1511 | + var messageType = messageData.message_type || 'text'; | ||
| 1512 | + var sender = messageData.sender || 'unknown'; | ||
| 1513 | + var sessionId = messageData.session_id; | ||
| 1514 | + var modelInfo = messageData.model_info || ''; | ||
| 1515 | + var requestSource = messageData.request_source || ''; | ||
| 1516 | + var timestamp = messageData.timestamp || new Date().toISOString(); | ||
| 1517 | + | ||
| 1518 | + // 判断消息方向和样式 | ||
| 1519 | + var alignment = 'left'; | ||
| 1520 | + var senderLabel = '数字人回复'; | ||
| 1521 | + var messageMode = messageType; | ||
| 1522 | + | ||
| 1523 | + if (sender === '用户' || sender === 'user' || sender === 'human') { | ||
| 1524 | + alignment = 'right'; | ||
| 1525 | + senderLabel = '用户'; | ||
| 1526 | + if (messageType === 'audio') { | ||
| 1527 | + senderLabel = '用户语音'; | ||
| 1528 | + messageContent = '[语音输入]'; | ||
| 1529 | + } | ||
| 1530 | + } else if (sender === 'AI助手' || sender === 'ai' || sender === 'assistant') { | ||
| 1531 | + alignment = 'left'; | ||
| 1532 | + senderLabel = modelInfo ? `AI回复(${modelInfo})` : 'AI回复'; | ||
| 1533 | + } else if (sender === '回音') { | ||
| 1534 | + alignment = 'left'; | ||
| 1535 | + senderLabel = modelInfo ? `回音模式(${modelInfo})` : '回音模式'; | ||
| 1536 | + } else if (sender === '系统错误') { | ||
| 1537 | + alignment = 'left'; | ||
| 1538 | + senderLabel = '系统错误'; | ||
| 1539 | + messageMode = 'error'; | ||
| 1540 | + } | ||
| 1541 | + | ||
| 1542 | + // 添加消息到界面 | ||
| 1543 | + addMessage(messageContent, alignment, senderLabel, messageMode, modelInfo, requestSource); | ||
| 1544 | + | ||
| 1545 | + // 保存到本地存储 | ||
| 1546 | + if (document.getElementById('enableStorage').checked) { | ||
| 1547 | + saveChatHistory({ | ||
| 1548 | + content: messageContent, | ||
| 1549 | + alignment: alignment, | ||
| 1550 | + sender: senderLabel, | ||
| 1551 | + mode: messageMode, | ||
| 1552 | + modelInfo: modelInfo, | ||
| 1553 | + requestSource: requestSource, | ||
| 1554 | + timestamp: timestamp, | ||
| 1555 | + sessionId: sessionId | ||
| 1556 | + }); | ||
| 1557 | + } | ||
| 1558 | + return; | ||
| 1559 | + } | ||
| 1560 | + | ||
| 1561 | + if (messageData.Data && messageData.Data.Key) { | ||
| 1562 | + if(messageData.Data.Key == "audio"){ | ||
| 1563 | + var value = messageData.Data.HttpValue; | ||
| 1564 | + console.log('Value:', value); | ||
| 1565 | + | ||
| 1566 | + // 发送语音文件到服务器处理,不再直接添加到界面,等待WebSocket推送 | ||
| 1567 | + fetch('/humanaudio', { | ||
| 1568 | + body: JSON.stringify({ | ||
| 1569 | + file_url: value, | ||
| 1570 | + sessionid:parseInt(document.getElementById('sessionid').value), | ||
| 1571 | + }), | ||
| 1572 | + headers: { | ||
| 1573 | + 'Content-Type': 'application/json', | ||
| 1574 | + 'X-Request-Source': 'web' | ||
| 1575 | + }, | ||
| 1576 | + method: 'POST' | ||
| 1577 | + }); | ||
| 1578 | + }else if (messageData.Data.Key == "text") { | ||
| 1579 | + var reply = messageData.Data.Value; | ||
| 1580 | + console.log('收到text消息,内容:', reply); | ||
| 1581 | + | ||
| 1582 | + // 将text类型消息推送到服务器,由数字人服务通过TTS合成语音并播放 | ||
| 1583 | + // 使用原始的消息类型,而不是固定的echo | ||
| 1584 | + var originalType = messageData.Data.Type || 'echo'; | ||
| 1585 | + fetch('/human', { | ||
| 1586 | + body: JSON.stringify({ | ||
| 1587 | + text: reply, | ||
| 1588 | + type: originalType, | ||
| 1589 | + interrupt: true, | ||
| 1590 | + sessionid: parseInt(document.getElementById('sessionid').value), | ||
| 1591 | + }), | ||
| 1592 | + headers: { | ||
| 1593 | + 'Content-Type': 'application/json' | ||
| 1594 | + }, | ||
| 1595 | + method: 'POST' | ||
| 1596 | + }).then(response => response.json()).then(data => { | ||
| 1597 | + console.log('/human接口响应(文本消息):', data); | ||
| 1598 | + if (data.code !== 0) { | ||
| 1599 | + console.error('服务器处理失败:', data.message || data.msg); | ||
| 1600 | + } | ||
| 1601 | + // 所有消息显示都通过WebSocket推送,不再从HTTP响应获取数据 | ||
| 1602 | + }).catch(error => { | ||
| 1603 | + console.error('发送文本消息错误:', error); | ||
| 1604 | + addMessage(`网络错误: ${error.message}`, 'left', '系统错误', 'error'); | ||
| 1605 | + }); | ||
| 1606 | + }else if (messageData.Data.Key == "plaintext") { | ||
| 1607 | + // 处理纯文本消息类型 | ||
| 1608 | + var textContent = messageData.Data.Value; | ||
| 1609 | + console.log('收到纯文本消息:', textContent); | ||
| 1610 | + | ||
| 1611 | + // 使用浏览器的语音合成API进行本地语音合成 | ||
| 1612 | + if (window.speechSynthesis) { | ||
| 1613 | + console.log('使用本地语音合成播放文本:', textContent); | ||
| 1614 | + var utterance = new SpeechSynthesisUtterance(textContent); | ||
| 1615 | + utterance.lang = 'zh-CN'; // 设置语言为中文 | ||
| 1616 | + utterance.rate = 1.0; // 设置语速 | ||
| 1617 | + utterance.pitch = 1.0; // 设置音高 | ||
| 1618 | + utterance.volume = 1.0; // 设置音量 | ||
| 1619 | + speechSynthesis.speak(utterance); | ||
| 1620 | + } | ||
| 1621 | + } | ||
| 1622 | + } | ||
| 1623 | + | ||
| 1624 | + }; | ||
| 1625 | + | ||
| 1626 | + ws.onclose = function(e) { | ||
| 1627 | + console.log('WebSocket connection closed'); | ||
| 1628 | + attemptReconnect(); | ||
| 1629 | + }; | ||
| 1630 | + | ||
| 1631 | + ws.onerror = function(e) { | ||
| 1632 | + console.error('WebSocket error:', e); | ||
| 1633 | + ws.close(); // 关闭连接并尝试重连 | ||
| 1634 | + }; | ||
| 1635 | + } | ||
| 1636 | + | ||
| 1637 | + function attemptReconnect() { | ||
| 1638 | + if (isReconnecting) return; // 防止多次重连 | ||
| 1639 | + | ||
| 1640 | + isReconnecting = true; | ||
| 1641 | + reconnectAttempts++; | ||
| 1642 | + | ||
| 1643 | + // 使用指数退避算法计算下一次重连间隔 | ||
| 1644 | + var currentInterval = Math.min(reconnectInterval * Math.pow(1.5, reconnectAttempts - 1), maxReconnectInterval); | ||
| 1645 | + | ||
| 1646 | + console.log('Attempting to reconnect... (Attempt ' + reconnectAttempts + ', 间隔: ' + currentInterval/1000 + '秒)'); | ||
| 1647 | + | ||
| 1648 | + if(document.getElementById('is_open') && parseInt(document.getElementById('is_open').value) == 1){ | ||
| 1649 | + stop() | ||
| 1650 | + } | ||
| 1651 | + | ||
| 1652 | + setTimeout(function() { | ||
| 1653 | + isReconnecting = false; | ||
| 1654 | + connectWebSocket(); | ||
| 1655 | + }, currentInterval); | ||
| 1656 | + } | ||
| 1657 | + | ||
| 1658 | + // SessionId管理功能 | ||
| 1659 | + function saveSessionId(sessionId) { | ||
| 1660 | + localStorage.setItem('currentSessionId', sessionId); | ||
| 1661 | + document.getElementById('current-sessionid').value = sessionId; | ||
| 1662 | + console.log('SessionId已保存到本地存储:', sessionId); | ||
| 1663 | + } | ||
| 1664 | + | ||
| 1665 | + function restoreSessionId() { | ||
| 1666 | + var savedSessionId = localStorage.getItem('currentSessionId'); | ||
| 1667 | + if (savedSessionId && savedSessionId !== '0') { | ||
| 1668 | + document.getElementById('sessionid').value = savedSessionId; | ||
| 1669 | + document.getElementById('current-sessionid').value = savedSessionId; | ||
| 1670 | + console.log('已恢复SessionId:', savedSessionId); | ||
| 1671 | + return savedSessionId; | ||
| 1672 | + } | ||
| 1673 | + return null; | ||
| 1674 | + } | ||
| 1675 | + | ||
| 1676 | + function clearSessionId() { | ||
| 1677 | + localStorage.removeItem('currentSessionId'); | ||
| 1678 | + document.getElementById('sessionid').value = '0'; | ||
| 1679 | + document.getElementById('current-sessionid').value = ''; | ||
| 1680 | + document.getElementById('current-sessionid').placeholder = '未连接'; | ||
| 1681 | + console.log('SessionId已清除'); | ||
| 1682 | + } | ||
| 1683 | + | ||
| 1684 | + // 绑定重置会话按钮事件 | ||
| 1685 | + document.getElementById('clear-session-btn').addEventListener('click', function() { | ||
| 1686 | + if (confirm('确定要重置会话ID吗?这将断开当前连接并清除会话记录。')) { | ||
| 1687 | + // 清除sessionId | ||
| 1688 | + clearSessionId(); | ||
| 1689 | + | ||
| 1690 | + // 断开WebSocket连接 | ||
| 1691 | + if (ws && ws.readyState === WebSocket.OPEN) { | ||
| 1692 | + ws.close(); | ||
| 1693 | + } | ||
| 1694 | + | ||
| 1695 | + // 停止WebRTC连接 | ||
| 1696 | + if (typeof stop === 'function') { | ||
| 1697 | + stop(); | ||
| 1698 | + } | ||
| 1699 | + | ||
| 1700 | + console.log('会话已重置,可以重新连接'); | ||
| 1701 | + alert('会话已重置,请重新点击"开始"按钮建立新连接'); | ||
| 1702 | + } | ||
| 1703 | + }); | ||
| 1704 | + | ||
| 1705 | + // 页面初始化时尝试恢复sessionId | ||
| 1706 | + var restoredSessionId = restoreSessionId(); | ||
| 1707 | + | ||
| 1708 | + // 如果恢复了sessionId,尝试重新连接WebSocket | ||
| 1709 | + if (restoredSessionId && typeof connectWebSocket === 'function') { | ||
| 1710 | + console.log('检测到已保存的SessionId,尝试重新连接WebSocket...'); | ||
| 1711 | + // 延迟一点时间确保页面完全加载 | ||
| 1712 | + setTimeout(function() { | ||
| 1713 | + connectWebSocket(); | ||
| 1714 | + }, 1000); | ||
| 1715 | + } | ||
| 1716 | + | ||
| 1717 | + // 注意:WebSocket连接现在由WebRTC连接建立后触发 | ||
| 1718 | + // connectWebSocket(); // 移除自动连接,改为在获得sessionid后连接 | ||
| 1719 | + | ||
| 1720 | + // 加载聊天记录 | ||
| 1721 | + loadChatHistory(); | ||
| 1722 | + | ||
| 1723 | + // 添加页面可见性变化监听,当页面从隐藏变为可见时尝试重连 | ||
| 1724 | + document.addEventListener('visibilitychange', function() { | ||
| 1725 | + if (document.visibilityState === 'visible') { | ||
| 1726 | + // 页面变为可见状态,检查WebSocket连接 | ||
| 1727 | + if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { | ||
| 1728 | + console.log('页面可见,检测到WebSocket未连接,尝试重连...'); | ||
| 1729 | + // 重置重连计数和间隔,立即尝试重连 | ||
| 1730 | + reconnectAttempts = 0; | ||
| 1731 | + reconnectInterval = 5000; | ||
| 1732 | + connectWebSocket(); | ||
| 1733 | + } | ||
| 1734 | + } | ||
| 1735 | + }); | ||
| 1736 | + }); | ||
| 1737 | +</script> | ||
| 1738 | +</body> | ||
| 1739 | +</html> |
web/websocket_test.html
0 → 100644
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html lang="zh-CN"> | ||
| 3 | +<head> | ||
| 4 | + <meta charset="UTF-8"> | ||
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | + <title>WebSocket通信测试</title> | ||
| 7 | + <style> | ||
| 8 | + body { | ||
| 9 | + font-family: Arial, sans-serif; | ||
| 10 | + max-width: 800px; | ||
| 11 | + margin: 0 auto; | ||
| 12 | + padding: 20px; | ||
| 13 | + } | ||
| 14 | + .container { | ||
| 15 | + border: 1px solid #ddd; | ||
| 16 | + border-radius: 8px; | ||
| 17 | + padding: 20px; | ||
| 18 | + margin-bottom: 20px; | ||
| 19 | + } | ||
| 20 | + .status { | ||
| 21 | + padding: 10px; | ||
| 22 | + border-radius: 4px; | ||
| 23 | + margin-bottom: 10px; | ||
| 24 | + } | ||
| 25 | + .connected { | ||
| 26 | + background-color: #d4edda; | ||
| 27 | + color: #155724; | ||
| 28 | + border: 1px solid #c3e6cb; | ||
| 29 | + } | ||
| 30 | + .disconnected { | ||
| 31 | + background-color: #f8d7da; | ||
| 32 | + color: #721c24; | ||
| 33 | + border: 1px solid #f5c6cb; | ||
| 34 | + } | ||
| 35 | + .message-form { | ||
| 36 | + display: flex; | ||
| 37 | + gap: 10px; | ||
| 38 | + margin-bottom: 20px; | ||
| 39 | + } | ||
| 40 | + .message-form input { | ||
| 41 | + flex: 1; | ||
| 42 | + padding: 8px; | ||
| 43 | + border: 1px solid #ddd; | ||
| 44 | + border-radius: 4px; | ||
| 45 | + } | ||
| 46 | + .message-form button { | ||
| 47 | + padding: 8px 16px; | ||
| 48 | + background-color: #007bff; | ||
| 49 | + color: white; | ||
| 50 | + border: none; | ||
| 51 | + border-radius: 4px; | ||
| 52 | + cursor: pointer; | ||
| 53 | + } | ||
| 54 | + .message-form button:hover { | ||
| 55 | + background-color: #0056b3; | ||
| 56 | + } | ||
| 57 | + .log { | ||
| 58 | + background-color: #f8f9fa; | ||
| 59 | + border: 1px solid #dee2e6; | ||
| 60 | + border-radius: 4px; | ||
| 61 | + padding: 10px; | ||
| 62 | + height: 300px; | ||
| 63 | + overflow-y: auto; | ||
| 64 | + font-family: monospace; | ||
| 65 | + font-size: 12px; | ||
| 66 | + } | ||
| 67 | + .log-entry { | ||
| 68 | + margin-bottom: 5px; | ||
| 69 | + padding: 2px 0; | ||
| 70 | + } | ||
| 71 | + .log-entry.info { | ||
| 72 | + color: #0066cc; | ||
| 73 | + } | ||
| 74 | + .log-entry.error { | ||
| 75 | + color: #cc0000; | ||
| 76 | + } | ||
| 77 | + .log-entry.success { | ||
| 78 | + color: #009900; | ||
| 79 | + } | ||
| 80 | + </style> | ||
| 81 | +</head> | ||
| 82 | +<body> | ||
| 83 | + <h1>WebSocket通信测试</h1> | ||
| 84 | + | ||
| 85 | + <div class="container"> | ||
| 86 | + <h3>连接状态</h3> | ||
| 87 | + <div id="status" class="status disconnected">未连接</div> | ||
| 88 | + <button id="connectBtn" onclick="connect()">连接</button> | ||
| 89 | + <button id="disconnectBtn" onclick="disconnect()" disabled>断开连接</button> | ||
| 90 | + </div> | ||
| 91 | + | ||
| 92 | + <div class="container"> | ||
| 93 | + <h3>发送消息测试</h3> | ||
| 94 | + <div class="message-form"> | ||
| 95 | + <input type="number" id="sessionid" placeholder="会话ID" value="0"> | ||
| 96 | + <select id="messageType"> | ||
| 97 | + <option value="chat">智能对话</option> | ||
| 98 | + <option value="echo">回音模式</option> | ||
| 99 | + </select> | ||
| 100 | + <input type="text" id="messageText" placeholder="输入消息内容"> | ||
| 101 | + <button onclick="sendMessage()">发送到/human接口</button> | ||
| 102 | + </div> | ||
| 103 | + </div> | ||
| 104 | + | ||
| 105 | + <div class="container"> | ||
| 106 | + <h3>消息日志</h3> | ||
| 107 | + <button onclick="clearLog()">清空日志</button> | ||
| 108 | + <div id="log" class="log"></div> | ||
| 109 | + </div> | ||
| 110 | + | ||
| 111 | + <script> | ||
| 112 | + let ws = null; | ||
| 113 | + let reconnectAttempts = 0; | ||
| 114 | + const maxReconnectAttempts = 5; | ||
| 115 | + | ||
| 116 | + function addLog(message, type = 'info') { | ||
| 117 | + const log = document.getElementById('log'); | ||
| 118 | + const entry = document.createElement('div'); | ||
| 119 | + entry.className = `log-entry ${type}`; | ||
| 120 | + const timestamp = new Date().toLocaleTimeString(); | ||
| 121 | + entry.textContent = `[${timestamp}] ${message}`; | ||
| 122 | + log.appendChild(entry); | ||
| 123 | + log.scrollTop = log.scrollHeight; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + function updateStatus(connected) { | ||
| 127 | + const status = document.getElementById('status'); | ||
| 128 | + const connectBtn = document.getElementById('connectBtn'); | ||
| 129 | + const disconnectBtn = document.getElementById('disconnectBtn'); | ||
| 130 | + | ||
| 131 | + if (connected) { | ||
| 132 | + status.textContent = '已连接'; | ||
| 133 | + status.className = 'status connected'; | ||
| 134 | + connectBtn.disabled = true; | ||
| 135 | + disconnectBtn.disabled = false; | ||
| 136 | + } else { | ||
| 137 | + status.textContent = '未连接'; | ||
| 138 | + status.className = 'status disconnected'; | ||
| 139 | + connectBtn.disabled = false; | ||
| 140 | + disconnectBtn.disabled = true; | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + function connect() { | ||
| 145 | + const host = window.location.hostname; | ||
| 146 | + const port = '8010'; | ||
| 147 | + const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; | ||
| 148 | + const wsUrl = `${protocol}${host}:${port}/ws`; | ||
| 149 | + | ||
| 150 | + addLog(`尝试连接到: ${wsUrl}`); | ||
| 151 | + | ||
| 152 | + ws = new WebSocket(wsUrl); | ||
| 153 | + | ||
| 154 | + ws.onopen = function() { | ||
| 155 | + addLog('WebSocket连接成功', 'success'); | ||
| 156 | + updateStatus(true); | ||
| 157 | + reconnectAttempts = 0; | ||
| 158 | + | ||
| 159 | + // 发送登录消息 | ||
| 160 | + const sessionid = parseInt(document.getElementById('sessionid').value) || 0; | ||
| 161 | + const loginMessage = { | ||
| 162 | + type: 'login', | ||
| 163 | + sessionid: sessionid, | ||
| 164 | + username: 'TestUser' | ||
| 165 | + }; | ||
| 166 | + ws.send(JSON.stringify(loginMessage)); | ||
| 167 | + addLog(`发送登录消息: ${JSON.stringify(loginMessage)}`); | ||
| 168 | + }; | ||
| 169 | + | ||
| 170 | + ws.onmessage = function(e) { | ||
| 171 | + addLog(`收到消息: ${e.data}`, 'success'); | ||
| 172 | + | ||
| 173 | + try { | ||
| 174 | + const messageData = JSON.parse(e.data); | ||
| 175 | + | ||
| 176 | + if (messageData.type === 'chat_message') { | ||
| 177 | + const data = messageData.data; | ||
| 178 | + addLog(`聊天消息 - 来源: ${data.source}, 类型: ${data.message_type}, 内容: ${data.content}`, 'success'); | ||
| 179 | + } else if (messageData.type === 'login_success') { | ||
| 180 | + addLog(`登录成功: ${messageData.message}`, 'success'); | ||
| 181 | + } else if (messageData.type === 'pong') { | ||
| 182 | + addLog('收到心跳响应', 'info'); | ||
| 183 | + } | ||
| 184 | + } catch (err) { | ||
| 185 | + addLog(`解析消息失败: ${err.message}`, 'error'); | ||
| 186 | + } | ||
| 187 | + }; | ||
| 188 | + | ||
| 189 | + ws.onclose = function(e) { | ||
| 190 | + addLog(`WebSocket连接关闭: ${e.code} - ${e.reason}`, 'error'); | ||
| 191 | + updateStatus(false); | ||
| 192 | + | ||
| 193 | + // 自动重连 | ||
| 194 | + if (reconnectAttempts < maxReconnectAttempts) { | ||
| 195 | + reconnectAttempts++; | ||
| 196 | + addLog(`尝试重连 (${reconnectAttempts}/${maxReconnectAttempts})...`); | ||
| 197 | + setTimeout(connect, 3000); | ||
| 198 | + } | ||
| 199 | + }; | ||
| 200 | + | ||
| 201 | + ws.onerror = function(e) { | ||
| 202 | + addLog('WebSocket连接错误', 'error'); | ||
| 203 | + }; | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + function disconnect() { | ||
| 207 | + if (ws) { | ||
| 208 | + ws.close(); | ||
| 209 | + ws = null; | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + async function sendMessage() { | ||
| 214 | + const sessionid = parseInt(document.getElementById('sessionid').value) || 0; | ||
| 215 | + const messageType = document.getElementById('messageType').value; | ||
| 216 | + const messageText = document.getElementById('messageText').value; | ||
| 217 | + | ||
| 218 | + if (!messageText.trim()) { | ||
| 219 | + addLog('请输入消息内容', 'error'); | ||
| 220 | + return; | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + const payload = { | ||
| 224 | + sessionid: sessionid, | ||
| 225 | + type: messageType, | ||
| 226 | + text: messageText | ||
| 227 | + }; | ||
| 228 | + | ||
| 229 | + addLog(`发送到/human接口: ${JSON.stringify(payload)}`); | ||
| 230 | + | ||
| 231 | + try { | ||
| 232 | + const response = await fetch('/human', { | ||
| 233 | + method: 'POST', | ||
| 234 | + headers: { | ||
| 235 | + 'Content-Type': 'application/json' | ||
| 236 | + }, | ||
| 237 | + body: JSON.stringify(payload) | ||
| 238 | + }); | ||
| 239 | + | ||
| 240 | + const result = await response.json(); | ||
| 241 | + addLog(`/human接口响应: ${JSON.stringify(result)}`, response.ok ? 'success' : 'error'); | ||
| 242 | + | ||
| 243 | + // 清空输入框 | ||
| 244 | + document.getElementById('messageText').value = ''; | ||
| 245 | + | ||
| 246 | + } catch (err) { | ||
| 247 | + addLog(`发送失败: ${err.message}`, 'error'); | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + function clearLog() { | ||
| 252 | + document.getElementById('log').innerHTML = ''; | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + // 页面加载时自动连接 | ||
| 256 | + window.onload = function() { | ||
| 257 | + addLog('页面加载完成,准备测试WebSocket通信'); | ||
| 258 | + }; | ||
| 259 | + | ||
| 260 | + // 监听回车键发送消息 | ||
| 261 | + document.getElementById('messageText').addEventListener('keypress', function(e) { | ||
| 262 | + if (e.key === 'Enter') { | ||
| 263 | + sendMessage(); | ||
| 264 | + } | ||
| 265 | + }); | ||
| 266 | + </script> | ||
| 267 | +</body> | ||
| 268 | +</html> |
| @@ -46,7 +46,15 @@ function start() { | @@ -46,7 +46,15 @@ function start() { | ||
| 46 | }; | 46 | }; |
| 47 | 47 | ||
| 48 | if (document.getElementById('use-stun').checked) { | 48 | if (document.getElementById('use-stun').checked) { |
| 49 | - config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }]; | 49 | + // 优化STUN服务器配置:使用多个快速STUN服务器 |
| 50 | + config.iceServers = [ | ||
| 51 | + { urls: ['stun:stun.l.google.com:19302'] }, | ||
| 52 | + { urls: ['stun:stun1.l.google.com:19302'] }, | ||
| 53 | + { urls: ['stun:stun2.l.google.com:19302'] } | ||
| 54 | + ]; | ||
| 55 | + // ICE传输策略和候选者池大小优化 | ||
| 56 | + config.iceTransportPolicy = 'all'; | ||
| 57 | + config.iceCandidatePoolSize = 10; | ||
| 50 | } | 58 | } |
| 51 | 59 | ||
| 52 | pc = new RTCPeerConnection(config); | 60 | pc = new RTCPeerConnection(config); |
-
Please register or login to post a comment