feat(ai): 优化 AI 提示词并完善建议交互功能

- 优化后端提示词:描述生成、子任务拆分、负责人推荐,新增栏目信息,去掉无效的 similar_count
- 优化前端提示词:去掉硬性字数限制,即时消息改为简短输出
- 新增 :::ai-action{...}::: 语法处理,支持单独采纳/忽略 assignee 和 similar
- 采纳/忽略后更新消息状态显示
- 负责人改为追加模式,保留现有负责人
- 新增任务关联功能,similar 采纳时自动创建双向关联
- 相似度阈值从 0.7 调整为 0.5,搜索结果增加到 200

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-21 12:09:45 +00:00
parent c253044f61
commit 29be29b9cf
9 changed files with 428 additions and 235 deletions

View File

@ -3838,6 +3838,8 @@ class ProjectController extends AbstractController
* @apiParam {Number} task_id 任务ID
* @apiParam {Number} msg_id 消息ID
* @apiParam {String} type 建议类型description/subtasks/assignee/similar
* @apiParam {Number} [userid] 用户IDassignee类型时用于指定采纳哪个推荐
* @apiParam {Number} [related] 关联任务IDsimilar类型时用于指定采纳哪个相似任务
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -3853,6 +3855,8 @@ class ProjectController extends AbstractController
$taskId = intval(Request::input('task_id'));
$msgId = intval(Request::input('msg_id'));
$type = trim(Request::input('type'));
$userid = intval(Request::input('userid'));
$related = intval(Request::input('related'));
// 验证建议类型
if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) {
@ -3871,8 +3875,8 @@ class ProjectController extends AbstractController
->where('msg_id', $msgId)
->first();
if (!$event || $event->status !== ProjectTaskAiEvent::STATUS_COMPLETED) {
return Base::retError('建议不存在或已处理');
if (!$event) {
return Base::retError('建议不存在');
}
$result = $event->result;
@ -3883,19 +3887,36 @@ class ProjectController extends AbstractController
// 标记事件为已采纳
$event->markApplied();
// 记录日志
$task->addLog('AI建议采纳' . $type . '建议');
// 更新消息状态
if ($msgId > 0 && $task->dialog_id) {
AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'applied');
// similar 类型:创建任务关联
if ($type === 'similar' && $related > 0) {
ProjectTaskRelation::createRelation(
$taskId,
$related,
$task->dialog_id,
$msgId,
User::userid()
);
}
// 返回建议数据,由前端调用相应接口处理
// 记录日志
if ($type === 'assignee' && $userid > 0) {
$user = User::find($userid);
$task->addLog('AI建议指派给 ' . ($user ? $user->nickname : $userid));
} elseif ($type === 'similar' && $related > 0) {
$task->addLog('AI建议关联任务 #' . $related);
} else {
$task->addLog('AI建议采纳' . $type . '建议');
}
// 更新消息状态
$msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'applied', $userid, $related);
// 返回建议数据和消息内容
return Base::retSuccess('已采纳', [
'type' => $type,
'task_id' => $taskId,
'result' => $result,
'msg' => $msgResult['data'] ?? null,
]);
}
@ -3909,6 +3930,8 @@ class ProjectController extends AbstractController
* @apiParam {Number} task_id 任务ID
* @apiParam {Number} msg_id 消息ID
* @apiParam {String} type 建议类型
* @apiParam {Number} [userid] 用户IDassignee类型时用于忽略单个推荐
* @apiParam {Number} [related] 关联任务IDsimilar类型时用于忽略单个推荐
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -3921,6 +3944,8 @@ class ProjectController extends AbstractController
$taskId = intval(Request::input('task_id'));
$msgId = intval(Request::input('msg_id'));
$type = trim(Request::input('type'));
$userid = intval(Request::input('userid'));
$related = intval(Request::input('related'));
// 验证建议类型
if (!in_array($type, ProjectTaskAiEvent::getEventTypes())) {
@ -3939,18 +3964,19 @@ class ProjectController extends AbstractController
->where('msg_id', $msgId)
->first();
if (!$event || $event->status !== ProjectTaskAiEvent::STATUS_COMPLETED) {
return Base::retError('建议不存在或已处理');
if (!$event) {
return Base::retError('建议不存在');
}
// 标记事件为已忽略
$event->markDismissed();
// 更新消息状态
if ($msgId > 0 && $task->dialog_id) {
AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed');
}
$msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed', $userid, $related);
return Base::retSuccess('已忽略');
// 返回消息内容
return Base::retSuccess('已忽略', [
'msg' => $msgResult['data'] ?? null,
]);
}
}

