mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-10 09:52:53 +00:00
feat: 完善AI助手功能,新增消息提示词整理接口,优化流式消息处理逻辑,移除冗余数据表和相关代码
This commit is contained in:
parent
892ad395a7
commit
69c66053b7
251
app/Http/Controllers/Api/AssistantController.php
Normal file
251
app/Http/Controllers/Api/AssistantController.php
Normal file
@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\AI;
|
||||
use App\Module\Base;
|
||||
use App\Module\Ihttp;
|
||||
use App\Models\WebSocketDialog;
|
||||
use App\Models\WebSocketDialogMsg;
|
||||
use App\Models\WebSocketDialogUser;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine assistant
|
||||
*
|
||||
* 助手
|
||||
*/
|
||||
class AssistantController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/assistant/dialog/prompt 生成对话提示词
|
||||
*
|
||||
* @apiDescription 需要token身份,整理当前会话的系统提示词与上下文信息
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName dialog__prompt
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {String} [content] 消息需求描述(用于提示词整理,可为空)
|
||||
* @apiParam {String} [draft] 当前草稿内容(HTML 格式)
|
||||
* @apiParam {Number} [quote_id] 引用消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.system_prompt AI 使用的系统提示词
|
||||
* @apiSuccess {String} data.context_prompt AI 使用的上下文提示词
|
||||
*/
|
||||
public function dialog__prompt()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
//
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$content = trim(Request::input('content', ''));
|
||||
if ($dialog_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
|
||||
// 基本信息
|
||||
$context = [
|
||||
'dialog_name' => $dialog->name ?: '',
|
||||
'dialog_type' => $dialog->type ?: '',
|
||||
'group_type' => $dialog->group_type ?: '',
|
||||
];
|
||||
|
||||
// 当前草稿
|
||||
$draft = Request::input('draft', '');
|
||||
if (is_string($draft) && trim($draft) !== '') {
|
||||
$context['current_draft'] = Base::html2markdown($draft);
|
||||
}
|
||||
|
||||
// 引用消息
|
||||
$quote_id = intval(Request::input('quote_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quote = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->whereId($quote_id)
|
||||
->with('user')
|
||||
->first();
|
||||
if ($quote) {
|
||||
$context['quote_summary'] = WebSocketDialogMsg::previewMsg($quote);
|
||||
$context['quote_user'] = $quote->user->nickname ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// 成员列表
|
||||
$members = WebSocketDialogUser::whereDialogId($dialog_id)
|
||||
->join('users', 'users.userid', '=', 'web_socket_dialog_users.userid')
|
||||
->orderBy('web_socket_dialog_users.id')
|
||||
->limit(10)
|
||||
->pluck('users.nickname')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
if (!empty($members)) {
|
||||
$context['members'] = $members;
|
||||
}
|
||||
|
||||
// 最近消息
|
||||
$recentMessagesQuery = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->orderByDesc('id')
|
||||
->with('user');
|
||||
|
||||
$recentMessages = (clone $recentMessagesQuery)->take(15)->get();
|
||||
if ($recentMessages->isNotEmpty()) {
|
||||
$formatRecentMessages = function ($messages) {
|
||||
return $messages->reverse()->map(function ($msg) {
|
||||
return [
|
||||
'sender' => $msg->user->nickname ?? ('用户' . $msg->userid),
|
||||
'summary' => $msg->extractMessageContent(500),
|
||||
];
|
||||
})->filter(function ($item) {
|
||||
return !empty($item['summary']);
|
||||
})->values();
|
||||
};
|
||||
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
$summaryLength = $formattedRecentMessages->sum(function ($item) {
|
||||
return mb_strlen($item['summary']);
|
||||
});
|
||||
|
||||
if ($summaryLength < 500 && $recentMessages->count() === 15) {
|
||||
$lastMessageId = optional($recentMessages->last())->id;
|
||||
$additionalMessages = collect();
|
||||
if ($lastMessageId) {
|
||||
$additionalMessages = (clone $recentMessagesQuery)
|
||||
->where('id', '<', $lastMessageId)
|
||||
->take(10)
|
||||
->get();
|
||||
}
|
||||
if ($additionalMessages->isNotEmpty()) {
|
||||
$recentMessages = $recentMessages->concat($additionalMessages);
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if ($formattedRecentMessages->isNotEmpty()) {
|
||||
$context['recent_messages'] = $formattedRecentMessages->all();
|
||||
}
|
||||
}
|
||||
|
||||
// 生成消息
|
||||
$systemPrompt = AI::messageSystemPrompt();
|
||||
$contextPrompt = AI::buildMessageContextPrompt($context);
|
||||
if ($content) {
|
||||
$contextPrompt .= "\n\n请根据以上信息,结合提示词生成一条待发送的消息:";
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'system_prompt' => $systemPrompt,
|
||||
'context_prompt' => $contextPrompt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/assistant/auth 生成授权码
|
||||
*
|
||||
* @apiDescription 需要token身份,生成 AI 流式会话的 stream_key
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup assistant
|
||||
* @apiName auth
|
||||
*
|
||||
* @apiParam {String} model_type 模型类型
|
||||
* @apiParam {String} model_name 模型名称
|
||||
* @apiParam {JSON} context 上下文数组
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.stream_key 流式会话凭证
|
||||
*/
|
||||
public function auth()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
|
||||
$modelType = trim(Request::input('model_type', ''));
|
||||
$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 参数格式错误');
|
||||
}
|
||||
|
||||
$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('模型未启用');
|
||||
}
|
||||
|
||||
$remoteModelType = match ($modelType) {
|
||||
'qianwen' => 'qwen',
|
||||
default => $modelType,
|
||||
};
|
||||
|
||||
$authResult = Ihttp::ihttp_post('http://nginx/ai/invoke/auth', [
|
||||
'api_key' => $apiKey,
|
||||
'model_type' => $remoteModelType,
|
||||
'model_name' => $modelName,
|
||||
'context' => $contextJson,
|
||||
], 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1026,133 +1026,6 @@ class DialogController extends AbstractController
|
||||
return Base::retSuccess('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/aiprompt AI 提示词助手
|
||||
*
|
||||
* @apiDescription 需要token身份,整理当前会话的系统提示词与上下文信息
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup dialog
|
||||
* @apiName msg__aiprompt
|
||||
*
|
||||
* @apiParam {Number} dialog_id 对话ID
|
||||
* @apiParam {String} content 消息需求描述(用于提示词整理,可为空)
|
||||
* @apiParam {String} [draft] 当前草稿内容(HTML 格式)
|
||||
* @apiParam {Number} [quote_id] 引用消息ID
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.system_prompt AI 使用的系统提示词
|
||||
* @apiSuccess {String} data.context_prompt AI 使用的上下文提示词
|
||||
*/
|
||||
public function msg__aiprompt()
|
||||
{
|
||||
$user = User::auth();
|
||||
$user->checkChatInformation();
|
||||
//
|
||||
$dialog_id = intval(Request::input('dialog_id'));
|
||||
$content = trim(Request::input('content', ''));
|
||||
if ($dialog_id <= 0) {
|
||||
return Base::retError('参数错误');
|
||||
}
|
||||
|
||||
$dialog = WebSocketDialog::checkDialog($dialog_id);
|
||||
|
||||
// 基本信息
|
||||
$context = [
|
||||
'dialog_name' => $dialog->name ?: '',
|
||||
'dialog_type' => $dialog->type ?: '',
|
||||
'group_type' => $dialog->group_type ?: '',
|
||||
];
|
||||
|
||||
// 当前草稿
|
||||
$draft = Request::input('draft', '');
|
||||
if (is_string($draft) && trim($draft) !== '') {
|
||||
$context['current_draft'] = Base::html2markdown($draft);
|
||||
}
|
||||
|
||||
// 引用消息
|
||||
$quote_id = intval(Request::input('quote_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quote = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->whereId($quote_id)
|
||||
->with('user')
|
||||
->first();
|
||||
if ($quote) {
|
||||
$context['quote_summary'] = WebSocketDialogMsg::previewMsg($quote);
|
||||
$context['quote_user'] = $quote->user->nickname ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// 成员列表
|
||||
$members = WebSocketDialogUser::whereDialogId($dialog_id)
|
||||
->join('users', 'users.userid', '=', 'web_socket_dialog_users.userid')
|
||||
->orderBy('web_socket_dialog_users.id')
|
||||
->limit(10)
|
||||
->pluck('users.nickname')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
if (!empty($members)) {
|
||||
$context['members'] = $members;
|
||||
}
|
||||
|
||||
// 最近消息
|
||||
$recentMessagesQuery = WebSocketDialogMsg::whereDialogId($dialog_id)
|
||||
->orderByDesc('id')
|
||||
->with('user');
|
||||
|
||||
$recentMessages = (clone $recentMessagesQuery)->take(15)->get();
|
||||
if ($recentMessages->isNotEmpty()) {
|
||||
$formatRecentMessages = function ($messages) {
|
||||
return $messages->reverse()->map(function ($msg) {
|
||||
return [
|
||||
'sender' => $msg->user->nickname ?? ('用户' . $msg->userid),
|
||||
'summary' => $msg->extractMessageContent(500),
|
||||
];
|
||||
})->filter(function ($item) {
|
||||
return !empty($item['summary']);
|
||||
})->values();
|
||||
};
|
||||
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
$summaryLength = $formattedRecentMessages->sum(function ($item) {
|
||||
return mb_strlen($item['summary']);
|
||||
});
|
||||
|
||||
if ($summaryLength < 500 && $recentMessages->count() === 15) {
|
||||
$lastMessageId = optional($recentMessages->last())->id;
|
||||
$additionalMessages = collect();
|
||||
if ($lastMessageId) {
|
||||
$additionalMessages = (clone $recentMessagesQuery)
|
||||
->where('id', '<', $lastMessageId)
|
||||
->take(10)
|
||||
->get();
|
||||
}
|
||||
if ($additionalMessages->isNotEmpty()) {
|
||||
$recentMessages = $recentMessages->concat($additionalMessages);
|
||||
$formattedRecentMessages = $formatRecentMessages($recentMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if ($formattedRecentMessages->isNotEmpty()) {
|
||||
$context['recent_messages'] = $formattedRecentMessages->all();
|
||||
}
|
||||
}
|
||||
|
||||
// 生成消息
|
||||
$systemPrompt = AI::messageSystemPrompt();
|
||||
$contextPrompt = AI::buildMessageContextPrompt($context);
|
||||
if ($content) {
|
||||
$contextPrompt .= "\n\n请根据以上信息,结合提示词生成一条待发送的消息:\n\n";
|
||||
}
|
||||
|
||||
return Base::retSuccess('success', [
|
||||
'system_prompt' => $systemPrompt,
|
||||
'context_prompt' => $contextPrompt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/dialog/msg/sendtext 发送消息
|
||||
*
|
||||
@ -1180,7 +1053,6 @@ class DialogController extends AbstractController
|
||||
* - no: 正常发送(默认)
|
||||
* - yes: 静默发送
|
||||
* @apiParam {String} [model_name] 模型名称(仅AI机器人支持)
|
||||
* @apiParam {Object} [extra_data] 附加数据(保存到附加表)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
@ -1202,7 +1074,6 @@ class DialogController extends AbstractController
|
||||
$text_type = strtolower(trim(Request::input('text_type')));
|
||||
$silence = in_array(strtolower(trim(Request::input('silence'))), ['yes', 'true', '1']);
|
||||
$model_name = trim(Request::input('model_name'));
|
||||
$extra_data = Request::input('extra_data');
|
||||
$markdown = in_array($text_type, ['md', 'markdown']);
|
||||
//
|
||||
$result = [];
|
||||
@ -1285,7 +1156,7 @@ class DialogController extends AbstractController
|
||||
if ($model_name) {
|
||||
$msgData['model_name'] = $model_name;
|
||||
}
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'longtext', $msgData, $user->userid, false, false, $silence, $key, $extra_data);
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'longtext', $msgData, $user->userid, false, false, $silence, $key);
|
||||
} else {
|
||||
$msgData = ['text' => $text];
|
||||
if ($markdown) {
|
||||
@ -1294,7 +1165,7 @@ class DialogController extends AbstractController
|
||||
if ($model_name) {
|
||||
$msgData['model_name'] = $model_name;
|
||||
}
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'text', $msgData, $user->userid, false, false, $silence, $key, $extra_data);
|
||||
$result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'text', $msgData, $user->userid, false, false, $silence, $key);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
|
||||
@ -43,7 +43,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
* @property-read int|mixed $percentage
|
||||
* @property-read \App\Models\User|null $user
|
||||
* @property-read \App\Models\WebSocketDialogMsgExtra|null $extra
|
||||
* @property-read \App\Models\WebSocketDialog|null $webSocketDialog
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
@ -112,14 +111,6 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
return $this->hasOne(User::class, 'userid', 'userid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
*/
|
||||
public function extra(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
{
|
||||
return $this->hasOne(WebSocketDialogMsgExtra::class, 'msg_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读占比
|
||||
* @return int|mixed
|
||||
@ -1242,10 +1233,9 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
* @param bool|null $push_silence 推送-静默
|
||||
* - type = [text|file|record|meeting] 默认为:false
|
||||
* @param string|null $search_key 搜索关键词(用于搜索,留空则自动生成)
|
||||
* @param array|null $extra_data 额外数据(仅在发送消息时有效)
|
||||
* @return array
|
||||
*/
|
||||
public static function sendMsg($action, $dialog_id, $type, $msg, $sender = null, $push_self = false, $push_retry = false, $push_silence = null, $search_key = null, $extra_data = null)
|
||||
public static function sendMsg($action, $dialog_id, $type, $msg, $sender = null, $push_self = false, $push_retry = false, $push_silence = null, $search_key = null)
|
||||
{
|
||||
$link = 0;
|
||||
$mtype = $type;
|
||||
@ -1390,17 +1380,10 @@ class WebSocketDialogMsg extends AbstractModel
|
||||
'msg' => $msg,
|
||||
'read' => 0,
|
||||
]);
|
||||
AbstractModel::transaction(function () use ($search_key, $dialogMsg, $extra_data) {
|
||||
AbstractModel::transaction(function () use ($search_key, $dialogMsg) {
|
||||
$dialogMsg->send = 1;
|
||||
$dialogMsg->generateKeyAndSave($search_key);
|
||||
//
|
||||
if ($extra_data) {
|
||||
WebSocketDialogMsgExtra::createInstance([
|
||||
'msg_id' => $dialogMsg->id,
|
||||
'data' => Base::array2json($extra_data),
|
||||
])->save();
|
||||
}
|
||||
//
|
||||
WebSocketDialogSession::updateTitle($dialogMsg->session_id, $dialogMsg);
|
||||
//
|
||||
if ($dialogMsg->type === 'meeting') {
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Module\Base;
|
||||
|
||||
/**
|
||||
* App\Models\WebSocketDialogMsgExtra
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $msg_id 消息ID
|
||||
* @property string|null $data 长内容
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\WebSocketDialogMsg|null $webSocketDialogMsg
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra whereData($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra whereMsgId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgExtra whereUpdatedAt($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class WebSocketDialogMsgExtra extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @param $value
|
||||
* @return array
|
||||
*/
|
||||
public function getDataAttribute($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
return Base::json2array($value);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 关联到消息
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function webSocketDialogMsg(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WebSocketDialogMsg::class, 'msg_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
}
|
||||
|
||||
// 判断消息是否存在
|
||||
$msg = WebSocketDialogMsg::with(['user', 'extra'])->find($this->msgId);
|
||||
$msg = WebSocketDialogMsg::with(['user'])->find($this->msgId);
|
||||
if (empty($msg)) {
|
||||
return;
|
||||
}
|
||||
@ -523,16 +523,6 @@ class BotReceiveMsgTask extends AbstractTask
|
||||
{$sendText}
|
||||
EOF;
|
||||
}
|
||||
// 处理额外数据
|
||||
if ($msg->extra && $msg->extra->data) {
|
||||
$extraData = $msg->extra->data;
|
||||
if ($extraData['system_prompt']) {
|
||||
$extras['system_message'] = $extraData['system_prompt'];
|
||||
}
|
||||
if ($extraData['context_prompt']) {
|
||||
$sendText = $extraData['context_prompt'] . $sendText;
|
||||
}
|
||||
}
|
||||
$webhookUrl = "http://nginx/ai/chat";
|
||||
} else {
|
||||
// 用户机器人
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateWebSocketDialogMsgExtrasTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('web_socket_dialog_msg_extras', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
|
||||
$table->longText('data')->nullable()->comment('额外数据');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('msg_id')->references('id')->on('web_socket_dialog_msgs')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('web_socket_dialog_msg_extras');
|
||||
}
|
||||
}
|
||||
@ -37,7 +37,7 @@
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon type="ios-sync" class="ai-assistant-output-icon ai-spin"/>
|
||||
<Icon type="ios-sync" class="ai-assistant-output-icon icon-loading"/>
|
||||
<span class="ai-assistant-output-status">{{ $L('生成中...') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
@ -93,8 +93,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from "vuex";
|
||||
import emitter from "../store/events";
|
||||
import {SSEClient} from "../utils";
|
||||
import {AIBotList, AIModelNames} from "../utils/ai";
|
||||
import DialogMarkdown from "../pages/manage/components/DialogMarkdown.vue";
|
||||
|
||||
@ -145,24 +145,20 @@ export default {
|
||||
|
||||
// 响应渲染
|
||||
responses: [],
|
||||
pendingResponses: [],
|
||||
responseSeed: 1,
|
||||
maxResponses: 5,
|
||||
activeStreams: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
emitter.on('openAIAssistant', this.onOpenAIAssistant);
|
||||
emitter.on('streamMsgData', this.onStreamMsgData);
|
||||
this.initModelCache();
|
||||
},
|
||||
beforeDestroy() {
|
||||
emitter.off('openAIAssistant', this.onOpenAIAssistant);
|
||||
emitter.off('streamMsgData', this.onStreamMsgData);
|
||||
this.clearActiveStreams();
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
'cacheDialogs',
|
||||
]),
|
||||
selectedModelOption({modelMap, inputModel}) {
|
||||
return modelMap[inputModel] || null;
|
||||
},
|
||||
@ -190,8 +186,8 @@ export default {
|
||||
this.inputOnBeforeSend = params.onBeforeSend || null;
|
||||
}
|
||||
this.responses = [];
|
||||
this.pendingResponses = [];
|
||||
this.showModal = true;
|
||||
this.clearActiveStreams();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -235,8 +231,7 @@ export default {
|
||||
});
|
||||
this.normalizeModelOptions(data);
|
||||
} catch (error) {
|
||||
const msg = error?.msg || error?.message || error || this.$L('获取模型列表失败');
|
||||
$A.modalError(msg);
|
||||
$A.modalError(error?.msg || error || '获取模型列表失败');
|
||||
} finally {
|
||||
this.modelsLoading = false;
|
||||
}
|
||||
@ -346,39 +341,38 @@ export default {
|
||||
return;
|
||||
}
|
||||
const rawValue = this.inputValue || '';
|
||||
const content = rawValue.trim();
|
||||
if (!content) {
|
||||
$A.messageWarning(this.$L('请输入你的问题'));
|
||||
return;
|
||||
}
|
||||
const modelOption = this.selectedModelOption;
|
||||
if (!modelOption) {
|
||||
$A.messageWarning(this.$L('请选择模型'));
|
||||
$A.messageWarning('请选择模型');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadIng++;
|
||||
let responseEntry = null;
|
||||
try {
|
||||
const {dialogId, userid} = await this.ensureAiDialog(modelOption.type);
|
||||
if (this.shouldCreateNewSession) {
|
||||
await this.createAiSession(dialogId);
|
||||
}
|
||||
const preparedPayload = await this.buildPayloadData({
|
||||
prompt: rawValue,
|
||||
model_type: modelOption.type,
|
||||
model_name: modelOption.value,
|
||||
}) || {};
|
||||
const context = this.buildContextMessages(preparedPayload);
|
||||
|
||||
responseEntry = this.createResponseEntry({
|
||||
modelOption,
|
||||
dialogId,
|
||||
prompt: rawValue,
|
||||
});
|
||||
this.scrollResponsesToBottom();
|
||||
const message = await this.sendAiMessage(dialogId, rawValue, modelOption.value);
|
||||
if (responseEntry) {
|
||||
responseEntry.userid = userid;
|
||||
responseEntry.message = message;
|
||||
responseEntry.messageId = message?.id || 0;
|
||||
}
|
||||
|
||||
const streamKey = await this.fetchStreamKey({
|
||||
model_type: modelOption.type,
|
||||
model_name: modelOption.value,
|
||||
context,
|
||||
});
|
||||
|
||||
this.inputValue = '';
|
||||
this.startStream(streamKey, responseEntry);
|
||||
} catch (error) {
|
||||
const msg = error?.msg || error?.message || error || this.$L('发送失败');
|
||||
const msg = error?.msg || error || '发送失败';
|
||||
if (responseEntry) {
|
||||
this.markResponseError(responseEntry, msg);
|
||||
}
|
||||
@ -388,113 +382,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 生成AI机器人邮箱
|
||||
*/
|
||||
getAiEmail(type) {
|
||||
return `ai-${type}@bot.system`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 在缓存会话里查找AI
|
||||
*/
|
||||
findAiDialog({type, userid}) {
|
||||
const email = this.getAiEmail(type);
|
||||
return this.cacheDialogs.find(dialog => {
|
||||
if (!dialog) {
|
||||
return false;
|
||||
}
|
||||
if (userid && dialog.dialog_user && dialog.dialog_user.userid === userid) {
|
||||
return true;
|
||||
}
|
||||
if (dialog.dialog_user && dialog.dialog_user.email === email) {
|
||||
return true;
|
||||
}
|
||||
if (dialog.email === email) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 确保能够打开AI会话
|
||||
*/
|
||||
async ensureAiDialog(type) {
|
||||
let dialog = this.findAiDialog({type});
|
||||
let userid = dialog?.dialog_user?.userid || dialog?.userid || 0;
|
||||
if (dialog) {
|
||||
return {
|
||||
dialogId: dialog.id,
|
||||
userid,
|
||||
};
|
||||
}
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'users/search/ai',
|
||||
data: {type},
|
||||
});
|
||||
userid = data?.userid;
|
||||
if (!userid) {
|
||||
throw new Error(this.$L('未找到AI机器人'));
|
||||
}
|
||||
const dialogResult = await this.$store.dispatch("call", {
|
||||
url: 'dialog/open/user',
|
||||
data: {userid},
|
||||
method: 'get',
|
||||
});
|
||||
dialog = dialogResult?.data || null;
|
||||
if (dialog) {
|
||||
this.$store.dispatch("saveDialog", dialog);
|
||||
}
|
||||
if (!dialog) {
|
||||
throw new Error(this.$L('AI对话打开失败'));
|
||||
}
|
||||
return {
|
||||
dialogId: dialog.id,
|
||||
userid,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建新的AI会话session
|
||||
*/
|
||||
async createAiSession(dialogId) {
|
||||
if (!dialogId) {
|
||||
return;
|
||||
}
|
||||
await this.$store.dispatch("call", {
|
||||
url: 'dialog/session/create',
|
||||
data: {dialog_id: dialogId},
|
||||
});
|
||||
await this.$store.dispatch("clearDialogMsgs", {
|
||||
id: dialogId,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
*/
|
||||
async sendAiMessage(dialogId, text, model) {
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'dialog/msg/sendtext',
|
||||
method: 'post',
|
||||
data: await this.buildPayloadData({
|
||||
dialog_id: dialogId,
|
||||
text,
|
||||
model_name: model,
|
||||
}),
|
||||
});
|
||||
if (data) {
|
||||
this.$store.dispatch("saveDialogMsg", data);
|
||||
this.$store.dispatch("increaseTaskMsgNum", {id: data.dialog_id});
|
||||
if (data.reply_id) {
|
||||
this.$store.dispatch("increaseMsgReplyNum", {id: data.reply_id});
|
||||
}
|
||||
this.$store.dispatch("updateDialogLastMsg", data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 构建最终发送的数据
|
||||
*/
|
||||
@ -518,14 +405,173 @@ export default {
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 组装上下文
|
||||
*/
|
||||
buildContextMessages({prompt, system_prompt, context_prompt}) {
|
||||
const context = [];
|
||||
const pushContext = (role, value) => {
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return;
|
||||
}
|
||||
const content = String(value).trim();
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const lastEntry = context[context.length - 1];
|
||||
if (lastEntry && lastEntry[0] === role) {
|
||||
lastEntry[1] = lastEntry[1] ? `${lastEntry[1]}\n${content}` : content;
|
||||
return;
|
||||
}
|
||||
context.push([role, content]);
|
||||
};
|
||||
if (system_prompt) {
|
||||
pushContext('system', String(system_prompt));
|
||||
}
|
||||
if (context_prompt) {
|
||||
pushContext('human', String(context_prompt));
|
||||
}
|
||||
this.responses.forEach(item => {
|
||||
if (item.prompt) {
|
||||
pushContext('human', item.prompt);
|
||||
}
|
||||
if (item.text) {
|
||||
pushContext('assistant', item.text);
|
||||
}
|
||||
});
|
||||
if (prompt && prompt.trim()) {
|
||||
pushContext('human', prompt.trim());
|
||||
}
|
||||
return context;
|
||||
},
|
||||
|
||||
/**
|
||||
* 请求 stream_key
|
||||
*/
|
||||
async fetchStreamKey({model_type, model_name, context}) {
|
||||
const payload = {
|
||||
model_type,
|
||||
model_name,
|
||||
context: JSON.stringify(context || []),
|
||||
};
|
||||
const {data} = await this.$store.dispatch("call", {
|
||||
url: 'assistant/auth',
|
||||
method: 'post',
|
||||
data: payload,
|
||||
});
|
||||
const streamKey = data?.stream_key || '';
|
||||
if (!streamKey) {
|
||||
throw new Error('获取 stream_key 失败');
|
||||
}
|
||||
return streamKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动 SSE 订阅
|
||||
*/
|
||||
startStream(streamKey, responseEntry) {
|
||||
if (!streamKey) {
|
||||
throw new Error('获取 stream_key 失败');
|
||||
}
|
||||
const sse = new SSEClient($A.mainUrl(`ai/invoke/stream/${streamKey}`));
|
||||
this.registerStream(sse);
|
||||
sse.subscribe(['append', 'replace', 'done'], (type, event) => {
|
||||
switch (type) {
|
||||
case 'append':
|
||||
case 'replace':
|
||||
this.handleStreamChunk(responseEntry, type, event);
|
||||
break;
|
||||
case 'done':
|
||||
if (responseEntry && responseEntry.status !== 'error' && responseEntry.text) {
|
||||
responseEntry.status = 'completed';
|
||||
}
|
||||
this.releaseStream(sse);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return sse;
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理 SSE 片段
|
||||
*/
|
||||
handleStreamChunk(responseEntry, type, event) {
|
||||
if (!responseEntry) {
|
||||
return;
|
||||
}
|
||||
const payload = this.parseStreamPayload(event);
|
||||
const chunk = this.resolveStreamContent(payload);
|
||||
if (type === 'replace') {
|
||||
responseEntry.text = chunk;
|
||||
} else {
|
||||
responseEntry.text += chunk;
|
||||
}
|
||||
responseEntry.status = 'streaming';
|
||||
this.scrollResponsesToBottom();
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析 SSE 数据
|
||||
*/
|
||||
parseStreamPayload(event) {
|
||||
if (!event || !event.data) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(event.data);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 SSE 文本
|
||||
*/
|
||||
resolveStreamContent(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return '';
|
||||
}
|
||||
if (typeof payload.content === 'string') {
|
||||
return payload.content;
|
||||
}
|
||||
if (typeof payload.c === 'string') {
|
||||
return payload.c;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
registerStream(sse) {
|
||||
if (!sse) {
|
||||
return;
|
||||
}
|
||||
this.activeStreams.push(sse);
|
||||
},
|
||||
|
||||
releaseStream(sse) {
|
||||
const index = this.activeStreams.indexOf(sse);
|
||||
if (index > -1) {
|
||||
this.activeStreams.splice(index, 1);
|
||||
}
|
||||
sse.unsunscribe();
|
||||
},
|
||||
|
||||
clearActiveStreams() {
|
||||
this.activeStreams.forEach(sse => {
|
||||
try {
|
||||
sse.unsunscribe();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
this.activeStreams = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建响应卡片
|
||||
*/
|
||||
createResponseEntry({modelOption, dialogId, prompt}) {
|
||||
createResponseEntry({modelOption, prompt}) {
|
||||
const entry = {
|
||||
localId: this.responseSeed++,
|
||||
id: null,
|
||||
dialogId,
|
||||
model: modelOption.value,
|
||||
modelLabel: modelOption.label,
|
||||
type: modelOption.type,
|
||||
@ -533,65 +579,21 @@ export default {
|
||||
text: '',
|
||||
status: 'waiting',
|
||||
error: '',
|
||||
userid: 0,
|
||||
message: null,
|
||||
messageId: 0,
|
||||
applyLoading: false,
|
||||
};
|
||||
this.responses.push(entry);
|
||||
this.pendingResponses.push(entry);
|
||||
if (this.responses.length > this.maxResponses) {
|
||||
const removed = this.responses.shift();
|
||||
this.pendingResponses = this.pendingResponses.filter(item => item !== removed);
|
||||
this.responses.shift();
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理流式输出
|
||||
*/
|
||||
onStreamMsgData(data) {
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
let response = this.responses.find(item => item.id === data.id);
|
||||
if (!response && data.reply_id) {
|
||||
response = this.responses.find(item => item.messageId === data.reply_id);
|
||||
}
|
||||
if (!response) {
|
||||
const index = this.pendingResponses.findIndex(item => {
|
||||
if (data.reply_id && item.messageId) {
|
||||
return item.messageId === data.reply_id;
|
||||
}
|
||||
if (data.dialog_id && item.dialogId) {
|
||||
return item.dialogId === data.dialog_id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
response = this.pendingResponses.splice(index, 1)[0];
|
||||
response.id = data.id;
|
||||
}
|
||||
const chunk = typeof data.text === 'string' ? data.text : '';
|
||||
if (data.type === 'replace') {
|
||||
response.text = chunk;
|
||||
response.status = 'completed';
|
||||
} else {
|
||||
response.text += chunk;
|
||||
response.status = 'streaming';
|
||||
}
|
||||
this.scrollResponsesToBottom();
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记响应失败
|
||||
*/
|
||||
markResponseError(response, msg) {
|
||||
response.status = 'error';
|
||||
response.error = msg;
|
||||
this.pendingResponses = this.pendingResponses.filter(item => item !== response);
|
||||
},
|
||||
|
||||
/**
|
||||
@ -602,7 +604,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (!response.text) {
|
||||
$A.messageWarning(this.$L('暂无可用内容'));
|
||||
$A.messageWarning('暂无可用内容');
|
||||
return;
|
||||
}
|
||||
if (typeof this.inputOnOk !== 'function') {
|
||||
@ -611,12 +613,9 @@ export default {
|
||||
}
|
||||
response.applyLoading = true;
|
||||
const payload = {
|
||||
dialogId: response.dialogId,
|
||||
userid: response.userid,
|
||||
model: response.model,
|
||||
type: response.type,
|
||||
content: response.prompt,
|
||||
message: response.message,
|
||||
aiContent: response.text,
|
||||
};
|
||||
try {
|
||||
@ -625,8 +624,7 @@ export default {
|
||||
result.then(() => {
|
||||
this.closeAssistant();
|
||||
}).catch(error => {
|
||||
const msg = error?.msg || error?.message || error || this.$L('应用失败');
|
||||
$A.modalError(msg);
|
||||
$A.modalError(error?.msg || error || '应用失败');
|
||||
}).finally(() => {
|
||||
response.applyLoading = false;
|
||||
});
|
||||
@ -636,8 +634,7 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
response.applyLoading = false;
|
||||
const msg = error?.msg || error?.message || error || this.$L('应用失败');
|
||||
$A.modalError(msg);
|
||||
$A.modalError(error?.msg || error || '应用错误');
|
||||
}
|
||||
},
|
||||
|
||||
@ -651,7 +648,7 @@ export default {
|
||||
this.closing = true;
|
||||
this.showModal = false;
|
||||
this.responses = [];
|
||||
this.pendingResponses = [];
|
||||
this.clearActiveStreams();
|
||||
setTimeout(() => {
|
||||
this.closing = false;
|
||||
}, 300);
|
||||
@ -675,6 +672,7 @@ export default {
|
||||
<style lang="scss">
|
||||
.ai-assistant-modal {
|
||||
.ivu-modal {
|
||||
transition: max-width 0.3s ease;
|
||||
.ivu-modal-header {
|
||||
padding-left: 30px !important;
|
||||
padding-right: 30px !important;
|
||||
@ -815,17 +813,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ai-assistant-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-spin {
|
||||
animation: ai-assistant-spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1914,17 +1914,17 @@ export default {
|
||||
return sendData;
|
||||
}
|
||||
try {
|
||||
const {data: extraData} = await this.$store.dispatch('call', {
|
||||
url: 'dialog/msg/aiprompt',
|
||||
const {data: promptData} = await this.$store.dispatch('call', {
|
||||
url: 'assistant/dialog/prompt',
|
||||
data: {
|
||||
dialog_id: this.dialogId,
|
||||
content: sendData.text,
|
||||
content: sendData.prompt,
|
||||
draft: this.value || '',
|
||||
quote_id: this.quoteData?.id || 0,
|
||||
},
|
||||
});
|
||||
if ($A.isJson(extraData)) {
|
||||
sendData.extra_data = extraData;
|
||||
if ($A.isJson(promptData)) {
|
||||
Object.assign(sendData, promptData);
|
||||
}
|
||||
return sendData;
|
||||
} catch (error) {
|
||||
|
||||
6
resources/assets/js/store/actions.js
vendored
6
resources/assets/js/store/actions.js
vendored
@ -4220,10 +4220,8 @@ export default {
|
||||
const data = $A.jsonParse(e.data);
|
||||
dispatch("streamMsgData", {
|
||||
type,
|
||||
id: e.lastEventId, // 消息ID
|
||||
text: data.c || data.content, // 消息内容
|
||||
reply_id: data.r || 0, // 回应的消息ID
|
||||
dialog_id: data.d || 0, // 会话ID
|
||||
id: e.lastEventId,
|
||||
text: data.content
|
||||
})
|
||||
break;
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ use App\Http\Controllers\Api\PublicController;
|
||||
use App\Http\Controllers\Api\ReportController;
|
||||
use App\Http\Controllers\Api\SystemController;
|
||||
use App\Http\Controllers\Api\ApproveController;
|
||||
use App\Http\Controllers\Api\AssistantController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ComplaintController;
|
||||
|
||||
@ -53,6 +54,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
// 审批
|
||||
Route::any('approve/{method}', ApproveController::class);
|
||||
Route::any('approve/{method}/{action}', ApproveController::class);
|
||||
// 助手
|
||||
Route::any('assistant/{method}', AssistantController::class);
|
||||
Route::any('assistant/{method}/{action}', AssistantController::class);
|
||||
// 投诉
|
||||
Route::any('complaint/{method}', ComplaintController::class);
|
||||
Route::any('complaint/{method}/{action}', ComplaintController::class);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user