diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index 9ce2a944b..65ab05e68 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -1027,25 +1027,25 @@ class DialogController extends AbstractController } /** - * @api {post} api/dialog/msg/ai_generate 使用 AI 助手生成消息 + * @api {post} api/dialog/msg/aiprompt AI 提示词助手 * - * @apiDescription 需要token身份,根据上下文自动生成拟发送的聊天消息 + * @apiDescription 需要token身份,整理当前会话的系统提示词与上下文信息 * @apiVersion 1.0.0 * @apiGroup dialog - * @apiName msg__ai_generate + * @apiName msg__aiprompt * * @apiParam {Number} dialog_id 对话ID - * @apiParam {String} content 消息需求描述 + * @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.text AI 生成的消息文本(Markdown 格式) - * @apiSuccess {String} data.html AI 生成的消息内容(HTML 格式) + * @apiSuccess {String} data.system_prompt AI 使用的系统提示词 + * @apiSuccess {String} data.context_prompt AI 使用的上下文提示词 */ - public function msg__ai_generate() + public function msg__aiprompt() { $user = User::auth(); $user->checkChatInformation(); @@ -1055,9 +1055,6 @@ class DialogController extends AbstractController if ($dialog_id <= 0) { return Base::retError('参数错误'); } - if ($content === '') { - return Base::retError('消息需求描述不能为空'); - } $dialog = WebSocketDialog::checkDialog($dialog_id); @@ -1144,12 +1141,16 @@ class DialogController extends AbstractController } // 生成消息 - $result = AI::generateMessage($content, $context); - if (Base::isError($result)) { - return Base::retError('生成消息失败', $result); + $systemPrompt = AI::messageSystemPrompt(); + $contextPrompt = AI::buildMessageContextPrompt($context); + if ($content) { + $contextPrompt .= "\n\n请根据以上信息,结合提示词生成一条待发送的消息:\n\n"; } - return Base::retSuccess('生成消息成功', $result['data']); + return Base::retSuccess('success', [ + 'system_prompt' => $systemPrompt, + 'context_prompt' => $contextPrompt, + ]); } /** @@ -1179,6 +1180,7 @@ class DialogController extends AbstractController * - no: 正常发送(默认) * - yes: 静默发送 * @apiParam {String} [model_name] 模型名称(仅AI机器人支持) + * @apiParam {Object} [extra_data] 附加数据(保存到附加表) * * @apiSuccess {Number} ret 返回状态码(1正确、0错误) * @apiSuccess {String} msg 返回信息(错误描述) @@ -1200,6 +1202,7 @@ 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 = []; @@ -1233,14 +1236,14 @@ class DialogController extends AbstractController $text = WebSocketDialogMsg::formatMsg($text, $dialog_id); } $strlen = mb_strlen($text); - $noimglen = mb_strlen(preg_replace("/]*?>/i", "", $text)); + $reallen = mb_strlen(preg_replace("/]*?>/i", "", $text)); if ($strlen < 1) { return Base::retError('消息内容不能为空'); } - if ($noimglen > 200000) { + if ($reallen > 200000) { return Base::retError('消息内容最大不能超过200000字'); } - if ($noimglen > 5000) { + if ($reallen > 5000) { // 内容过长转成文件发送 $path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/"; Base::makeDir(public_path($path)); @@ -1282,7 +1285,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); + $result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'longtext', $msgData, $user->userid, false, false, $silence, $key, $extra_data); } else { $msgData = ['text' => $text]; if ($markdown) { @@ -1291,7 +1294,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); + $result = WebSocketDialogMsg::sendMsg($action, $dialog_id, 'text', $msgData, $user->userid, false, false, $silence, $key, $extra_data); } } return $result; @@ -3134,11 +3137,11 @@ class DialogController extends AbstractController // WebSocketDialog::checkDialog($dialog_id); $strlen = mb_strlen($text); - $noimglen = mb_strlen(preg_replace("/]*?>/i", "", $text)); + $reallen = mb_strlen(preg_replace("/]*?>/i", "", $text)); if ($strlen < 1 || empty($list)) { return Base::retError('内容不能为空'); } - if ($noimglen > 200000) { + if ($reallen > 200000) { return Base::retError('内容最大不能超过200000字'); } // @@ -3283,11 +3286,11 @@ class DialogController extends AbstractController }); } else { $strlen = mb_strlen($text); - $noimglen = mb_strlen(preg_replace("/]*?>/i", "", $text)); + $reallen = mb_strlen(preg_replace("/]*?>/i", "", $text)); if ($strlen < 1) { return Base::retError('内容不能为空'); } - if ($noimglen > 200000) { + if ($reallen > 200000) { return Base::retError('内容最大不能超过200000字'); } $msgData = [ diff --git a/app/Models/WebSocketDialogMsg.php b/app/Models/WebSocketDialogMsg.php index 716429299..cf33a03f4 100644 --- a/app/Models/WebSocketDialogMsg.php +++ b/app/Models/WebSocketDialogMsg.php @@ -43,6 +43,7 @@ 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() @@ -111,6 +112,14 @@ 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 @@ -1233,9 +1242,10 @@ 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) + 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) { $link = 0; $mtype = $type; @@ -1380,10 +1390,17 @@ class WebSocketDialogMsg extends AbstractModel 'msg' => $msg, 'read' => 0, ]); - AbstractModel::transaction(function () use ($search_key, $dialogMsg) { + AbstractModel::transaction(function () use ($search_key, $dialogMsg, $extra_data) { $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') { diff --git a/app/Models/WebSocketDialogMsgExtra.php b/app/Models/WebSocketDialogMsgExtra.php new file mode 100644 index 000000000..1d9ef32fc --- /dev/null +++ b/app/Models/WebSocketDialogMsgExtra.php @@ -0,0 +1,56 @@ +belongsTo(WebSocketDialogMsg::class, 'msg_id', 'id'); + } +} + diff --git a/app/Module/AI.php b/app/Module/AI.php index 3a1101142..0152880e0 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -556,72 +556,6 @@ class AI ]); } - /** - * 通过 openAI 生成聊天消息 - * @param string $text 用户提供的提示词 - * @param array $context 上下文信息 - * @return array - */ - public static function generateMessage($text, $context = []) - { - $text = trim((string)$text); - if ($text === '') { - return Base::retError("消息提示词不能为空"); - } - - $contextPrompt = self::buildMessageContextPrompt($context); - - $post = json_encode([ - "model" => "gpt-5-mini", - "reasoning_effort" => "minimal", - "messages" => [ - [ - "role" => "system", - "content" => << "user", - "content" => ($contextPrompt ? $contextPrompt . "\n\n" : "") . "请根据以上信息,并结合以下提示词生成一条待发送的消息:\n\n" . $text - ], - ], - ]); - - $ai = new self($post); - $ai->setTimeout(45); - - $res = $ai->request(); - if (Base::isError($res)) { - return Base::retError("消息生成失败", $res); - } - - $content = trim($res['data']); - $content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content); - $content = preg_replace('/\s*```\s*$/', '', $content); - $content = trim($content); - - if ($content === '') { - return Base::retError("消息生成结果为空"); - } - - return Base::retSuccess("success", [ - 'text' => $content, - 'html' => Base::markdown2html($content), - ]); - } - /** * 对工作汇报内容进行分析 * @param Report $report @@ -836,7 +770,25 @@ class AI return empty($prompts) ? "" : implode("\n", $prompts); } - private static function buildMessageContextPrompt($context) + public static function messageSystemPrompt() + { + return <<find($this->msgId); + $msg = WebSocketDialogMsg::with(['user', 'extra'])->find($this->msgId); if (empty($msg)) { return; } @@ -523,6 +523,16 @@ 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 { // 用户机器人 diff --git a/database/migrations/2025_11_07_183944_create_web_socket_dialog_msg_extras_table.php b/database/migrations/2025_11_07_183944_create_web_socket_dialog_msg_extras_table.php new file mode 100644 index 000000000..6b812ae88 --- /dev/null +++ b/database/migrations/2025_11_07_183944_create_web_socket_dialog_msg_extras_table.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/resources/assets/js/components/AIAssistant.vue b/resources/assets/js/components/AIAssistant.vue index 998340fe9..76313a8ee 100644 --- a/resources/assets/js/components/AIAssistant.vue +++ b/resources/assets/js/components/AIAssistant.vue @@ -133,6 +133,7 @@ export default { inputAutosize: this.defaultInputAutosize, inputMaxlength: this.defaultInputMaxlength, inputOnOk: null, + inputOnBeforeSend: null, // 模型选择 inputModel: '', @@ -186,6 +187,7 @@ export default { this.inputAutosize = params.autosize || this.defaultInputAutosize; this.inputMaxlength = params.maxlength || this.defaultInputMaxlength; this.inputOnOk = params.onOk || null; + this.inputOnBeforeSend = params.onBeforeSend || null; } this.responses = []; this.pendingResponses = []; @@ -368,7 +370,7 @@ export default { prompt: rawValue, }); this.scrollResponsesToBottom(); - const message = await this.sendAiMessage(dialogId, this.formatPlainText(rawValue), modelOption.value); + const message = await this.sendAiMessage(dialogId, rawValue, modelOption.value); if (responseEntry) { responseEntry.userid = userid; responseEntry.message = message; @@ -476,11 +478,11 @@ export default { const {data} = await this.$store.dispatch("call", { url: 'dialog/msg/sendtext', method: 'post', - data: { + data: await this.buildPayloadData({ dialog_id: dialogId, text, model_name: model, - }, + }), }); if (data) { this.$store.dispatch("saveDialogMsg", data); @@ -494,15 +496,26 @@ export default { }, /** - * 将纯文本转换成HTML + * 构建最终发送的数据 */ - formatPlainText(text) { - const escaped = `${text}` - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - return `

