$messages * @param array $options * @return array{ok:bool,content?:string,error?:string,raw?:array} */ public static function chat(array $messages, array $options = []): array { $cfg = config('ai') ?: []; if (empty($cfg['enable'])) { return ['ok' => false, 'error' => 'AI 未启用']; } $apiKey = trim((string) ($cfg['api_key'] ?? '')); if ($apiKey === '') { return ['ok' => false, 'error' => 'AI 密钥未配置']; } $baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/'); $model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat')); $timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60)); $payload = [ 'model' => $model, 'messages' => $messages, 'temperature' => (float) ($options['temperature'] ?? 0.4), 'max_tokens' => (int) ($options['max_tokens'] ?? 1200), ]; if (!empty($options['response_format'])) { $payload['response_format'] = $options['response_format']; } $url = $baseUrl . '/v1/chat/completions'; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3)))); curl_setopt($ch, CURLOPT_TIMEOUT, max(5, $timeout)); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ]); $body = curl_exec($ch); $errno = curl_errno($ch); $err = curl_error($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { return ['ok' => false, 'error' => 'AI 请求失败:' . $err]; } $decoded = json_decode((string) $body, true); if ($code >= 400 || !is_array($decoded)) { $msg = is_array($decoded) ? ($decoded['error']['message'] ?? $decoded['message'] ?? 'AI 返回异常') : 'AI 返回异常'; return ['ok' => false, 'error' => (string) $msg, 'raw' => $decoded]; } $content = (string) ($decoded['choices'][0]['message']['content'] ?? ''); if ($content === '') { return ['ok' => false, 'error' => 'AI 未返回内容', 'raw' => $decoded]; } return ['ok' => true, 'content' => $content, 'raw' => $decoded]; } /** * 流式 Chat Completions(OpenAI SSE 格式) * * @param callable(string):void $onDelta 每收到一段文本回调 * @return array{ok:bool,content?:string,error?:string} */ public static function streamChat(array $messages, callable $onDelta, array $options = []): array { $cfg = config('ai') ?: []; if (empty($cfg['enable'])) { return ['ok' => false, 'error' => 'AI 未启用']; } $apiKey = trim((string) ($cfg['api_key'] ?? '')); if ($apiKey === '') { return ['ok' => false, 'error' => 'AI 密钥未配置']; } $baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/'); $model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat')); $timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60)); $payload = [ 'model' => $model, 'messages' => $messages, 'temperature' => (float) ($options['temperature'] ?? 0.4), 'max_tokens' => (int) ($options['max_tokens'] ?? 1200), 'stream' => true, ]; $lineBuffer = ''; $fullContent = ''; $writeFn = static function ($ch, string $chunk) use (&$lineBuffer, &$fullContent, $onDelta): int { $lineBuffer .= $chunk; while (($pos = strpos($lineBuffer, "\n")) !== false) { $line = rtrim(substr($lineBuffer, 0, $pos), "\r"); $lineBuffer = substr($lineBuffer, $pos + 1); if ($line === '' || strncmp($line, 'data:', 5) !== 0) { continue; } $data = trim(substr($line, 5)); if ($data === '' || $data === '[DONE]') { continue; } $json = json_decode($data, true); if (!is_array($json)) { continue; } $delta = (string) ($json['choices'][0]['delta']['content'] ?? ''); if ($delta === '') { continue; } $fullContent .= $delta; $onDelta($delta); } return strlen($chunk); }; $url = $baseUrl . '/v1/chat/completions'; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_WRITEFUNCTION, $writeFn); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3)))); curl_setopt($ch, CURLOPT_TIMEOUT, max(10, $timeout)); curl_setopt($ch, CURLOPT_TCP_NODELAY, true); curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, 'Accept: text/event-stream', ]); curl_exec($ch); $errno = curl_errno($ch); $err = curl_error($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { return ['ok' => false, 'error' => 'AI 请求失败:' . $err]; } if ($code >= 400) { return ['ok' => false, 'error' => 'AI 返回异常(HTTP ' . $code . ')']; } if ($fullContent === '') { return ['ok' => false, 'error' => 'AI 未返回内容']; } return ['ok' => true, 'content' => $fullContent]; } }