4 Commits
Author SHA1 Message Date
sunny-335 122069e221 fix: ui/build.gradle cross-platform pnpm command for Linux CI 2026-06-23 19:59:25 +08:00
sunny-335 e5f973c13d feat: 评论前置过滤(合规检测)与 AI Foundation 隔离加载 (v1.1.0) 2026-06-23 19:54:55 +08:00
bbb-lsy07andbbb-lsy07 1a2732fe19 feat: 彻底重构后台 UI 与对话上下文引用模块,全面优化移动端适配 (v1.0.4) (#6)
* feat: 对话气泡增加引用摘要模块,解决多用户混杂交谈上下文不清晰问题

- 后端 ConversationMessage Record 新增 quoteOwner/quoteContent 字段
- 后端 getConversation 方法重写,构建 Reply 映射字典溯源引用关系
- 前端 ConversationMessage 类型定义新增 quoteOwner/quoteContent
- 前端新增 truncateQuote 截断方法(复用 stripHtml,限30字符)
- 前端对话气泡模板渲染灰色引用条(bg-black/5 + border-l-2)

* chore: 补充 .gitignore 规则(*.jar、ui/dist 等)

* feat: Markdown 引用注入法 - AI回复自动拼接引用块(主题无关通用方案)

- 后端 CommentReplyPublisher.doPublish 重写,发布前查询被回复对象并拼接 Markdown Blockquote
- 新增 buildQuoteMarkdown 辅助方法,Jsoup 清除 HTML 后截断 40 字符生成引用
- 前端 LogsView 恢复简洁气泡模板,移除 quoteOwner/quoteContent 前端引用逻辑
- renderContent 新增换行符处理,确保 Markdown 引用块正确渲染

* style: 重写 LogsView.vue - 纯 Tailwind 标签替代 Emoji,移除 300+ 行自定义 CSS

- 状态/情感标签改用纯色 Tailwind 背景标签,去除所有 Emoji
- 删除 300+ 行自定义 CSS,全部替换为 Tailwind 原子类
- 对话弹窗 Markdown 引用块正则提取,去除气泡和机器人 Emoji
- 优化移动端响应式布局,解决排版错位问题

* style: 重写 SettingsView.vue - 纯 Tailwind 栅格布局,移除自定义 CSS

- 标签导航改用 Tailwind flex + overflow-x-auto
- 所有设置面板(basic/persona/model/prompt/cleanup)改用 Tailwind 原子类
- 开关改用 peer-checked 伪类实现,移除自定义 toggle CSS
- 滑块刻度改用 flex justify-between 实现
- 侧边栏 lg:sticky lg:top-24,移动端自然折叠到底部
- 弹窗(评论者选择/角色编辑)改用 fixed inset-0 + backdrop-blur
- 删除 600+ 行自定义 CSS

* refactor: 返璞归真 - 剥离 Markdown 注入,利用 Halo 原生层级回复

- 后端 CommentReplyPublisher 删除 buildQuoteMarkdown 和 Markdown 拼接逻辑
- AI 回复直接存入纯净文本,由 Halo 原生 quoteReply 字段渲染前台层级关系
- 前端 ConversationMessage 恢复 quoteOwner/quoteContent 字段
- 前端对话弹窗添加原生 Tailwind 引用摘要框(灰色 border-l-[3px])
- 简化 renderContent,删除 Markdown 引用正则匹配

* chore: 版本号升级至 1.0.1,强制刷新 Halo 前端缓存

- plugin.yaml version: 1.0.0 -> 1.0.1
- build.gradle version: 1.0.0 -> 1.0.1
- LogsView truncateQuote/renderContent 增加历史 Markdown 引用文本清理正则
- 防止旧版测试数据 (💬 **@某人**:) 在界面套娃显示

* style: 彻底重写 LogsView & SettingsView - 原生 Scoped CSS 替代 Tailwind

- LogsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- SettingsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- 标签配色、气泡样式、引用框全部使用纯 CSS 实现,避免 Halo 主题冲突
- 版本号升级至 1.0.2 强制刷新前端缓存

* feat: SettingsView 完整功能版 - AI角色/数据清理/导入导出

- 5个设置面板:基本设置、AI角色、模型设置、Prompt、数据清理
- AI角色:CRUD、Gravatar头像、性别/唤醒词/默认角色
- 数据清理:自动清理开关、保留天数滑块、手动清理
- 导入导出:JSON配置导入导出
- 评论者黑名单弹窗选择
- 全部使用原生 Scoped CSS

* chore: 版本号升级至 1.0.3

* v1.0.4: 美化 LogsView 和 SettingsView UI,优化引用框样式与移动端适配

---------

Co-authored-by: bbb-lsy07 <bbb-lsy07@users.noreply.github.com>
2026-06-21 12:20:25 +08:00
sunny-335 633f3ff588 fix: CD pre-release-cleanup fails when no assets exist 2026-06-18 22:56:35 +08:00
27 changed files with 1384 additions and 2563 deletions
+10 -5
View File
@@ -15,11 +15,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
TAG_NAME="${{ github.event.release.tag_name }}" TAG_NAME="${{ github.event.release.tag_name }}"
# List all existing assets and delete them # Capture asset list first to avoid pipefail issues
gh release view "$TAG_NAME" --json assets --jq '.assets[].name' 2>/dev/null | while read -r filename; do ASSETS=$(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' 2>/dev/null || true)
echo "Deleting existing asset: $filename" if [ -n "$ASSETS" ]; then
gh release delete-asset "$TAG_NAME" "$filename" --yes 2>/dev/null || true echo "$ASSETS" | while read -r filename; do
done echo "Deleting existing asset: $filename"
gh release delete-asset "$TAG_NAME" "$filename" --yes 2>/dev/null || true
done
else
echo "No existing assets to delete"
fi
shell: bash shell: bash
cd: cd:
+7
View File
@@ -63,6 +63,7 @@ lerna-debug.log*
*.ctxt *.ctxt
### Package Files ### Package Files
*.jar
*.war *.war
*.nar *.nar
*.ear *.ear
@@ -70,6 +71,12 @@ lerna-debug.log*
*.tar.gz *.tar.gz
*.rar *.rar
### UI build output
ui/dist/
ui/dist-ssr/
ui/*.local
ui/.eslintcache
### Local file ### Local file
application-local.yml application-local.yml
application-local.yaml application-local.yaml
+3 -2
View File
@@ -1,6 +1,6 @@
# AI回评 / Comment AI Autopilot # AI回评 / Comment AI Autopilot
基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。 基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、合规检测、自审核、自动发布和对话式连续回复。
## 功能特性 ## 功能特性
@@ -9,6 +9,7 @@
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复 - **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/无意义内容,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
- **失败重试** — AI 生成失败时自动重试,指数退避策略 - **失败重试** — AI 生成失败时自动重试,指数退避策略
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
@@ -20,7 +21,7 @@
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数、对话历史等) - **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数、对话历史等)
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 - **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性 - **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
- **日志筛选** — 按状态、情感筛选,关键词搜索 - **日志筛选** — 按状态、情感筛选,关键词搜索,支持查看拦截原因和分类标签
- **数据清理** — 自动清理超过指定天数的旧记录 - **数据清理** — 自动清理超过指定天数的旧记录
- **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件 - **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
} }
group 'top.nxxy335.commentaiautopilot' group 'top.nxxy335.commentaiautopilot'
version '1.0.0' version project.property('version')
repositories { repositories {
mavenCentral() mavenCentral()
+82
View File
@@ -1,5 +1,87 @@
# 更新日志 # 更新日志
## v1.1.0
> 2026-06-23
### 新增
- **评论前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,识别广告/辱骂攻击/敏感内容/无意义内容,违规评论停止生成 AI 回复,节省 Token
- **违规评论自动设为待审核** — 检测到违规评论时自动将原评论 `approved` 置为 `false`,进入待审核队列,前端不再展示该评论
- **FILTERED 日志状态** — 被拦截的评论生成"已拦截"状态记录,日志页支持按"已拦截"状态筛选
- **拦截原因分类标签** — 日志页显示拦截分类标签(广告/辱骂攻击/敏感内容/无意义)和详细拦截原因(含评论内容摘要)
- **安全优先策略** — AI 分类服务不可用或异常时,默认拦截评论而非放行,防止违规内容漏网
### 改进
- **AI Foundation 隔离加载** — 将 AI Foundation API 引用隔离到 `AiFoundationDelegate` 类,`AiFoundationClient` 不再直接引用 AI Foundation 类,修复未安装 AI Foundation 时插件无法启动的问题(`NoClassDefFoundError`
- **评论内容 HTML 剥离** — 前置过滤检测前自动剥离评论 HTML 标签,提升 AI 分类准确性
- **对话场景精准处罚** — AI 对话场景下违规内容来自 Reply 时,仅取消通过该 Reply 而非父级 Comment,避免误伤
- **升级配置自动迁移** — 从 v1.0.x 升级时自动将 `preFilterEnabled``false` 迁移为 `true`(新默认值)
### Bug 修复
- **修复未安装 AI Foundation 时插件无法启动** — `BeanDefinitionStoreException: Failed to parse AiFoundationClient`,将 AI Foundation API 引用隔离到委托类
- **修复前置过滤默认关闭** — `preFilterEnabled` 默认值从 `false` 改为 `true`,新安装和升级用户均默认启用
- **修复 `penalize()` 遗漏 `approved=null`** — Halo 评论创建时 `approved` 可能为 `null`,原代码仅处理 `approved=true` 的情况
- **修复 `classify()` 失败时放行违规评论** — `defaultIfEmpty``onErrorResume` 改为拦截而非放行
- **修复 Windows 构建失败** — Gradle Worker Daemon 执行 pnpm 退出码 268435659,改用系统 pnpm Exec 任务并禁用 Daemon
---
## v1.0.4
> 2026-06-19
### 改进
- **对话弹窗头像显示** — 对话弹窗中每条消息显示 Gravatar 头像,基于评论者或 AI 角色的邮箱自动匹配
- **对话引用摘要** — 对话弹窗中回复消息显示引用摘要框,标明引用了谁的什么内容,支持截断显示
- **UI 全面重构** — LogsView 和 SettingsView 改用纯 Scoped CSS,移除所有 Tailwind 类和自定义 CSS 依赖,避免 Halo 主题冲突
- **标签去 Emoji 化** — 状态、情感标签改用纯色背景标签,去除所有 Emoji
- **移动端适配优化** — 全面优化移动端响应式布局,解决排版错位问题
- **AI角色设置完善** — 支持 CRUD、Gravatar 头像预览、性别/唤醒词/默认角色配置
- **配置导入导出** — 支持将插件配置(ConfigMap + AI角色)导出为 JSON 文件,方便备份和迁移
- **评论者黑名单弹窗选择** — 设置页面可从已有评论列表中选择评论者添加到黑名单
### Bug 修复
- **修复对话弹窗引用溯源** — 后端 `getConversation` 重写,构建 Reply 映射字典正确溯源引用关系
- **修复 ConversationMessage 数据结构** — 新增 `quoteOwner`/`quoteContent` 字段支持引用摘要展示
- **修复 AI 角色邮箱提取** — 后端新增 `extractOwnerEmail` 方法,正确从 CommentOwner 提取邮箱用于头像生成
---
## v1.0.3
### 改进
- **SettingsView 完整功能版** — 5个设置面板(基本设置、AI角色、模型设置、Prompt、数据清理)全部实现
- **AI角色管理** — 支持 CRUD、Gravatar 头像、性别/唤醒词/默认角色配置
- **数据清理** — 自动清理开关、保留天数滑块、手动清理
- **导入导出** — JSON 配置导入导出
- **评论者黑名单弹窗选择** — 从已有评论列表中选择评论者
---
## v1.0.2
### 改进
- **LogsView & SettingsView 样式重构** — 移除所有 Tailwind 类,改用 `<style scoped>` 原生 CSS
- **标签配色、气泡样式、引用框** — 全部使用纯 CSS 实现,避免 Halo 主题冲突
---
## v1.0.1
### 改进
- **版本号升级** — 强制刷新 Halo 前端缓存
- **历史数据兼容** — LogsView 增加历史 Markdown 引用文本清理正则,防止旧版测试数据套娃显示
---
## v1.0.0 ## v1.0.0
> 2026-06-18 > 2026-06-18
+12
View File
@@ -22,6 +22,18 @@
- 草稿记录显示 **审核通过****拒绝** 按钮 - 草稿记录显示 **审核通过****拒绝** 按钮
- 已发布的记录显示正常状态 - 已发布的记录显示正常状态
- 被拒绝的记录显示 REJECTED 标签 - 被拒绝的记录显示 REJECTED 标签
- 失败的记录显示 FAIL 标签,并显示重试次数
- 每条记录可点击 **查看对话** 查看完整对话上下文
## 对话上下文查看
点击日志记录的 **查看对话** 按钮,弹出对话上下文窗口:
- 以气泡形式展示完整对话(评论 + 所有回复)
- AI 回复和用户回复以不同颜色气泡区分
- 每条消息显示发送者头像(通过 Gravatar 服务生成)
- 回复消息显示引用摘要框,标明该回复引用了哪条消息
- 支持移动端响应式布局
## 批量操作 ## 批量操作
+31
View File
@@ -23,6 +23,7 @@
3. **已有AI回复记录** — 同一评论不会重复触发 3. **已有AI回复记录** — 同一评论不会重复触发
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发 4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
5. **AI生成失败** — 检查AI模型配置和日志 5. **AI生成失败** — 检查AI模型配置和日志
6. **被前置过滤拦截** — 若启用"前置过滤",违规评论会被拦截,可在日志页通过"已拦截"状态筛选查看
## 如何对历史评论触发AI回复? ## 如何对历史评论触发AI回复?
@@ -66,3 +67,33 @@
## 黑名单支持邮箱吗? ## 黑名单支持邮箱吗?
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。 支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
## 对话窗口中的头像是怎么来的?
对话窗口中每条消息的头像通过 [Gravatar](https://gravatar.com) 服务生成(使用 [Cravatar](https://cn.cravatar.com) 镜像)。头像基于评论者或 AI 角色的邮箱自动匹配。如果未设置邮箱,则显示默认图标。
## 对话窗口中的引用框是什么?
当一条回复是针对另一条回复的(即层级回复),对话窗口会在该消息气泡内显示一个引用摘要框,标明该回复引用了谁的什么内容。引用内容会截断显示(最多35个字符),方便快速了解对话脉络。
## 如何备份和迁移插件配置?
在插件设置页面顶部点击 **导出** 按钮,将当前配置导出为 JSON 文件。在目标实例中点击 **导入** 按钮选择该文件即可恢复配置。导入会覆盖当前配置,请谨慎操作。
## AI Foundation 显示"部分功能不可用"怎么办?
这通常表示 AI Foundation 插件未正确配置模型。请检查:
1. AI Foundation 插件已安装并启用
2. 在 AI Foundation 中配置了至少一个 AI 模型
3. 如果回评插件未指定模型名称,将使用 AI Foundation 的默认模型
## 前置过滤会误伤正常评论吗?
前置过滤默认启用。AI 会对评论进行分类判断,若 AI 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。
## 被前置过滤拦截的评论会怎样?
1. **停止生成 AI 回复** — 不会消耗后续 Token
2. **创建拦截记录** — 在日志页显示为"已拦截"状态,标注分类标签(如"辱骂攻击")和详细原因(含评论内容摘要)
3. **自动设为待审核** — 原评论的 `approved` 会被置为 `false`,前端不再展示该评论,需人工判断后审核通过
+11 -7
View File
@@ -15,19 +15,22 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **批量操作** — 草稿模式下支持批量通过/拒绝/删除
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式 - **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式
- **前置过滤(合规检测)** — AI回复前对评论进行合规性分类,自动拦截广告/辱骂/敏感/无意义内容,节省Token;可选将违规评论设为待审核状态
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复
- **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝 - **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 - **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度 - **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索 - **日志筛选搜索** — 按状态、情感筛选,关键词搜索
- **对话上下文查看** — 在日志页面查看完整对话上下文,支持引用摘要展示和 Gravatar 头像显示
- **数据清理** — 自动清理超过指定天数的旧记录 - **数据清理** — 自动清理超过指定天数的旧记录
- **配置导入导出** — 支持将插件配置导出为 JSON 文件,方便备份和迁移
- **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件 - **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
## 工作流程 ## 工作流程
``` ```
新评论 → 唤醒词检查 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿 新评论 → 唤醒词检查 → 过滤检查 → 前置过滤(合规检测) → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
↓ (失败) ↓ (失败)
重试 → ... → 最终失败 重试 → ... → 最终失败
``` ```
@@ -35,12 +38,13 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
1. **新评论到达** — Reconciler 监听到新评论创建事件 1. **新评论到达** — Reconciler 监听到新评论创建事件
2. **唤醒词检查** — 检查评论是否以某个角色的唤醒词开头,匹配则唤醒对应角色 2. **唤醒词检查** — 检查评论是否以某个角色的唤醒词开头,匹配则唤醒对应角色
3. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中(唤醒词触发时绕过页面级启用检查) 3. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中(唤醒词触发时绕过页面级启用检查)
4. **情感分析** — 调用AI分析评论情感倾向 4. **前置过滤(合规检测)** — 若启用,AI 对评论内容进行合规性分类(正常/广告/辱骂攻击/敏感内容/无意义)。违规评论将停止后续流程,可选自动设为待审核状态
5. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt 5. **情感分析** — 调用AI分析评论情感倾向
6. **AI生成** — 调用AI模型生成回复内容 6. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
7. **安全审核**对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100) 7. **AI生成**调用AI模型生成回复内容
8. **发布/草稿**根据设置自动发布或存为草稿等待审核 8. **安全审核**对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
9. **重试**如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增 9. **发布/草稿**根据设置自动发布或存为草稿等待审核
10. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
## 前置要求 ## 前置要求
+12
View File
@@ -37,3 +37,15 @@ POST /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/{replyN
``` ```
对指定回复触发对话式AI回复。 对指定回复触发对话式AI回复。
### 更新草稿回复内容
```
PUT /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/{name}/content
```
更新草稿状态的AI回复内容。请求体为 JSON 格式:`{"reply": "新的回复内容"}`。仅未发布的草稿回复可编辑。
::: warning
已发布的回复不可编辑。
:::
+2
View File
@@ -49,8 +49,10 @@ Prompt模板控制AI生成回复时的完整提示词结构。
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加: 情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
- **非常正面** → 追加"评论者情绪非常正面积极,请用热情洋溢的语气回复,表达真诚的感谢和共鸣。"
- **正面** → 追加"评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。" - **正面** → 追加"评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。"
- **负面** → 追加"评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。" - **负面** → 追加"评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
- **非常负面** → 追加"评论者情绪非常负面,请用非常温和、理性的语气回复,避免任何可能激化矛盾的表达,展现充分的理解和耐心。"
- **中性** → 不追加额外提示 - **中性** → 不追加额外提示
## 安全提示 ## 安全提示
+6 -6
View File
@@ -22,13 +22,13 @@
## 日志展示 ## 日志展示
在AI回复日志页面,每条记录会显示情感标签: 在AI回复日志页面,每条记录会显示情感标签(纯色背景标签)
- 🟢 **非常正面** — 深绿色标签 - **非常正面** — 深绿色标签
- 🟩 **正面** — 浅绿色标签 - **正面** — 浅绿色标签
- **中性** — 灰色标签 - **中性** — 灰色标签
- 🟥 **负面** — 浅红色标签 - **负面** — 浅红色标签
- 🔴 **非常负面** — 深红色标签 - **非常负面** — 深红色标签
## 性能影响 ## 性能影响
+42
View File
@@ -8,6 +8,8 @@
- Prompt设置 - Prompt设置
- 数据清理 - 数据清理
页面右侧为操作控制侧边栏,显示保存按钮和未保存状态指示器。在 Prompt 设置页面,侧边栏还会显示可用模板变量列表。
## 基本设置 ## 基本设置
| 配置项 | 说明 | 默认值 | | 配置项 | 说明 | 默认值 |
@@ -18,6 +20,8 @@
| 速率限制 | 每分钟最大AI回复数量,防止批量评论消耗过多额度 | 10 | | 速率限制 | 每分钟最大AI回复数量,防止批量评论消耗过多额度 | 10 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式(`regex:` 开头),逗号分隔 | 空 | | 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式(`regex:` 开头),逗号分隔 | 空 |
| 启用前置过滤 | AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token | 开启 |
| 违规评论设为待审核 | 检测到违规评论时自动取消通过,需人工审核 | 开启 |
::: tip 评论者黑名单 ::: tip 评论者黑名单
黑名单支持三种格式: 黑名单支持三种格式:
@@ -28,6 +32,26 @@
点击"添加评论者"按钮可从已有评论列表中选择评论者自动添加到黑名单。 点击"添加评论者"按钮可从已有评论列表中选择评论者自动添加到黑名单。
::: :::
::: tip 前置过滤(合规检测)
启用前置过滤后,AI 在生成回复前会先对评论内容进行合规性分类,识别以下类别:
- **正常**:放行,继续走 AI 回复流程
- **广告**:包含推广链接、产品推销、引流信息等
- **辱骂攻击**:包含辱骂、人身攻击、恶意挑衅、歧视性言论等
- **敏感内容**:涉及政治敏感、违法违规、色情暴力等
- **无意义**:纯乱码、无意义字符堆砌、与文章完全无关的废话
对于非"正常"类别的评论,插件会:
1. **停止生成 AI 回复**,节省 Token 与 API 调用
2. 创建一条 `FILTERED` 状态的日志记录(可在日志页通过"已拦截"状态筛选查看)
3. 若启用"违规评论设为待审核",会自动将原评论的 `approved` 置为 `false`,使其进入待审核队列,需人工判断后审核通过
::: warning
前置过滤依赖 AI Foundation 插件进行分类判断,会额外消耗少量 Token。若 AI 服务不可用或分类失败,为安全起见将拦截评论而非放行,防止违规内容漏网。
:::
:::
## AI角色设置 ## AI角色设置
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。 AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。
@@ -113,3 +137,21 @@ AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个
::: warning ::: warning
清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。 清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。
::: :::
## 配置导入导出
插件设置页面顶部提供导入导出按钮,方便备份和迁移配置。
### 导出配置
点击 **导出** 按钮,将当前配置(包括 ConfigMap 数据和所有 AI 角色)导出为 JSON 文件。
### 导入配置
1. 点击 **导入** 按钮,选择 JSON 配置文件
2. 确认导入操作(导入会覆盖当前配置,不可撤销)
3. 导入完成后自动刷新设置和角色列表
::: warning
导入操作会覆盖当前配置,请谨慎操作。建议在导入前先导出当前配置作为备份。
:::
+7 -5
View File
@@ -16,14 +16,16 @@ hero:
features: features:
- title: 自动回复 - title: 自动回复
details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试 details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试
- title: 语言适配 - title: AI 角色
details: 根据评论语言自动用对应语言回复,中文评论中文回复,英文评论英文回复 details: 创建多个虚拟角色,独立昵称、人格、性别、语气和 Gravatar 头像
- title: 情感分析 - title: 情感分析
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气 details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
- title: 前置过滤
details: AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token
- title: 草稿模式 - title: 草稿模式
details: AI回复先存为草稿,管理员审核后再发布,支持批量操作 details: AI回复先存为草稿,管理员审核后再发布,支持批量操作
- title: 灵活过滤 - title: 对话上下文
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配 details: 查看完整对话上下文,支持引用摘要展示和头像显示
- title: 数据管理 - title: 数据管理
details: 仪表盘统计、日志筛选搜索、自动清理旧记录 details: 仪表盘统计、日志筛选搜索、自动清理旧记录、配置导入导出
--- ---
+4 -1
View File
@@ -1 +1,4 @@
version=1.0.0-SNAPSHOT version=1.1.0
# Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks
org.gradle.daemon=false
@@ -1,6 +1,10 @@
package top.nxxy335.commentaiautopilot; package top.nxxy335.commentaiautopilot;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.IndexSpecs; import run.halo.app.extension.index.IndexSpecs;
import run.halo.app.extension.Scheme; import run.halo.app.extension.Scheme;
@@ -25,13 +29,18 @@ import reactor.core.publisher.Mono;
@Component @Component
public class CommentAiAutopilotPlugin extends BasePlugin { public class CommentAiAutopilotPlugin extends BasePlugin {
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
private final SchemeManager schemeManager; private final SchemeManager schemeManager;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ObjectMapper objectMapper;
public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager, ReactiveExtensionClient client) { public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager,
ReactiveExtensionClient client, ObjectMapper objectMapper) {
super(pluginContext); super(pluginContext);
this.schemeManager = schemeManager; this.schemeManager = schemeManager;
this.client = client; this.client = client;
this.objectMapper = objectMapper;
} }
@Override @Override
@@ -54,6 +63,49 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
// 初始化默认AI角色"小回" // 初始化默认AI角色"小回"
initDefaultPersona(); initDefaultPersona();
// 迁移:确保升级用户的前置过滤配置正确
migratePreFilterConfig();
}
/**
* 迁移前置过滤配置:从 v1.0.x 升级到 v1.1.0 时,
* ConfigMap 中可能保存了旧默认值 preFilterEnabled=false
* 需要将其更新为 true(新默认值)。
*/
private void migratePreFilterConfig() {
client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.flatMap(cm -> {
var data = cm.getData();
if (data == null) return Mono.empty();
String basicJson = data.get("basic");
if (basicJson == null || basicJson.isBlank()) return Mono.empty();
try {
JsonNode node = objectMapper.readTree(basicJson);
if (!node.has("preFilterEnabled")) {
// 字段不存在,添加并设为 true
((ObjectNode) node).put("preFilterEnabled", true);
data.put("basic", objectMapper.writeValueAsString(node));
return client.update(cm)
.doOnSuccess(c -> log.info("[Migration] Added preFilterEnabled=true to ConfigMap"));
}
if (node.has("preFilterEnabled") && !node.get("preFilterEnabled").asBoolean(true)) {
// 字段存在但为 false(旧默认值),迁移为 true
((ObjectNode) node).put("preFilterEnabled", true);
data.put("basic", objectMapper.writeValueAsString(node));
return client.update(cm)
.doOnSuccess(c -> log.info("[Migration] Migrated preFilterEnabled from false to true"));
}
} catch (Exception e) {
log.warn("[Migration] Failed to migrate preFilter config: {}", e.getMessage());
}
return Mono.empty();
})
.subscribe(
null,
err -> log.debug("[Migration] PreFilter config migration skipped: {}", err.getMessage()),
() -> log.debug("[Migration] PreFilter config migration check completed")
);
} }
private void initDefaultPersona() { private void initDefaultPersona() {
@@ -291,26 +291,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp()); var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp());
var isCommentAi = isAiOwner(comment.getSpec().getOwner()); var isCommentAi = isAiOwner(comment.getSpec().getOwner());
// 首条评论没有引用对象
var commentMsg = new ConversationMessage( var commentMsg = new ConversationMessage(
"comment", commentOwner, commentContent, commentTime, isCommentAi "comment", commentOwner, commentContent, commentTime, isCommentAi, null, null
); );
return client.list(Reply.class, return client.list(Reply.class,
reply -> commentName.equals(reply.getSpec().getCommentName()), reply -> commentName.equals(reply.getSpec().getCommentName()),
null) null)
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp())) .sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
.map(reply -> { .collectList() // 收集为List以便统一处理引用映射
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
var isAi = isAiOwner(reply.getSpec().getOwner());
return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi);
})
.collectList()
.map(replyList -> { .map(replyList -> {
List<ConversationMessage> messages = new ArrayList<>(); List<ConversationMessage> messages = new ArrayList<>();
messages.add(commentMsg); messages.add(commentMsg);
messages.addAll(replyList);
// 构建 Reply 的映射字典,方便查找引用关系
Map<String, Reply> replyMap = new HashMap<>();
for (Reply r : replyList) {
replyMap.put(r.getMetadata().getName(), r);
}
for (Reply reply : replyList) {
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
var isAi = isAiOwner(reply.getSpec().getOwner());
String quoteOwner = null;
String quoteContent = null;
// 获取引用的 Reply 名称 (Halo中如果为空,代表直接回复顶级 Comment)
String quoteReplyName = reply.getSpec().getQuoteReply();
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
Reply quotedReply = replyMap.get(quoteReplyName);
if (quotedReply != null) {
quoteOwner = extractOwnerName(quotedReply.getSpec().getOwner());
quoteContent = extractContent(quotedReply.getSpec().getRaw(), quotedReply.getSpec().getContent());
}
} else {
// 没有 quoteReply 表示直接回复首条评论
quoteOwner = commentOwner;
quoteContent = commentContent;
}
messages.add(new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi, quoteOwner, quoteContent));
}
return messages; return messages;
}); });
}) })
@@ -712,7 +737,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
String owner, String owner,
String content, String content,
String time, String time,
boolean isAi boolean isAi,
String quoteOwner,
String quoteContent
) {} ) {}
public record CommenterInfo( public record CommenterInfo(
@@ -65,5 +65,11 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "已发布的回复名称") @Schema(description = "已发布的回复名称")
private String replyName; private String replyName;
@Schema(description = "前置过滤拦截分类(广告/辱骂攻击/敏感内容/无意义,为空表示未被拦截)")
private String filterCategory;
@Schema(description = "前置过滤拦截原因详情(为空表示未被拦截)")
private String filterReason;
} }
} }
@@ -3,30 +3,25 @@ package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.aifoundation.AiModelService;
import run.halo.aifoundation.chat.GenerateTextRequest;
import run.halo.aifoundation.chat.GenerateTextResult;
import run.halo.aifoundation.schema.OutputSpec;
import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import java.util.List; import java.util.List;
/** /**
* AI Foundation client that uses Halo's {@link ExtensionGetter} to obtain the * AI Foundation 客户端,通过 Halo {@link ExtensionGetter} 获取 AI 服务。
* {@link AiModelService} extension provided by the ai-foundation plugin. *
* <p> * <p>此类不直接引用任何 AI Foundation API 类(AiModelService、GenerateTextRequest 等),
* This is the recommended way to integrate with AI Foundation, see * 所有 AI Foundation 交互委托给 {@link AiFoundationDelegate}。
* <a href="https://github.com/halo-dev/plugin-ai-foundation/blob/main/dev/dev.md">dev guide</a>. * 当 AI Foundation 插件未安装时,{@link AiFoundationDelegate} 的类加载会触发
* <p> * {@link NoClassDefFoundError},在 {@code Mono.defer()} 中被捕获,
* Requires the following declaration in plugin.yaml: * 保证插件在无 AI Foundation 环境下仍可正常启动。
*
* <p>需要在 plugin.yaml 中声明可选依赖:
* <pre> * <pre>
* spec: * spec:
* pluginDependencies: * pluginDependencies:
* ai-foundation?: "*" * ai-foundation?: "*"
* </pre> * </pre>
* The dependency is optional, so the plugin still loads when AI Foundation is
* not installed; availability is checked at runtime and all calls return empty
* in that case.
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -39,90 +34,68 @@ public class AiFoundationClient {
} }
/** /**
* Call AI Foundation to generate a chat response using the specified model. * 调用 AI Foundation 生成聊天回复。
* Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that
* transient model errors are retried by the SDK.
* *
* @param prompt the prompt text * @param prompt 提示词文本
* @param modelName the AiModel metadata.name, null or blank to use default model * @param modelName AiModel metadata.namenull 或空则使用默认模型
* @return the generated text, or empty if AI Foundation is unavailable * @return 生成的文本,AI Foundation 不可用时返回 empty
*/ */
public Mono<String> chat(String prompt, String modelName) { public Mono<String> chat(String prompt, String modelName) {
return aiModelService()
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
.map(GenerateTextResult::getText))
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* Call AI Foundation to classify text into one of the given choices using
* structured output ({@link OutputSpec#choice(List)}).
* <p>
* This is the recommended way to do classification per the dev guide,
* as it is more reliable than prompt parsing.
*
* @param systemPrompt system prompt describing the task
* @param userPrompt the user input to classify
* @param choices the allowed classification values
* @param modelName the AiModel metadata.name, null or blank to use default model
* @return the selected choice string, or empty if AI Foundation is unavailable
*/
public Mono<String> classify(String systemPrompt, String userPrompt,
List<String> choices, String modelName) {
return aiModelService()
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder()
.system(systemPrompt)
.prompt(userPrompt)
.output(OutputSpec.choice(choices))
.maxRetries(2)
.build()))
.map(result -> {
Object output = result.getOutput();
return output != null ? String.valueOf(output).trim() : "";
}))
.doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* Check if AI Foundation is available: plugin installed and an
* AiModelService extension is enabled.
*/
public Mono<Boolean> isAvailable() {
return aiModelService().hasElement()
.onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage());
return Mono.just(false);
});
}
/**
* Obtain the enabled AiModelService extension via ExtensionGetter.
* <p>
* Wrapped in {@link Mono#defer} with a {@link NoClassDefFoundError} guard so
* that the plugin still works when the optional ai-foundation dependency is
* not installed (the AiModelService API class is then absent from the
* classloader).
*/
private Mono<AiModelService> aiModelService() {
return Mono.defer(() -> { return Mono.defer(() -> {
try { try {
return extensionGetter.getEnabledExtension(AiModelService.class); return AiFoundationDelegate.chat(extensionGetter, prompt, modelName);
} catch (NoClassDefFoundError e) { } catch (NoClassDefFoundError e) {
log.debug("AI Foundation API not on classpath: {}", e.getMessage()); log.debug("AI Foundation API not on classpath: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
} }
})
.onErrorResume(NoClassDefFoundError.class, e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* 调用 AI Foundation 进行文本分类,使用结构化输出(OutputSpec.choice)。
*
* @param systemPrompt 系统提示词
* @param userPrompt 待分类的用户输入
* @param choices 允许的分类值列表
* @param modelName AiModel metadata.namenull 或空则使用默认模型
* @return 选中的分类字符串,AI Foundation 不可用时返回 empty
*/
public Mono<String> classify(String systemPrompt, String userPrompt,
List<String> choices, String modelName) {
return Mono.defer(() -> {
try {
return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName);
} catch (NoClassDefFoundError e) {
log.debug("AI Foundation API not on classpath: {}", e.getMessage());
return Mono.empty();
}
})
.onErrorResume(NoClassDefFoundError.class, e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* 检查 AI Foundation 是否可用(插件已安装且 AiModelService 扩展已启用)。
*/
public Mono<Boolean> isAvailable() {
return Mono.defer(() -> {
try {
return AiFoundationDelegate.isAvailable(extensionGetter);
} catch (NoClassDefFoundError e) {
log.debug("AI Foundation API not on classpath: {}", e.getMessage());
return Mono.just(false);
}
})
.onErrorResume(NoClassDefFoundError.class, e -> Mono.just(false))
.onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage());
return Mono.just(false);
}); });
} }
} }
@@ -0,0 +1,73 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import run.halo.aifoundation.AiModelService;
import run.halo.aifoundation.chat.GenerateTextRequest;
import run.halo.aifoundation.chat.GenerateTextResult;
import run.halo.aifoundation.schema.OutputSpec;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import java.util.List;
/**
* AI Foundation API 隔离层。
*
* <p>此类集中了所有对 AI Foundation 插件 API 的直接引用(AiModelService、
* GenerateTextRequest、GenerateTextResult、OutputSpec)。
*
* <p>关键设计:此类不是 Spring 组件,由 {@link AiFoundationClient} 通过
* {@code Mono.defer()} 懒加载调用。当 AI Foundation 插件未安装时,
* JVM 加载此类会触发 NoClassDefFoundError,该错误在
* {@code AiFoundationClient} 的 defer + try-catch 中被捕获,
* 从而保证插件在无 AI Foundation 的环境下仍可正常启动。
*/
@Slf4j
class AiFoundationDelegate {
private AiFoundationDelegate() {}
static Mono<String> chat(ExtensionGetter extensionGetter, String prompt, String modelName) {
return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
.map(GenerateTextResult::getText))
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) {
return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder()
.system(systemPrompt)
.prompt(userPrompt)
.output(OutputSpec.choice(choices))
.maxRetries(2)
.build()))
.map(result -> {
Object output = result.getOutput();
return output != null ? String.valueOf(output).trim() : "";
}))
.doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
static Mono<Boolean> isAvailable(ExtensionGetter extensionGetter) {
return extensionGetter.getEnabledExtension(AiModelService.class)
.hasElement()
.onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage());
return Mono.just(false);
});
}
}
@@ -31,6 +31,7 @@ public class AiReplyOrchestrator {
private final CommentReplyPublisher commentReplyPublisher; private final CommentReplyPublisher commentReplyPublisher;
private final FilterService filterService; private final FilterService filterService;
private final RateLimitService rateLimitService; private final RateLimitService rateLimitService;
private final CommentPreFilterService preFilterService;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -49,6 +50,7 @@ public class AiReplyOrchestrator {
CommentReplyPublisher commentReplyPublisher, CommentReplyPublisher commentReplyPublisher,
FilterService filterService, FilterService filterService,
RateLimitService rateLimitService, RateLimitService rateLimitService,
CommentPreFilterService preFilterService,
ReactiveExtensionClient client, ReactiveExtensionClient client,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.contextExtractor = contextExtractor; this.contextExtractor = contextExtractor;
@@ -59,6 +61,7 @@ public class AiReplyOrchestrator {
this.commentReplyPublisher = commentReplyPublisher; this.commentReplyPublisher = commentReplyPublisher;
this.filterService = filterService; this.filterService = filterService;
this.rateLimitService = rateLimitService; this.rateLimitService = rateLimitService;
this.preFilterService = preFilterService;
this.client = client; this.client = client;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -209,14 +212,25 @@ public class AiReplyOrchestrator {
String personaName) { String personaName) {
return getModelName().flatMap(modelName -> return getModelName().flatMap(modelName ->
contextExtractor.extract(commentName, replyName, isAiConversation) contextExtractor.extract(commentName, replyName, isAiConversation)
.flatMap(context -> sentimentService.analyzeSentiment(context.commentContent(), modelName) .flatMap(context -> preFilterService.check(context.commentContent(), modelName)
.flatMap(sentimentResult -> { .flatMap(preFilterResult -> {
log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})", if (!preFilterResult.passed()) {
commentName, sentimentResult.sentiment(), sentimentResult.confidence()); log.warn("[Orchestrator] Comment pre-filtered: {}, reason: {}",
return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName) commentName, preFilterResult.reason());
.flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName) // 创建拦截记录并执行处罚(针对实际违规的 Comment 或 Reply
.flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName)) return createFilteredRecord(context, preFilterResult)
); .then(preFilterService.penalize(commentName, replyName))
.then();
}
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
.flatMap(sentimentResult -> {
log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})",
commentName, sentimentResult.sentiment(), sentimentResult.confidence());
return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName)
.flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName)
.flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName))
);
});
}) })
) )
); );
@@ -574,6 +588,34 @@ public class AiReplyOrchestrator {
.defaultIfEmpty(10); .defaultIfEmpty(10);
} }
/**
* 创建被前置过滤拦截的记录。
*/
private Mono<AiCommentReply> createFilteredRecord(ContextExtractor.CommentContext context,
CommentPreFilterService.PreFilterResult preFilterResult) {
AiCommentReply record = new AiCommentReply();
record.setMetadata(new Metadata());
record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8));
record.setSpec(new AiCommentReply.Spec());
record.getSpec().setCommentId(context.commentId());
record.getSpec().setPostId(context.postId());
record.getSpec().setPostSlug(context.postSlug());
record.getSpec().setPostKind(context.postKind());
record.getSpec().setReply("");
record.getSpec().setScore(0);
record.getSpec().setStatus("FILTERED");
record.getSpec().setRetryCount(0);
record.getSpec().setReplyTo(context.replyTo());
record.getSpec().setIsAiConversation(context.isAiConversation());
record.getSpec().setPublished(false);
record.getSpec().setSentiment("NEUTRAL");
record.getSpec().setFilterCategory(preFilterResult.category());
record.getSpec().setFilterReason(preFilterResult.reason());
return client.create(record)
.doOnSuccess(created -> log.info("[Orchestrator] Created filtered record: {} category={} reason={}",
created.getMetadata().getName(), preFilterResult.category(), preFilterResult.reason()));
}
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment, private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment,
String personaName) { String personaName) {
AiCommentReply record = new AiCommentReply(); AiCommentReply record = new AiCommentReply();
@@ -0,0 +1,220 @@
package top.nxxy335.commentaiautopilot.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 评论前置过滤服务 AI 回复之前检测评论合规性
*
* 检测维度
* 1. 敏感词/辱骂/广告/恶意攻击 通过 AI 分类判断
* 2. 自动处置 违规评论跳过 AI 回复可选将评论设为待审核状态
*/
@Component
@Slf4j
public class CommentPreFilterService {
private final ReactiveExtensionClient client;
private final ObjectMapper objectMapper;
private final AiFoundationClient aiFoundationClient;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
private static final String CLEAN = "正常";
private static final String SPAM = "广告";
private static final String ABUSE = "辱骂攻击";
private static final String SENSITIVE = "敏感内容";
private static final String MEANINGLESS = "无意义";
private static final List<String> CLASSIFY_CHOICES = List.of(CLEAN, SPAM, ABUSE, SENSITIVE, MEANINGLESS);
private static final Map<String, String> CATEGORY_DESCRIPTIONS = Map.of(
SPAM, "检测到推广链接、产品推销或引流信息",
ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论",
SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容",
MEANINGLESS, "检测到纯乱码、无意义字符或与文章完全无关的废话"
);
private static final String CLASSIFY_SYSTEM_PROMPT = """
你是评论内容合规检测员请判断以下评论属于哪个类别
- 正常正常的评论提问讨论赞美等
- 广告包含推广链接产品推销引流信息等
- 辱骂攻击包含辱骂人身攻击恶意挑衅歧视性言论等
- 敏感内容涉及政治敏感违法违规色情暴力等
- 无意义纯乱码无意义字符堆砌与文章完全无关的废话
只返回类别名称不要返回其他内容""";
public CommentPreFilterService(ReactiveExtensionClient client,
ObjectMapper objectMapper,
AiFoundationClient aiFoundationClient) {
this.client = client;
this.objectMapper = objectMapper;
this.aiFoundationClient = aiFoundationClient;
}
/**
* 检测评论是否合规
*
* @param commentContent 评论内容纯文本
* @param modelName AI 模型名称
* @return 检测结果
*/
public Mono<PreFilterResult> check(String commentContent, String modelName) {
return loadConfig().flatMap(config -> {
if (!config.enabled()) {
log.info("[PreFilter] Pre-filter is DISABLED, allowing all comments");
return Mono.just(new PreFilterResult(true, CLEAN, "前置过滤未启用"));
}
// 剥离 HTML 标签获取纯文本
String plainText = stripHtml(commentContent);
String truncated = truncate(plainText, 500);
String userPrompt = "评论内容:\n" + truncated;
log.info("[PreFilter] Checking comment (enabled=true): {}", truncated.substring(0, Math.min(50, truncated.length())));
return aiFoundationClient.classify(CLASSIFY_SYSTEM_PROMPT, userPrompt, CLASSIFY_CHOICES, modelName)
.map(result -> {
if (CLEAN.equals(result)) {
log.info("[PreFilter] Comment passed: category={}", result);
return new PreFilterResult(true, CLEAN, "评论合规");
}
String desc = CATEGORY_DESCRIPTIONS.getOrDefault(result, "检测到违规内容");
String snippet = truncated.substring(0, Math.min(50, truncated.length()));
String reason = desc + " — 「" + snippet + "";
log.warn("[PreFilter] Comment BLOCKED: category={}, content={}", result, snippet);
return new PreFilterResult(false, result, reason);
})
// 分类失败时拦截评论安全优先而非放行
.defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截"))
.onErrorResume(e -> {
log.warn("[PreFilter] Detection error, BLOCKING comment for safety: {}", e.getMessage());
return Mono.just(new PreFilterResult(false, MEANINGLESS, "AI分类服务异常,安全拦截"));
});
});
}
/**
* 对违规评论执行自动处置将评论或回复设为待审核状态
*
* <p> replyName 不为空时AI 对话场景或回复触发取消通过的是包含违规内容的 Reply
* 否则取消通过的是顶层 Comment这样可避免误伤父级 Comment 中正常的内容
*
* @param commentName 评论的 metadata.name
* @param replyName 回复的 metadata.name可为 null表示顶层评论
* @return Mono<Void>
*/
public Mono<Void> penalize(String commentName, String replyName) {
return loadConfig().flatMap(config -> {
if (!config.pendingOnViolation()) {
return Mono.empty();
}
// 优先处理 ReplyAI 对话场景下违规内容来自 Reply
if (replyName != null && !replyName.isBlank()) {
return penalizeReply(replyName);
}
return penalizeComment(commentName);
});
}
private Mono<Void> penalizeComment(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var spec = comment.getSpec();
if (spec == null) return Mono.<Comment>empty();
// 只要 approved 不是 false就强制设为 false
// 覆盖 approved=true approved=null 两种情况
if (!Boolean.FALSE.equals(spec.getApproved())) {
log.info("[PreFilter] Penalizing comment {}: approved={} → false", commentName, spec.getApproved());
spec.setApproved(false);
spec.setApprovedTime(null);
return client.update(comment)
.doOnSuccess(c -> log.info("[PreFilter] Comment {} set to pending for violation", commentName))
.onErrorResume(e -> {
log.warn("[PreFilter] Failed to penalize comment {}: {}", commentName, e.getMessage());
return Mono.empty();
});
}
log.debug("[PreFilter] Comment {} already unapproved, skip penalize", commentName);
return Mono.<Comment>empty();
})
.then();
}
private Mono<Void> penalizeReply(String replyName) {
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
var spec = reply.getSpec();
if (spec == null) return Mono.<Reply>empty();
// 只要 approved 不是 false就强制设为 false
if (!Boolean.FALSE.equals(spec.getApproved())) {
log.info("[PreFilter] Penalizing reply {}: approved={} → false", replyName, spec.getApproved());
spec.setApproved(false);
spec.setApprovedTime(null);
return client.update(reply)
.doOnSuccess(r -> log.info("[PreFilter] Reply {} set to pending for violation", replyName))
.onErrorResume(e -> {
log.warn("[PreFilter] Failed to penalize reply {}: {}", replyName, e.getMessage());
return Mono.empty();
});
}
log.debug("[PreFilter] Reply {} already unapproved, skip penalize", replyName);
return Mono.<Reply>empty();
})
.then();
}
/**
* 加载前置过滤配置
*/
private Mono<PreFilterConfig> loadConfig() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> {
var data = cm.getData();
if (data == null) return new PreFilterConfig(true, true);
String basicJson = data.get("basic");
if (basicJson == null || basicJson.isBlank()) return new PreFilterConfig(true, true);
try {
JsonNode node = objectMapper.readTree(basicJson);
boolean enabled = !node.has("preFilterEnabled")
|| node.get("preFilterEnabled").asBoolean(true);
boolean pendingOnViolation = !node.has("preFilterPendingOnViolation")
|| node.get("preFilterPendingOnViolation").asBoolean(true);
return new PreFilterConfig(enabled, pendingOnViolation);
} catch (Exception e) {
log.warn("[PreFilter] Failed to parse config: {}", e.getMessage());
return new PreFilterConfig(true, true);
}
})
.defaultIfEmpty(new PreFilterConfig(true, true))
.onErrorResume(e -> {
log.warn("[PreFilter] Failed to load config: {}", e.getMessage());
return Mono.just(new PreFilterConfig(true, true));
});
}
private String truncate(String text, int maxLength) {
if (text == null) return "";
return text.length() > maxLength ? text.substring(0, maxLength) : text;
}
private String stripHtml(String html) {
if (html == null || html.isBlank()) return "";
return Jsoup.clean(html, Safelist.none()).trim();
}
public record PreFilterResult(boolean passed, String category, String reason) {}
public record PreFilterConfig(boolean enabled, boolean pendingOnViolation) {}
}
@@ -80,63 +80,63 @@ public class CommentReplyPublisher {
private Mono<Reply> doPublish(String parentCommentName, String replyContent, private Mono<Reply> doPublish(String parentCommentName, String replyContent,
String postName, String quoteReplyName, boolean autoPublish, String postName, String quoteReplyName, boolean autoPublish,
String personaName) { String personaName) {
return resolvePersona(personaName).flatMap(persona -> {
String displayName = persona.displayName();
String email = persona.email();
Reply reply = new Reply(); // 解析 AI 角色并直接发布纯净的回复内容
reply.setMetadata(new Metadata()); return resolvePersona(personaName)
reply.getMetadata().setName(generateReplyName()); .flatMap(persona -> {
reply.setSpec(new Reply.ReplySpec()); String displayName = persona.displayName();
String email = persona.email();
var spec = reply.getSpec(); Reply reply = new Reply();
spec.setCommentName(parentCommentName); reply.setMetadata(new Metadata());
spec.setRaw(replyContent); reply.getMetadata().setName(generateReplyName());
spec.setContent(replyContent); reply.setSpec(new Reply.ReplySpec());
spec.setApproved(autoPublish);
if (autoPublish) {
spec.setApprovedTime(Instant.now());
}
spec.setPriority(0);
spec.setTop(false);
spec.setAllowNotification(false);
spec.setHidden(false);
if (quoteReplyName != null && !quoteReplyName.isBlank()) { var spec = reply.getSpec();
spec.setQuoteReply(quoteReplyName); spec.setCommentName(parentCommentName);
}
var owner = new Comment.CommentOwner(); // 直接存入纯净的 AI 回复内容不加任何 Markdown 前缀
owner.setKind(Comment.CommentOwner.KIND_EMAIL); spec.setRaw(replyContent);
if (email != null && !email.isBlank()) { spec.setContent(replyContent);
owner.setName(email);
} else {
owner.setName(AI_PERSONA_OWNER_PREFIX + displayName);
}
owner.setDisplayName(displayName + " AI");
Map<String, String> ownerAnnotations = new HashMap<>(); spec.setApproved(autoPublish);
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); if (autoPublish) {
// 使用Gravatar邮箱头像 spec.setApprovedTime(Instant.now());
if (email != null && !email.isBlank()) { }
String gravatarUrl = GravatarUtil.generateUrl(email); spec.setPriority(0);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); spec.setTop(false);
} spec.setAllowNotification(false);
owner.setAnnotations(ownerAnnotations); spec.setHidden(false);
spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}", // Halo 原生评论组件正是靠这个字段来渲染 "回复 @某人"
parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations); if (quoteReplyName != null && !quoteReplyName.isBlank()) {
spec.setQuoteReply(quoteReplyName);
}
return client.create(reply) var owner = new Comment.CommentOwner();
.doOnSuccess(created -> { owner.setKind(Comment.CommentOwner.KIND_EMAIL);
var createdOwner = created.getSpec().getOwner(); if (email != null && !email.isBlank()) {
log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}", owner.setName(email);
displayName, parentCommentName, quoteReplyName, } else {
createdOwner != null ? createdOwner.getAnnotations() : "null"); owner.setName(AI_PERSONA_OWNER_PREFIX + displayName);
}) }
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); owner.setDisplayName(displayName + " AI");
});
Map<String, String> ownerAnnotations = new HashMap<>();
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
if (email != null && !email.isBlank()) {
String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
}
owner.setAnnotations(ownerAnnotations);
spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, content length: {}", parentCommentName, replyContent.length());
return client.create(reply)
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}", displayName, parentCommentName))
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
});
} }
/** /**
@@ -40,6 +40,16 @@ spec:
label: 评论者黑名单 label: 评论者黑名单
help: "输入评论者显示名称或邮箱,多个用逗号分隔。支持正则表达式,以 regex: 开头,如 regex:^spam.*" help: "输入评论者显示名称或邮箱,多个用逗号分隔。支持正则表达式,以 regex: 开头,如 regex:^spam.*"
value: "" value: ""
- $formkit: switch
name: preFilterEnabled
label: 启用前置过滤
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
value: true
- $formkit: switch
name: preFilterPendingOnViolation
label: 违规评论设为待审核
help: "检测到违规评论时自动取消通过,需人工审核"
value: true
- group: model - group: model
label: 模型设置 label: 模型设置
formSchema: formSchema:
+1 -1
View File
@@ -30,4 +30,4 @@ spec:
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings" settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "1.0.0" version: "1.1.0"
+29 -11
View File
@@ -5,16 +5,33 @@ plugins {
group 'top.nxxy335.commentaiautopilot.ui' group 'top.nxxy335.commentaiautopilot.ui'
// Fix Gradle 9.x compatibility with pnpm symlinks // Use system pnpm directly avoids Windows exit code 268435659
tasks.named('pnpmInstall') { // caused by Gradle Worker Daemon / node-gradle downloading pnpm on Windows
doNotTrackState("pnpm symlinks are not compatible with Gradle state tracking") node {
download = false
} }
tasks.register('pnpmBuild', PnpmTask) { // Skip built-in pnpm tasks (they fail on Windows), replace with Exec-based tasks
tasks.named('pnpmSetup').configure { enabled = false }
tasks.named('pnpmInstall').configure { enabled = false }
// Cross-platform: use 'cmd /c' on Windows, direct 'pnpm' on Linux/macOS
def isWindows = System.properties['os.name'].toLowerCase().contains('windows')
def pnpmCmd = isWindows ? ['cmd', '/c', 'pnpm'] : ['pnpm']
tasks.register('uiInstall', Exec) {
group = 'build'
description = 'Install UI dependencies using system pnpm'
workingDir layout.projectDirectory
commandLine(pnpmCmd + ['install'])
}
tasks.register('uiBuild', Exec) {
group = 'build' group = 'build'
description = 'Build the UI project using pnpm' description = 'Build the UI project using pnpm'
args = ['build'] workingDir layout.projectDirectory
dependsOn tasks.named('pnpmInstall') commandLine(pnpmCmd + ['run', 'build'])
dependsOn uiInstall
inputs.dir(layout.projectDirectory.dir('src')) inputs.dir(layout.projectDirectory.dir('src'))
inputs.files(fileTree( inputs.files(fileTree(
dir: layout.projectDirectory, dir: layout.projectDirectory,
@@ -22,17 +39,18 @@ tasks.register('pnpmBuild', PnpmTask) {
outputs.dir(layout.buildDirectory.dir('dist')) outputs.dir(layout.buildDirectory.dir('dist'))
} }
tasks.register('pnpmCheck', PnpmTask) { tasks.register('uiCheck', Exec) {
group = 'verification' group = 'verification'
description = 'Run unit tests for the UI project using pnpm' description = 'Run unit tests for the UI project using pnpm'
args = ['test:unit'] workingDir layout.projectDirectory
dependsOn tasks.named('pnpmInstall') commandLine(pnpmCmd + ['run', 'test:unit'])
dependsOn uiInstall
} }
tasks.named('check') { tasks.named('check') {
dependsOn tasks.named('pnpmCheck') dependsOn tasks.named('uiCheck')
} }
tasks.named('assemble') { tasks.named('assemble') {
dependsOn tasks.named('pnpmBuild') dependsOn tasks.named('uiBuild')
} }
+216 -873
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff