no message

- 添加AI助手流式会话凭证生成方法
- 优化AI助手模型获取逻辑
- 更新相关接口调用
This commit is contained in:
kuaifan 2025-11-09 22:20:22 +00:00
parent 7a6bbfac75
commit f6e4ed7c60
8 changed files with 194 additions and 150 deletions

View File

@ -3,8 +3,9 @@
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\AI;
use App\Module\Apps;
use App\Module\Base;
use App\Module\Ihttp;
use Request;
/**
@ -14,6 +15,11 @@ use Request;
*/
class AssistantController extends AbstractController
{
public function __construct()
{
Apps::isInstalledThrow('ai');
}
/**
* @api {post} api/assistant/auth 生成授权码
*
@ -40,104 +46,28 @@ class AssistantController extends AbstractController
$modelName = trim(Request::input('model_name', ''));
$contextInput = Request::input('context', []);
if ($modelType === '' || $modelName === '') {
return Base::retError('参数错误');
}
if (is_string($contextInput)) {
$decoded = json_decode($contextInput, true);
if (json_last_error() === JSON_ERROR_NONE) {
$contextInput = $decoded;
}
}
if (!is_array($contextInput)) {
return Base::retError('context 参数格式错误');
}
$context = [];
foreach ($contextInput as $item) {
if (!is_array($item) || count($item) < 2) {
continue;
}
$role = trim((string)($item[0] ?? ''));
$message = trim((string)($item[1] ?? ''));
if ($role === '' || $message === '') {
continue;
}
$context[] = [$role, $message];
}
$contextJson = json_encode($context, JSON_UNESCAPED_UNICODE);
if ($contextJson === false) {
return Base::retError('context 参数格式错误');
}
return AI::createStreamKey($modelType, $modelName, $contextInput);
}
/**
* @api {get} api/assistant/models 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup assistant
* @apiName models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function models()
{
$setting = Base::setting('aibotSetting');
$apiKey = Base::val($setting, $modelType . '_key');
if ($modelType === 'wenxin') {
$wenxinSecret = Base::val($setting, 'wenxin_secret');
if ($wenxinSecret) {
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
}
}
if ($modelType === 'ollama' && empty($apiKey)) {
$apiKey = Base::strRandom(6);
}
if (empty($apiKey)) {
return Base::retError('模型未启用');
}
$setting = array_filter($setting, function ($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
$remoteModelType = match ($modelType) {
'qianwen' => 'qwen',
default => $modelType,
};
$authParams = [
'api_key' => $apiKey,
'model_type' => $remoteModelType,
'model_name' => $modelName,
'context' => $contextJson,
];
if ($setting[$modelType . '_base_url']) {
$authParams['base_url'] = $setting[$modelType . '_base_url'];
}
if ($setting[$modelType . '_agency']) {
$authParams['agency'] = $setting[$modelType . '_agency'];
}
$thinkPatterns = [
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
];
$thinkMatch = [];
foreach ($thinkPatterns as $pattern) {
if (preg_match($pattern, $authParams['model_name'], $thinkMatch)) {
break;
}
}
if ($thinkMatch && !empty($thinkMatch[1])) {
$authParams['model_name'] = $thinkMatch[1];
}
$authResult = Ihttp::ihttp_post('http://nginx/ai/invoke/auth', $authParams, 30);
if (Base::isError($authResult)) {
return Base::retError($authResult['msg']);
}
$body = Base::json2array($authResult['data']);
if ($body['code'] !== 200) {
return Base::retError($body['error'] ?: 'AI 接口返回异常', $body);
}
$streamKey = Base::val($body, 'data.stream_key');
if (empty($streamKey)) {
return Base::retError('AI 接口返回数据异常');
}
return Base::retSuccess('success', [
'stream_key' => $streamKey,
]);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
}

View File

@ -333,27 +333,6 @@ class SystemController extends AbstractController
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/aibot_models 获取AI模型
*
* @apiDescription 获取所有AI机器人模型设置
* @apiVersion 1.0.0
* @apiGroup system
* @apiName aibot_models
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function setting__aibot_models()
{
$setting = Base::setting('aibotSetting');
$setting = array_filter($setting, function($value, $key) {
return str_ends_with($key, '_models') || str_ends_with($key, '_model');
}, ARRAY_FILTER_USE_BOTH);
return Base::retSuccess('success', $setting ?: json_decode('{}'));
}
/**
* @api {get} api/system/setting/checkin 获取签到设置、保存签到设置(限管理员)
*

View File

@ -132,6 +132,126 @@ class AI
return Base::retSuccess("success", $result);
}
/**
* 生成 AI 流式会话凭证
* @param string $modelType
* @param string $modelName
* @param mixed $contextInput
* @return array
*/
public static function createStreamKey($modelType, $modelName, $contextInput = [])
{
$modelType = trim((string)$modelType);
$modelName = trim((string)$modelName);
if ($modelType === '' || $modelName === '') {
return Base::retError('参数错误');
}
if (is_string($contextInput)) {
$decoded = json_decode($contextInput, true);
if (json_last_error() === JSON_ERROR_NONE) {
$contextInput = $decoded;
}
}
if (!is_array($contextInput)) {
return Base::retError('context 参数格式错误');
}
$context = [];
foreach ($contextInput as $item) {
if (!is_array($item) || count($item) < 2) {
continue;
}
$role = trim((string)($item[0] ?? ''));
$message = trim((string)($item[1] ?? ''));
if ($role === '' || $message === '') {
continue;
}
$context[] = [$role, $message];
}
$contextJson = json_encode($context, JSON_UNESCAPED_UNICODE);
if ($contextJson === false) {
return Base::retError('context 参数格式错误');
}
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
$apiKey = Base::val($setting, $modelType . '_key');
if ($modelType === 'wenxin') {
$wenxinSecret = Base::val($setting, 'wenxin_secret');
if ($wenxinSecret) {
$apiKey = trim(($apiKey ?: '') . ':' . $wenxinSecret);
}
}
if ($modelType === 'ollama' && empty($apiKey)) {
$apiKey = Base::strRandom(6);
}
if (empty($apiKey)) {
return Base::retError('模型未启用');
}
$remoteModelType = match ($modelType) {
'qianwen' => 'qwen',
default => $modelType,
};
$authParams = [
'api_key' => $apiKey,
'model_type' => $remoteModelType,
'model_name' => $modelName,
'context' => $contextJson,
];
$baseUrl = trim((string)($setting[$modelType . '_base_url'] ?? ''));
if ($baseUrl !== '') {
$authParams['base_url'] = $baseUrl;
}
$agency = trim((string)($setting[$modelType . '_agency'] ?? ''));
if ($agency !== '') {
$authParams['agency'] = $agency;
}
$thinkPatterns = [
"/^(.+?)(\s+|\s*[_-]\s*)(think|thinking|reasoning)\s*$/",
"/^(.+?)\s*\(\s*(think|thinking|reasoning)\s*\)\s*$/"
];
$thinkMatch = [];
foreach ($thinkPatterns as $pattern) {
if (preg_match($pattern, $authParams['model_name'], $thinkMatch)) {
break;
}
}
if ($thinkMatch && !empty($thinkMatch[1])) {
$authParams['model_name'] = $thinkMatch[1];
}
$authResult = Ihttp::ihttp_post('http://nginx/ai/invoke/auth', $authParams, 30);
if (Base::isError($authResult)) {
return Base::retError($authResult['msg']);
}
$body = Base::json2array($authResult['data']);
if (($body['code'] ?? null) !== 200) {
return Base::retError(($body['error'] ?? '') ?: 'AI 接口返回异常', $body);
}
$streamKey = Base::val($body, 'data.stream_key');
if (empty($streamKey)) {
return Base::retError('AI 接口返回数据异常');
}
return Base::retSuccess('success', [
'stream_key' => $streamKey,
]);
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */
@ -146,6 +266,8 @@ class AI
*/
public static function transcriptions($filePath, $extParams = [], $extHeaders = [], $noCache = false)
{
Apps::isInstalledThrow('ai');
if (!file_exists($filePath)) {
return Base::retError("语音文件不存在");
}
@ -202,6 +324,8 @@ class AI
*/
public static function translations($text, $targetLanguage, $noCache = false)
{
Apps::isInstalledThrow('ai');
$cacheKey = "openAItranslations::" . md5($text . '_' . $targetLanguage);
if ($noCache) {
Cache::forget($cacheKey);
@ -285,6 +409,10 @@ class AI
*/
public static function generateTitle($text, $noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
$cacheKey = "openAIGenerateTitle::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
@ -362,6 +490,10 @@ class AI
*/
public static function generateJokeAndSoup($noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
$cacheKey = "openAIJokeAndSoup::" . md5(date('Y-m-d'));
if ($noCache) {
Cache::forget($cacheKey);

View File

@ -44,7 +44,7 @@ class Apps
{
if (!self::isInstalled($appId)) {
$name = match ($appId) {
'ai' => 'AI Robot',
'ai' => 'AI Assistant',
'face' => 'Face check-in',
'appstore' => 'AppStore',
'approve' => 'Approval',

View File

@ -446,7 +446,7 @@ class BotReceiveMsgTask extends AbstractTask
}
// 判断AI应用是否安装
if (!Apps::isInstalled('ai')) {
throw new Exception('应用「AI Robot」未安装');
throw new Exception('应用「AI Assistant」未安装');
}
// 整理机器人参数
$setting = Base::setting('aibotSetting');

View File

@ -4,10 +4,7 @@
:title="$L('AI 助手')"
:mask-closable="false"
:closable="false"
:styles="{
width: '90%',
maxWidth: shouldCreateNewSession ? '420px' : '600px',
}"
:width="shouldCreateNewSession ? '420px' : '600px'"
class-name="ai-assistant-modal">
<div class="ai-assistant-content">
<div
@ -126,6 +123,7 @@ export default {
inputModel: '',
modelGroups: [],
modelMap: {},
modelsFirstLoad: true,
modelsLoading: false,
modelCacheKey: 'aiAssistant.model',
cachedModelId: '',
@ -139,7 +137,7 @@ export default {
},
mounted() {
emitter.on('openAIAssistant', this.onOpenAIAssistant);
this.initModelCache();
this.loadCachedModel();
},
beforeDestroy() {
emitter.off('openAIAssistant', this.onOpenAIAssistant);
@ -181,6 +179,7 @@ export default {
//
this.responses = [];
this.showModal = true;
this.fetchModelOptions();
this.clearActiveSSEClients();
this.clearAutoSubmitTimer();
this.$nextTick(() => {
@ -188,14 +187,6 @@ export default {
});
},
/**
* 初始化模型缓存与下拉数据
*/
async initModelCache() {
await this.loadCachedModel();
this.fetchModelOptions();
},
/**
* 读取缓存的模型ID
*/
@ -222,16 +213,30 @@ export default {
* 拉取模型配置
*/
async fetchModelOptions() {
this.modelsLoading = true;
const needFetch = this.modelsFirstLoad
if (needFetch) {
this.modelsFirstLoad = false;
this.modelsLoading = true;
}
try {
const {data} = await this.$store.dispatch("call", {
url: 'system/setting/aibot_models',
url: 'assistant/models',
});
this.normalizeModelOptions(data);
} catch (error) {
$A.modalError(error?.msg || error || '获取模型列表失败');
if (this.modelGroups.length > 0) {
return;
}
$A.modalError({
content: error?.msg || error || '获取模型列表失败',
onOk: _ => {
this.showModal = false;
},
});
} finally {
this.modelsLoading = false;
if (needFetch) {
this.modelsLoading = false;
}
}
},
@ -488,6 +493,7 @@ export default {
if (!streamKey) {
throw new Error('获取 stream_key 失败');
}
this.clearActiveSSEClients();
const sse = new SSEClient($A.mainUrl(`ai/invoke/stream/${streamKey}`));
this.registerSSEClient(sse);
sse.subscribe(['append', 'replace', 'done'], (type, event) => {
@ -925,10 +931,16 @@ export default {
.ai-assistant-footer {
display: flex;
justify-content: between;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
.ai-assistant-footer-models {
text-align: left;
.ivu-select-disabled {
.ivu-select-selection {
background-color: transparent;
}
}
.ivu-select-selection {
border: 0;
box-shadow: none;

View File

@ -490,10 +490,7 @@
v-model="delayTaskShow"
:title="$L('任务延期')"
:mask-closable="false"
:styles="{
width: '90%',
maxWidth: '450px'
}">
width="450px">
<Form
ref="formDelayTaskRef"
:model="delayTaskForm"

View File

@ -108,10 +108,7 @@
v-model="moveTaskShow"
:title="$L('移动任务')"
:mask-closable="false"
:styles="{
width: '90%',
maxWidth: '540px'
}"
width="540px"
footer-hide>
<TaskMove ref="addTask" v-model="moveTaskShow" :task="task"/>
</Modal>
@ -121,10 +118,7 @@
v-model="copyTaskShow"
:title="$L('复制任务')"
:mask-closable="false"
:styles="{
width: '90%',
maxWidth: '540px'
}"
width="540px"
footer-hide>
<TaskMove v-model="copyTaskShow" :task="task" type="copy"/>
</Modal>