如果想在小程序内嵌入一个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即可使用

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