profile = ($config['profile'] ?? 'local') === 'online' ? 'online' : 'local'; $this->forceEnable = !empty($config['force']); $this->temperature = (float)($config['temperature'] ?? $file['temperature'] ?? 0.1); if ($this->profile === 'online') { $this->driver = 'openai'; $this->onlineEnable = self::isOn($config['online_enable'] ?? $site['llm_online_enable'] ?? $file['online_enable'] ?? '0'); $this->baseUrl = rtrim((string)($config['base_url'] ?? self::siteOr($site, 'llm_online_base_url', $file['online_base_url'] ?? '')), '/'); $this->apiKey = (string)($config['api_key'] ?? self::siteOr($site, 'llm_online_api_key', $file['online_api_key'] ?? '')); $this->timeout = (int)($config['timeout'] ?? self::siteOr($site, 'llm_online_timeout', $file['online_timeout'] ?? 180)); $this->textModel = (string)($config['text_model'] ?? self::siteOr($site, 'llm_online_model', $file['online_model'] ?? 'qwen-plus')); $this->visionModel = (string)($config['vision_model'] ?? self::siteOr($site, 'llm_online_vision_model', $file['online_vision_model'] ?? 'qwen-vl-max')); $this->visionModelComplex = $this->visionModel; if ($this->timeout < 30) { $this->timeout = 30; } return; } $this->driver = 'ollama'; $this->onlineEnable = false; $this->baseUrl = rtrim((string)($config['base_url'] ?? self::siteOr($site, 'llm_base_url', $file['base_url'] ?? 'http://127.0.0.1:11434')), '/'); $this->apiKey = (string)($config['api_key'] ?? self::siteOr($site, 'llm_api_key', $file['api_key'] ?? '')); $this->timeout = (int)($config['timeout'] ?? self::siteOr($site, 'llm_timeout', $file['timeout'] ?? 300)); $this->textModel = (string)($config['text_model'] ?? self::siteOr($site, 'llm_text_model', $file['text_model'] ?? 'qwen2.5:7b-instruct')); $this->visionModel = (string)($config['vision_model'] ?? self::siteOr($site, 'llm_vision_model', $file['vision_model'] ?? 'minicpm-v4.6')); $this->visionModelComplex = (string)($config['vision_model_complex'] ?? self::siteOr($site, 'llm_vision_complex', $file['vision_model_complex'] ?? 'llama3.2-vision:11b')); } /** * 本地 Ollama,简单文本 / 备用识版。 * @return self */ public static function local() { return new self(['profile' => 'local']); } /** * 线上 OpenAI 兼容接口。 * @return self */ public static function online() { return new self(['profile' => 'online']); } /** * 拆版:线上已启用则走线上,否则本地。 * @return self */ public static function forRecognize() { $online = self::online(); return $online->enabled() ? $online : self::local(); } /** * @return bool */ public function enabled() { if ($this->profile === 'online') { if (!$this->forceEnable && !$this->onlineEnable) { return false; } return $this->baseUrl !== '' && $this->apiKey !== '' && $this->visionModel !== ''; } return $this->baseUrl !== ''; } /** * @return string local|online */ public function profile() { return $this->profile; } /** * @param array $site * @param string $key * @param mixed $default * @return mixed */ protected static function siteOr(array $site, $key, $default) { if (isset($site[$key]) && $site[$key] !== '' && $site[$key] !== null) { return $site[$key]; } return $default; } /** * @param mixed $v * @return bool */ protected static function isOn($v) { return $v === 1 || $v === '1' || $v === true || $v === 'true' || $v === 'on'; } /** * 列出本地已安装模型(Ollama /api/tags) * @return array */ public function listModels() { if ($this->driver !== 'ollama') { return $this->listOnlineModels(); } $result = $this->httpJson('GET', $this->baseUrl . '/api/tags', null, (int)Config::get('llm.list_timeout') ?: 8); $names = []; foreach ($result['models'] ?? [] as $item) { if (!empty($item['name'])) { $names[] = $item['name']; } } return $names; } /** * @return array */ protected function listOnlineModels() { if ($this->baseUrl === '' || $this->apiKey === '') { return []; } try { $result = $this->httpJson('GET', $this->openaiRoot() . '/models', null, 15, true); } catch (\Exception $e) { return []; } $names = []; foreach ($result['data'] ?? [] as $item) { if (!empty($item['id'])) { $names[] = $item['id']; } } return $names; } /** * @return string */ public function textModel() { return $this->textModel; } /** * @return string */ public function visionModel() { return $this->visionModel; } /** * @return string */ public function visionModelComplex() { return $this->visionModelComplex; } /** * 指定单个视觉模型。CSV 识版不要开 JSON 模式,弱模型会去抄示例 JSON。 * @param string $prompt * @param string $imagePath * @param string $model * @param bool $jsonMode * @return string */ public function visionRaw($prompt, $imagePath, $model, $jsonMode = false) { $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], $jsonMode); if (trim((string)$content) === '' && $jsonMode) { $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], false); } if (is_array($content)) { $content = json_encode($content, JSON_UNESCAPED_UNICODE); } return trim((string)$content); } /** * 按文件类型给出默认模型:图片用视觉,PDF 用文本。 * @param string $fileType image|pdf * @return string */ public function defaultModel($fileType = 'image') { return $fileType === 'pdf' ? $this->textModel : $this->visionModel; } /** * 后台下拉:线上识版优先,本地模型保留作简单任务。 * @return array name => label */ public function formOptions() { $options = []; $online = $this->profile === 'online' ? $this : self::online(); $local = $this->profile === 'local' ? $this : self::local(); if ($online->enabled()) { if ($online->visionModel) { $options[$online->visionModel] = $online->visionModel . '(线上识版,推荐)'; } if ($online->textModel && !isset($options[$online->textModel])) { $options[$online->textModel] = $online->textModel . '(线上文本)'; } } if ($local->visionModel) { $key = $local->visionModel; if (!isset($options[$key])) { $options[$key] = $key . '(本地视觉,简单/备用)'; } } if ($local->visionModelComplex && !isset($options[$local->visionModelComplex])) { $options[$local->visionModelComplex] = $local->visionModelComplex . '(本地视觉备用)'; } if ($local->textModel && !isset($options[$local->textModel])) { $options[$local->textModel] = $local->textModel . '(本地文本,简单任务)'; } try { foreach ($local->listModels() as $name) { if ($name && !isset($options[$name])) { $options[$name] = $name . '(本地)'; } } } catch (\Exception $e) { // Ollama 未启动时仍展示已配置项 } return $options; } /** * 是否为本地 Ollama 模型名(含冒号标签)。 * @param string $name * @return bool */ public static function isLocalModelName($name) { $name = trim((string)$name); if ($name === '') { return false; } $local = self::local(); $known = [$local->textModel(), $local->visionModel(), $local->visionModelComplex()]; if (in_array($name, $known, true)) { return true; } return strpos($name, ':') !== false; } /** * 文本对话 * @param string $prompt * @param string|null $model * @return string */ public function chat($prompt, $model = null, $jsonMode = false) { return $this->requestChat($model ?: $this->textModel, $prompt, [], $jsonMode); } /** * 视觉识版。优先用指定模型,失败时回退复杂视觉模型。 * @param string $prompt * @param string $imagePath 本地文件路径 * @param string|null $preferredModel * @return array {content, model} */ public function vision($prompt, $imagePath, $preferredModel = null) { $models = []; if ($preferredModel) { $models[] = $preferredModel; } if ($this->visionModel && !in_array($this->visionModel, $models, true)) { $models[] = $this->visionModel; } if ($this->visionModelComplex && !in_array($this->visionModelComplex, $models, true)) { $models[] = $this->visionModelComplex; } if (!$models) { $models[] = $this->visionModel; } $lastError = null; foreach ($models as $model) { try { $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], true); if (trim((string)$content) === '') { $content = $this->requestChat($model, $prompt, [$this->encodeImage($imagePath)], false); } if (trim((string)$content) !== '') { return ['content' => $content, 'model' => $model]; } } catch (\Exception $e) { $lastError = $e; if (!$this->isModelLoadError($e)) { throw $e; } } } throw $lastError ?: new Exception('视觉模型没有返回内容,请再试一次'); } /** * 从模型输出中抽取 JSON * @param string $text * @return array */ public static function extractJson($text) { $text = trim((string)$text); $text = preg_replace('/[\s\S]*?<\/think>/i', '', $text); $text = preg_replace('/^```(?:json)?\s*/i', '', $text); $text = preg_replace('/\s*```$/', '', $text); $text = trim($text); if ($text === '') { throw new Exception('识版模型没有返回内容,请再点一次「重新 AI 拆版」'); } $candidates = [$text]; if (preg_match('/\{[\s\S]*\}/', $text, $m)) { $candidates[] = $m[0]; } foreach ($candidates as $candidate) { $decoded = self::decodeLooseJson($candidate); if (is_array($decoded)) { return $decoded; } } $salvaged = self::salvageJson($text); if (is_array($salvaged)) { return $salvaged; } throw new Exception('识版结果不完整,请再点一次「重新 AI 拆版」'); } /** * @param string $text * @return array|null */ protected static function decodeLooseJson($text) { $text = trim((string)$text); if ($text === '') { return null; } $decoded = json_decode($text, true); if (is_array($decoded)) { return $decoded; } $text = preg_replace('/,\s*([}\]])/', '$1', $text); $text = preg_replace('/"([A-Za-z_][A-Za-z0-9_]*)"\s*([}\]])/', '"$1":0$2', $text); $text = preg_replace('/":\s*([}\],])/', ':0$1', $text); $decoded = json_decode($text, true); return is_array($decoded) ? $decoded : null; } /** * 截断的 JSON 尽量补全到最后一个完整对象。 * @param string $text * @return array|null */ public static function salvageJson($text) { $start = strpos($text, '{'); if ($start === false) { $start = strpos($text, '['); } if ($start === false) { return null; } $json = substr($text, $start); $stack = []; $inStr = false; $esc = false; $lastComplete = -1; $stackAtComplete = []; $len = strlen($json); for ($i = 0; $i < $len; $i++) { $ch = $json[$i]; if ($inStr) { if ($esc) { $esc = false; continue; } if ($ch === '\\') { $esc = true; continue; } if ($ch === '"') { $inStr = false; } continue; } if ($ch === '"') { $inStr = true; continue; } if ($ch === '{' || $ch === '[') { $stack[] = $ch; continue; } if ($ch === '}' || $ch === ']') { if ($stack) { array_pop($stack); } $lastComplete = $i; $stackAtComplete = $stack; if (!$stack) { $decoded = json_decode(substr($json, 0, $i + 1), true); if (is_array($decoded)) { return $decoded; } } } } if ($lastComplete < 0) { return null; } $slice = rtrim(substr($json, 0, $lastComplete + 1)); $slice = preg_replace('/,\s*$/', '', $slice); $close = array_reverse($stackAtComplete); foreach ($close as $open) { $slice .= $open === '{' ? '}' : ']'; } $slice = preg_replace('/,\s*([}\]])/', '$1', $slice); $decoded = self::decodeLooseJson($slice); return is_array($decoded) ? $decoded : null; } /** * @param string $model * @param string $prompt * @param array $images base64 列表 * @return string */ protected function requestChat($model, $prompt, array $images, $jsonMode = false) { if ($this->driver === 'openai') { return $this->openaiChat($model, $prompt, $images, $jsonMode); } return $this->ollamaChat($model, $prompt, $images, $jsonMode); } protected function ollamaChat($model, $prompt, array $images, $jsonMode = false) { $message = [ 'role' => 'user', 'content' => $prompt, ]; if ($images) { $message['images'] = $images; } $payload = [ 'model' => $model, 'messages' => [$message], 'stream' => false, 'options' => [ 'temperature' => $this->temperature, 'num_predict' => 8192, ], ]; if ($jsonMode) { $payload['format'] = 'json'; } try { $result = $this->httpJson('POST', $this->baseUrl . '/api/chat', $payload, $this->timeout); } catch (\Exception $e) { if ($jsonMode && (strpos($e->getMessage(), 'HTTP 400') !== false || strpos($e->getMessage(), 'format') !== false)) { unset($payload['format']); $result = $this->httpJson('POST', $this->baseUrl . '/api/chat', $payload, $this->timeout); } else { throw $e; } } $content = $result['message']['content'] ?? ($result['response'] ?? ''); if (is_array($content)) { $content = json_encode($content, JSON_UNESCAPED_UNICODE); } $content = preg_replace('/[\s\S]*?<\/think>/i', '', (string)$content); return trim($content); } /** * @return string */ public function openaiRoot() { $base = rtrim($this->baseUrl, '/'); if ($base === '') { return ''; } if (preg_match('#/v\d+$#', $base) || preg_match('#/compatible-mode/v\d+$#', $base)) { return $base; } return $base . '/v1'; } protected function openaiChat($model, $prompt, array $images, $jsonMode = false) { $content = [['type' => 'text', 'text' => $prompt]]; foreach ($images as $b64) { $content[] = [ 'type' => 'image_url', 'image_url' => ['url' => 'data:image/jpeg;base64,' . $b64], ]; } $payload = [ 'model' => $model, 'temperature' => $this->temperature, 'max_tokens' => 8192, 'enable_thinking' => false, 'messages' => [ ['role' => 'user', 'content' => $images ? $content : $prompt], ], ]; if ($jsonMode) { $payload['response_format'] = ['type' => 'json_object']; } $url = $this->openaiRoot() . '/chat/completions'; try { $result = $this->httpJson('POST', $url, $payload, $this->timeout, true); } catch (\Exception $e) { if ($jsonMode && (strpos($e->getMessage(), 'HTTP 400') !== false || stripos($e->getMessage(), 'response_format') !== false || stripos($e->getMessage(), 'json_object') !== false || stripos($e->getMessage(), 'enable_thinking') !== false)) { unset($payload['response_format'], $payload['enable_thinking']); $result = $this->httpJson('POST', $url, $payload, $this->timeout, true); } else { throw $e; } } if (!empty($result['error'])) { $msg = $result['error']['message'] ?? json_encode($result['error'], JSON_UNESCAPED_UNICODE); throw new Exception('线上模型:' . mb_substr((string)$msg, 0, 300)); } $text = $result['choices'][0]['message']['content'] ?? ''; if (is_array($text)) { $parts = []; foreach ($text as $part) { if (is_string($part)) { $parts[] = $part; } elseif (is_array($part) && isset($part['text'])) { $parts[] = $part['text']; } } $text = implode('', $parts); } return trim((string)$text); } protected function encodeImage($path) { if (!is_file($path)) { throw new Exception('识版文件不存在: ' . $path); } $data = file_get_contents($path); if ($data === false || $data === '') { throw new Exception('识版文件无法读取'); } if (function_exists('imagecreatefromstring')) { $img = @imagecreatefromstring($data); if ($img) { $w = imagesx($img); $h = imagesy($img); $max = $this->driver === 'openai' ? 1280 : 1600; if ($w > $max || $h > $max || strlen($data) > 800 * 1024 || $this->driver === 'openai') { $scale = min(1, $max / max($w, $h)); $nw = max(1, (int)($w * $scale)); $nh = max(1, (int)($h * $scale)); $dst = imagecreatetruecolor($nw, $nh); imagecopyresampled($dst, $img, 0, 0, 0, 0, $nw, $nh, $w, $h); ob_start(); imagejpeg($dst, null, 82); $data = ob_get_clean(); imagedestroy($dst); } imagedestroy($img); } } return base64_encode($data); } protected function isModelLoadError(\Exception $e) { $msg = strtolower($e->getMessage()); return strpos($msg, 'mllama') !== false || strpos($msg, 'unknown model architecture') !== false || strpos($msg, 'status code: 500') !== false; } /** * @param string $method * @param string $url * @param array|null $payload * @param int $timeout * @param bool $useBearer * @return array */ protected function httpJson($method, $url, $payload, $timeout, $useBearer = false) { $ch = curl_init($url); $headers = ['Content-Type: application/json']; if (($useBearer || $this->driver === 'openai') && $this->apiKey) { $headers[] = 'Authorization: Bearer ' . $this->apiKey; } $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => $this->driver === 'openai' ? 15 : 5, ]; $ca = CONF_PATH . 'extra' . DS . 'cacert.pem'; if (is_file($ca)) { $opts[CURLOPT_CAINFO] = $ca; } if ($payload !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_UNESCAPED_UNICODE); } curl_setopt_array($ch, $opts); $body = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($errno) { throw new Exception('大模型请求失败(' . $this->baseUrl . '): ' . $error); } if ($code >= 400) { throw new Exception('大模型 HTTP ' . $code . ': ' . mb_substr((string)$body, 0, 300)); } $decoded = json_decode((string)$body, true); if (!is_array($decoded)) { throw new Exception('大模型返回非 JSON'); } return $decoded; } }