| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- <?php
- namespace app\common\library\ps;
- use think\Config;
- use think\Exception;
- /**
- * 大模型客户端。本地 Ollama 做简单任务,线上 OpenAI 兼容接口做拆版。
- */
- class LlmClient
- {
- protected $profile;
- protected $driver;
- protected $baseUrl;
- protected $apiKey;
- protected $timeout;
- protected $textModel;
- protected $visionModel;
- protected $visionModelComplex;
- protected $temperature;
- protected $onlineEnable;
- protected $forceEnable;
- /**
- * @param array $config profile=local|online,可覆盖 base_url / api_key / 模型名
- */
- public function __construct(array $config = [])
- {
- $file = Config::get('llm') ?: [];
- $site = Config::get('site') ?: [];
- $this->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('/<think>[\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('/<think>[\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;
- }
- }
|