Files
zyt/server/app/common/service/AiChatService.php
T
2026-05-29 09:28:38 +08:00

182 lines
6.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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];
}
}