chore(addon/aikefu): 流式对话接口实现
This commit is contained in:
@@ -22,72 +22,87 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
private function curlRequestStreaming($url, $method = 'GET', $data = [], $headers = [], $on_data = null, $on_error = null)
|
private function curlRequestStreaming($url, $method = 'GET', $data = [], $headers = [], $on_data = null, $on_error = null)
|
||||||
{
|
{
|
||||||
$ch = curl_init();
|
try {
|
||||||
|
$ch = curl_init();
|
||||||
|
|
||||||
// 设置URL
|
// 设置URL
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
|
||||||
// 设置请求方法
|
// 设置请求方法
|
||||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
|
||||||
// 设置POST数据
|
// 设置POST数据
|
||||||
if ($method === 'POST' && !empty($data)) {
|
if ($method === 'POST' && !empty($data)) {
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? json_encode($data) : $data);
|
curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? json_encode($data) : $data);
|
||||||
}
|
|
||||||
|
|
||||||
// 设置请求头
|
|
||||||
if (!empty($headers)) {
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
||||||
} else {
|
|
||||||
// 默认请求头
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
||||||
'Content-Type: application/json',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置cURL选项以支持流式输出
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); // 不返回结果,直接输出
|
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 0); // 无超时限制,适用于长时间流式响应
|
|
||||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
|
|
||||||
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use ($on_data) {
|
|
||||||
// 调用自定义数据处理回调
|
|
||||||
if (is_callable($on_data)) {
|
|
||||||
$on_data($data);
|
|
||||||
} else {
|
|
||||||
// 默认直接输出
|
|
||||||
echo $data;
|
|
||||||
ob_flush();
|
|
||||||
flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return strlen($data);
|
// 设置请求头
|
||||||
});
|
if (!empty($headers)) {
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
// 设置SSE响应头
|
|
||||||
header('Content-Type: text/event-stream');
|
|
||||||
header('Cache-Control: no-cache');
|
|
||||||
header('Connection: keep-alive');
|
|
||||||
ob_end_clean(); // 清除输出缓冲区
|
|
||||||
ob_implicit_flush(true); // 自动刷新输出
|
|
||||||
|
|
||||||
// 执行请求并流式输出响应
|
|
||||||
curl_exec($ch);
|
|
||||||
|
|
||||||
if (curl_errno($ch)) {
|
|
||||||
$error = curl_error($ch);
|
|
||||||
if (is_callable($on_error)) {
|
|
||||||
$on_error($error);
|
|
||||||
} else {
|
} else {
|
||||||
echo "data: " . json_encode(["event" => "error", "data" => $error]) . "\n\n";
|
// 默认请求头
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置cURL选项以支持流式输出
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); // 不返回结果,直接输出
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 0); // 无超时限制,适用于长时间流式响应
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
|
||||||
|
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use ($on_data) {
|
||||||
|
// 调用自定义数据处理回调
|
||||||
|
if (is_callable($on_data)) {
|
||||||
|
$on_data($data);
|
||||||
|
} else {
|
||||||
|
// 默认直接输出
|
||||||
|
echo $data;
|
||||||
|
// 只在有输出缓冲时才刷新
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return strlen($data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置SSE响应头
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('Connection: keep-alive');
|
||||||
|
header('X-Accel-Buffering: no'); // 禁用Nginx缓冲
|
||||||
|
|
||||||
|
ob_end_clean(); // 清除输出缓冲区
|
||||||
|
ob_implicit_flush(true); // 自动刷新输出
|
||||||
|
|
||||||
|
// 执行请求并流式输出响应
|
||||||
|
curl_exec($ch);
|
||||||
|
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
$error = curl_error($ch);
|
||||||
|
if (is_callable($on_error)) {
|
||||||
|
$on_error($error);
|
||||||
|
} else {
|
||||||
|
echo "data: " . json_encode(["event" => "error", "data" => $error]) . "\n\n";
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log(json_encode(["event" => "error", "data" => $e->getMessage(), "line" => $e->getLine(), "file" => $e->getFile()]), 'error');
|
||||||
|
if (is_callable($on_error)) {
|
||||||
|
$on_error($e->getMessage());
|
||||||
|
} else {
|
||||||
|
echo "data: " . json_encode(["event" => "error", "data" => $e->getMessage()]) . "\n\n";
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
curl_close($ch);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,7 +358,7 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
private function log($message, $level = 'info')
|
private function log($message, $level = 'info')
|
||||||
{
|
{
|
||||||
// Log::write($message, $level);
|
log_write($message, $level, '', 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleStreamingResponse($url, $requestData, $headers, $message, $user_id)
|
private function handleStreamingResponse($url, $requestData, $headers, $message, $user_id)
|
||||||
@@ -365,234 +380,243 @@ class Kefu extends BaseApi
|
|||||||
$user_message_saved = false;
|
$user_message_saved = false;
|
||||||
$user_message_content = $message;
|
$user_message_content = $message;
|
||||||
|
|
||||||
// 数据处理回调函数
|
|
||||||
$on_data = function ($data) use (&$real_conversation_id, &$real_assistant_message_id, &$real_user_message_id, &$assistant_content, &$user_message_saved, $user_message_content, $kefu_message_model, $kefu_conversation_model, $site_id, $current_user_id) {
|
|
||||||
// 记录接收到的数据块
|
|
||||||
$this->log('接收到AI客服数据块:' . $data, 'debug');
|
|
||||||
|
|
||||||
// 输出原始数据
|
|
||||||
echo $data;
|
|
||||||
ob_flush();
|
|
||||||
flush();
|
|
||||||
|
|
||||||
// 解析Dify的流式响应
|
|
||||||
$lines = explode("\n", $data);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
if (empty($line))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// 查找以"data: "开头的行
|
|
||||||
if (strpos($line, 'data: ') === 0) {
|
|
||||||
$json_data = substr($line, 6);
|
|
||||||
$event_data = json_decode($json_data, true);
|
|
||||||
|
|
||||||
if (json_last_error() === JSON_ERROR_NONE && isset($event_data['event'])) {
|
|
||||||
$event = $event_data['event'];
|
|
||||||
$this->log('处理AI客服事件:' . $event, 'info');
|
|
||||||
|
|
||||||
switch ($event_data['event']) {
|
|
||||||
case 'message':
|
|
||||||
// LLM返回文本块事件
|
|
||||||
if (isset($event_data['data']['conversation_id'])) {
|
|
||||||
$real_conversation_id = $event_data['data']['conversation_id'];
|
|
||||||
$this->log('获取到会话ID:' . $real_conversation_id, 'info');
|
|
||||||
}
|
|
||||||
if (isset($event_data['data']['message_id'])) {
|
|
||||||
$real_assistant_message_id = $event_data['data']['message_id'];
|
|
||||||
$this->log('获取到助手消息ID:' . $real_assistant_message_id, 'info');
|
|
||||||
}
|
|
||||||
// 积累助手回复内容
|
|
||||||
if (isset($event_data['data']['answer'])) {
|
|
||||||
$assistant_content .= $event_data['data']['answer'];
|
|
||||||
$this->log('积累助手回复内容:' . $event_data['data']['answer'], 'debug');
|
|
||||||
}
|
|
||||||
// 保存用户消息(仅在第一次获取到会话ID时保存)
|
|
||||||
if (!$user_message_saved && !empty($real_conversation_id)) {
|
|
||||||
// 使用Dify返回的真实会话ID保存用户消息
|
|
||||||
$this->saveUserMessage($kefu_message_model, $site_id, $current_user_id, $real_conversation_id, '', $user_message_content);
|
|
||||||
$this->updateOrCreateConversation($kefu_conversation_model, $site_id, $current_user_id, $real_conversation_id);
|
|
||||||
$user_message_saved = true;
|
|
||||||
$this->log('用户消息已保存,会话ID:' . $real_conversation_id, 'info');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'agent_message':
|
|
||||||
// Agent模式下返回文本块事件
|
|
||||||
if (isset($event_data['data']['conversation_id'])) {
|
|
||||||
$real_conversation_id = $event_data['data']['conversation_id'];
|
|
||||||
$this->log('获取到Agent模式会话ID:' . $real_conversation_id, 'info');
|
|
||||||
}
|
|
||||||
if (isset($event_data['data']['message_id'])) {
|
|
||||||
$real_assistant_message_id = $event_data['data']['message_id'];
|
|
||||||
$this->log('获取到Agent模式助手消息ID:' . $real_assistant_message_id, 'info');
|
|
||||||
}
|
|
||||||
// 积累助手回复内容
|
|
||||||
if (isset($event_data['data']['answer'])) {
|
|
||||||
$assistant_content .= $event_data['data']['answer'];
|
|
||||||
$this->log('积累Agent回复内容:' . $event_data['data']['answer'], 'debug');
|
|
||||||
}
|
|
||||||
// 保存用户消息(仅在第一次获取到会话ID时保存)
|
|
||||||
if (!$user_message_saved && !empty($real_conversation_id)) {
|
|
||||||
$this->saveUserMessage($kefu_message_model, $site_id, $current_user_id, $real_conversation_id, '', $user_message_content);
|
|
||||||
$this->updateOrCreateConversation($kefu_conversation_model, $site_id, $current_user_id, $real_conversation_id);
|
|
||||||
$user_message_saved = true;
|
|
||||||
$this->log('Agent模式下用户消息已保存,会话ID:' . $real_conversation_id, 'info');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'agent_thought':
|
|
||||||
if (isset($event_data['data']['thought'])) {
|
|
||||||
// 格式化思考过程
|
|
||||||
$thought_content = "\n[思考过程]: " . $event_data['data']['thought'];
|
|
||||||
if (isset($event_data['data']['tool'])) {
|
|
||||||
$thought_content .= "\n[使用工具]: " . $event_data['data']['tool'];
|
|
||||||
}
|
|
||||||
if (isset($event_data['data']['observation'])) {
|
|
||||||
$thought_content .= "\n[观察结果]: " . $event_data['data']['observation'];
|
|
||||||
}
|
|
||||||
$assistant_content .= $thought_content;
|
|
||||||
$this->log('Agent思考过程:' . $thought_content, 'debug');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'file':
|
|
||||||
if (isset($event_data['data']['id']) && isset($event_data['data']['type']) && isset($event_data['data']['url'])) {
|
|
||||||
$file_id = $event_data['data']['id'];
|
|
||||||
$file_type = $event_data['data']['type'];
|
|
||||||
$file_url = $event_data['data']['url'];
|
|
||||||
// 可以将文件信息作为特殊内容添加到回复中
|
|
||||||
$file_content = "\n[文件]: " . $file_type . " - " . $file_url;
|
|
||||||
$assistant_content .= $file_content;
|
|
||||||
$this->log('收到文件事件:' . $file_type . ' - ' . $file_url, 'info');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'message_start':
|
|
||||||
if (isset($event_data['data']['conversation_id'])) {
|
|
||||||
$real_conversation_id = $event_data['data']['conversation_id'];
|
|
||||||
$this->log('消息开始事件,会话ID:' . $real_conversation_id, 'info');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'message_delta':
|
|
||||||
if (isset($event_data['data']['delta']['content'])) {
|
|
||||||
$assistant_content .= $event_data['data']['delta']['content'];
|
|
||||||
$this->log('积累增量内容:' . $event_data['data']['delta']['content'], 'debug');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'message_end':
|
|
||||||
// 最终内容已通过message或message_delta事件积累
|
|
||||||
$this->log('消息结束事件,会话ID:' . $real_conversation_id . ',消息ID:' . $real_assistant_message_id, 'info');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tts_message':
|
|
||||||
// TTS音频流事件
|
|
||||||
// 这里可以记录音频流信息,但由于是文本消息处理,暂时不做特殊处理
|
|
||||||
$this->log('收到TTS消息事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tts_message_end':
|
|
||||||
// TTS音频流结束事件
|
|
||||||
// 同样不做特殊处理
|
|
||||||
$this->log('收到TTS消息结束事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'message_replace':
|
|
||||||
if (isset($event_data['data']['answer'])) {
|
|
||||||
// 直接替换所有回复文本
|
|
||||||
$assistant_content = $event_data['data']['answer'];
|
|
||||||
$this->log('消息内容替换为:' . $event_data['data']['answer'], 'debug');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
$error_message = isset($event_data['data']['message']) ? $event_data['data']['message'] : '流式输出异常';
|
|
||||||
$assistant_content .= "\n[错误]: " . $error_message;
|
|
||||||
$this->log('AI客服错误事件:' . $error_message, 'error');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ping':
|
|
||||||
// 保持连接存活的ping事件
|
|
||||||
// 无需特殊处理,继续保持连接
|
|
||||||
$this->log('收到ping事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_call_start':
|
|
||||||
// 工具调用开始事件
|
|
||||||
$this->log('工具调用开始事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_call_delta':
|
|
||||||
// 工具调用增量事件
|
|
||||||
$this->log('工具调用增量事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'tool_call_end':
|
|
||||||
// 工具调用结束事件
|
|
||||||
$this->log('工具调用结束事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'done':
|
|
||||||
// 完成事件
|
|
||||||
$this->log('完成事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_start':
|
|
||||||
// 工作流开始事件
|
|
||||||
$this->log('工作流开始事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_node_start':
|
|
||||||
if (isset($event_data['data']['node_id']) && isset($event_data['data']['node_name'])) {
|
|
||||||
$this->log('工作流节点开始:' . $event_data['data']['node_name'], 'debug');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_node_end':
|
|
||||||
if (isset($event_data['data']['node_id']) && isset($event_data['data']['outputs'])) {
|
|
||||||
$this->log('工作流节点结束:' . $event_data['data']['node_id'], 'debug');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_end':
|
|
||||||
// 工作流结束事件
|
|
||||||
$this->log('工作流结束事件', 'debug');
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// 处理未知事件类型
|
|
||||||
$this->log('未知事件类型:' . $event_data['event'], 'warning');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->log('AI客服事件解析失败:' . $json_data, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 错误处理回调函数
|
|
||||||
$on_error = function ($error) use ($user_id) {
|
|
||||||
$this->log('AI客服请求错误,用户ID:' . $user_id . ',错误信息:' . json_encode($error), 'error');
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 数据处理回调函数
|
||||||
|
$on_data = function ($data) use (&$real_conversation_id, &$real_assistant_message_id, &$real_user_message_id, &$assistant_content, &$user_message_saved, $user_message_content, $kefu_message_model, $kefu_conversation_model, $site_id, $current_user_id) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录接收到的数据块
|
||||||
|
$this->log('接收到AI客服数据块:' . $data, 'debug');
|
||||||
|
|
||||||
|
// 输出原始数据(为了兼容旧版本或调试)
|
||||||
|
echo $data;
|
||||||
|
// 只在有输出缓冲时才刷新
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
// 解析Dify的流式响应
|
||||||
|
$lines = explode("\n", $data);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// 查找以"data: "开头的行
|
||||||
|
if (strpos($line, 'data: ') === 0) {
|
||||||
|
$json_data = substr($line, 6);
|
||||||
|
$event_data = json_decode($json_data, true);
|
||||||
|
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE && isset($event_data['event'])) {
|
||||||
|
$event = $event_data['event'];
|
||||||
|
$this->log('处理AI客服事件:' . $event, 'info');
|
||||||
|
|
||||||
|
switch ($event_data['event']) {
|
||||||
|
case 'message':
|
||||||
|
// LLM返回文本块事件
|
||||||
|
if (isset($event_data['data']['conversation_id'])) {
|
||||||
|
$real_conversation_id = $event_data['data']['conversation_id'];
|
||||||
|
$this->log('获取到会话ID:' . $real_conversation_id, 'info');
|
||||||
|
}
|
||||||
|
if (isset($event_data['data']['message_id'])) {
|
||||||
|
$real_assistant_message_id = $event_data['data']['message_id'];
|
||||||
|
$this->log('获取到助手消息ID:' . $real_assistant_message_id, 'info');
|
||||||
|
}
|
||||||
|
// 积累助手回复内容
|
||||||
|
if (isset($event_data['data']['answer'])) {
|
||||||
|
$assistant_content .= $event_data['data']['answer'];
|
||||||
|
$this->log('积累助手回复内容:' . $event_data['data']['answer'], 'debug');
|
||||||
|
}
|
||||||
|
// 保存用户消息(仅在第一次获取到会话ID时保存)
|
||||||
|
if (!$user_message_saved && !empty($real_conversation_id)) {
|
||||||
|
// 使用Dify返回的真实会话ID保存用户消息
|
||||||
|
$this->saveUserMessage($kefu_message_model, $site_id, $current_user_id, $real_conversation_id, '', $user_message_content);
|
||||||
|
$this->updateOrCreateConversation($kefu_conversation_model, $site_id, $current_user_id, $real_conversation_id);
|
||||||
|
$user_message_saved = true;
|
||||||
|
$this->log('用户消息已保存,会话ID:' . $real_conversation_id, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'agent_message':
|
||||||
|
// Agent模式下返回文本块事件
|
||||||
|
if (isset($event_data['data']['conversation_id'])) {
|
||||||
|
$real_conversation_id = $event_data['data']['conversation_id'];
|
||||||
|
$this->log('获取到Agent模式会话ID:' . $real_conversation_id, 'info');
|
||||||
|
}
|
||||||
|
if (isset($event_data['data']['message_id'])) {
|
||||||
|
$real_assistant_message_id = $event_data['data']['message_id'];
|
||||||
|
$this->log('获取到Agent模式助手消息ID:' . $real_assistant_message_id, 'info');
|
||||||
|
}
|
||||||
|
// 积累助手回复内容
|
||||||
|
if (isset($event_data['data']['answer'])) {
|
||||||
|
$assistant_content .= $event_data['data']['answer'];
|
||||||
|
$this->log('积累Agent回复内容:' . $event_data['data']['answer'], 'debug');
|
||||||
|
}
|
||||||
|
// 保存用户消息(仅在第一次获取到会话ID时保存)
|
||||||
|
if (!$user_message_saved && !empty($real_conversation_id)) {
|
||||||
|
$this->saveUserMessage($kefu_message_model, $site_id, $current_user_id, $real_conversation_id, '', $user_message_content);
|
||||||
|
$this->updateOrCreateConversation($kefu_conversation_model, $site_id, $current_user_id, $real_conversation_id);
|
||||||
|
$user_message_saved = true;
|
||||||
|
$this->log('Agent模式下用户消息已保存,会话ID:' . $real_conversation_id, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'agent_thought':
|
||||||
|
if (isset($event_data['data']['thought'])) {
|
||||||
|
// 格式化思考过程
|
||||||
|
$thought_content = "\n[思考过程]: " . $event_data['data']['thought'];
|
||||||
|
if (isset($event_data['data']['tool'])) {
|
||||||
|
$thought_content .= "\n[使用工具]: " . $event_data['data']['tool'];
|
||||||
|
}
|
||||||
|
if (isset($event_data['data']['observation'])) {
|
||||||
|
$thought_content .= "\n[观察结果]: " . $event_data['data']['observation'];
|
||||||
|
}
|
||||||
|
$assistant_content .= $thought_content;
|
||||||
|
$this->log('Agent思考过程:' . $thought_content, 'debug');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'file':
|
||||||
|
if (isset($event_data['data']['id']) && isset($event_data['data']['type']) && isset($event_data['data']['url'])) {
|
||||||
|
$file_id = $event_data['data']['id'];
|
||||||
|
$file_type = $event_data['data']['type'];
|
||||||
|
$file_url = $event_data['data']['url'];
|
||||||
|
// 可以将文件信息作为特殊内容添加到回复中
|
||||||
|
$file_content = "\n[文件]: " . $file_type . " - " . $file_url;
|
||||||
|
$assistant_content .= $file_content;
|
||||||
|
$this->log('收到文件事件:' . $file_type . ' - ' . $file_url, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_start':
|
||||||
|
if (isset($event_data['data']['conversation_id'])) {
|
||||||
|
$real_conversation_id = $event_data['data']['conversation_id'];
|
||||||
|
$this->log('消息开始事件,会话ID:' . $real_conversation_id, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_delta':
|
||||||
|
if (isset($event_data['data']['delta']['content'])) {
|
||||||
|
$assistant_content .= $event_data['data']['delta']['content'];
|
||||||
|
$this->log('积累增量内容:' . $event_data['data']['delta']['content'], 'debug');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_end':
|
||||||
|
// 最终内容已通过message或message_delta事件积累
|
||||||
|
$this->log('消息结束事件,会话ID:' . $real_conversation_id . ',消息ID:' . $real_assistant_message_id, 'info');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tts_message':
|
||||||
|
// TTS音频流事件
|
||||||
|
// 这里可以记录音频流信息,但由于是文本消息处理,暂时不做特殊处理
|
||||||
|
$this->log('收到TTS消息事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tts_message_end':
|
||||||
|
// TTS音频流结束事件
|
||||||
|
// 同样不做特殊处理
|
||||||
|
$this->log('收到TTS消息结束事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_replace':
|
||||||
|
if (isset($event_data['data']['answer'])) {
|
||||||
|
// 直接替换所有回复文本
|
||||||
|
$assistant_content = $event_data['data']['answer'];
|
||||||
|
$this->log('消息内容替换为:' . $event_data['data']['answer'], 'debug');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
$error_message = isset($event_data['data']['message']) ? $event_data['data']['message'] : '流式输出异常';
|
||||||
|
$assistant_content .= "\n[错误]: " . $error_message;
|
||||||
|
$this->log('AI客服错误事件:' . $error_message, 'error');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ping':
|
||||||
|
// 保持连接存活的ping事件
|
||||||
|
// 无需特殊处理,继续保持连接
|
||||||
|
$this->log('收到ping事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_call_start':
|
||||||
|
// 工具调用开始事件
|
||||||
|
$this->log('工具调用开始事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_call_delta':
|
||||||
|
// 工具调用增量事件
|
||||||
|
$this->log('工具调用增量事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_call_end':
|
||||||
|
// 工具调用结束事件
|
||||||
|
$this->log('工具调用结束事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'done':
|
||||||
|
// 完成事件
|
||||||
|
$this->log('完成事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_start':
|
||||||
|
// 工作流开始事件
|
||||||
|
$this->log('工作流开始事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_node_start':
|
||||||
|
if (isset($event_data['data']['node_id']) && isset($event_data['data']['node_name'])) {
|
||||||
|
$this->log('工作流节点开始:' . $event_data['data']['node_name'], 'debug');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_node_end':
|
||||||
|
if (isset($event_data['data']['node_id']) && isset($event_data['data']['outputs'])) {
|
||||||
|
$this->log('工作流节点结束:' . $event_data['data']['node_id'], 'debug');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_end':
|
||||||
|
// 工作流结束事件
|
||||||
|
$this->log('工作流结束事件', 'debug');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 处理未知事件类型
|
||||||
|
$this->log('未知事件类型:' . $event_data['event'], 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->log('AI客服事件解析失败:' . $json_data, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->log('AI客服事件处理异常:' . $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错误处理回调函数
|
||||||
|
$on_error = function ($error) use ($user_id) {
|
||||||
|
$this->log('AI客服请求错误,用户ID:' . $user_id . ',错误信息:' . json_encode($error), 'error');
|
||||||
|
};
|
||||||
|
|
||||||
// 调用curl流式请求
|
// 调用curl流式请求
|
||||||
$this->curlRequestStreaming($url, $requestData, $headers, $on_data, $on_error);
|
$this->curlRequestStreaming($url, 'POST', $requestData, $headers, $on_data, $on_error);
|
||||||
|
$this->log('AI客服请求成功,用户ID:' . $user_id . ',会话ID:' . $real_conversation_id, 'info');
|
||||||
|
|
||||||
// 数据流结束时发送明确的"done"事件
|
// 数据流结束时发送明确的"done"事件
|
||||||
$done_event = [
|
$done_data = [
|
||||||
'event' => 'done',
|
'conversation_id' => $real_conversation_id,
|
||||||
'data' => [
|
'message_id' => $real_assistant_message_id,
|
||||||
'conversation_id' => $real_conversation_id,
|
'content' => $assistant_content
|
||||||
'message_id' => $real_assistant_message_id,
|
|
||||||
'content' => $assistant_content
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
echo "data: " . json_encode($done_event) . "\n\n";
|
echo "event: done\ndata: " . json_encode($done_data) . "\n\n";
|
||||||
ob_flush();
|
// 只在有输出缓冲时才刷新
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_flush();
|
||||||
|
}
|
||||||
flush();
|
flush();
|
||||||
$this->log('发送done事件,会话ID:' . $real_conversation_id, 'info');
|
$this->log('发送done事件,会话ID:' . $real_conversation_id, 'info');
|
||||||
|
|
||||||
|
|||||||
437
src/addon/aikefu/docs/stream_chat_demo.html
Normal file
437
src/addon/aikefu/docs/stream_chat_demo.html
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>流式聊天测试 Demo</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.method-selector {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.method-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.method-btn.active {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
#chat-container {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
.user-message {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.ai-message {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #333;
|
||||||
|
margin-right: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
#message-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#send-btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#send-btn:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>流式聊天测试 Demo</h1>
|
||||||
|
|
||||||
|
<div class="method-selector">
|
||||||
|
<h3>选择请求方式:</h3>
|
||||||
|
<button class="method-btn active" data-method="eventsource">EventSource</button>
|
||||||
|
<button class="method-btn" data-method="fetch">Fetch API</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-container"></div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<input type="text" id="message-input" placeholder="输入消息...">
|
||||||
|
<button id="send-btn">发送</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<span id="status-text">就绪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 配置
|
||||||
|
const API_URL = 'http://localhost:8050/api/kefu/chat';
|
||||||
|
const UNIACID = '1';
|
||||||
|
let conversationId = '';
|
||||||
|
let currentMethod = 'eventsource';
|
||||||
|
let es = null;
|
||||||
|
let controller = null;
|
||||||
|
|
||||||
|
// DOM 元素
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
const messageInput = document.getElementById('message-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const methodBtns = document.querySelectorAll('.method-btn');
|
||||||
|
|
||||||
|
// 事件监听
|
||||||
|
messageInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', sendMessage);
|
||||||
|
|
||||||
|
methodBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
methodBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentMethod = btn.dataset.method;
|
||||||
|
statusText.textContent = `已切换到 ${btn.textContent} 方式`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
function sendMessage() {
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
// 清空输入框
|
||||||
|
messageInput.value = '';
|
||||||
|
|
||||||
|
// 添加用户消息到聊天记录
|
||||||
|
addMessageToChat(message, 'user');
|
||||||
|
|
||||||
|
// 禁用发送按钮
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
statusText.textContent = '正在发送请求...';
|
||||||
|
|
||||||
|
// 根据选择的方式发送请求
|
||||||
|
if (currentMethod === 'eventsource') {
|
||||||
|
sendEventSourceRequest(message);
|
||||||
|
} else {
|
||||||
|
sendFetchStreamRequest(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventSource 方式
|
||||||
|
function sendEventSourceRequest(message) {
|
||||||
|
// 关闭之前的连接
|
||||||
|
if (es) {
|
||||||
|
es.close();
|
||||||
|
es = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求 URL
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
uniacid: UNIACID,
|
||||||
|
user_id: '123456',
|
||||||
|
message: message,
|
||||||
|
conversation_id: conversationId || '',
|
||||||
|
stream: 'true'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${API_URL}?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusText.textContent = 'EventSource 连接中...';
|
||||||
|
es = new EventSource(url);
|
||||||
|
|
||||||
|
let aiMessage = '';
|
||||||
|
|
||||||
|
// 监听消息事件
|
||||||
|
es.addEventListener('message', (event) => {
|
||||||
|
console.log('收到消息:', event);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.event === 'message') {
|
||||||
|
// 更新 AI 消息
|
||||||
|
aiMessage += data.answer || '';
|
||||||
|
updateAIMessage(aiMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event === 'message_end') {
|
||||||
|
statusText.textContent = '对话完成';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.conversation_id) {
|
||||||
|
conversationId = data.conversation_id;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析消息失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听完成事件
|
||||||
|
es.addEventListener('done', (event) => {
|
||||||
|
console.log('收到完成事件:', event);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.conversation_id) {
|
||||||
|
conversationId = data.conversation_id;
|
||||||
|
}
|
||||||
|
statusText.textContent = '对话完成';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析完成事件失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听错误事件
|
||||||
|
es.addEventListener('error', (error) => {
|
||||||
|
console.error('EventSource 错误:', error);
|
||||||
|
statusText.textContent = '连接错误';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
es.close();
|
||||||
|
es = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听关闭事件
|
||||||
|
es.addEventListener('close', () => {
|
||||||
|
console.log('EventSource 连接关闭');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 EventSource 失败:', error);
|
||||||
|
statusText.textContent = '请求失败';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch API 流式请求方式
|
||||||
|
async function sendFetchStreamRequest(message) {
|
||||||
|
// 取消之前的请求
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
controller = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 AbortController
|
||||||
|
controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
uniacid: UNIACID,
|
||||||
|
user_id: '123456',
|
||||||
|
message: message,
|
||||||
|
conversation_id: conversationId || '',
|
||||||
|
stream: 'true'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusText.textContent = 'Fetch 连接中...';
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'text/event-stream'
|
||||||
|
},
|
||||||
|
signal: signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP 错误! 状态: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('响应体不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
let aiMessage = '';
|
||||||
|
|
||||||
|
statusText.textContent = '接收流式响应...';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// 处理接收到的数据
|
||||||
|
processStreamData(buffer, (newData) => {
|
||||||
|
buffer = newData;
|
||||||
|
if (newData) {
|
||||||
|
// 更新 AI 消息
|
||||||
|
aiMessage += newData;
|
||||||
|
updateAIMessage(aiMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理剩余数据
|
||||||
|
processStreamData(buffer, (newData) => {
|
||||||
|
if (newData) {
|
||||||
|
aiMessage += newData;
|
||||||
|
updateAIMessage(aiMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
statusText.textContent = '对话完成';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
statusText.textContent = '请求已取消';
|
||||||
|
} else {
|
||||||
|
console.error('Fetch 请求失败:', error);
|
||||||
|
statusText.textContent = `请求失败: ${error.message}`;
|
||||||
|
}
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理流式数据
|
||||||
|
function processStreamData(buffer, callback) {
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // 最后一行可能不完整
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
// 解析 SSE 格式
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
const dataPart = line.slice(5).trim();
|
||||||
|
if (dataPart) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataPart);
|
||||||
|
|
||||||
|
if (data.event === 'message') {
|
||||||
|
callback(data.answer || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.conversation_id) {
|
||||||
|
conversationId = data.conversation_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event === 'message_end') {
|
||||||
|
// 对话完成
|
||||||
|
console.log('对话完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析流式数据失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加消息到聊天记录
|
||||||
|
function addMessageToChat(message, type) {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `message ${type}-message`;
|
||||||
|
messageDiv.textContent = message;
|
||||||
|
chatContainer.appendChild(messageDiv);
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
|
||||||
|
// 如果是用户消息,添加一个临时的 AI 消息容器
|
||||||
|
if (type === 'user') {
|
||||||
|
const aiMessageDiv = document.createElement('div');
|
||||||
|
aiMessageDiv.id = 'temp-ai-message';
|
||||||
|
aiMessageDiv.className = 'message ai-message';
|
||||||
|
chatContainer.appendChild(aiMessageDiv);
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 AI 消息
|
||||||
|
function updateAIMessage(message) {
|
||||||
|
let aiMessageDiv = document.getElementById('temp-ai-message');
|
||||||
|
if (!aiMessageDiv) {
|
||||||
|
// 如果临时容器不存在,创建一个新的
|
||||||
|
aiMessageDiv = document.createElement('div');
|
||||||
|
aiMessageDiv.id = 'temp-ai-message';
|
||||||
|
aiMessageDiv.className = 'message ai-message';
|
||||||
|
chatContainer.appendChild(aiMessageDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
aiMessageDiv.textContent = message;
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面卸载时清理资源
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (es) {
|
||||||
|
es.close();
|
||||||
|
}
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
statusText.textContent = '就绪,使用 EventSource 方式';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,7 +12,7 @@ class KefuChat
|
|||||||
/**
|
/**
|
||||||
* 处理智能客服聊天事件
|
* 处理智能客服聊天事件
|
||||||
* @param array $data 事件数据
|
* @param array $data 事件数据
|
||||||
* @return array
|
* @return array|null
|
||||||
*/
|
*/
|
||||||
public function handle($data)
|
public function handle($data)
|
||||||
{
|
{
|
||||||
@@ -26,6 +26,11 @@ class KefuChat
|
|||||||
// 调用addon的chat方法
|
// 调用addon的chat方法
|
||||||
$response = $kefu_api->chat();
|
$response = $kefu_api->chat();
|
||||||
|
|
||||||
|
// 对于流式请求,直接输出不返回数据
|
||||||
|
if (isset($data['stream']) && $data['stream']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 返回响应数据
|
// 返回响应数据
|
||||||
return json_decode($response->getContent(), true);
|
return json_decode($response->getContent(), true);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
public function health()
|
public function health()
|
||||||
{
|
{
|
||||||
|
// 检查请求方式是否为POST
|
||||||
|
if (!request()->isPost()) {
|
||||||
|
return $this->response($this->error('请求方式错误,仅支持POST请求'));
|
||||||
|
}
|
||||||
|
|
||||||
$start_time = microtime(true);
|
$start_time = microtime(true);
|
||||||
|
|
||||||
// (必选) 获取站点ID和会员ID,可以通过事件数据传递
|
// (必选) 获取站点ID和会员ID,可以通过事件数据传递
|
||||||
@@ -49,7 +54,7 @@ class Kefu extends BaseApi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 触发健康检查事件
|
// 触发健康检查事件
|
||||||
$result = Event::trigger('KefuHealthCheck', $event_data);
|
$result = event('KefuHealthCheck', $event_data, true);
|
||||||
|
|
||||||
// 汇总检查结果
|
// 汇总检查结果
|
||||||
$health_summary = [
|
$health_summary = [
|
||||||
@@ -143,6 +148,11 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
public function info()
|
public function info()
|
||||||
{
|
{
|
||||||
|
// 检查请求方式是否为POST
|
||||||
|
if (!request()->isPost()) {
|
||||||
|
return $this->response($this->error('请求方式错误,仅支持POST请求'));
|
||||||
|
}
|
||||||
|
|
||||||
// (可选)获取站点ID和会员ID,可以通过事件数据传递
|
// (可选)获取站点ID和会员ID,可以通过事件数据传递
|
||||||
$site_id = $this->params['uniacid'] ?? $this->site_id;
|
$site_id = $this->params['uniacid'] ?? $this->site_id;
|
||||||
$member_id = $this->params['member_id'] ?? $this->member_id;
|
$member_id = $this->params['member_id'] ?? $this->member_id;
|
||||||
@@ -167,7 +177,7 @@ class Kefu extends BaseApi
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 触发获取配置信息事件
|
// 触发获取配置信息事件
|
||||||
$result = event('KefuGetInfo', $event_data);
|
$result = event('KefuGetInfo', $event_data, true);
|
||||||
|
|
||||||
// 处理事件结果
|
// 处理事件结果
|
||||||
$response = [
|
$response = [
|
||||||
@@ -223,6 +233,11 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
public function clearConversation()
|
public function clearConversation()
|
||||||
{
|
{
|
||||||
|
// 检查请求方式是否为POST
|
||||||
|
if (!request()->isPost()) {
|
||||||
|
return $this->response($this->error('请求方式错误,仅支持POST请求'));
|
||||||
|
}
|
||||||
|
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
$conversation_id = $this->params['conversation_id'] ?? '';
|
$conversation_id = $this->params['conversation_id'] ?? '';
|
||||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||||
@@ -253,7 +268,7 @@ class Kefu extends BaseApi
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 触发清除会话事件
|
// 触发清除会话事件
|
||||||
$result = event('KefuClearConversation', $event_data);
|
$result = event('KefuClearConversation', $event_data, true);
|
||||||
|
|
||||||
// 处理事件结果
|
// 处理事件结果
|
||||||
$response = [
|
$response = [
|
||||||
@@ -286,11 +301,33 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
public function chat()
|
public function chat()
|
||||||
{
|
{
|
||||||
|
// 检查请求方式是否为POST或者是EventSource请求
|
||||||
|
$isPost = request()->isPost();
|
||||||
|
$isGet = request()->isGet();
|
||||||
|
$isEventSource = $isGet && request()->header('Accept') === 'text/event-stream';
|
||||||
|
|
||||||
|
if (!$isPost && !$isEventSource) {
|
||||||
|
return $this->response($this->error('请求方式错误,仅支持POST或EventSource请求'));
|
||||||
|
}
|
||||||
|
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
$message = $this->params['message'] ?? '';
|
// 对于GET请求,需要单独获取参数,因为BaseApi的构造函数可能没有正确处理GET参数
|
||||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
if ($isGet) {
|
||||||
$conversation_id = $this->params['conversation_id'] ?? '';
|
$message = request()->get('message', '');
|
||||||
$stream = $this->params['stream'] ?? false;
|
$user_id = request()->get('user_id', $this->member_id);
|
||||||
|
$conversation_id = request()->get('conversation_id', '');
|
||||||
|
$stream = request()->get('stream', false);
|
||||||
|
} else {
|
||||||
|
$message = $this->params['message'] ?? '';
|
||||||
|
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||||
|
$conversation_id = $this->params['conversation_id'] ?? '';
|
||||||
|
$stream = $this->params['stream'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保stream参数正确处理字符串'false'和'0'
|
||||||
|
if (is_string($stream)) {
|
||||||
|
$stream = filter_var($stream, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
// (可选)获取站点ID和会员ID,可以通过事件数据传递
|
// (可选)获取站点ID和会员ID,可以通过事件数据传递
|
||||||
$site_id = $this->params['uniacid'] ?? $this->site_id; // 使用 uniacid, 方便以后迁移,而且uniacid 是唯一的, site_id 不是,同时被params给过滤了
|
$site_id = $this->params['uniacid'] ?? $this->site_id; // 使用 uniacid, 方便以后迁移,而且uniacid 是唯一的, site_id 不是,同时被params给过滤了
|
||||||
@@ -320,33 +357,39 @@ class Kefu extends BaseApi
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($stream) {
|
if ($stream) {
|
||||||
|
// 设置SSE响应头
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('Connection: keep-alive');
|
||||||
|
header('X-Accel-Buffering: no'); // 禁用Nginx缓冲
|
||||||
|
|
||||||
|
// 触发事件,让监听器处理流式响应
|
||||||
event('KefuChat', $event_data);
|
event('KefuChat', $event_data);
|
||||||
exit; // 流式请求直接输出并结束
|
} else {
|
||||||
}
|
// 触发智能客服聊天事件(非流式)
|
||||||
|
$result = event('KefuChat', $event_data);
|
||||||
|
|
||||||
// 触发智能客服聊天事件(非流式)
|
// 处理事件结果
|
||||||
$result = event('KefuChat', $event_data);
|
$response = [
|
||||||
|
'code' => 0,
|
||||||
|
'message' => 'success',
|
||||||
|
'data' => []
|
||||||
|
];
|
||||||
|
|
||||||
// 处理事件结果
|
if (is_array($result) && !empty($result)) {
|
||||||
$response = [
|
foreach ($result as $res) {
|
||||||
'code' => 0,
|
if (isset($res['code']) && $res['code'] < 0) {
|
||||||
'message' => 'success',
|
$response = $res;
|
||||||
'data' => []
|
break;
|
||||||
];
|
}
|
||||||
|
if (isset($res['data'])) {
|
||||||
if (is_array($result) && !empty($result)) {
|
$response['data'] = array_merge($response['data'], $res['data']);
|
||||||
foreach ($result as $res) {
|
}
|
||||||
if (isset($res['code']) && $res['code'] < 0) {
|
|
||||||
$response = $res;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isset($res['data'])) {
|
|
||||||
$response['data'] = array_merge($response['data'], $res['data']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response($response);
|
return $this->response($response);
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->response($this->error('请求失败:' . $e->getMessage()));
|
return $this->response($this->error('请求失败:' . $e->getMessage()));
|
||||||
}
|
}
|
||||||
@@ -358,6 +401,11 @@ class Kefu extends BaseApi
|
|||||||
*/
|
*/
|
||||||
public function getHistory()
|
public function getHistory()
|
||||||
{
|
{
|
||||||
|
// 检查请求方式是否为POST
|
||||||
|
if (!request()->isPost()) {
|
||||||
|
return $this->response($this->error('请求方式错误,仅支持POST请求'));
|
||||||
|
}
|
||||||
|
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
$conversation_id = $this->params['conversation_id'] ?? '';
|
$conversation_id = $this->params['conversation_id'] ?? '';
|
||||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||||
@@ -387,7 +435,7 @@ class Kefu extends BaseApi
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 触发获取历史消息事件
|
// 触发获取历史消息事件
|
||||||
$result = event('KefuGetHistory', $event_data);
|
$result = event('KefuGetHistory', $event_data, true);
|
||||||
|
|
||||||
// 处理事件结果
|
// 处理事件结果
|
||||||
$response = [
|
$response = [
|
||||||
|
|||||||
Reference in New Issue
Block a user