523 lines
18 KiB
PHP
523 lines
18 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;
|
|
}
|
|
|
|
/**
|
|
* 语音合成必须使用 OpenAI 兼容协议模型。Dify/ComfyUI 的 API 地址不提供
|
|
* /audio/speech,因此所选对话模型不兼容时自动回落到已启用的 OpenAI 模型。
|
|
*/
|
|
public static function getSpeechModel(?int $preferredModelId = null): AiModel
|
|
{
|
|
$model = null;
|
|
if ($preferredModelId) {
|
|
$model = AiModel::where('id', $preferredModelId)
|
|
->where('enabled', 1)
|
|
->where('provider', 'openai')
|
|
->find();
|
|
}
|
|
|
|
if (!$model) {
|
|
$model = AiModel::where('enabled', 1)
|
|
->where('provider', 'openai')
|
|
->order('is_default', 'desc')
|
|
->order('sort_order')
|
|
->find();
|
|
}
|
|
|
|
if (!$model) {
|
|
self::throwUnavailableModel('未配置支持语音合成的 OpenAI 兼容模型');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 使用神经语音模型生成短句 WAV。短句由前端在文本流式输出期间提前提交,
|
|
* WAV 则避免浏览器额外的解码启动开销。
|
|
*
|
|
* @return array{audio: string, content_type: string}
|
|
*/
|
|
public static function speech(AiModel $model, string $input, string $voice = 'marin'): array
|
|
{
|
|
$extra = is_array($model->extra_config ?? null) ? $model->extra_config : [];
|
|
$ttsModel = trim((string) ($extra['tts_model'] ?? 'gpt-4o-mini-tts'));
|
|
$ttsVoice = trim((string) ($extra['tts_voice'] ?? $voice));
|
|
$instructions = trim((string) ($extra['tts_instructions'] ?? (
|
|
'Speak in natural, warm, conversational Mandarin Chinese. '
|
|
. 'Use relaxed pacing, subtle emotion, human-like phrasing and short natural pauses. '
|
|
. 'Avoid an announcer, customer-service, robotic, or overly enthusiastic tone.'
|
|
)));
|
|
|
|
$allowedVoices = [
|
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
|
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
|
];
|
|
if (!in_array($ttsVoice, $allowedVoices, true)) {
|
|
$ttsVoice = 'marin';
|
|
}
|
|
if ($ttsModel === '') {
|
|
$ttsModel = 'gpt-4o-mini-tts';
|
|
}
|
|
|
|
$legacyTts = in_array($ttsModel, ['tts-1', 'tts-1-hd'], true);
|
|
if ($legacyTts && !in_array($ttsVoice, ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], true)) {
|
|
$ttsVoice = 'nova';
|
|
}
|
|
|
|
$url = rtrim((string) $model->api_base_url, '/') . '/audio/speech';
|
|
$payload = [
|
|
'model' => $ttsModel,
|
|
'input' => $input,
|
|
'voice' => $ttsVoice,
|
|
'response_format' => 'wav',
|
|
];
|
|
if (!$legacyTts && $instructions !== '') {
|
|
$payload['instructions'] = $instructions;
|
|
}
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Accept: audio/wav',
|
|
'Authorization: Bearer ' . $model->api_key,
|
|
],
|
|
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);
|
|
$contentType = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'audio/wav');
|
|
$curlError = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($response === false) {
|
|
throw new HttpResponseException(json([
|
|
'code' => 1,
|
|
'message' => '语音合成连接失败: ' . ($curlError ?: '网络不可达'),
|
|
'data' => null,
|
|
], 502));
|
|
}
|
|
|
|
if ($httpCode < 200 || $httpCode >= 300) {
|
|
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
|
throw new HttpResponseException(json([
|
|
'code' => 1,
|
|
'message' => '自然语音生成失败: ' . $detail,
|
|
'data' => null,
|
|
], 502));
|
|
}
|
|
|
|
if ($response === '') {
|
|
throw new HttpResponseException(json([
|
|
'code' => 1,
|
|
'message' => '语音服务返回了空音频',
|
|
'data' => null,
|
|
], 502));
|
|
}
|
|
|
|
if (!str_starts_with(strtolower($contentType), 'audio/')) {
|
|
$contentType = 'audio/wav';
|
|
}
|
|
|
|
return [
|
|
'audio' => $response,
|
|
'content_type' => $contentType,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 测试模型连接是否正常
|
|
* @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;
|
|
}
|
|
}
|