mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
feat(AI模型): 新增文本向量化与厂商模型列表拉取能力
This commit is contained in:
parent
44542bed8c
commit
a7f57bc354
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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'];
|
||||
|
||||
22
backend/crmeb/services/ai/EmbeddingAiInterface.php
Normal file
22
backend/crmeb/services/ai/EmbeddingAiInterface.php
Normal 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;
|
||||
}
|
||||
@ -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 行内容
|
||||
|
||||
@ -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 模型流式对话
|
||||
*
|
||||
|
||||
@ -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 '默认采样温度',
|
||||
|
||||
@ -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, '拉取厂商可用模型列表接口权限');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user