This commit is contained in:
Your Name
2026-05-29 09:28:38 +08:00
parent 7b46204454
commit 94e787ae80
11 changed files with 1834 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace app\common\service;
/**
* OpenAI 兼容 Chat Completions 客户端
*/
class AiChatService
{
public static function isEnabled(): bool
{
$cfg = config('ai') ?: [];
return !empty($cfg['enable']) && trim((string) ($cfg['api_key'] ?? '')) !== '';
}
/**
* @param array<int, array{role:string,content:string}> $messages
* @param array<string, mixed> $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 CompletionsOpenAI 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];
}
}