feat(AI模型): 新增文本向量化与厂商模型列表拉取能力

This commit is contained in:
sugar1569 2026-10-07 00:55:26 +08:00
parent 44542bed8c
commit a7f57bc354
10 changed files with 505 additions and 23 deletions

View File

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

View File

@ -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([

View File

@ -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'];

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace crmeb\services\ai;
/**
* AI 文本向量化能力契约
*
* 扩展 AiInterface:协议适配器实现本接口即具备向量化能力,
* 调用方以 instanceof EmbeddingAiInterface 探测后调用 embeddings()。
* 由 crmeb\services\ai\AiProtocol 工厂按模型记录的 protocol 字段创建实例。
*/
interface EmbeddingAiInterface extends AiInterface
{
/**
* 文本向量化
* @param array $inputs 待向量化的文本列表(一维字符串数组)
* @return array 向量列表,与输入顺序一一对应,每项为 float 数组
* @throws \RuntimeException 配置缺失、请求失败或平台返回错误时抛出
*/
public function embeddings(array $inputs): array;
}

View File

@ -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\utils\Http;
@ -17,7 +18,7 @@ use crmeb\utils\Http;
* 配置项:base_url(接口地址)、api_key(密钥)、model(模型标识)、
* temperature、max_tokens、timeout。
*/
class OpenAiCompatible extends BaseProtocol implements StreamAiInterface
class OpenAiCompatible extends BaseProtocol implements StreamAiInterface, EmbeddingAiInterface
{
/**
* AI 模型对话(非流式)
@ -47,7 +48,8 @@ class OpenAiCompatible extends BaseProtocol implements StreamAiInterface
$response = Http::postRequest($url, $this->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 行内容

View File

@ -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 模型流式对话
*

View File

@ -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 '默认采样温度',

View File

@ -739,4 +739,5 @@ INSERT INTO `eb_system_menus` (`id`, `pid`, `icon`, `menu_name`, `module`, `cont
(6655, 6647, '', '修改AI模型状态', 'admin', '', '', 'ai/model/set_status/<id>', '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/<id>', '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, '拉取厂商可用模型列表接口权限');

View File

@ -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

View File

@ -34,11 +34,18 @@
<span v-else class="mono">{{ scope.row.has_api_key ? scope.row.api_key : '未配置' }}</span>
</template>
</el-table-column>
<el-table-column label="能力" min-width="120">
<el-table-column label="能力" min-width="160">
<template #default="scope">
<el-tag v-if="scope.row.supports_tools === 1" size="small" type="success">工具调用</el-tag>
<el-tag v-if="scope.row.supports_vision === 1" size="small" type="info">视觉</el-tag>
<span v-if="scope.row.supports_tools !== 1 && scope.row.supports_vision !== 1" class="muted">-</span>
<el-tag v-if="scope.row.supports_embedding === 1" size="small" type="warning">向量化</el-tag>
<span
v-if="
scope.row.supports_tools !== 1 && scope.row.supports_vision !== 1 && scope.row.supports_embedding !== 1
"
class="muted"
>-</span
>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
@ -113,13 +120,43 @@
"
/>
</el-form-item>
<el-form-item label="模型 ID" prop="model">
<el-form-item label="API 密钥">
<el-input
v-model.trim="form.model"
maxlength="100"
placeholder="输入模型 ID,例如 deepseek-chat、qwen-plus"
v-model.trim="form.api_key"
maxlength="255"
show-password
:placeholder="form.id ? '留空保持原密钥不变' : '厂商控制台签发的 API Key'"
/>
</el-form-item>
<el-form-item label="模型 ID" prop="model">
<div class="model-id-row">
<!-- 拉取到厂商模型列表后切换为可搜索下拉,仍允许手动输入其他模型 ID -->
<el-select
v-if="remoteOptions.length"
v-model="form.model"
filterable
allow-create
default-first-option
placeholder="下拉选择或输入模型 ID"
class="model-id-main"
>
<el-option v-for="item in remoteOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input
v-else
v-model.trim="form.model"
maxlength="100"
placeholder="输入模型 ID,例如 deepseek-chat、qwen-plus"
class="model-id-main"
/>
<el-tooltip content="获取模型" placement="top">
<el-button class="fetch-model-btn" :loading="remoteLoading" v-db-click @click="fetchRemoteModels">
<i v-if="!remoteLoading" class="el-icon-refresh" />
</el-button>
</el-tooltip>
</div>
<div class="field-tip">填写接口地址与 API 密钥后,点击右侧按钮获取厂商可用模型,成功后可直接下拉选择。</div>
</el-form-item>
<el-form-item label="模型名称" prop="name">
<el-input
v-model.trim="form.name"
@ -130,19 +167,15 @@
/>
<div class="field-tip">在模型列表中展示的名称,未设置时默认显示模型 ID。</div>
</el-form-item>
<el-form-item label="API 密钥">
<el-input
v-model.trim="form.api_key"
maxlength="255"
show-password
:placeholder="form.id ? '留空保持原密钥不变' : '厂商控制台签发的 API Key'"
/>
</el-form-item>
<el-collapse v-model="advancedOpen" class="advanced-collapse">
<el-collapse-item title="高级配置" name="advanced">
<el-form-item label="模型能力">
<el-checkbox v-model="form.supports_tools" :true-value="1" :false-value="0">支持工具调用</el-checkbox>
<el-checkbox v-model="form.supports_vision" :true-value="1" :false-value="0">支持视觉输入</el-checkbox>
<el-checkbox v-model="form.supports_embedding" :true-value="1" :false-value="0"
>支持文本向量化</el-checkbox
>
</el-form-item>
<el-form-item label="采样温度">
<el-slider
@ -205,6 +238,7 @@ import {
aiModelDeleteApi,
aiModelDetailApi,
aiModelListApi,
aiModelRemoteListApi,
aiModelSaveApi,
aiModelSetDefaultApi,
aiModelStatusApi,
@ -227,6 +261,7 @@ const emptyForm = () => ({
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;
}