diff --git a/backend/app/adminapi/controller/ai/AiModelController.php b/backend/app/adminapi/controller/ai/AiModelController.php index d45b665d5..968eb5071 100644 --- a/backend/app/adminapi/controller/ai/AiModelController.php +++ b/backend/app/adminapi/controller/ai/AiModelController.php @@ -81,6 +81,7 @@ class AiModelController extends AuthController ['full_url', 0], ['supports_tools', 0], ['supports_vision', 0], + ['supports_embedding', 0], ['context_window', 0], ['max_tokens', 2048], ['temperature', 0.7], @@ -108,6 +109,7 @@ class AiModelController extends AuthController ['full_url', 0], ['supports_tools', 0], ['supports_vision', 0], + ['supports_embedding', 0], ['context_window', 0], ['max_tokens', 2048], ['temperature', 0.7], @@ -198,6 +200,7 @@ class AiModelController extends AuthController ['full_url', 0], ['supports_tools', 0], ['supports_vision', 0], + ['supports_embedding', 0], ['context_window', 0], ['max_tokens', 2048], ['temperature', 0.7], @@ -207,4 +210,19 @@ class AiModelController extends AuthController ]); return app('json')->success($this->services->testModel($data)); } + + /** + * 拉取厂商可用模型列表(按表单配置请求 /models 端点,不落库) + * @return mixed + */ + public function remoteModels() + { + $data = $this->request->postMore([ + ['id', 0], + ['base_url', ''], + ['api_key', ''], + ['full_url', 0], + ]); + return app('json')->success($this->services->fetchRemoteModels($data)); + } } diff --git a/backend/app/adminapi/route/ai.php b/backend/app/adminapi/route/ai.php index a4ad2ef43..6511da77b 100644 --- a/backend/app/adminapi/route/ai.php +++ b/backend/app/adminapi/route/ai.php @@ -37,6 +37,7 @@ Route::group('ai', function () { Route::put('model/set_default/:id', [\app\adminapi\controller\ai\AiModelController::class, 'setDefault'])->option(['real_name' => '设置默认AI模型']); Route::put('model/sort', [\app\adminapi\controller\ai\AiModelController::class, 'sort'])->option(['real_name' => 'AI模型排序']); Route::post('model/test', [\app\adminapi\controller\ai\AiModelController::class, 'test'])->option(['real_name' => 'AI模型连通性测试']); + Route::post('model/remote_models', [\app\adminapi\controller\ai\AiModelController::class, 'remoteModels'])->option(['real_name' => '拉取厂商可用模型列表']); })->option(['parent' => 'ai', 'cate_name' => 'AI模型管理']); })->middleware([ diff --git a/backend/app/services/ai/AiModelServices.php b/backend/app/services/ai/AiModelServices.php index ed11c9c1e..d018c78d8 100644 --- a/backend/app/services/ai/AiModelServices.php +++ b/backend/app/services/ai/AiModelServices.php @@ -17,8 +17,9 @@ use crmeb\basic\BaseService; use crmeb\exceptions\AdminException; use crmeb\services\ai\AiInterface; use crmeb\services\ai\AiProtocol; +use crmeb\services\ai\EmbeddingAiInterface; +use crmeb\utils\Http; use crmeb\utils\SsrfGuard; - /** * AI 模型管理服务(系统内置 AI 能力) * @@ -192,7 +193,10 @@ class AiModelServices extends BaseService } /** - * 模型连通性测试:按表单配置构造协议适配器发起一次真实对话请求(不落库) + * 模型连通性测试:按表单配置构造协议适配器发起一次真实请求(不落库) + * + * 按模型能力分流:仅勾选「支持文本向量化」的纯向量模型没有对话端点, + * 改用 /embeddings 端点真实测试,其余模型走对话接口。 * @param array $data 表单数据(与保存表单一致,编辑时需携带 id 以继承原密钥) * @return array [ok=>1|0, cost_ms=>int, message=>string] */ @@ -212,17 +216,42 @@ class AiModelServices extends BaseService $payload = $this->buildData($data, $id); $driver = AiProtocol::make($payload['protocol'], $this->buildConfig($payload)); + // 纯向量模型:仅勾选向量化、未勾选工具调用/视觉(向量模型调 /chat/completions 会被厂商直接拒绝) + $embeddingOnly = (int) ($payload['supports_embedding'] ?? 0) === 1 + && (int) ($payload['supports_tools'] ?? 0) !== 1 + && (int) ($payload['supports_vision'] ?? 0) !== 1; $start = microtime(true); try { - $result = $driver->chat('连通性测试,请直接回复:ok', ['stream' => 0]); + if ($embeddingOnly) { + if (!$driver instanceof EmbeddingAiInterface) { + throw new \RuntimeException('该模型协议不支持文本向量化,请检查模型能力配置'); + } + $driver->embeddings(['连通性测试']); + } else { + $result = $driver->chat('连通性测试,请直接回复:ok', ['stream' => 0]); + } } catch (\Throwable $e) { + $message = $e->getMessage(); + // 向量模型因未勾选向量化能力走了对话端点被厂商拒绝时,给出可操作的勾选建议 + if (!$embeddingOnly + && (int) ($payload['supports_embedding'] ?? 0) !== 1 + && str_contains(strtolower((string) ($payload['model'] ?? '')), 'embedding')) { + $message .= ';检测到模型 ID 含有「embedding」,请先在高级配置勾选「支持文本向量化」再测试'; + } return [ 'ok' => 0, 'cost_ms' => (int) round((microtime(true) - $start) * 1000), - 'message' => '连通性测试失败:' . $e->getMessage(), + 'message' => '连通性测试失败:' . $message, ]; } $costMs = (int) round((microtime(true) - $start) * 1000); + if ($embeddingOnly) { + return [ + 'ok' => 1, + 'cost_ms' => $costMs, + 'message' => '连通性测试成功,向量接口正常响应(耗时 ' . $costMs . 'ms)', + ]; + } $content = trim((string) ($result['content'] ?? '')); return [ 'ok' => 1, @@ -233,6 +262,107 @@ class AiModelServices extends BaseService ]; } + /** + * 拉取厂商可用模型列表(OpenAI 兼容 /models 端点,不落库) + * + * 供新增/编辑弹窗「获取模型」按钮调用:按表单填写的接口地址与密钥请求 /models, + * 返回模型标识列表,前端拿到后把「模型 ID」切换为下拉选择。 + * @param array $data 表单数据(base_url/api_key/full_url,编辑时需携带 id 以继承原密钥) + * @return array 模型标识一维数组(去重升序) + */ + public function fetchRemoteModels(array $data): array + { + $id = (int) ($data['id'] ?? 0); + $old = $id > 0 ? $this->dao->findById($id) : null; + if ($old) { + $data = array_merge($old, $data); + // 编辑场景密钥回传占位符或留空时,使用原密钥真实拉取 + if (in_array(trim((string) ($data['api_key'] ?? '')), ['', self::SECRET_KEEP], true)) { + $data['api_key'] = (string) $old['api_key']; + } + } + + $baseUrl = trim((string) ($data['base_url'] ?? '')); + if (!preg_match('#^https?://#i', $baseUrl)) { + throw new AdminException('请先填写以 http:// 或 https:// 开头的接口地址'); + } + // 完整 URL 模式没有「网关根地址」概念,无法推导 /models 端点 + if ((int) ($data['full_url'] ?? 0) === 1) { + throw new AdminException('完整 URL 模式无法自动获取模型列表,请手动输入模型 ID'); + } + $apiKey = trim((string) ($data['api_key'] ?? '')); + if ($apiKey === '') { + throw new AdminException('请先填写 API 密钥'); + } + // 出站 SSRF 防护:与模型保存同口径(「允许内网 AI 服务」开关打开时放行内网自建模型) + try { + SsrfGuard::assertSafeUrl($baseUrl, $this->privateEndpointAllowed() ? SsrfGuard::MODE_SELFCALL : SsrfGuard::MODE_STRICT); + } catch (\InvalidArgumentException $e) { + throw new AdminException('接口地址不安全:' . $e->getMessage()); + } + + // 地址可能被填成对话端点(…/chat/completions),先剥掉再拼 /models + if (str_ends_with($baseUrl, '/chat/completions')) { + $baseUrl = substr($baseUrl, 0, -strlen('/chat/completions')); + } + $url = rtrim($baseUrl, '/') . '/models'; + + // 原生 curl 而非 Http::getRequest:4xx(密钥错误等)响应体里有厂商的明确错误信息,需要取出来提示 + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + $body = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($body === false) { + throw new AdminException('获取模型列表失败:' . ($curlError ?: '网络异常,请稍后重试')); + } + $result = json_decode((string) $body, true); + if (!is_array($result)) { + throw new AdminException($httpCode > 0 ? '获取模型列表失败:HTTP ' . $httpCode : '获取模型列表失败:响应解析失败'); + } + if ($httpCode >= 400 || isset($result['error'])) { + throw new AdminException('获取模型列表失败:' . $this->remoteModelsErrorMessage($result, $httpCode)); + } + $models = []; + foreach ((array) ($result['data'] ?? []) as $item) { + $modelId = trim((string) ($item['id'] ?? '')); + if ($modelId !== '') { + $models[] = $modelId; + } + } + $models = array_values(array_unique($models)); + sort($models); + return $models; + } + + /** + * 从拉取模型列表的错误响应中提取可读消息 + * @param array $result 已解析的 JSON 响应 + * @param int $httpCode HTTP 状态码 + * @return string + */ + protected function remoteModelsErrorMessage(array $result, int $httpCode): string + { + $error = $result['error'] ?? null; + $message = is_array($error) ? (string) ($error['message'] ?? '') : (string) $error; + if ($message === '') { + $message = (string) ($result['message'] ?? ($result['msg'] ?? '')); + } + if ($message === '') { + $message = 'HTTP ' . ($httpCode > 0 ? $httpCode : '请求异常'); + } + return mb_substr(trim($message), 0, 300); + } + /** * 可用模型清单(下拉选择用,不含密钥) * @param bool $onlyEnabled 仅返回启用中的模型 @@ -251,6 +381,8 @@ class AiModelServices extends BaseService 'provider' => (string) $item['provider'], 'model' => (string) $item['model'], 'supports_tools' => (int) $item['supports_tools'], + 'supports_vision' => (int) $item['supports_vision'], + 'supports_embedding' => (int) ($item['supports_embedding'] ?? 0), 'is_default' => (int) $item['is_default'], 'status' => (int) $item['status'], ]; @@ -299,6 +431,29 @@ class AiModelServices extends BaseService return $modelId > 0 ? $this->getHandler($modelId) : $this->getYihaotongHandler(); } + /** + * 向量模型适配器解析:显式指定模型走登记模型(须勾选向量化能力),缺省走一号通全局凭证 + * + * 与 getChatHandler 同口径:model_id 为 0 时不依赖登记记录, + * 直接使用一号通全局凭证 + 平台默认向量模型,保证向量化能力开箱可用。 + * @param int $modelId 向量模型ID,传 0 使用内置一号通向量模型 + * @return EmbeddingAiInterface + */ + public function getEmbeddingHandler(int $modelId = 0): EmbeddingAiInterface + { + $handler = $modelId > 0 ? $this->getHandler($modelId) : $this->getYihaotongHandler(); + if (!$handler instanceof EmbeddingAiInterface) { + throw new AdminException('该模型协议不支持文本向量化,请更换向量模型或在模型管理中勾选「支持文本向量化」'); + } + if ($modelId > 0) { + $model = $this->dao->findById($modelId); + if ((int) ($model['supports_embedding'] ?? 0) !== 1) { + throw new AdminException('模型[' . ($model['name'] ?? '') . ']未勾选「支持文本向量化」,请先在「AI Agent → 模型管理」中勾选'); + } + } + return $handler; + } + /** * 创建内置一号通 AI 适配器 * @@ -408,6 +563,7 @@ class AiModelServices extends BaseService 'full_url' => (int) ($data['full_url'] ?? 0) === 1 ? 1 : 0, 'supports_tools' => (int) ($data['supports_tools'] ?? 0) === 1 ? 1 : 0, 'supports_vision' => (int) ($data['supports_vision'] ?? 0) === 1 ? 1 : 0, + 'supports_embedding' => (int) ($data['supports_embedding'] ?? 0) === 1 ? 1 : 0, 'context_window' => max(0, (int) ($data['context_window'] ?? 0)), 'max_tokens' => max(0, (int) ($data['max_tokens'] ?? 2048)), 'temperature' => $temperature, @@ -449,6 +605,7 @@ class AiModelServices extends BaseService $item['full_url'] = (int) ($item['full_url'] ?? 0); $item['supports_tools'] = (int) $item['supports_tools']; $item['supports_vision'] = (int) $item['supports_vision']; + $item['supports_embedding'] = (int) ($item['supports_embedding'] ?? 0); $item['is_default'] = (int) $item['is_default']; $item['status'] = (int) $item['status']; $item['sort'] = (int) $item['sort']; diff --git a/backend/crmeb/services/ai/EmbeddingAiInterface.php b/backend/crmeb/services/ai/EmbeddingAiInterface.php new file mode 100644 index 000000000..da6d453e9 --- /dev/null +++ b/backend/crmeb/services/ai/EmbeddingAiInterface.php @@ -0,0 +1,22 @@ +encodeRequestBody($data), $headers, $this->timeout()); if ($response === false) { - throw new \RuntimeException('AI接口请求失败:' . (Http::getCurlError() ?: '网络异常,请稍后重试')); + // 按 HTTP 状态码区分网络错误与端点错误(非 200 同样返回 false),提示更可定位 + throw new \RuntimeException('AI接口请求失败:' . $this->requestFailReason()); } $result = json_decode($response, true); @@ -164,6 +166,59 @@ class OpenAiCompatible extends BaseProtocol implements StreamAiInterface } } + /** + * 文本向量化 + * + * 调用 OpenAI 兼容 /embeddings 端点(通义 text-embedding-v4、智谱 embedding-3、 + * 硅基流动 BAAI/bge-m3 及 vLLM/Ollama 自建网关均兼容该端点), + * 响应 data 按 index 排序后提取 embedding,保证与输入顺序一一对应。 + * + * @param array $inputs 待向量化的文本列表 + * @return array 向量列表,与输入顺序一一对应,每项为 float 数组 + * @throws \RuntimeException 配置缺失、请求失败或平台返回错误时抛出 + */ + public function embeddings(array $inputs): array + { + $inputs = array_values(array_filter(array_map('strval', $inputs), fn ($item) => $item !== '')); + if ($inputs === []) { + throw new \RuntimeException('向量化内容不能为空'); + } + + [$url, $headers, $model] = $this->prepareRequest(); + + $response = Http::postRequest($this->embeddingsUrl($url), $this->encodeRequestBody([ + 'model' => $model, + 'input' => $inputs, + ]), $headers, $this->timeout()); + if ($response === false) { + // 按 HTTP 状态码区分网络错误与端点错误(非 200 同样返回 false),提示更可定位 + throw new \RuntimeException('AI向量接口请求失败:' . $this->requestFailReason()); + } + + $result = json_decode($response, true); + if (!is_array($result)) { + throw new \RuntimeException('AI向量接口响应解析失败'); + } + if (isset($result['error'])) { + throw new \RuntimeException('AI向量接口错误:' . $this->errorMessage($result['error'])); + } + $data = $result['data'] ?? []; + if (!is_array($data) || $data === []) { + throw new \RuntimeException('AI向量接口未返回向量数据'); + } + // 按 index 归位,保证输出顺序与输入一致(部分网关乱序返回) + usort($data, fn ($a, $b) => ((int) ($a['index'] ?? 0)) <=> ((int) ($b['index'] ?? 0))); + $vectors = []; + foreach ($data as $item) { + $vector = $item['embedding'] ?? null; + if (!is_array($vector) || $vector === []) { + throw new \RuntimeException('AI向量接口返回的向量结构异常'); + } + $vectors[] = array_map('floatval', $vector); + } + return $vectors; + } + /** * 校验并整理请求三要素 * @return array [接口地址, 请求头数组, 模型标识] @@ -214,6 +269,41 @@ class OpenAiCompatible extends BaseProtocol implements StreamAiInterface return $baseUrl . '/chat/completions'; } + /** + * 请求失败原因:按是否拿到 HTTP 响应区分端点错误与网络错误 + * @return string + */ + protected function requestFailReason(): string + { + $httpCode = (int) (Http::getStatus()['http_code'] ?? 0); + if ($httpCode > 0) { + return 'HTTP ' . $httpCode . ',请检查模型 ID 与请求端点是否匹配'; + } + return Http::getCurlError() ?: '网络异常,请稍后重试'; + } + + /** + * 归一化向量接口地址 + * + * prepareRequest() 返回的是对话地址,这里在完整 URL 模式下直接使用, + * 其余场景按 chatUrl 同规则把地址补全为 /embeddings 端点。 + * @param string $chatUrl prepareRequest() 归一化后的对话接口地址 + * @return string 完整向量接口地址 + */ + protected function embeddingsUrl(string $chatUrl): string + { + // 完整 URL 模式:地址即最终请求路径 + if ((int) $this->getConfigField('full_url') === 1) { + return $chatUrl; + } + // 网关根地址(不带 chat/completions 后缀)直接补 /embeddings + $baseUrl = rtrim((string) $this->getConfigField('base_url'), '/'); + if ($baseUrl !== '' && !str_ends_with($baseUrl, '/chat/completions')) { + return $baseUrl . '/embeddings'; + } + return substr($chatUrl, 0, -strlen('/chat/completions')) . '/embeddings'; + } + /** * 解析单行 SSE 增量文本 * @param string $line SSE 行内容 diff --git a/backend/crmeb/services/ai/protocol/Yihaotong.php b/backend/crmeb/services/ai/protocol/Yihaotong.php index 0abd047b7..65c69705d 100644 --- a/backend/crmeb/services/ai/protocol/Yihaotong.php +++ b/backend/crmeb/services/ai/protocol/Yihaotong.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace crmeb\services\ai\protocol; use crmeb\services\ai\BaseProtocol; +use crmeb\services\ai\EmbeddingAiInterface; use crmeb\services\ai\StreamAiInterface; use crmeb\services\yihaotong\AccessTokenServeService; use crmeb\utils\Http; @@ -11,17 +12,22 @@ use crmeb\utils\Http; /** * 一号通 AI 协议适配器 * - * 对接一号通平台对话接口(POST v2/chat/conversation)。 + * 对接一号通平台对话接口(POST v2/chat/conversation)与向量接口(POST v2/chat/embeddings)。 * 与 OpenAI 兼容协议的差异在于凭证:一号通 appid / appsecret 由「一号通登录」页 * 一次性写入 eb_system_config,全站(短信/物流/AI)共享,因此模型记录中无需再填 api_key。 */ -class Yihaotong extends BaseProtocol implements StreamAiInterface +class Yihaotong extends BaseProtocol implements StreamAiInterface, EmbeddingAiInterface { /** * 一号通对话接口(相对网关地址) */ const API_CHAT = 'v2/chat/conversation'; + /** + * 一号通向量接口(相对网关地址,见一号通文档「AI 模型 → 向量转换」) + */ + const API_EMBEDDINGS = 'v2/chat/embeddings'; + /** * @var AccessTokenServeService|null 一号通API服务(延迟初始化,token 自动缓存) */ @@ -85,6 +91,78 @@ class Yihaotong extends BaseProtocol implements StreamAiInterface return $this->parseChatResult($this->unwrapChoices($result), $this->model()); } + /** + * 文本向量化 + * + * 调用一号通向量转换接口(POST v2/chat/embeddings,见一号通文档「AI 模型 → 向量转换」): + * 请求体为 {inputs: [文本数组]},平台按套餐自动选择默认向量模型,无需(也不支持)下发 model。 + * 响应为三层结构 {status:200, data:{code:200, data:{data:[{index, embedding}]}}}, + * 按 index 排序后提取 embedding,保证与输入顺序一一对应。 + * + * @param array $inputs 待向量化的文本列表 + * @return array 向量列表,与输入顺序一一对应,每项为 float 数组 + * @throws \RuntimeException 凭证缺失、请求失败或平台返回错误时抛出 + */ + public function embeddings(array $inputs): array + { + $inputs = array_values(array_filter(array_map('strval', $inputs), fn ($item) => $item !== '')); + if ($inputs === []) { + throw new \RuntimeException('向量化内容不能为空'); + } + + $service = $this->getAccessToken(); + $data = ['inputs' => $inputs]; + + $response = Http::postRequest( + $service->get(self::API_EMBEDDINGS), + $this->encodeRequestBody($data), + ['Authorization:Bearer-' . $service->getToken(), 'Content-Type: application/json'], + $this->timeout() + ); + if ($response === false) { + // 瞬时网络抖动透明重试一次,仍失败才按请求失败处理 + $response = Http::postRequest( + $service->get(self::API_EMBEDDINGS), + $this->encodeRequestBody($data), + ['Authorization:Bearer-' . $service->getToken(), 'Content-Type: application/json'], + $this->timeout() + ); + } + if ($response === false) { + throw new \RuntimeException('一号通向量接口请求失败:' . (Http::getCurlError() ?: '网络异常,请稍后重试')); + } + + $result = json_decode($response, true); + if (!is_array($result)) { + throw new \RuntimeException('一号通向量接口响应解析失败'); + } + // 兼容平台 code / status 两种成功标识(最外层) + $code = (int) ($result['code'] ?? $result['status'] ?? 0); + if ($code !== 200) { + throw new \RuntimeException('一号通向量接口错误:' . ($result['msg'] ?? '未知错误')); + } + // 一号通 data 层为 {code, msg, data} 包装,向量数组位于 data.data.data;兼容省略中间层的结构 + $payload = $result['data']['data'] ?? []; + if (is_array($payload) && isset($payload['data']) && is_array($payload['data'])) { + $rows = isset($payload['data'][0]) ? $payload['data'] : $payload['data']['data'] ?? []; + } else { + $rows = is_array($payload) ? $payload : []; + } + if ($rows === []) { + throw new \RuntimeException('一号通向量接口未返回向量数据'); + } + usort($rows, fn ($a, $b) => ((int) ($a['index'] ?? 0)) <=> ((int) ($b['index'] ?? 0))); + $vectors = []; + foreach ($rows as $row) { + $vector = $row['embedding'] ?? null; + if (!is_array($vector) || $vector === []) { + throw new \RuntimeException('一号通向量接口返回的向量结构异常'); + } + $vectors[] = array_map('floatval', $vector); + } + return $vectors; + } + /** * AI 模型流式对话 * diff --git a/backend/public/install/crmeb_table.sql b/backend/public/install/crmeb_table.sql index 9fad5ba65..865d04497 100755 --- a/backend/public/install/crmeb_table.sql +++ b/backend/public/install/crmeb_table.sql @@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS `eb_ai_model` ( `api_key` varchar(255) NOT NULL DEFAULT '' COMMENT '接口密钥', `supports_tools` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否支持工具调用', `supports_vision` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否支持视觉输入', + `supports_embedding` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否支持文本向量化', `context_window` int unsigned NOT NULL DEFAULT '0' COMMENT '上下文窗口大小', `max_tokens` int unsigned NOT NULL DEFAULT '2048' COMMENT '单次最大输出token', `temperature` decimal(3,2) NOT NULL DEFAULT '0.70' COMMENT '默认采样温度', diff --git a/backend/public/install/data/system_menus_data.sql b/backend/public/install/data/system_menus_data.sql index 2c09609fd..11e52a48d 100755 --- a/backend/public/install/data/system_menus_data.sql +++ b/backend/public/install/data/system_menus_data.sql @@ -739,4 +739,5 @@ INSERT INTO `eb_system_menus` (`id`, `pid`, `icon`, `menu_name`, `module`, `cont (6655, 6647, '', '修改AI模型状态', 'admin', '', '', 'ai/model/set_status/', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-status', 'core:setting-ai-model-status', 0, '修改AI模型状态接口权限'), (6656, 6647, '', '设置默认AI模型', 'admin', '', '', 'ai/model/set_default/', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-default', 'core:setting-ai-model-default', 0, '设置默认AI模型接口权限'), (6657, 6647, '', 'AI模型排序', 'admin', '', '', 'ai/model/sort', 'PUT', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-sort', 'core:setting-ai-model-sort', 0, 'AI模型排序接口权限'), -(6658, 6647, '', 'AI模型连通性测试', 'admin', '', '', 'ai/model/test', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-test', 'core:setting-ai-model-test', 0, 'AI模型连通性测试接口权限'); +(6658, 6647, '', 'AI模型连通性测试', 'admin', '', '', 'ai/model/test', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-test', 'core:setting-ai-model-test', 0, 'AI模型连通性测试接口权限'), +(6659, 6647, '', '拉取厂商可用模型列表', 'admin', '', '', 'ai/model/remote_models', 'POST', '[]', 1, 1, 0, 1, '', '12/6647', 2, '', 0, 'setting-ai-model-remote-list', 'core:setting-ai-model-remote-list', 0, '拉取厂商可用模型列表接口权限'); diff --git a/frontend/admin/src/api/ai.js b/frontend/admin/src/api/ai.js index 0b61b1196..8eefff727 100644 --- a/frontend/admin/src/api/ai.js +++ b/frontend/admin/src/api/ai.js @@ -46,6 +46,15 @@ export function aiModelTestApi(data) { return request({ url: 'ai/model/test', method: 'post', data }); } +/** + * 拉取厂商可用模型列表(按表单接口地址与密钥请求 /models 端点,不落库) + * @param {Object} data 表单数据(id/base_url/api_key/full_url,编辑时携带 id 以继承原密钥) + * @returns {Promise} 模型标识字符串数组 + */ +export function aiModelRemoteListApi(data) { + return request({ url: 'ai/model/remote_models', method: 'post', data }); +} + /** * 修改 AI 模型 * @param {Number} id 模型 ID diff --git a/frontend/admin/src/pages/setting/aiModel/index.vue b/frontend/admin/src/pages/setting/aiModel/index.vue index 65bbf5188..f0f92b89b 100644 --- a/frontend/admin/src/pages/setting/aiModel/index.vue +++ b/frontend/admin/src/pages/setting/aiModel/index.vue @@ -34,11 +34,18 @@ {{ scope.row.has_api_key ? scope.row.api_key : '未配置' }} - + @@ -113,13 +120,43 @@ " /> - + + +
+ + + + + + + + + + +
+
填写接口地址与 API 密钥后,点击右侧按钮获取厂商可用模型,成功后可直接下拉选择。
+
在模型列表中展示的名称,未设置时默认显示模型 ID。
- - - + 支持工具调用 支持视觉输入 + 支持文本向量化 ({ full_url: 0, supports_tools: 0, supports_vision: 0, + supports_embedding: 0, context_window: 0, max_tokens: 2048, temperature: 0.7, @@ -252,6 +287,9 @@ const form = ref(emptyForm()); const advancedOpen = ref([]); const nameTouched = ref(false); const modelForm = ref(null); +// 厂商可用模型列表(点击「获取模型」拉取),非空时模型 ID 切换为下拉选择 +const remoteOptions = ref([]); +const remoteLoading = ref(false); const rules = { model: [{ required: true, message: '请填写模型 ID', trigger: 'blur' }], @@ -290,6 +328,51 @@ watch( }, ); +/** 接口地址或密钥变化后,已拉取的模型列表不再适用,清空回退为手动输入 */ +watch( + () => [form.value.base_url, form.value.api_key], + () => { + remoteOptions.value = []; + }, +); + +/** 拉取厂商可用模型列表:成功后模型 ID 切换为下拉选择,失败提示原因 */ +const fetchRemoteModels = () => { + if (!/^https?:\/\//i.test(form.value.base_url || '')) { + ElMessage.error('请先填写以 http:// 或 https:// 开头的接口地址'); + return; + } + if (form.value.full_url === 1) { + ElMessage.warning('完整 URL 模式无法自动获取模型列表,请手动输入模型 ID'); + return; + } + // 编辑时密钥可为空(后端继承原密钥),新增时必须已填写 + if (!form.value.id && !form.value.api_key) { + ElMessage.error('请先填写 API 密钥'); + return; + } + remoteLoading.value = true; + aiModelRemoteListApi({ + id: form.value.id, + base_url: form.value.base_url, + api_key: form.value.api_key, + full_url: form.value.full_url, + }) + .then((res) => { + const list = res.data || []; + if (!list.length) { + ElMessage.warning('厂商未返回可用模型,请手动输入模型 ID'); + return; + } + remoteOptions.value = list; + ElMessage.success(`获取成功,共 ${list.length} 个模型,可直接下拉选择`); + }) + .catch(() => {}) + .finally(() => { + remoteLoading.value = false; + }); +}; + /** 内置一号通 AI 配置状态(未配置时提示「一键配置」并跳转一号通账户页) */ const yihaotongConfigured = ref(true); @@ -337,6 +420,7 @@ const openEditor = (row) => { form.value = emptyForm(); nameTouched.value = false; advancedOpen.value = []; + remoteOptions.value = []; editorVisible.value = true; nextTick(() => modelForm.value && modelForm.value.clearValidate()); return; @@ -345,6 +429,7 @@ const openEditor = (row) => { form.value = { ...emptyForm(), ...(res.data || {}) }; nameTouched.value = !!form.value.name; advancedOpen.value = []; + remoteOptions.value = []; editorVisible.value = true; nextTick(() => modelForm.value && modelForm.value.clearValidate()); }); @@ -356,11 +441,13 @@ const resetForm = () => { aiModelDetailApi(form.value.id).then((res) => { form.value = { ...emptyForm(), ...(res.data || {}) }; nameTouched.value = !!form.value.name; + remoteOptions.value = []; nextTick(() => modelForm.value && modelForm.value.clearValidate()); }); } else { form.value = emptyForm(); nameTouched.value = false; + remoteOptions.value = []; nextTick(() => modelForm.value && modelForm.value.clearValidate()); } }; @@ -454,6 +541,24 @@ onMounted(() => { font-size: 12px; line-height: 1.5; } +/* 模型 ID 行:输入框/下拉占满剩余宽度 + 右侧「获取模型」图标按钮 */ +.model-id-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + .model-id-main { + flex: 1; + } +} +.fetch-model-btn { + flex-shrink: 0; + width: 32px; + padding: 0; + :deep(i) { + font-size: 14px; + } +} .inline-tip { margin-left: 12px; }