mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-11 18:42:54 +00:00
feat: 添加 AI 助手生成任务功能
- 在 ProjectController 中新增 ai_generate 接口,支持根据用户输入生成任务标题和详细描述 - 在 AI 模块中实现 generateTask 方法,处理任务生成逻辑 - 更新前端 TaskAdd 组件,添加 AI 生成按钮,集成任务生成请求 - 优化 TEditor 和 TEditorTask 组件,支持设置内容格式 - 增强样式以提升用户体验
This commit is contained in:
parent
1c4bae2d91
commit
8ddc507bd5
@ -14,6 +14,7 @@ use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Module\Timer;
|
||||
use Swoole\Coroutine;
|
||||
use App\Module\AI;
|
||||
use App\Models\Deleted;
|
||||
use App\Models\Project;
|
||||
use App\Module\TimeRange;
|
||||
@ -2590,6 +2591,66 @@ class ProjectController extends AbstractController
|
||||
return Base::retSuccess('移动成功', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/project/task/ai_generate 40. 使用 AI 助手生成任务
|
||||
*
|
||||
* @apiDescription 需要token身份,使用AI根据用户输入和上下文信息生成任务标题和详细描述
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup project
|
||||
* @apiName task__ai_generate
|
||||
*
|
||||
* @apiParam {String} content 用户输入的任务描述(必填)
|
||||
* @apiParam {String} [current_title] 当前已有的任务标题(用于优化改进)
|
||||
* @apiParam {String} [current_content] 当前已有的任务内容(HTML格式,用于优化改进)
|
||||
* @apiParam {String} [template_name] 选中的任务模板名称
|
||||
* @apiParam {String} [template_content] 选中的任务模板内容(HTML格式)
|
||||
* @apiParam {Boolean} [has_owner] 是否已设置负责人
|
||||
* @apiParam {Boolean} [has_time_plan] 是否已设置计划时间
|
||||
* @apiParam {String} [priority_level] 任务优先级等级名称
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息(错误描述)
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
* @apiSuccess {String} data.title AI 生成的任务标题
|
||||
* @apiSuccess {String} data.content AI 生成的任务内容(HTML 格式)
|
||||
* @apiSuccess {Array} data.subtasks 当任务较复杂时生成的子任务名称列表
|
||||
*/
|
||||
public function task__ai_generate()
|
||||
{
|
||||
User::auth();
|
||||
|
||||
// 获取用户输入的任务描述
|
||||
$content = Request::input('content');
|
||||
if (empty($content)) {
|
||||
return Base::retError('任务描述不能为空');
|
||||
}
|
||||
|
||||
// 获取上下文信息
|
||||
$context = [
|
||||
'current_title' => Request::input('current_title', ''),
|
||||
'current_content' => Request::input('current_content', ''),
|
||||
'template_name' => Request::input('template_name', ''),
|
||||
'template_content' => Request::input('template_content', ''),
|
||||
'has_owner' => boolval(Request::input('has_owner', false)),
|
||||
'has_time_plan' => boolval(Request::input('has_time_plan', false)),
|
||||
'priority_level' => Request::input('priority_level', ''),
|
||||
];
|
||||
|
||||
// 如果当前内容是HTML格式,转换为markdown
|
||||
if (!empty($context['current_content'])) {
|
||||
$context['current_content'] = Base::html2markdown($context['current_content']);
|
||||
}
|
||||
if (!empty($context['template_content'])) {
|
||||
$context['template_content'] = Base::html2markdown($context['template_content']);
|
||||
}
|
||||
|
||||
$result = AI::generateTask($content, $context);
|
||||
if (Base::isError($result)) {
|
||||
return Base::retError('生成任务失败', $result);
|
||||
}
|
||||
return Base::retSuccess('生成任务成功', $result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/project/flow/list 40. 工作流列表
|
||||
*
|
||||
|
||||
@ -220,8 +220,6 @@ class AI
|
||||
"content" => "请将以下内容翻译为 {$targetLanguage}:\n\n{$text}"
|
||||
]
|
||||
],
|
||||
"temperature" => 0.2,
|
||||
"max_tokens" => max(1000, intval(mb_strlen($text) * 1.5))
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
@ -289,8 +287,6 @@ class AI
|
||||
"content" => "请为以下内容生成一个合适的标题:\n\n" . $text
|
||||
]
|
||||
],
|
||||
"temperature" => 0.3,
|
||||
"max_tokens" => 100
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
@ -319,6 +315,185 @@ class AI
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成任务标题和描述
|
||||
* @param string $text 任务描述
|
||||
* @param array $context 上下文信息
|
||||
* @return array
|
||||
*/
|
||||
public static function generateTask($text, $context = [])
|
||||
{
|
||||
// 构建上下文提示信息
|
||||
$contextPrompt = self::buildTaskContextPrompt($context);
|
||||
|
||||
$post = json_encode([
|
||||
"model" => "gpt-5-nano",
|
||||
"messages" => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的任务管理专家,擅长将想法和需求转化为清晰、可执行的项目任务。
|
||||
|
||||
任务生成要求:
|
||||
1. 根据输入内容分析并生成合适的任务标题和详细描述
|
||||
2. 标题要简洁明了,准确概括任务核心目标,长度控制在8-30个字符
|
||||
3. 描述需覆盖任务背景、具体要求、交付标准、风险提示等关键信息
|
||||
4. 描述内容使用Markdown格式,合理组织标题、列表、加粗等结构
|
||||
5. 内容需适配项目管理系统,表述专业、逻辑清晰,并与用户输入语言保持一致
|
||||
6. 优先遵循用户在输入中给出的风格、长度或复杂度要求;默认情况下将详细描述控制在120-200字内,如用户要求简单或简短,则控制在80-120字内
|
||||
7. 当任务具有多个执行步骤、阶段或协作角色时,请拆解出 2-6 个关键子任务;如无必要,可返回空数组
|
||||
8. 子任务应聚焦单一可执行动作,名称控制在8-30个字符内,避免重复和含糊表述
|
||||
|
||||
返回格式要求:
|
||||
必须严格按照以下 JSON 结构返回,禁止输出额外文字或 Markdown 代码块标记;即使某项为空,也保留对应字段:
|
||||
{
|
||||
"title": "任务标题",
|
||||
"content": "任务的详细描述内容,使用Markdown格式,根据实际情况组织结构",
|
||||
"subtasks": [
|
||||
"子任务名称1",
|
||||
"子任务名称2"
|
||||
]
|
||||
}
|
||||
|
||||
内容格式建议(非强制):
|
||||
- 可以使用标题、列表、加粗等Markdown格式
|
||||
- 可以包含任务背景、具体要求、验收标准等部分
|
||||
- 根据任务性质灵活组织内容结构
|
||||
- 仅在确有必要时生成子任务,并确保每个子任务都是独立、可执行、便于追踪的动作
|
||||
- 若用户明确要求简洁或简单,保持描述紧凑,避免添加冗余段落或重复信息
|
||||
|
||||
上下文信息处理指南:
|
||||
- 如果已有标题和内容,优先考虑优化改进而非完全重写
|
||||
- 如果使用了任务模板,严格按照模板的结构和格式要求生成
|
||||
- 如果已设置负责人或时间计划,在任务描述中体现相关要求
|
||||
- 根据优先级等级调整任务的紧急程度和详细程度
|
||||
|
||||
注意事项:
|
||||
- 标题要体现任务的核心动作和目标
|
||||
- 描述要包含足够的细节让执行者理解任务
|
||||
- 如果涉及技术开发,要明确技术要求和实现方案
|
||||
- 如果涉及设计,要说明设计要求和期望效果
|
||||
- 如果涉及测试,要明确测试范围和验收标准
|
||||
EOF
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $contextPrompt . "\n\n请根据以上上下文和以下用户描述生成一个完整的项目任务(包含标题和详细描述):\n\n" . $text
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
$ai->setTimeout(60);
|
||||
|
||||
$res = $ai->request();
|
||||
if (Base::isError($res)) {
|
||||
return Base::retError("任务生成失败", $res);
|
||||
}
|
||||
|
||||
// 清理可能的markdown代码块标记
|
||||
$content = $res['data'];
|
||||
$content = preg_replace('/^\s*```json\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```\s*$/', '', $content);
|
||||
|
||||
if (empty($content)) {
|
||||
return Base::retError("任务生成结果为空");
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
$parsedData = Base::json2array($content);
|
||||
if (!$parsedData || !isset($parsedData['title']) || !isset($parsedData['content'])) {
|
||||
return Base::retError("任务生成格式错误", $content);
|
||||
}
|
||||
|
||||
$title = trim($parsedData['title']);
|
||||
$markdownContent = trim($parsedData['content']);
|
||||
$rawSubtasks = $parsedData['subtasks'] ?? [];
|
||||
|
||||
if (empty($title) || empty($markdownContent)) {
|
||||
return Base::retError("生成的任务标题或内容为空", $parsedData);
|
||||
}
|
||||
|
||||
$subtasks = [];
|
||||
if (is_array($rawSubtasks)) {
|
||||
foreach ($rawSubtasks as $raw) {
|
||||
if (is_array($raw)) {
|
||||
$name = trim($raw['title'] ?? $raw['name'] ?? '');
|
||||
} else {
|
||||
$name = trim($raw);
|
||||
}
|
||||
|
||||
if (!empty($name)) {
|
||||
$subtasks[] = $name;
|
||||
}
|
||||
|
||||
if (count($subtasks) >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Base::retSuccess("success", [
|
||||
'title' => $title,
|
||||
'content' => Base::markdown2html($markdownContent), // 将 Markdown 转换为 HTML
|
||||
'subtasks' => $subtasks
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建任务生成的上下文提示信息
|
||||
* @param array $context 上下文信息
|
||||
* @return string
|
||||
*/
|
||||
private static function buildTaskContextPrompt($context)
|
||||
{
|
||||
$prompts = [];
|
||||
|
||||
// 当前任务信息
|
||||
if (!empty($context['current_title']) || !empty($context['current_content'])) {
|
||||
$prompts[] = "## 当前任务信息";
|
||||
if (!empty($context['current_title'])) {
|
||||
$prompts[] = "当前标题:" . $context['current_title'];
|
||||
}
|
||||
if (!empty($context['current_content'])) {
|
||||
$prompts[] = "当前内容:" . $context['current_content'];
|
||||
}
|
||||
$prompts[] = "请在此基础上优化改进,而不是完全重写。";
|
||||
}
|
||||
|
||||
// 任务模板信息
|
||||
if (!empty($context['template_name']) || !empty($context['template_content'])) {
|
||||
$prompts[] = "## 任务模板要求";
|
||||
if (!empty($context['template_name'])) {
|
||||
$prompts[] = "模板名称:" . $context['template_name'];
|
||||
}
|
||||
if (!empty($context['template_content'])) {
|
||||
$prompts[] = "模板内容结构:" . $context['template_content'];
|
||||
}
|
||||
$prompts[] = "请严格按照此模板的结构和格式要求生成内容。";
|
||||
}
|
||||
|
||||
// 项目状态信息
|
||||
$statusInfo = [];
|
||||
if (!empty($context['has_owner'])) {
|
||||
$statusInfo[] = "已设置负责人";
|
||||
}
|
||||
if (!empty($context['has_time_plan'])) {
|
||||
$statusInfo[] = "已设置计划时间";
|
||||
}
|
||||
if (!empty($context['priority_level'])) {
|
||||
$statusInfo[] = "优先级:" . $context['priority_level'];
|
||||
}
|
||||
|
||||
if (!empty($statusInfo)) {
|
||||
$prompts[] = "## 任务状态";
|
||||
$prompts[] = implode(",", $statusInfo);
|
||||
$prompts[] = "请在任务描述中体现相应的要求和约束。";
|
||||
}
|
||||
|
||||
return empty($prompts) ? "" : implode("\n", $prompts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 openAI 生成职场笑话、心灵鸡汤
|
||||
* @param bool $noCache 是否禁用缓存
|
||||
@ -365,7 +540,6 @@ class AI
|
||||
"content" => "请生成20个职场笑话和20个心灵鸡汤"
|
||||
]
|
||||
],
|
||||
"temperature" => 0.8
|
||||
]);
|
||||
|
||||
$ai = new self($post);
|
||||
|
||||
@ -3052,7 +3052,7 @@ class Base
|
||||
{
|
||||
try {
|
||||
$converter = new CommonMarkConverter();
|
||||
return $converter->convert($markdown);
|
||||
return $converter->convert($markdown)->getContent();
|
||||
} catch (\League\CommonMark\Exception\CommonMarkException $e) {
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
@ -567,11 +567,11 @@ export default {
|
||||
return this.getEditor().getContent();
|
||||
},
|
||||
|
||||
setContent(content) {
|
||||
setContent(content, args = {}) {
|
||||
if (this.getEditor() === null) {
|
||||
this.content = content;
|
||||
} else if (content != this.getEditor().getContent()) {
|
||||
this.getEditor().setContent(content);
|
||||
this.getEditor().setContent(content, args);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -39,6 +39,39 @@
|
||||
.task-editor {
|
||||
position: relative;
|
||||
word-break: break-all;
|
||||
::v-deep .mce-content-body,
|
||||
::v-deep .task-editor-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
::v-deep p {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
::v-deep blockquote,
|
||||
::v-deep pre,
|
||||
::v-deep ul,
|
||||
::v-deep ol {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
::v-deep ul,
|
||||
::v-deep ol {
|
||||
margin-left: 1.5em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
::v-deep li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
::v-deep h1 {margin: 0.67em 0;}
|
||||
::v-deep h2 {margin: 0.83em 0;}
|
||||
::v-deep h3 {margin: 1em 0;}
|
||||
::v-deep h4 {margin: 1.33em 0;}
|
||||
::v-deep h5 {margin: 1.67em 0;}
|
||||
::v-deep h6 {margin: 2.33em 0;}
|
||||
|
||||
.task-editor-operate {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -86,7 +119,7 @@ export default {
|
||||
min_height: 200,
|
||||
max_height: 380,
|
||||
contextmenu: 'checklist | bold italic underline forecolor backcolor | link | uploadImages imagePreview | history screenload',
|
||||
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
|
||||
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,h1,h2,h3,h4,h5,h6,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
|
||||
extended_valid_elements: 'a[href|title|target=_blank]',
|
||||
toolbar: false
|
||||
},
|
||||
@ -94,9 +127,9 @@ export default {
|
||||
menubar: 'file edit view',
|
||||
removed_menuitems: 'preview,print',
|
||||
contextmenu: 'checklist | bold italic underline forecolor backcolor | link | uploadImages imagePreview | screenload',
|
||||
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
|
||||
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,h1,h2,h3,h4,h5,h6,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
|
||||
extended_valid_elements: 'a[href|title|target=_blank]',
|
||||
toolbar: 'uploadImages | checklist | bold italic underline | forecolor backcolor',
|
||||
toolbar: 'uploadImages | checklist | bullist numlist | formatselect | bold italic underline | forecolor backcolor',
|
||||
mobile: {
|
||||
menubar: 'file edit view',
|
||||
},
|
||||
@ -164,6 +197,10 @@ export default {
|
||||
this.content = html
|
||||
},
|
||||
|
||||
setContent(html, args = {}) {
|
||||
this.$refs.desc.setContent(html, args);
|
||||
},
|
||||
|
||||
onEditing() {
|
||||
this.$refs.desc.onFull()
|
||||
},
|
||||
|
||||
@ -33,8 +33,12 @@
|
||||
:placeholder="$L('任务描述')"
|
||||
enterkeyhint="done"
|
||||
@on-keydown="onKeydown"/>
|
||||
<div class="ai-btn" @click="onAI">
|
||||
<i class="taskfont"></i>
|
||||
</div>
|
||||
</div>
|
||||
<TEditorTask
|
||||
ref="editorTaskRef"
|
||||
class="desc"
|
||||
v-model="addData.content"
|
||||
:placeholder="$L(windowLandscape ? '详细描述,选填...(点击右键使用工具栏)' : '详细描述,选填...')"
|
||||
@ -618,6 +622,84 @@ export default {
|
||||
if (defaultTemplate) {
|
||||
this.setTaskTemplate(defaultTemplate);
|
||||
}
|
||||
},
|
||||
|
||||
onAI() {
|
||||
$A.modalInput({
|
||||
title: 'AI 生成',
|
||||
placeholder: `请输入任务需求,AI 将自动生成标题和详细描述`,
|
||||
inputProps: {
|
||||
type: 'textarea',
|
||||
rows: 2,
|
||||
autosize: { minRows: 2, maxRows: 6 },
|
||||
maxlength: 500,
|
||||
},
|
||||
onOk: (value) => {
|
||||
if (!value) {
|
||||
return `请输入任务描述`
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// 获取当前任务模板信息
|
||||
const currentTemplate = this.templateActiveID ?
|
||||
this.taskTemplateList.find(item => item.id === this.templateActiveID) : null;
|
||||
|
||||
this.$store.dispatch("call", {
|
||||
url: 'project/task/ai_generate',
|
||||
data: {
|
||||
content: value,
|
||||
// 当前已有的标题和内容作为参考
|
||||
current_title: this.addData.name || '',
|
||||
current_content: this.addData.content || '',
|
||||
// 当前选中的任务模板信息
|
||||
template_name: currentTemplate ? currentTemplate.name : '',
|
||||
template_content: currentTemplate ? currentTemplate.content : '',
|
||||
// 其他上下文信息
|
||||
has_owner: this.addData.owner && this.addData.owner.length > 0,
|
||||
has_time_plan: this.addData.times && this.addData.times.length > 0,
|
||||
priority_level: this.addData.p_name || ''
|
||||
},
|
||||
timeout: 60 * 1000,
|
||||
}).then(({data}) => {
|
||||
this.addData.name = data.title;
|
||||
this.$refs.editorTaskRef.setContent(data.content, {format: 'raw'});
|
||||
if (Array.isArray(data.subtasks) && data.subtasks.length > 0) {
|
||||
const normalized = data.subtasks
|
||||
.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return item.trim();
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const name = item.title || item.name || '';
|
||||
return typeof name === 'string' ? name.trim() : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(item => item !== '');
|
||||
|
||||
const unique = Array.from(new Set(normalized)).slice(0, 8);
|
||||
|
||||
if (unique.length > 0) {
|
||||
const mainOwner = Array.isArray(this.addData.owner) && this.addData.owner.length > 0
|
||||
? [this.addData.owner[0]]
|
||||
: (this.userId ? [this.userId] : []);
|
||||
|
||||
const subtasks = unique.map(name => ({
|
||||
name,
|
||||
owner: [...mainOwner],
|
||||
times: [],
|
||||
}));
|
||||
|
||||
this.$set(this.addData, 'subtasks', subtasks);
|
||||
this.advanced = true;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}).catch(({msg}) => {
|
||||
reject(msg);
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,10 +96,11 @@
|
||||
.task-add-form,
|
||||
.task-add-advanced {
|
||||
.title {
|
||||
position: relative;
|
||||
.ivu-input {
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
padding: 4px 0;
|
||||
padding: 4px 32px 4px 0;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
border-color: transparent;
|
||||
@ -107,6 +108,24 @@
|
||||
box-shadow: none
|
||||
}
|
||||
}
|
||||
.ai-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
> i {
|
||||
font-size: 24px;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.desc {
|
||||
margin-top: 24px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user