feat: 添加 AI 助手生成消息功能

- 在 DialogController 中新增 msg__ai_generate 接口,支持根据用户需求自动生成聊天消息
- 在 AI 模块中实现 generateMessage 方法,处理消息生成逻辑
- 更新前端 ChatInput 组件,添加 AI 生成按钮,集成消息生成请求
- 增强用户交互体验,支持输入消息主题和要点
This commit is contained in:
kuaifan 2025-09-23 14:05:01 +08:00
parent c190aab8b9
commit 7f6abc331b
3 changed files with 276 additions and 0 deletions

View File

@ -989,6 +989,100 @@ class DialogController extends AbstractController
return Base::retSuccess('success');
}
/**
* @api {post} api/dialog/msg/ai_generate 21.1 使用 AI 助手生成消息
*
* @apiDescription 需要token身份根据上下文自动生成拟发送的聊天消息
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__ai_generate
*
* @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.text AI 生成的消息文本Markdown 格式)
* @apiSuccess {String} data.html AI 生成的消息内容HTML 格式)
*/
public function msg__ai_generate()
{
$user = User::auth();
$user->checkChatInformation();
//
$dialog_id = intval(Request::input('dialog_id'));
$content = trim(Request::input('content', ''));
if ($dialog_id <= 0) {
return Base::retError('参数错误');
}
if ($content === '') {
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;
}
$recentMessages = WebSocketDialogMsg::whereDialogId($dialog_id)
->orderByDesc('id')
->take(6)
->with('user')
->get();
if ($recentMessages->isNotEmpty()) {
$context['recent_messages'] = $recentMessages->reverse()->map(function ($msg) {
return [
'sender' => $msg->user->nickname ?? ('用户' . $msg->userid),
'summary' => WebSocketDialogMsg::previewMsg($msg),
];
})->filter(function ($item) {
return !empty($item['summary']);
})->values()->all();
}
$result = AI::generateMessage($content, $context);
if (Base::isError($result)) {
return Base::retError('生成消息失败', $result);
}
return Base::retSuccess('生成消息成功', $result['data']);
}
/**
* @api {post} api/dialog/msg/sendtext 21. 发送消息
*

View File

@ -551,6 +551,71 @@ 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-nano",
"messages" => [
[
"role" => "system",
"content" => <<<EOF
你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
写作要求:
1. 根据用户提供的需求与上下文生成完整消息,语气需符合业务沟通场景,保持真诚、礼貌且高效
2. 默认使用简洁的短段落,可使用 Markdown 基础格式(加粗、列表、引用)增强结构,但不要输出代码块或 JSON
3. 如果上下文包含引用信息或草稿,请在消息中自然呼应相关要点
4. 如无特别说明,将消息长度控制在 60-180 字;若需更短或更长,遵循用户描述
5. 如需提出行动或问题,请明确表达,避免含糊
输出规范:
- 仅返回可直接发送的消息内容
- 禁止在内容前后添加额外说明、标签或引导语
EOF
],
[
"role" => "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 array $context 上下文信息
@ -636,6 +701,62 @@ class AI
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function buildMessageContextPrompt($context)
{
$prompts = [];
if (!empty($context['dialog_name']) || !empty($context['dialog_type']) || !empty($context['group_type'])) {
$prompts[] = "## 会话信息";
if (!empty($context['dialog_name'])) {
$prompts[] = "名称:" . Base::cutStr($context['dialog_name'], 60);
}
if (!empty($context['dialog_type'])) {
$typeMap = ['group' => '群聊', 'user' => '单聊'];
$prompts[] = "类型:" . ($typeMap[$context['dialog_type']] ?? $context['dialog_type']);
}
if (!empty($context['group_type'])) {
$prompts[] = "分类:" . Base::cutStr($context['group_type'], 60);
}
}
if (!empty($context['members']) && is_array($context['members'])) {
$members = array_slice(array_filter($context['members']), 0, 10);
if (!empty($members)) {
$prompts[] = "## 会话成员";
$prompts[] = implode("", array_map(fn($name) => Base::cutStr($name, 30), $members));
}
}
if (!empty($context['recent_messages']) && is_array($context['recent_messages'])) {
$prompts[] = "## 最近消息";
foreach ($context['recent_messages'] as $item) {
$sender = Base::cutStr(trim($item['sender'] ?? ''), 40) ?: '成员';
$summary = Base::cutStr(trim($item['summary'] ?? ''), 120);
if ($summary !== '') {
$prompts[] = "- {$sender}{$summary}";
}
}
}
if (!empty($context['quote_summary'])) {
$prompts[] = "## 引用消息";
$quoteUser = Base::cutStr(trim($context['quote_user'] ?? ''), 40);
$quoteText = Base::cutStr(trim($context['quote_summary']), 200);
if ($quoteUser !== '') {
$prompts[] = "{$quoteUser}{$quoteText}";
} else {
$prompts[] = $quoteText;
}
}
if (!empty($context['current_draft'])) {
$prompts[] = "## 当前草稿";
$prompts[] = Base::cutStr(trim($context['current_draft']), 200);
}
return empty($prompts) ? "" : implode("\n", $prompts);
}
private static function normalizeProjectColumns($columns)
{
if (is_string($columns)) {

View File

@ -152,6 +152,10 @@
<em>{{$L('上传文件')}}</em>
</div>
</template>
<div class="chat-input-popover-item" @click="onToolbar('ai')">
<i class="taskfont">&#xe8a1;</i>
<em>{{$L('AI 生成')}}</em>
</div>
<div ref="moreFull" class="chat-input-popover-item" @click="onToolbar('full')">
<i class="taskfont">&#xe6a7;</i>
<em>{{$L('全屏输入')}}</em>
@ -429,6 +433,8 @@ export default {
showMore: false,
showEmoji: false,
chatAiLoading: false,
emojiQuickShow: false,
emojiQuickKey: '',
emojiQuickItems: [],
@ -1762,6 +1768,10 @@ export default {
this.openMenu("#");
break;
case 'ai':
this.onMessageAI();
break;
case 'maybe-photo':
this.$emit('on-file', {
type: 'photo',
@ -1812,6 +1822,57 @@ export default {
}
},
onMessageAI() {
if (this.disabled || this.chatAiLoading) {
return;
}
if (!this.dialogId) {
$A.messageWarning(this.$L('当前未选择会话'));
return;
}
$A.modalInput({
title: 'AI 生成',
placeholder: '请简要描述消息的主题、语气或要点AI 将生成完整消息',
inputProps: {
type: 'textarea',
rows: 2,
autosize: {minRows: 2, maxRows: 6},
maxlength: 500,
},
onOk: (value) => {
if (!value) {
return '请输入消息需求';
}
return new Promise((resolve, reject) => {
this.chatAiLoading = true;
this.$store.dispatch('call', {
url: 'dialog/msg/ai_generate',
data: {
dialog_id: this.dialogId,
content: value,
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 (!html) {
reject(this.$L('AI 未生成内容'));
return;
}
this.$emit('input', html);
this.$nextTick(() => this.focus());
resolve();
}).catch(({msg}) => {
reject(msg);
}).finally(() => {
this.chatAiLoading = false;
});
});
}
})
},
onFullInput() {
if (this.disabled) {
return