冯杨

豆包模型的接入

豆包模型、通义千问模型配置化
界面对话框优化:区分不同角色、不同数据来源
对话框展示数据来源:数据来源统一取自ws(独立建设本服务ws默认8010),完整的一套ws数据响应收发,心跳机制。(app.py)
增加ws测试页面、增加豆包模型测试页面
页面sessionId获取机制修改
STUN服务器优化
@@ -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.
  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 +}
  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)
  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()
  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']
  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()
  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
  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>
  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);