From 72726da9b817fc535b5ac194447ec9a2748f57f8 Mon Sep 17 00:00:00 2001 From: sunny-335 Date: Tue, 16 Jun 2026 18:52:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8D=89=E7=A8=BF=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E5=9B=9E=E5=A4=8D=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E3=80=81=E9=A2=84=E8=AE=BE=E6=89=A9=E5=B1=95=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=9B=9E=E5=A4=8D=E3=80=81=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复草稿模式下仍自动发布评论的Bug - 新增草稿模式下编辑AI回复内容功能 - 新增3个Prompt预设(技术解答型/鼓励型/知识科普型) - 新增多语言回复指令 - 新增日志页面时间范围筛选 - 新增AI角色排序功能 - 新增配置导入/导出功能 - 修复processingLocks内存泄漏 - 修复OptimisticLockingFailureException缺少重试 - 修复publishReply空Mono导致审核挂起 - 移除近7日回复趋势卡片 - 版本号更新至0.0.0-ygkszvd - 全面更新插件文档 --- README.md | 13 +- build.gradle | 2 +- docs/guide/auto-reply.md | 2 + docs/guide/draft-mode.md | 35 +- docs/guide/faq.md | 47 +- docs/guide/filter.md | 18 +- docs/guide/introduction.md | 13 +- docs/guide/persona.md | 36 +- docs/guide/prompt.md | 39 +- docs/guide/settings.md | 70 ++- docs/index.md | 2 +- .../endpoint/CommentAiAutopilotEndpoint.java | 522 +++++++++++++++--- .../extension/AiCommentReply.java | 3 + .../extension/AiPersona.java | 3 + .../listener/CommentReconciler.java | 34 ++ .../listener/ReplyReconciler.java | 34 ++ .../service/AiReplyOrchestrator.java | 76 ++- .../service/CommentReplyPublisher.java | 36 +- .../service/FilterService.java | 2 +- .../service/PromptBuilder.java | 22 +- .../extensions/annotation-setting.yaml | 30 + .../resources/extensions/roleTemplate.yaml | 6 + src/main/resources/plugin.yaml | 2 +- ui/src/views/HomeView.vue | 88 +-- ui/src/views/LogsView.vue | 70 ++- ui/src/views/SettingsView.vue | 288 ++++++++-- 26 files changed, 1217 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 3cc576a..5fc76cc 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ ## 功能特性 -- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 +- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,支持排序,可为不同文章指定不同角色 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 -- **多语言适配** — 根据评论语言自动用对应语言回复 +- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等) - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 -- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 +- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容,支持批量操作 - **失败重试** — AI 生成失败时自动重试,指数退避策略 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度 @@ -17,10 +17,11 @@ - **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复 - **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝 - **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等) -- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 -- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换 +- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合 +- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息 - **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性 -- **日志筛选** — 按状态、情感筛选,关键词搜索 +- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页 +- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份 - **数据清理** — 自动清理超过指定天数的旧记录 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力 diff --git a/build.gradle b/build.gradle index 9bd62d3..563178a 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '0.0.1-t5w8r3' +version '0.0.0-ygkszvd' repositories { mavenCentral() diff --git a/docs/guide/auto-reply.md b/docs/guide/auto-reply.md index 854bf4e..0e825cc 100644 --- a/docs/guide/auto-reply.md +++ b/docs/guide/auto-reply.md @@ -31,6 +31,8 @@ |--------|------|--------| | 自动回复 | 是否启用自动回复功能 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | +| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 | +| 速率限制 | 每分钟最大AI回复数量 | 10 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | ## 重试机制 diff --git a/docs/guide/draft-mode.md b/docs/guide/draft-mode.md index d86ff5a..6323a5f 100644 --- a/docs/guide/draft-mode.md +++ b/docs/guide/draft-mode.md @@ -9,17 +9,32 @@ ## 审核流程 1. 新评论到达后,AI生成回复内容 -2. 回复以草稿状态保存(`approved = false`) +2. 回复以草稿状态保存(状态为"通过",但未发布) 3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮 4. 管理员可以: + - **编辑** — 修改AI回复内容后再审核 - **审核通过** — 回复立即发布,访客可见 - **拒绝** — 删除草稿回复,记录标记为 REJECTED +## 编辑回复内容 + +草稿模式下,管理员可以在发布前编辑AI生成的回复内容: + +1. 在日志页面找到状态为"通过"且未发布的记录 +2. 点击 **编辑** 按钮 +3. 在弹出的编辑对话框中修改回复内容 +4. 点击 **保存** 完成修改 +5. 修改后可继续审核通过或拒绝 + +::: tip +编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。 +::: + ## 日志页面操作 在AI回复日志页面: -- 草稿记录显示 **审核通过** 和 **拒绝** 按钮 +- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮 - 已发布的记录显示正常状态 - 被拒绝的记录显示 REJECTED 标签 @@ -37,3 +52,19 @@ ::: warning 批量操作不可撤销,请谨慎操作。 ::: + +## 草稿模式工作原理 + +草稿模式下,AI回复的处理流程与自动发布模式不同: + +| 步骤 | 自动发布模式 | 草稿模式 | +|------|------------|---------| +| AI生成回复 | ✅ | ✅ | +| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 | +| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 | +| 审核通过时 | — | 创建 Reply 扩展并发布 | +| 拒绝时 | — | 标记为 REJECTED | + +::: info +草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。 +::: diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 8724a69..25db149 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -10,10 +10,14 @@ ## AI回复头像不显示? -1. 确认在插件设置中填写了AI角色邮箱 +1. 确认在AI角色设置中填写了邮箱 2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像 3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务 +::: info +由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。 +::: + ## 评论没有触发AI回复? 可能的原因: @@ -23,6 +27,8 @@ 3. **已有AI回复记录** — 同一评论不会重复触发 4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发 5. **AI生成失败** — 检查AI模型配置和日志 +6. **速率限制** — 检查是否触发了每分钟回复数量限制 +7. **对话轮次上限** — 检查是否达到了最大对话轮次 ## 如何对历史评论触发AI回复? @@ -32,12 +38,24 @@ ## 草稿模式的回复在哪里审核? -在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。 +在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。 + +## 如何在发布前编辑AI回复? + +1. 确保草稿模式已启用(关闭"自动发布"开关) +2. 在AI回复日志页面找到状态为"通过"且未发布的记录 +3. 点击 **编辑** 按钮修改回复内容 +4. 修改完成后点击"审核通过"发布 + +::: tip +编辑功能仅在草稿状态(通过但未发布)下可用。 +::: ## 如何修改AI回复的语气风格? 1. 在插件设置中修改 **AI角色人格提示词** 2. 或修改 **自定义Prompt模板** +3. 或选择不同的 **Prompt预设** 风格组合 ## AI Foundation 插件是必须的吗? @@ -63,6 +81,27 @@ 在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。 -## 黑名单支持邮箱吗? +## 黑名单支持正则表达式吗? -支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。 +支持。黑名单支持三种匹配方式: +- **名称匹配**:直接输入评论者显示名称 +- **邮箱匹配**:输入邮箱地址 +- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*` + +## 如何管理多个AI角色? + +在插件设置的 **AI角色设置** 区域,可以添加、编辑、删除AI角色,也可以使用 ▲▼ 按钮调整角色顺序。每个角色有独立的昵称、人格提示词和Gravatar头像。 + +## 如何导入/导出配置? + +在插件设置页面右上角: +- 点击 **导出配置** 将当前设置和AI角色导出为 JSON 文件 +- 点击 **导入配置** 从 JSON 文件导入配置(导入前会显示确认对话框) + +## 日志页面如何按时间筛选? + +在日志页面的筛选栏中,使用日期选择器设置起始日期和结束日期,即可筛选指定时间范围内的记录。 + +## 为什么AI角色不支持自定义上传头像? + +Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,会忽略自定义头像 URL。这是 Halo 评论系统的设计限制,因此插件只支持通过 Gravatar 邮箱方式设置AI角色头像。 diff --git a/docs/guide/filter.md b/docs/guide/filter.md index 45ae346..3cee5e9 100644 --- a/docs/guide/filter.md +++ b/docs/guide/filter.md @@ -32,9 +32,21 @@ 1. 进入插件设置页面 2. 在 **基本设置** 中找到 **评论者黑名单** -3. 输入评论者的显示名称或邮箱,多个用逗号分隔 +3. 输入评论者的显示名称、邮箱或正则表达式,多个用逗号分隔 4. 保存设置 +### 匹配方式 + +| 方式 | 格式 | 示例 | +|------|------|------| +| 名称匹配 | 直接输入名称 | `张三` | +| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` | +| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` | + +::: tip +正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。 +::: + ### 从评论列表选择 1. 在黑名单输入框旁点击 **添加评论者** 按钮 @@ -45,7 +57,7 @@ ### 示例 ``` -张三,spam@example.com,李四 +张三,spam@example.com,李四,regex:^bot.* ``` -黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。 +黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式也会参与匹配。匹配成功则跳过AI回复。 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 5c5475d..901b297 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -5,18 +5,19 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 ## 核心功能 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 -- **多语言适配** — 根据评论语言自动用对应语言回复 +- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等) - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 -- **草稿模式** — AI回复先存为草稿,管理员审核后再发布 +- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容 - **失败重试** — AI生成失败时自动重试,指数退避策略 - **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 -- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复 +- **评论者黑名单** — 屏蔽指定评论者,支持名称、邮箱和正则表达式匹配 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复 -- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像 +- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,支持多角色管理和排序 - **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 -- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 -- **日志筛选搜索** — 按状态、情感筛选,关键词搜索 +- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息 +- **日志筛选搜索** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页 +- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份 - **数据清理** — 自动清理超过指定天数的旧记录 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 diff --git a/docs/guide/persona.md b/docs/guide/persona.md index 1b1ce63..f382f5d 100644 --- a/docs/guide/persona.md +++ b/docs/guide/persona.md @@ -1,6 +1,28 @@ # AI角色 -AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。 +AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。 + +## 多角色管理 + +插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。 + +### 默认角色 + +系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。 + +### 角色排序 + +多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。 + +### 角色操作 + +| 操作 | 说明 | +|------|------| +| 添加角色 | 点击"添加角色"按钮创建新角色 | +| 编辑角色 | 点击编辑图标修改角色信息 | +| 删除角色 | 点击删除图标移除角色(默认角色不可删除) | +| 设为默认 | 点击星标图标将角色设为默认角色 | +| 排序 | 使用 ▲▼ 按钮调整角色顺序 | ## 角色配置 @@ -28,11 +50,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 ### 邮箱与头像 -填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成: +填写邮箱后,AI回复者的头像将通过 Cravatar(Gravatar 镜像)服务自动生成: 1. 插件根据邮箱生成 SHA-256 哈希 -2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}` -3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中 +2. 构造头像 URL:`https://cn.cravatar.com/avatar/{hash}` +3. Halo 评论系统通过 `email-hash` 注解自动匹配头像 ::: warning 如果不填写邮箱,AI回复者将使用 Halo 默认头像。 @@ -40,7 +62,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 ### 头像预览 -在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。 +在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。 ::: tip 如果预览头像不正确,请检查: @@ -48,3 +70,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像 3. 头像更新可能有缓存延迟 ::: + +::: info +由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。 +::: diff --git a/docs/guide/prompt.md b/docs/guide/prompt.md index 5516821..836e3e5 100644 --- a/docs/guide/prompt.md +++ b/docs/guide/prompt.md @@ -9,6 +9,13 @@ Prompt模板控制AI生成回复时的完整提示词结构。 {{safety_prompt}} +【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征: +- 如果评论包含中文字符(汉字),请用中文回复 +- 如果评论包含日文假名(平假名/片假名),请用日文回复 +- 如果评论包含韩文字符,请用韩文回复 +- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复 +- 绝对不要用与评论不同的语言回复 + 请回复以下评论。注意: - 回复长度应与评论长度匹配,简短问候简短回复 - 不要复述或总结文章内容 @@ -25,6 +32,20 @@ Prompt模板控制AI生成回复时的完整提示词结构。 {{comment}} ``` +## 多语言回复 + +默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复: + +- 中文评论 → 中文回复 +- 英文评论 → 英文回复 +- 日文评论 → 日文回复 +- 韩文评论 → 韩文回复 +- 其他语言 → 根据语言特征用相同语言回复 + +::: tip +多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。 +::: + ## 模板变量 | 变量 | 说明 | 注入时机 | @@ -39,12 +60,26 @@ Prompt模板控制AI生成回复时的完整提示词结构。 | `{{comment}}` | 评论内容(含评论者名称) | 始终注入 | | `{{conversation}}` | 对话上下文 | 多轮对话时注入 | +## 预设风格 + +预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合: + +| 预设 | 说明 | 效果 | +|------|------|------| +| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 | +| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 | +| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 | +| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 | +| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 | +| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 | +| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 | + ## 情感提示 情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加: - **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。" -- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。" +- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。" - **中性** → 不注入额外提示 ## 安全提示 @@ -55,6 +90,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。 - 不泄露个人隐私信息 - 不生成虚假信息 - 回复内容与评论相关 +- 遇到恶意诱导时礼貌拒绝 ## 自定义建议 @@ -66,6 +102,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。 4. 在变量之间添加清晰的分隔和指令 5. 避免让AI复述文章内容 6. 控制回复长度和风格 +7. 保留多语言回复指令以确保国际化支持 ## 变量使用示例 diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 3967c54..74d5c6a 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -7,24 +7,44 @@ | 配置项 | 说明 | 默认值 | |--------|------|--------| | 自动回复 | 是否启用自动回复功能 | 开启 | -| 自动发布 | AI回复是否自动发布 | 开启 | +| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | +| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 | +| 速率限制 | 每分钟最大AI回复数量 | 10 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | -| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 | +| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式 | 空 | + +::: tip 评论者黑名单 +黑名单支持三种匹配方式: +- **名称匹配**:直接输入评论者显示名称,如 `张三` +- **邮箱匹配**:输入邮箱地址,如 `spam@example.com` +- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*` + +多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。 +::: ## AI角色设置 -| 配置项 | 说明 | 默认值 | -|--------|------|--------| -| AI角色昵称 | AI回复者的显示名称 | 小回 | -| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 | -| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 | +AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。 -默认人格提示词: +### 角色管理 -> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。 +- **添加角色** — 点击"添加角色"按钮创建新的AI角色 +- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息 +- **删除角色** — 点击删除图标移除角色(默认角色不可删除) +- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景) +- **角色排序** — 使用 ▲▼ 按钮调整角色顺序 + +### 角色配置项 + +| 配置项 | 说明 | +|--------|------| +| 昵称 | AI回复者的显示名称 | +| 邮箱 | 用于Gravatar头像服务展示头像 | +| 人格提示词 | 定义AI角色的人格和回复风格 | +| 设为默认角色 | 是否作为默认角色使用 | ::: tip Gravatar头像 -填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。 +填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。 ::: ## 模型设置 @@ -41,8 +61,21 @@ | 配置项 | 说明 | 默认值 | |--------|------|--------| +| Prompt预设 | 选择预设风格,可多选 | 无 | | 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 | +### 预设风格 + +| 预设 | 说明 | +|------|------| +| 友好型 | 热情友好,像朋友聊天 | +| 专业型 | 严谨正式,有逻辑性 | +| 幽默型 | 轻松诙谐,适当幽默 | +| 简洁型 | 一两句话,简洁明了 | +| 技术解答型 | 深入浅出,专业解答技术问题 | +| 鼓励型 | 积极正面,给予鼓励和支持 | +| 知识科普型 | 通俗易懂,普及相关知识 | + ### 模板变量 | 变量 | 说明 | @@ -52,7 +85,9 @@ | `{{sentiment_prompt}}` | 情感语气提示词(自动注入) | | `{{article}}` | 文章内容 | | `{{comment}}` | 评论内容 | -| `{{conversation}}` | 对话上下文(多轮对话时) | +| `{{post_title}}` | 文章标题 | +| `{{post_date}}` | 文章发布日期 | +| `{{comment_count}}` | 评论数 | ## 数据清理 @@ -62,5 +97,16 @@ | 保留天数 | 超过此天数的记录将被自动清理 | 30 | ::: tip -你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。 +你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。 +::: + +## 配置导入/导出 + +设置页面右上角提供配置导入/导出功能: + +- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件 +- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览 + +::: warning +导入配置将覆盖当前配置,此操作不可撤销。 ::: diff --git a/docs/index.md b/docs/index.md index 4860314..78b7a09 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,5 +25,5 @@ features: - title: 灵活过滤 details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配 - title: 数据管理 - details: 仪表盘统计、日志筛选搜索、自动清理旧记录 + details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录 --- diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 2d4f13b..3fa7bee 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -13,8 +13,10 @@ import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.PageRequestImpl; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; @@ -22,11 +24,15 @@ import top.nxxy335.commentaiautopilot.extension.AiPersona; import top.nxxy335.commentaiautopilot.service.AiFoundationClient; import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; +import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; +import reactor.util.retry.Retry; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -49,15 +55,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private final AiReplyOrchestrator orchestrator; private final AiReplyCleanupService cleanupService; private final ObjectProvider aiFoundationClientProvider; + private final CommentReplyPublisher commentReplyPublisher; private final ObjectMapper objectMapper; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; - public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider aiFoundationClientProvider) { + public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) { this.client = client; this.orchestrator = orchestrator; this.cleanupService = cleanupService; this.aiFoundationClientProvider = aiFoundationClientProvider; + this.commentReplyPublisher = commentReplyPublisher; this.objectMapper = new ObjectMapper(); } @@ -84,6 +92,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .POST("/personas", this::createPersona) .PUT("/personas/{name}", this::updatePersona) .DELETE("/personas/{name}", this::deletePersona) + // 导出配置 + .GET("/export", this::exportConfig) + // 导入配置 + .POST("/import", this::importConfig) + // 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展) + .PUT("/replies/{name}/content", this::updateReplyContent) .build(); } @@ -98,48 +112,108 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var statusFilter = request.queryParam("status").orElse(""); var sentimentFilter = request.queryParam("sentiment").orElse(""); var keywordFilter = request.queryParam("keyword").orElse(""); + var startDateStr = request.queryParam("startDate").orElse(""); + var endDateStr = request.queryParam("endDate").orElse(""); + var sortOrder = request.queryParam("sortOrder").orElse("desc"); - return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) - .collectList() - .map(replies -> { - var filtered = replies.stream() - .filter(r -> { - if (!statusFilter.isBlank() - && !statusFilter.equals(r.getSpec().getStatus())) { - return false; - } - if (!sentimentFilter.isBlank() - && !sentimentFilter.equals(r.getSpec().getSentiment())) { - return false; - } - if (!keywordFilter.isBlank()) { - String reply = r.getSpec().getReply(); - if (reply == null || !reply.contains(keywordFilter)) { - return false; + // Parse date filters + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + ZoneId zoneId = ZoneId.systemDefault(); + Instant startInstant = null; + Instant endInstant = null; + try { + if (!startDateStr.isBlank()) { + startInstant = LocalDate.parse(startDateStr, dateFormatter).atStartOfDay(zoneId).toInstant(); + } + if (!endDateStr.isBlank()) { + endInstant = LocalDate.parse(endDateStr, dateFormatter).plusDays(1).atStartOfDay(zoneId).toInstant(); + } + } catch (Exception e) { + log.warn("Failed to parse date filter: {}", e.getMessage()); + } + final Instant finalStartInstant = startInstant; + final Instant finalEndInstant = endInstant; + + // Check if we need in-memory filtering (keyword or date range) + boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null; + + if (needsMemoryFilter) { + // Fall back to listAll + in-memory filter for complex queries + return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) + .collectList() + .map(replies -> { + var filtered = replies.stream() + .filter(r -> { + if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false; + if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false; + if (!keywordFilter.isBlank()) { + String reply = r.getSpec().getReply(); + if (reply == null || !reply.contains(keywordFilter)) return false; } - } + if (finalStartInstant != null || finalEndInstant != null) { + Instant creationTs = r.getMetadata().getCreationTimestamp(); + if (creationTs == null) return false; + if (finalStartInstant != null && creationTs.isBefore(finalStartInstant)) return false; + if (finalEndInstant != null && !creationTs.isBefore(finalEndInstant)) return false; + } + return true; + }) + .sorted(Comparator.comparing( + (AiCommentReply r) -> r.getMetadata().getCreationTimestamp(), + Comparator.nullsLast("asc".equalsIgnoreCase(sortOrder) + ? Comparator.naturalOrder() : Comparator.reverseOrder()) + )) + .toList(); + + int total = filtered.size(); + int fromIndex = (page - 1) * size; + int toIndex = Math.min(fromIndex + size, total); + List pageContent = fromIndex < total + ? filtered.subList(fromIndex, toIndex) : List.of(); + + Map result = new HashMap<>(); + result.put("items", pageContent); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + result.put("first", page == 1); + result.put("last", toIndex >= total); + return result; + }) + .flatMap(result -> ServerResponse.ok().bodyValue(result)); + } + + // Simple filters only - use server-side pagination + Sort sort = "asc".equalsIgnoreCase(sortOrder) + ? Sort.by(Sort.Order.asc("metadata.creationTimestamp")) + : Sort.by(Sort.Order.desc("metadata.creationTimestamp")); + + var listOptions = ListOptions.builder().build(); + // Note: Halo's ListOptions fieldSelector support may be limited + // For status and sentiment, we'll still filter in memory but with paginated data + + return client.listBy(AiCommentReply.class, listOptions, + PageRequestImpl.of(page - 1, size, sort)) + .map(listResult -> { + var items = listResult.getItems(); + // Apply status/sentiment filter in memory on the current page + var filtered = items.stream() + .filter(r -> { + if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false; + if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false; return true; }) - .sorted(Comparator.comparing( - (AiCommentReply r) -> r.getMetadata().getCreationTimestamp(), - Comparator.nullsLast(Comparator.reverseOrder()) - )) .toList(); - int total = filtered.size(); - int fromIndex = (page - 1) * size; - int toIndex = Math.min(fromIndex + size, total); - List pageContent = fromIndex < total - ? filtered.subList(fromIndex, toIndex) : List.of(); - Map result = new HashMap<>(); - result.put("items", pageContent); - result.put("total", total); + result.put("items", filtered); + result.put("total", listResult.getTotal()); result.put("page", page); result.put("size", size); - result.put("totalPages", (int) Math.ceil((double) total / size)); + result.put("totalPages", (int) Math.ceil((double) listResult.getTotal() / size)); result.put("first", page == 1); - result.put("last", toIndex >= total); + result.put("last", page >= (int) Math.ceil((double) listResult.getTotal() / size)); return result; }) .flatMap(result -> ServerResponse.ok().bodyValue(result)); @@ -362,22 +436,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var name = request.pathVariable("name"); return client.fetch(AiCommentReply.class, name) .flatMap(record -> { - // Find the corresponding Reply and set approved=true - return findReplyForRecord(record) - .flatMap(reply -> { - reply.getSpec().setApproved(true); - reply.getSpec().setApprovedTime(Instant.now()); - return client.update(reply); - }) - .then(Mono.defer(() -> { - // Update AiCommentReply record - return client.fetch(AiCommentReply.class, name) + String replyName = record.getSpec().getReplyName(); + if (replyName == null || replyName.isBlank()) { + // Draft mode: no Reply extension exists, create one with approved=true + return commentReplyPublisher.publishReply( + record.getSpec().getCommentId(), + record.getSpec().getReply(), + record.getSpec().getPostId(), + record.getSpec().getReplyTo(), + true, + record.getSpec().getPersonaName() + ) + .switchIfEmpty(Mono.defer(() -> { + log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name); + return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布")); + })) + .flatMap(publishedReply -> { + String newReplyName = publishedReply.getMetadata().getName(); + return client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(newReplyName); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)); + }) + .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); + } else { + // Reply extension already exists, set approved=true + return client.fetch(Reply.class, replyName) + .flatMap(reply -> { + reply.getSpec().setApproved(true); + reply.getSpec().setApprovedTime(Instant.now()); + return client.update(reply); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) .flatMap(latest -> { latest.getSpec().setPublished(true); return client.update(latest); - }); - })) - .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )) + .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); + } }) .switchIfEmpty(ServerResponse.notFound().build()); } @@ -386,18 +491,28 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var name = request.pathVariable("name"); return client.fetch(AiCommentReply.class, name) .flatMap(record -> { - // Delete the draft Reply if it exists - return findReplyForRecord(record) - .flatMap(reply -> client.delete(reply)) - .then(Mono.defer(() -> { - // Update AiCommentReply record status to REJECTED - return client.fetch(AiCommentReply.class, name) - .flatMap(latest -> { - latest.getSpec().setStatus("REJECTED"); - latest.getSpec().setPublished(false); - return client.update(latest); - }); - })) + String replyName = record.getSpec().getReplyName(); + Mono deleteReplyMono; + if (replyName != null && !replyName.isBlank()) { + // Reply extension exists, delete it + deleteReplyMono = client.fetch(Reply.class, replyName) + .flatMap(reply -> client.delete(reply)) + .then(); + } else { + // No Reply extension in draft mode, nothing to delete + deleteReplyMono = Mono.empty(); + } + return deleteReplyMono + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setStatus("REJECTED"); + latest.getSpec().setPublished(false); + latest.getSpec().setReplyName(null); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )) .then(ServerResponse.ok().bodyValue(Map.of("message", "rejected"))); }) .switchIfEmpty(ServerResponse.notFound().build()); @@ -418,19 +533,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return Flux.fromIterable(names) .flatMap(name -> client.fetch(AiCommentReply.class, name) - .flatMap(record -> findReplyForRecord(record) - .flatMap(reply -> { - reply.getSpec().setApproved(true); - reply.getSpec().setApprovedTime(Instant.now()); - return client.update(reply); - }) - .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) - .flatMap(latest -> { - latest.getSpec().setPublished(true); - return client.update(latest); - }))) - .thenReturn(true) - ) + .flatMap(record -> { + String replyName = record.getSpec().getReplyName(); + if (replyName == null || replyName.isBlank()) { + // Draft mode: no Reply extension exists, create one with approved=true + return commentReplyPublisher.publishReply( + record.getSpec().getCommentId(), + record.getSpec().getReply(), + record.getSpec().getPostId(), + record.getSpec().getReplyTo(), + true, + record.getSpec().getPersonaName() + ) + .switchIfEmpty(Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"))) + .flatMap(publishedReply -> { + String newReplyName = publishedReply.getMetadata().getName(); + return client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(newReplyName); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)); + }); + } else { + // Reply extension already exists, set approved=true + return client.fetch(Reply.class, replyName) + .flatMap(reply -> { + reply.getSpec().setApproved(true); + reply.getSpec().setApprovedTime(Instant.now()); + return client.update(reply); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )); + } + }) + .thenReturn(true) .onErrorResume(e -> { log.warn("Batch approve failed for {}: {}", name, e.getMessage()); return Mono.just(false); @@ -462,16 +609,29 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return Flux.fromIterable(names) .flatMap(name -> client.fetch(AiCommentReply.class, name) - .flatMap(record -> findReplyForRecord(record) - .flatMap(reply -> client.delete(reply)) - .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) - .flatMap(latest -> { - latest.getSpec().setStatus("REJECTED"); - latest.getSpec().setPublished(false); - return client.update(latest); - }))) - .thenReturn(true) - ) + .flatMap(record -> { + String replyName = record.getSpec().getReplyName(); + Mono deleteReplyMono; + if (replyName != null && !replyName.isBlank()) { + deleteReplyMono = client.fetch(Reply.class, replyName) + .flatMap(reply -> client.delete(reply)) + .then(); + } else { + deleteReplyMono = Mono.empty(); + } + return deleteReplyMono + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setStatus("REJECTED"); + latest.getSpec().setPublished(false); + latest.getSpec().setReplyName(null); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )) + .thenReturn(true); + }) .onErrorResume(e -> { log.warn("Batch reject failed for {}: {}", name, e.getMessage()); return Mono.just(false); @@ -600,8 +760,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } private Mono findReplyForRecord(AiCommentReply record) { - // Find the Reply that belongs to the same comment and was created by AI - // If the record has a quoteReply, match by that too for precision + // First try using replyName if available + String replyName = record.getSpec().getReplyName(); + if (replyName != null && !replyName.isBlank()) { + return client.fetch(Reply.class, replyName); + } + // Fallback: find the Reply by commentName + owner annotations return client.list(Reply.class, reply -> { if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) { @@ -634,7 +798,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { public record CommenterInfo( String displayName, - String email + String email, + String avatarUrl ) {} private Mono listCommenters(ServerRequest request) { @@ -647,11 +812,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null; if (owner == null) continue; String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : ""; - String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null + String email = Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()) && owner.getName() != null ? owner.getName() : ""; String key = displayName.toLowerCase() + "|" + email.toLowerCase(); if (seen.add(key)) { - result.add(new CommenterInfo(displayName, email)); + // 优先使用 owner 注解中的头像,否则用邮箱生成 Gravatar + String avatarUrl = ""; + if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) { + avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO); + } else if (!email.isBlank()) { + avatarUrl = generateGravatarUrl(email); + } + result.add(new CommenterInfo(displayName, email, avatarUrl)); } } return result; @@ -659,6 +831,20 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .flatMap(commenters -> ServerResponse.ok().bodyValue(commenters)); } + private String generateGravatarUrl(String email) { + try { + var digest = java.security.MessageDigest.getInstance("SHA-256"); + var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var hexString = new StringBuilder(); + for (byte b : hashBytes) { + hexString.append(String.format("%02x", b)); + } + return "https://cn.cravatar.com/avatar/" + hexString; + } catch (Exception e) { + return ""; + } + } + private Mono triggerCleanup(ServerRequest request) { return Mono.fromCallable(() -> { int retentionDays = cleanupService.getRetentionDays(); @@ -703,6 +889,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private Mono listPersonas(ServerRequest request) { return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted()) .collectList() + .map(personas -> personas.stream() + .sorted(Comparator.comparing( + (AiPersona p) -> p.getSpec() != null ? p.getSpec().getPriority() : null, + Comparator.nullsLast(Comparator.naturalOrder()) + )) + .toList() + ) .flatMap(personas -> ServerResponse.ok().bodyValue(personas)); } @@ -742,6 +935,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { existing.setSpec(updatedPersona.getSpec()); return client.update(existing); }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) .flatMap(saved -> ServerResponse.ok().bodyValue(saved)) .onErrorResume(e -> { log.warn("Failed to update persona {}: {}", name, e.getMessage()); @@ -765,4 +960,159 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { }) .switchIfEmpty(ServerResponse.notFound().build()); } + + private Mono exportConfig(ServerRequest request) { + var result = new java.util.LinkedHashMap(); + // 导出 ConfigMap + return client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME) + .map(configMap -> { + result.put("configMap", configMap.getData()); + return result; + }) + .defaultIfEmpty(result) + .flatMap(r -> { + // 导出所有 AiPersona + return client.list(AiPersona.class, null, null) + .collectList() + .map(personas -> { + r.put("personas", personas); + return r; + }); + }) + .flatMap(r -> ServerResponse.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .bodyValue(r)); + } + + private Mono importConfig(ServerRequest request) { + return request.bodyToMono(java.util.Map.class) + .flatMap(body -> { + if (body == null || !body.containsKey("configMap") && !body.containsKey("personas")) { + return ServerResponse.badRequest() + .bodyValue(java.util.Map.of("error", "无效的配置格式")); + } + var results = new java.util.ArrayList(); + Mono importMono = Mono.empty(); + + // 导入 ConfigMap + if (body.containsKey("configMap")) { + @SuppressWarnings("unchecked") + var configMapData = (java.util.Map) body.get("configMap"); + importMono = importMono.then( + client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME) + .flatMap(existing -> { + existing.setData(configMapData); + return client.update(existing) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .doOnSuccess(v -> results.add("ConfigMap 已更新")) + .then(); + }) + .switchIfEmpty(Mono.defer(() -> { + run.halo.app.extension.ConfigMap cm = new run.halo.app.extension.ConfigMap(); + cm.setMetadata(new run.halo.app.extension.Metadata()); + cm.getMetadata().setName(CONFIG_MAP_NAME); + cm.setData(configMapData); + return client.create(cm) + .doOnSuccess(v -> results.add("ConfigMap 已创建")) + .then(); + })) + ); + } + + // 导入 AiPersona + if (body.containsKey("personas")) { + @SuppressWarnings("unchecked") + var personasList = (java.util.List>) body.get("personas"); + for (var personaData : personasList) { + importMono = importMono.then(Mono.defer(() -> { + try { + var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var personaJson = objectMapper.writeValueAsString(personaData); + var persona = objectMapper.readValue(personaJson, AiPersona.class); + var personaName = persona.getMetadata().getName(); + return client.fetch(AiPersona.class, personaName) + .flatMap(existing -> { + persona.getMetadata().setVersion(existing.getMetadata().getVersion()); + return client.update(persona) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已更新")) + .then(); + }) + .switchIfEmpty(client.create(persona) + .doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已创建")) + .then()); + } catch (Exception e) { + results.add("导入角色失败: " + e.getMessage()); + return Mono.empty(); + } + })); + } + } + + return importMono.then( + ServerResponse.ok().bodyValue(java.util.Map.of("results", results)) + ); + }) + .onErrorResume(e -> ServerResponse.badRequest() + .bodyValue(java.util.Map.of("error", "导入失败: " + e.getMessage()))); + } + + private Mono updateReplyContent(ServerRequest request) { + var name = request.pathVariable("name"); + return request.bodyToMono(String.class) + .flatMap(body -> { + String newReply; + try { + JsonNode node = objectMapper.readTree(body); + JsonNode replyNode = node.get("reply"); + if (replyNode == null || replyNode.asText().isBlank()) { + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "reply 字段不能为空")); + } + newReply = replyNode.asText(); + } catch (Exception e) { + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "请求体格式错误")); + } + + return client.fetch(AiCommentReply.class, name) + .flatMap(record -> { + // Only allow when published is false (draft mode) + if (Boolean.TRUE.equals(record.getSpec().getPublished())) { + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "已发布的回复不可编辑")); + } + + // Update AiCommentReply.spec.reply + return client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReply(newReply); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .flatMap(updatedRecord -> { + String replyName = updatedRecord.getSpec().getReplyName(); + if (replyName != null && !replyName.isBlank()) { + // Reply extension exists, update its content too + return client.fetch(Reply.class, replyName) + .flatMap(reply -> { + reply.getSpec().setRaw(newReply); + reply.getSpec().setContent(newReply); + return client.update(reply); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name))); + } + // Draft mode: no Reply extension, only update AiCommentReply + return Mono.just(updatedRecord); + }) + .flatMap(finalRecord -> ServerResponse.ok().bodyValue(finalRecord)); + }) + .switchIfEmpty(ServerResponse.notFound().build()); + }); + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java index 2012362..428c220 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java @@ -59,5 +59,8 @@ public class AiCommentReply extends AbstractExtension { @Schema(description = "使用的AI角色名称") private String personaName; + + @Schema(description = "关联的Reply扩展名称,草稿模式下为空") + private String replyName; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java index 7d2ee31..364d937 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java @@ -37,5 +37,8 @@ public class AiPersona extends AbstractExtension { @Schema(description = "是否为默认角色") @JsonProperty("isDefault") private Boolean isDefault; + + @Schema(description = "排序优先级,数值越小越靠前") + private Integer priority; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java index f8ef3a0..d0c0c7d 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java @@ -4,8 +4,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.scheduler.Schedulers; +import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; @@ -148,6 +150,7 @@ public class CommentReconciler implements Reconciler { String postName = subjectRef.getName(); return client.fetch(Post.class, postName) .map(post -> { + // 1. 文章注解优先 var annotations = post.getMetadata().getAnnotations(); if (annotations != null) { String persona = annotations.get(AI_PERSONA_ANNOTATION); @@ -155,6 +158,37 @@ public class CommentReconciler implements Reconciler { return persona; } } + // 2. 分类注解 + var spec = post.getSpec(); + if (spec != null && spec.getCategories() != null) { + for (String categoryName : spec.getCategories()) { + var cat = client.fetch(Category.class, categoryName).orElse(null); + if (cat != null) { + var catAnnotations = cat.getMetadata().getAnnotations(); + if (catAnnotations != null) { + String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION); + if (catPersona != null && !catPersona.isBlank()) { + return catPersona; + } + } + } + } + } + // 3. 标签注解 + if (spec != null && spec.getTags() != null) { + for (String tagName : spec.getTags()) { + var tag = client.fetch(Tag.class, tagName).orElse(null); + if (tag != null) { + var tagAnnotations = tag.getMetadata().getAnnotations(); + if (tagAnnotations != null) { + String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION); + if (tagPersona != null && !tagPersona.isBlank()) { + return tagPersona; + } + } + } + } + } return null; }) .orElse(null); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java index 2f49d3a..793eb06 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java @@ -4,9 +4,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.scheduler.Schedulers; +import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; @@ -159,6 +161,7 @@ public class ReplyReconciler implements Reconciler { String postName = subjectRef.getName(); return client.fetch(Post.class, postName) .map(post -> { + // 1. 文章注解优先 var annotations = post.getMetadata().getAnnotations(); if (annotations != null) { String persona = annotations.get(AI_PERSONA_ANNOTATION); @@ -166,6 +169,37 @@ public class ReplyReconciler implements Reconciler { return persona; } } + // 2. 分类注解 + var spec = post.getSpec(); + if (spec != null && spec.getCategories() != null) { + for (String categoryName : spec.getCategories()) { + var cat = client.fetch(Category.class, categoryName).orElse(null); + if (cat != null) { + var catAnnotations = cat.getMetadata().getAnnotations(); + if (catAnnotations != null) { + String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION); + if (catPersona != null && !catPersona.isBlank()) { + return catPersona; + } + } + } + } + } + // 3. 标签注解 + if (spec != null && spec.getTags() != null) { + for (String tagName : spec.getTags()) { + var tag = client.fetch(Tag.class, tagName).orElse(null); + if (tag != null) { + var tagAnnotations = tag.getMetadata().getAnnotations(); + if (tagAnnotations != null) { + String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION); + if (tagPersona != null && !tagPersona.isBlank()) { + return tagPersona; + } + } + } + } + } return null; }) .orElse(null); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index b8a1b9c..7d6e3fe 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -36,7 +36,10 @@ public class AiReplyOrchestrator { // In-memory dedup: tracks which comment/reply is currently being processed // Prevents duplicate replies when Reconciler fires multiple times - private final ConcurrentHashMap processingLocks = new ConcurrentHashMap<>(); + // Value is the timestamp when the lock was acquired, used for leak detection + private final ConcurrentHashMap processingLocks = new ConcurrentHashMap<>(); + + private static final long LOCK_EXPIRY_MS = 2 * 60 * 1000L; // 2 minutes public AiReplyOrchestrator(ContextExtractor contextExtractor, PromptBuilder promptBuilder, @@ -71,8 +74,11 @@ public class AiReplyOrchestrator { String personaName) { String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top"; + // Clean up stale locks before acquiring new one + cleanupStaleLocks(); + // In-memory dedup: if already processing, skip immediately - if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) { + if (processingLocks.putIfAbsent(lockKey, System.currentTimeMillis()) != null) { log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey); return Mono.empty(); } @@ -220,7 +226,7 @@ public class AiReplyOrchestrator { log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing", context.commentId()); // Save the failed reply content, then retry - return updateRecord(replyRecord, aiReply, 0, "FAIL", false) + return updateRecord(replyRecord, aiReply, 0, "FAIL", false, null) .then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed")); } return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName); @@ -263,7 +269,7 @@ public class AiReplyOrchestrator { } else { log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}", maxRetry, context.commentId(), reason); - return updateRecord(replyRecord, "", 0, "FAIL", false).then(); + return updateRecord(replyRecord, "", 0, "FAIL", false, null).then(); } }); } @@ -305,19 +311,36 @@ public class AiReplyOrchestrator { } /** - * Publish the reply and update the record to PASS + published=true. + * Publish the reply and update the record. + * When autoPublish=true: create Reply extension and save replyName. + * When autoPublish=false (draft mode): do NOT create Reply extension, only save AI reply content. */ private Mono publishReply(ContextExtractor.CommentContext context, String aiReply, AiCommentReply replyRecord, int score, String personaName) { return isAutoPublishEnabled() .flatMap(autoPublish -> { - return commentReplyPublisher.publishReply( - context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName) - .flatMap(publishedReply -> { - log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId()); - return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish); - }) - .flatMap(updated -> Mono.empty()); + if (autoPublish) { + // Auto publish: create Reply extension and save replyName + return commentReplyPublisher.publishReply( + context.commentId(), aiReply, context.postId(), context.replyTo(), true, personaName) + .flatMap(publishedReply -> { + String replyName = publishedReply.getMetadata().getName(); + log.info("[Orchestrator] Reply published for: {}, replyName={}", context.commentId(), replyName); + return updateRecord(replyRecord, aiReply, score, "PASS", true, replyName); + }) + .switchIfEmpty(Mono.defer(() -> { + // publishReply returned empty (dedup: AI reply already exists) + // Fallback to draft mode to avoid leaving record in PENDING state + log.warn("[Orchestrator] publishReply returned empty for {}, falling back to draft mode", context.commentId()); + return updateRecord(replyRecord, aiReply, score, "PASS", false, null); + })) + .flatMap(updated -> Mono.empty()); + } else { + // Draft mode: do NOT create Reply extension, only save AI reply content + log.info("[Orchestrator] Draft mode: saving reply content without creating Reply extension for: {}", context.commentId()); + return updateRecord(replyRecord, aiReply, score, "PASS", false, null) + .flatMap(updated -> Mono.empty()); + } }) .then(); } @@ -515,15 +538,17 @@ public class AiReplyOrchestrator { } private Mono updateRecord(AiCommentReply record, String reply, - int score, String status, boolean published) { - log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}", - record.getMetadata().getName(), status, score, published); + int score, String status, boolean published, + String replyName) { + log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}, replyName={}", + record.getMetadata().getName(), status, score, published, replyName); return client.fetch(AiCommentReply.class, record.getMetadata().getName()) .flatMap(latest -> { latest.getSpec().setReply(reply); latest.getSpec().setScore(score); latest.getSpec().setStatus(status); latest.getSpec().setPublished(published); + latest.getSpec().setReplyName(replyName); return client.update(latest); }) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) @@ -531,7 +556,24 @@ public class AiReplyOrchestrator { .doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock", record.getMetadata().getName())) ) - .doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}", - record.getMetadata().getName(), status, score, published)); + .doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}, replyName={}", + record.getMetadata().getName(), status, score, published, replyName)); + } + + /** + * Clean up stale locks that have been held longer than LOCK_EXPIRY_MS. + * This prevents memory leaks in case of unexpected errors or cancellations + * that bypass the doFinally cleanup. + */ + private void cleanupStaleLocks() { + long now = System.currentTimeMillis(); + processingLocks.entrySet().removeIf(entry -> { + long age = now - entry.getValue(); + if (age > LOCK_EXPIRY_MS) { + log.warn("[Orchestrator] Removing stale lock: {} (held for {}ms)", entry.getKey(), age); + return true; + } + return false; + }); } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java index b54ede4..f49b742 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java @@ -118,6 +118,7 @@ public class CommentReplyPublisher { Map ownerAnnotations = new HashMap<>(); ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); + // 使用Gravatar邮箱头像 if (email != null && !email.isBlank()) { String gravatarUrl = generateGravatarUrl(email); ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); @@ -125,9 +126,16 @@ public class CommentReplyPublisher { owner.setAnnotations(ownerAnnotations); spec.setOwner(owner); + log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}", + parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations); + return client.create(reply) - .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}", - displayName, parentCommentName, quoteReplyName)) + .doOnSuccess(created -> { + var createdOwner = created.getSpec().getOwner(); + log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}", + displayName, parentCommentName, quoteReplyName, + createdOwner != null ? createdOwner.getAnnotations() : "null"); + }) .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); }); } @@ -142,10 +150,14 @@ public class CommentReplyPublisher { private Mono resolvePersona(String personaName) { if (personaName != null && !personaName.isBlank()) { return client.fetch(AiPersona.class, personaName) - .map(p -> new ResolvedPersona( - p.getSpec().getDisplayName(), - p.getSpec().getEmail() - )) + .map(p -> { + log.info("[Publisher] Resolved persona by name: {}, email={}", + personaName, p.getSpec().getEmail()); + return new ResolvedPersona( + p.getSpec().getDisplayName(), + p.getSpec().getEmail() + ); + }) .defaultIfEmpty(new ResolvedPersona("小回", "")); } // Find default persona @@ -153,10 +165,14 @@ public class CommentReplyPublisher { persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()), null) .next() - .map(p -> new ResolvedPersona( - p.getSpec().getDisplayName(), - p.getSpec().getEmail() - )) + .map(p -> { + log.info("[Publisher] Resolved default persona: {}, email={}", + p.getMetadata().getName(), p.getSpec().getEmail()); + return new ResolvedPersona( + p.getSpec().getDisplayName(), + p.getSpec().getEmail() + ); + }) .defaultIfEmpty(new ResolvedPersona("小回", "")); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java index af57d39..3a345a4 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java @@ -135,7 +135,7 @@ public class FilterService { private String getCommenterEmail(Comment comment) { if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return ""; var owner = comment.getSpec().getOwner(); - if ("EMAIL".equals(owner.getKind())) { + if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { var name = owner.getName(); return name != null ? name : ""; } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index 1755cc3..5adbfc7 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -39,6 +39,18 @@ public class PromptBuilder { private static final String PRESET_CONCISE = """ 【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。 +"""; + + private static final String PRESET_TECHNICAL = """ +【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。 +"""; + + private static final String PRESET_ENCOURAGING = """ +【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。 +"""; + + private static final String PRESET_EDUCATIONAL = """ +【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。 """; private static final Map PRESET_MAP = new LinkedHashMap<>(); @@ -47,6 +59,9 @@ public class PromptBuilder { PRESET_MAP.put("professional", PRESET_PROFESSIONAL); PRESET_MAP.put("humorous", PRESET_HUMOROUS); PRESET_MAP.put("concise", PRESET_CONCISE); + PRESET_MAP.put("technical", PRESET_TECHNICAL); + PRESET_MAP.put("encouraging", PRESET_ENCOURAGING); + PRESET_MAP.put("educational", PRESET_EDUCATIONAL); } private static final String SAFETY_PROMPT = """ @@ -61,7 +76,12 @@ public class PromptBuilder { {{safety_prompt}} - 【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。 + 【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征: +- 如果评论包含中文字符(汉字),请用中文回复 +- 如果评论包含日文假名(平假名/片假名),请用日文回复 +- 如果评论包含韩文字符,请用韩文回复 +- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复 +- 绝对不要用与评论不同的语言回复 请回复以下评论。注意: - 回复长度应与评论长度匹配,简短问候简短回复 diff --git a/src/main/resources/extensions/annotation-setting.yaml b/src/main/resources/extensions/annotation-setting.yaml index b63bad0..2ad45a7 100644 --- a/src/main/resources/extensions/annotation-setting.yaml +++ b/src/main/resources/extensions/annotation-setting.yaml @@ -37,3 +37,33 @@ spec: label: AI角色 help: 选择该页面使用的AI回复角色名称,留空使用默认角色 value: "" +--- +apiVersion: v1alpha1 +kind: AnnotationSetting +metadata: + name: comment-ai-autopilot-category-annotation-setting +spec: + targetRef: + group: content.halo.run + kind: Category + formSchema: + - $formkit: text + name: comment-ai-autopilot.nxxy335.top/ai-persona + label: AI角色 + help: 选择该分类下文章使用的AI回复角色名称,留空使用默认角色 + value: "" +--- +apiVersion: v1alpha1 +kind: AnnotationSetting +metadata: + name: comment-ai-autopilot-tag-annotation-setting +spec: + targetRef: + group: content.halo.run + kind: Tag + formSchema: + - $formkit: text + name: comment-ai-autopilot.nxxy335.top/ai-persona + label: AI角色 + help: 选择该标签下文章使用的AI回复角色名称,留空使用默认角色 + value: "" diff --git a/src/main/resources/extensions/roleTemplate.yaml b/src/main/resources/extensions/roleTemplate.yaml index 79a8137..c6832a3 100644 --- a/src/main/resources/extensions/roleTemplate.yaml +++ b/src/main/resources/extensions/roleTemplate.yaml @@ -19,3 +19,9 @@ rules: - apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"] resources: ["*"] verbs: ["*"] + - apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"] + resources: ["export"] + verbs: ["get"] + - apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"] + resources: ["import"] + verbs: ["create"] diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 3982c0c..7074152 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -22,6 +22,6 @@ spec: url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" settingName: "comment-ai-autopilot-settings" configMapName: "comment-ai-autopilot-configmap" - version: "0.0.1-t5w8r3" + version: "0.0.0-ygkszvd" pluginDependencies: ai-foundation: "*" diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue index 1a746e7..b4b3399 100644 --- a/ui/src/views/HomeView.vue +++ b/ui/src/views/HomeView.vue @@ -177,53 +177,6 @@ - - -
-

近7日回复趋势

-
- - - -
-
-
-
-
{{ day.count }}
-
-
{{ formatTrendDate(day.date) }}
-
-
-
- 暂无数据 -
-
@@ -299,16 +252,11 @@