Compare commits

...

30 Commits

Author SHA1 Message Date
kuaifan
801d0b24ab perf(ai): 缩短 AI 任务分析延迟时间至 10 秒
将 AiTaskLoopTask 的 DELAY_SECONDS 从 60 秒减少到 10 秒,
使新建任务更快获得 AI 建议。

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>
2026-01-21 15:30:07 +00:00
kuaifan
29be29b9cf 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>
2026-01-21 15:30:07 +00:00
kuaifan
c253044f61 fix(ai): 更新 AI 助手头像显示逻辑和样式 2026-01-21 15:30:07 +00:00
kuaifan
9acf7d2046 fix(ai): 调整 AI 建议执行条件
1. subtasks: 标题长度阈值从 10 改为 5
2. similar: 启用向量搜索查找相似任务

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>
2026-01-21 15:30:07 +00:00
kuaifan
3911af7b51 fix(ai): 修复描述格式和负责人重复问题
1. 描述建议:AI 返回 Markdown,前端用 MarkdownConver 转 HTML
2. 负责人推荐:排除已分配的任务成员
3. 解析负责人推荐时去重,防止 AI 返回重复用户

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>
2026-01-21 15:30:07 +00:00
kuaifan
6b722b7ed7 fix(ai): 修正 AiTaskLoopTask 中 Apps 类的命名空间
App\Models\Apps -> App\Module\Apps

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>
2026-01-21 15:30:07 +00:00
kuaifan
6a00b87f72 fix(ai): 修正 API 路由地址格式
将 ai-apply/ai-dismiss 改为 ai_apply/ai_dismiss,
匹配 Laravel 路由方法命名转换规则(task__ai_apply -> ai_apply)

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>
2026-01-21 15:30:07 +00:00
kuaifan
0a97039d75 refactor(ai): 重构 AI 建议功能并完善向量搜索
1. 重构 task__ai_apply 接口:移除业务逻辑,仅负责状态更新和日志记录,
   返回建议数据由前端调用现有接口处理(taskUpdate/taskAddSub)

2. 实现 searchSimilarByEmbedding 向量搜索:
   - 使用 ManticoreBase::taskVectorSearch 进行向量搜索
   - 按 project_id 过滤同项目任务
   - 排除当前任务及其子任务
   - 设置 0.7 相似度阈值,最多返回 5 个结果

3. 更新 AI 助手头像:将文字 "AI" 替换为 SVG 图标

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>
2026-01-21 15:30:06 +00:00
kuaifan
cb56a01622 fix(ai): fix URL parsing for ai-apply/ai-dismiss links
The regex pattern (\w+) didn't match 'ai-apply' or 'ai-dismiss' because
\w doesn't include hyphens, causing all AI suggestion buttons to fail.

Fix by handling AI links before the regex match using startsWith().
Remove dead switch cases that were never reached.

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>
2026-01-21 15:30:06 +00:00
kuaifan
452af4bd2f fix(ai): address issues from second code review
- Add STATUS_APPLIED and STATUS_DISMISSED constants to model
- Add markApplied() and markDismissed() methods
- Update event status after apply/dismiss actions (prevent duplicate ops)
- Validate related_task_id exists and user has permission
- Filter empty or overly long subtask names before creation

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>
2026-01-21 15:30:06 +00:00
kuaifan
75073d4320 fix(ai): address security and robustness issues from code review
Security fixes:
- Add escapeUserInput() to prevent Prompt injection via user input
- Validate msgId belongs to dialogId in updateMessageStatus()
- Add type parameter whitelist validation in ai-apply/ai-dismiss
- Add event record validation in task__ai_dismiss

Robustness fixes:
- Use atomic update for markProcessing to prevent concurrent processing
- Add subtask count limit check before creation (max 50)
- Disable similar task feature until vector search is implemented
- Fix Promise anti-pattern in frontend actions

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>
2026-01-21 15:30:06 +00:00
kuaifan
d4d7a0d69f feat(ai): add AI::invoke() method for task suggestions
- Add generic invoke() static method to AI module for custom chat completion
- Fix AiTaskSuggestion::callAi() to properly handle AI::invoke() response
- Fix findSimilarTasks() to properly handle AI::getEmbedding() response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
165ad03024 feat(ai): add ai-apply/ai-dismiss protocol handlers 2026-01-21 15:30:06 +00:00
kuaifan
3603cf9889 feat(ai): display AI assistant avatar for userid=-1
When a message has userid=-1 (AI assistant), display a special AI avatar
with gradient styling instead of the regular UserAvatar component.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
027662ebab feat(ai): add ai-apply and ai-dismiss API endpoints 2026-01-21 15:30:06 +00:00
kuaifan
106465b932 feat(ai): add AiTaskLoopTask timer and register to crontab 2026-01-21 15:30:06 +00:00
kuaifan
eef4c6fbe5 feat(ai): add AiTaskAnalyzeTask async task 2026-01-21 15:30:06 +00:00
kuaifan
916ae97ca7 feat(ai): add AiTaskSuggestion module with prompt templates
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:30:06 +00:00
kuaifan
841405505d feat(ai): add ProjectTaskAiEvent model 2026-01-21 15:30:06 +00:00
kuaifan
22a653bb0f feat(ai): add project_task_ai_events migration 2026-01-21 15:30:06 +00:00
kuaifan
3482e4b1a8 fix(file): 修复日期格式文件名被误转换导致创建失败的问题
newDateString 函数在处理请求参数时会将所有符合日期格式的字符串
(如 "2026-01-15")转换为完整日期时间格式("2026-01-15 00:00:00"),
导致文件名中出现冒号,触发后端文件名校验错误。

修复方案:
- 直接调用时(key=null),保持原有行为用于显示格式化
- 递归处理对象属性时,仅对白名单字段(times、*_at)进行转换
- 其他字段(如 name)保持原值不转换

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:33:45 +00:00
kuaifan
9097369b0c fix(ai-assistant): 修复图片预览调用不存在方法的错误
将 $A.previewFile 替换为 this.$store.dispatch("previewImage"),
解决 TypeError: $A.previewFile is not a function 错误。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:58:37 +00:00
kuaifan
95c6b53f10 fix(ai-assistant): 优化图片压缩逻辑避免重复质量压缩
- 新增 forceCompress 参数控制是否强制质量压缩
- compressImageForAI: 始终进行质量压缩(发送给 AI)
- saveImageToCache: 仅在需要缩小尺寸时才压缩(避免已压缩图片被重复压缩)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:52:57 +00:00
kuaifan
f7d5040b02 feat(ai-assistant): 支持拖放和粘贴上传图片
- 新增拖放上传:可将图片拖放到对话窗口任意位置
- 新增粘贴上传:在输入框中可直接粘贴剪贴板图片
- 提取 handleImageFiles 通用方法供多种上传方式复用
- 添加拖放时的视觉反馈(虚线边框 + 提示遮罩)
- 使用计数器方式正确处理嵌套元素和拖出窗口的情况

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:45:38 +00:00
kuaifan
26b7f83d35 no message 2026-01-20 14:45:02 +00:00
kuaifan
07b99c6e75 fix(ai-assistant): 修复 SSE 连接失败时状态未正确更新的问题
当 SSE 连接一开始就失败时,响应状态保持 'waiting' 而非 'streaming',
导致 onFailed 回调不会更新状态,UI 一直显示 loading。

现在同时处理 'streaming' 和 'waiting' 状态,并标记为错误状态显示失败提示。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:20:50 +00:00
kuaifan
cb5e7e2cc7 refactor(ai): 优化 AI 提示词构建逻辑
- withLanguagePreferencePrompt: 修复无语言标签时占位符未添加的问题
- handleBeforeSend: 简化操作会话提示词,移除冗余的工具名称说明

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 13:47:25 +00:00
kuaifan
2180998e81 feat(ai-assistant): 添加图片发送功能支持多模态对话
- 支持上传图片并压缩(当前消息 1024px,历史 512px)
- 图片独立缓存存储,使用占位符 [IMG:xxx] 替代 base64
- 新增 prompt-image.vue 组件展示历史图片缩略图
- 后端 AI.php 支持多模态消息格式处理
- 添加图片缓存清理机制(删除会话时同步清理)
- 优化 parsePromptContent 避免重复调用
- 会话标题自动过滤图片占位符

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:31:34 +00:00
kuaifan
478876ddc1 feat(workflow): 在工作流配置中添加规则摘要展示
在工作流展开后的配置表格上方添加规则摘要区块,根据实际配置动态展示:
- 状态负责人规则:区分添加模式、流转模式、剔除模式的不同描述
- 限制负责人规则:显示仅限任务负责人和项目管理员修改状态
- 关联列表规则:显示流转时自动移动至指定列表

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 01:55:00 +00:00
kuaifan
ae021fd148 fix(push): 修复友盟延迟推送已读检查失效的问题
消息ID取值路径错误,导致延迟推送时无法正确判断消息已读状态,
用户在PC端阅读消息后APP仍会收到重复推送。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 00:44:45 +00:00
25 changed files with 2770 additions and 119 deletions

View File

