feat(ai): 添加 /analyze 和 /summarize 对话命令

- 新增 AiDialogCommand 模块处理 AI 命令业务逻辑
- 新增 AiDialogCommandTask 异步任务
- /analyze: 任务对话分析任务状态,项目对话分析项目健康度
- /summarize: 总结对话中的讨论内容
- 前端 ChatInput 添加斜杠命令菜单项
- 支持并发控制,同一对话同时只能执行一个 AI 命令
- 执行状态通过 notice 消息实时反馈

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
kuaifan 2026-01-25 02:07:39 +00:00
parent 1ac6bad2bb
commit 5ee5f253ec
4 changed files with 750 additions and 0 deletions

View File

@ -33,6 +33,8 @@ use App\Models\WebSocketDialogSession;
use App\Models\UserRecentItem;
use App\Module\Table\OnlineData;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Apps;
use App\Tasks\AiDialogCommandTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
@ -3555,4 +3557,76 @@ class DialogController extends AbstractController
//
return Base::retSuccess('重命名成功', $session);
}
/**
* @api {post} api/dialog/ai/command 执行AI命令
*
* @apiDescription 需要token身份在对话中执行 AI 命令(/analyze, /summarize
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName ai__command
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} command 命令名称 (analyze/summarize)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function ai__command()
{
$user = User::auth();
// 检查 AI 插件是否安装
if (!Apps::isInstalled('ai')) {
return Base::retError('AI 助手未安装');
}
$dialogId = intval(Request::input('dialog_id'));
$command = trim(Request::input('command', ''));
// 验证命令
if (!in_array($command, ['analyze', 'summarize'])) {
return Base::retError('无效的命令');
}
// 验证对话存在
$dialog = WebSocketDialog::find($dialogId);
if (!$dialog) {
return Base::retError('对话不存在');
}
// 检查用户是否在对话中
if (!WebSocketDialogUser::whereDialogId($dialogId)->whereUserid($user->userid)->exists()) {
return Base::retError('无权限访问此对话');
}
// 检查是否有正在进行的 AI 命令(防止并发)
$lockKey = "ai_dialog_command:{$dialogId}";
if (Cache::has($lockKey)) {
return Base::retError('当前对话正在处理 AI 命令,请稍候再试');
}
// 设置锁,有效期 3 分钟AI 任务超时时间为 120 秒)
Cache::put($lockKey, true, Carbon::now()->addMinutes(3));
// 发送"正在处理"提示消息notice 类型,前端自动翻译)
$noticeKey = $command === 'analyze' ? '正在分析,请稍候...' : '正在总结,请稍候...';
$result = WebSocketDialogMsg::sendMsg(
null,
$dialogId,
'notice',
['notice' => $noticeKey],
\App\Module\AiDialogCommand::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
$notifyMsgId = $result['data']->id ?? 0;
// 投递异步任务
Task::deliver(new AiDialogCommandTask($dialogId, $command, $user->userid, $notifyMsgId));
return Base::retSuccess('命令已接受');
}
}

View File

@ -0,0 +1,598 @@
<?php
namespace App\Module;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\AI;
use App\Module\Base;
use App\Module\Doo;
use App\Module\AiTaskSuggestion;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* AI 对话命令模块
* 处理 /analyze /summarize 命令的业务逻辑
*/
class AiDialogCommand
{
/**
* AI 助手的 userid
*/
const AI_ASSISTANT_USERID = -1;
/**
* 执行 /analyze 命令
*/
public static function analyze(WebSocketDialog $dialog, int $userId, int $notifyMsgId = 0): void
{
$lang = self::getUserLanguage($userId);
switch ($dialog->group_type) {
case 'task':
self::analyzeTask($dialog, $userId, $lang, $notifyMsgId);
break;
case 'project':
self::analyzeProject($dialog, $userId, $lang, $notifyMsgId);
break;
default:
self::updateNotifyMessage($dialog, $notifyMsgId, '/analyze 命令仅在任务和项目对话中可用', 'error');
break;
}
}
/**
* 执行 /summarize 命令
*/
public static function summarize(WebSocketDialog $dialog, int $userId, int $notifyMsgId = 0): void
{
$lang = self::getUserLanguage($userId);
switch ($dialog->group_type) {
case 'task':
self::summarizeTask($dialog, $lang, $notifyMsgId);
break;
case 'project':
self::summarizeProject($dialog, $lang, $notifyMsgId);
break;
default:
self::summarizeGeneral($dialog, $lang, $notifyMsgId);
break;
}
}
/**
* 获取用户语言(用于 AI 输出语言)
*/
private static function getUserLanguage(int $userId): string
{
$user = User::find($userId);
return $user->lang ?? 'zh';
}
/**
* 分析任务对话 - 复用 AiTaskSuggestion 逻辑
*/
private static function analyzeTask(WebSocketDialog $dialog, int $userId, string $lang, int $notifyMsgId): void
{
$task = ProjectTask::with(['project', 'projectColumn'])->whereDialogId($dialog->id)->first();
if (!$task) {
self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的任务', 'error');
return;
}
$suggestions = [];
// 检查并生成各类建议
if (AiTaskSuggestion::shouldExecute($task, 'description')) {
$result = AiTaskSuggestion::generateDescription($task);
if ($result) $suggestions[] = $result;
}
if (AiTaskSuggestion::shouldExecute($task, 'subtasks')) {
$result = AiTaskSuggestion::generateSubtasks($task);
if ($result) $suggestions[] = $result;
}
if (AiTaskSuggestion::shouldExecute($task, 'assignee')) {
$result = AiTaskSuggestion::generateAssignee($task);
if ($result) $suggestions[] = $result;
}
if (AiTaskSuggestion::shouldExecute($task, 'similar')) {
$result = AiTaskSuggestion::findSimilarTasks($task);
if ($result) $suggestions[] = $result;
}
if (empty($suggestions)) {
self::updateNotifyMessage($dialog, $notifyMsgId, '当前任务状态良好,暂无建议', 'success');
} else {
// 更新提示消息为成功
self::updateNotifyMessage($dialog, $notifyMsgId, '分析完成', 'success');
// 复用 AiTaskSuggestion 的消息构建和发送逻辑
AiTaskSuggestion::sendSuggestionMessage($task, $suggestions);
}
}
/**
* 分析项目对话 - 项目健康度分析
*/
private static function analyzeProject(WebSocketDialog $dialog, int $userId, string $lang, int $notifyMsgId): void
{
$project = Project::whereDialogId($dialog->id)->first();
if (!$project) {
self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的项目', 'error');
return;
}
// 收集项目统计数据
$stats = self::getProjectHealthStats($project);
// 构建分析 prompt
$prompt = self::buildProjectAnalysisPrompt($project, $stats, $lang);
// 调用 AI
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,擅长分析项目健康度并给出改进建议。'],
['user', $prompt],
], 120);
if (Base::isError($result)) {
self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 分析失败', 'error');
return;
}
$content = $result['data']['content'] ?? '';
if (empty($content)) {
self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error');
return;
}
// 更新提示消息为成功
self::updateNotifyMessage($dialog, $notifyMsgId, '分析完成', 'success');
// 发送分析结果
self::sendMessage($dialog, $content);
}
/**
* 获取项目健康度统计数据
*/
private static function getProjectHealthStats(Project $project): array
{
$projectId = $project->id;
$now = Carbon::now();
// 基础统计 - 只统计主任务
$baseBuilder = ProjectTask::whereProjectId($projectId)
->whereNull('archived_at')
->whereNull('deleted_at')
->where('parent_id', 0);
$totalTasks = (clone $baseBuilder)->count();
$completedTasks = (clone $baseBuilder)
->whereNotNull('complete_at')
->count();
// 逾期任务(未完成且已超过截止日期)
$overdueTasks = (clone $baseBuilder)
->whereNull('complete_at')
->whereNotNull('end_at')
->where('end_at', '<', $now)
->get(['id', 'name', 'end_at']);
// 今日到期任务
$todayTasks = (clone $baseBuilder)
->whereNull('complete_at')
->whereDate('end_at', $now->toDateString())
->get(['id', 'name']);
// 无负责人任务
$unassignedTasks = (clone $baseBuilder)
->whereNull('complete_at')
->whereDoesntHave('taskUser', function ($q) {
$q->where('owner', 1);
})
->get(['id', 'name']);
return [
'total' => $totalTasks,
'completed' => $completedTasks,
'completion_rate' => $totalTasks > 0 ? round($completedTasks / $totalTasks * 100, 1) : 0,
'overdue' => $overdueTasks->toArray(),
'overdue_count' => $overdueTasks->count(),
'today' => $todayTasks->toArray(),
'today_count' => $todayTasks->count(),
'unassigned' => $unassignedTasks->toArray(),
'unassigned_count' => $unassignedTasks->count(),
];
}
/**
* 构建项目分析 Prompt
*/
private static function buildProjectAnalysisPrompt(Project $project, array $stats, string $lang): string
{
$langName = Doo::getLanguages($lang) ?: '简体中文';
$overdueList = '';
foreach (array_slice($stats['overdue'], 0, 5) as $task) {
$endAt = Carbon::parse($task['end_at'])->format('Y-m-d');
$overdueList .= "- #{$task['id']} {$task['name']} (截止: {$endAt})\n";
}
if ($stats['overdue_count'] > 5) {
$overdueList .= "- ... 还有 " . ($stats['overdue_count'] - 5) . " 个逾期任务\n";
}
$todayList = '';
foreach (array_slice($stats['today'], 0, 5) as $task) {
$todayList .= "- #{$task['id']} {$task['name']}\n";
}
$unassignedList = '';
foreach (array_slice($stats['unassigned'], 0, 5) as $task) {
$unassignedList .= "- #{$task['id']} {$task['name']}\n";
}
if ($stats['unassigned_count'] > 5) {
$unassignedList .= "- ... 还有 " . ($stats['unassigned_count'] - 5) . " 个无负责人任务\n";
}
$projectDesc = mb_substr($project->desc ?? '', 0, 200);
return <<<PROMPT
分析以下项目的健康度状况,给出评估和建议。
项目名称:{$project->name}
项目描述:{$projectDesc}
统计数据:
- 总任务数:{$stats['total']}
- 已完成:{$stats['completed']}
- 完成率:{$stats['completion_rate']}%
- 逾期任务数:{$stats['overdue_count']}
- 今日到期:{$stats['today_count']}
- 无负责人任务:{$stats['unassigned_count']}
逾期任务前5个
{$overdueList}
今日到期任务:
{$todayList}
无负责人任务前5个
{$unassignedList}
输出要求:
1. 使用 Markdown 格式
2. 先给出项目健康度评分(优秀/良好/一般/需关注),用 emoji 标识
3. 分析当前存在的主要问题
4. 列出需要立即关注的事项
5. 给出具体的改进建议
6. 使用{$langName}输出
7. 保持简洁专业,不超过 500
PROMPT;
}
/**
* 总结任务对话
*/
private static function summarizeTask(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void
{
$task = ProjectTask::whereDialogId($dialog->id)->first();
if (!$task) {
self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的任务', 'error');
return;
}
// 获取最近消息
$messages = self::getRecentMessages($dialog->id, 50);
if (empty($messages)) {
self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success');
return;
}
// 构建总结 prompt
$prompt = self::buildTaskSummaryPrompt($task, $messages, $lang);
// 调用 AI
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结任务讨论内容和进展情况。'],
['user', $prompt],
], 120);
if (Base::isError($result)) {
self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error');
return;
}
$content = $result['data']['content'] ?? '';
if (empty($content)) {
self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error');
return;
}
// 更新提示消息为成功
self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success');
// 发送总结结果
self::sendMessage($dialog, $content);
}
/**
* 总结项目对话
*/
private static function summarizeProject(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void
{
$project = Project::whereDialogId($dialog->id)->first();
if (!$project) {
self::updateNotifyMessage($dialog, $notifyMsgId, '未找到关联的项目', 'error');
return;
}
// 获取最近消息
$messages = self::getRecentMessages($dialog->id, 50);
if (empty($messages)) {
self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success');
return;
}
// 构建总结 prompt
$prompt = self::buildProjectSummaryPrompt($project, $messages, $lang);
// 调用 AI
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结项目讨论内容。'],
['user', $prompt],
], 120);
if (Base::isError($result)) {
self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error');
return;
}
$content = $result['data']['content'] ?? '';
if (empty($content)) {
self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error');
return;
}
// 更新提示消息为成功
self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success');
// 发送总结结果
self::sendMessage($dialog, $content);
}
/**
* 总结普通对话
*/
private static function summarizeGeneral(WebSocketDialog $dialog, string $lang, int $notifyMsgId): void
{
// 获取最近消息
$messages = self::getRecentMessages($dialog->id, 50);
if (empty($messages)) {
self::updateNotifyMessage($dialog, $notifyMsgId, '暂无可供总结的消息记录', 'success');
return;
}
// 构建总结 prompt
$prompt = self::buildGeneralSummaryPrompt($dialog, $messages, $lang);
// 调用 AI
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,擅长总结聊天内容。'],
['user', $prompt],
], 120);
if (Base::isError($result)) {
self::updateNotifyMessage($dialog, $notifyMsgId, $result['msg'] ?? 'AI 总结失败', 'error');
return;
}
$content = $result['data']['content'] ?? '';
if (empty($content)) {
self::updateNotifyMessage($dialog, $notifyMsgId, 'AI 未返回有效内容', 'error');
return;
}
// 更新提示消息为成功
self::updateNotifyMessage($dialog, $notifyMsgId, '总结完成', 'success');
// 发送总结结果
self::sendMessage($dialog, $content);
}
/**
* 获取最近消息
*/
private static function getRecentMessages(int $dialogId, int $limit = 50): array
{
$messages = WebSocketDialogMsg::whereDialogId($dialogId)
->where('type', 'text')
->where('userid', '>', 0) // 排除系统消息和 AI 消息
->orderBy('id', 'desc')
->limit($limit)
->get();
$result = [];
foreach ($messages->reverse() as $msg) {
$user = User::find($msg->userid);
$text = $msg->msg['text'] ?? '';
// 移除 HTML 标签,保留纯文本
$text = strip_tags($text);
$text = mb_substr(trim($text), 0, 500);
if (!empty($text)) {
$result[] = [
'user' => $user ? $user->nickname : '未知用户',
'text' => $text,
'time' => $msg->created_at->format('Y-m-d H:i'),
];
}
}
return $result;
}
/**
* 构建任务总结 Prompt
*/
private static function buildTaskSummaryPrompt(ProjectTask $task, array $messages, string $lang): string
{
$langName = Doo::getLanguages($lang) ?: '简体中文';
$chatContent = '';
foreach ($messages as $msg) {
$chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n";
}
$taskStatus = $task->complete_at ? '已完成' : '进行中';
$progress = $task->percent ?? 0;
return <<<PROMPT
总结以下任务的评论区讨论内容和进展情况。
任务名称:{$task->name}
任务状态:{$taskStatus}
完成进度:{$progress}%
讨论记录:
{$chatContent}
输出要求:
1. 使用 Markdown 格式
2. 总结主要讨论内容和关键决策
3. 列出待解决的问题(如有)
4. 总结任务当前进展和下一步计划
5. 使用{$langName}输出
6. 保持简洁,不超过 300
PROMPT;
}
/**
* 构建项目总结 Prompt
*/
private static function buildProjectSummaryPrompt(Project $project, array $messages, string $lang): string
{
$langName = Doo::getLanguages($lang) ?: '简体中文';
$chatContent = '';
foreach ($messages as $msg) {
$chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n";
}
return <<<PROMPT
总结以下项目对话的讨论内容。
项目名称:{$project->name}
讨论记录:
{$chatContent}
输出要求:
1. 使用 Markdown 格式
2. 总结主要讨论话题
3. 列出关键决策和结论
4. 列出待处理事项(如有)
5. 使用{$langName}输出
6. 保持简洁,不超过 300
PROMPT;
}
/**
* 构建普通对话总结 Prompt
*/
private static function buildGeneralSummaryPrompt(WebSocketDialog $dialog, array $messages, string $lang): string
{
$langName = Doo::getLanguages($lang) ?: '简体中文';
$chatContent = '';
foreach ($messages as $msg) {
$chatContent .= "[{$msg['time']}] {$msg['user']}: {$msg['text']}\n";
}
$dialogName = $dialog->name ?? '对话';
return <<<PROMPT
总结以下聊天记录的主要内容。
对话名称:{$dialogName}
聊天记录:
{$chatContent}
输出要求:
1. 使用 Markdown 格式
2. 总结主要讨论话题
3. 列出关键信息点和结论
4. 列出待处理事项(如有)
5. 使用{$langName}输出
6. 保持简洁,不超过 300
PROMPT;
}
/**
* 发送消息到对话
*/
private static function sendMessage(WebSocketDialog $dialog, string $content): void
{
WebSocketDialogMsg::sendMsg(
null,
$dialog->id,
'text',
['text' => $content, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
}
/**
* 更新提示消息
* @param WebSocketDialog $dialog 对话
* @param int $notifyMsgId 提示消息ID
* @param string $content 新内容
* @param string $status 状态: success/error
*/
private static function updateNotifyMessage(WebSocketDialog $dialog, int $notifyMsgId, string $content, string $status): void
{
// 清除并发锁
self::releaseLock($dialog->id);
if ($notifyMsgId <= 0) {
// 没有提示消息ID直接发送新消息
self::sendMessage($dialog, $content);
return;
}
// 根据状态添加前缀图标
$prefix = $status === 'error' ? '❌ ' : '✅ ';
$noticeContent = $prefix . $content;
// 更新消息(不带 source=api让前端自动翻译
WebSocketDialogMsg::sendMsg(
'update-' . $notifyMsgId,
$dialog->id,
'notice',
['notice' => $noticeContent],
self::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
}
/**
* 释放对话的 AI 命令锁
*/
private static function releaseLock(int $dialogId): void
{
Cache::forget("ai_dialog_command:{$dialogId}");
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Tasks;
use App\Models\WebSocketDialog;
use App\Module\AiDialogCommand;
use Illuminate\Support\Facades\Cache;
/**
* AI 对话命令异步任务
* 处理 /analyze /summarize 命令
*/
class AiDialogCommandTask extends AbstractTask
{
protected int $dialogId;
protected string $command;
protected int $userId;
protected int $notifyMsgId;
public function __construct(int $dialogId, string $command, int $userId, int $notifyMsgId = 0)
{
parent::__construct();
$this->dialogId = $dialogId;
$this->command = $command;
$this->userId = $userId;
$this->notifyMsgId = $notifyMsgId;
}
public function start()
{
$dialog = WebSocketDialog::find($this->dialogId);
if (!$dialog) {
// 对话不存在,释放锁
Cache::forget("ai_dialog_command:{$this->dialogId}");
return;
}
try {
match ($this->command) {
'analyze' => AiDialogCommand::analyze($dialog, $this->userId, $this->notifyMsgId),
'summarize' => AiDialogCommand::summarize($dialog, $this->userId, $this->notifyMsgId),
default => null,
};
} catch (\Throwable $e) {
// 异常时释放锁,避免死锁
Cache::forget("ai_dialog_command:{$this->dialogId}");
throw $e;
}
}
public function end()
{
}
}

View File

@ -1220,6 +1220,7 @@ export default {
},
quillMention() {
const self = this;
return {
allowedChars: /^\S*$/,
mentionDenotationChars: ["@", "#", "~", "%", "/"],
@ -1255,6 +1256,19 @@ export default {
}
if (["@", "#", "~", "%"].includes(item.tip)) {
this.openMenu(item.tip);
} else if (['/analyze', '/summarize'].includes(item.tip)) {
// AI API
const command = item.tip.slice(1);
$A.apiCall({
url: 'dialog/ai/command',
method: 'post',
data: {
dialog_id: self.dialogId,
command: command,
},
}).catch(({msg}) => {
$A.messageError(msg);
});
} else {
const insertText = item.tip.endsWith(' ') ? item.tip : `${item.tip} `;
const insertAt = typeof mentionCharPos === 'number' ? mentionCharPos : (this.quill.getSelection(true)?.index || 0);
@ -2725,6 +2739,16 @@ export default {
value: this.$L('工作报告'),
tip: '%',
},
{
id: 'analyze',
value: this.$L('分析'),
tip: '/analyze',
},
{
id: 'summarize',
value: this.$L('总结'),
tip: '/summarize',
},
]
}];
if (showBotCommands) {