feat: v1.0.0-beta.1 - AI Foundation reflection integration and draft mode fix

This commit is contained in:
sunny-335
2026-06-16 23:20:51 +08:00
parent 72726da9b8
commit d737cbc8f1
26 changed files with 410 additions and 601 deletions
+6 -7
View File
@@ -4,11 +4,11 @@
## 功能特性
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,支持排序,可为不同文章指定不同角色
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等)
- **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容,支持批量操作
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
- **失败重试** — AI 生成失败时自动重试,指数退避策略
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
@@ -17,11 +17,10 @@
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合
- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页
- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份
- **日志筛选** — 按状态、情感筛选,关键词搜索
- **数据清理** — 自动清理超过指定天数的旧记录
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
+4 -4
View File
@@ -5,14 +5,14 @@ plugins {
}
group 'top.nxxy335.commentaiautopilot'
version '0.0.0-ygkszvd'
version '1.0.0-beta.1'
repositories {
mavenCentral()
}
dependencies {
implementation platform('run.halo.tools.platform:plugin:2.23.0')
implementation platform('run.halo.tools.platform:plugin:2.25.0')
compileOnly 'run.halo.app:api'
compileOnly files('libs/ai-foundation-api.jar')
implementation 'org.jsoup:jsoup:1.18.1'
@@ -39,7 +39,7 @@ tasks.withType(JavaCompile).configureEach {
tasks.register('processUiResources', Copy) {
from project(':ui').layout.buildDirectory.dir('dist')
into layout.buildDirectory.dir('resources/main/console')
into layout.buildDirectory.dir('resources/main/ui')
dependsOn project(':ui').tasks.named('assemble')
shouldRunAfter tasks.named('processResources')
}
@@ -53,6 +53,6 @@ tasks.named('generatePluginComponentsIdx') {
}
halo {
version = '2.23'
version = '2.25'
}
-2
View File
@@ -31,8 +31,6 @@
|--------|------|--------|
| 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
| 速率限制 | 每分钟最大AI回复数量 | 10 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
## 重试机制
+2 -33
View File
@@ -9,32 +9,17 @@
## 审核流程
1. 新评论到达后,AI生成回复内容
2. 回复以草稿状态保存(状态为"通过",但未发布
2. 回复以草稿状态保存(`approved = false`
3.**AI回复日志** 页面,草稿状态的记录会显示审核按钮
4. 管理员可以:
- **编辑** — 修改AI回复内容后再审核
- **审核通过** — 回复立即发布,访客可见
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
## 编辑回复内容
草稿模式下,管理员可以在发布前编辑AI生成的回复内容:
1. 在日志页面找到状态为"通过"且未发布的记录
2. 点击 **编辑** 按钮
3. 在弹出的编辑对话框中修改回复内容
4. 点击 **保存** 完成修改
5. 修改后可继续审核通过或拒绝
::: tip
编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。
:::
## 日志页面操作
在AI回复日志页面:
- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮
- 草稿记录显示 **审核通过****拒绝** 按钮
- 已发布的记录显示正常状态
- 被拒绝的记录显示 REJECTED 标签
@@ -52,19 +37,3 @@
::: warning
批量操作不可撤销,请谨慎操作。
:::
## 草稿模式工作原理
草稿模式下,AI回复的处理流程与自动发布模式不同:
| 步骤 | 自动发布模式 | 草稿模式 |
|------|------------|---------|
| AI生成回复 | ✅ | ✅ |
| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 |
| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 |
| 审核通过时 | — | 创建 Reply 扩展并发布 |
| 拒绝时 | — | 标记为 REJECTED |
::: info
草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。
:::
+4 -43
View File
@@ -10,14 +10,10 @@
## AI回复头像不显示?
1. 确认在AI角色设置中填写了邮箱
1. 确认在插件设置中填写了AI角色邮箱
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
::: info
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。
:::
## 评论没有触发AI回复?
可能的原因:
@@ -27,8 +23,6 @@
3. **已有AI回复记录** — 同一评论不会重复触发
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
5. **AI生成失败** — 检查AI模型配置和日志
6. **速率限制** — 检查是否触发了每分钟回复数量限制
7. **对话轮次上限** — 检查是否达到了最大对话轮次
## 如何对历史评论触发AI回复?
@@ -38,24 +32,12 @@
## 草稿模式的回复在哪里审核?
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。
## 如何在发布前编辑AI回复?
1. 确保草稿模式已启用(关闭"自动发布"开关)
2. 在AI回复日志页面找到状态为"通过"且未发布的记录
3. 点击 **编辑** 按钮修改回复内容
4. 修改完成后点击"审核通过"发布
::: tip
编辑功能仅在草稿状态(通过但未发布)下可用。
:::
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。
## 如何修改AI回复的语气风格?
1. 在插件设置中修改 **AI角色人格提示词**
2. 或修改 **自定义Prompt模板**
3. 或选择不同的 **Prompt预设** 风格组合
## AI Foundation 插件是必须的吗?
@@ -81,27 +63,6 @@
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
## 黑名单支持正则表达式吗?
## 黑名单支持邮箱吗?
支持。黑名单支持三种匹配方式:
- **名称匹配**:直接输入评论者显示名称
- **邮箱匹配**:输入邮箱地址
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
## 如何管理多个AI角色?
在插件设置的 **AI角色设置** 区域,可以添加、编辑、删除AI角色,也可以使用 ▲▼ 按钮调整角色顺序。每个角色有独立的昵称、人格提示词和Gravatar头像。
## 如何导入/导出配置?
在插件设置页面右上角:
- 点击 **导出配置** 将当前设置和AI角色导出为 JSON 文件
- 点击 **导入配置** 从 JSON 文件导入配置(导入前会显示确认对话框)
## 日志页面如何按时间筛选?
在日志页面的筛选栏中,使用日期选择器设置起始日期和结束日期,即可筛选指定时间范围内的记录。
## 为什么AI角色不支持自定义上传头像?
Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,会忽略自定义头像 URL。这是 Halo 评论系统的设计限制,因此插件只支持通过 Gravatar 邮箱方式设置AI角色头像。
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
+3 -15
View File
@@ -32,21 +32,9 @@
1. 进入插件设置页面
2.**基本设置** 中找到 **评论者黑名单**
3. 输入评论者的显示名称邮箱或正则表达式,多个用逗号分隔
3. 输入评论者的显示名称邮箱,多个用逗号分隔
4. 保存设置
### 匹配方式
| 方式 | 格式 | 示例 |
|------|------|------|
| 名称匹配 | 直接输入名称 | `张三` |
| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` |
| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` |
::: tip
正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。
:::
### 从评论列表选择
1. 在黑名单输入框旁点击 **添加评论者** 按钮
@@ -57,7 +45,7 @@
### 示例
```
张三,spam@example.com,李四,regex:^bot.*
张三,spam@example.com,李四
```
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式也会参与匹配。匹配成功则跳过AI回复。
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。
+6 -7
View File
@@ -5,19 +5,18 @@ 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模型能力
+5 -31
View File
@@ -1,28 +1,6 @@
# AI角色
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。
## 多角色管理
插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。
### 默认角色
系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。
### 角色排序
多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。
### 角色操作
| 操作 | 说明 |
|------|------|
| 添加角色 | 点击"添加角色"按钮创建新角色 |
| 编辑角色 | 点击编辑图标修改角色信息 |
| 删除角色 | 点击删除图标移除角色(默认角色不可删除) |
| 设为默认 | 点击星标图标将角色设为默认角色 |
| 排序 | 使用 ▲▼ 按钮调整角色顺序 |
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
## 角色配置
@@ -50,11 +28,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
### 邮箱与头像
填写邮箱后,AI回复者的头像将通过 CravatarGravatar 镜像)服务自动生成:
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
1. 插件根据邮箱生成 SHA-256 哈希
2. 构造头像 URL`https://cn.cravatar.com/avatar/{hash}`
3. Halo 评论系统通过 `email-hash` 注解自动匹配头像
2. 构造 Gravatar URL`https://cn.cravatar.com/avatar/{hash}`
3. 头像URL存储在评论的 `owner.annotations["avatar"]`
::: warning
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
@@ -62,7 +40,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
### 头像预览
角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。
设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
::: tip
如果预览头像不正确,请检查:
@@ -70,7 +48,3 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
3. 头像更新可能有缓存延迟
:::
::: info
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。
:::
+1 -38
View File
@@ -9,13 +9,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
{{safety_prompt}}
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
- 如果评论包含中文字符(汉字),请用中文回复
- 如果评论包含日文假名(平假名/片假名),请用日文回复
- 如果评论包含韩文字符,请用韩文回复
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
- 绝对不要用与评论不同的语言回复
请回复以下评论。注意:
- 回复长度应与评论长度匹配,简短问候简短回复
- 不要复述或总结文章内容
@@ -32,20 +25,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
{{comment}}
```
## 多语言回复
默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复:
- 中文评论 → 中文回复
- 英文评论 → 英文回复
- 日文评论 → 日文回复
- 韩文评论 → 韩文回复
- 其他语言 → 根据语言特征用相同语言回复
::: tip
多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。
:::
## 模板变量
| 变量 | 说明 | 注入时机 |
@@ -60,26 +39,12 @@ Prompt模板控制AI生成回复时的完整提示词结构。
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
## 预设风格
预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合:
| 预设 | 说明 | 效果 |
|------|------|------|
| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 |
| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 |
| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 |
| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 |
| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 |
| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 |
| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 |
## 情感提示
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。"
- **中性** → 不注入额外提示
## 安全提示
@@ -90,7 +55,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
- 不泄露个人隐私信息
- 不生成虚假信息
- 回复内容与评论相关
- 遇到恶意诱导时礼貌拒绝
## 自定义建议
@@ -102,7 +66,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
4. 在变量之间添加清晰的分隔和指令
5. 避免让AI复述文章内容
6. 控制回复长度和风格
7. 保留多语言回复指令以确保国际化支持
## 变量使用示例
+12 -58
View File
@@ -7,44 +7,24 @@
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
| 速率限制 | 每分钟最大AI回复数量 | 10 |
| 自动发布 | AI回复是否自动发布 | 开启 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
| 评论者黑名单 | 不触发AI回复的评论者,支持名称邮箱和正则表达式 | 空 |
::: tip 评论者黑名单
黑名单支持三种匹配方式:
- **名称匹配**:直接输入评论者显示名称,如 `张三`
- **邮箱匹配**:输入邮箱地址,如 `spam@example.com`
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。
:::
| 评论者黑名单 | 不触发AI回复的评论者显示名称邮箱,逗号分隔 | 空 |
## AI角色设置
AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| AI角色昵称 | AI回复者的显示名称 | 小回 |
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 |
### 角色管理
默认人格提示词:
- **添加角色** — 点击"添加角色"按钮创建新的AI角色
- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息
- **删除角色** — 点击删除图标移除角色(默认角色不可删除)
- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景)
- **角色排序** — 使用 ▲▼ 按钮调整角色顺序
### 角色配置项
| 配置项 | 说明 |
|--------|------|
| 昵称 | AI回复者的显示名称 |
| 邮箱 | 用于Gravatar头像服务展示头像 |
| 人格提示词 | 定义AI角色的人格和回复风格 |
| 设为默认角色 | 是否作为默认角色使用 |
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
::: tip Gravatar头像
填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。
:::
## 模型设置
@@ -61,21 +41,8 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| Prompt预设 | 选择预设风格,可多选 | 无 |
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
### 预设风格
| 预设 | 说明 |
|------|------|
| 友好型 | 热情友好,像朋友聊天 |
| 专业型 | 严谨正式,有逻辑性 |
| 幽默型 | 轻松诙谐,适当幽默 |
| 简洁型 | 一两句话,简洁明了 |
| 技术解答型 | 深入浅出,专业解答技术问题 |
| 鼓励型 | 积极正面,给予鼓励和支持 |
| 知识科普型 | 通俗易懂,普及相关知识 |
### 模板变量
| 变量 | 说明 |
@@ -85,9 +52,7 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
| `{{article}}` | 文章内容 |
| `{{comment}}` | 评论内容 |
| `{{post_title}}` | 文章标题 |
| `{{post_date}}` | 文章发布日期 |
| `{{comment_count}}` | 评论数 |
| `{{conversation}}` | 对话上下文(多轮对话时) |
## 数据清理
@@ -97,16 +62,5 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
::: tip
你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。
:::
## 配置导入/导出
设置页面右上角提供配置导入/导出功能:
- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件
- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览
::: warning
导入配置将覆盖当前配置,此操作不可撤销。
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
:::
+1 -1
View File
@@ -25,5 +25,5 @@ features:
- title: 灵活过滤
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
- title: 数据管理
details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
---
Binary file not shown.
@@ -1,7 +1,6 @@
package top.nxxy335.commentaiautopilot.endpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -54,17 +53,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final AiReplyOrchestrator orchestrator;
private final AiReplyCleanupService cleanupService;
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
private final AiFoundationClient aiFoundationClient;
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<AiFoundationClient> aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) {
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) {
this.client = client;
this.orchestrator = orchestrator;
this.cleanupService = cleanupService;
this.aiFoundationClientProvider = aiFoundationClientProvider;
this.aiFoundationClient = aiFoundationClient;
this.commentReplyPublisher = commentReplyPublisher;
this.objectMapper = new ObjectMapper();
}
@@ -134,8 +133,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
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;
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
if (needsMemoryFilter) {
// Fall back to listAll + in-memory filter for complex queries
@@ -184,30 +184,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.flatMap(result -> ServerResponse.ok().bodyValue(result));
}
// Simple filters only - use server-side pagination
// No filters - use server-side pagination directly
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;
})
.toList();
Map<String, Object> result = new HashMap<>();
result.put("items", filtered);
result.put("items", listResult.getItems());
result.put("total", listResult.getTotal());
result.put("page", page);
result.put("size", size);
@@ -438,31 +426,50 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.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.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);
})
// Draft mode: no Reply extension exists yet.
// First check if a Reply already exists (e.g. from a previous autoPublish=true run)
return findReplyForRecord(record)
.flatMap(existingReply -> {
// Reply already exists, just approve it
existingReply.getSpec().setApproved(true);
existingReply.getSpec().setApprovedTime(Instant.now());
return client.update(existingReply)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException));
.filter(e -> e instanceof OptimisticLockingFailureException))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
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")));
})
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
.switchIfEmpty(Mono.defer(() -> {
// No existing Reply found, create a new approved one
return commentReplyPublisher.publishReply(
record.getSpec().getCommentId(),
record.getSpec().getReply(),
record.getSpec().getPostId(),
record.getSpec().getReplyTo(),
true,
record.getSpec().getPersonaName()
)
.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)
@@ -536,27 +543,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.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);
})
// Draft mode: check if Reply already exists first
return findReplyForRecord(record)
.flatMap(existingReply -> {
existingReply.getSpec().setApproved(true);
existingReply.getSpec().setApprovedTime(Instant.now());
return client.update(existingReply)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException));
});
.filter(e -> e instanceof OptimisticLockingFailureException))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
latest.getSpec().setPublished(true);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
));
})
.switchIfEmpty(Mono.defer(() ->
commentReplyPublisher.publishReply(
record.getSpec().getCommentId(),
record.getSpec().getReply(),
record.getSpec().getPostId(),
record.getSpec().getReplyTo(),
true,
record.getSpec().getPersonaName()
)
.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)
@@ -860,21 +885,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
private Mono<ServerResponse> health(ServerRequest request) {
AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable();
boolean aiFoundationInstalled = aiClient != null;
if (!aiFoundationInstalled) {
return ServerResponse.ok().bodyValue(
new HealthResponse(false, false, false, "", "unhealthy"));
}
// AI Foundation is installed, check if it's enabled and model is available
return aiClient.chat("ping", null)
.map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy"))
.onErrorResume(e -> {
log.debug("Health check: AI Foundation call failed: {}", e.getMessage());
return Mono.just(new HealthResponse(true, true, false, "", "degraded"));
})
// AiFoundationClient is always created; availability is checked at runtime
return aiFoundationClient.isAvailable()
.map(available -> (HealthResponse) new HealthResponse(available, available, available, "", available ? "healthy" : "degraded"))
.defaultIfEmpty(new HealthResponse(false, false, false, "", "unhealthy"))
.flatMap(health -> ServerResponse.ok().bodyValue(health));
}
@@ -60,7 +60,7 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "使用的AI角色名称")
private String personaName;
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
@Schema(description = "已发布的回复名称")
private String replyName;
}
}
@@ -38,7 +38,7 @@ public class AiPersona extends AbstractExtension {
@JsonProperty("isDefault")
private Boolean isDefault;
@Schema(description = "排序优先级,数值越小越靠前")
@Schema(description = "角色优先级,数值越小优先级越高")
private Integer priority;
}
}
@@ -72,6 +72,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
String parentCommentName = reply.getSpec().getCommentName();
if (parentCommentName == null || parentCommentName.isBlank()) {
markProcessed(reply);
client.update(reply);
return;
}
@@ -83,6 +85,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
// No quoteReply - this is a direct reply to the top-level comment,
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
markProcessed(reply);
client.update(reply);
return;
}
@@ -92,6 +96,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
if (!isReplyToAi) {
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
markProcessed(reply);
client.update(reply);
return;
}
@@ -1,32 +1,43 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.aifoundation.AiModelService;
import run.halo.aifoundation.chat.LanguageModel;
import run.halo.aifoundation.chat.GenerateTextResult;
import java.lang.reflect.Method;
import java.util.Map;
/**
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
* AI Foundation client that uses runtime class loading and reflection
* to call the AI Foundation plugin's AiModelService.
* <p>
* This approach avoids classloader identity issues by loading AiModelService
* from ai-foundation's own classloader, so that Spring's getBeansOfType()
* can correctly match the implementation bean.
* <p>
* No @ConditionalOnClass or pluginDependencies needed.
* Always registered as a bean; availability is checked at runtime.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class AiFoundationClient {
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
private final ExtensionGetter extensionGetter;
private final ReactiveExtensionClient client;
private final ApplicationContext applicationContext;
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
this.client = client;
this.applicationContext = applicationContext;
}
/**
* Call AI Foundation to generate a chat response using the specified model.
* Checks at runtime whether the ai-foundation plugin is installed and enabled
* before attempting to use it.
*
* @param prompt the prompt text
* @param modelName the AiModel metadata.name, null or blank to use default model
@@ -44,8 +55,16 @@ public class AiFoundationClient {
}
/**
* Check if the ai-foundation plugin is installed and enabled at runtime.
* Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found.
*/
public Mono<Boolean> isAvailable() {
return isAiFoundationEnabled()
.flatMap(enabled -> {
if (!enabled) return Mono.just(false);
return findAiModelService().hasElement();
});
}
private Mono<Boolean> isAiFoundationEnabled() {
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
.map(plugin -> plugin.getSpec().getEnabled())
@@ -57,24 +76,144 @@ public class AiFoundationClient {
}
private Mono<String> doChat(String prompt, String modelName) {
return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> {
Mono<LanguageModel> modelMono;
if (modelName != null && !modelName.isBlank()) {
modelMono = service.languageModel(modelName);
} else {
modelMono = service.languageModel();
}
return modelMono.flatMap(model -> model.generateText(prompt)
.map(GenerateTextResult::getText)
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
text.length(), modelName != null ? modelName : "default"))
);
})
return findAiModelService()
.flatMap(service -> invokeLanguageModel(service, modelName)
.flatMap(model -> invokeGenerateText(model, prompt))
)
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* Get PluginManager via the pluginWrapper bean registered in our plugin context.
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton:
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance.
*/
private Object findPluginManager() {
try {
Object pluginWrapper = applicationContext.getBean("pluginWrapper");
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager");
getPluginManagerMethod.setAccessible(true);
Object pm = getPluginManagerMethod.invoke(pluginWrapper);
if (pm != null) {
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName());
}
return pm;
} catch (NoSuchMethodException e) {
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage());
} catch (Exception e) {
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage());
}
log.warn("PluginManager not found");
return null;
}
/**
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
* to get the plugin's ApplicationContext.
*/
private Mono<Object> findAiModelService() {
return Mono.fromCallable(() -> {
Object pm = findPluginManager();
if (pm == null) return null;
// Call pm.getPlugin("ai-foundation") via reflection
Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class);
getPluginMethod.setAccessible(true);
Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME);
if (pluginWrapper == null) {
log.debug("ai-foundation plugin not found in PluginManager");
return null;
}
// Call pluginWrapper.getPlugin() to get the plugin instance
Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin");
getPluginInstanceMethod.setAccessible(true);
Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper);
if (pluginInstance == null) {
log.debug("ai-foundation plugin instance is null");
return null;
}
// Get the plugin's ApplicationContext via reflection on SpringPlugin
// DefaultSpringPlugin is package-private, so we need setAccessible
Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext");
getCtxMethod.setAccessible(true);
ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance);
// Get the plugin classloader
Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader");
getClassLoaderMethod.setAccessible(true);
ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper);
// Load AiModelService from ai-foundation's classloader
Class<?> aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS);
// Find the AiModelService bean in ai-foundation's ApplicationContext
Map<String, ?> beans = pluginAppContext.getBeansOfType(aiModelServiceClass);
if (beans.isEmpty()) {
log.debug("AiModelService bean not found in ai-foundation's ApplicationContext");
return null;
}
log.info("Found AiModelService bean in ai-foundation's ApplicationContext");
Object result = beans.values().iterator().next();
return (Object) result;
}).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage()));
}
/**
* Call service.languageModel(modelName) or service.languageModel() via reflection.
* Returns Mono&lt;LanguageModel&gt; from ai-foundation's classloader.
*/
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
return Mono.fromCallable(() -> {
Method method;
if (modelName != null && !modelName.isBlank()) {
method = service.getClass().getMethod("languageModel", String.class);
method.setAccessible(true);
return method.invoke(service, modelName);
} else {
method = service.getClass().getMethod("languageModel");
method.setAccessible(true);
return method.invoke(service);
}
}).flatMap(result -> {
if (result instanceof Mono<?> mono) return mono;
return Mono.justOrEmpty(result);
});
}
/**
* Call model.generateText(prompt) via reflection, then extract text from result.
* Returns the generated text string.
*/
private Mono<String> invokeGenerateText(Object model, String prompt) {
return Mono.fromCallable(() -> {
Method method = model.getClass().getMethod("generateText", String.class);
method.setAccessible(true);
return method.invoke(model, prompt);
}).flatMap(result -> {
if (result instanceof Mono<?> mono) {
return mono.map(this::extractText);
}
return Mono.justOrEmpty(extractText(result));
});
}
private String extractText(Object result) {
if (result == null) return null;
try {
Method getText = result.getClass().getMethod("getText");
getText.setAccessible(true);
return (String) getText.invoke(result);
} catch (Exception e) {
throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e);
}
}
}
@@ -1,23 +0,0 @@
package top.nxxy335.commentaiautopilot.service;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
/**
* Configuration that registers AiFoundationClient only when
* AI Foundation plugin classes are available in the classloader.
* When AI Foundation is not installed, this entire configuration is skipped.
*/
@Configuration
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
public class AiFoundationConfiguration {
@Bean
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
ReactiveExtensionClient client) {
return new AiFoundationClient(extensionGetter, client);
}
}
@@ -1,7 +1,6 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
@Slf4j
public class AiReplyService {
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
private final AiFoundationClient aiFoundationClient;
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
public AiReplyService(AiFoundationClient aiFoundationClient) {
this.aiFoundationClient = aiFoundationClient;
}
/**
@@ -22,12 +21,7 @@ public class AiReplyService {
* @param modelName the model name (null for default)
*/
public Mono<String> generateReply(String prompt, String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("AI Foundation plugin is not installed, cannot generate reply");
return Mono.empty();
}
return client.chat(prompt, modelName)
return aiFoundationClient.chat(prompt, modelName)
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
@@ -39,18 +39,6 @@ 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<String, String> PRESET_MAP = new LinkedHashMap<>();
@@ -59,9 +47,6 @@ 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 = """
@@ -76,12 +61,7 @@ public class PromptBuilder {
{{safety_prompt}}
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
- 如果评论包含中文字符(汉字),请用中文回复
- 如果评论包含日文假名(平假名/片假名),请用日文回复
- 如果评论包含韩文字符,请用韩文回复
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
- 绝对不要用与评论不同的语言回复
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
请回复以下评论。注意:
- 回复长度应与评论长度匹配,简短问候简短回复
@@ -1,7 +1,6 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
@Slf4j
public class ReviewService {
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
private final AiFoundationClient aiFoundationClient;
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
public ReviewService(AiFoundationClient aiFoundationClient) {
this.aiFoundationClient = aiFoundationClient;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
@@ -37,18 +36,12 @@ public class ReviewService {
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
}
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
truncate(articleContent, 2000),
truncate(commentContent, 500),
truncate(aiReply, 500));
return client.chat(reviewPrompt, modelName)
return aiFoundationClient.chat(reviewPrompt, modelName)
.map(this::parseSafetyResult)
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
.onErrorResume(e -> {
@@ -1,7 +1,6 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
@Slf4j
public class SentimentService {
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
private final AiFoundationClient aiFoundationClient;
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
public SentimentService(AiFoundationClient aiFoundationClient) {
this.aiFoundationClient = aiFoundationClient;
}
public record SentimentResult(String sentiment, double confidence) {
@@ -22,15 +21,9 @@ public class SentimentService {
}
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
}
String prompt = buildSentimentPrompt(commentContent);
return client.chat(prompt, modelName)
return aiFoundationClient.chat(prompt, modelName)
.map(response -> {
String sentiment = parseSentiment(response);
return new SentimentResult(sentiment, 1.0);
+2 -4
View File
@@ -7,7 +7,7 @@ metadata:
name: comment-ai-autopilot
spec:
enabled: true
requires: ">=2.23.0"
requires: ">=2.25.0"
author:
name: 暖心向阳335
website: https://nxxy335.top
@@ -22,6 +22,4 @@ 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.0-ygkszvd"
pluginDependencies:
ai-foundation: "*"
version: "1.0.0-beta.1"
+73 -2
View File
@@ -177,6 +177,53 @@
</div>
</VCard>
<!-- Daily Trend -->
<VCard :body-class="['!p-5']">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
<button
class="px-2.5 py-1 text-xs transition-colors"
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '7'"
>
7
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '30'"
>
30
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = 'all'"
>
全部
</button>
</div>
</div>
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
<div
v-for="day in stats.dailyTrend"
:key="day.date"
class="flex-1 flex flex-col items-center justify-end h-full"
>
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
<div
class="w-full rounded-t-md transition-all duration-500"
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
></div>
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
</div>
</div>
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
暂无数据
</div>
</VCard>
</div>
<!-- Bottom: Score + Quick Actions -->
@@ -252,11 +299,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
import { ref, computed, onMounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
interface DailyCount {
date: string
count: number
}
interface StatsResponse {
total: number
passCount: number
@@ -264,6 +316,7 @@ interface StatsResponse {
reviewingCount: number
avgScore: number
sentimentDistribution: Record<string, number>
dailyTrend: DailyCount[]
}
interface PersonaResponse {
@@ -278,6 +331,7 @@ interface HealthResponse {
const stats = ref<StatsResponse | null>(null)
const persona = ref<PersonaResponse | null>(null)
const range = ref("7")
const health = ref<HealthResponse | null>(null)
const healthVisible = ref(true)
@@ -303,7 +357,7 @@ const scoreBarColor = computed(() => {
const fetchStats = async () => {
try {
const { data } = await axiosInstance.get(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
)
stats.value = data
} catch (e) {
@@ -375,6 +429,23 @@ const getSentimentPercent = (sentiment: string): number => {
return Math.round(((dist[sentiment] || 0) / total) * 100)
}
const getTrendBarHeight = (count: number): number => {
const trend = stats.value?.dailyTrend
if (!trend || trend.length === 0) return 0
const max = Math.max(...trend.map(d => d.count), 1)
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
}
const formatTrendDate = (dateStr: string): string => {
if (!dateStr) return ''
const parts = dateStr.split('-')
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
}
watch(range, () => {
fetchStats()
})
onMounted(() => {
fetchStats()
fetchPersona()
+2 -70
View File
@@ -65,9 +65,6 @@
placeholder="搜索回复内容..."
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input v-model="filterStartDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
<span class="text-xs text-gray-400"></span>
<input v-model="filterEndDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
<button
class="text-xs text-gray-500 hover:text-gray-700"
@click="resetFilters"
@@ -175,12 +172,6 @@
</div>
<div class="flex items-center gap-2">
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
<button
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors px-2 py-1 rounded hover:bg-blue-50"
@click="openEditDialog(reply)"
>
编辑
</button>
<button
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
@click="handleApprove(reply.metadata.name)"
@@ -308,28 +299,6 @@
</div>
</div>
</teleport>
<!-- Edit Dialog -->
<teleport to="body">
<div v-if="showEditDialog" class="fixed inset-0 z-[9999] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="showEditDialog = false"></div>
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 class="text-base font-semibold text-gray-800">编辑AI回复</h3>
<button class="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100" @click="showEditDialog = false">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="flex-1 px-5 py-4">
<textarea v-model="editContent" rows="8" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 resize-y" placeholder="输入回复内容..."></textarea>
</div>
<div class="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
<button class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg" @click="showEditDialog = false">取消</button>
<button class="px-4 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50" :disabled="editSaving" @click="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
@@ -382,8 +351,6 @@ const selectAll = ref(false)
const filterStatus = ref("")
const filterSentiment = ref("")
const filterKeyword = ref("")
const filterStartDate = ref("")
const filterEndDate = ref("")
const toggleSelect = (name: string) => {
if (selectedNames.value.has(name)) {
@@ -410,12 +377,6 @@ const showDialog = ref(false)
const conversationLoading = ref(false)
const conversationMessages = ref<ConversationMessage[]>([])
// Edit dialog state
const showEditDialog = ref(false)
const editingReply = ref<AiCommentReplyItem | null>(null)
const editContent = ref("")
const editSaving = ref(false)
const fetchReplies = async () => {
loading.value = true
try {
@@ -423,8 +384,6 @@ const fetchReplies = async () => {
if (filterStatus.value) params.status = filterStatus.value
if (filterSentiment.value) params.sentiment = filterSentiment.value
if (filterKeyword.value) params.keyword = filterKeyword.value
if (filterStartDate.value) params.startDate = filterStartDate.value
if (filterEndDate.value) params.endDate = filterEndDate.value
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
{ params },
@@ -434,7 +393,6 @@ const fetchReplies = async () => {
totalPages.value = Math.ceil(total.value / size.value)
} catch (e) {
console.error("Failed to fetch replies", e)
Toast.error("获取回复列表失败")
} finally {
loading.value = false
}
@@ -457,34 +415,10 @@ const openConversation = async (reply: AiCommentReplyItem) => {
}
}
const openEditDialog = (reply: AiCommentReplyItem) => {
editingReply.value = reply
editContent.value = stripHtml(reply.spec.reply || "")
showEditDialog.value = true
}
const saveEdit = async () => {
if (!editingReply.value || !editContent.value.trim()) return
editSaving.value = true
try {
await axiosInstance.put(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${editingReply.value.metadata.name}/content`,
{ reply: editContent.value }
)
Toast.success("保存成功")
showEditDialog.value = false
fetchReplies()
} catch (e) {
Toast.error("保存失败")
} finally {
editSaving.value = false
}
}
const handleDelete = async (name: string) => {
try {
await axiosInstance.delete(
`/apis/comment-ai-autopilot.nxxy335.top/v1alpha1/aicommentreplies/${name}`,
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`,
)
Toast.success("删除成功")
fetchReplies()
@@ -675,13 +609,11 @@ const resetFilters = () => {
filterStatus.value = ""
filterSentiment.value = ""
filterKeyword.value = ""
filterStartDate.value = ""
filterEndDate.value = ""
page.value = 1
fetchReplies()
}
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
watch([filterStatus, filterSentiment, filterKeyword], () => {
page.value = 1
fetchReplies()
})
+11 -104
View File
@@ -122,38 +122,26 @@
<span>请添加至少一个AI角色</span>
</div>
<div v-else class="persona-list">
<div v-for="(persona, index) in personas" :key="persona.metadata.name" class="persona-card">
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
<div class="persona-card__avatar">
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
</div>
<div class="persona-card__info">
<div class="persona-card__name">
{{ persona.spec.displayName || '未命名' }}
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
{{ p.spec.displayName || '未命名' }}
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
</div>
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
</div>
<div class="persona-card__actions">
<button
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
:disabled="index === 0"
@click="movePersonaUp(index)"
title="上移"
></button>
<button
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
:disabled="index === personas.length - 1"
@click="movePersonaDown(index)"
title="下移"
></button>
<button class="btn-icon" title="编辑" @click="openPersonaDialog(persona)">
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button v-if="!persona.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(persona)">
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<button v-if="!persona.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(persona)">
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
</button>
</div>
@@ -444,9 +432,6 @@ const promptPresets = [
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
{ key: 'technical', label: '技术解答型', desc: '深入浅出,专业解答技术问题' },
{ key: 'encouraging', label: '鼓励型', desc: '积极正面,给予鼓励和支持' },
{ key: 'educational', label: '知识科普型', desc: '通俗易懂,普及相关知识' },
]
const enabledPresetKeys = computed({
@@ -642,7 +627,6 @@ const fetchPersonas = async () => {
})
} catch (e) {
console.error("Failed to fetch personas", e)
Toast.error("获取角色列表失败")
personas.value = []
} finally {
personasLoading.value = false
@@ -727,7 +711,7 @@ const savePersona = async () => {
isDefault: personaForm.isDefault,
},
apiVersion: 'comment-ai-autopilot.nxxy335.top/v1alpha1',
kind: 'Persona',
kind: 'AiPersona',
metadata: personaEditing.value
? { name: personaEditing.value.metadata.name }
: { generateName: 'persona-' },
@@ -795,56 +779,6 @@ const setDefaultPersona = async (persona: any) => {
}
}
const movePersonaUp = async (index: number) => {
if (index <= 0) return
const current = personas.value[index]
const prev = personas.value[index - 1]
// Swap priorities
const currentPriority = current.spec?.priority ?? index
const prevPriority = prev.spec?.priority ?? (index - 1)
try {
// Update both personas
await Promise.all([
axiosInstance.put(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
{ ...current, spec: { ...current.spec, priority: prevPriority } }
),
axiosInstance.put(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${prev.metadata.name}`,
{ ...prev, spec: { ...prev.spec, priority: currentPriority } }
)
])
Toast.success("排序已更新")
fetchPersonas()
} catch (e) {
Toast.error("排序更新失败")
}
}
const movePersonaDown = async (index: number) => {
if (index >= personas.value.length - 1) return
const current = personas.value[index]
const next = personas.value[index + 1]
const currentPriority = current.spec?.priority ?? index
const nextPriority = next.spec?.priority ?? (index + 1)
try {
await Promise.all([
axiosInstance.put(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
{ ...current, spec: { ...current.spec, priority: nextPriority } }
),
axiosInstance.put(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${next.metadata.name}`,
{ ...next, spec: { ...next.spec, priority: currentPriority } }
)
])
Toast.success("排序已更新")
fetchPersonas()
} catch (e) {
Toast.error("排序更新失败")
}
}
// Watch persona dialog email for Gravatar preview
let emailDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => personaForm.email, (newEmail) => {
@@ -882,7 +816,7 @@ const fetchSettings = async () => {
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
}
} catch (e) { console.error("Failed to fetch settings", e); Toast.error("获取设置失败") }
} catch (e) { console.error("Failed to fetch settings", e) }
finally { loading.value = false }
}
@@ -922,33 +856,6 @@ onMounted(async () => {
@media (max-width: 1024px) {
.settings-container { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.settings-container {
grid-template-columns: 1fr !important;
}
.settings-sidebar {
order: -1;
}
.persona-list {
display: flex;
flex-direction: column;
}
.settings-section .form-row {
flex-direction: column;
align-items: stretch;
}
.settings-section .form-row__label {
min-width: auto;
margin-bottom: 4px;
}
.settings-section .form-input,
.settings-section .form-textarea {
width: 100%;
}
.preset-grid {
grid-template-columns: 1fr !important;
}
}
/* ===== Section ===== */
.settings-section {