Files
chat/backend/app/service/OpenAIService.php
T
2026-07-22 10:18:59 +08:00

394 lines
13 KiB
PHP

<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
class OpenAIService
{
public static function getModel(?int $modelId = null): AiModel
{
if ($modelId) {
$model = AiModel::where('id', $modelId)->where('enabled', 1)->find();
} else {
$model = AiModel::where('is_default', 1)->where('enabled', 1)->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)->order('sort_order')->find();
}
if (!$model) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '未配置可用的 AI 模型',
'data' => null,
], 500));
}
return $model;
}
public static function getLanguageModel(?int $preferredModelId = null): AiModel
{
$model = null;
if ($preferredModelId) {
$model = AiModel::where('id', $preferredModelId)
->where('enabled', 1)
->where('provider', '<>', 'comfy')
->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)
->where('provider', '<>', 'comfy')
->order('is_default', 'desc')
->order('sort_order')
->find();
}
if (!$model) {
self::throwUnavailableModel('未配置可用的语言模型,Agent 暂时无法处理文本任务');
}
return $model;
}
public static function getImageModel(?int $preferredModelId = null): AiModel
{
$model = null;
if ($preferredModelId) {
$model = AiModel::where('id', $preferredModelId)
->where('enabled', 1)
->where('provider', 'comfy')
->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)
->where('provider', 'comfy')
->order('is_default', 'desc')
->order('sort_order')
->find();
}
if (!$model) {
self::throwUnavailableModel('未配置可用的图片生成模型,Agent 暂时无法生成图片');
}
return $model;
}
private static function throwUnavailableModel(string $message): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], 503));
}
/**
* frequency_penalty / presence_penalty 是 OpenAI 协议标准参数,vLLM/SGLang 等
* OpenAI 兼容服务通常都支持。部分自部署模型(尤其是 OCR/文档解析类模型)在纯文本
* 对话场景下容易陷入重复输出循环,通过这两个参数可以有效抑制。仅在管理员配置了
* 非零值时才带上,避免影响已经正常工作的模型。
*/
private static function penaltyParams(AiModel $model): array
{
$params = [];
$frequencyPenalty = (float) ($model->frequency_penalty ?? 0);
$presencePenalty = (float) ($model->presence_penalty ?? 0);
if ($frequencyPenalty !== 0.0) {
$params['frequency_penalty'] = $frequencyPenalty;
}
if ($presencePenalty !== 0.0) {
$params['presence_penalty'] = $presencePenalty;
}
return $params;
}
public static function streamChat(AiModel $model, array $messages, callable $onChunk, ?callable $onError = null): void
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = array_merge([
'model' => $model->model_id,
'messages' => $messages,
'stream' => true,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
], self::penaltyParams($model));
$errorBody = '';
$httpCode = 0;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$httpCode) {
if (preg_match('/^HTTP\/\d+\.\d+\s+(\d+)/', $header, $m)) {
$httpCode = (int) $m[1];
}
return strlen($header);
},
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$errorBody, &$httpCode, $onChunk) {
if ($httpCode >= 400) {
$errorBody .= $data;
return strlen($data);
}
$lines = explode("\n", $data);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line === 'data: [DONE]') {
continue;
}
if (str_starts_with($line, 'data: ')) {
$json = json_decode(substr($line, 6), true);
if ($json) {
if (!empty($json['error']['message'])) {
throw new \RuntimeException($json['error']['message']);
}
$onChunk($json);
}
}
}
return strlen($data);
},
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
try {
$result = curl_exec($ch);
if ($result === false) {
$message = 'AI 请求失败: ' . curl_error($ch);
if ($onError) {
$onError($message);
} else {
self::sseEvent('error', ['message' => $message]);
}
curl_close($ch);
return;
}
if ($httpCode >= 400) {
$detail = self::parseErrorBody($errorBody) ?: ('HTTP ' . $httpCode);
$message = 'AI 请求失败: ' . $detail;
if ($onError) {
$onError($message);
} else {
self::sseEvent('error', ['message' => $message]);
}
}
} catch (\Throwable $e) {
if ($onError) {
$onError($e->getMessage());
} else {
self::sseEvent('error', ['message' => $e->getMessage()]);
}
} finally {
curl_close($ch);
}
}
public static function chat(AiModel $model, array $messages): array
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = array_merge([
'model' => $model->model_id,
'messages' => $messages,
'stream' => false,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
], self::penaltyParams($model));
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 请求失败: ' . $detail,
'data' => null,
], 502));
}
$data = json_decode($response, true);
if (!$data) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 响应解析失败',
'data' => null,
], 502));
}
return $data;
}
/**
* 测试模型连接是否正常
* @return array{success: bool, latency_ms: int, reply: string, model: string}
*/
public static function testConnection(array $config): array
{
$apiBaseUrl = rtrim($config['api_base_url'] ?? '', '/');
$modelId = trim($config['model_id'] ?? '');
$apiKey = $config['api_key'] ?? '';
if (!$apiBaseUrl || !$modelId) {
throw new \InvalidArgumentException('请填写 API 地址和 Model ID');
}
$url = $apiBaseUrl . '/chat/completions';
$payload = [
'model' => $modelId,
'messages' => [
['role' => 'user', 'content' => '请只回复:测试成功'],
],
'stream' => false,
'max_tokens' => 32,
'temperature' => (float) ($config['temperature'] ?? 0.7),
];
$headers = ['Content-Type: application/json'];
if ($apiKey !== '') {
$headers[] = 'Authorization: Bearer ' . $apiKey;
}
$start = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$latencyMs = (int) round((microtime(true) - $start) * 1000);
if ($response === false) {
throw new \RuntimeException('连接失败: ' . ($curlError ?: '网络不可达'));
}
if ($httpCode !== 200) {
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
throw new \RuntimeException('API 返回错误: ' . $detail);
}
$data = json_decode($response, true);
if (!$data) {
throw new \RuntimeException('响应解析失败,请确认接口为 OpenAI 兼容格式');
}
$reply = self::extractMessageContent($data);
if ($reply === '') {
throw new \RuntimeException('接口连接成功,但未返回有效内容');
}
return [
'success' => true,
'latency_ms' => $latencyMs,
'reply' => $reply,
'model' => $modelId,
'tokens' => $data['usage']['total_tokens'] ?? null,
];
}
public static function extractStreamDelta(array $chunk): string
{
$choice = $chunk['choices'][0] ?? [];
$delta = $choice['delta'] ?? [];
$message = $choice['message'] ?? [];
$content = $delta['content']
?? $delta['reasoning_content']
?? $message['content']
?? '';
return is_string($content) ? $content : '';
}
public static function extractMessageContent(array $result): string
{
$choice = $result['choices'][0] ?? [];
$message = $choice['message'] ?? [];
$content = $message['content']
?? $message['reasoning_content']
?? $choice['text']
?? '';
return is_string($content) ? trim($content) : '';
}
public static function sseHeaders(): void
{
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
}
public static function sseEvent(string $event, mixed $data): void
{
// 用户关闭页面后仍继续后台等待,不再往已断开的连接写数据
if (connection_aborted()) {
return;
}
echo "event: {$event}\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level() > 0) {
@ob_flush();
}
@flush();
}
private static function parseErrorBody(?string $body): ?string
{
if (!$body) {
return null;
}
$json = json_decode($body, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json['error']['message'] ?? $json['message'] ?? null;
}
return trim($body) ?: null;
}
}