扣子AI智能体 curl进行请求会话
·
如果想在小程序内嵌入一个AI的智能对话
使用扣子平台时 可能会存在不能嵌入的问题 推荐使用扣子平台
下面是一个demo
代码:
<?php
namespace app\api\controller;
use app\common\controller\Api;
/**
* 扣子(Coze)智能体对话
*
* 文档参考:
* - Chat v3: https://docs.coze.cn/developer_guides/chat_v3
* - OAuth Apps: https://docs.coze.cn/developer_guides/oauth_apps
*
* 说明:
* 1) 本控制器默认需要登录,使用当前登录用户ID作为 user_id 参与会话。
* 2) 你只需要把下面配置中的 xxx 改成真实值即可:
* - COZE_BASE_URL
* - COZE_BOT_ID
* - COZE_PAT_TOKEN(可选;若已配 OAuth Apps 可不填)
* 3) 支持两种调用模式:
* - 官方 OpenAPI Chat v3(api.coze.cn)
* - 自定义 Runtime stream_run(*.coze.site/stream_run)
* 4) 若配置了 COZE_STREAM_RUN_URL,则 chat 默认优先走 stream_run。
*/
class Kouzi extends Api
{
protected $noNeedLogin = [];
protected $noNeedRight = '*';
/**
* ====== 需要你填写的参数(xxx)======
*/
const COZE_BASE_URL = 'https://api.coze.cn'; // xxx: 若你使用国际站/代理域名,请改这里
const COZE_BOT_ID = 'xxxxxxxx'; // 扣子智能体ID(bot_id)
const COZE_PAT_TOKEN = 'xxxxxxxxxxxxxxxxxxxxx'; // 长期有效token(兜底方案)
// ===== Runtime stream_run 模式(按你提供的 curl 结构)=====
const COZE_STREAM_RUN_URL = 'https://wc8vrv53fv.coze.site/stream_run'; // 例如 https://wc8vrv53fv.coze.site/stream_run
const COZE_PROJECT_ID = '7622961322879254562'; // project_id(通常是数字字符串)
// OAuth Apps(推荐): https://docs.coze.cn/developer_guides/oauth_apps
const COZE_OAUTH_CLIENT_ID = 'xxx'; // xxx: OAuth 应用 client_id
const COZE_OAUTH_CLIENT_SECRET = 'xxx'; // xxx: OAuth 应用 client_secret
const COZE_OAUTH_TOKEN_PATH = '/api/permission/oauth2/token'; // 若文档路径有差异,请按实际调整
const COZE_OAUTH_GRANT_TYPE = 'client_credentials';
// OAuth token 本地缓存文件(避免每次请求都换 token)
const COZE_TOKEN_CACHE_FILE = 'runtime/cache/coze_oauth_token.json';
const COZE_TOKEN_EXPIRE_SAFE_GAP = 120; // 秒:提前刷新缓冲
const COZE_STREAM_SESSION_DIR = 'runtime/cache/coze_stream_sessions'; // stream_run 连续会话本地存储目录
// 轮询等待配置(毫秒)
const CHAT_POLL_INTERVAL_MS = 800;
const CHAT_POLL_MAX_TIMES = 6;
/**
* 发起一轮对话(用户发消息 -> 智能体回复)
*
* 入参(POST):
* - message string 必填,用户消息
* - conversation_id string 可选,续聊时传;新会话可不传
* - custom_user_id string 可选,不传则默认 "user_{登录用户ID}"
* - bot_id string 可选,支持传纯数字ID,或传链接(如 https://code.coze.cn/p/7622...)
* - auto_poll int 可选,1=自动短轮询(默认),0=仅创建chat(仅 Chat v3 有效)
* - session_id string 可选(stream_run 模式),不传则后端自动生成
*
* 出参:
* - conversation_id/chat_id/status
* - assistant_message(拿到最终结果时返回)
* - is_finished
*/
public function chat()
{
$message = trim((string)$this->request->post('message', ''));
$conversationId = trim((string)$this->request->post('conversation_id', ''));
$customUserId = trim((string)$this->request->post('custom_user_id', ''));
$botIdInput = trim((string)$this->request->post('bot_id', ''));
$sessionIdInput = trim((string)$this->request->post('session_id', ''));
$autoPoll = (int)$this->request->post('auto_poll', 1);
$autoPoll = $autoPoll === 0 ? 0 : 1;
if ($message === '') {
$this->error('message不能为空');
}
$this->assertConfigReady();
// 兼容:stream_run 场景下前端如果传 conversation_id,这里映射为 session_id
if ($sessionIdInput === '' && $conversationId !== '') {
$sessionIdInput = $conversationId;
}
// 若配置了 stream_run,则优先走你给的 coze.site Runtime 请求方式
if ($this->isStreamRunEnabled()) {
$ret = $this->runStreamChat($message, $sessionIdInput, $customUserId);
$this->success('请求成功', $ret);
}
$userId = $customUserId !== '' ? $customUserId : ('user_' . (int)$this->auth->id);
$botId = $this->resolveBotId($botIdInput);
if ($botId === '') {
$this->error('bot_id不能为空,请在配置中填写 COZE_BOT_ID 或请求参数传 bot_id');
}
$payload = [
'bot_id' => $botId,
'user_id' => $userId,
'stream' => false,
// Chat v3 常见写法:additional_messages 塞入用户输入
'additional_messages' => [
[
'role' => 'user',
'content' => $message,
'content_type' => 'text',
],
],
// 可选:让会话历史自动保存
'auto_save_history' => true,
];
if ($conversationId !== '') {
$payload['conversation_id'] = $conversationId;
}
$create = $this->cozeRequest('POST', '/v3/chat', $payload);
if (!$create['ok']) {
$this->error($this->buildCreateChatErrorMessage($create, $botId), $create);
}
$createData = $create['data']['data'] ?? $create['data'];
$chatId = (string)($createData['id'] ?? $createData['chat_id'] ?? '');
$conversationId = (string)($createData['conversation_id'] ?? $conversationId);
$status = (string)($createData['status'] ?? 'created');
if ($chatId === '' || $conversationId === '') {
$this->error('扣子返回缺少 chat_id 或 conversation_id', $createData);
}
// 不自动轮询:直接返回会话标识,前端再调用 retrieve
if ($autoPoll === 0) {
$this->success('对话已创建', [
'conversation_id' => $conversationId,
'chat_id' => $chatId,
'status' => $status,
'assistant_message' => '',
'is_finished' => false,
]);
}
// 自动短轮询,尽量直接返回最终回答
$last = [];
for ($i = 0; $i < self::CHAT_POLL_MAX_TIMES; $i++) {
$last = $this->retrieveChatCore($conversationId, $chatId);
if (!empty($last['is_finished'])) {
break;
}
usleep(self::CHAT_POLL_INTERVAL_MS * 1000);
}
$this->success('请求成功', [
'conversation_id' => $conversationId,
'chat_id' => $chatId,
'status' => (string)($last['status'] ?? $status),
'assistant_message' => (string)($last['assistant_message'] ?? ''),
'messages' => $last['messages'] ?? [],
'is_finished' => !empty($last['is_finished']),
]);
}
/**
* 查询对话结果(前端轮询用)
*
* 入参(POST):
* - conversation_id string 必填
* - chat_id string 必填
*/
public function retrieve()
{
// 兼容:前端若传 session_id,这里先做别名映射
$conversationId = trim((string)$this->request->post('conversation_id', ''));
if ($conversationId === '') {
$conversationId = trim((string)$this->request->post('session_id', ''));
}
$chatId = trim((string)$this->request->post('chat_id', ''));
if ($conversationId === '' || $chatId === '') {
$this->error('conversation_id 和 chat_id 不能为空');
}
$this->assertConfigReady();
// 重要:stream_run 模式没有 Chat v3 的 retrieve 能力,不能用 session_id 去查 chat_id
if ($this->isStreamRunEnabled()) {
$this->error(
'当前为 stream_run 模式,不支持 retrieve 查询。请继续调用 chat 并传同一个 session_id 连续对话;' .
'若要使用 retrieve,请切换到 Chat v3(关闭 COZE_STREAM_RUN_URL)并使用 chat 返回的 conversation_id/chat_id。'
);
}
$ret = $this->retrieveChatCore($conversationId, $chatId);
$this->success('请求成功', $ret);
}
/**
* 核心:查询 chat 状态并提取助手文本
*/
protected function retrieveChatCore($conversationId, $chatId)
{
$path = '/v3/chat/retrieve?conversation_id=' . rawurlencode($conversationId) . '&chat_id=' . rawurlencode($chatId);
$resp = $this->cozeRequest('GET', $path, null);
if (!$resp['ok']) {
return [
'conversation_id' => $conversationId,
'chat_id' => $chatId,
'status' => 'error',
'assistant_message' => '',
'messages' => [],
'is_finished' => false,
'error_msg' => $resp['msg'],
];
}
$d = $resp['data']['data'] ?? $resp['data'];
$status = (string)($d['status'] ?? '');
$messages = $this->extractMessages($d);
$assistantMessage = $this->pickAssistantText($messages);
// 常见完成态:completed / done / finished
$isFinished = in_array(strtolower($status), ['completed', 'done', 'finished', 'success'], true);
if (!$isFinished && $assistantMessage !== '') {
// 某些响应未显式给 completed,但已经回了 answer,这里兜底判定
$isFinished = true;
}
return [
'conversation_id' => $conversationId,
'chat_id' => $chatId,
'status' => $status,
'assistant_message' => $assistantMessage,
'messages' => $messages,
'is_finished' => $isFinished,
];
}
/**
* 统一请求扣子 API(封装 token/header/curl)
*/
protected function cozeRequest($method, $path, $payload = null)
{
$url = rtrim(self::COZE_BASE_URL, '/') . $path;
$headers = [
'Authorization: Bearer ' . $this->getCozeToken(),
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if (stripos($url, 'https://') === 0) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
}
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$errmsg = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
return ['ok' => false, 'msg' => 'curl异常:' . $errmsg, 'http_code' => 0, 'data' => []];
}
$json = json_decode((string)$raw, true);
if (!is_array($json)) {
return ['ok' => false, 'msg' => '返回非JSON', 'http_code' => $httpCode, 'data' => ['raw' => (string)$raw]];
}
// Coze 通常 code=0 为成功;同时兼容 http 2xx
$code = isset($json['code']) ? (int)$json['code'] : 0;
$ok = ($httpCode >= 200 && $httpCode < 300) && ($code === 0 || !isset($json['code']));
$msg = $ok ? 'ok' : (string)($json['msg'] ?? $json['message'] ?? '请求失败');
return ['ok' => $ok, 'msg' => $msg, 'http_code' => $httpCode, 'data' => $json];
}
/**
* stream_run 模式开关:
* 只要配置了 COZE_STREAM_RUN_URL(且非 xxx)就启用
*/
protected function isStreamRunEnabled()
{
$url = trim((string)self::COZE_STREAM_RUN_URL);
return $url !== '' && $url !== 'xxx';
}
/**
* 使用 coze.site 的 /stream_run 对话(按你给的 curl demo)
*/
protected function runStreamChat($message, $sessionIdInput = '', $customUserId = '')
{
$url = trim((string)self::COZE_STREAM_RUN_URL);
$projectId = $this->resolveProjectId();
if ($projectId === '') {
$this->error('stream_run 模式下请填写 COZE_PROJECT_ID');
}
$sessionId = trim((string)$sessionIdInput);
if ($sessionId === '') {
// 自动生成一个可复用会话ID(前端拿到后下次带回即可续聊)
$suffix = $customUserId !== '' ? $customUserId : ('u' . (int)$this->auth->id);
$sessionId = 'sess_' . md5($suffix . '_' . microtime(true));
}
$payload = [
'content' => [
'query' => [
'prompt' => [
[
'type' => 'text',
'content' => ['text' => (string)$message],
],
],
],
],
'type' => 'query',
'session_id' => $sessionId,
'project_id' => $projectId,
];
$headers = [
'Authorization: Bearer ' . $this->getCozeToken(),
'Content-Type: application/json',
];
$resp = $this->httpJsonRequest('POST', $url, $headers, $payload);
if (!$resp['ok']) {
$this->error('stream_run 调用失败:' . $resp['msg'], $resp);
}
$body = $resp['data'];
$sse = isset($body['_sse']) && is_array($body['_sse']) ? $body['_sse'] : [];
$answer = '';
$toolResult = '';
$replyId = '';
$isFinished = false;
if (!empty($sse['events']) && is_array($sse['events'])) {
$answer = $this->extractStreamRunAnswerFromSseEvents($sse['events']);
$toolResult = $this->extractStreamRunToolResultFromSseEvents($sse['events']);
$replyId = $this->extractStreamRunReplyIdFromSseEvents($sse['events']);
$isFinished = $this->isStreamRunFinished($sse['events']);
} else {
$answer = $this->extractStreamRunAnswer($body);
}
$debug = (int)$this->request->post('debug', 0) === 1;
// 常规返回精简:只保留连续会话必须字段
$ret = [
'mode' => 'stream_run',
'session_id' => $sessionId,
'reply_id' => $replyId,
'assistant_message' => $answer,
'is_finished' => $isFinished,
// 连续会话关键:下轮把这个 session_id 继续传回来即可
'next_session_id' => $sessionId,
];
// 某些轮次 answer 为空但 tool_result 有值,给个兜底文本
if ($ret['assistant_message'] === '' && $toolResult !== '') {
$ret['assistant_message'] = $toolResult;
}
// 记录并返回连续会话列表(最近100条)
$ret['conversation_list'] = $this->appendAndGetStreamConversationList(
$sessionId,
(string)$message,
(string)$ret['assistant_message'],
(string)$replyId
);
// 仅调试时返回完整原始数据,避免响应体过大
if ($debug) {
$ret['project_id'] = $projectId;
$ret['tool_result'] = $toolResult;
$ret['raw'] = $body;
}
return $ret;
}
/**
* 兼容提取 stream_run 返回文本
*/
protected function extractStreamRunAnswer(array $body)
{
// 常见字段兜底
$paths = [
['data', 'output', 'text'],
['data', 'text'],
['output', 'text'],
['text'],
['answer'],
['message'],
];
foreach ($paths as $path) {
$v = $body;
$ok = true;
foreach ($path as $k) {
if (!is_array($v) || !array_key_exists($k, $v)) {
$ok = false;
break;
}
$v = $v[$k];
}
if ($ok && is_string($v) && trim($v) !== '') {
return trim($v);
}
}
// 如果没有固定字段,则把 body json 给前端自己解析
return '';
}
/**
* 从 SSE 事件中拼接最终回答(type=answer 的 content.answer 分片)
*/
protected function extractStreamRunAnswerFromSseEvents(array $events)
{
$parts = [];
foreach ($events as $e) {
if (!is_array($e)) {
continue;
}
if ((string)($e['event'] ?? '') !== 'message') {
continue;
}
$d = $e['data'] ?? [];
if (!is_array($d)) {
continue;
}
if ((string)($d['type'] ?? '') !== 'answer') {
continue;
}
$txt = (string)($d['content']['answer'] ?? '');
if ($txt !== '') {
$parts[] = $txt;
}
}
return implode('', $parts);
}
/**
* 从 SSE 事件里提取工具返回结果(type=tool_response)
*/
protected function extractStreamRunToolResultFromSseEvents(array $events)
{
$result = '';
foreach ($events as $e) {
if (!is_array($e)) {
continue;
}
if ((string)($e['event'] ?? '') !== 'message') {
continue;
}
$d = $e['data'] ?? [];
if (!is_array($d) || (string)($d['type'] ?? '') !== 'tool_response') {
continue;
}
$r = (string)($d['content']['tool_response']['result'] ?? '');
if ($r !== '') {
$result = $r;
}
}
return $result;
}
/**
* 提取 reply_id(方便排障)
*/
protected function extractStreamRunReplyIdFromSseEvents(array $events)
{
foreach ($events as $e) {
if (!is_array($e)) {
continue;
}
$d = $e['data'] ?? [];
if (is_array($d) && !empty($d['reply_id'])) {
return (string)$d['reply_id'];
}
}
return '';
}
/**
* 判断本轮流式是否结束(任一事件 finish=true 即视为结束)
*/
protected function isStreamRunFinished(array $events)
{
foreach ($events as $e) {
if (!is_array($e)) {
continue;
}
$d = $e['data'] ?? [];
if (is_array($d) && !empty($d['finish'])) {
return true;
}
}
return false;
}
/**
* 通用 JSON 请求(给 stream_run 与 token 请求复用)
*/
protected function httpJsonRequest($method, $url, array $headers, $payload = null)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if (stripos($url, 'https://') === 0) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
}
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$errmsg = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
return ['ok' => false, 'msg' => 'curl异常:' . $errmsg, 'http_code' => 0, 'data' => []];
}
$json = json_decode((string)$raw, true);
if (!is_array($json)) {
// stream_run 常见返回为 SSE 文本,不是单个 JSON;在这里做兼容解析
$events = $this->parseSseEvents((string)$raw);
if (!empty($events)) {
return [
'ok' => ($httpCode >= 200 && $httpCode < 300),
'msg' => 'ok',
'http_code' => $httpCode,
'data' => [
'_sse' => [
'events' => $events,
'raw' => (string)$raw,
],
],
];
}
return ['ok' => false, 'msg' => '返回非JSON', 'http_code' => $httpCode, 'data' => ['raw' => (string)$raw]];
}
$ok = ($httpCode >= 200 && $httpCode < 300);
$code = isset($json['code']) ? (int)$json['code'] : 0;
if (isset($json['code'])) {
$ok = $ok && ($code === 0);
}
$msg = $ok ? 'ok' : (string)($json['msg'] ?? $json['message'] ?? '请求失败');
return ['ok' => $ok, 'msg' => $msg, 'http_code' => $httpCode, 'data' => $json];
}
/**
* 解析 SSE 文本(event/data 块)
*/
protected function parseSseEvents($raw)
{
$raw = str_replace("\r\n", "\n", (string)$raw);
if (strpos($raw, 'event:') === false || strpos($raw, 'data:') === false) {
return [];
}
$chunks = explode("\n\n", $raw);
$events = [];
foreach ($chunks as $chunk) {
$chunk = trim((string)$chunk);
if ($chunk === '') {
continue;
}
$eventName = '';
$dataLine = '';
$lines = explode("\n", $chunk);
foreach ($lines as $line) {
$line = trim((string)$line);
if (stripos($line, 'event:') === 0) {
$eventName = trim(substr($line, 6));
} elseif (stripos($line, 'data:') === 0) {
$dataLine .= trim(substr($line, 5));
}
}
if ($eventName === '') {
continue;
}
$data = json_decode($dataLine, true);
if (!is_array($data)) {
$data = ['raw' => $dataLine];
}
$events[] = [
'event' => $eventName,
'data' => $data,
];
}
return $events;
}
/**
* 追加一轮问答并返回会话列表
* 每轮包含:用户问题 + AI回答 两条记录;最多保留最近100条
*/
protected function appendAndGetStreamConversationList($sessionId, $userMessage, $assistantMessage, $replyId = '')
{
$sessionId = trim((string)$sessionId);
if ($sessionId === '') {
return [];
}
$list = $this->loadStreamConversationList($sessionId);
$now = time();
if ($userMessage !== '') {
$list[] = [
'role' => 'user',
'content' => $userMessage,
'reply_id' => '',
'time' => $now,
'time_text' => date('Y-m-d H:i:s', $now),
];
}
if ($assistantMessage !== '') {
$list[] = [
'role' => 'assistant',
'content' => $assistantMessage,
'reply_id' => (string)$replyId,
'time' => $now,
'time_text' => date('Y-m-d H:i:s', $now),
];
}
if (count($list) > 100) {
$list = array_slice($list, -100);
}
$this->saveStreamConversationList($sessionId, $list);
return $list;
}
protected function getStreamConversationFile($sessionId)
{
$dir = ROOT_PATH . self::COZE_STREAM_SESSION_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$safeId = preg_replace('/[^a-zA-Z0-9_\-]/', '_', (string)$sessionId);
return rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $safeId . '.json';
}
protected function loadStreamConversationList($sessionId)
{
$file = $this->getStreamConversationFile($sessionId);
if (!is_file($file)) {
return [];
}
$raw = @file_get_contents($file);
$json = json_decode((string)$raw, true);
return is_array($json) ? $json : [];
}
protected function saveStreamConversationList($sessionId, array $list)
{
$file = $this->getStreamConversationFile($sessionId);
@file_put_contents($file, json_encode(array_values($list), JSON_UNESCAPED_UNICODE));
}
/**
* project_id 允许传数字、字符串或链接,尽量提取数字ID
*/
protected function resolveProjectId()
{
$raw = trim((string)self::COZE_PROJECT_ID);
if ($raw === '' || $raw === 'xxx') {
return '';
}
if (preg_match('/(\d{8,})/', $raw, $m)) {
return (string)$m[1];
}
return $raw;
}
/**
* 从 Coze 响应中提取 messages(兼容不同字段结构)
*/
protected function extractMessages(array $data)
{
if (isset($data['messages']) && is_array($data['messages'])) {
return $data['messages'];
}
if (isset($data['message_list']) && is_array($data['message_list'])) {
return $data['message_list'];
}
if (isset($data['chat_messages']) && is_array($data['chat_messages'])) {
return $data['chat_messages'];
}
return [];
}
/**
* 选取助手最后一条可读文本
*/
protected function pickAssistantText(array $messages)
{
$text = '';
foreach ($messages as $m) {
if (!is_array($m)) {
continue;
}
$role = strtolower((string)($m['role'] ?? ''));
$type = strtolower((string)($m['type'] ?? $m['content_type'] ?? ''));
if ($role !== 'assistant' && $role !== 'bot') {
continue;
}
if ($type !== '' && !in_array($type, ['text', 'answer', 'message'], true)) {
continue;
}
$candidate = '';
if (isset($m['content']) && is_string($m['content'])) {
$candidate = $m['content'];
} elseif (isset($m['text']) && is_string($m['text'])) {
$candidate = $m['text'];
}
if ($candidate !== '') {
$text = $candidate;
}
}
return trim($text);
}
/**
* 解析 bot_id:
* - 优先请求参数 bot_id
* - 允许传链接(自动提取 /p/{id})
* - 否则回退到常量 COZE_BOT_ID
*/
protected function resolveBotId($botIdInput)
{
$raw = trim((string)$botIdInput);
if ($raw === '') {
$raw = trim((string)self::COZE_BOT_ID);
}
if ($raw === '' || $raw === 'xxx') {
return '';
}
// 链接格式: https://code.coze.cn/p/7622961322879254562
if (preg_match('#/p/(\d+)#', $raw, $m)) {
return (string)$m[1];
}
// 若字符串中包含纯数字,提取最长数字段
if (preg_match('/(\d{8,})/', $raw, $m)) {
return (string)$m[1];
}
return $raw;
}
/**
* 发起对话失败时生成可读错误(重点提示 4200 的 bot_id 问题)
*/
protected function buildCreateChatErrorMessage(array $createResp, $botId)
{
$msg = '发起对话失败:' . (string)($createResp['msg'] ?? '未知错误');
$code = (int)($createResp['data']['code'] ?? 0);
if ($code === 4200) {
$msg .= '。排查建议:';
$msg .= '1) 当前 bot_id=' . $botId . ' 可能不是 API 可用的智能体ID(code.coze.cn 的项目ID不一定等于开放API bot_id);';
$msg .= '2) 到扣子开放平台/API页面确认“Bot ID”后再填;';
$msg .= '3) 确认 token 所在账号/空间对该 bot 有权限。';
}
return $msg;
}
/**
* token读取封装(优先 OAuth Apps,失败后再降级 PAT)
*/
protected function getCozeToken()
{
$oauthToken = $this->getOAuthAccessToken();
if ($oauthToken !== '') {
return $oauthToken;
}
return trim((string)self::COZE_PAT_TOKEN);
}
/**
* 通过 OAuth Apps 获取 Access Token(带缓存)
* - 命中未过期缓存:直接返回
* - 缓存失效:请求 token 接口并刷新缓存
*/
protected function getOAuthAccessToken()
{
$clientId = trim((string)self::COZE_OAUTH_CLIENT_ID);
$clientSecret = trim((string)self::COZE_OAUTH_CLIENT_SECRET);
if ($clientId === '' || $clientId === 'xxx' || $clientSecret === '' || $clientSecret === 'xxx') {
return '';
}
$cache = $this->readTokenCache();
if (!empty($cache['access_token']) && !empty($cache['expire_at']) && (int)$cache['expire_at'] > time() + self::COZE_TOKEN_EXPIRE_SAFE_GAP) {
return (string)$cache['access_token'];
}
$fresh = $this->requestOAuthToken($clientId, $clientSecret);
if ($fresh === null || empty($fresh['access_token'])) {
return '';
}
$this->writeTokenCache($fresh);
return (string)$fresh['access_token'];
}
/**
* 请求 OAuth token(client_credentials)
*/
protected function requestOAuthToken($clientId, $clientSecret)
{
$url = rtrim(self::COZE_BASE_URL, '/') . self::COZE_OAUTH_TOKEN_PATH;
$payload = http_build_query([
'grant_type' => self::COZE_OAUTH_GRANT_TYPE,
'client_id' => $clientId,
'client_secret' => $clientSecret,
]);
$headers = ['Content-Type: application/x-www-form-urlencoded'];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
if (stripos($url, 'https://') === 0) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno) {
return null;
}
$json = json_decode((string)$raw, true);
if (!is_array($json)) {
return null;
}
// 兼容常见字段:access_token / expires_in
$token = (string)($json['access_token'] ?? (($json['data']['access_token'] ?? '')));
$expiresIn = (int)($json['expires_in'] ?? (($json['data']['expires_in'] ?? 0)));
if ($token === '') {
return null;
}
if ($expiresIn <= 0) {
$expiresIn = 7200; // 默认2小时兜底
}
return [
'access_token' => $token,
'expire_at' => time() + $expiresIn,
];
}
/**
* 读 token 缓存
*/
protected function readTokenCache()
{
$path = ROOT_PATH . self::COZE_TOKEN_CACHE_FILE;
if (!is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
$json = json_decode((string)$raw, true);
return is_array($json) ? $json : [];
}
/**
* 写 token 缓存
*/
protected function writeTokenCache(array $tokenData)
{
$path = ROOT_PATH . self::COZE_TOKEN_CACHE_FILE;
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($path, json_encode($tokenData, JSON_UNESCAPED_UNICODE));
}
/**
* 启动前校验必填配置,避免请求到线上才报错
*/
protected function assertConfigReady()
{
if ($this->isStreamRunEnabled() && $this->resolveProjectId() === '') {
$this->error('请先在 Kouzi.php 填写 COZE_PROJECT_ID(stream_run 模式)');
}
$token = $this->getCozeToken();
if ($token === '' || $token === 'xxx') {
$this->error('请先在 Kouzi.php 填写 OAuth(client_id/client_secret) 或 COZE_PAT_TOKEN');
}
}
}
返回demo
{
"code": 1,
"msg": "请求成功",
"time": "1774864415",
"data": {
"mode": "stream_run",
"session_id": "xxxxxxxxxxxxx",
"reply_id": "xxxxxxxx",
"assistant_message": "好的,了解了。接下来我们了解孩子的学习状态:\n\n问题1:孩子是否会逃避学习任务,比如故意漏写作业、抄袭作业,或谎称作业已经完成?",
"is_finished": true,
"next_session_id": "xxxxxxxxxxxxxxxx",
"conversation_list": [
{
"role": "user",
"content": "你好",
"reply_id": "",
"time": 1774864303,
"time_text": "2026-03-30 17:51:43"
},
{
"role": "assistant",
"content": "您好!我是儿童心理专家,很高兴为您服务。我将从五个维度(情绪、学习、生活心态、亲子关系、家庭教育)各提问2个问题,帮助您了解孩子的心理状态。请您根据孩子近1-3个月的实际表现回答。\n\n让我们先从情绪状态开始:\n\n问题1:面对挫折(如玩具损坏、考试失利、被批评),孩子是否难以平复情绪,会持续哭闹、赌气或沉默很久?",
"reply_id": "4832e106-9510-46f3-9b78-13830d31ac19",
"time": 1774864303,
"time_text": "2026-03-30 17:51:43"
},
{
"role": "user",
"content": "是的",
"reply_id": "",
"time": 1774864328,
"time_text": "2026-03-30 17:52:08"
},
{
"role": "assistant",
"content": "谢谢您的回答。请问:\n\n问题2:孩子是否容易烦躁、易怒,哪怕是小事(如饭菜不合口味、催促起床),也会表现出强烈的不耐烦?",
"reply_id": "0d96df05-883b-461f-b8fe-4f7376e28c48",
"time": 1774864328,
"time_text": "2026-03-30 17:52:08"
},
{
"role": "user",
"content": "是的",
"reply_id": "",
"time": 1774864419,
"time_text": "2026-03-30 17:53:39"
},
{
"role": "assistant",
"content": "好的,了解了。接下来我们了解孩子的学习状态:\n\n问题1:孩子是否会逃避学习任务,比如故意漏写作业、抄袭作业,或谎称作业已经完成?",
"reply_id": "851fcf04-27a7-4a47-a7c9-11173ea29aa5",
"time": 1774864419,
"time_text": "2026-03-30 17:53:39"
}
]
}
}
复制修改COZE_BOT_ID 、COZE_PAT_TOKEN即可使用
更多推荐




所有评论(0)