冯杨

豆包模型的接入

豆包模型、通义千问模型配置化
界面对话框优化:区分不同角色、不同数据来源
对话框展示数据来源:数据来源统一取自ws(独立建设本服务ws默认8010),完整的一套ws数据响应收发,心跳机制。(app.py)
增加ws测试页面、增加豆包模型测试页面
页面sessionId获取机制修改
STUN服务器优化
... ... @@ -29,7 +29,7 @@ from threading import Thread,Event
#import multiprocessing
import torch.multiprocessing as mp
from aiohttp import web
from aiohttp import web, WSMsgType
import aiohttp
import aiohttp_cors
from aiortc import RTCPeerConnection, RTCSessionDescription
... ... @@ -45,11 +45,15 @@ import asyncio
import torch
from typing import Dict
from logger import logger
import gc
import weakref
import time
app = Flask(__name__)
#sockets = Sockets(app)
nerfreals:Dict[int, BaseReal] = {} #sessionid:BaseReal
websocket_connections:Dict[int, weakref.WeakSet] = {} #sessionid:websocket_connections
opt = None
model = None
avatar = None
... ... @@ -58,6 +62,108 @@ avatar = None
#####webrtc###############################
pcs = set()
# WebSocket消息推送函数
async def broadcast_message_to_session(sessionid: int, message_type: str, content: str, source: str = "数字人回复", model_info: str = None, request_source: str = "页面"):
"""向指定会话的所有WebSocket连接推送消息"""
logger.info(f'[SessionID:{sessionid}] 开始推送消息: {message_type}, source: {source}, content: {content[:50]}...')
logger.info(f'[SessionID:{sessionid}] 当前websocket_connections keys: {list(websocket_connections.keys())}')
if sessionid not in websocket_connections:
logger.warning(f'[SessionID:{sessionid}] 会话不存在于websocket_connections中')
return
logger.info(f'[SessionID:{sessionid}] 找到会话,连接数量: {len(websocket_connections[sessionid])}')
message = {
"type": "chat_message",
"data": {
"sessionid": sessionid,
"message_type": message_type,
"content": content,
"source": source,
"model_info": model_info,
"request_source": request_source,
"timestamp": time.time()
}
}
# 获取该会话的所有WebSocket连接
connections = list(websocket_connections[sessionid])
# 向所有连接发送消息
logger.info(f'[SessionID:{sessionid}] 准备向{len(connections)}个连接发送消息')
for i, ws in enumerate(connections):
try:
logger.info(f'[SessionID:{sessionid}] 检查连接{i+1}: closed={ws.closed}')
if not ws.closed:
logger.info(f'[SessionID:{sessionid}] 向连接{i+1}发送消息: {json.dumps(message)}')
await ws.send_str(json.dumps(message))
logger.info(f'[SessionID:{sessionid}] 连接{i+1}消息发送成功: {message_type} from {request_source}')
else:
logger.warning(f'[SessionID:{sessionid}] 连接{i+1}已关闭,跳过发送')
except Exception as e:
logger.error(f'[SessionID:{sessionid}] 连接{i+1}发送失败: {e}')
# WebSocket处理器
async def websocket_handler(request):
"""处理WebSocket连接"""
ws = web.WebSocketResponse()
await ws.prepare(request)
sessionid = None
logger.info('New WebSocket connection established')
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
try:
data = json.loads(msg.data)
if data.get('type') == 'login':
sessionid = data.get('sessionid', 0)
logger.info(f'[SessionID:{sessionid}] 收到登录请求,当前连接池: {list(websocket_connections.keys())}')
# 初始化该会话的WebSocket连接集合
if sessionid not in websocket_connections:
websocket_connections[sessionid] = weakref.WeakSet()
logger.info(f'[SessionID:{sessionid}] 创建新的连接集合')
# 添加当前连接到会话
websocket_connections[sessionid].add(ws)
logger.info(f'[SessionID:{sessionid}] 连接已添加,当前会话连接数: {len(websocket_connections[sessionid])}')
logger.info(f'[SessionID:{sessionid}] WebSocket client logged in')
# 发送登录确认
await ws.send_str(json.dumps({
"type": "login_success",
"sessionid": sessionid,
"message": "WebSocket连接成功"
}))
elif data.get('type') == 'ping':
# 心跳检测
await ws.send_str(json.dumps({"type": "pong"}))
except json.JSONDecodeError:
logger.error('Invalid JSON received from WebSocket')
except Exception as e:
logger.error(f'Error processing WebSocket message: {e}')
elif msg.type == WSMsgType.ERROR:
logger.error(f'WebSocket error: {ws.exception()}')
break
except Exception as e:
logger.error(f'WebSocket connection error: {e}')
finally:
if sessionid is not None:
logger.info(f'[SessionID:{sessionid}] WebSocket connection closed')
else:
logger.info('WebSocket connection closed')
return ws
def randN(N)->int:
'''生成长度为 N的随机数 '''
min = pow(10, N - 1)
... ... @@ -65,21 +171,49 @@ def randN(N)->int:
return random.randint(min, max - 1)
def build_nerfreal(sessionid:int)->BaseReal:
import time
import gc
opt.sessionid=sessionid
logger.info('[SessionID:%d] Building %s model instance' % (sessionid, opt.model))
try:
model_start = time.time()
if opt.model == 'wav2lip':
logger.info('[SessionID:%d] Loading Wav2Lip model...' % sessionid)
from lipreal import LipReal
nerfreal = LipReal(opt,model,avatar)
elif opt.model == 'musetalk':
logger.info('[SessionID:%d] Loading MuseTalk model...' % sessionid)
from musereal import MuseReal
nerfreal = MuseReal(opt,model,avatar)
elif opt.model == 'ernerf':
logger.info('[SessionID:%d] Loading ERNeRF model...' % sessionid)
from nerfreal import NeRFReal
nerfreal = NeRFReal(opt,model,avatar)
elif opt.model == 'ultralight':
logger.info('[SessionID:%d] Loading UltraLight model...' % sessionid)
from lightreal import LightReal
nerfreal = LightReal(opt,model,avatar)
else:
raise ValueError(f"Unknown model type: {opt.model}")
model_end = time.time()
model_duration = model_end - model_start
logger.info('[SessionID:%d] %s model loaded successfully in %.3f seconds' % (sessionid, opt.model, model_duration))
# 强制垃圾回收以释放内存
gc.collect()
return nerfreal
except Exception as e:
logger.error('[SessionID:%d] Failed to build %s model: %s' % (sessionid, opt.model, str(e)))
# 清理可能的部分初始化资源
gc.collect()
raise e
#@app.route('/offer', methods=['POST'])
async def offer(request):
params = await request.json()
... ... @@ -87,42 +221,140 @@ async def offer(request):
if len(nerfreals) >= opt.max_session:
logger.info('reach max session')
return -1
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": -1, "msg": "reach max session"}
),
)
sessionid = randN(6) #len(nerfreals)
logger.info('sessionid=%d',sessionid)
logger.info('[SessionID:%d] Starting session initialization',sessionid)
nerfreals[sessionid] = None
# 记录模型初始化开始时间
import time
model_init_start = time.time()
logger.info('[SessionID:%d] Starting model initialization for %s' % (sessionid, opt.model))
nerfreal = await asyncio.get_event_loop().run_in_executor(None, build_nerfreal,sessionid)
# 记录模型初始化完成时间
model_init_end = time.time()
init_duration = model_init_end - model_init_start
logger.info('[SessionID:%d] Model initialization completed in %.3f seconds' % (sessionid, init_duration))
nerfreals[sessionid] = nerfreal
pc = RTCPeerConnection()
pcs.add(pc)
# 添加ICE连接状态监控
@pc.on("iceconnectionstatechange")
async def on_iceconnectionstatechange():
import time
timestamp = time.time()
logger.info("[SessionID:%d] ICE connection state changed to %s at %.3f" % (sessionid, pc.iceConnectionState, timestamp))
if pc.iceConnectionState == "checking":
logger.info("[SessionID:%d] ICE connectivity checks in progress..." % sessionid)
elif pc.iceConnectionState == "connected":
logger.info("[SessionID:%d] ICE connection established" % sessionid)
elif pc.iceConnectionState == "completed":
logger.info("[SessionID:%d] ICE connection completed" % sessionid)
elif pc.iceConnectionState == "failed":
logger.error("[SessionID:%d] ICE connection failed" % sessionid)
elif pc.iceConnectionState == "disconnected":
logger.warning("[SessionID:%d] ICE connection disconnected" % sessionid)
# 添加ICE候选者收集状态监控
@pc.on("icegatheringstatechange")
async def on_icegatheringstatechange():
import time
timestamp = time.time()
logger.info("[SessionID:%d] ICE gathering state changed to %s at %.3f" % (sessionid, pc.iceGatheringState, timestamp))
if pc.iceGatheringState == "gathering":
logger.info("[SessionID:%d] ICE candidates gathering..." % sessionid)
elif pc.iceGatheringState == "complete":
logger.info("[SessionID:%d] ICE candidates gathering completed" % sessionid)
@pc.on("connectionstatechange")
async def on_connectionstatechange():
logger.info("Connection state is %s" % pc.connectionState)
if pc.connectionState == "failed":
import time
timestamp = time.time()
logger.info("[SessionID:%d] Connection state changed to %s at %.3f" % (sessionid, pc.connectionState, timestamp))
if pc.connectionState == "connecting":
logger.info("[SessionID:%d] WebRTC connection establishing..." % sessionid)
elif pc.connectionState == "connected":
logger.info("[SessionID:%d] WebRTC connection established successfully" % sessionid)
elif pc.connectionState == "failed":
logger.error("[SessionID:%d] WebRTC connection failed" % sessionid)
await pc.close()
pcs.discard(pc)
if sessionid in nerfreals:
del nerfreals[sessionid]
if pc.connectionState == "closed":
elif pc.connectionState == "closed":
logger.info("[SessionID:%d] WebRTC connection closed" % sessionid)
pcs.discard(pc)
if sessionid in nerfreals:
del nerfreals[sessionid]
gc.collect()
# 记录音视频轨道初始化开始时间
track_init_start = time.time()
logger.info('[SessionID:%d] Initializing audio/video tracks' % sessionid)
player = HumanPlayer(nerfreals[sessionid])
logger.info('[SessionID:%d] HumanPlayer created' % sessionid)
audio_sender = pc.addTrack(player.audio)
logger.info('[SessionID:%d] Audio track added' % sessionid)
video_sender = pc.addTrack(player.video)
logger.info('[SessionID:%d] Video track added' % sessionid)
# 记录音视频轨道初始化完成时间
track_init_end = time.time()
track_duration = track_init_end - track_init_start
logger.info('[SessionID:%d] Audio/video tracks initialized in %.3f seconds' % (sessionid, track_duration))
# 记录编解码器配置开始时间
codec_start = time.time()
logger.info('[SessionID:%d] Configuring video codecs' % sessionid)
capabilities = RTCRtpSender.getCapabilities("video")
preferences = list(filter(lambda x: x.name == "H264", capabilities.codecs))
preferences += list(filter(lambda x: x.name == "VP8", capabilities.codecs))
preferences += list(filter(lambda x: x.name == "rtx", capabilities.codecs))
logger.info('[SessionID:%d] Available codecs: %s' % (sessionid, [codec.name for codec in preferences]))
transceiver = pc.getTransceivers()[1]
transceiver.setCodecPreferences(preferences)
# 记录编解码器配置完成时间
codec_end = time.time()
codec_duration = codec_end - codec_start
logger.info('[SessionID:%d] Video codecs configured in %.3f seconds' % (sessionid, codec_duration))
# 记录SDP协商开始时间
import time
sdp_start = time.time()
logger.info('[SessionID:%d] Starting SDP negotiation' % sessionid)
await pc.setRemoteDescription(offer)
logger.info('[SessionID:%d] Remote description set' % sessionid)
answer = await pc.createAnswer()
logger.info('[SessionID:%d] Answer created' % sessionid)
await pc.setLocalDescription(answer)
# 记录SDP协商完成时间
sdp_end = time.time()
sdp_duration = sdp_end - sdp_start
logger.info('[SessionID:%d] SDP negotiation completed in %.3f seconds' % (sessionid, sdp_duration))
#return jsonify({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type})
return web.Response(
... ... @@ -133,22 +365,93 @@ async def offer(request):
)
async def human(request):
try:
params = await request.json()
sessionid = params.get('sessionid',0)
user_message = params.get('text', '')
message_type = params.get('type', 'echo')
# 检测请求来源(通过User-Agent或自定义头部)
user_agent = request.headers.get('User-Agent', '')
request_source = "第三方服务" if 'python' in user_agent.lower() or 'curl' in user_agent.lower() or 'postman' in user_agent.lower() else "页面"
# 如果有自定义来源标识,优先使用
if 'X-Request-Source' in request.headers:
request_source = request.headers['X-Request-Source']
if params.get('interrupt'):
nerfreals[sessionid].flush_talk()
if params['type']=='echo':
nerfreals[sessionid].put_msg_txt(params['text'])
elif params['type']=='chat':
res=await asyncio.get_event_loop().run_in_executor(None, llm_response, params['text'],nerfreals[sessionid])
#nerfreals[sessionid].put_msg_txt(res)
# 推送用户消息到WebSocket(统一推送所有用户输入)
await broadcast_message_to_session(sessionid, message_type, user_message, "用户", None, request_source)
ai_response = None
model_info = None
if message_type == 'echo':
nerfreals[sessionid].put_msg_txt(user_message)
ai_response = user_message
model_info = "Echo模式"
# 推送回音消息到WebSocket
await broadcast_message_to_session(sessionid, 'echo', user_message, "回音", model_info, request_source)
elif message_type == 'chat':
# 获取当前使用的大模型信息
model_info = getattr(nerfreals[sessionid], 'llm_model_name', 'Unknown LLM')
if hasattr(nerfreals[sessionid], 'llm') and hasattr(nerfreals[sessionid].llm, 'model_name'):
model_info = nerfreals[sessionid].llm.model_name
ai_response = await asyncio.get_event_loop().run_in_executor(None, llm_response, user_message, nerfreals[sessionid])
# 推送AI回复到WebSocket(包含大模型信息)
await broadcast_message_to_session(sessionid, 'chat', ai_response, "AI助手", model_info, request_source)
# 注释掉的代码保持不变,因为数字人回复通过其他方式处理
#nerfreals[sessionid].put_msg_txt(ai_response)
# 只返回简单的处理状态,所有数据通过WebSocket推送
return web.Response(
content_type="application/json",
text=json.dumps({
"code": 0,
"message": "消息已处理并推送"
}),
)
except Exception as e:
error_msg = str(e)
logger.exception('exception:')
# 推送错误消息到WebSocket
try:
sessionid = params.get('sessionid', 0) if 'params' in locals() else 0
request_source = "页面" # 默认来源
await broadcast_message_to_session(sessionid, 'error', f"处理消息时发生错误: {error_msg}", "系统错误", "Error", request_source)
except:
pass # 如果WebSocket推送也失败,不影响HTTP响应
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": 0, "data":"ok"}
{"code": -1, "msg": error_msg, "error_details": error_msg}
),
)
async def interrupt_talk(request):
try:
params = await request.json()
sessionid = params.get('sessionid',0)
nerfreals[sessionid].flush_talk()
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": 0, "msg":"ok"}
),
)
except Exception as e:
logger.exception('exception:')
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": -1, "msg": str(e)}
),
)
from pydub import AudioSegment
... ... @@ -191,12 +494,14 @@ async def humanaudio(request):
)
except Exception as e:
logger.exception('exception:')
return web.Response(
content_type="application/json",
text=json.dumps({"code": -1, "msg": "err", "data": str(e)})
text=json.dumps( {"code": -1, "msg": str(e)})
)
async def set_audiotype(request):
try:
params = await request.json()
sessionid = params.get('sessionid',0)
... ... @@ -208,8 +513,16 @@ async def set_audiotype(request):
{"code": 0, "data":"ok"}
),
)
except Exception as e:
logger.exception('exception:')
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": -1, "msg": str(e)}
),
)
async def record(request):
try:
params = await request.json()
sessionid = params.get('sessionid',0)
... ... @@ -224,7 +537,14 @@ async def record(request):
{"code": 0, "data":"ok"}
),
)
except Exception as e:
logger.exception('exception:')
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": -1, "msg": str(e)}
),
)
async def is_speaking(request):
params = await request.json()
... ... @@ -474,7 +794,9 @@ if __name__ == '__main__':
appasync.router.add_post("/humanaudio", humanaudio)
appasync.router.add_post("/set_audiotype", set_audiotype)
appasync.router.add_post("/record", record)
appasync.router.add_post("/interrupt_talk", interrupt_talk)
appasync.router.add_post("/is_speaking", is_speaking)
appasync.router.add_get("/ws", websocket_handler)
appasync.router.add_static('/',path='web')
# Configure default CORS settings.
... ...
{
"api_key": "0d885904-1636-4b7e-9ebb-45ec19a66899",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"model": "ep-20250214233333-sp8z4",
"stream": true,
"max_tokens": 1024,
"temperature": 0.7,
"top_p": 0.9,
"character": {
"name": "小艺",
"base_prompt": "你是小艺,是由艺云展陈开发的AI语音聊天机器人",
"personality": "友善、耐心、专业,具有亲和力",
"background": "专门为数字人交互场景设计的AI助手,擅长自然对话和情感交流",
"speaking_style": "回答风格精简、自然,语言亲切易懂,避免过于正式或冗长",
"constraints": "保持积极正面的态度,不讨论敏感话题,专注于为用户提供有价值的帮助"
},
"response_config": {
"enable_emotion": true,
"max_response_length": 200,
"break_sentences": true,
"sentence_delimiters": ",.!;:,。!?:;",
"min_chunk_length": 10
},
"advanced_settings": {
"retry_times": 3,
"timeout": 30,
"enable_context_memory": true,
"max_context_turns": 10
}
}
\ No newline at end of file
... ...
{
"model_type": "doubao",
"description": "LLM模型配置文件 - 支持qwen和doubao模型切换",
"models": {
"qwen": {
"name": "通义千问",
"api_key_env": "DASHSCOPE_API_KEY",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
"system_prompt": "你是小艺,是由艺云展陈开发的AI语音聊天机器人,回答风格精简。"
},
"doubao": {
"name": "豆包大模型",
"config_file": "config/doubao_config.json",
"description": "使用豆包模型进行对话,支持丰富的人物设定和参数配置"
}
},
"settings": {
"stream": true,
"sentence_split_chars": ",.!;:,。!?:;",
"min_sentence_length": 10,
"log_performance": true
}
}
\ No newline at end of file
... ...
import time
import os
import time
import json
from basereal import BaseReal
from logger import logger
def llm_response(message,nerfreal:BaseReal):
def llm_response(message, nerfreal: BaseReal):
"""LLM响应函数,支持多种模型配置"""
start = time.perf_counter()
# 加载LLM配置
llm_config = _load_llm_config()
model_type = llm_config.get("model_type", "qwen") # 默认使用通义千问
logger.info(f"使用LLM模型: {model_type}")
try:
if model_type == "doubao":
return _handle_doubao_response(message, nerfreal, start)
elif model_type == "qwen":
return _handle_qwen_response(message, nerfreal, start)
else:
logger.error(f"不支持的模型类型: {model_type}")
nerfreal.put_msg_txt("抱歉,当前模型配置有误,请检查配置文件。")
except Exception as e:
logger.error(f"LLM响应处理异常: {e}")
nerfreal.put_msg_txt("抱歉,我现在无法回答您的问题,请稍后再试。")
def _load_llm_config():
"""加载LLM配置文件"""
config_path = "config/llm_config.json"
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
logger.warning(f"LLM配置文件 {config_path} 不存在,使用默认配置")
return {"model_type": "qwen"}
except json.JSONDecodeError as e:
logger.error(f"LLM配置文件格式错误: {e}")
return {"model_type": "qwen"}
def _handle_doubao_response(message, nerfreal, start_time):
"""处理豆包模型响应"""
try:
from llm.Doubao import Doubao
doubao = Doubao()
end = time.perf_counter()
logger.info(f"豆包模型初始化时间: {end-start_time:.3f}s")
result = ""
first = True
def token_callback(content):
nonlocal result, first
if first:
end = time.perf_counter()
logger.info(f"豆包首个token时间: {end-start_time:.3f}s")
first = False
# 处理分句逻辑
lastpos = 0
for i, char in enumerate(content):
if char in ",.!;:,。!?:;":
result = result + content[lastpos:i+1]
lastpos = i+1
if len(result) > 10:
logger.info(f"豆包分句输出: {result}")
nerfreal.put_msg_txt(result)
result = ""
result = result + content[lastpos:]
# 使用流式响应
full_response = doubao.chat_stream(message, callback=token_callback)
# 输出剩余内容
if result:
logger.info(f"豆包最终输出: {result}")
nerfreal.put_msg_txt(result)
end = time.perf_counter()
logger.info(f"豆包总响应时间: {end-start_time:.3f}s")
return full_response
except ImportError:
logger.error("豆包模块导入失败,请检查Doubao.py文件")
nerfreal.put_msg_txt("抱歉,豆包模型暂时不可用。")
except Exception as e:
logger.error(f"豆包模型处理异常: {e}")
nerfreal.put_msg_txt("抱歉,豆包模型处理出现问题。")
def _handle_qwen_response(message, nerfreal, start_time):
"""处理通义千问模型响应(保持原有逻辑)"""
from openai import OpenAI
client = OpenAI(
# 如果您没有配置环境变量,请在此处用您的API Key进行替换
api_key=os.getenv("DASHSCOPE_API_KEY"),
#api_key = "localkey",
#base_url="http://127.0.0.1:5000/v1"
# 填写DashScope SDK的base_url
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
end = time.perf_counter()
logger.info(f"llm Time init: {end-start}s")
logger.info(f"通义千问初始化时间: {end-start_time:.3f}s")
completion = client.chat.completions.create(
# model="fay-streaming",
model="qwen-plus",
messages=[{'role': 'system', 'content': '你是小艺,是由艺云展陈开发的AI语音聊天机器人,回答风格精简。'},
{'role': 'user', 'content': message}],
stream=True,
# 通过以下设置,在流式输出的最后一行展示token使用信息
stream_options={"include_usage": True}
)
result=""
result = ""
first = True
for chunk in completion:
if len(chunk.choices)>0:
#print(chunk.choices[0].delta.content)
if len(chunk.choices) > 0:
if first:
end = time.perf_counter()
logger.info(f"llm Time to first chunk: {end-start}s")
logger.info(f"通义千问首个token时间: {end-start_time:.3f}s")
first = False
msg = chunk.choices[0].delta.content
lastpos=0
#msglist = re.split('[,.!;:,。!?]',msg)
if msg:
lastpos = 0
for i, char in enumerate(msg):
if char in ",.!;:,。!?:;" :
result = result+msg[lastpos:i+1]
if char in ",.!;:,。!?:;":
result = result + msg[lastpos:i+1]
lastpos = i+1
if len(result)>10:
logger.info(result)
if len(result) > 10:
logger.info(f"通义千问分句输出: {result}")
nerfreal.put_msg_txt(result)
result=""
result = result+msg[lastpos:]
result = ""
result = result + msg[lastpos:]
end = time.perf_counter()
logger.info(f"llm Time to last chunk: {end-start}s")
logger.info(f"通义千问总响应时间: {end-start_time:.3f}s")
if result:
nerfreal.put_msg_txt(result)
\ No newline at end of file
... ...
# AIfeng/2024-12-19
# 豆包大模型API实现
# 基于火山引擎豆包API: https://www.volcengine.com/docs/82379/1494384
import os
import json
import requests
from typing import Dict, List, Any, Optional
from logger import logger
class Doubao:
"""豆包大模型API客户端"""
def __init__(self, config_path: str = "config/doubao_config.json"):
"""初始化豆包模型
Args:
config_path: 配置文件路径
"""
self.config_file = config_path
self.config = self._load_config(config_path)
self.api_key = os.getenv("DOUBAO_API_KEY") or self.config.get("api_key")
self.base_url = self.config.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
self.model = self.config.get("model", "ep-20241219000000-xxxxx")
self.character_config = self.config.get("character", {})
if not self.api_key:
raise ValueError("豆包API密钥未配置,请设置环境变量DOUBAO_API_KEY或在配置文件中设置api_key")
def _load_config(self, config_path: str) -> Dict[str, Any]:
"""加载配置文件"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
logger.warning(f"配置文件 {config_path} 不存在,使用默认配置")
return {}
except json.JSONDecodeError as e:
logger.error(f"配置文件格式错误: {e}")
return {}
def _build_system_message(self) -> str:
"""构建系统消息"""
character = self.character_config
system_prompt = character.get("base_prompt", "你是一个AI助手")
# 添加角色设定
if character.get("name"):
system_prompt += f",你的名字是{character['name']}"
if character.get("personality"):
system_prompt += f",性格特点:{character['personality']}"
if character.get("background"):
system_prompt += f",背景设定:{character['background']}"
if character.get("speaking_style"):
system_prompt += f",说话风格:{character['speaking_style']}"
if character.get("constraints"):
system_prompt += f",行为约束:{character['constraints']}"
return system_prompt
def chat(self, message: str, history: Optional[List[Dict[str, str]]] = None) -> str:
"""发送聊天请求
Args:
message: 用户消息
history: 对话历史
Returns:
AI回复内容
"""
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# 构建消息列表
messages = []
# 添加系统消息
system_message = self._build_system_message()
messages.append({
"role": "system",
"content": system_message
})
# 添加历史对话
if history:
messages.extend(history)
# 添加当前用户消息
messages.append({
"role": "user",
"content": message
})
# 构建请求数据
data = {
"model": self.model,
"messages": messages,
"stream": self.config.get("stream", True),
"max_tokens": self.config.get("max_tokens", 1024),
"temperature": self.config.get("temperature", 0.7),
"top_p": self.config.get("top_p", 0.9)
}
try:
response = requests.post(url, headers=headers, json=data, timeout=30)
response.raise_for_status()
if self.config.get("stream", True):
return self._handle_stream_response(response)
else:
result = response.json()
return result["choices"][0]["message"]["content"]
except requests.exceptions.RequestException as e:
logger.error(f"豆包API请求失败: {e}")
return "抱歉,我现在无法回答您的问题,请稍后再试。"
except Exception as e:
logger.error(f"豆包API处理异常: {e}")
return "抱歉,处理您的请求时出现了问题。"
def _handle_stream_response(self, response) -> str:
"""处理流式响应"""
result = ""
try:
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:]
if data_str.strip() == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
result += content
except json.JSONDecodeError:
continue
return result
except Exception as e:
logger.error(f"处理流式响应失败: {e}")
return "抱歉,处理响应时出现问题。"
def chat_stream(self, message: str, history: Optional[List[Dict[str, str]]] = None, callback=None):
"""流式聊天,支持回调函数处理每个token
Args:
message: 用户消息
history: 对话历史
callback: 回调函数,接收每个生成的token
"""
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# 构建消息列表
messages = []
# 添加系统消息
system_message = self._build_system_message()
messages.append({
"role": "system",
"content": system_message
})
# 添加历史对话
if history:
messages.extend(history)
# 添加当前用户消息
messages.append({
"role": "user",
"content": message
})
# 构建请求数据
data = {
"model": self.model,
"messages": messages,
"stream": True,
"max_tokens": self.config.get("max_tokens", 1024),
"temperature": self.config.get("temperature", 0.7),
"top_p": self.config.get("top_p", 0.9)
}
try:
response = requests.post(url, headers=headers, json=data, stream=True, timeout=30)
response.raise_for_status()
result = ""
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:]
if data_str.strip() == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
result += content
if callback:
callback(content)
except json.JSONDecodeError:
continue
return result
except Exception as e:
logger.error(f"豆包流式API请求失败: {e}")
if callback:
callback("抱歉,我现在无法回答您的问题,请稍后再试。")
return "抱歉,我现在无法回答您的问题,请稍后再试。"
def test_doubao():
"""测试豆包API"""
try:
doubao = Doubao()
response = doubao.chat("你好,请介绍一下自己")
print(f"豆包回复: {response}")
except Exception as e:
print(f"测试失败: {e}")
if __name__ == "__main__":
test_doubao()
\ No newline at end of file
... ...
# AIfeng/2024-12-19
# LLM模块包初始化文件
"""
LLM模块包 - 支持多种大语言模型
支持的模型:
- ChatGPT: OpenAI GPT模型
- Qwen: 阿里云通义千问模型
- Gemini: Google Gemini模型
- VllmGPT: VLLM加速的GPT模型
- Doubao: 火山引擎豆包模型
使用示例:
from llm.Doubao import Doubao
from llm.Qwen import Qwen
from llm.ChatGPT import ChatGPT
"""
__version__ = "1.0.0"
__author__ = "AIfeng"
# 导入所有模型类
try:
from .ChatGPT import ChatGPT
except ImportError:
ChatGPT = None
try:
from .Qwen import Qwen
except ImportError:
Qwen = None
try:
from .Gemini import Gemini
except ImportError:
Gemini = None
try:
from .VllmGPT import VllmGPT
except ImportError:
VllmGPT = None
try:
from .Doubao import Doubao
except ImportError:
Doubao = None
try:
from .LLM import LLM
except ImportError:
LLM = None
# 导入llm_response函数
try:
import sys
import os
# 添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
sys.path.insert(0, parent_dir)
# 导入llm模块中的函数
import importlib.util
spec = importlib.util.spec_from_file_location("llm_module", os.path.join(parent_dir, "llm.py"))
llm_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(llm_module)
llm_response = llm_module.llm_response
except Exception as e:
print(f"Warning: Failed to import llm_response: {e}")
llm_response = None
# 可用模型列表
AVAILABLE_MODELS = []
if ChatGPT:
AVAILABLE_MODELS.append('ChatGPT')
if Qwen:
AVAILABLE_MODELS.append('Qwen')
if Gemini:
AVAILABLE_MODELS.append('Gemini')
if VllmGPT:
AVAILABLE_MODELS.append('VllmGPT')
if Doubao:
AVAILABLE_MODELS.append('Doubao')
if LLM:
AVAILABLE_MODELS.append('LLM')
__all__ = ['ChatGPT', 'Qwen', 'Gemini', 'VllmGPT', 'Doubao', 'LLM', 'llm_response', 'AVAILABLE_MODELS']
\ No newline at end of file
... ...
#!/usr/bin/env python3
# AIfeng/2024-12-19
# 豆包模型集成测试脚本
import os
import sys
import json
from pathlib import Path
# 添加项目根目录到Python路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
def test_config_files():
"""测试配置文件是否存在和格式正确"""
print("=== 配置文件测试 ===")
# 测试LLM配置文件
llm_config_path = project_root / "config" / "llm_config.json"
if llm_config_path.exists():
try:
with open(llm_config_path, 'r', encoding='utf-8') as f:
llm_config = json.load(f)
print(f"✓ LLM配置文件加载成功: {llm_config_path}")
print(f" 当前模型类型: {llm_config.get('model_type', 'unknown')}")
except Exception as e:
print(f"✗ LLM配置文件格式错误: {e}")
else:
print(f"✗ LLM配置文件不存在: {llm_config_path}")
# 测试豆包配置文件
doubao_config_path = project_root / "config" / "doubao_config.json"
if doubao_config_path.exists():
try:
with open(doubao_config_path, 'r', encoding='utf-8') as f:
doubao_config = json.load(f)
print(f"✓ 豆包配置文件加载成功: {doubao_config_path}")
print(f" 模型名称: {doubao_config.get('model', 'unknown')}")
print(f" 人物设定: {doubao_config.get('character', {}).get('name', 'unknown')}")
except Exception as e:
print(f"✗ 豆包配置文件格式错误: {e}")
else:
print(f"✗ 豆包配置文件不存在: {doubao_config_path}")
def test_module_import():
"""测试模块导入"""
print("\n=== 模块导入测试 ===")
try:
from llm.Doubao import Doubao
print("✓ 豆包模块导入成功")
except ImportError as e:
print(f"✗ 豆包模块导入失败: {e}")
return False
try:
import llm
print(f"✓ LLM包导入成功,可用模型: {llm.AVAILABLE_MODELS}")
except ImportError as e:
print(f"✗ LLM包导入失败: {e}")
return True
def test_llm_config_loading():
"""测试LLM配置加载函数"""
print("\n=== LLM配置加载测试 ===")
try:
# 模拟llm.py中的配置加载函数
config_path = project_root / "config" / "llm_config.json"
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
print(f"✓ 配置加载成功")
print(f" 模型类型: {config.get('model_type')}")
print(f" 配置项: {list(config.keys())}")
return config
else:
print("✗ 配置文件不存在,使用默认配置")
return {"model_type": "qwen"}
except Exception as e:
print(f"✗ 配置加载失败: {e}")
return {"model_type": "qwen"}
def test_doubao_instantiation():
"""测试豆包模型实例化(不需要真实API密钥)"""
print("\n=== 豆包实例化测试 ===")
try:
from llm.Doubao import Doubao
# 设置测试API密钥
os.environ['DOUBAO_API_KEY'] = 'test_key_for_validation'
doubao = Doubao()
print("✓ 豆包实例化成功")
print(f" 配置文件路径: {doubao.config_file}")
print(f" API基础URL: {doubao.base_url}")
print(f" 模型名称: {doubao.model}")
# 清理测试环境变量
if 'DOUBAO_API_KEY' in os.environ:
del os.environ['DOUBAO_API_KEY']
return True
except Exception as e:
print(f"✗ 豆包实例化失败: {e}")
return False
def test_integration_flow():
"""测试完整集成流程"""
print("\n=== 集成流程测试 ===")
try:
# 模拟llm.py中的流程
config = test_llm_config_loading()
model_type = config.get("model_type", "qwen")
print(f"根据配置选择模型: {model_type}")
if model_type == "doubao":
print("✓ 将使用豆包模型处理请求")
elif model_type == "qwen":
print("✓ 将使用通义千问模型处理请求")
else:
print(f"⚠ 未知模型类型: {model_type}")
return True
except Exception as e:
print(f"✗ 集成流程测试失败: {e}")
return False
def main():
"""主测试函数"""
print("豆包模型集成测试")
print("=" * 50)
# 运行所有测试
test_config_files()
if not test_module_import():
print("\n模块导入失败,停止测试")
return
test_llm_config_loading()
test_doubao_instantiation()
test_integration_flow()
print("\n=== 测试总结 ===")
print("✓ 豆包模型已成功集成到项目中")
print("✓ 配置文件结构正确")
print("✓ 模块导入正常")
print("\n使用说明:")
print("1. 设置环境变量 DOUBAO_API_KEY 为您的豆包API密钥")
print("2. 在 config/llm_config.json 中设置 model_type 为 'doubao'")
print("3. 根据需要修改 config/doubao_config.json 中的人物设定")
print("4. 重启应用即可使用豆包模型")
if __name__ == "__main__":
main()
\ No newline at end of file
... ...
#!/usr/bin/env python3
# AIfeng/2024-12-19
# WebSocket通信测试服务器
import asyncio
import json
import time
import weakref
from aiohttp import web, WSMsgType
import aiohttp_cors
from typing import Dict
# 全局变量
websocket_connections: Dict[int, weakref.WeakSet] = {} # sessionid:websocket_connections
# WebSocket消息推送函数
async def broadcast_message_to_session(sessionid: int, message_type: str, content: str, source: str = "测试服务器"):
"""向指定会话的所有WebSocket连接推送消息"""
if sessionid not in websocket_connections:
print(f'[SessionID:{sessionid}] No WebSocket connections found')
return
message = {
"type": "chat_message",
"data": {
"sessionid": sessionid,
"message_type": message_type,
"content": content,
"source": source,
"timestamp": time.time()
}
}
# 获取该会话的所有WebSocket连接
connections = list(websocket_connections[sessionid])
print(f'[SessionID:{sessionid}] Broadcasting to {len(connections)} connections')
# 向所有连接发送消息
for ws in connections:
try:
if not ws.closed:
await ws.send_str(json.dumps(message))
print(f'[SessionID:{sessionid}] Message sent to WebSocket: {message_type}')
except Exception as e:
print(f'[SessionID:{sessionid}] Failed to send WebSocket message: {e}')
# WebSocket处理器
async def websocket_handler(request):
"""处理WebSocket连接"""
ws = web.WebSocketResponse()
await ws.prepare(request)
sessionid = None
print('New WebSocket connection established')
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
try:
data = json.loads(msg.data)
print(f'Received WebSocket message: {data}')
if data.get('type') == 'login':
sessionid = data.get('sessionid', 0)
# 初始化该会话的WebSocket连接集合
if sessionid not in websocket_connections:
websocket_connections[sessionid] = weakref.WeakSet()
# 添加当前连接到会话
websocket_connections[sessionid].add(ws)
print(f'[SessionID:{sessionid}] WebSocket client logged in')
# 发送登录确认
await ws.send_str(json.dumps({
"type": "login_success",
"sessionid": sessionid,
"message": "WebSocket连接成功"
}))
elif data.get('type') == 'ping':
# 心跳检测
await ws.send_str(json.dumps({"type": "pong"}))
print('Sent pong response')
except json.JSONDecodeError:
print('Invalid JSON received from WebSocket')
except Exception as e:
print(f'Error processing WebSocket message: {e}')
elif msg.type == WSMsgType.ERROR:
print(f'WebSocket error: {ws.exception()}')
break
except Exception as e:
print(f'WebSocket connection error: {e}')
finally:
if sessionid is not None:
print(f'[SessionID:{sessionid}] WebSocket connection closed')
else:
print('WebSocket connection closed')
return ws
# 模拟human接口
async def human(request):
try:
params = await request.json()
sessionid = params.get('sessionid', 0)
user_message = params.get('text', '')
message_type = params.get('type', 'echo')
print(f'[SessionID:{sessionid}] Received {message_type} message: {user_message}')
# 推送用户消息到WebSocket
await broadcast_message_to_session(sessionid, message_type, user_message, "用户")
if message_type == 'echo':
# 推送回音消息到WebSocket
await broadcast_message_to_session(sessionid, 'echo', user_message, "回音")
elif message_type == 'chat':
# 模拟AI回复
ai_response = f"这是对 '{user_message}' 的AI回复"
await broadcast_message_to_session(sessionid, 'chat', ai_response, "AI助手")
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": 0, "data": "ok", "message": "消息已处理并推送"}
),
)
except Exception as e:
print(f'Error in human endpoint: {e}')
return web.Response(
content_type="application/json",
text=json.dumps(
{"code": -1, "msg": str(e)}
),
)
# 创建应用
def create_app():
app = web.Application()
# 添加路由
app.router.add_post("/human", human)
app.router.add_get("/ws", websocket_handler)
app.router.add_static('/', path='web')
# 配置CORS
cors = aiohttp_cors.setup(app, defaults={
"*": aiohttp_cors.ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
)
})
# 为所有路由配置CORS
for route in list(app.router.routes()):
cors.add(route)
return app
if __name__ == '__main__':
app = create_app()
print('Starting WebSocket test server on http://localhost:8000')
print('WebSocket endpoint: ws://localhost:8000/ws')
print('HTTP endpoint: http://localhost:8000/human')
print('Test page: http://localhost:8000/websocket_test.html')
web.run_app(app, host='0.0.0.0', port=8000)
\ No newline at end of file
... ...
... ... @@ -6,14 +6,28 @@ function negotiate() {
return pc.createOffer().then((offer) => {
return pc.setLocalDescription(offer);
}).then(() => {
// wait for ICE gathering to complete
// wait for ICE gathering to complete with timeout
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') {
resolve();
} else {
let resolved = false;
// ICE收集超时机制:3秒后强制继续
const timeout = setTimeout(() => {
if (!resolved) {
console.warn('ICE gathering timeout after 3 seconds, proceeding with available candidates');
resolved = true;
resolve();
}
}, 3000);
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
if (pc.iceGatheringState === 'complete' && !resolved) {
clearTimeout(timeout);
resolved = true;
pc.removeEventListener('icegatheringstatechange', checkState);
console.log('ICE gathering completed successfully');
resolve();
}
};
... ... @@ -36,6 +50,19 @@ function negotiate() {
return response.json();
}).then((answer) => {
document.getElementById('sessionid').value = answer.sessionid
console.log('SessionID已设置:', answer.sessionid);
// 保存sessionId到本地存储(如果saveSessionId函数存在)
if (typeof saveSessionId === 'function') {
saveSessionId(answer.sessionid);
}
// 触发WebSocket连接(如果connectWebSocket函数存在)
if (typeof connectWebSocket === 'function') {
console.log('触发WebSocket连接...');
connectWebSocket();
}
return pc.setRemoteDescription(answer);
}).catch((e) => {
alert(e);
... ... @@ -48,11 +75,46 @@ function start() {
};
if (document.getElementById('use-stun').checked) {
config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
// 优化STUN服务器配置:使用多个快速STUN服务器
config.iceServers = [
{ urls: ['stun:stun.l.google.com:19302'] },
{ urls: ['stun:stun1.l.google.com:19302'] },
{ urls: ['stun:stun2.l.google.com:19302'] }
];
// ICE传输策略和候选者池大小优化
config.iceTransportPolicy = 'all';
config.iceCandidatePoolSize = 10;
}
pc = new RTCPeerConnection(config);
// 添加ICE连接状态监控
pc.addEventListener('iceconnectionstatechange', () => {
console.log('ICE connection state:', pc.iceConnectionState);
const statusElement = document.getElementById('connection-status');
if (statusElement) {
statusElement.textContent = `ICE状态: ${pc.iceConnectionState}`;
}
});
// 添加ICE候选者收集状态监控
pc.addEventListener('icegatheringstatechange', () => {
console.log('ICE gathering state:', pc.iceGatheringState);
const statusElement = document.getElementById('gathering-status');
if (statusElement) {
statusElement.textContent = `ICE收集: ${pc.iceGatheringState}`;
}
});
// 添加连接状态监控
pc.addEventListener('connectionstatechange', () => {
console.log('Connection state:', pc.connectionState);
const statusElement = document.getElementById('overall-status');
if (statusElement) {
statusElement.textContent = `连接状态: ${pc.connectionState}`;
}
});
// connect audio / video
pc.addEventListener('track', (evt) => {
if (evt.track.kind == 'video') {
... ...
... ... @@ -18,6 +18,8 @@
/* 侧边栏样式 */
#sidebar {
width: 300px;
min-width: 300px;
max-width: 300px;
background-color: #f8f9fa;
padding: 20px;
box-shadow: 0 0 15px rgba(0,0,0,0.1);
... ... @@ -26,7 +28,10 @@
flex-direction: column;
gap: 15px;
transition: all 0.4s ease;
position: relative;
position: fixed;
top: 0;
left: 0;
height: 100vh;
border-radius: 0 10px 10px 0;
z-index: 50;
}
... ... @@ -34,9 +39,11 @@
/* 收缩状态的侧边栏 */
#sidebar.collapsed {
width: 0;
min-width: 0;
padding: 0;
margin-left: -15px;
opacity: 0.95;
margin-left: 0;
opacity: 0;
transform: translateX(-100%);
}
/* 收缩状态下隐藏内容 */
... ... @@ -47,41 +54,49 @@
/* 切换按钮样式 */
#sidebar-toggle {
position: fixed;
top: 50%;
transform: translateY(-50%);
/* left is managed by JavaScript */
bottom: 20px;
left: 20px;
width: 48px;
height: 48px;
background-color: #4285f4; /* Original color */
background-color: #4285f4;
color: white;
border: none; /* Original border */
border: none;
border-radius: 50%;
display: flex !important; /* Ensure it's displayed as flex */
display: flex !important;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1002 !important; /* Higher than media's 1000, and ensure it applies */
z-index: 1002 !important;
font-size: 20px;
box-shadow: 0 3px 8px rgba(0,0,0,0.2);
transition: left 0.3s ease, background-color 0.3s ease, transform 0.3s ease;
opacity: 1 !important; /* Ensure opacity */
visibility: visible !important; /* Ensure visibility */
transition: all 0.3s ease;
opacity: 1 !important;
visibility: visible !important;
}
/* 主内容区域样式 */
#main-content {
flex: 1;
margin-left: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
overflow: visible; /* 修改为visible以防止内容被裁剪 */
overflow: visible;
transition: all 0.4s ease;
position: relative;
padding: 10px;
box-sizing: border-box;
min-height: 100vh; /* 确保至少有足够的高度 */
min-height: 100vh;
width: calc(100vw - 300px);
}
/* 侧边栏收缩时主内容区域样式 */
#sidebar.collapsed ~ #main-content {
margin-left: 0;
width: 100vw;
}
... ... @@ -168,26 +183,22 @@
visibility: visible !important;
opacity: 1 !important;
}
/* NEW: Fullscreen media when sidebar is collapsed */
/* 侧边栏收缩时媒体区域全屏显示 */
#sidebar.collapsed ~ #main-content #media {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
aspect-ratio: unset;
z-index: 1000; /* Ensure it's above other elements like the toggle button */
margin: 0; /* Reset margin */
padding: 0; /* Reset padding */
border-radius: 0; /* Remove any border-radius */
margin: 0;
padding: 0;
border-radius: 0;
}
/* Optional: Adjust main content padding when sidebar is collapsed */
/* 侧边栏收缩时主内容区域调整 */
#sidebar.collapsed ~ #main-content {
padding: 0; /* Remove padding as #media takes full screen */
background-color: #000; /* Match media background */
padding: 0;
background-color: #000;
}
... ... @@ -339,42 +350,15 @@
<script type="text/javascript" charset="utf-8">
$(document).ready(function() {
var expandedSidebarLeft = '300px'; // Corresponds to #sidebar CSS width
var collapsedSidebarLeft = '10px'; // Test: offset from edge
// Function to update toggle button based on sidebar state
// Function to update toggle button icon based on sidebar state
function updateToggleButtonState() {
var sidebarIsCollapsed = $('#sidebar').hasClass('collapsed');
console.log('[Sidebar Toggle] Sidebar collapsed state:', sidebarIsCollapsed);
var toggleButton = $('#sidebar-toggle');
if (sidebarIsCollapsed) {
// Restore intended behavior: set left, icon, and original background color
// Relies on base CSS for top: 50% and transform: translateY(-50%)
toggleButton.html('≫').css({
'left': collapsedSidebarLeft, // This is '10px'
'background-color': '#4285f4', // Original color
'top': '50%', // Ensure it matches base CSS
'transform': 'translateY(-50%)' // Ensure it matches base CSS
});
console.log('[Sidebar Toggle] Action: Set to collapsed state. Left:', collapsedSidebarLeft, 'HTML:', toggleButton.html());
toggleButton.html('≫');
} else {
// Restore intended behavior: set left, icon, and original background color
toggleButton.html('≪').css({
'left': expandedSidebarLeft,
'background-color': '#4285f4', // Original color
'top': '50%', // Ensure it matches base CSS
'transform': 'translateY(-50%)' // Ensure it matches base CSS
});
console.log('[Sidebar Toggle] Action: Set to expanded state. Left:', expandedSidebarLeft, 'HTML:', toggleButton.html());
}
// Log applied styles to be sure
console.log('[Sidebar Toggle] Applied style attribute:', toggleButton.attr('style'));
console.log('[Sidebar Toggle] Computed z-index:', toggleButton.css('z-index'));
console.log('[Sidebar Toggle] Computed opacity:', toggleButton.css('opacity'));
console.log('[Sidebar Toggle] Computed visibility:', toggleButton.css('visibility'));
console.log('[Sidebar Toggle] Computed display:', toggleButton.css('display'));
console.log('[Sidebar Toggle] Offset:', toggleButton.offset());
console.log('[Sidebar Toggle] Position:', toggleButton.position());
toggleButton.html('≪');
}
}
// Initial state setup for the toggle button
... ...
<!--
AIfeng/2024-12-19
WebRTC API Chat - 数字人对话页面
功能:支持文字、语音输入与数字人实时对话,包含完整的对话记录功能
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<title>WebRTC 数字人</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
display: flex;
height: 100vh;
}
/* 侧边栏样式 */
#sidebar {
width: 300px;
min-width: 300px;
max-width: 300px;
background-color: #f8f9fa;
padding: 20px;
box-shadow: 0 0 15px rgba(0,0,0,0.1);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
transition: all 0.4s ease;
position: fixed;
top: 0;
left: 0;
height: 100vh;
border-radius: 0 10px 10px 0;
z-index: 50;
}
/* 收缩状态的侧边栏 */
#sidebar.collapsed {
width: 0;
min-width: 0;
padding: 0;
margin-left: 0;
opacity: 0;
transform: translateX(-100%);
}
/* 收缩状态下隐藏内容 */
#sidebar.collapsed > div {
display: none;
}
/* 切换按钮样式 */
#sidebar-toggle {
position: fixed;
bottom: 20px;
left: 20px;
width: 48px;
height: 48px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 50%;
display: flex !important;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1002 !important;
font-size: 20px;
box-shadow: 0 3px 8px rgba(0,0,0,0.2);
transition: all 0.3s ease;
opacity: 1 !important;
visibility: visible !important;
}
/* 主内容区域样式 */
#main-content {
margin-left: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
overflow: visible;
transition: all 0.4s ease;
position: relative;
padding: 10px;
box-sizing: border-box;
min-height: 100vh;
width: calc(100vw - 300px);
}
/* 侧边栏收缩时主内容区域样式 */
#sidebar.collapsed ~ #main-content {
margin-left: 0;
width: 100vw;
}
/* 按钮样式 */
button {
padding: 8px 16px;
margin: 5px 0;
cursor: pointer;
border: none;
border-radius: 4px;
background-color: #4285f4;
color: white;
font-weight: bold;
}
button:hover {
background-color: #3367d6;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
/* 表单样式 */
.form-group {
margin-bottom: 15px;
width: 100%;
}
.form-control {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
/* 媒体区域样式 */
#media {
width: 100%;
max-width: 1080px;
height: 100%;
max-height: 90vh; /* 限制最大高度为视口高度的90% */
position: relative;
overflow: hidden;
background-color: #000;
margin: 0 auto; /* 居中显示 */
box-sizing: border-box; /* 确保padding不会增加宽度 */
aspect-ratio: 9/16; /* 设置宽高比为9:16(竖屏) */
display: block !important; /* 确保始终显示 */
z-index: 20; /* 提高z-index确保可见 */
}
#media h2 {
position: absolute;
top: 10px;
left: 10px;
color: white;
margin: 0;
z-index: 10;
background-color: rgba(0,0,0,0.5);
padding: 5px 10px;
border-radius: 4px;
}
video {
width: 100%;
height: 100%;
object-fit: contain; /* 保持视频比例 */
position: absolute;
top: 0;
left: 0;
max-width: 100%; /* 确保不超出容器 */
max-height: 100%; /* 确保不超出容器 */
}
/* 确保侧边栏收缩时视频元素本身也可见 */
#sidebar.collapsed ~ #main-content #video,
#sidebar.collapsed + #main-content #video {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
/* 侧边栏收缩时媒体区域全屏显示 */
#sidebar.collapsed ~ #main-content #media {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
aspect-ratio: unset;
margin: 0;
padding: 0;
border-radius: 0;
}
/* 侧边栏收缩时主内容区域调整 */
#sidebar.collapsed ~ #main-content {
padding: 0;
background-color: #000;
}
.option {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.section-title {
font-weight: bold;
margin-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 8px;
color: #4285f4;
font-size: 16px;
letter-spacing: 0.5px;
}
/* 美化表单控件 */
.form-control:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
/* 侧边栏内部元素样式优化 */
#sidebar > div {
background-color: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* 聊天消息样式 */
#chatOverlay {
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
#chatOverlay .message {
display: flex;
margin-bottom: 12px;
max-width: 100%;
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#chatOverlay .message.right {
justify-content: flex-end;
}
#chatOverlay .message.left {
justify-content: flex-start;
}
#chatOverlay .avatar {
width: 28px;
height: 28px;
border-radius: 50%;
margin: 0 6px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.2);
}
#chatOverlay .text-container {
background-color: rgba(255,255,255,0.95);
border-radius: 12px;
padding: 8px 12px;
max-width: 75%;
color: #333;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
position: relative;
}
#chatOverlay .message.right .text-container {
background-color: #4285f4;
color: white;
}
/* 数字人回复样式 - 根据模式区分 */
#chatOverlay .message.left .text-container {
background-color: rgba(248,249,250,0.95);
border-left: 3px solid #4285f4;
}
/* Echo模式 - 回音重复 */
#chatOverlay .message.left.mode-echo .text-container {
background-color: rgba(255,235,59,0.9);
border-left: 3px solid #FFC107;
color: #333;
}
/* Chat模式 - 大模型回复 */
#chatOverlay .message.left.mode-chat .text-container {
background-color: rgba(76,175,80,0.9);
border-left: 3px solid #4CAF50;
color: white;
}
/* Audio模式 - 语音识别回复 */
#chatOverlay .message.left.mode-audio .text-container {
background-color: rgba(156,39,176,0.9);
border-left: 3px solid #9C27B0;
color: white;
}
/* Plaintext模式 - 纯文本 */
#chatOverlay .message.left.mode-plaintext .text-container {
background-color: rgba(96,125,139,0.9);
border-left: 3px solid #607D8B;
color: white;
}
#chatOverlay .source-tag {
font-size: 8px;
color: #666;
background-color: rgba(66,133,244,0.1);
padding: 1px 4px;
border-radius: 6px;
margin-bottom: 3px;
display: inline-block;
font-weight: 500;
letter-spacing: 0.2px;
}
#chatOverlay .message.right .source-tag {
background-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.9);
}
/* 不同模式的source-tag样式 */
#chatOverlay .message.left.mode-echo .source-tag {
background-color: rgba(255,193,7,0.2);
color: #E65100;
}
#chatOverlay .message.left.mode-chat .source-tag {
background-color: rgba(76,175,80,0.2);
color: #1B5E20;
}
#chatOverlay .message.left.mode-audio .source-tag {
background-color: rgba(156,39,176,0.2);
color: #4A148C;
}
#chatOverlay .message.left.mode-plaintext .source-tag {
background-color: rgba(96,125,139,0.2);
color: #263238;
}
#chatOverlay .text {
line-height: 1.3;
word-wrap: break-word;
margin-bottom: 3px;
font-size: 13px;
}
#chatOverlay .time {
font-size: 9px;
color: #999;
text-align: right;
margin-top: 3px;
opacity: 0.7;
}
#chatOverlay .message.right .time {
color: rgba(255,255,255,0.7);
}
/* 简化的聊天框头部 - 图标化 */
#chatOverlay .chat-header {
background-color: rgba(0,0,0,0.4);
color: rgba(255,255,255,0.9);
padding: 3px 8px;
border-radius: 0 0 8px 8px;
font-size: 10px;
font-weight: normal;
text-align: center;
margin: 0 -8px -8px -8px;
border-top: 1px solid rgba(255,255,255,0.15);
backdrop-filter: blur(8px);
position: relative;
flex-shrink: 0;
}
/* 清空按钮 */
#chatOverlay .clear-chat {
position: absolute;
top: 0px;
right: 15px;
background: none;
border: none;
color: rgba(255,255,255,0.6);
cursor: pointer;
font-size: 12px;
padding: 1px;
border-radius: 2px;
transition: all 0.2s;
}
#chatOverlay .clear-chat:hover {
background-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.9);
}
/* 响应式适配 */
@media (max-width: 2160px) {
#chatOverlay {
width: min(600px, 32vw) !important;
height: 180px !important;
}
}
/* 响应式适配 */
@media (max-width: 1200px) {
#chatOverlay {
width: min(400px, 32vw) !important;
height: 180px !important;
}
}
@media (max-width: 768px) {
#chatOverlay {
width: min(280px, 38vw) !important;
height: 160px !important;
bottom: 10px !important;
right: 10px !important;
}
}
@media (max-width: 480px) {
#chatOverlay {
width: min(200px, 45vw) !important;
height: 140px !important;
}
}
</style>
</head>
<body>
<!-- 侧边栏切换按钮 (Moved to be a direct child of body) -->
<button id="sidebar-toggle"></button>
<!-- 侧边栏 -->
<div id="sidebar">
<div>
<div class="section-title">连接控制</div>
<div class="option">
<input id="use-stun" type="checkbox"/>
<label for="use-stun">使用 STUN 服务器</label>
</div>
<button id="start" onclick="start()">开始连接</button>
<button id="stop" style="display: none" onclick="stop()">停止连接</button>
</div>
<div>
<div class="section-title">录制控制</div>
<button class="btn btn-primary" id="btn_start_record">开始录制</button>
<button class="btn btn-primary" id="btn_stop_record" disabled>停止录制</button>
</div>
<div>
<div class="section-title">文本输入</div>
<input type="hidden" id="sessionid" value="0">
<div class="form-group">
<label for="current-sessionid">当前会话ID</label>
<div class="input-group">
<input type="text" class="form-control" id="current-sessionid" readonly placeholder="未连接">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="clear-session-btn" title="清除会话ID,重新连接">重置</button>
</div>
</div>
</div>
<form class="form-inline" id="echo-form">
<div class="form-group">
<label for="message-type">消息类型</label>
<select class="form-control" id="message-type">
<option value="chat">智能对话</option>
<option value="echo">回音模式</option>
</select>
</div>
<div class="form-group">
<label for="message">输入文本</label>
<textarea rows="3" class="form-control" id="message">test</textarea>
</div>
<button type="submit" class="btn btn-default">发送</button>
</form>
</div>
<div>
<div class="section-title">本地存储设置</div>
<div class="option">
<input id="enable-storage" type="checkbox" checked/>
<label for="enable-storage">启用本地聊天记录</label>
</div>
<button id="load-history" class="btn btn-secondary">加载历史记录</button>
<button id="clear-storage" class="btn btn-danger">清理本地记录</button>
<button id="view-history" class="btn btn-info">查看历史记录</button>
</div>
<div>
<div class="section-title">控制服务器配置</div>
<div class="option">
<input id="enable-control-ws" type="checkbox" checked/>
<label for="enable-control-ws">启用控制服务器连接</label>
</div>
<div class="form-group">
<label for="control-ws-host">控制服务器主机</label>
<input type="text" class="form-control" id="control-ws-host" placeholder="默认: 127.0.0.1">
</div>
<div class="form-group">
<label for="control-ws-port">控制服务器端口</label>
<input type="text" class="form-control" id="control-ws-port" placeholder="默认: 10002">
</div>
<button id="save-control-ws-config" class="btn btn-default">保存控制配置</button>
<button id="reset-control-ws-config" class="btn btn-default">重置控制配置</button>
<button id="connect-control-ws" class="btn btn-default">连接控制服务器</button>
<button id="disconnect-control-ws" class="btn btn-default" disabled>断开控制服务器</button>
</div>
<div>
<div class="section-title">聊天服务器配置</div>
<div class="form-group">
<label for="chat-ws-host">聊天服务器主机</label>
<input type="text" class="form-control" id="chat-ws-host" placeholder="默认: localhost">
</div>
<div class="form-group">
<label for="chat-ws-port">聊天服务器端口</label>
<input type="text" class="form-control" id="chat-ws-port" placeholder="默认: 8010">
</div>
<button id="save-chat-ws-config" class="btn btn-default">保存聊天配置</button>
<button id="reset-chat-ws-config" class="btn btn-default">重置聊天配置</button>
</div>
</div>
<!-- 主内容区域 -->
<div id="main-content">
<input type="hidden" id="username" value="User">
<div id="media">
<h2>艺云展陈</h2>
<audio id="audio" autoplay="true"></audio>
<video id="video" autoplay="true" playsinline="true"></video>
</div>
<!-- 聊天消息显示区域 -->
<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;">
<div id="chatMessages" style="overflow: hidden; flex: 1; margin-bottom: 3px; display: flex; flex-direction: column; justify-content: flex-end; position: relative; cursor: pointer;">
<!-- 消息将在这里动态添加 -->
</div>
<div class="chat-header">
💬 对话
<button class="clear-chat" onclick="clearChatHistory()" title="清空对话记录"></button>
</div>
</div>
</div>
<script src="client.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.1/dist/sockjs.min.js"></script>
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<script type="text/javascript" charset="utf-8">
$(document).ready(function() {
// Function to update toggle button icon based on sidebar state
function updateToggleButtonState() {
var sidebarIsCollapsed = $('#sidebar').hasClass('collapsed');
var toggleButton = $('#sidebar-toggle');
if (sidebarIsCollapsed) {
toggleButton.html('≫');
} else {
toggleButton.html('≪');
}
}
// Initial state setup for the toggle button
updateToggleButtonState();
// Load saved WebSocket configuration or set defaults
function loadWsConfig() {
// 控制服务器配置
var savedControlHost = localStorage.getItem('controlWsHost');
var savedControlPort = localStorage.getItem('controlWsPort');
var enableControlWs = localStorage.getItem('enableControlWs');
if (savedControlHost) {
$('#control-ws-host').val(savedControlHost);
} else {
$('#control-ws-host').val('127.0.0.1'); // Default host
}
if (savedControlPort) {
$('#control-ws-port').val(savedControlPort);
} else {
$('#control-ws-port').val('10002'); // Default port
}
// 设置控制服务器开关状态
if (enableControlWs !== null) {
$('#enable-control-ws').prop('checked', enableControlWs === 'true');
}
// 聊天服务器配置
var savedChatHost = localStorage.getItem('chatWsHost');
var savedChatPort = localStorage.getItem('chatWsPort');
if (savedChatHost) {
$('#chat-ws-host').val(savedChatHost);
} else {
$('#chat-ws-host').val('localhost'); // Default host
}
if (savedChatPort) {
$('#chat-ws-port').val(savedChatPort);
} else {
$('#chat-ws-port').val('8010'); // Default port
}
// 初始化控制服务器连接按钮状态
$('#connect-control-ws').prop('disabled', false);
$('#disconnect-control-ws').prop('disabled', true);
}
loadWsConfig(); // Load config on document ready
// 初始化聊天滚轮支持
initChatWheelSupport();
// 侧边栏切换功能
$('#sidebar-toggle').click(function() {
$('#sidebar').toggleClass('collapsed');
updateToggleButtonState();
});
// Note: The original '#expand-sidebar'.click handler was part of the replaced block.
// If an element with id #expand-sidebar exists and needs to control the sidebar,
// its click handler should be re-implemented similar to this:
// $('#expand-sidebar').click(function() {
// if ($('#sidebar').hasClass('collapsed')) {
// $('#sidebar').removeClass('collapsed');
// updateToggleButtonState();
// }
// });
// 控制服务器配置保存和重置
$('#save-control-ws-config').click(function() {
var controlHost = $('#control-ws-host').val().trim();
var controlPort = $('#control-ws-port').val().trim();
var enableControl = $('#enable-control-ws').prop('checked');
if (!controlHost) {
alert('控制服务器主机不能为空。');
$('#control-ws-host').focus();
return;
}
if (!controlPort) {
alert('控制服务器端口不能为空。');
$('#control-ws-port').focus();
return;
}
localStorage.setItem('controlWsHost', controlHost);
localStorage.setItem('controlWsPort', controlPort);
localStorage.setItem('enableControlWs', enableControl.toString());
var originalText = $(this).text();
$(this).text('已保存!').prop('disabled', true);
setTimeout(function() {
$('#save-control-ws-config').text(originalText).prop('disabled', false);
}, 1500);
console.log('控制服务器配置已保存: Host - ' + controlHost + ', Port - ' + controlPort + ', Enabled - ' + enableControl);
});
// 聊天服务器配置保存
$('#save-chat-ws-config').click(function() {
var chatHost = $('#chat-ws-host').val().trim();
var chatPort = $('#chat-ws-port').val().trim();
if (!chatHost) {
alert('聊天服务器主机不能为空。');
$('#chat-ws-host').focus();
return;
}
if (!chatPort) {
alert('聊天服务器端口不能为空。');
$('#chat-ws-port').focus();
return;
}
localStorage.setItem('chatWsHost', chatHost);
localStorage.setItem('chatWsPort', chatPort);
var originalText = $(this).text();
$(this).text('已保存!').prop('disabled', true);
setTimeout(function() {
$('#save-chat-ws-config').text(originalText).prop('disabled', false);
}, 1500);
console.log('聊天服务器配置已保存: Host - ' + chatHost + ', Port - ' + chatPort);
});
// 控制服务器配置重置
$('#reset-control-ws-config').click(function() {
$('#control-ws-host').val('127.0.0.1');
$('#control-ws-port').val('10002');
$('#enable-control-ws').prop('checked', true);
var originalText = $(this).text();
$(this).text('已重置!').prop('disabled', true);
setTimeout(function() {
$('#reset-control-ws-config').text(originalText).prop('disabled', false);
}, 1500);
console.log('控制服务器配置已重置为默认值');
});
// 聊天服务器配置重置
$('#reset-chat-ws-config').click(function() {
$('#chat-ws-host').val('localhost');
$('#chat-ws-port').val('8010');
var originalText = $(this).text();
$(this).text('已重置!').prop('disabled', true);
setTimeout(function() {
$('#reset-chat-ws-config').text(originalText).prop('disabled', false);
}, 1500);
console.log('聊天服务器配置已重置为默认值');
});
// 控制服务器连接管理
var controlWs = null;
$('#connect-control-ws').click(function() {
if (!$('#enable-control-ws').prop('checked')) {
alert('请先启用控制服务器连接');
return;
}
var controlHost = $('#control-ws-host').val().trim() || '127.0.0.1';
var controlPort = $('#control-ws-port').val().trim() || '10002';
var controlWsUrl = 'ws://' + controlHost + ':' + controlPort + '/ws';
console.log('连接控制服务器:', controlWsUrl);
controlWs = new WebSocket(controlWsUrl);
controlWs.onopen = function() {
console.log('控制服务器连接成功');
$('#connect-control-ws').prop('disabled', true);
$('#disconnect-control-ws').prop('disabled', false);
};
controlWs.onclose = function() {
console.log('控制服务器连接关闭');
$('#connect-control-ws').prop('disabled', false);
$('#disconnect-control-ws').prop('disabled', true);
};
controlWs.onerror = function(error) {
console.error('控制服务器连接错误:', error);
alert('控制服务器连接失败');
};
});
$('#disconnect-control-ws').click(function() {
if (controlWs) {
controlWs.close();
controlWs = null;
}
});
// 控制服务器开关状态变化处理
$('#enable-control-ws').change(function() {
var enabled = $(this).prop('checked');
localStorage.setItem('enableControlWs', enabled.toString());
if (!enabled && controlWs) {
controlWs.close();
controlWs = null;
}
});
// Old WebSocket code commented out
// var host = window.location.hostname
// var ws = new WebSocket("ws://"+host+":8000/humanecho");
// //document.getElementsByTagName("video")[0].setAttribute("src", aa["video"]);
// ws.onopen = function() {
// console.log('Connected');
// };
// ws.onmessage = function(e) {
// console.log('Received: ' + e.data);
// data = e
// var vid = JSON.parse(data.data);
// console.log(typeof(vid),vid)
// //document.getElementsByTagName("video")[0].setAttribute("src", vid["video"]);
// };
// ws.onclose = function(e) {
// console.log('Closed');
// };
$('#echo-form').on('submit', function(e) {
e.preventDefault();
var message = $('#message').val().trim();
if (!message) return;
console.log('Sending: ' + message);
console.log('sessionid: ', document.getElementById('sessionid').value);
// 保存最后一条用户消息用于模式判断
localStorage.setItem('lastUserMessage', message);
// 获取选择的消息类型,默认为chat
var messageType = document.getElementById('message-type') ? document.getElementById('message-type').value : 'chat';
// 发送消息到服务器,不再直接添加到界面,等待WebSocket推送
var requestData = {
text: message,
type: messageType,
interrupt: true,
sessionid: parseInt(document.getElementById('sessionid').value),
};
console.log('准备发送HTTP请求到/human:', requestData);
console.log('当前WebSocket连接状态:', ws ? ws.readyState : 'WebSocket未初始化');
fetch('/human', {
body: JSON.stringify(requestData),
headers: {
'Content-Type': 'application/json',
'X-Request-Source': 'web'
},
method: 'POST'
}).then(response => {
console.log('HTTP响应状态:', response.status);
return response.json();
}).then(data => {
console.log('/human接口响应:', data);
if (data.code !== 0) {
console.error('服务器处理失败:', data.message || data.msg);
}
// 所有消息显示都通过WebSocket推送,不再从HTTP响应获取数据
}).catch(error => {
console.error('发送消息错误:', error);
// 网络错误时添加错误消息到界面
addMessage(`网络错误: ${error.message}`, 'left', '系统错误', 'error');
});
$('#message').val('');
});
// 本地存储设置事件处理
$('#enable-storage').change(function() {
const enabled = $(this).is(':checked');
ChatStorage.setStorageEnabled(enabled);
console.log('本地存储已', enabled ? '启用' : '禁用');
});
$('#load-history').click(function() {
loadChatHistory();
alert('历史记录已加载!');
});
$('#clear-storage').click(function() {
if (confirm('确定要清理所有本地聊天记录吗?此操作不可恢复!')) {
ChatStorage.clearStorage();
const chatMessages = document.getElementById("chatMessages");
if (chatMessages) {
chatMessages.innerHTML = '';
}
alert('本地记录已清理!');
}
});
$('#view-history').click(function() {
const dates = ChatStorage.getAllDates();
if (dates.length === 0) {
alert('暂无历史记录');
return;
}
let historyHtml = '<div style="max-height: 400px; overflow-y: auto; padding: 10px;">';
historyHtml += '<h3>聊天记录</h3>';
dates.forEach(date => {
const dateHistory = ChatStorage.getDateHistory(date);
historyHtml += `<h4>${date} (${dateHistory.length}条消息)</h4>`;
dateHistory.forEach(msg => {
const time = new Date(msg.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
const sourceIcon = msg.type === 'right' ? '👤' : '🤖';
historyHtml += `<div style="margin: 5px 0; padding: 5px; border-left: 3px solid ${msg.type === 'right' ? '#4285f4' : '#4CAF50'}; background: #f9f9f9;">`;
historyHtml += `<small>${sourceIcon} ${time} - 会话${msg.sessionId}</small><br>`;
historyHtml += `<span>${msg.text}</span>`;
historyHtml += '</div>';
});
});
historyHtml += '</div>';
// 创建模态框显示历史记录
const modal = document.createElement('div');
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;';
const content = document.createElement('div');
content.style.cssText = 'background: white; border-radius: 8px; max-width: 80%; max-height: 80%; overflow: hidden;';
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>';
modal.appendChild(content);
document.body.appendChild(modal);
// 点击背景关闭
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.remove();
}
});
});
// 页面加载时初始化存储设置
$(document).ready(function() {
const storageEnabled = ChatStorage.isStorageEnabled();
$('#enable-storage').prop('checked', storageEnabled);
// 自动加载历史记录
if (storageEnabled) {
setTimeout(loadChatHistory, 1000); // 延迟1秒加载,确保页面完全加载
}
});
$('#btn_start_record').click(function() {
console.log('Starting recording...');
fetch('/record', {
body: JSON.stringify({
type: 'start_record',
sessionid: parseInt(document.getElementById('sessionid').value),
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(function(response) {
if (response.ok) {
console.log('Recording started.');
$('#btn_start_record').prop('disabled', true);
$('#btn_stop_record').prop('disabled', false);
} else {
console.error('Failed to start recording.');
}
}).catch(function(error) {
console.error('Error:', error);
});
});
$('#btn_stop_record').click(function() {
console.log('Stopping recording...');
fetch('/record', {
body: JSON.stringify({
type: 'end_record',
sessionid: parseInt(document.getElementById('sessionid').value),
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(function(response) {
if (response.ok) {
console.log('Recording stopped.');
$('#btn_start_record').prop('disabled', false);
$('#btn_stop_record').prop('disabled', true);
} else {
console.error('Failed to stop recording.');
}
}).catch(function(error) {
console.error('Error:', error);
});
});
// WebSocket connection to Fay digital avatar (port 10002)
var ws;
var reconnectInterval = 5000; // 初始重连间隔为5秒
var reconnectAttempts = 0;
var maxReconnectInterval = 60000; // 最大重连间隔为60秒
var isReconnecting = false; // 标记是否正在重连中
function generateUsername() {
var username = 'User';
// + Math.floor(Math.random() * 10000)
return username;
}
function setUsername() {
var storedUsername = localStorage.getItem('username');
// console.log("当前存有的username:"+storedUsername);
if (!storedUsername) {
storedUsername = generateUsername();
localStorage.setItem('username', storedUsername);
}
$('#username').val(storedUsername); // Use the username as the session ID
}
setUsername();
// 页面加载时恢复聊天记录
function loadChatHistory() {
const recentMessages = ChatStorage.loadRecentMessages();
const chatMessages = document.getElementById("chatMessages");
if (chatMessages && recentMessages.length > 0) {
// 清空现有消息
chatMessages.innerHTML = '';
// 添加历史消息
recentMessages.forEach(msg => {
addMessage(msg.text, msg.type, msg.source, msg.mode, msg.modelInfo || '', msg.requestSource || '');
});
console.log(`已加载 ${recentMessages.length} 条历史消息`);
}
}
// 本地存储管理
const ChatStorage = {
// 获取存储设置
isStorageEnabled: function() {
return localStorage.getItem('chatStorageEnabled') !== 'false';
},
// 设置存储开关
setStorageEnabled: function(enabled) {
localStorage.setItem('chatStorageEnabled', enabled.toString());
},
// 保存消息到本地存储
saveMessage: function(messageData) {
if (!this.isStorageEnabled()) return;
const sessionId = document.getElementById('sessionid').value;
const storageKey = `chat_history_${sessionId}`;
let history = JSON.parse(localStorage.getItem(storageKey) || '[]');
// 添加新消息
history.push({
...messageData,
timestamp: new Date().toISOString(),
date: new Date().toDateString()
});
// 保存到localStorage
localStorage.setItem(storageKey, JSON.stringify(history));
// 同时保存到按日期分组的存储中
this.saveToDateStorage(messageData);
},
// 按日期保存消息
saveToDateStorage: function(messageData) {
const today = new Date().toDateString();
const dateKey = `chat_date_${today.replace(/\s+/g, '_')}`;
let dateHistory = JSON.parse(localStorage.getItem(dateKey) || '[]');
dateHistory.push({
...messageData,
timestamp: new Date().toISOString(),
sessionId: document.getElementById('sessionid').value
});
localStorage.setItem(dateKey, JSON.stringify(dateHistory));
},
// 加载最近12条消息
loadRecentMessages: function() {
if (!this.isStorageEnabled()) return [];
const sessionId = document.getElementById('sessionid').value;
const storageKey = `chat_history_${sessionId}`;
const history = JSON.parse(localStorage.getItem(storageKey) || '[]');
// 返回最后12条消息
return history.slice(-12);
},
// 获取所有日期的聊天记录
getAllDates: function() {
const dates = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('chat_date_')) {
const date = key.replace('chat_date_', '').replace(/_/g, ' ');
dates.push(date);
}
}
return dates.sort((a, b) => new Date(b) - new Date(a));
},
// 获取指定日期的聊天记录
getDateHistory: function(date) {
const dateKey = `chat_date_${date.replace(/\s+/g, '_')}`;
return JSON.parse(localStorage.getItem(dateKey) || '[]');
},
// 清理本地记录
clearStorage: function() {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('chat_history_') || key.startsWith('chat_date_'))) {
keys.push(key);
}
}
keys.forEach(key => localStorage.removeItem(key));
}
};
// 全局 addMessage 函数定义
function addMessage(text, type = "right", source = "", mode = "", modelInfo = "", requestSource = "") {
const chatMessages = document.getElementById("chatMessages");
if (!chatMessages) {
console.error('聊天消息容器不存在');
return;
}
// 创建消息数据对象
const messageData = {
text: text,
type: type,
source: source,
mode: mode,
modelInfo: modelInfo,
requestSource: requestSource
};
// 保存到本地存储
ChatStorage.saveMessage(messageData);
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", type);
// 为左侧消息添加模式类
if (type === "left" && mode) {
messageDiv.classList.add("mode-" + mode);
}
const avatar = document.createElement("div");
avatar.classList.add("avatar");
avatar.style.width = "28px";
avatar.style.height = "28px";
avatar.style.borderRadius = "50%";
avatar.style.display = "flex";
avatar.style.alignItems = "center";
avatar.style.justifyContent = "center";
avatar.style.fontSize = "12px";
avatar.style.fontWeight = "bold";
avatar.style.color = "white";
avatar.style.border = "1px solid rgba(255,255,255,0.2)";
if (type === "right") {
avatar.style.backgroundColor = "#4285f4";
avatar.textContent = "👤";
} else {
// 根据模式设置不同的头像和颜色
switch(mode) {
case "echo":
avatar.style.backgroundColor = "#FFC107";
avatar.textContent = "🔄";
break;
case "chat":
avatar.style.backgroundColor = "#4CAF50";
avatar.textContent = "🤖";
break;
case "audio":
avatar.style.backgroundColor = "#9C27B0";
avatar.textContent = "🎤";
break;
case "plaintext":
avatar.style.backgroundColor = "#607D8B";
avatar.textContent = "📝";
break;
default:
avatar.style.backgroundColor = "#34a853";
avatar.textContent = "🤖";
}
}
const textContainer = document.createElement("div");
textContainer.classList.add("text-container");
// 添加来源标签
if (source) {
const sourceTag = document.createElement("div");
sourceTag.classList.add("source-tag");
// 根据模式显示不同的标签文本
let tagText = source;
if (type === "right") {
// 用户消息,显示请求来源
if (requestSource) {
tagText = requestSource === 'web' ? "🌐 网页" : requestSource === 'api' ? "🔗 API" : "👤 用户";
} else {
tagText = "👤 用户";
}
} else {
// 数字人回复,显示模式和模型信息
switch(mode) {
case "echo":
tagText = "🔄 回音";
break;
case "chat":
tagText = modelInfo ? `🤖 ${modelInfo}` : "🤖 智能";
break;
case "audio":
tagText = "🎤 语音";
break;
case "plaintext":
tagText = "📝 文本";
break;
default:
tagText = "🤖 数字人";
}
}
sourceTag.textContent = tagText;
textContainer.appendChild(sourceTag);
}
const textDiv = document.createElement("div");
textDiv.classList.add("text");
textDiv.textContent = text;
textContainer.appendChild(textDiv);
const timeDiv = document.createElement("div");
timeDiv.classList.add("time");
const now = new Date();
timeDiv.textContent = now.toLocaleTimeString('zh-CN', { hour12: false });
textContainer.appendChild(timeDiv);
if (type === "right") {
messageDiv.appendChild(textContainer);
messageDiv.appendChild(avatar);
} else {
messageDiv.appendChild(avatar);
messageDiv.appendChild(textContainer);
}
chatMessages.appendChild(messageDiv);
// 限制消息数量,只保留最新的12条消息
const messages = chatMessages.children;
const maxMessages = 12;
while (messages.length > maxMessages) {
chatMessages.removeChild(messages[0]);
}
// 显示聊天区域(如果之前隐藏)
const chatOverlay = document.getElementById("chatOverlay");
if (chatOverlay) {
chatOverlay.style.display = "flex";
}
// 保存聊天记录
saveChatHistory();
}
// 清空聊天记录函数
function clearChatHistory() {
const chatMessages = document.getElementById("chatMessages");
if (chatMessages) {
chatMessages.innerHTML = "";
}
localStorage.removeItem('chatHistory');
}
// 初始化聊天滚轮支持
function initChatWheelSupport() {
const chatMessages = document.getElementById("chatMessages");
const chatOverlay = document.getElementById("chatOverlay");
if (!chatMessages || !chatOverlay) return;
let scrollPosition = 0; // 当前滚动位置
const scrollStep = 25; // 每次滚动的像素数
let isScrolling = false; // 防止滚动冲突
let lastWheelTime = 0; // 上次滚轮事件时间
const throttleDelay = 16; // 约60fps的节流延迟
// 节流函数
function throttle(func, delay) {
return function(...args) {
const now = Date.now();
if (now - lastWheelTime >= delay) {
lastWheelTime = now;
func.apply(this, args);
}
};
}
// 滚轮处理函数
function handleWheel(e) {
e.preventDefault();
e.stopPropagation();
if (isScrolling) return;
const messages = chatMessages.children;
if (messages.length === 0) return;
// 计算总内容高度
let totalHeight = 0;
for (let i = 0; i < messages.length; i++) {
totalHeight += messages[i].offsetHeight + 12; // 12px是margin-bottom
}
const containerHeight = chatMessages.offsetHeight;
const maxScroll = Math.max(0, totalHeight - containerHeight);
// 如果内容不超出容器,不需要滚动
if (maxScroll <= 0) return;
isScrolling = true;
// 根据滚轮方向和强度调整滚动位置
const delta = Math.sign(e.deltaY) * scrollStep;
const newPosition = Math.max(0, Math.min(scrollPosition + delta, maxScroll));
if (newPosition !== scrollPosition) {
scrollPosition = newPosition;
// 应用滚动效果
chatMessages.style.transform = `translateY(-${scrollPosition}px)`;
chatMessages.style.transition = 'transform 0.08s ease-out';
// 清除过渡效果和滚动锁定
setTimeout(() => {
chatMessages.style.transition = '';
isScrolling = false;
}, 80);
} else {
isScrolling = false;
}
}
// 使用节流的滚轮处理函数
const throttledWheelHandler = throttle(handleWheel, throttleDelay);
// 同时在chatMessages和chatOverlay上绑定事件,提高响应性
chatMessages.addEventListener('wheel', throttledWheelHandler, { passive: false });
chatOverlay.addEventListener('wheel', throttledWheelHandler, { passive: false });
// 当新消息添加时,自动滚动到底部
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// 重置滚动位置到底部
scrollPosition = 0;
chatMessages.style.transform = 'translateY(0)';
chatMessages.style.transition = '';
}
});
});
observer.observe(chatMessages, { childList: true });
// 返回清理函数
return function cleanup() {
chatMessages.removeEventListener('wheel', throttledWheelHandler);
chatOverlay.removeEventListener('wheel', throttledWheelHandler);
observer.disconnect();
};
}
// 保存聊天记录到本地存储
function saveChatHistory() {
const chatMessages = document.getElementById("chatMessages");
if (chatMessages) {
localStorage.setItem('chatHistory', chatMessages.innerHTML);
}
}
// 从本地存储加载聊天记录
function loadChatHistory() {
const savedHistory = localStorage.getItem('chatHistory');
const chatMessages = document.getElementById("chatMessages");
if (savedHistory && chatMessages) {
chatMessages.innerHTML = savedHistory;
// 自动滚动到底部
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
function connectWebSocket() {
var host = window.location.hostname;
// 获取聊天服务器WebSocket地址,优先使用配置值,否则使用当前主机名
var chatWsHost = localStorage.getItem('chatWsHost') || host;
var chatWsPort = localStorage.getItem('chatWsPort') || '8010'; // 聊天服务器端口
var wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
var wsUrl = wsProtocol + chatWsHost + ':' + chatWsPort + '/ws'; // 聊天服务器WebSocket路径
console.log('Connecting to WebSocket server:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('Connected to WebSocket server');
console.log('WebSocket URL:', ws.url);
console.log('WebSocket readyState:', ws.readyState);
reconnectAttempts = 0; // 重置重连次数
reconnectInterval = 5000; // 重置重连间隔
// 等待sessionid设置完成后再发送登录消息
function attemptLogin(retryCount = 0) {
var sessionid = parseInt(document.getElementById('sessionid').value) || 0;
if (sessionid === 0 && retryCount < 20) {
console.log(`等待sessionid设置,重试次数: ${retryCount + 1}/20`);
setTimeout(() => attemptLogin(retryCount + 1), 200);
return;
}
if (sessionid === 0) {
console.error('sessionid仍为0,WebRTC连接可能失败,使用默认值继续');
// 即使sessionid为0也尝试连接,但会在日志中标记
}
var loginMessage = {
type: 'login',
sessionid: sessionid,
username: $('#username').val() || 'User'
};
console.log('发送登录消息:', loginMessage);
console.log('当前sessionid值:', sessionid);
console.log('sessionid元素值:', document.getElementById('sessionid').value);
ws.send(JSON.stringify(loginMessage));
console.log('登录消息已发送:', JSON.stringify(loginMessage));
// 更新显示的sessionId
if (sessionid !== 0) {
document.getElementById('current-sessionid').value = sessionid;
document.getElementById('current-sessionid').placeholder = '已连接';
}
}
// 开始尝试登录
attemptLogin();
// 发送心跳检测
setInterval(function() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000); // 每30秒发送一次心跳
};
ws.onmessage = function(e) {
console.log('WebSocket原始消息:', e.data);
console.log('WebSocket连接状态:', ws.readyState);
var messageData = JSON.parse(e.data);
console.log('解析后的消息数据:', messageData);
// 处理聊天消息推送
if (messageData.type === 'chat_message') {
var data = messageData.data;
console.log('收到聊天消息推送:', data);
console.log('消息sessionid:', data.sessionid);
console.log('当前页面sessionid:', document.getElementById('sessionid').value);
// 根据消息来源确定显示位置
var position = data.source === '用户' ? 'right' : 'left';
var messageType = data.message_type || 'chat';
var modelInfo = data.model_info || '';
var requestSource = data.request_source || '';
console.log('准备添加消息到界面:', {
content: data.content,
position: position,
source: data.source,
messageType: messageType,
modelInfo: modelInfo,
requestSource: requestSource
});
// 添加消息到聊天界面
addMessage(data.content, position, data.source, messageType, modelInfo, requestSource);
return;
}
// 处理登录成功消息
if (messageData.type === 'login_success') {
console.log('WebSocket登录成功:', messageData.message);
console.log('登录成功的sessionid:', messageData.sessionid);
return;
}
// 处理心跳响应
if (messageData.type === 'pong') {
console.log('收到心跳响应');
return;
}
// 处理服务器推送的聊天消息
if (messageData.type === 'chat_message') {
console.log('收到聊天消息:', messageData);
var messageContent = messageData.content || messageData.message || '';
var messageType = messageData.message_type || 'text';
var sender = messageData.sender || 'unknown';
var sessionId = messageData.session_id;
var modelInfo = messageData.model_info || '';
var requestSource = messageData.request_source || '';
var timestamp = messageData.timestamp || new Date().toISOString();
// 判断消息方向和样式
var alignment = 'left';
var senderLabel = '数字人回复';
var messageMode = messageType;
if (sender === '用户' || sender === 'user' || sender === 'human') {
alignment = 'right';
senderLabel = '用户';
if (messageType === 'audio') {
senderLabel = '用户语音';
messageContent = '[语音输入]';
}
} else if (sender === 'AI助手' || sender === 'ai' || sender === 'assistant') {
alignment = 'left';
senderLabel = modelInfo ? `AI回复(${modelInfo})` : 'AI回复';
} else if (sender === '回音') {
alignment = 'left';
senderLabel = modelInfo ? `回音模式(${modelInfo})` : '回音模式';
} else if (sender === '系统错误') {
alignment = 'left';
senderLabel = '系统错误';
messageMode = 'error';
}
// 添加消息到界面
addMessage(messageContent, alignment, senderLabel, messageMode, modelInfo, requestSource);
// 保存到本地存储
if (document.getElementById('enableStorage').checked) {
saveChatHistory({
content: messageContent,
alignment: alignment,
sender: senderLabel,
mode: messageMode,
modelInfo: modelInfo,
requestSource: requestSource,
timestamp: timestamp,
sessionId: sessionId
});
}
return;
}
if (messageData.Data && messageData.Data.Key) {
if(messageData.Data.Key == "audio"){
var value = messageData.Data.HttpValue;
console.log('Value:', value);
// 发送语音文件到服务器处理,不再直接添加到界面,等待WebSocket推送
fetch('/humanaudio', {
body: JSON.stringify({
file_url: value,
sessionid:parseInt(document.getElementById('sessionid').value),
}),
headers: {
'Content-Type': 'application/json',
'X-Request-Source': 'web'
},
method: 'POST'
});
}else if (messageData.Data.Key == "text") {
var reply = messageData.Data.Value;
console.log('收到text消息,内容:', reply);
// 将text类型消息推送到服务器,由数字人服务通过TTS合成语音并播放
// 使用原始的消息类型,而不是固定的echo
var originalType = messageData.Data.Type || 'echo';
fetch('/human', {
body: JSON.stringify({
text: reply,
type: originalType,
interrupt: true,
sessionid: parseInt(document.getElementById('sessionid').value),
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(response => response.json()).then(data => {
console.log('/human接口响应(文本消息):', data);
if (data.code !== 0) {
console.error('服务器处理失败:', data.message || data.msg);
}
// 所有消息显示都通过WebSocket推送,不再从HTTP响应获取数据
}).catch(error => {
console.error('发送文本消息错误:', error);
addMessage(`网络错误: ${error.message}`, 'left', '系统错误', 'error');
});
}else if (messageData.Data.Key == "plaintext") {
// 处理纯文本消息类型
var textContent = messageData.Data.Value;
console.log('收到纯文本消息:', textContent);
// 使用浏览器的语音合成API进行本地语音合成
if (window.speechSynthesis) {
console.log('使用本地语音合成播放文本:', textContent);
var utterance = new SpeechSynthesisUtterance(textContent);
utterance.lang = 'zh-CN'; // 设置语言为中文
utterance.rate = 1.0; // 设置语速
utterance.pitch = 1.0; // 设置音高
utterance.volume = 1.0; // 设置音量
speechSynthesis.speak(utterance);
}
}
}
};
ws.onclose = function(e) {
console.log('WebSocket connection closed');
attemptReconnect();
};
ws.onerror = function(e) {
console.error('WebSocket error:', e);
ws.close(); // 关闭连接并尝试重连
};
}
function attemptReconnect() {
if (isReconnecting) return; // 防止多次重连
isReconnecting = true;
reconnectAttempts++;
// 使用指数退避算法计算下一次重连间隔
var currentInterval = Math.min(reconnectInterval * Math.pow(1.5, reconnectAttempts - 1), maxReconnectInterval);
console.log('Attempting to reconnect... (Attempt ' + reconnectAttempts + ', 间隔: ' + currentInterval/1000 + '秒)');
if(document.getElementById('is_open') && parseInt(document.getElementById('is_open').value) == 1){
stop()
}
setTimeout(function() {
isReconnecting = false;
connectWebSocket();
}, currentInterval);
}
// SessionId管理功能
function saveSessionId(sessionId) {
localStorage.setItem('currentSessionId', sessionId);
document.getElementById('current-sessionid').value = sessionId;
console.log('SessionId已保存到本地存储:', sessionId);
}
function restoreSessionId() {
var savedSessionId = localStorage.getItem('currentSessionId');
if (savedSessionId && savedSessionId !== '0') {
document.getElementById('sessionid').value = savedSessionId;
document.getElementById('current-sessionid').value = savedSessionId;
console.log('已恢复SessionId:', savedSessionId);
return savedSessionId;
}
return null;
}
function clearSessionId() {
localStorage.removeItem('currentSessionId');
document.getElementById('sessionid').value = '0';
document.getElementById('current-sessionid').value = '';
document.getElementById('current-sessionid').placeholder = '未连接';
console.log('SessionId已清除');
}
// 绑定重置会话按钮事件
document.getElementById('clear-session-btn').addEventListener('click', function() {
if (confirm('确定要重置会话ID吗?这将断开当前连接并清除会话记录。')) {
// 清除sessionId
clearSessionId();
// 断开WebSocket连接
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
// 停止WebRTC连接
if (typeof stop === 'function') {
stop();
}
console.log('会话已重置,可以重新连接');
alert('会话已重置,请重新点击"开始"按钮建立新连接');
}
});
// 页面初始化时尝试恢复sessionId
var restoredSessionId = restoreSessionId();
// 如果恢复了sessionId,尝试重新连接WebSocket
if (restoredSessionId && typeof connectWebSocket === 'function') {
console.log('检测到已保存的SessionId,尝试重新连接WebSocket...');
// 延迟一点时间确保页面完全加载
setTimeout(function() {
connectWebSocket();
}, 1000);
}
// 注意:WebSocket连接现在由WebRTC连接建立后触发
// connectWebSocket(); // 移除自动连接,改为在获得sessionid后连接
// 加载聊天记录
loadChatHistory();
// 添加页面可见性变化监听,当页面从隐藏变为可见时尝试重连
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
// 页面变为可见状态,检查WebSocket连接
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
console.log('页面可见,检测到WebSocket未连接,尝试重连...');
// 重置重连计数和间隔,立即尝试重连
reconnectAttempts = 0;
reconnectInterval = 5000;
connectWebSocket();
}
}
});
});
</script>
</body>
</html>
... ...
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket通信测试</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.container {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.message-form input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.message-form button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.message-form button:hover {
background-color: #0056b3;
}
.log {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
.log-entry {
margin-bottom: 5px;
padding: 2px 0;
}
.log-entry.info {
color: #0066cc;
}
.log-entry.error {
color: #cc0000;
}
.log-entry.success {
color: #009900;
}
</style>
</head>
<body>
<h1>WebSocket通信测试</h1>
<div class="container">
<h3>连接状态</h3>
<div id="status" class="status disconnected">未连接</div>
<button id="connectBtn" onclick="connect()">连接</button>
<button id="disconnectBtn" onclick="disconnect()" disabled>断开连接</button>
</div>
<div class="container">
<h3>发送消息测试</h3>
<div class="message-form">
<input type="number" id="sessionid" placeholder="会话ID" value="0">
<select id="messageType">
<option value="chat">智能对话</option>
<option value="echo">回音模式</option>
</select>
<input type="text" id="messageText" placeholder="输入消息内容">
<button onclick="sendMessage()">发送到/human接口</button>
</div>
</div>
<div class="container">
<h3>消息日志</h3>
<button onclick="clearLog()">清空日志</button>
<div id="log" class="log"></div>
</div>
<script>
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
function addLog(message, type = 'info') {
const log = document.getElementById('log');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
const timestamp = new Date().toLocaleTimeString();
entry.textContent = `[${timestamp}] ${message}`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function updateStatus(connected) {
const status = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
if (connected) {
status.textContent = '已连接';
status.className = 'status connected';
connectBtn.disabled = true;
disconnectBtn.disabled = false;
} else {
status.textContent = '未连接';
status.className = 'status disconnected';
connectBtn.disabled = false;
disconnectBtn.disabled = true;
}
}
function connect() {
const host = window.location.hostname;
const port = '8010';
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wsUrl = `${protocol}${host}:${port}/ws`;
addLog(`尝试连接到: ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
addLog('WebSocket连接成功', 'success');
updateStatus(true);
reconnectAttempts = 0;
// 发送登录消息
const sessionid = parseInt(document.getElementById('sessionid').value) || 0;
const loginMessage = {
type: 'login',
sessionid: sessionid,
username: 'TestUser'
};
ws.send(JSON.stringify(loginMessage));
addLog(`发送登录消息: ${JSON.stringify(loginMessage)}`);
};
ws.onmessage = function(e) {
addLog(`收到消息: ${e.data}`, 'success');
try {
const messageData = JSON.parse(e.data);
if (messageData.type === 'chat_message') {
const data = messageData.data;
addLog(`聊天消息 - 来源: ${data.source}, 类型: ${data.message_type}, 内容: ${data.content}`, 'success');
} else if (messageData.type === 'login_success') {
addLog(`登录成功: ${messageData.message}`, 'success');
} else if (messageData.type === 'pong') {
addLog('收到心跳响应', 'info');
}
} catch (err) {
addLog(`解析消息失败: ${err.message}`, 'error');
}
};
ws.onclose = function(e) {
addLog(`WebSocket连接关闭: ${e.code} - ${e.reason}`, 'error');
updateStatus(false);
// 自动重连
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
addLog(`尝试重连 (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connect, 3000);
}
};
ws.onerror = function(e) {
addLog('WebSocket连接错误', 'error');
};
}
function disconnect() {
if (ws) {
ws.close();
ws = null;
}
}
async function sendMessage() {
const sessionid = parseInt(document.getElementById('sessionid').value) || 0;
const messageType = document.getElementById('messageType').value;
const messageText = document.getElementById('messageText').value;
if (!messageText.trim()) {
addLog('请输入消息内容', 'error');
return;
}
const payload = {
sessionid: sessionid,
type: messageType,
text: messageText
};
addLog(`发送到/human接口: ${JSON.stringify(payload)}`);
try {
const response = await fetch('/human', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const result = await response.json();
addLog(`/human接口响应: ${JSON.stringify(result)}`, response.ok ? 'success' : 'error');
// 清空输入框
document.getElementById('messageText').value = '';
} catch (err) {
addLog(`发送失败: ${err.message}`, 'error');
}
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
// 页面加载时自动连接
window.onload = function() {
addLog('页面加载完成,准备测试WebSocket通信');
};
// 监听回车键发送消息
document.getElementById('messageText').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
\ No newline at end of file
... ...
... ... @@ -46,7 +46,15 @@ function start() {
};
if (document.getElementById('use-stun').checked) {
config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }];
// 优化STUN服务器配置:使用多个快速STUN服务器
config.iceServers = [
{ urls: ['stun:stun.l.google.com:19302'] },
{ urls: ['stun:stun1.l.google.com:19302'] },
{ urls: ['stun:stun2.l.google.com:19302'] }
];
// ICE传输策略和候选者池大小优化
config.iceTransportPolicy = 'all';
config.iceCandidatePoolSize = 10;
}
pc = new RTCPeerConnection(config);
... ...