182 lines
6.8 KiB
PHP
182 lines
6.8 KiB
PHP
<?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 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];
|
||
}
|
||
}
|