@@ -45,6 +45,8 @@ use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectTaskTemplate;
use App\Models\ProjectTag;
use App\Models\ProjectTaskRelation;
use App\Models\ProjectTaskAiEvent;
use App\Module\AiTaskSuggestion;
use App\Observers\ProjectTaskObserver;
/**
@@ -3823,4 +3825,158 @@ class ProjectController extends AbstractController
->get();
return Base::retSuccess('success', $tags);
}
/**
* @api {post} api/project/task/ai_apply 26. 采纳AI建议
*
* @apiDescription 标记AI建议为已采纳返回建议数据供前端调用相应业务接口处理
*
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__ai_apply
*
* @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 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* @apiSuccess {String} data.type 建议类型
* @apiSuccess {Number} data.task_id 任务ID
* @apiSuccess {Object} data.result 建议内容格式根据type不同而异
*/
public function task__ai_apply()
{
User::auth();
//
$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())) {
return Base::retError('无效的建议类型');
}
// 验证任务
$task = ProjectTask::userTask($taskId);
if (!$task) {
return Base::retError('任务不存在或无权限');
}
// 获取事件记录
$event = ProjectTaskAiEvent::where('task_id', $taskId)
->where('event_type', $type)
->where('msg_id', $msgId)
->first();
if (!$event) {
return Base::retError('建议不存在');
}
$result = $event->result;
if (empty($result)) {
return Base::retError('建议内容为空');
}
// 标记事件为已采纳
$event->markApplied();
// 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,
]);
}
/**
* @api {post} api/project/task/ai_dismiss 27. 忽略AI建议
*
* @apiVersion 1.0.0
* @apiGroup project
* @apiName task__ai_dismiss
*
* @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 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function task__ai_dismiss()
{
User::auth();
//
$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())) {
return Base::retError('无效的建议类型');
}
// 验证任务
$task = ProjectTask::userTask($taskId);
if (!$task) {
return Base::retError('任务不存在或无权限');
}
// 验证事件记录存在
$event = ProjectTaskAiEvent::where('task_id', $taskId)
->where('event_type', $type)
->where('msg_id', $msgId)
->first();
if (!$event) {
return Base::retError('建议不存在');
}
// 标记事件为已忽略
$event->markDismissed();
// 更新消息状态
$msgResult = AiTaskSuggestion::updateMessageStatus($msgId, $task->dialog_id, $type, 'dismissed', $userid, $related);
// 返回消息内容
return Base::retSuccess('已忽略', [
'msg' => $msgResult['data'] ?? null,
]);
}
}

View File

@@ -23,6 +23,7 @@ use App\Tasks\CheckinRemindTask;
use App\Tasks\CloseMeetingRoomTask;
use App\Tasks\ManticoreSyncTask;
use App\Tasks\UnclaimedTaskRemindTask;
use App\Tasks\AiTaskLoopTask;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Laravolt\Avatar\Avatar;
@@ -273,6 +274,8 @@ class IndexController extends InvokeController
Task::deliver(new CloseMeetingRoomTask());
// Manticore Search 同步
Task::deliver(new ManticoreSyncTask());
// AI 任务建议
Task::deliver(new AiTaskLoopTask());
return "success";
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ProjectTaskAiEvent
*
* @property int $id
* @property int $task_id 任务ID
* @property string $event_type 事件类型
* @property string $status 状态
* @property int $retry_count 重试次数
* @property array|null $result 执行结果
* @property string|null $error 错误信息
* @property int $msg_id 消息ID
* @property \Illuminate\Support\Carbon|null $executed_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class ProjectTaskAiEvent extends AbstractModel
{
const EVENT_DESCRIPTION = 'description';
const EVENT_SUBTASKS = 'subtasks';
const EVENT_ASSIGNEE = 'assignee';
const EVENT_SIMILAR = 'similar';
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_SKIPPED = 'skipped';
const STATUS_APPLIED = 'applied';
const STATUS_DISMISSED = 'dismissed';
const MAX_RETRY = 3;
protected $table = 'project_task_ai_events';
protected $fillable = [
'task_id',
'event_type',
'status',
'retry_count',
'result',
'error',
'msg_id',
'executed_at',
];
protected $casts = [
'result' => 'array',
'executed_at' => 'datetime',
];
/**
* 关联任务
*/
public function task(): BelongsTo
{
return $this->belongsTo(ProjectTask::class, 'task_id', 'id');
}
/**
* 获取所有事件类型
*/
public static function getEventTypes(): array
{
return [
self::EVENT_DESCRIPTION,
self::EVENT_SUBTASKS,
self::EVENT_ASSIGNEE,
self::EVENT_SIMILAR,
];
}
/**
* 标记为处理中
*/
public function markProcessing(): bool
{
return $this->update([
'status' => self::STATUS_PROCESSING,
]);
}
/**
* 标记为完成
*/
public function markCompleted(array $result, int $msgId = 0): bool
{
return $this->update([
'status' => self::STATUS_COMPLETED,
'result' => $result,
'msg_id' => $msgId,
'executed_at' => now(),
]);
}
/**
* 标记为失败
*/
public function markFailed(string $error): bool
{
return $this->update([
'status' => self::STATUS_FAILED,
'retry_count' => $this->retry_count + 1,
'error' => $error,
'executed_at' => now(),
]);
}
/**
* 标记为跳过
*/
public function markSkipped(string $reason = ''): bool
{
return $this->update([
'status' => self::STATUS_SKIPPED,
'error' => $reason,
'executed_at' => now(),
]);
}
/**
* 是否可以重试
*/
public function canRetry(): bool
{
return $this->status === self::STATUS_FAILED
&& $this->retry_count < self::MAX_RETRY;
}
/**
* 标记为已采纳
*/
public function markApplied(): bool
{
return $this->update([
'status' => self::STATUS_APPLIED,
]);
}
/**
* 标记为已忽略
*/
public function markDismissed(): bool
{
return $this->update([
'status' => self::STATUS_DISMISSED,
]);
}
}

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

@@ -166,14 +166,29 @@ class AI
continue;
}
$role = trim((string)($item[0] ?? ''));
$message = trim((string)($item[1] ?? ''));
if ($role === '' || $message === '') {
$message = $item[1] ?? '';
// 跳过空消息
if (empty($message)) {
continue;
}
// 替换系统条件性提示块占位符
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
// 处理纯文本(字符串)
if (!is_array($message)) {
// 纯文本
$message = trim((string)$message);
if ($role === '' || $message === '') {
continue;
}
// 替换系统条件性提示块占位符
if (str_contains($message, '{{SYSTEM_OPTIONAL_PROMPTS}}')) {
$optionalPrompts = PromptPlaceholder::buildOptionalPrompts(User::userid());
$message = str_replace('{{SYSTEM_OPTIONAL_PROMPTS}}', $optionalPrompts, $message);
}
}
if ($role === '') {
continue;
}
$context[] = [$role, $message];
}
@@ -261,6 +276,101 @@ class AI
]);
}
/**
* 通用 AI 调用接口
* 适用于自定义对话场景
*
* @param array $messages 消息数组,格式:[['role', 'content'], ...]
* role: system | user | assistant
* @param int $timeout 超时时间(秒)
* @param bool $noCache 是否禁用缓存
* @return array 返回结果,成功时 data 包含 content 字段
*/
public static function invoke(array $messages, int $timeout = 60, bool $noCache = true): array
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
if (empty($messages)) {
return Base::retError('消息内容不能为空');
}
$provider = self::resolveTextProvider();
if (!$provider) {
return Base::retError("请先配置 AI 助手");
}
// 转换消息格式
$formattedMessages = [];
foreach ($messages as $msg) {
if (!is_array($msg) || count($msg) < 2) {
continue;
}
$role = trim((string)($msg[0] ?? ''));
$content = trim((string)($msg[1] ?? ''));
if ($role === '' || $content === '') {
continue;
}
// 标准化 role
$role = match ($role) {
'system' => 'system',
'assistant' => 'assistant',
default => 'user',
};
$formattedMessages[] = [
'role' => $role,
'content' => $content,
];
}
if (empty($formattedMessages)) {
return Base::retError('消息内容格式错误');
}
// 构建缓存 key
$cacheKey = "AIInvoke::" . md5(json_encode($formattedMessages));
if ($noCache) {
Cache::forget($cacheKey);
}
$result = Cache::remember($cacheKey, Carbon::now()->addHours(1), function () use ($formattedMessages, $provider, $timeout) {
$payload = [
"model" => $provider['model'],
"messages" => $formattedMessages,
];
$reasoningEffort = self::getReasoningEffort($provider);
if ($reasoningEffort !== null) {
$payload['reasoning_effort'] = $reasoningEffort;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setTimeout($timeout);
$res = $ai->request();
if (Base::isError($res)) {
return Base::retError("AI 调用失败", $res);
}
$content = $res['data'];
if (empty($content)) {
return Base::retError("AI 返回内容为空");
}
return Base::retSuccess("success", [
'content' => $content,
]);
});
if (Base::isError($result)) {
Cache::forget($cacheKey);
}
return $result;
}
/** ******************************************************************************************** */
/** ******************************************************************************************** */
/** ******************************************************************************************** */

View File