View File

@ -63,6 +63,86 @@ class ProjectTaskRelation extends AbstractModel
return $this->belongsTo(ProjectTask::class, 'related_task_id');
}
/**
* 创建双向任务关联
*
* @param int $sourceTaskId 源任务ID
* @param int $targetTaskId 目标任务ID
* @param int|null $dialogId 来源对话ID
* @param int|null $msgId 来源消息ID
* @param int|null $userid 操作人
* @param bool $push 是否推送更新
* @return bool 是否创建成功
*/
public static function createRelation(
int $sourceTaskId,
int $targetTaskId,
?int $dialogId = null,
?int $msgId = null,
?int $userid = null,
bool $push = true
): bool {
if ($sourceTaskId === $targetTaskId) {
return false;
}
$sourceTask = ProjectTask::with('project')->find($sourceTaskId);
$targetTask = ProjectTask::with('project')->find($targetTaskId);
if (!$sourceTask || !$targetTask) {
return false;
}
if ($sourceTask->deleted_at || $targetTask->deleted_at) {
return false;
}
// 创建正向关联:源任务提及目标任务
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTaskId,
'related_task_id' => $targetTaskId,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 创建反向关联:目标任务被源任务提及
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTaskId,
'related_task_id' => $sourceTaskId,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $dialogId,
'msg_id' => $msgId,
'userid' => $userid,
]
);
// 推送关联更新
if ($push) {
$needPush = $mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()
|| $reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged();
if ($needPush) {
if ($sourceTask->project) {
$sourceTask->pushMsg('relation', null, null, false);
}
if ($targetTask->project) {
$targetTask->pushMsg('relation', null, null, false);
}
}
}
return true;
}
public static function recordMentionsFromMessage(WebSocketDialogMsg $msg): void
{
if ($msg->type !== 'text') {
@ -84,71 +164,25 @@ class ProjectTaskRelation extends AbstractModel
return;
}
$sourceTasks = ProjectTask::with('project')->whereDialogId($msg->dialog_id)->get();
if ($sourceTasks->isEmpty()) {
$sourceTaskIds = ProjectTask::whereDialogId($msg->dialog_id)
->whereNull('deleted_at')
->pluck('id')
->toArray();
if (empty($sourceTaskIds)) {
return;
}
$targetTasks = ProjectTask::with('project')->whereIn('id', $targetIds)->get()->keyBy('id');
if ($targetTasks->isEmpty()) {
return;
}
$pushTasks = [];
foreach ($sourceTasks as $sourceTask) {
foreach ($sourceTaskIds as $sourceTaskId) {
foreach ($targetIds as $targetId) {
if ($targetId === $sourceTask->id) {
continue;
}
$targetTask = $targetTasks->get($targetId);
if (!$targetTask) {
continue;
}
$mentionRelation = static::updateOrCreate(
[
'task_id' => $sourceTask->id,
'related_task_id' => $targetTask->id,
'direction' => self::DIRECTION_MENTION,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
self::createRelation(
$sourceTaskId,
$targetId,
$msg->dialog_id,
$msg->id,
$msg->userid
);
if ($mentionRelation->wasRecentlyCreated || $mentionRelation->wasChanged()) {
$pushTasks[$sourceTask->id] = $sourceTask;
}
$reverseRelation = static::updateOrCreate(
[
'task_id' => $targetTask->id,
'related_task_id' => $sourceTask->id,
'direction' => self::DIRECTION_MENTIONED_BY,
],
[
'dialog_id' => $msg->dialog_id,
'msg_id' => $msg->id,
'userid' => $msg->userid,
]
);
if ($reverseRelation->wasRecentlyCreated || $reverseRelation->wasChanged()) {
$pushTasks[$targetTask->id] = $targetTask;
}
}
}
foreach ($pushTasks as $task) {
$task->loadMissing('project');
if (!$task->project) {
continue;
}
$task->pushMsg('relation', null, null, false);
}
}
}

View File

@ -7,6 +7,7 @@ use App\Models\ProjectTaskAiEvent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
@ -43,9 +44,7 @@ class AiTaskSuggestion
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
// 未指定负责人
$hasOwner = ProjectTaskUser::where('task_id', $task->id)
->where('owner', 1)
->exists();
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
return !$hasOwner;
case ProjectTaskAiEvent::EVENT_SIMILAR:
@ -145,7 +144,10 @@ class AiTaskSuggestion
public static function findSimilarTasks(ProjectTask $task): ?array
{
// 使用 AI 模块的 Embedding 搜索
$searchText = $task->name . ' ' . ($task->content ?? '');
$searchText = $task->name;
if (empty($searchText)) {
return null;
}
try {
$result = AI::getEmbedding($searchText);
@ -179,12 +181,12 @@ class AiTaskSuggestion
/**
* 转义用户输入以防止 Prompt 注入
*/
private static function escapeUserInput(string $input): string
private static function escapeUserInput(string $input, int $length = 500): string
{
// 移除可能影响 AI Prompt 解析的特殊字符
$input = str_replace(['```', '---', '==='], '', $input);
// 截断过长的输入
return mb_substr(trim($input), 0, 500);
return mb_substr(trim($input), 0, $length);
}
/**
@ -192,32 +194,30 @@ class AiTaskSuggestion
*/
private static function buildDescriptionPrompt(ProjectTask $task): string
{
$taskName = self::escapeUserInput($task->name);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目');
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
return <<<PROMPT
你是一个专业的项目管理助手。请根据以下任务标题,生成结构化的任务描述。
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
任务标题:{$taskName}
所属项目:{$projectName}
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
请按以下格式生成任务描述(使用 Markdown
你的任务:
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
**背景**[描述任务的背景和上下文]
生成原则:
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
4. 与标题语言保持一致
**目标**[明确任务要达成的目标]
**验收标准**
- [验收标准1]
- [验收标准2]
- [验收标准3]
要求:
1. 内容要专业、简洁
2. 验收标准要具体、可衡量
3. 与用户输入语言保持一致
4. 只返回 Markdown 内容,不要返回其他文字
PROMPT;
输出要求:
- 仅返回 Markdown 格式的描述内容
- 禁止输出额外说明、引导语或与任务无关的内容
PROMPT;
}
/**
@ -225,26 +225,37 @@ PROMPT;
*/
private static function buildSubtasksPrompt(ProjectTask $task): string
{
$taskName = self::escapeUserInput($task->name);
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$content = self::escapeUserInput($task->content ?? '');
return <<<PROMPT
你是一个专业的项目管理助手。请将以下任务拆分为可执行的子任务。
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
任务标题:{$taskName}
任务描述:{$content}
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$content}
请返回 3-5 个子任务,每行一个,格式如下:
1. [子任务名称]
2. [子任务名称]
...
你的任务:
分析任务内容,拆解出关键的执行步骤作为子任务。
要求:
1. 每个子任务要具体、可执行
2. 子任务之间有合理的顺序
3. 子任务名称简洁明了不超过30字
4. 只返回子任务列表,不要其他内容
PROMPT;
拆解原则:
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
3. 子任务之间保持合理的执行顺序或逻辑关系
4. 子任务名称简洁明了,控制在 8-30 个字符内
5. 与任务标题语言保持一致
输出格式:
1. [子任务名称]
2. [子任务名称]
...
输出要求:
- 仅返回子任务列表,禁止输出额外说明或引导语
PROMPT;
}
/**
@ -252,45 +263,49 @@ PROMPT;
*/
private static function buildAssigneePrompt(ProjectTask $task, array $members): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$taskContent = self::escapeUserInput($task->content ?? '');
$membersText = '';
foreach ($members as $member) {
$nickname = self::escapeUserInput($member['nickname']);
$nickname = self::escapeUserInput($member['nickname'], 20);
$membersText .= "- {$nickname}ID:{$member['userid']}";
if (!empty($member['profession'])) {
$profession = self::escapeUserInput($member['profession']);
$profession = self::escapeUserInput($member['profession'], 50);
$membersText .= ",职位:{$profession}";
}
$membersText .= ",进行中任务{$member['in_progress_count']}";
$membersText .= ",进行中{$member['in_progress_count']}";
$membersText .= ",近期完成:{$member['completed_count']}";
if ($member['similar_count'] > 0) {
$membersText .= ",处理过类似任务:{$member['similar_count']}";
}
$membersText .= "\n";
}
$taskName = self::escapeUserInput($task->name);
$taskContent = self::escapeUserInput($task->content ?? '');
return <<<PROMPT
你是一个专业的项目管理助手。请根据任务内容和团队成员情况,推荐最合适的负责人。
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
任务标题:{$taskName}
任务描述:{$taskContent}
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$taskContent}
团队成员:
{$membersText}
可选成员:
{$membersText}
请推荐 2 名最合适的负责人,按优先级排序,格式如下:
1. [userid]|[推荐理由,简短说明]
2. [userid]|[推荐理由,简短说明]
推荐原则:
1. 分析任务内容,匹配成员职位或专业方向
2. 优先推荐进行中任务较少的成员,平衡工作负载
3. 近期完成任务多说明执行力强,可作为参考
推荐依据:
1. 优先选择处理过类似任务的成员
2. 考虑当前工作负载(进行中任务较少的优先)
3. 考虑专业匹配度
输出格式:
1. [userid]|[推荐理由]
2. [userid]|[推荐理由]
只返回推荐列表,不要其他内容。
PROMPT;
输出要求:
- 推荐 1-2 名最合适的负责人,按优先级排序
- 推荐理由需具体说明为何此人适合该任务,不超过 20
- 仅返回推荐列表,禁止输出额外说明
PROMPT;
}
/**
@ -439,7 +454,7 @@ PROMPT;
try {
// 使用 ManticoreBase 进行向量搜索
// userid=0 跳过权限过滤,我们通过 project_id 过滤
$results = ManticoreBase::taskVectorSearch($embedding, 0, 50);
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
if (empty($results)) {
return [];
@ -469,9 +484,9 @@ PROMPT;
continue;
}
// 相似度阈值0.7 以上才算相似)
// 相似度阈值0.5 以上才算相似)
$similarity = $item['similarity'] ?? 0;
if ($similarity < 0.7) {
if ($similarity < 0.5) {
continue;
}
@ -499,7 +514,7 @@ PROMPT;
*/
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0): string
{
$parts = ["## AI 任务建议\n"];
$parts = [];
foreach ($suggestions as $suggestion) {
switch ($suggestion['type']) {
@ -526,15 +541,12 @@ PROMPT;
*/
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content): string
{
$applyUrl = "dootask://ai-apply/description/{$taskId}/{$msgId}";
$dismissUrl = "dootask://ai-dismiss/description/{$taskId}/{$msgId}";
return <<<MD
### 建议补充任务描述
{$content}
[ 采纳描述]({$applyUrl}) [ 忽略]({$dismissUrl})
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
@ -549,14 +561,11 @@ MD;
$list .= "{$num}. {$name}\n";
}
$applyUrl = "dootask://ai-apply/subtasks/{$taskId}/{$msgId}";
$dismissUrl = "dootask://ai-dismiss/subtasks/{$taskId}/{$msgId}";
return <<<MD
### 建议拆分子任务
{$list}
[ 创建子任务]({$applyUrl}) [ 忽略]({$dismissUrl})
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
@ -566,18 +575,16 @@ MD;
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations): string
{
$list = '';
$buttons = '';
foreach ($recommendations as $rec) {
$list .= "- **{$rec['nickname']}** - {$rec['reason']}\n";
$applyUrl = "dootask://ai-apply/assignee/{$taskId}/{$msgId}?userid={$rec['userid']}";
$buttons .= "[指派给{$rec['nickname']}]({$applyUrl}) ";
$stUserId = $rec['userid'];
$viewUrl = "dootask://contact/{$stUserId}";
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
}
return <<<MD
### 推荐负责人
{$list}
{$buttons}
MD;
}
@ -589,22 +596,17 @@ MD;
$list = '';
foreach ($similarTasks as $i => $st) {
$num = $i + 1;
$viewUrl = "dootask://task/{$st['id']}";
$addUrl = "dootask://ai-apply/similar/{$taskId}/{$msgId}?related={$st['id']}";
$similarity = round($st['similarity'] * 100);
$list .= "{$num}. **#{$st['id']} {$st['name']}** - 相似度 {$similarity}%\n";
$list .= " [查看任务]({$viewUrl}) [添加关联]({$addUrl})\n\n";
$stTaskId = $st['task_id'];
$viewUrl = "dootask://task/{$stTaskId}";
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
}
$dismissUrl = "dootask://ai-dismiss/similar/{$taskId}/{$msgId}";
return <<<MD
### 发现相似任务
以下任务与当前任务内容相似,可能是重复任务或可作为参考:
{$list}
[全部忽略]({$dismissUrl})
MD;
}
@ -613,13 +615,24 @@ MD;
*/
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
{
if (empty($suggestions) || empty($task->dialog_id)) {
if (empty($suggestions)) {
return null;
}
// 如果任务没有对话,自动创建
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
return null;
}
}
// 先发送消息获取 msg_id然后更新消息内容带上 msg_id
$tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0);
$result = WebSocketDialogMsg::sendMsg(
null,
$task->dialog_id,
@ -630,72 +643,71 @@ MD;
false, // push_retry
true // push_silence
);
if (Base::isSuccess($result)) {
$msgId = $result['data']->id ?? 0;
if ($msgId > 0) {
// 更新消息,带上真实的 msg_id
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId);
WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$task->dialog_id,
'text',
['text' => $finalMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID
);
return $msgId;
}
if (Base::isError($result)) {
return null;
}
$msgId = $result['data']->id ?? 0;
if (empty($msgId)) {
return null;
}
return null;
// 更新消息,带上真实的 msg_id
$finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId);
WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$task->dialog_id,
'text',
['text' => $finalMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
);
return $msgId;
}
/**
* 更新消息状态(采纳/忽略后)
*
* @param int $msgId 消息ID
* @param int $dialogId 对话ID
* @param string $type 建议类型
* @param string $status 状态applied/dismissed
* @param int $userid 用户IDassignee类型单独处理时使用
* @param int $related 关联任务IDsimilar类型单独处理时使用
* @return array 更新后的消息数据
*/
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status): void
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
{
// 验证消息存在且属于指定对话
$msg = WebSocketDialogMsg::where('id', $msgId)
->where('dialog_id', $dialogId)
->first();
if (!$msg) {
return;
return Base::retError('消息不存在');
}
$content = $msg->msg['text'] ?? '';
if (empty($content)) {
return;
return Base::retError('消息内容为空');
}
// 根据状态替换对应的按钮
$statusText = $status === 'applied' ? '✓ 已采纳' : '✗ 已忽略';
// 替换对应类型的按钮为状态文字
$pattern = '/\[.*?\]\(dootask:\/\/ai-(apply|dismiss)\/' . preg_quote($type, '/') . '\/\d+\/\d+[^)]*\)\s*/';
$newContent = preg_replace($pattern, '', $content);
// 在对应标题后添加状态
$sectionTitles = [
'description' => '### 建议补充任务描述',
'subtasks' => '### 建议拆分子任务',
'assignee' => '### 推荐负责人',
'similar' => '### 发现相似任务',
];
if (isset($sectionTitles[$type])) {
$title = $sectionTitles[$type];
$newContent = str_replace($title, $title . "\n\n**{$statusText}**", $newContent);
// 根据类型和参数构建匹配模式,添加 status 属性
if ($type === 'assignee' && $userid > 0) {
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
} elseif ($type === 'similar' && $related > 0) {
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
} else {
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
}
// 更新消息
WebSocketDialogMsg::sendMsg(
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
// 更新消息并返回结果
return WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$dialogId,
'text',
['text' => $newContent, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
self::AI_ASSISTANT_USERID
);
}
}

View File

@ -5,7 +5,6 @@ namespace App\Tasks;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\AiTaskSuggestion;
use App\Module\Base;
/**
* AI 任务分析异步任务
@ -24,7 +23,7 @@ class AiTaskAnalyzeTask extends AbstractTask
public function start()
{
$task = ProjectTask::with('project')->find($this->taskId);
if (!$task || $task->deleted_at || !$task->dialog_id) {
if (!$task || $task->deleted_at) {
return;
}
@ -66,7 +65,9 @@ class AiTaskAnalyzeTask extends AbstractTask
try {
// 检查是否满足执行条件
if (!AiTaskSuggestion::shouldExecute($task, $eventType)) {
$shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType);
if (!$shouldExecute) {
$event->markSkipped('不满足执行条件');
continue;
}

View File

@ -37,11 +37,6 @@ class AiTaskLoopTask extends AbstractTask
return;
}
// 检查功能开关
if (Base::settingFind('aiAssistant', 'taskSuggestion') !== 'open') {
return;
}
// 查询待处理的任务
$tasks = $this->findPendingTasks();
@ -72,7 +67,6 @@ class AiTaskLoopTask extends AbstractTask
->whereNull('archived_at')
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的
->whereNotNull('dialog_id') // 有对话ID
->whereNotIn('id', $processedTaskIds)
->orderBy('created_at', 'asc')
->take(self::BATCH_SIZE)

View File

@ -172,12 +172,29 @@ export default {
const [, type, taskId, msgId, queryString] = match;
const params = new URLSearchParams(queryString || '');
//
this.$store.dispatch('applyAiSuggestion', {
//
const requestData = {
task_id: parseInt(taskId, 10),
msg_id: parseInt(msgId, 10),
type,
}).then(({data}) => {
};
// assignee userid
if (type === 'assignee' && params.get('userid')) {
requestData.userid = parseInt(params.get('userid'), 10);
}
// similar related
if (type === 'similar' && params.get('related')) {
requestData.related = parseInt(params.get('related'), 10);
}
//
this.$store.dispatch('applyAiSuggestion', requestData).then(({data}) => {
//
if (data.msg) {
this.$store.dispatch('saveDialogMsg', data.msg);
}
//
this.applyAiSuggestionByType(data.type, data.task_id, data.result, params);
}).catch(({msg}) => {
@ -208,15 +225,21 @@ export default {
break;
case 'assignee':
//
//
const userid = params.get('userid');
if (!userid || isNaN(parseInt(userid, 10))) {
$A.modalError(this.$L('请选择负责人'));
return;
}
const newUserId = parseInt(userid, 10);
//
const task = this.$store.state.cacheTasks.find(t => t.id === taskId);
const currentOwners = task?.task_user?.filter(u => u.owner === 1).map(u => u.userid) || [];
//
const owners = [...new Set([...currentOwners, newUserId])];
this.$store.dispatch('taskUpdate', {
task_id: taskId,
owner: [parseInt(userid, 10)],
owner: owners,
}).then(() => {
$A.messageSuccess(this.$L('应用成功'));
}).catch(({msg}) => {
@ -225,7 +248,7 @@ export default {
break;
case 'similar':
//
//
$A.messageSuccess(this.$L('应用成功'));
break;
@ -274,20 +297,37 @@ export default {
/**
* 处理 AI 建议忽略
* 格式: dootask://ai-dismiss/{type}/{task_id}/{msg_id}
* 格式: dootask://ai-dismiss/{type}/{task_id}/{msg_id}?userid=xxx&related=xxx
*/
handleAiDismiss(href) {
const match = href.match(/^dootask:\/\/ai-dismiss\/(\w+)\/(\d+)\/(\d+)$/);
const match = href.match(/^dootask:\/\/ai-dismiss\/(\w+)\/(\d+)\/(\d+)(\?.*)?$/);
if (!match) {
return;
}
const [, type, taskId, msgId] = match;
const [, type, taskId, msgId, queryString] = match;
const params = new URLSearchParams(queryString || '');
this.$store.dispatch('dismissAiSuggestion', {
const data = {
task_id: parseInt(taskId, 10),
msg_id: parseInt(msgId, 10),
type,
}).then(() => {
};
// assignee userid
if (type === 'assignee' && params.get('userid')) {
data.userid = parseInt(params.get('userid'), 10);
}
// similar related
if (type === 'similar' && params.get('related')) {
data.related = parseInt(params.get('related'), 10);
}
this.$store.dispatch('dismissAiSuggestion', data).then(({data: respData}) => {
//
if (respData.msg) {
this.$store.dispatch('saveDialogMsg', respData.msg);
}
$A.messageSuccess(this.$L('已忽略'));
}).catch(({msg}) => {
$A.modalError(msg);

View File

@ -213,18 +213,17 @@ const AISystemConfig = {
/**
* 即时消息生成系统提示词
*/
const MESSAGE_AI_SYSTEM_PROMPT = `你是一名专业的沟通助手,协助用户编写得体、清晰且具行动指向的即时消息。
const MESSAGE_AI_SYSTEM_PROMPT = `你是一名沟通助手,帮助用户编写即时消息。
写作要求
1. 根据用户提供的需求与上下文生成完整消息语气需符合业务沟通场景保持真诚礼貌且高效
2. 默认使用简洁的短段落可使用 Markdown 基础格式加粗列表引用增强结构但不要输出代码块或 JSON
3. 如果上下文包含引用信息或草稿请在消息中自然呼应相关要点
4. 如无特别说明将消息长度控制在 60-180 若需更短或更长遵循用户描述
5. 如需提出行动或问题请明确表达避免含糊
1. 生成简短得体的消息语气符合业务沟通场景
2. 可使用 Markdown 基础格式加粗列表但不要输出代码块或 JSON
3. 如有引用内容或草稿自然呼应相关要点
4. 默认生成简短消息一到几句话仅在用户明确要求时才写长
输出规范
- 仅返回可直接发送的消息内容
- 禁止在内容前后添加额外说明标签或引导语`;
- 禁止添加额外说明或引导语`;
/**
* 任务生成系统提示词
@ -237,7 +236,7 @@ const TASK_AI_SYSTEM_PROMPT = `你是一个专业的任务管理专家,擅长
3. 描述需覆盖任务背景具体要求交付标准风险提示等关键信息
4. 描述内容使用Markdown格式合理组织标题列表加粗等结构
5. 内容需适配项目管理系统表述专业逻辑清晰并与用户输入语言保持一致
6. 优先遵循用户在输入中给出的风格长度或复杂度要求默认情况下将详细描述控制在120-200字内如用户要求简单或简短则控制在80-120字内
6. 根据任务复杂度灵活调整描述长度简单任务保持简洁复杂任务可适当展开
7. 当任务具有多个执行步骤阶段或协作角色时请拆解出 2-6 个关键子任务如无必要可返回空数组
8. 子任务应聚焦单一可执行动作名称控制在8-30个字符内避免重复和含糊表述
@ -322,12 +321,12 @@ const REPORT_ANALYSIS_SYSTEM_PROMPT = `你是一名经验丰富的团队管理
2. 先给出整体概览再列出具体亮点风险或问题以及明确的改进建议
3. 如有数据或目标应评估其完成情况和后续跟进要点
4. 语气保持专业客观中立不过度夸赞或批评
5. 控制在 200-400 字之间可视内容复杂度略微增减但保持紧凑`;
5. 根据汇报内容的复杂度调整分析篇幅保持紧凑避免冗余`;
/**
* 智能搜索系统提示词
*/
const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。
const SEARCH_AI_SYSTEM_PROMPT = `你是一个智能搜索助手,帮助用户搜索和整理信息。
你可以使用 intelligent_search 工具来搜索任务项目文件和联系人
请根据用户的搜索需求

View File

@ -10,6 +10,65 @@ const MarkdownUtils = {
mdi: null,
mds: null,
/**
* 处理 AI 建议操作按钮语法
* 格式: :::ai-action{type="xxx" task="123" msg="456" userid="789" related="123" status="applied"}:::
* @param {string} text
* @returns {string}
*/
processAiAction: (text) => {
// 匹配 :::ai-action{...}::: 语法
return text.replace(/:::ai-action\{([^}]+)\}:::/g, (match, attrs) => {
// 解析属性
const params = {};
attrs.replace(/(\w+)="([^"]+)"/g, (m, key, value) => {
params[key] = value;
});
const type = params.type || '';
const status = params.status || '';
// 如果有 status显示状态文字
if (status) {
const statusLabels = {
description: { applied: '✓ 已采纳', dismissed: '✗ 已忽略' },
subtasks: { applied: '✓ 已创建', dismissed: '✗ 已忽略' },
assignee: { applied: '✓ 已指派', dismissed: '✗ 已忽略' },
similar: { applied: '✓ 已关联', dismissed: '✗ 已忽略' },
};
const label = statusLabels[type]?.[status] || (status === 'applied' ? '✓ 已采纳' : '✗ 已忽略');
const statusClass = status === 'applied' ? 'ai-status-applied' : 'ai-status-dismissed';
return `<span class="ai-status ${statusClass}">${label}</span>`;
}
const taskId = params.task || '';
const msgId = params.msg || '';
const userid = params.userid || '';
const related = params.related || '';
// 根据类型生成按钮文案
const buttonLabels = {
description: ['采纳描述', '忽略'],
subtasks: ['创建子任务', '忽略'],
assignee: ['指派', '忽略'],
similar: ['关联', '忽略'],
};
const [applyLabel, dismissLabel] = buttonLabels[type] || ['采纳', '忽略'];
// 构建 URL 查询参数
let queryParams = [];
if (userid) queryParams.push(`userid=${userid}`);
if (related) queryParams.push(`related=${related}`);
const queryString = queryParams.length > 0 ? '?' + queryParams.join('&') : '';
const applyUrl = `dootask://ai-apply/${type}/${taskId}/${msgId}${queryString}`;
const dismissUrl = `dootask://ai-dismiss/${type}/${taskId}/${msgId}${queryString}`;
// 返回按钮 HTML
return `<span class="ai-action-buttons"><a href="${applyUrl}" class="ai-btn ai-btn-apply">✓ ${applyLabel}</a> <a href="${dismissUrl}" class="ai-btn ai-btn-dismiss">✗ ${dismissLabel}</a></span>`;
});
},
/**
* 解析Markdown
* @param {*} text
@ -369,6 +428,7 @@ export function MarkdownConver(text) {
}
text = MarkdownPluginUtils.clearEmptyReasoning(text);
text = mergeConsecutiveToolUse(text);
text = MarkdownUtils.processAiAction(text);
text = MarkdownUtils.mdi.render(text);
return MarkdownUtils.formatMsg(text)
}

View File

@ -95,6 +95,33 @@ body {
animation: blink-animate 1.2s infinite steps(1, start);
}
}
.ai-action-buttons {
display: inline-flex;
gap: 4px;
.ai-btn {
display: inline-flex;
align-items: center;
padding: 0 4px;
text-decoration: none;
cursor: pointer;
&.ai-btn-apply {
color: #52c41a;
}
&.ai-btn-dismiss {
color: #909399;
}
}
}
.ai-status {
display: inline-block;
margin-left: 8px;
color: #909399;
}
}
.self {