Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801d0b24ab | ||
|
|
29be29b9cf | ||
|
|
c253044f61 | ||
|
|
9acf7d2046 | ||
|
|
3911af7b51 | ||
|
|
6b722b7ed7 | ||
|
|
6a00b87f72 | ||
|
|
0a97039d75 | ||
|
|
cb56a01622 | ||
|
|
452af4bd2f | ||
|
|
75073d4320 | ||
|
|
d4d7a0d69f | ||
|
|
165ad03024 | ||
|
|
3603cf9889 | ||
|
|
027662ebab | ||
|
|
106465b932 | ||
|
|
eef4c6fbe5 | ||
|
|
916ae97ca7 | ||
|
|
841405505d | ||
|
|
22a653bb0f | ||
|
|
3482e4b1a8 | ||
|
|
9097369b0c | ||
|
|
95c6b53f10 | ||
|
|
f7d5040b02 | ||
|
|
26b7f83d35 | ||
|
|
07b99c6e75 | ||
|
|
cb5e7e2cc7 | ||
|
|
2180998e81 | ||
|
|
478876ddc1 | ||
|
|
ae021fd148 |
@@ -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] 用户ID(assignee类型时用于指定采纳哪个推荐)
|
||||
* @apiParam {Number} [related] 关联任务ID(similar类型时用于指定采纳哪个相似任务)
|
||||
*
|
||||
* @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] 用户ID(assignee类型时用于忽略单个推荐)
|
||||
* @apiParam {Number} [related] 关联任务ID(similar类型时用于忽略单个推荐)
|
||||
*
|
||||
* @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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
154
app/Models/ProjectTaskAiEvent.php
Normal file
154
app/Models/ProjectTaskAiEvent.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
/** ******************************************************************************************** */
|
||||
|
||||
713
app/Module/AiTaskSuggestion.php
Normal file
713
app/Module/AiTaskSuggestion.php
Normal 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 用户ID(assignee类型单独处理时使用)
|
||||
* @param int $related 关联任务ID(similar类型单独处理时使用)
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
132
app/Tasks/AiTaskAnalyzeTask.php
Normal file
132
app/Tasks/AiTaskAnalyzeTask.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
113
app/Tasks/AiTaskLoopTask.php
Normal file
113
app/Tasks/AiTaskLoopTask.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2297,3 +2297,16 @@ AI 消息助手
|
||||
关闭应用
|
||||
请描述你想搜索的内容...
|
||||
搜索中...
|
||||
|
||||
工作流规则
|
||||
流转到
|
||||
时改变任务负责人为
|
||||
,原负责人移至协助人员
|
||||
仅限任务负责人和项目管理员修改状态
|
||||
时自动将任务移动至列表
|
||||
(并保留操作人),原负责人移至协助人员
|
||||
时添加
|
||||
至任务负责人
|
||||
连接失败,请重试
|
||||
最多上传(*)张图片
|
||||
松开以上传图片
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"></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"></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"></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
85
resources/assets/js/components/AIAssistant/prompt-image.vue
Normal file
85
resources/assets/js/components/AIAssistant/prompt-image.vue
Normal 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"></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>
|
||||
29
resources/assets/js/functions/common.js
vendored
29
resources/assets/js/functions/common.js
vendored
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
resources/assets/js/store/actions.js
vendored
24
resources/assets/js/store/actions.js
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
25
resources/assets/js/utils/ai.js
vendored
25
resources/assets/js/utils/ai.js
vendored
@@ -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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
60
resources/assets/js/utils/markdown.js
vendored
60
resources/assets/js/utils/markdown.js
vendored
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user