${escaped}

`; + async buildPayloadData(data) { + if (typeof this.inputOnBeforeSend !== 'function') { + return data; + } + try { + const result = this.inputOnBeforeSend(data); + if (result && typeof result.then === 'function') { + const resolved = await result; + if ($A.isJson(resolved)) { + return resolved; + } + } else if ($A.isJson(result)) { + return result; + } + } catch (e) { + console.warn('[AIAssistant] onBeforeSend error:', e); + } + return data; }, /** @@ -681,16 +694,19 @@ export default { display: flex; flex-direction: column; gap: 16px; + max-height: calc(100vh - 344px); + @media (height <= 900px) { + max-height: calc(100vh - 214px); + } + .ai-assistant-output { + flex: 1; + min-height: 0; padding: 12px; border-radius: 8px; background: #f8f9fb; border: 1px solid rgba(0, 0, 0, 0.04); - max-height: calc(100vh - 390px); overflow-y: auto; - @media (height <= 900px) { - max-height: calc(100vh - 260px); - } } .ai-assistant-output-item + .ai-assistant-output-item { diff --git a/resources/assets/js/pages/manage/components/ChatInput/index.vue b/resources/assets/js/pages/manage/components/ChatInput/index.vue index 8a3382071..a1809a089 100755 --- a/resources/assets/js/pages/manage/components/ChatInput/index.vue +++ b/resources/assets/js/pages/manage/components/ChatInput/index.vue @@ -342,7 +342,7 @@ import clickoutside from "../../../../directives/clickoutside"; import longpress from "../../../../directives/longpress"; import {inputLoadAdd, inputLoadIsLast, inputLoadRemove} from "./one"; import {languageList, languageName} from "../../../../language"; -import {isMarkdownFormat} from "../../../../utils/markdown"; +import {isMarkdownFormat, MarkdownConver} from "../../../../utils/markdown"; import emitter from "../../../../store/events"; import historyMixin from "./history"; @@ -1904,63 +1904,45 @@ export default { return; } if (!this.dialogId) { - $A.messageWarning(this.$L('当前未选择会话')); + $A.messageWarning('当前未选择会话'); return; } - let canceled = false; - $A.modalInput({ - title: 'AI 生成', - placeholder: '请简要描述消息的主题、语气或要点,AI 将生成完整消息', - inputProps: { - type: 'textarea', - rows: 2, - autosize: {minRows: 2, maxRows: 6}, - maxlength: 500, - }, - onCancel: () => { - canceled = true; - }, - onOk: (value) => { - if (!value) { - return '请输入消息需求'; + emitter.emit('openAIAssistant', { + placeholder: this.$L('请简要描述消息的主题、语气或要点,AI 将生成完整消息'), + onBeforeSend: async (sendData) => { + if (!sendData) { + return sendData; } - return new Promise((resolve, reject) => { - if (canceled) { - reject(); - return; - } - this.$store.dispatch('call', { - url: 'dialog/msg/ai_generate', + try { + const {data: extraData} = await this.$store.dispatch('call', { + url: 'dialog/msg/aiprompt', data: { dialog_id: this.dialogId, - content: value, + content: sendData.text, draft: this.value || '', quote_id: this.quoteData?.id || 0, }, - timeout: 45 * 1000, - }).then(({data}) => { - const html = data && (data.html || data.text) ? (data.html || data.text) : ''; - if (canceled) { - resolve(); - return; - } - if (!html) { - reject(this.$L('AI 未生成内容')); - return; - } - this.$emit('input', html); - this.$nextTick(() => this.focus()); - resolve(); - }).catch(({msg}) => { - if (canceled) { - resolve(); - return; - } - reject(msg); }); - }); - } - }) + if ($A.isJson(extraData)) { + sendData.extra_data = extraData; + } + return sendData; + } catch (error) { + const msg = error?.msg || 'AI 提示生成失败'; + $A.modalError(msg); + throw error; + } + }, + onOk: ({aiContent}) => { + if (!aiContent) { + $A.messageWarning('AI 未生成内容'); + return; + } + const html = MarkdownConver(aiContent); + this.$emit('input', html); + this.$nextTick(() => this.focus()); + }, + }); }, onFullInput() {