@@ -0,0 +1,713 @@
<?php
namespace App\Module;
use App\Models\ProjectTask;
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;
use Cache;
use Carbon\Carbon;
class AiTaskSuggestion
{
/**
* AI 助手的 userid
*/
const AI_ASSISTANT_USERID = -1;
/**
* 相似度阈值
*/
const SIMILAR_THRESHOLD = 0.7;
/**
* 检查是否满足执行条件
*/
public static function shouldExecute(ProjectTask $task, string $eventType): bool
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
// 描述为空或长度 < 20
$content = trim($task->content ?? '');
return empty($content) || mb_strlen($content) < 20;
case ProjectTaskAiEvent::EVENT_SUBTASKS:
// 无子任务且标题长度 > 5
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
return !$hasSubtasks && mb_strlen($task->name) > 5;
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
// 未指定负责人
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
return !$hasOwner;
case ProjectTaskAiEvent::EVENT_SIMILAR:
// 需要安装 search 插件才能使用向量搜索
return Apps::isInstalled('search');
default:
return false;
}
}
/**
* 生成任务描述建议
*/
public static function generateDescription(ProjectTask $task): ?array
{
$prompt = self::buildDescriptionPrompt($task);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
return [
'type' => 'description',
'content' => $result,
];
}
/**
* 生成子任务拆分建议
*/
public static function generateSubtasks(ProjectTask $task): ?array
{
$prompt = self::buildSubtasksPrompt($task);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析返回的子任务列表
$subtasks = self::parseSubtasksList($result);
if (empty($subtasks)) {
return null;
}
return [
'type' => 'subtasks',
'content' => $subtasks,
];
}
/**
* 生成负责人推荐
*/
public static function generateAssignee(ProjectTask $task): ?array
{
// 获取当前任务已有的成员(负责人和协助人)
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
->pluck('userid')
->toArray();
// 获取项目成员,排除已有任务成员
$members = self::getProjectMembersInfo($task->project_id);
$members = array_filter($members, function ($member) use ($existingUserIds) {
return !in_array($member['userid'], $existingUserIds);
});
$members = array_values($members); // 重新索引
if (empty($members)) {
return null;
}
$prompt = self::buildAssigneePrompt($task, $members);
$result = self::callAi($prompt);
if (empty($result)) {
return null;
}
// 解析推荐结果
$recommendations = self::parseAssigneeRecommendations($result, $members);
if (empty($recommendations)) {
return null;
}
return [
'type' => 'assignee',
'content' => $recommendations,
];
}
/**
* 搜索相似任务
*/
public static function findSimilarTasks(ProjectTask $task): ?array
{
// 使用 AI 模块的 Embedding 搜索
$searchText = $task->name;
if (empty($searchText)) {
return null;
}
try {
$result = AI::getEmbedding($searchText);
if (Base::isError($result) || empty($result['data'])) {
return null;
}
$embedding = $result['data'];
// 搜索相似任务(排除自己和子任务)
$similarTasks = self::searchSimilarByEmbedding(
$embedding,
$task->project_id,
$task->id
);
if (empty($similarTasks)) {
return null;
}
return [
'type' => 'similar',
'content' => $similarTasks,
];
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage());
return null;
}
}
/**
* 转义用户输入以防止 Prompt 注入
*/
private static function escapeUserInput(string $input, int $length = 500): string
{
// 移除可能影响 AI Prompt 解析的特殊字符
$input = str_replace(['```', '---', '==='], '', $input);
// 截断过长的输入
return mb_substr(trim($input), 0, $length);
}
/**
* 构建描述生成 Prompt
*/
private static function buildDescriptionPrompt(ProjectTask $task): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
return <<<PROMPT
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
你的任务:
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
生成原则:
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
4. 与标题语言保持一致
输出要求:
- 仅返回 Markdown 格式的描述内容
- 禁止输出额外说明、引导语或与任务无关的内容
PROMPT;
}
/**
* 构建子任务拆分 Prompt
*/
private static function buildSubtasksPrompt(ProjectTask $task): string
{
$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
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$content}
你的任务:
分析任务内容,拆解出关键的执行步骤作为子任务。
拆解原则:
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
3. 子任务之间保持合理的执行顺序或逻辑关系
4. 子任务名称简洁明了,控制在 8-30 个字符内
5. 与任务标题语言保持一致
输出格式:
1. [子任务名称]
2. [子任务名称]
...
输出要求:
- 仅返回子任务列表,禁止输出额外说明或引导语
PROMPT;
}
/**
* 构建负责人推荐 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'], 20);
$membersText .= "- {$nickname}ID:{$member['userid']}";
if (!empty($member['profession'])) {
$profession = self::escapeUserInput($member['profession'], 50);
$membersText .= ",职位:{$profession}";
}
$membersText .= ",进行中:{$member['in_progress_count']}";
$membersText .= ",近期完成:{$member['completed_count']}";
$membersText .= "\n";
}
return <<<PROMPT
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$taskContent}
可选成员:
{$membersText}
推荐原则:
1. 分析任务内容,匹配成员职位或专业方向
2. 优先推荐进行中任务较少的成员,平衡工作负载
3. 近期完成任务多说明执行力强,可作为参考
输出格式:
1. [userid]|[推荐理由]
2. [userid]|[推荐理由]
输出要求:
- 推荐 1-2 名最合适的负责人,按优先级排序
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
- 仅返回推荐列表,禁止输出额外说明
PROMPT;
}
/**
* 调用 AI 接口
*/
private static function callAi(string $prompt): ?string
{
try {
// 使用 AI 模块调用
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
['user', $prompt],
]);
if (Base::isError($result)) {
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
return null;
}
return $result['data']['content'] ?? null;
} catch (\Exception $e) {
\Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage());
return null;
}
}
/**
* 获取项目成员信息
*/
private static function getProjectMembersInfo(int $projectId): array
{
$projectUsers = ProjectUser::where('project_id', $projectId)->get();
$members = [];
foreach ($projectUsers as $pu) {
$user = User::find($pu->userid);
if (!$user || $user->bot || $user->disable_at) {
continue;
}
// 获取进行中任务数量
$inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNull('project_tasks.complete_at')
->whereNull('project_tasks.archived_at')
->whereNull('project_tasks.deleted_at')
->count();
// 获取近期完成任务数量
$completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id')
->where('project_task_users.userid', $user->userid)
->whereNotNull('project_tasks.complete_at')
->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30))
->whereNull('project_tasks.deleted_at')
->count();
$members[] = [
'userid' => $user->userid,
'nickname' => $user->nickname,
'profession' => $user->profession ?? '',
'in_progress_count' => $inProgressCount,
'completed_count' => $completedCount,
'similar_count' => 0, // TODO: 计算相似任务数量
];
}
return $members;
}
/**
* 解析子任务列表
*/
private static function parseSubtasksList(string $text): array
{
$lines = explode("\n", trim($text));
$subtasks = [];
foreach ($lines as $line) {
$line = trim($line);
// 移除序号前缀
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (!empty($line) && mb_strlen($line) <= 100) {
$subtasks[] = $line;
}
}
return array_slice($subtasks, 0, 5); // 最多5个
}
/**
* 解析负责人推荐结果
*/
private static function parseAssigneeRecommendations(string $text, array $members): array
{
$memberMap = [];
foreach ($members as $m) {
$memberMap[$m['userid']] = $m;
}
$lines = explode("\n", trim($text));
$recommendations = [];
$addedUserIds = []; // 记录已添加的用户ID防止重复
foreach ($lines as $line) {
$line = trim($line);
$line = preg_replace('/^\d+[\.\)、]\s*/', '', $line);
if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) {
$userid = intval($matches[1]);
$reason = trim($matches[2]);
// 跳过已添加的用户
if (in_array($userid, $addedUserIds)) {
continue;
}
if (isset($memberMap[$userid])) {
$recommendations[] = [
'userid' => $userid,
'nickname' => $memberMap[$userid]['nickname'],
'reason' => $reason,
];
$addedUserIds[] = $userid;
}
}
}
return array_slice($recommendations, 0, 2); // 最多2个
}
/**
* 通过 Embedding 搜索相似任务
*
* @param array $embedding 任务内容的向量表示
* @param int $projectId 项目ID用于过滤同项目任务
* @param int $excludeTaskId 排除的任务ID当前任务
* @return array 相似任务列表
*/
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
{
if (empty($embedding)) {
return [];
}
try {
// 使用 ManticoreBase 进行向量搜索
// userid=0 跳过权限过滤,我们通过 project_id 过滤
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
if (empty($results)) {
return [];
}
// 获取当前任务的子任务ID列表
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
->whereNull('deleted_at')
->pluck('id')
->toArray();
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
$similarTasks = [];
foreach ($results as $item) {
// 过滤不同项目的任务
if ($item['project_id'] != $projectId) {
continue;
}
// 排除当前任务
if ($item['task_id'] == $excludeTaskId) {
continue;
}
// 排除子任务
if (in_array($item['task_id'], $childTaskIds)) {
continue;
}
// 相似度阈值0.5 以上才算相似)
$similarity = $item['similarity'] ?? 0;
if ($similarity < 0.5) {
continue;
}
$similarTasks[] = [
'task_id' => $item['task_id'],
'name' => $item['task_name'] ?? '',
'similarity' => round($similarity, 2),
];
// 最多返回 5 个相似任务
if (count($similarTasks) >= 5) {
break;
}
}
return $similarTasks;
} catch (\Exception $e) {
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
return [];
}
}
/**
* 构建 Markdown 消息
*/
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0): string
{
$parts = [];
foreach ($suggestions as $suggestion) {
switch ($suggestion['type']) {
case 'description':
$parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content']);
break;
case 'subtasks':
$parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content']);
break;
case 'assignee':
$parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content']);
break;
case 'similar':
$parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content']);
break;
}
}
return implode("\n\n---\n\n", $parts);
}
/**
* 构建描述建议 Markdown
*/
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content): string
{
return <<<MD
### 建议补充任务描述
{$content}
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建子任务建议 Markdown
*/
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks): string
{
$list = '';
foreach ($subtasks as $i => $name) {
$num = $i + 1;
$list .= "{$num}. {$name}\n";
}
return <<<MD
### 建议拆分子任务
{$list}
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建负责人建议 Markdown
*/
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations): string
{
$list = '';
foreach ($recommendations as $rec) {
$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}
MD;
}
/**
* 构建相似任务 Markdown
*/
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks): string
{
$list = '';
foreach ($similarTasks as $i => $st) {
$num = $i + 1;
$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";
}
return <<<MD
### 发现相似任务
以下任务与当前任务内容相似,可能是重复任务或可作为参考:
{$list}
MD;
}
/**
* 发送建议消息
*/
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
{
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,
'text',
['text' => $tempMarkdown, 'type' => 'md'],
self::AI_ASSISTANT_USERID,
true, // push_self
false, // push_retry
true // push_silence
);
if (Base::isError($result)) {
return null;
}
$msgId = $result['data']->id ?? 0;
if (empty($msgId)) {
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, int $userid = 0, int $related = 0): array
{
// 验证消息存在且属于指定对话
$msg = WebSocketDialogMsg::where('id', $msgId)
->where('dialog_id', $dialogId)
->first();
if (!$msg) {
return Base::retError('消息不存在');
}
$content = $msg->msg['text'] ?? '';
if (empty($content)) {
return Base::retError('消息内容为空');
}
// 根据类型和参数构建匹配模式,添加 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, '/') . '"[^}]*)\}:::/';
}
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
// 更新消息并返回结果
return WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$dialogId,
'text',
['text' => $newContent, 'type' => 'md'],
self::AI_ASSISTANT_USERID
);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Tasks;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\AiTaskSuggestion;
/**
* AI 任务分析异步任务
* 处理单个任务的所有 AI 事件
*/
class AiTaskAnalyzeTask extends AbstractTask
{
protected int $taskId;
public function __construct(int $taskId)
{
parent::__construct();
$this->taskId = $taskId;
}
public function start()
{
$task = ProjectTask::with('project')->find($this->taskId);
if (!$task || $task->deleted_at) {
return;
}
// 获取该任务的所有待处理事件
$events = ProjectTaskAiEvent::where('task_id', $this->taskId)
->whereIn('status', [
ProjectTaskAiEvent::STATUS_PENDING,
ProjectTaskAiEvent::STATUS_FAILED,
])
->get()
->keyBy('event_type');
$suggestions = [];
// 遍历所有事件类型
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
$event = $events->get($eventType);
// 如果没有记录,跳过
if (!$event) {
continue;
}
// 如果是失败状态但不能重试,跳过
if ($event->status === ProjectTaskAiEvent::STATUS_FAILED && !$event->canRetry()) {
continue;
}
// 使用原子操作标记为处理中(防止并发重复处理)
$updated = ProjectTaskAiEvent::where('id', $event->id)
->whereIn('status', [ProjectTaskAiEvent::STATUS_PENDING, ProjectTaskAiEvent::STATUS_FAILED])
->update(['status' => ProjectTaskAiEvent::STATUS_PROCESSING]);
if (!$updated) {
// 已被其他进程处理
continue;
}
$event->status = ProjectTaskAiEvent::STATUS_PROCESSING;
try {
// 检查是否满足执行条件
$shouldExecute = AiTaskSuggestion::shouldExecute($task, $eventType);
if (!$shouldExecute) {
$event->markSkipped('不满足执行条件');
continue;
}
// 执行对应的分析
$result = $this->executeAnalysis($task, $eventType);
if ($result === null) {
$event->markSkipped('未生成有效建议');
continue;
}
// 收集建议
$suggestions[] = $result;
$event->markCompleted($result);
} catch (\Exception $e) {
$event->markFailed($e->getMessage());
\Log::error("AiTaskAnalyzeTask error: task={$this->taskId}, type={$eventType}, error={$e->getMessage()}");
}
}
// 如果有建议,发送消息
if (!empty($suggestions)) {
$msgId = AiTaskSuggestion::sendSuggestionMessage($task, $suggestions);
// 更新所有事件的 msg_id
if ($msgId) {
ProjectTaskAiEvent::where('task_id', $this->taskId)
->where('status', ProjectTaskAiEvent::STATUS_COMPLETED)
->update(['msg_id' => $msgId]);
}
}
}
/**
* 执行具体的分析
*/
private function executeAnalysis(ProjectTask $task, string $eventType): ?array
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
return AiTaskSuggestion::generateDescription($task);
case ProjectTaskAiEvent::EVENT_SUBTASKS:
return AiTaskSuggestion::generateSubtasks($task);
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
return AiTaskSuggestion::generateAssignee($task);
case ProjectTaskAiEvent::EVENT_SIMILAR:
return AiTaskSuggestion::findSimilarTasks($task);
default:
return null;
}
}
public function end()
{
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Tasks;
use App\Module\Apps;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Module\Base;
use Carbon\Carbon;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* AI 任务建议定时任务
* 扫描新建任务并投递分析任务
*/
class AiTaskLoopTask extends AbstractTask
{
/**
* 单次处理任务数量上限
*/
const BATCH_SIZE = 5;
/**
* 任务创建后多久开始分析(秒)
*/
const DELAY_SECONDS = 10;
public function __construct()
{
parent::__construct();
}
public function start()
{
// 检查 AI 插件是否安装
if (!Apps::isInstalled('ai')) {
return;
}
// 查询待处理的任务
$tasks = $this->findPendingTasks();
foreach ($tasks as $task) {
// 为任务创建事件记录
$this->createEventRecords($task);
// 投递异步分析任务
Task::deliver(new AiTaskAnalyzeTask($task->id));
}
}
/**
* 查找待处理的任务
*/
private function findPendingTasks(): \Illuminate\Support\Collection
{
$delayTime = Carbon::now()->subSeconds(self::DELAY_SECONDS);
// 子查询:已经有 AI 事件记录的任务
$processedTaskIds = ProjectTaskAiEvent::select('task_id')
->distinct()
->pluck('task_id');
// 查询新建任务(未处理过的)
$newTasks = ProjectTask::where('parent_id', 0) // 只处理主任务
->whereNull('deleted_at')
->whereNull('archived_at')
->where('created_at', '<=', $delayTime) // 创建超过延迟时间
->where('created_at', '>=', Carbon::now()->subDays(1)) // 只处理1天内的
->whereNotIn('id', $processedTaskIds)
->orderBy('created_at', 'asc')
->take(self::BATCH_SIZE)
->get();
// 查询需要重试的任务(优先处理较早失败的)
$retryTaskIds = ProjectTaskAiEvent::where('status', ProjectTaskAiEvent::STATUS_FAILED)
->where('retry_count', '<', ProjectTaskAiEvent::MAX_RETRY)
->select('task_id')
->distinct()
->orderBy('updated_at', 'asc')
->take(self::BATCH_SIZE - $newTasks->count())
->pluck('task_id');
$retryTasks = ProjectTask::whereIn('id', $retryTaskIds)
->whereNull('deleted_at')
->get();
return $newTasks->merge($retryTasks)->take(self::BATCH_SIZE);
}
/**
* 为任务创建事件记录
*/
private function createEventRecords(ProjectTask $task): void
{
foreach (ProjectTaskAiEvent::getEventTypes() as $eventType) {
ProjectTaskAiEvent::firstOrCreate(
[
'task_id' => $task->id,
'event_type' => $eventType,
],
[
'status' => ProjectTaskAiEvent::STATUS_PENDING,
'retry_count' => 0,
]
);
}
}
public function end()
{
}
}

View File

@@ -38,7 +38,7 @@ class PushUmengMsg extends AbstractTask
}
// 消息ID
$msgId = isset($this->array['id']) ? intval($this->array['id']) : 0;
$msgId = isset($this->array['extra']['msg_id']) ? intval($this->array['extra']['msg_id']) : 0;
// 处理用户列表
$userids = is_array($this->userid) ? $this->userid : [$this->userid];

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProjectTaskAiEventsTable extends Migration
{
public function up()
{
Schema::create('project_task_ai_events', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('task_id')->comment('任务ID');
$table->string('event_type', 50)->comment('事件类型: description/subtasks/assignee/similar');
$table->string('status', 20)->default('pending')->comment('状态: pending/processing/completed/failed/skipped');
$table->tinyInteger('retry_count')->unsigned()->default(0)->comment('重试次数');
$table->json('result')->nullable()->comment('执行结果');
$table->text('error')->nullable()->comment('错误信息');
$table->bigInteger('msg_id')->nullable()->default(0)->comment('消息ID');
$table->timestamp('executed_at')->nullable()->comment('执行时间');
$table->timestamps();
$table->unique(['task_id', 'event_type'], 'uk_task_event');
$table->index('status', 'idx_status');
$table->index('created_at', 'idx_created');
});
}
public function down()
{
Schema::dropIfExists('project_task_ai_events');
}
}

