From e0443aa336c255d0d05e37cb4ee72e0e85fd7143 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Wed, 5 Nov 2025 02:33:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0AI=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B1=87=E6=8A=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Api/ReportController.php | 132 ++++++++++++++++++ app/Models/Report.php | 11 ++ app/Models/ReportAnalysis.php | 27 ++++ app/Module/AI.php | 120 ++++++++++++++++ ...120000_create_report_ai_analyses_table.php | 43 ++++++ .../pages/manage/components/ReportDetail.vue | 108 ++++++++++++-- resources/assets/sass/components/report.scss | 51 +++++++ 7 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 app/Models/ReportAnalysis.php create mode 100644 database/migrations/2025_03_22_120000_create_report_ai_analyses_table.php diff --git a/app/Http/Controllers/Api/ReportController.php b/app/Http/Controllers/Api/ReportController.php index ec8d834ef..003efef69 100755 --- a/app/Http/Controllers/Api/ReportController.php +++ b/app/Http/Controllers/Api/ReportController.php @@ -6,10 +6,12 @@ use App\Exceptions\ApiException; use App\Models\AbstractModel; use App\Models\ProjectTask; use App\Models\Report; +use App\Models\ReportAnalysis; use App\Models\ReportLink; use App\Models\ReportReceive; use App\Models\User; use App\Models\WebSocketDialogMsg; +use App\Module\AI; use App\Module\Base; use App\Module\Doo; use App\Tasks\PushTask; @@ -501,9 +503,121 @@ class ReportController extends AbstractController $one->report_link = $link; $link->increment("num"); } + $analysis = ReportAnalysis::query() + ->whereRid($one->id) + ->whereUserid($user->userid) + ->first(); + if ($analysis) { + $updatedAt = $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null; + $one->setAttribute('ai_analysis', [ + 'id' => $analysis->id, + 'text' => $analysis->analysis_text, + 'model' => $analysis->model, + 'updated_at' => $updatedAt, + ]); + } else { + $one->setAttribute('ai_analysis', null); + } + return Base::retSuccess("success", $one); } + /** + * @api {post} api/report/ai_analyze 生成工作汇报 AI 分析 + * + * @apiDescription 需要token身份,仅支持报告提交人或接收人发起分析 + * @apiVersion 1.0.0 + * @apiGroup report + * @apiName ai_analyze + * + * @apiParam {Number} id 报告ID + * @apiParam {Array|String} [focus] 分析关注点(可选) + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + * @apiSuccess {Number} data.id 分析记录ID + * @apiSuccess {String} data.text 分析内容(Markdown) + * @apiSuccess {String} data.updated_at 最近更新时间 + */ + public function ai_analyze(): array + { + $user = User::auth(); + $id = intval(Request::input("id")); + if ($id <= 0) { + return Base::retError("缺少ID参数"); + } + + $report = Report::getOne($id); + + if (!$this->userCanAccessReport($report, $user)) { + return Base::retError("无权访问该工作汇报"); + } + + $analysis = ReportAnalysis::query() + ->whereRid($report->id) + ->whereUserid($user->userid) + ->first(); + + $context = [ + 'viewer_name' => $user->nickname ?? ('用户' . $user->userid), + ]; + if (!empty($user->profession)) { + $context['viewer_role'] = $user->profession; + } elseif (is_array($user->identity) && !empty($user->identity)) { + $context['viewer_role'] = implode('/', $user->identity); + } + if ($analysis && $analysis->analysis_text) { + $context['previous_feedback'] = $analysis->analysis_text; + } + + $focus = Request::input('focus'); + if (is_array($focus)) { + $context['focus'] = $focus; + } elseif (is_string($focus) && trim($focus) !== '') { + $context['focus_note'] = trim($focus); + } + + $result = AI::analyzeReport($report, $context); + if (Base::isError($result)) { + return Base::retError("生成AI分析失败", $result); + } + $data = $result['data']; + + if (!$analysis) { + $analysis = ReportAnalysis::fillInstance([ + 'rid' => $report->id, + 'userid' => $user->userid, + ]); + } + + $meta = array_filter([ + 'viewer_role' => $context['viewer_role'] ?? null, + 'viewer_name' => $context['viewer_name'] ?? null, + 'focus' => $context['focus'] ?? null, + ], function ($value) { + if (is_array($value)) { + return !empty($value); + } + return $value !== null && $value !== ''; + }); + + $analysis->updateInstance([ + 'model' => $data['model'] ?? '', + 'analysis_text' => $data['text'], + 'meta' => $meta, + ]); + $analysis->save(); + + $analysis->refresh(); + + return Base::retSuccess("success", [ + 'id' => $analysis->id, + 'text' => $analysis->analysis_text, + 'updated_at' => $analysis->updated_at ? $analysis->updated_at->toDateTimeString() : null, + ]); + } + /** * @api {get} api/report/mark 标记已读/未读 * @@ -691,4 +805,22 @@ class ReportController extends AbstractController } return Base::retSuccess("success", $data); } + + /** + * 判断当前用户是否有权限查看/分析指定工作汇报 + * @param Report $report + * @param User $user + * @return bool + */ + protected function userCanAccessReport(Report $report, User $user): bool + { + if ($report->userid === $user->userid) { + return true; + } + + return ReportReceive::query() + ->whereRid($report->id) + ->whereUserid($user->userid) + ->exists(); + } } diff --git a/app/Models/Report.php b/app/Models/Report.php index 458bfb998..c51522da9 100644 --- a/app/Models/Report.php +++ b/app/Models/Report.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use JetBrains\PhpStorm\Pure; /** @@ -78,6 +79,16 @@ class Report extends AbstractModel ->withPivot("receive_at", "read"); } + public function aiAnalyses(): HasMany + { + return $this->hasMany(ReportAnalysis::class, 'rid'); + } + + public function aiAnalysis(): HasOne + { + return $this->hasOne(ReportAnalysis::class, 'rid'); + } + public function sendUser() { return $this->hasOne(User::class, "userid", "userid"); diff --git a/app/Models/ReportAnalysis.php b/app/Models/ReportAnalysis.php new file mode 100644 index 000000000..011404189 --- /dev/null +++ b/app/Models/ReportAnalysis.php @@ -0,0 +1,27 @@ + 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class, 'rid'); + } +} diff --git a/app/Module/AI.php b/app/Module/AI.php index 7c4f81acb..13d1325a4 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -2,6 +2,7 @@ namespace App\Module; +use App\Models\Report; use App\Models\Setting; use Cache; use Carbon\Carbon; @@ -621,6 +622,67 @@ class AI ]); } + /** + * 对工作汇报内容进行分析 + * @param Report $report + * @param array $context + * @return array + */ + public static function analyzeReport(Report $report, array $context = []) + { + $prompt = self::buildReportAnalysisPrompt($report, $context); + if ($prompt === '') { + return Base::retError("报告内容为空,无法进行分析"); + } + + $model = "gpt-5-mini"; + $post = json_encode([ + "model" => $model, + "reasoning_effort" => "minimal", + "messages" => [ + [ + "role" => "system", + "content" => << "user", + "content" => $prompt, + ], + ], + ]); + + $ai = new self($post); + $ai->setTimeout(60); + + $res = $ai->request(); + if (Base::isError($res)) { + return Base::retError("工作汇报分析失败", $res); + } + + $content = trim($res['data']); + $content = preg_replace('/^\s*```(?:markdown|md|text)?\s*/i', '', $content); + $content = preg_replace('/\s*```\s*$/', '', $content); + $content = trim($content); + + if ($content === '') { + return Base::retError("工作汇报分析结果为空"); + } + + return Base::retSuccess("success", [ + 'text' => $content, + 'model' => $model, + ]); + } + /** * 构建任务生成的上下文提示信息 * @param array $context 上下文信息 @@ -890,6 +952,64 @@ class AI return $result; } + /** + * 构建工作汇报分析的提示词 + * @param Report $report + * @param array $context + * @return string + */ + private static function buildReportAnalysisPrompt(Report $report, array $context = []): string + { + $sections = []; + + $metaLines = []; + $metaLines[] = "标题:" . ($report->title ?: "(未填写)"); + $metaLines[] = "类型:" . match ($report->type) { + Report::WEEKLY => "周报", + Report::DAILY => "日报", + default => $report->type, + }; + if ($report->created_at) { + $createdAt = $report->created_at instanceof Carbon + ? $report->created_at->toDateTimeString() + : (string)$report->created_at; + $metaLines[] = "提交时间:" . $createdAt; + } + if (!empty($context['viewer_role'])) { + $metaLines[] = "查看人角色:" . trim($context['viewer_role']); + } + if (!empty($context['viewer_name'])) { + $metaLines[] = "查看人:" . trim($context['viewer_name']); + } + if (!empty($metaLines)) { + $sections[] = "### 基础信息\n" . implode("\n", array_map(function ($line) { + return "- " . $line; + }, $metaLines)); + } + + if (!empty($context['focus']) && is_array($context['focus'])) { + $focusItems = array_filter(array_map('trim', $context['focus'])); + if (!empty($focusItems)) { + $sections[] = "### 关注重点\n" . implode("\n", array_map(function ($line) { + return "- " . $line; + }, $focusItems)); + } + } elseif (!empty($context['focus_note'])) { + $sections[] = "### 关注重点\n- " . trim($context['focus_note']); + } + + if (!empty($context['previous_feedback'])) { + $sections[] = "### 历史反馈\n" . trim($context['previous_feedback']); + } + + $contentMarkdown = trim(Base::html2markdown($report->content)); + if ($contentMarkdown !== '') { + $sections[] = "### 汇报正文\n" . $contentMarkdown; + } + + return trim(implode("\n\n", array_filter($sections))); + } + /** * 获取 ollama 模型 * @param $baseUrl diff --git a/database/migrations/2025_03_22_120000_create_report_ai_analyses_table.php b/database/migrations/2025_03_22_120000_create_report_ai_analyses_table.php new file mode 100644 index 000000000..e34c57f58 --- /dev/null +++ b/database/migrations/2025_03_22_120000_create_report_ai_analyses_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('rid')->comment('报告ID'); + $table->unsignedBigInteger('userid')->comment('生成分析的会员ID'); + $table->string('model')->default('')->comment('使用的模型名称'); + $table->longText('analysis_text')->comment('AI 分析的原始文本(Markdown)'); + $table->json('meta')->nullable()->comment('额外的上下文信息'); + $table->timestamps(); + + $table->unique(['rid', 'userid'], 'uk_report_ai_analysis_rid_userid'); + $table->index(['userid', 'updated_at'], 'idx_report_ai_analysis_user_updated'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('report_ai_analyses'); + } +} diff --git a/resources/assets/js/pages/manage/components/ReportDetail.vue b/resources/assets/js/pages/manage/components/ReportDetail.vue index f1baee0b6..72c855244 100644 --- a/resources/assets/js/pages/manage/components/ReportDetail.vue +++ b/resources/assets/js/pages/manage/components/ReportDetail.vue @@ -1,17 +1,17 @@ + \ No newline at end of file diff --git a/resources/assets/sass/components/report.scss b/resources/assets/sass/components/report.scss index 39bb01d4b..e08c17cd0 100644 --- a/resources/assets/sass/components/report.scss +++ b/resources/assets/sass/components/report.scss @@ -328,3 +328,54 @@ } } +.report-ai-analysis { + margin-top: 24px; + padding: 16px; + border: 1px solid #f0f0f0; + border-radius: 8px; + background-color: #fafbff; + + .analysis-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .analysis-title { + font-size: 16px; + font-weight: 600; + color: #17233d; + } + + .analysis-loading { + display: flex; + align-items: center; + gap: 8px; + color: #515a6e; + font-size: 14px; + } + + .analysis-meta { + margin-bottom: 12px; + font-size: 12px; + color: #808695; + } + + .analysis-empty { + font-size: 14px; + color: #808695; + } + + .vuepress-markdown-body { + h1 { + font-size: 1.8em; + } + h2 { + font-size: 1.5em; + } + h3 { + font-size: 1.2em; + } + } +} \ No newline at end of file