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; } }