View File

@@ -2297,3 +2297,16 @@ AI 消息助手
关闭应用
请描述你想搜索的内容...
搜索中...
工作流规则
流转到
时改变任务负责人为
,原负责人移至协助人员
仅限任务负责人和项目管理员修改状态
时自动将任务移动至列表
(并保留操作人),原负责人移至协助人员
时添加
至任务负责人
连接失败,请重试
最多上传(*)张图片
松开以上传图片

View File

@@ -405,7 +405,7 @@ export default {
// 添加操作会话信息
let operationContext = '';
if (this.operationSessionId) {
operationContext = `\n\n前端操作会话已建立,session_id: ${this.operationSessionId}你可以使用 get_page_context、execute_action、execute_element_action 工具直接操作用户的页面。`;
operationContext = `\n\n页面操作会话 session_id: ${this.operationSessionId}`;
}
const prepared = [

View File

@@ -47,7 +47,20 @@
</Dropdown>
</div>
</div>
<div class="ai-assistant-content">
<div
class="ai-assistant-content"
:class="{'ai-assistant-content-dragging': isDragging}"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave="onDragLeave"
@drop.prevent="onDrop">
<!-- 拖放提示遮罩 -->
<div v-if="isDragging" class="ai-assistant-drop-overlay">
<div class="ai-assistant-drop-hint">
<i class="taskfont">&#xe7bc;</i>
<span>{{ $L('松开以上传图片') }}</span>
</div>
</div>
<div
v-if="responses.length"
ref="responseContainer"
@@ -97,13 +110,26 @@
<Button type="primary" size="small" :loading="loadIng > 0" @click="submitEditedQuestion">{{ $L('发送') }}</Button>
</div>
</div>
<!-- 正常显示模式 -->
<div v-else class="ai-assistant-output-question">
<span class="ai-assistant-output-question-text">{{ response.prompt }}</span>
<span class="ai-assistant-output-question-edit" :title="$L('编辑问题')" @click="startEditQuestion(responses.indexOf(response))">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11.331 3.568a3.61 3.61 0 0 1 4.973.128l.128.135a3.61 3.61 0 0 1 0 4.838l-.128.135-6.292 6.29c-.324.324-.558.561-.79.752l-.235.177q-.309.21-.65.36l-.23.093c-.181.066-.369.114-.585.159l-.765.135-2.394.399c-.142.024-.294.05-.422.06-.1.007-.233.01-.378-.026l-.149-.049a1.1 1.1 0 0 1-.522-.474l-.046-.094a1.1 1.1 0 0 1-.074-.526c.01-.129.035-.28.06-.423l.398-2.394.134-.764a4 4 0 0 1 .16-.586l.093-.23q.15-.342.36-.65l.176-.235c.19-.232.429-.466.752-.79l6.291-6.292zm-5.485 7.36c-.35.35-.533.535-.66.688l-.11.147a2.7 2.7 0 0 0-.24.433l-.062.155c-.04.11-.072.225-.106.394l-.127.717-.398 2.393-.001.002h.003l2.393-.399.717-.126c.169-.034.284-.065.395-.105l.153-.062q.228-.1.433-.241l.148-.11c.153-.126.338-.31.687-.66l4.988-4.988-3.226-3.226zm9.517-6.291a2.28 2.28 0 0 0-3.053-.157l-.173.157-.364.363L15 8.226l.363-.363.157-.174a2.28 2.28 0 0 0 0-2.878z"/></svg>
</span>
</div>
<!-- 正常显示模式使用 template 缓存 parsePromptContent 结果避免重复调用 -->
<template v-else v-for="(parsed, _pk) in [parsePromptContent(response.prompt)]">
<div class="ai-assistant-output-question">
<!-- 图片区域单独一行 -->
<div v-if="parsed.images.length" class="ai-assistant-output-question-images">
<PromptImage
v-for="(img, imgIndex) in parsed.images"
:key="'img' + imgIndex"
:image-id="img.imageId"
:get-image="getImageFromCache" />
</div>
<!-- 文字区域 -->
<div class="ai-assistant-output-question-content">
<span class="ai-assistant-output-question-text">{{ parsed.text }}</span>
<span class="ai-assistant-output-question-edit" :title="$L('编辑问题')" @click="startEditQuestion(responses.indexOf(response))">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11.331 3.568a3.61 3.61 0 0 1 4.973.128l.128.135a3.61 3.61 0 0 1 0 4.838l-.128.135-6.292 6.29c-.324.324-.558.561-.79.752l-.235.177q-.309.21-.65.36l-.23.093c-.181.066-.369.114-.585.159l-.765.135-2.394.399c-.142.024-.294.05-.422.06-.1.007-.233.01-.378-.026l-.149-.049a1.1 1.1 0 0 1-.522-.474l-.046-.094a1.1 1.1 0 0 1-.074-.526c.01-.129.035-.28.06-.423l.398-2.394.134-.764a4 4 0 0 1 .16-.586l.093-.23q.15-.342.36-.65l.176-.235c.19-.232.429-.466.752-.79l6.291-6.292zm-5.485 7.36c-.35.35-.533.535-.66.688l-.11.147a2.7 2.7 0 0 0-.24.433l-.062.155c-.04.11-.072.225-.106.394l-.127.717-.398 2.393-.001.002h.003l2.393-.399.717-.126c.169-.034.284-.065.395-.105l.153-.062q.228-.1.433-.241l.148-.11c.153-.126.338-.31.687-.66l4.988-4.988-3.226-3.226zm9.517-6.291a2.28 2.28 0 0 0-3.053-.157l-.173.157-.364.363L15 8.226l.363-.363.157-.174a2.28 2.28 0 0 0 0-2.878z"/></svg>
</span>
</div>
</div>
</template>
</div>
<DialogMarkdown
v-if="response.rawOutput"
@@ -136,6 +162,18 @@
</div>
</div>
<div class="ai-assistant-input">
<!-- 图片预览区域 -->
<div v-if="pendingImages.length" class="ai-assistant-images">
<div
v-for="img in pendingImages"
:key="img.id"
class="ai-assistant-image-item">
<img :src="img.dataUrl" alt="preview" />
<div class="ai-assistant-image-remove" @click="removeImage(img.id)">
<i class="taskfont">&#xe6e5;</i>
</div>
</div>
</div>
<Input
v-model="inputValue"
ref="inputRef"
@@ -146,7 +184,16 @@
:maxlength="inputMaxlength || 500"
@on-keydown="onInputKeydown"
@compositionstart.native="isComposing = true"
@compositionend.native="isComposing = false" />
@compositionend.native="isComposing = false"
@paste.native="onPaste" />
<!-- 隐藏的图片上传 input -->
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
style="display: none"
@change="onImageSelect" />
<div class="ai-assistant-footer">
<div class="ai-assistant-footer-models">
<Select
@@ -171,6 +218,9 @@
</Select>
</div>
<div class="ai-assistant-footer-btns">
<div class="ai-assistant-image-btn" :title="$L('上传图片')" @click="triggerImageSelect">
<i class="taskfont">&#xe7bc;</i>
</div>
<Button v-if="submitButtonText" type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit">{{ submitButtonText }}</Button>
<Button v-else type="primary" shape="circle" icon="md-arrow-up" :loading="loadIng > 0" @click="onSubmit"></Button>
</div>
@@ -189,11 +239,12 @@ import {AIBotMap, AIModelNames} from "../../utils/ai";
import DialogMarkdown from "../../pages/manage/components/DialogMarkdown.vue";
import FloatButton from "./float-button.vue";
import AssistantModal from "./modal.vue";
import PromptImage from "./prompt-image.vue";
import {getWelcomePrompts} from "./welcome-prompts";
export default {
name: 'AIAssistant',
components: {AssistantModal, DialogMarkdown},
components: {AssistantModal, DialogMarkdown, PromptImage},
floatButtonInstance: null,
data() {
return {
@@ -265,6 +316,15 @@ export default {
inputHistoryCacheKey: 'aiAssistant.inputHistory',
inputHistoryLimit: 50,
// 图片上传
pendingImages: [], // 待发送的图片列表 [{id, dataUrl, file}]
imageIdSeed: 0, // 图片 ID 种子
maxImages: 5, // 最大图片数量
imageCacheKeyPrefix: 'aiAssistant.images', // 图片缓存 key 前缀
imageCache: {}, // 内存中的图片缓存 {imageId: dataUrl}
isDragging: false, // 是否正在拖放图片
dragCounter: 0, // 拖放计数器(处理嵌套元素)
// 动态 z-index确保始终在最顶层
topZIndex: (window.modalTransferIndex || 1000) + 1000,
zIndexTimer: null,
@@ -627,6 +687,7 @@ export default {
const success = await this._doSendQuestion(prompt);
if (success) {
this.inputValue = '';
this.clearPendingImages();
}
},
@@ -646,12 +707,16 @@ export default {
this.loadIng++;
let responseEntry = null;
try {
const baseContext = this.collectBaseContext(prompt);
const baseContext = await this.collectBaseContext(prompt);
const context = await this.buildPayloadData(baseContext);
// 处理图片:存储到独立缓存,生成带占位符的 prompt
const currentContent = this.buildCurrentContent(prompt);
const displayPrompt = await this.processContentForStorage(currentContent);
responseEntry = this.createResponseEntry({
modelOption,
prompt,
prompt: displayPrompt,
});
this.scrollResponsesToBottom();
@@ -700,39 +765,106 @@ export default {
return baseContext;
},
/**
* 还原历史 prompt 中的图片占位符为多模态内容
* @param {string} prompt - 带占位符的 prompt
* @returns {Promise<string|Array>} - 纯文本或多模态数组
*/
async restorePromptImages(prompt) {
if (!prompt || typeof prompt !== 'string') {
return prompt || '';
}
const parsed = this.parsePromptContent(prompt);
// 没有图片,返回纯文本
if (parsed.images.length === 0) {
return parsed.text;
}
// 有图片,构建多模态内容
const content = [];
for (const img of parsed.images) {
const dataUrl = await this.getImageFromCache(img.imageId);
if (dataUrl) {
content.push({
type: 'image_url',
image_url: {url: dataUrl}
});
}
}
if (parsed.text) {
content.push({type: 'text', text: parsed.text});
}
// 如果所有图片都获取失败,返回纯文本
return content.length > 0 ? content : parsed.text;
},
/**
* 汇总当前会话的基础上下文
*/
collectBaseContext(prompt) {
async collectBaseContext(prompt) {
const pushEntry = (context, role, value) => {
if (typeof value === 'undefined' || value === null) {
return;
}
const content = String(value).trim();
if (!content) {
return;
// value 可以是字符串或多模态数组
if (typeof value === 'string') {
const content = value.trim();
if (!content) {
return;
}
context.push([role, content]);
} else if (Array.isArray(value) && value.length > 0) {
// 多模态内容
context.push([role, value]);
}
context.push([role, content]);
};
const context = [];
const windowSize = Number(this.contextWindowSize) || 0;
const recentResponses = windowSize > 0
? this.responses.slice(-windowSize)
: this.responses;
recentResponses.forEach(item => {
// 处理历史消息(还原图片 base64
for (const item of recentResponses) {
if (item.prompt) {
pushEntry(context, 'human', item.prompt);
const restoredPrompt = await this.restorePromptImages(item.prompt);
pushEntry(context, 'human', restoredPrompt);
}
if (item.rawOutput) {
pushEntry(context, 'assistant', item.rawOutput);
}
});
}
// 构建当前提问内容(可能包含图片)
if (prompt && String(prompt).trim()) {
pushEntry(context, 'human', prompt);
const currentContent = this.buildCurrentContent(prompt);
pushEntry(context, 'human', currentContent);
}
return context;
},
/**
* 构建当前提问内容(支持多模态)
*/
buildCurrentContent(prompt) {
const text = String(prompt).trim();
if (!this.pendingImages.length) {
// 无图片,返回纯文本
return text;
}
// 有图片,构建多模态内容数组
const content = [];
// 先添加图片
for (const img of this.pendingImages) {
content.push({
type: 'image_url',
image_url: {url: img.dataUrl}
});
}
// 后添加文本
if (text) {
content.push({type: 'text', text});
}
return content;
},
/**
* 归一化上下文结构
*/
@@ -747,6 +879,15 @@ export default {
}
const [role, value] = entry;
const roleName = typeof role === 'string' ? role.trim() : '';
// 支持多模态内容(数组)
if (Array.isArray(value)) {
if (roleName && value.length > 0) {
normalized.push([roleName, value]);
}
return;
}
const content = typeof value === 'string'
? value.trim()
: String(value ?? '').trim();
@@ -820,8 +961,8 @@ export default {
}
}, () => {
// SSE 连接失败(重试次数用完)时的回调
if (responseEntry && responseEntry.status === 'streaming') {
responseEntry.status = 'completed';
if (responseEntry && ['streaming', 'waiting'].includes(responseEntry.status)) {
this.markResponseError(responseEntry, this.$L('连接失败,请重试'));
}
this.releaseSSEClient(sse);
this.saveCurrentSession();
@@ -1095,6 +1236,7 @@ export default {
this.clearAutoSubmitTimer();
this.clearActiveSSEClients();
this.resetInputHistoryNavigation();
this.clearPendingImages();
this.showModal = false;
this.responses = [];
setTimeout(() => {
@@ -1191,9 +1333,14 @@ export default {
if (!firstPrompt) {
return this.$L('新会话');
}
// 过滤图片占位符,只保留文本
const textOnly = this.parsePromptContent(firstPrompt).text;
if (!textOnly) {
return this.$L('新会话');
}
// 截取前20个字符作为标题
const title = firstPrompt.trim().substring(0, 20);
return title.length < firstPrompt.trim().length ? `${title}...` : title;
const title = textOnly.trim().substring(0, 20);
return title.length < textOnly.trim().length ? `${title}...` : title;
},
/**
@@ -1344,6 +1491,9 @@ export default {
deleteSession(sessionId) {
const index = this.sessionStore.findIndex(s => s.id === sessionId);
if (index > -1) {
const session = this.sessionStore[index];
// 清理会话相关的图片缓存
this.clearSessionImageCache(session);
this.sessionStore.splice(index, 1);
this.saveSessionStore();
// 如果删除的是当前会话,创建新会话
@@ -1360,7 +1510,11 @@ export default {
$A.modalConfirm({
title: this.$L('清空历史会话'),
content: this.$L('确定要清空当前场景的所有历史会话吗?'),
onOk: () => {
onOk: async () => {
// 清理所有会话的图片缓存
for (const session of this.sessionStore) {
await this.clearSessionImageCache(session);
}
this.sessionStore = [];
this.saveSessionStore();
this.createNewSession(false);
@@ -1611,6 +1765,361 @@ export default {
this.zIndexTimer = null;
}
},
// ==================== 图片上传相关 ====================
/**
* 触发图片选择
*/
triggerImageSelect() {
if (this.$refs.imageInput) {
this.$refs.imageInput.click();
}
},
/**
* 处理图片选择
*/
async onImageSelect(event) {
const files = event.target.files;
await this.handleImageFiles(files);
event.target.value = '';
},
/**
* 通用图片文件处理方法
* @param {FileList|File[]} files - 文件列表
*/
async handleImageFiles(files) {
if (!files || files.length === 0) {
return;
}
const remainingSlots = this.maxImages - this.pendingImages.length;
if (remainingSlots <= 0) {
$A.messageWarning(`最多上传 ${this.maxImages} 张图片`);
return;
}
const filesToProcess = Array.from(files).slice(0, remainingSlots);
for (const file of filesToProcess) {
if (!file.type.startsWith('image/')) {
continue;
}
try {
const dataUrl = await this.compressImageForAI(file);
this.pendingImages.push({
id: ++this.imageIdSeed,
dataUrl,
file,
});
} catch (e) {
console.warn('[AIAssistant] 图片压缩失败:', e);
}
}
},
/**
* 处理拖放进入
*/
onDragEnter(event) {
// 检查是否包含文件
if (event.dataTransfer?.types?.includes('Files')) {
this.dragCounter++;
this.isDragging = true;
}
},
/**
* 处理拖放离开
*/
onDragLeave() {
this.dragCounter--;
if (this.dragCounter <= 0) {
this.dragCounter = 0;
this.isDragging = false;
}
},
/**
* 处理拖放放置
*/
async onDrop(event) {
this.dragCounter = 0;
this.isDragging = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
await this.handleImageFiles(imageFiles);
}
},
/**
* 处理粘贴图片
*/
async onPaste(event) {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const imageFiles = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
event.preventDefault();
await this.handleImageFiles(imageFiles);
}
},
/**
* 压缩图片用于 AI 视觉分析
* @param {File} file - 图片文件
* @returns {Promise<string>} - Base64 data URL (image/jpeg)
*/
async compressImageForAI(file) {
// File 转 dataUrl 后压缩到 1024px强制质量压缩
const dataUrl = await this.fileToDataUrl(file);
return this.resizeDataUrl(dataUrl, 1024, true);
},
/**
* File 转 dataUrl
*/
fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsDataURL(file);
});
},
/**
* 移除待发送的图片
*/
removeImage(id) {
const index = this.pendingImages.findIndex(img => img.id === id);
if (index !== -1) {
this.pendingImages.splice(index, 1);
}
},
/**
* 清空待发送的图片
*/
clearPendingImages() {
this.pendingImages = [];
},
// ==================== 图片缓存管理 ====================
/**
* 生成图片缓存 ID
*/
generateImageCacheId() {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
return `${timestamp}_${random}`;
},
/**
* 获取图片缓存 key
*/
getImageCacheKey(imageId) {
return `${this.imageCacheKeyPrefix}_${imageId}`;
},
/**
* 保存图片到独立缓存
*/
async saveImageToCache(imageId, dataUrl) {
const cacheKey = this.getImageCacheKey(imageId);
try {
// 压缩到 512px 再保存(历史图片不需要高清)
const compressedUrl = await this.resizeDataUrl(dataUrl, 512);
await $A.IDBSave(cacheKey, compressedUrl);
// 同时保存到内存缓存
this.imageCache[imageId] = compressedUrl;
} catch (e) {
console.warn('[AIAssistant] 图片缓存保存失败:', e);
}
},
/**
* 压缩 dataUrl 图片到指定尺寸
* @param {string} dataUrl - Base64 图片
* @param {number} maxSize - 最大边长
* @param {boolean} forceCompress - 是否强制质量压缩(即使尺寸不变)
* @returns {Promise<string>} - 压缩后的 dataUrl
*/
resizeDataUrl(dataUrl, maxSize, forceCompress = false) {
return new Promise((resolve, reject) => {
// 参数校验
if (!dataUrl || typeof dataUrl !== 'string') {
reject(new Error('无效的图片数据'));
return;
}
const img = new Image();
img.onload = () => {
let {width, height} = img;
const needResize = width > maxSize || height > maxSize;
// 如果不需要缩放且不强制压缩,直接返回原图
if (!needResize && !forceCompress) {
resolve(dataUrl);
return;
}
// 计算缩放比例
if (needResize) {
const ratio = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
// Canvas 压缩
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.onerror = () => reject(new Error('图片加载失败'));
img.src = dataUrl;
});
},
/**
* 从缓存获取图片
*/
async getImageFromCache(imageId) {
// 先检查内存缓存
if (this.imageCache[imageId]) {
return this.imageCache[imageId];
}
// 从 IndexedDB 获取
const cacheKey = this.getImageCacheKey(imageId);
try {
const dataUrl = await $A.IDBString(cacheKey);
if (dataUrl) {
// 保存到内存缓存
this.imageCache[imageId] = dataUrl;
return dataUrl;
}
} catch (e) {
console.warn('[AIAssistant] 图片缓存读取失败:', e);
}
return null;
},
/**
* 删除单个图片缓存
*/
async deleteImageCache(imageId) {
const cacheKey = this.getImageCacheKey(imageId);
try {
await $A.IDBDel(cacheKey);
delete this.imageCache[imageId];
} catch (e) {
console.warn('[AIAssistant] 图片缓存删除失败:', e);
}
},
/**
* 从会话中提取所有图片 ID
*/
extractImageIdsFromSession(session) {
const imageIds = [];
if (!session?.responses) return imageIds;
for (const response of session.responses) {
if (response.prompt) {
const parsed = this.parsePromptContent(response.prompt);
for (const img of parsed.images) {
imageIds.push(img.imageId);
}
}
}
return imageIds;
},
/**
* 清理会话相关的图片缓存
*/
async clearSessionImageCache(session) {
const imageIds = this.extractImageIdsFromSession(session);
for (const imageId of imageIds) {
await this.deleteImageCache(imageId);
}
},
/**
* 处理多模态内容用于存储
* 将图片存储到独立缓存,返回带占位符的纯文本
* @param {string|Array} content - 消息内容(字符串或多模态数组)
* @returns {Promise<string>} - 带占位符的纯文本
*/
async processContentForStorage(content) {
// 如果是字符串,直接返回
if (typeof content === 'string') {
return content;
}
// 如果不是数组,转为字符串
if (!Array.isArray(content)) {
return String(content);
}
// 处理多模态数组
const parts = [];
for (const item of content) {
if (item.type === 'text') {
parts.push(item.text || '');
} else if (item.type === 'image_url' && item.image_url?.url) {
// 生成图片 ID 并存储
const imageId = this.generateImageCacheId();
await this.saveImageToCache(imageId, item.image_url.url);
parts.push(`[IMG:${imageId}]`);
}
}
return parts.join(' ');
},
/**
* 解析 prompt 内容,分离图片和文字
* @param {string} text - 带占位符的文本
* @returns {Object} - {images: [{imageId}], text: string}
*/
parsePromptContent(text) {
const result = {images: [], text: ''};
if (!text || typeof text !== 'string') {
result.text = text || '';
return result;
}
const regex = /\[IMG:([^\]]+)\]/g;
const textParts = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 收集图片
result.images.push({imageId: match[1]});
// 收集占位符前的文本
if (match.index > lastIndex) {
textParts.push(text.slice(lastIndex, match.index));
}
lastIndex = match.index + match[0].length;
}
// 收集剩余文本
if (lastIndex < text.length) {
textParts.push(text.slice(lastIndex));
}
result.text = textParts.join('').trim();
return result;
},
},
}
</script>
@@ -1672,6 +2181,46 @@ export default {
.ai-assistant-content {
display: flex;
flex-direction: column;
position: relative;
&.ai-assistant-content-dragging {
&::before {
content: '';
position: absolute;
inset: 8px;
border: 2px dashed #2d8cf0;
border-radius: 8px;
background-color: rgba(45, 140, 240, 0.05);
pointer-events: none;
z-index: 10;
}
}
.ai-assistant-drop-overlay {
position: absolute;
inset: 8px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 8px;
z-index: 11;
pointer-events: none;
}
.ai-assistant-drop-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #2d8cf0;
.taskfont {
font-size: 32px;
}
span {
font-size: 14px;
}
}
.ai-assistant-welcome,
.ai-assistant-output {
@@ -1753,12 +2302,24 @@ export default {
.ai-assistant-output-question {
display: flex;
align-items: flex-start;
gap: 4px;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #666;
line-height: 1.4;
.ai-assistant-output-question-images {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ai-assistant-output-question-content {
display: flex;
align-items: flex-start;
gap: 4px;
}
.ai-assistant-output-question-text {
flex: 1;
min-width: 0;
@@ -1939,7 +2500,71 @@ export default {
.ai-assistant-footer-btns {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.ai-assistant-image-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
color: #666;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
}
.taskfont {
font-size: 18px;
}
}
}
.ai-assistant-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.02);
border-radius: 8px;
margin-top: -4px;
.ai-assistant-image-item {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ai-assistant-image-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
.taskfont {
font-size: 12px;
color: #fff;
}
}
&:hover .ai-assistant-image-remove {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,85 @@
<template>
<span class="prompt-image-wrapper" @click="showPreview">
<img v-if="imageUrl" :src="imageUrl" class="prompt-image-thumb" alt="uploaded image" />
<span v-else class="prompt-image-placeholder">
<i class="taskfont">&#xe6ef;</i>
</span>
</span>
</template>
<script>
export default {
name: 'PromptImage',
props: {
imageId: {
type: String,
required: true,
},
getImage: {
type: Function,
required: true,
},
},
data() {
return {
imageUrl: null,
loading: true,
};
},
mounted() {
this.loadImage();
},
methods: {
async loadImage() {
try {
const url = await this.getImage(this.imageId);
this.imageUrl = url;
} catch (e) {
console.warn('[PromptImage] 加载图片失败:', e);
} finally {
this.loading = false;
}
},
showPreview() {
if (this.imageUrl) {
this.$store.dispatch("previewImage", this.imageUrl);
}
},
},
};
</script>
<style lang="scss" scoped>
.prompt-image-wrapper {
display: inline-block;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.prompt-image-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.prompt-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #f5f5f5;
color: #999;
font-size: 18px;
}
</style>

View File

@@ -2409,24 +2409,37 @@ const timezone = require("dayjs/plugin/timezone");
* 对象中有Date格式的转成指定格式
* @param value 支持类型dayjs、Date、string
* @param format 默认格式YYYY-MM-DD HH:mm:ss
* @param key 当前字段名(用于白名单判断)
* @returns {*}
*/
newDateString(value, format = "YYYY-MM-DD HH:mm:ss") {
newDateString(value, format = "YYYY-MM-DD HH:mm:ss", key = null) {
if (value === null) {
return value;
}
if (value instanceof dayjs || value instanceof Date || $A.isDateString(value)) {
value = $A.dayjs(value).format(format);
} else if ($A.isJson(value)) {
// Date/dayjs 对象直接转换
if (value instanceof dayjs || value instanceof Date) {
return $A.dayjs(value).format(format);
}
// 字符串日期处理:
// 1. 直接调用key=null始终转换用于显示格式化
// 2. 递归调用key有值仅白名单字段转换避免文件名等被误转换
if ($A.isDateString(value)) {
if (key === null || key === 'times' || /_at$/i.test(key)) {
return $A.dayjs(value).format(format);
}
return value;
}
// 对象:递归处理
if ($A.isJson(value)) {
value = Object.assign({}, value)
for (let key in value) {
if (!value.hasOwnProperty(key)) continue;
value[key] = $A.newDateString(value[key], format);
for (let k in value) {
if (!value.hasOwnProperty(k)) continue;
value[k] = $A.newDateString(value[k], format, k);
}
} else if ($A.isArray(value)) {
value = Object.assign([], value)
value.forEach((val, index) => {
value[index] = $A.newDateString(val, format);
value[index] = $A.newDateString(val, format, key);
});
}
return value;

View File

@@ -43,7 +43,13 @@
<div
class="dialog-avatar"
@pointerdown="handleOperation">
<UserAvatar :userid="source.userid" :size="30" click-open-detail/>
<!-- AI 助手头像 -->
<div v-if="source.userid === -1" class="ai-assistant-avatar">
<svg class="no-dark-content" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M385.80516777 713.87417358c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404756l-48.91927648-123.9413531c-18.40341303-46.75969229-55.77360888-84.0359932-102.53330118-102.53330117l-123.94135309-48.91927649c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.8257541s7.79328205-24.13100586 19.62404757-28.82575407l123.94135309-48.91927649c46.75969229-18.40341303 84.0359932-55.77360888 102.53330118-102.53330119l48.91927648-123.94135308c4.69474822-11.83076552 16.05603892-19.62404757 28.8257541-19.62404757s24.13100586 7.79328205 28.82575408 19.62404757l48.91927648 123.94135308c18.40341303 46.75969229 55.77360888 84.0359932 102.53330118 102.53330119l123.94135309 48.91927649c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575407 0 12.76971517-7.79328205 24.13100586-19.62404757 28.8257541l-123.94135309 48.91927649c-46.75969229 18.40341303-84.0359932 55.77360888-102.53330118 102.53330117l-48.91927648 123.9413531c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575408 19.62404756zM177.45224165 390.12433614l50.89107073 20.0935224c62.62794129 24.69437565 112.67395736 74.74039171 137.368333 137.36833299l20.09352239 50.89107073 20.0935224-50.89107073c24.69437565-62.62794129 74.74039171-112.67395736 137.368333-137.36833299l50.89107072-20.0935224-50.89107073-20.09352239c-62.62794129-24.69437565-112.67395736-74.74039171-137.36833299-137.36833301l-20.09352239-50.89107074-20.0935224 50.89107074c-24.69437565 62.62794129-74.74039171 112.67395736-137.368333 137.36833301l-50.89107073 20.09352239zM771.33789183 957.62550131c-12.76971517 0-24.13100586-7.79328205-28.82575409-19.62404758l-26.6661699-67.6043744c-8.63833672-21.87752672-26.10280012-39.34199011-47.98032684-47.98032684l-67.60437441-26.6661699c-11.83076552-4.69474822-19.62404757-16.05603892-19.62404757-28.82575409s7.79328205-24.13100586 19.62404757-28.82575409l67.60437441-26.6661699c21.87752672-8.63833672 39.34199011-26.10280012 47.98032684-47.98032685l26.6661699-67.6043744c4.69474822-11.83076552 16.05603892-19.62404757 28.82575409-19.62404757s24.13100586 7.79328205 28.82575409 19.62404757l26.66616991 67.6043744c8.63833672 21.87752672 26.10280012 39.34199011 47.98032684 47.98032685l67.6043744 26.6661699c11.83076552 4.69474822 19.62404757 16.05603892 19.62404757 28.82575409s-7.79328205 24.13100586-19.62404757 28.82575409l-67.6043744 26.6661699c-21.87752672 8.63833672-39.34199011 26.10280012-47.98032684 47.98032684l-26.66616991 67.6043744c-4.69474822 11.83076552-16.14993388 19.62404757-28.82575409 19.62404758z m-75.58544639-190.70067281c33.61439727 14.83540438 60.75004201 41.87715415 75.49155143 75.49155143 14.83540438-33.61439727 41.87715415-60.75004201 75.49155142-75.49155143-33.61439727-14.83540438-60.75004201-41.87715415-75.49155142-75.49155143-14.74150942 33.61439727-41.87715415 60.75004201-75.49155143 75.49155143z"/>
</svg>
</div>
<UserAvatar v-else :userid="source.userid" :size="30" click-open-detail/>
</div>
<DialogView
:msg-data="source"

View File

@@ -94,8 +94,19 @@ export default {
* 处理 dootask:// 协议链接
* 格式: dootask://type/id 或 dootask://type/id1/id2
* 文件链接支持: dootask://file/123 (数字ID) 或 dootask://file/OSwxLHY3ZlN2R245 (base64编码)
* AI 建议链接: dootask://ai-apply/{type}/{task_id}/{msg_id} 或 dootask://ai-dismiss/...
*/
handleDooTaskLink(href) {
// 优先处理 AI 建议链接(格式与其他类型不同)
if (href.startsWith('dootask://ai-apply/')) {
this.handleAiApply(href);
return;
}
if (href.startsWith('dootask://ai-dismiss/')) {
this.handleAiDismiss(href);
return;
}
const match = href.match(/^dootask:\/\/(\w+)\/([^/]+)(?:\/(\d+))?$/);
if (!match) {
return;
@@ -147,6 +158,180 @@ export default {
});
break;
}
},
/**
* 处理 AI 建议采纳
* 格式: dootask://ai-apply/{type}/{task_id}/{msg_id}?{params}
*/
handleAiApply(href) {
const match = href.match(/^dootask:\/\/ai-apply\/(\w+)\/(\d+)\/(\d+)(?:\?(.*))?$/);
if (!match) {
return;
}
const [, type, taskId, msgId, queryString] = match;
const params = new URLSearchParams(queryString || '');
// 构建请求数据
const requestData = {
task_id: parseInt(taskId, 10),
msg_id: parseInt(msgId, 10),
type,
};
// 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}) => {
$A.modalError(msg);
});
},
/**
* 根据类型执行对应的业务操作
*/
applyAiSuggestionByType(type, taskId, result, params) {
switch (type) {
case 'description':
// 更新任务描述Markdown 转 HTML
this.$store.dispatch('taskUpdate', {
task_id: taskId,
content: MarkdownConver(result.content),
}).then(() => {
$A.messageSuccess(this.$L('应用成功'));
}).catch(({msg}) => {
$A.modalError(msg);
});
break;
case 'subtasks':
// 批量创建子任务
this.createSubtasksSequentially(taskId, result.content || []);
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: owners,
}).then(() => {
$A.messageSuccess(this.$L('应用成功'));
}).catch(({msg}) => {
$A.modalError(msg);
});
break;
case 'similar':
// 相似任务关联(后端已处理)
$A.messageSuccess(this.$L('应用成功'));
break;
default:
$A.modalError(this.$L('未知的建议类型'));
}
},
/**
* 顺序创建子任务
*/
createSubtasksSequentially(taskId, subtasks) {
if (!subtasks || subtasks.length === 0) {
$A.modalError(this.$L('没有有效的子任务'));
return;
}
let completed = 0;
const total = subtasks.length;
const createNext = (index) => {
if (index >= total) {
$A.messageSuccess(this.$L('应用成功'));
return;
}
const name = subtasks[index];
if (!name || typeof name !== 'string' || !name.trim()) {
createNext(index + 1);
return;
}
this.$store.dispatch('taskAddSub', {
task_id: taskId,
name: name.trim(),
}).then(() => {
completed++;
createNext(index + 1);
}).catch(({msg}) => {
// 单个失败不影响后续创建
console.warn(`创建子任务失败: ${name}`, msg);
createNext(index + 1);
});
};
createNext(0);
},
/**
* 处理 AI 建议忽略
* 格式: dootask://ai-dismiss/{type}/{task_id}/{msg_id}?userid=xxx&related=xxx
*/
handleAiDismiss(href) {
const match = href.match(/^dootask:\/\/ai-dismiss\/(\w+)\/(\d+)\/(\d+)(\?.*)?$/);
if (!match) {
return;
}
const [, type, taskId, msgId, queryString] = match;
const params = new URLSearchParams(queryString || '');
const data = {
task_id: parseInt(taskId, 10),
msg_id: parseInt(msgId, 10),
type,
};
// 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

@@ -2,7 +2,9 @@
<div class="dialog-view" :class="viewClass" :data-id="msgData.id">
<!--昵称-->
<div v-if="dialogType === 'group'" class="dialog-username" @pointerdown="handleOperation($event, 'mention')">
<UserAvatar :userid="msgData.userid" :show-icon="false" :show-name="true" click-open-detail/>
<!-- AI 助手只显示名称 -->
<span v-if="msgData.userid === -1" class="ai-assistant-name">{{ $L('AI 助手') }}</span>
<UserAvatar v-else :userid="msgData.userid" :show-icon="false" :show-name="true" click-open-detail/>
</div>
<div

View File

@@ -30,6 +30,36 @@
</div>
</div>
<div slot="content" class="taskflow-config">
<div v-if="flowRulesMap[data.id] && flowRulesMap[data.id].length > 0" class="taskflow-config-rules">
<div class="rules-title">
<Icon type="md-list-box" />
<span>{{$L('工作流规则')}}</span>
</div>
<div class="rules-list">
<div v-for="(rule, ruleIndex) in flowRulesMap[data.id]" :key="ruleIndex" class="rules-item">
<template v-if="rule.type === 'owner'">
<span>{{$L('流转到')}}</span>
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span v-if="rule.usertype === 'add'">{{$L('时添加')}}</span>
<span v-else>{{$L('时改变任务负责人为')}}</span>
<UserAvatar v-for="(uid, uidx) in rule.userids" :key="`${ruleIndex}_${uidx}`" :userid="uid" :size="20" :borderWidth="1" showName/>
<span v-if="rule.usertype === 'add'">{{$L('至任务负责人')}}</span>
<span v-else-if="rule.usertype === 'merge'">{{$L('并保留操作人原负责人移至协助人员')}}</span>
<span v-else>{{$L('原负责人移至协助人员')}}</span>
</template>
<template v-else-if="rule.type === 'limit'">
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span>{{$L('仅限任务负责人和项目管理员修改状态')}}</span>
</template>
<template v-else-if="rule.type === 'column'">
<span>{{$L('流转到')}}</span>
<span class="rule-status" :class="rule.status">{{rule.name}}</span>
<span>{{$L('时自动将任务移动至列表')}}</span>
<span class="rule-column">{{rule.columnName}}</span>
</template>
</div>
</div>
</div>
<div class="taskflow-config-table">
<div class="taskflow-config-table-left-container">
<div class="taskflow-config-table-column-header left-header">{{$L('配置项')}}</div>
@@ -269,6 +299,52 @@ export default {
name: item.name,
}
});
},
flowRulesMap() {
const map = {};
const columnMap = {};
this.columnList.forEach(col => {
columnMap[col.id] = col.name;
});
this.list.forEach(data => {
const rules = [];
data.project_flow_item.forEach(item => {
// 状态负责人规则
if (item.userids && item.userids.length > 0) {
rules.push({
type: 'owner',
name: item.name,
status: item.status,
userids: item.userids,
usertype: item.usertype
});
}
// 限制负责人规则(不依赖状态负责人)
if (item.userlimit === 1) {
rules.push({
type: 'limit',
name: item.name,
status: item.status
});
}
// 关联列表规则
if (item.columnid && columnMap[item.columnid]) {
rules.push({
type: 'column',
name: item.name,
status: item.status,
columnName: columnMap[item.columnid]
});
}
});
map[data.id] = rules;
});
return map;
}
},
@@ -573,6 +649,7 @@ export default {
}
});
},
}
}
</script>

View File

@@ -5219,6 +5219,30 @@ export default {
// 启动 MCP 服务器
commit('mcp/server/status', {running: 'running'});
}
},
/** *****************************************************************************************/
/** *********************************** AI Suggestions **************************************/
/** *****************************************************************************************/
/**
* 采纳 AI 建议
*/
applyAiSuggestion({}, params) {
return this.dispatch('call', {
url: 'project/task/ai_apply',
data: params,
});
},
/**
* 忽略 AI 建议
*/
dismissAiSuggestion({}, params) {
return this.dispatch('call', {
url: 'project/task/ai_dismiss',
data: params,
});
}
}

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 工具来搜索任务、项目、文件和联系人。
请根据用户的搜索需求:
@@ -360,10 +359,8 @@ const withLanguagePreferencePrompt = (prompt) => {
return prompt;
}
const label = languageList[languageName] || languageName || '';
if (!label) {
return prompt;
}
return `${prompt}\n\n${LANGUAGE_PREFERENCE_PROMPT(label)}\n\n${SYSTEM_OPTIONAL_PROMPTS_PLACEHOLDER}`;
const languagePart = label ? `\n\n${LANGUAGE_PREFERENCE_PROMPT(label)}` : '';
return `${prompt}${languagePart}\n\n${SYSTEM_OPTIONAL_PROMPTS_PLACEHOLDER}`;
};
/**

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 {

View File

@@ -657,6 +657,22 @@
flex-shrink: 0;
width: 30px;
height: 30px;
.ai-assistant-avatar {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #8bcf70;
fill: #ffffff;
svg {
width: 20px;
height: 20px;
}
}
}
.dialog-view {
@@ -680,6 +696,11 @@
height: 22px;
margin-bottom: 6px;
opacity: 0.8;
.ai-assistant-name {
font-size: 12px;
color: #666;
}
}
.dialog-head {

View File

@@ -127,8 +127,76 @@
.taskflow-config {
display: flex;
flex-direction: column;
max-height: 100%;
.taskflow-config-rules {
flex-shrink: 0;
padding: 12px 20px;
margin-bottom: 12px;
background-color: #f7f8fa;
border-radius: 4px;
.rules-title {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: $primary-title-color;
> i {
margin-right: 4px;
font-size: 16px;
color: #1890ff;
}
}
.rules-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rules-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 13px;
color: #666;
line-height: 24px;
.rule-status {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
&.start {
background-color: rgba($flow-status-start-color, 0.1);
color: $flow-status-start-color;
}
&.progress {
background-color: rgba($flow-status-progress-color, 0.1);
color: $flow-status-progress-color;
}
&.test {
background-color: rgba($flow-status-test-color, 0.1);
color: $flow-status-test-color;
}
&.end {
background-color: rgba($flow-status-end-color, 0.1);
color: $flow-status-end-color;
}
}
.rule-column {
padding: 2px 8px;
background-color: rgba(#1890ff, 0.1);
color: #1890ff;
border-radius: 3px;
font-size: 12px;
}
.common-avatar {
margin: 0 2px;
}
}
}
.taskflow-config-table {
display: flex;
width: 100%;