From 8ddc507bd5212f3b8d8cd44bafc52aebe7b60241 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Tue, 23 Sep 2025 13:11:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AI=20=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ProjectController 中新增 ai_generate 接口,支持根据用户输入生成任务标题和详细描述 - 在 AI 模块中实现 generateTask 方法,处理任务生成逻辑 - 更新前端 TaskAdd 组件,添加 AI 生成按钮,集成任务生成请求 - 优化 TEditor 和 TEditorTask 组件,支持设置内容格式 - 增强样式以提升用户体验 --- .../Controllers/Api/ProjectController.php | 61 ++++++ app/Module/AI.php | 184 +++++++++++++++++- app/Module/Base.php | 2 +- resources/assets/js/components/TEditor.vue | 4 +- .../assets/js/components/TEditorTask.vue | 43 +++- .../js/pages/manage/components/TaskAdd.vue | 82 ++++++++ .../sass/pages/components/task-add.scss | 21 +- 7 files changed, 385 insertions(+), 12 deletions(-) diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 0f57c7a0a..5c2a86087 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -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. 工作流列表 * diff --git a/app/Module/AI.php b/app/Module/AI.php index 2de492511..eac63fd16 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -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" => << "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); diff --git a/app/Module/Base.php b/app/Module/Base.php index f2b8cb1ad..bbed5b8ed 100755 --- a/app/Module/Base.php +++ b/app/Module/Base.php @@ -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; } diff --git a/resources/assets/js/components/TEditor.vue b/resources/assets/js/components/TEditor.vue index e94dca979..d19f0b956 100755 --- a/resources/assets/js/components/TEditor.vue +++ b/resources/assets/js/components/TEditor.vue @@ -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); } }, diff --git a/resources/assets/js/components/TEditorTask.vue b/resources/assets/js/components/TEditorTask.vue index 8aba6c869..27985ddf8 100755 --- a/resources/assets/js/components/TEditorTask.vue +++ b/resources/assets/js/components/TEditorTask.vue @@ -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() }, diff --git a/resources/assets/js/pages/manage/components/TaskAdd.vue b/resources/assets/js/pages/manage/components/TaskAdd.vue index 1f66915e1..4e3b9833d 100644 --- a/resources/assets/js/pages/manage/components/TaskAdd.vue +++ b/resources/assets/js/pages/manage/components/TaskAdd.vue @@ -33,8 +33,12 @@ :placeholder="$L('任务描述')" enterkeyhint="done" @on-keydown="onKeydown"/> +
+ +
{ + 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); + }); + }) + } + }) } } } diff --git a/resources/assets/sass/pages/components/task-add.scss b/resources/assets/sass/pages/components/task-add.scss index a24cccf66..454a6a42e 100644 --- a/resources/assets/sass/pages/components/task-add.scss +++ b/resources/assets/sass/pages/components/task-add.scss @@ -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;