Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ac25c4081 | ||
|
|
2693f88982 | ||
|
|
1cd273494d | ||
|
|
5337a62a13 | ||
|
|
122069e221 | ||
|
|
e5f973c13d |
@@ -1,6 +1,6 @@
|
||||
# AI回评 / Comment AI Autopilot
|
||||
|
||||
基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。
|
||||
基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、合规检测、自审核、自动发布和对话式连续回复。
|
||||
|
||||
## 功能特性
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
||||
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
|
||||
- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/乱码,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态
|
||||
- **误报反馈** — 被误拦截的评论可进行误报反馈,支持"AI回复"和"仅通过"两种处理方式,"仅通过"后可随时补触发 AI 回复
|
||||
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
||||
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
||||
@@ -20,7 +22,7 @@
|
||||
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数、对话历史等)
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
|
||||
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索,支持查看拦截原因和分类标签
|
||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||
- **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot'
|
||||
version '1.0.4'
|
||||
version project.property('version')
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
@@ -1,5 +1,143 @@
|
||||
# 更新日志
|
||||
|
||||
## v1.2.0
|
||||
|
||||
> 2026-06-25
|
||||
|
||||
### 新增
|
||||
|
||||
- **误报反馈功能** — 被拦截的评论可进行误报反馈,支持两种处理方式:
|
||||
- **AI 回复**:标记为通过 + 触发 AI 生成回复
|
||||
- **仅通过**:仅标记为通过,不生成回复
|
||||
- **误报通过状态** — 新增 `FALSE_POSITIVE` 状态,"仅通过"的记录显示为"误报通过",不显示"通过/拒绝"按钮
|
||||
- **触发 AI 回复按钮** — "误报通过"状态的记录可随时点击"触发AI回复"按钮补生成 AI 回复
|
||||
- **上下文优先判断原则** — 前置过滤 AI 提示词重写,遵循五条核心原则:上下文优先、口语化宽容、恶意导向判定、宁放勿杀、闲聊不算无意义
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- **修复误报反馈"AI 回复"被前置过滤再次拦截** — `processComment()` 始终调用 `preFilterService.check()`,用户已确认为误报的评论会被再次拦截。新增 `processFalsePositive()` 方法跳过前置过滤和去重检查
|
||||
- **修复误报反馈"AI 回复"被去重检查拦截** — `hasExistingReply()` 找到已有的 FILTERED→PENDING 记录导致 AI 回复无法生成。`processFalsePositive()` 复用已有记录,不经过去重检查
|
||||
- **修复误报反馈"AI 回复"导致全站崩溃** — `processComment()` 同步等待 AI 生成完成,HTTP 请求长时间不返回。改为 `.subscribe()` 异步执行,API 立即返回
|
||||
- **修复误报反馈"仅通过"后显示通过/拒绝按钮** — "仅通过"将记录设为 `status=PASS, published=false, reply=""`,导致显示"通过/拒绝"按钮且内容为空。改为 `status=FALSE_POSITIVE`
|
||||
- **修复 `extractChoice` 无匹配时返回原始文本** — AI 返回非预期文本时被误判为违规类别。改为返回空字符串触发安全拦截
|
||||
- **修复 `approveOriginalComment` 缺少乐观锁重试** — 并发更新 Comment/Reply 时可能静默失败。添加 `Retry.backoff(3, 100ms)` 重试
|
||||
|
||||
### 改进
|
||||
|
||||
- **消除 `checkBlockedCommenters` 重复代码** — `FilterService` 新增 `isCommenterBlocked(commentName)` 公共方法,`AiReplyOrchestrator` 改为调用它
|
||||
- **前端批量操作防重复提交** — 批量通过/拒绝/删除按钮添加 `batchLoading` 状态,操作期间禁用按钮
|
||||
|
||||
---
|
||||
|
||||
## v1.1.2
|
||||
|
||||
> 2026-06-24
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- **修复 AI 分类完全不可用** — `classifyWithChoice` 和 `classifyWithChat` 均使用了 `GenerateTextRequest.Builder.system()` 方法,而该方法在当前 AI Foundation 版本中不被支持或导致运行时错误,导致所有评论均被拦截并显示"AI分类服务不可用,安全拦截"。现改为将 system prompt 合并到 user prompt 中,与可用的 `chat()` 方法保持一致的调用方式
|
||||
- **修复 `classifyWithChoice` NPE** — `.map()` 返回 `null` 时触发 Reactor 内部 NullPointerException,改为 `.flatMap()` + `Mono.empty()` 正确触发 fallback
|
||||
|
||||
### 改进
|
||||
|
||||
- **分类调用诊断日志增强** — 在 `AiFoundationDelegate`、`AiFoundationClient`、`CommentPreFilterService` 中增加关键诊断日志(分类开始、fallback 触发、分类结果、异常详情),便于排查分类链路问题
|
||||
- **AI 分类空结果处理** — 当 AI 返回空字符串时单独拦截,区别于"服务不可用"场景
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1
|
||||
|
||||
> 2026-06-24
|
||||
|
||||
### 改进
|
||||
|
||||
- **"无意义"分类范围收窄** — 与文章主题无关的闲聊、灌水、打招呼不再被判为"无意义",仅纯乱码和无意义字符堆砌(如随机符号、键盘乱敲)才归类为"无意义"
|
||||
- **AI 分类降级方案** — 当 `OutputSpec.choice` 结构化输出不被模型支持时,自动退回到普通 chat 调用并从响应文本中提取分类值(`classifyWithChat` fallback)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
> 2026-06-18
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
||||
- 已发布的记录显示正常状态
|
||||
- 被拒绝的记录显示 REJECTED 标签
|
||||
- 失败的记录显示 FAIL 标签,并显示重试次数
|
||||
- 每条记录可点击 **查看对话** 查看完整对话上下文
|
||||
|
||||
## 对话上下文查看
|
||||
|
||||
点击日志记录的 **查看对话** 按钮,弹出对话上下文窗口:
|
||||
|
||||
- 以气泡形式展示完整对话(评论 + 所有回复)
|
||||
- AI 回复和用户回复以不同颜色气泡区分
|
||||
- 每条消息显示发送者头像(通过 Gravatar 服务生成)
|
||||
- 回复消息显示引用摘要框,标明该回复引用了哪条消息
|
||||
- 支持移动端响应式布局
|
||||
|
||||
## 批量操作
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
3. **已有AI回复记录** — 同一评论不会重复触发
|
||||
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
||||
5. **AI生成失败** — 检查AI模型配置和日志
|
||||
6. **被前置过滤拦截** — 若启用"前置过滤",违规评论会被拦截,可在日志页通过"已拦截"状态筛选查看
|
||||
|
||||
## 如何对历史评论触发AI回复?
|
||||
|
||||
@@ -66,3 +67,57 @@
|
||||
## 黑名单支持邮箱吗?
|
||||
|
||||
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
|
||||
|
||||
## 对话窗口中的头像是怎么来的?
|
||||
|
||||
对话窗口中每条消息的头像通过 [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 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。
|
||||
|
||||
## 所有评论都显示"AI分类服务不可用,安全拦截"怎么办?
|
||||
|
||||
这表示 AI 分类调用链路存在问题,可能的原因:
|
||||
|
||||
1. **AI Foundation 插件未安装或未启用** — 请确保 AI Foundation 插件已正确安装并启用
|
||||
2. **AI Foundation 中未配置模型** — 请在 AI Foundation 中配置至少一个 AI 模型
|
||||
3. **模型名称配置错误** — 检查插件设置中的模型名称是否与 AI Foundation 中的 AiModel 资源名称一致,留空则使用默认模型
|
||||
4. **AI Foundation 版本过旧** — 请确保使用最新版本的 AI Foundation 插件
|
||||
|
||||
::: tip 排查步骤
|
||||
1. 检查插件设置页面顶部的 AI Foundation 连接状态
|
||||
2. 查看插件日志中 `[Delegate]` 和 `[PreFilter]` 前缀的诊断信息
|
||||
3. 确认 AI 回复功能(非前置过滤)是否正常工作 — 如果 AI 回复也无法生成,说明是 AI Foundation 连接问题
|
||||
:::
|
||||
|
||||
## 被前置过滤拦截的评论会怎样?
|
||||
|
||||
1. **停止生成 AI 回复** — 不会消耗后续 Token
|
||||
2. **创建拦截记录** — 在日志页显示为"已拦截"状态,标注分类标签(如"辱骂攻击")和详细原因(含评论内容摘要)
|
||||
3. **自动设为待审核** — 原评论的 `approved` 会被置为 `false`,前端不再展示该评论,需人工判断后审核通过
|
||||
|
||||
## 被误拦截的评论怎么处理?
|
||||
|
||||
在日志页的"已拦截"记录右侧,点击 **误报反馈** 按钮,可选择:
|
||||
|
||||
- **AI 回复** — 标记为误报 + 自动通过评论 + 触发 AI 生成回复
|
||||
- **仅通过** — 仅标记为误报 + 自动通过评论,不生成 AI 回复
|
||||
|
||||
选择"仅通过"后,记录状态变为"误报通过",可随时点击 **触发AI回复** 按钮补生成 AI 回复。
|
||||
|
||||
@@ -15,19 +15,23 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
||||
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除
|
||||
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
|
||||
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式
|
||||
- **前置过滤(合规检测)** — AI回复前对评论进行合规性分类,自动拦截广告/辱骂/敏感/乱码内容,节省Token;可选将违规评论设为待审核状态
|
||||
- **误报反馈** — 被误拦截的评论可进行误报反馈,支持"AI回复"和"仅通过"两种处理方式,"仅通过"后可随时补触发 AI 回复
|
||||
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
|
||||
- **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
|
||||
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
||||
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
|
||||
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索
|
||||
- **对话上下文查看** — 在日志页面查看完整对话上下文,支持引用摘要展示和 Gravatar 头像显示
|
||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||
- **配置导入导出** — 支持将插件配置导出为 JSON 文件,方便备份和迁移
|
||||
- **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
新评论 → 唤醒词检查 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
||||
新评论 → 唤醒词检查 → 过滤检查 → 前置过滤(合规检测) → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
||||
↓ (失败)
|
||||
重试 → ... → 最终失败
|
||||
```
|
||||
@@ -35,12 +39,13 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
||||
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
||||
2. **唤醒词检查** — 检查评论是否以某个角色的唤醒词开头,匹配则唤醒对应角色
|
||||
3. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中(唤醒词触发时绕过页面级启用检查)
|
||||
4. **情感分析** — 调用AI分析评论情感倾向
|
||||
5. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
|
||||
6. **AI生成** — 调用AI模型生成回复内容
|
||||
7. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
|
||||
8. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
||||
9. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
|
||||
4. **前置过滤(合规检测)** — 若启用,AI 对评论内容进行合规性分类(正常/广告/辱骂攻击/敏感内容/无意义)。违规评论将停止后续流程,可选自动设为待审核状态
|
||||
5. **情感分析** — 调用AI分析评论情感倾向
|
||||
6. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
|
||||
7. **AI生成** — 调用AI模型生成回复内容
|
||||
8. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
|
||||
9. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
||||
10. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
|
||||
|
||||
## 前置要求
|
||||
|
||||
|
||||
@@ -37,3 +37,15 @@ POST /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/{replyN
|
||||
```
|
||||
|
||||
对指定回复触发对话式AI回复。
|
||||
|
||||
### 更新草稿回复内容
|
||||
|
||||
```
|
||||
PUT /apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/{name}/content
|
||||
```
|
||||
|
||||
更新草稿状态的AI回复内容。请求体为 JSON 格式:`{"reply": "新的回复内容"}`。仅未发布的草稿回复可编辑。
|
||||
|
||||
::: warning
|
||||
已发布的回复不可编辑。
|
||||
:::
|
||||
|
||||
@@ -49,8 +49,10 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
|
||||
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
|
||||
|
||||
- **非常正面** → 追加"评论者情绪非常正面积极,请用热情洋溢的语气回复,表达真诚的感谢和共鸣。"
|
||||
- **正面** → 追加"评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。"
|
||||
- **负面** → 追加"评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
|
||||
- **非常负面** → 追加"评论者情绪非常负面,请用非常温和、理性的语气回复,避免任何可能激化矛盾的表达,展现充分的理解和耐心。"
|
||||
- **中性** → 不追加额外提示
|
||||
|
||||
## 安全提示
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
|
||||
## 日志展示
|
||||
|
||||
在AI回复日志页面,每条记录会显示情感标签:
|
||||
在AI回复日志页面,每条记录会显示情感标签(纯色背景标签):
|
||||
|
||||
- 🟢 **非常正面** — 深绿色标签
|
||||
- 🟩 **正面** — 浅绿色标签
|
||||
- ⚪ **中性** — 灰色标签
|
||||
- 🟥 **负面** — 浅红色标签
|
||||
- 🔴 **非常负面** — 深红色标签
|
||||
- **非常正面** — 深绿色标签
|
||||
- **正面** — 浅绿色标签
|
||||
- **中性** — 灰色标签
|
||||
- **负面** — 浅红色标签
|
||||
- **非常负面** — 深红色标签
|
||||
|
||||
## 性能影响
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- Prompt设置
|
||||
- 数据清理
|
||||
|
||||
页面右侧为操作控制侧边栏,显示保存按钮和未保存状态指示器。在 Prompt 设置页面,侧边栏还会显示可用模板变量列表。
|
||||
|
||||
## 基本设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
@@ -18,6 +20,8 @@
|
||||
| 速率限制 | 每分钟最大AI回复数量,防止批量评论消耗过多额度 | 10 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式(`regex:` 开头),逗号分隔 | 空 |
|
||||
| 启用前置过滤 | AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token | 开启 |
|
||||
| 违规评论设为待审核 | 检测到违规评论时自动取消通过,需人工审核 | 开启 |
|
||||
|
||||
::: tip 评论者黑名单
|
||||
黑名单支持三种格式:
|
||||
@@ -28,6 +32,28 @@
|
||||
点击"添加评论者"按钮可从已有评论列表中选择评论者自动添加到黑名单。
|
||||
:::
|
||||
|
||||
::: tip 前置过滤(合规检测)
|
||||
启用前置过滤后,AI 在生成回复前会先对评论内容进行合规性分类,识别以下类别:
|
||||
|
||||
- **正常**:放行,继续走 AI 回复流程
|
||||
- **广告**:包含推广链接、产品推销、引流信息等
|
||||
- **辱骂攻击**:包含辱骂、人身攻击、恶意挑衅、歧视性言论等
|
||||
- **敏感内容**:涉及政治敏感、违法违规、色情暴力等
|
||||
- **无意义**:纯乱码、无意义字符堆砌(如随机符号、键盘乱敲)
|
||||
|
||||
对于非"正常"类别的评论,插件会:
|
||||
|
||||
1. **停止生成 AI 回复**,节省 Token 与 API 调用
|
||||
2. 创建一条 `FILTERED` 状态的日志记录(可在日志页通过"已拦截"状态筛选查看)
|
||||
3. 若启用"违规评论设为待审核",会自动将原评论的 `approved` 置为 `false`,使其进入待审核队列,需人工判断后审核通过
|
||||
|
||||
被误拦截的评论可在日志页点击 **误报反馈** 按钮处理,支持"AI 回复"和"仅通过"两种方式。选择"仅通过"后记录变为"误报通过"状态,可随时点击"触发AI回复"按钮补生成回复。
|
||||
|
||||
::: warning
|
||||
前置过滤依赖 AI Foundation 插件进行分类判断,会额外消耗少量 Token。若 AI 服务不可用或分类失败,为安全起见将拦截评论而非放行,防止违规内容漏网。
|
||||
:::
|
||||
:::
|
||||
|
||||
## AI角色设置
|
||||
|
||||
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。
|
||||
@@ -113,3 +139,21 @@ AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个
|
||||
::: warning
|
||||
清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。
|
||||
:::
|
||||
|
||||
## 配置导入导出
|
||||
|
||||
插件设置页面顶部提供导入导出按钮,方便备份和迁移配置。
|
||||
|
||||
### 导出配置
|
||||
|
||||
点击 **导出** 按钮,将当前配置(包括 ConfigMap 数据和所有 AI 角色)导出为 JSON 文件。
|
||||
|
||||
### 导入配置
|
||||
|
||||
1. 点击 **导入** 按钮,选择 JSON 配置文件
|
||||
2. 确认导入操作(导入会覆盖当前配置,不可撤销)
|
||||
3. 导入完成后自动刷新设置和角色列表
|
||||
|
||||
::: warning
|
||||
导入操作会覆盖当前配置,请谨慎操作。建议在导入前先导出当前配置作为备份。
|
||||
:::
|
||||
|
||||
+7
-5
@@ -16,14 +16,16 @@ hero:
|
||||
features:
|
||||
- title: 自动回复
|
||||
details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试
|
||||
- title: 多语言适配
|
||||
details: 根据评论语言自动用对应语言回复,中文评论中文回复,英文评论英文回复
|
||||
- title: 多 AI 角色
|
||||
details: 创建多个虚拟角色,独立昵称、人格、性别、语气和 Gravatar 头像
|
||||
- title: 情感分析
|
||||
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
|
||||
- title: 前置过滤
|
||||
details: AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token
|
||||
- title: 草稿模式
|
||||
details: AI回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||
- title: 灵活过滤
|
||||
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
||||
- title: 对话上下文
|
||||
details: 查看完整对话上下文,支持引用摘要展示和头像显示
|
||||
- title: 数据管理
|
||||
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
|
||||
details: 仪表盘统计、日志筛选搜索、自动清理旧记录、配置导入导出
|
||||
---
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
version=1.0.0-SNAPSHOT
|
||||
version=1.2.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;
|
||||
|
||||
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 run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.IndexSpecs;
|
||||
import run.halo.app.extension.Scheme;
|
||||
@@ -25,13 +29,18 @@ import reactor.core.publisher.Mono;
|
||||
@Component
|
||||
public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
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);
|
||||
this.schemeManager = schemeManager;
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,6 +63,49 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
|
||||
// 初始化默认AI角色"小回"
|
||||
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() {
|
||||
|
||||
+124
@@ -101,6 +101,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.POST("/import", this::importConfig)
|
||||
// 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展)
|
||||
.PUT("/replies/{name}/content", this::updateReplyContent)
|
||||
// 误报反馈:将拦截记录标记为误报,可选触发AI回复
|
||||
.POST("/replies/{name}/false-positive", this::falsePositive)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1035,4 +1037,126 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 误报反馈:将被拦截的评论标记为误报(正常),并可选触发 AI 回复。
|
||||
*
|
||||
* 请求体:{ "action": "aiReply" | "approveOnly" }
|
||||
* - aiReply: 将评论审核状态设为已通过 + 触发 AI 生成回复
|
||||
* - approveOnly: 仅将评论审核状态设为已通过,不触发 AI 回复
|
||||
*/
|
||||
private Mono<ServerResponse> falsePositive(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
String actionStr;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
actionStr = node.has("action") ? node.get("action").asText("approveOnly") : "approveOnly";
|
||||
} catch (Exception e) {
|
||||
actionStr = "approveOnly";
|
||||
}
|
||||
final String action = actionStr;
|
||||
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
if (!"FILTERED".equals(record.getSpec().getStatus())
|
||||
&& !"FALSE_POSITIVE".equals(record.getSpec().getStatus())) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("message", "仅已拦截或误报通过状态的记录可进行误报反馈"));
|
||||
}
|
||||
|
||||
String commentName = record.getSpec().getCommentId();
|
||||
String replyName = record.getSpec().getReplyTo();
|
||||
|
||||
// 1. 将原评论/回复的审核状态设为已通过
|
||||
Mono<Void> approveMono = approveOriginalComment(commentName, replyName);
|
||||
|
||||
// 2. 更新 AiCommentReply 记录状态
|
||||
Mono<Void> updateRecordMono = Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setFilterCategory("误报");
|
||||
latest.getSpec().setFilterReason("用户确认为误报,已通过");
|
||||
if ("aiReply".equals(action)) {
|
||||
latest.getSpec().setStatus("PENDING");
|
||||
latest.getSpec().setReply("");
|
||||
} else {
|
||||
// 仅通过:使用 FALSE_POSITIVE 状态,区别于 PASS
|
||||
// 避免前端显示"通过/拒绝"按钮和"未发布"标签
|
||||
latest.getSpec().setStatus("FALSE_POSITIVE");
|
||||
latest.getSpec().setPublished(false);
|
||||
}
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then());
|
||||
|
||||
// 3. 异步触发 AI 回复(不阻塞 HTTP 响应)
|
||||
// 使用 processFalsePositive 跳过前置过滤和去重检查
|
||||
if ("aiReply".equals(action)) {
|
||||
boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation());
|
||||
String recordName = record.getMetadata().getName();
|
||||
personaResolver.getPersonaNameFromComment(commentName)
|
||||
.flatMap(personaName ->
|
||||
orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName)
|
||||
)
|
||||
.subscribe(
|
||||
null,
|
||||
err -> log.warn("[FalsePositive] AI reply trigger failed for {}: {}", commentName, err.getMessage()),
|
||||
() -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName)
|
||||
);
|
||||
}
|
||||
|
||||
return approveMono
|
||||
.then(updateRecordMono)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of(
|
||||
"message", "aiReply".equals(action) ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过"
|
||||
)));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将被拦截评论的原 Comment 或 Reply 审核状态设为已通过。
|
||||
*/
|
||||
private Mono<Void> approveOriginalComment(String commentName, String replyName) {
|
||||
// 优先处理 Reply(AI 对话场景下违规内容来自 Reply)
|
||||
if (replyName != null && !replyName.isBlank()) {
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> {
|
||||
var spec = reply.getSpec();
|
||||
if (spec != null && !Boolean.TRUE.equals(spec.getApproved())) {
|
||||
spec.setApproved(true);
|
||||
spec.setApprovedTime(Instant.now());
|
||||
return client.update(reply)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.doOnSuccess(r -> log.info("[FalsePositive] Reply {} approved", replyName))
|
||||
.then();
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> approveComment(commentName)));
|
||||
}
|
||||
return approveComment(commentName);
|
||||
}
|
||||
|
||||
private Mono<Void> approveComment(String commentName) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
var spec = comment.getSpec();
|
||||
if (spec != null && !Boolean.TRUE.equals(spec.getApproved())) {
|
||||
spec.setApproved(true);
|
||||
spec.setApprovedTime(Instant.now());
|
||||
return client.update(comment)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.doOnSuccess(c -> log.info("[FalsePositive] Comment {} approved", commentName))
|
||||
.then();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,5 +65,11 @@ public class AiCommentReply extends AbstractExtension {
|
||||
|
||||
@Schema(description = "已发布的回复名称")
|
||||
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 org.springframework.stereotype.Component;
|
||||
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 client that uses Halo's {@link ExtensionGetter} to obtain the
|
||||
* {@link AiModelService} extension provided by the ai-foundation plugin.
|
||||
* <p>
|
||||
* This is the recommended way to integrate with AI Foundation, see
|
||||
* <a href="https://github.com/halo-dev/plugin-ai-foundation/blob/main/dev/dev.md">dev guide</a>.
|
||||
* <p>
|
||||
* Requires the following declaration in plugin.yaml:
|
||||
* AI Foundation 客户端,通过 Halo 的 {@link ExtensionGetter} 获取 AI 服务。
|
||||
*
|
||||
* <p>此类不直接引用任何 AI Foundation API 类(AiModelService、GenerateTextRequest 等),
|
||||
* 所有 AI Foundation 交互委托给 {@link AiFoundationDelegate}。
|
||||
* 当 AI Foundation 插件未安装时,{@link AiFoundationDelegate} 的类加载会触发
|
||||
* {@link NoClassDefFoundError},在 {@code Mono.defer()} 中被捕获,
|
||||
* 保证插件在无 AI Foundation 环境下仍可正常启动。
|
||||
*
|
||||
* <p>需要在 plugin.yaml 中声明可选依赖:
|
||||
* <pre>
|
||||
* spec:
|
||||
* pluginDependencies:
|
||||
* ai-foundation?: "*"
|
||||
* </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
|
||||
@Component
|
||||
@@ -39,90 +34,68 @@ public class AiFoundationClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI Foundation to generate a chat response using the specified model.
|
||||
* Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that
|
||||
* transient model errors are retried by the SDK.
|
||||
* 调用 AI Foundation 生成聊天回复。
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||
* @return the generated text, or empty if AI Foundation is unavailable
|
||||
* @param prompt 提示词文本
|
||||
* @param modelName AiModel metadata.name,null 或空则使用默认模型
|
||||
* @return 生成的文本,AI Foundation 不可用时返回 empty
|
||||
*/
|
||||
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(() -> {
|
||||
try {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class);
|
||||
return AiFoundationDelegate.chat(extensionGetter, prompt, 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 进行文本分类,使用结构化输出(OutputSpec.choice)。
|
||||
*
|
||||
* @param systemPrompt 系统提示词
|
||||
* @param userPrompt 待分类的用户输入
|
||||
* @param choices 允许的分类值列表
|
||||
* @param modelName AiModel metadata.name,null 或空则使用默认模型
|
||||
* @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.warn("[Client] AI Foundation API not on classpath (classify): {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
}
|
||||
})
|
||||
.onErrorResume(NoClassDefFoundError.class, e -> {
|
||||
log.warn("[Client] AI Foundation NoClassDefFoundError during classify: {}", 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,156 @@
|
||||
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("[Delegate] chat call failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Delegate] chat not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 进行文本分类。
|
||||
* 优先使用 OutputSpec.choice 结构化输出,失败时退回到普通 chat 并解析响应。
|
||||
*/
|
||||
static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt,
|
||||
String userPrompt, List<String> choices, String modelName) {
|
||||
log.info("[Delegate] Starting classification, modelName='{}'", modelName);
|
||||
return classifyWithChoice(extensionGetter, systemPrompt, userPrompt, choices, modelName)
|
||||
.switchIfEmpty(
|
||||
Mono.defer(() -> {
|
||||
log.info("[Delegate] classifyWithChoice returned empty, falling back to classifyWithChat");
|
||||
return classifyWithChat(extensionGetter, systemPrompt, userPrompt, choices, modelName);
|
||||
})
|
||||
)
|
||||
.doOnNext(result -> log.info("[Delegate] Classification succeeded: '{}'", result))
|
||||
.doOnSuccess(result -> {
|
||||
if (result == null) {
|
||||
log.warn("[Delegate] Classification completed with no result (both methods returned empty)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 OutputSpec.choice 结构化输出分类(部分模型不支持)。
|
||||
* 注意:不使用 system() 方法,因为部分 AI Foundation 版本可能不支持,
|
||||
* 将 system prompt 合并到 user prompt 中。
|
||||
*/
|
||||
private static Mono<String> classifyWithChoice(ExtensionGetter extensionGetter, String systemPrompt,
|
||||
String userPrompt, List<String> choices, String modelName) {
|
||||
// 合并 system prompt 和 user prompt,避免使用 system() 方法
|
||||
String combinedPrompt = systemPrompt + "\n\n" + userPrompt;
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
|
||||
.flatMap(model -> model.generateText(
|
||||
GenerateTextRequest.builder()
|
||||
.prompt(combinedPrompt)
|
||||
.output(OutputSpec.choice(choices))
|
||||
.maxRetries(2)
|
||||
.build()))
|
||||
.flatMap(result -> {
|
||||
Object output = result.getOutput();
|
||||
if (output != null) {
|
||||
String value = String.valueOf(output).trim();
|
||||
if (!value.isEmpty()) {
|
||||
log.debug("[Delegate] classifyWithChoice got output: '{}'", value);
|
||||
return Mono.just(value);
|
||||
}
|
||||
}
|
||||
// output 为空可能是模型不支持结构化输出,返回 empty 触发 fallback
|
||||
log.info("[Delegate] classifyWithChoice: output is null/empty, triggering fallback");
|
||||
return Mono.empty();
|
||||
}))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Delegate] classifyWithChoice failed, will fallback to chat: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用普通 chat 调用进行分类,从响应文本中提取匹配的分类值。
|
||||
* 作为 OutputSpec.choice 不可用时的降级方案。
|
||||
* 不使用 system() 方法,将 system prompt 合并到 user prompt 中,
|
||||
* 与可用的 chat() 方法保持一致的调用方式。
|
||||
*/
|
||||
private static Mono<String> classifyWithChat(ExtensionGetter extensionGetter, String systemPrompt,
|
||||
String userPrompt, List<String> choices, String modelName) {
|
||||
// 合并 system prompt 和 user prompt,与 chat() 方法保持一致的调用方式
|
||||
String combinedPrompt = systemPrompt + "\n\n" + userPrompt;
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
|
||||
.flatMap(model -> model.generateText(
|
||||
GenerateTextRequest.builder()
|
||||
.prompt(combinedPrompt)
|
||||
.maxRetries(2)
|
||||
.build()))
|
||||
.map(GenerateTextResult::getText)
|
||||
.map(text -> extractChoice(text, choices)))
|
||||
.doOnError(e -> log.error("[Delegate] classifyWithChat failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Delegate] classifyWithChat error: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 chat 响应文本中提取匹配的分类值。
|
||||
* 优先精确匹配,其次包含匹配。
|
||||
* 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。
|
||||
*/
|
||||
static String extractChoice(String text, List<String> choices) {
|
||||
if (text == null || text.isBlank()) return "";
|
||||
String trimmed = text.trim();
|
||||
// 精确匹配
|
||||
for (String choice : choices) {
|
||||
if (trimmed.equals(choice)) return choice;
|
||||
}
|
||||
// 包含匹配(响应可能包含额外文字,如"该评论属于:广告")
|
||||
for (String choice : choices) {
|
||||
if (trimmed.contains(choice)) return choice;
|
||||
}
|
||||
// 无匹配,返回空字符串触发安全拦截
|
||||
log.warn("[Delegate] No matching choice found in response: '{}', returning empty for safety", trimmed);
|
||||
return "";
|
||||
}
|
||||
|
||||
static Mono<Boolean> isAvailable(ExtensionGetter extensionGetter) {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.hasElement()
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Delegate] AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class AiReplyOrchestrator {
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final FilterService filterService;
|
||||
private final RateLimitService rateLimitService;
|
||||
private final CommentPreFilterService preFilterService;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -49,6 +50,7 @@ public class AiReplyOrchestrator {
|
||||
CommentReplyPublisher commentReplyPublisher,
|
||||
FilterService filterService,
|
||||
RateLimitService rateLimitService,
|
||||
CommentPreFilterService preFilterService,
|
||||
ReactiveExtensionClient client,
|
||||
ObjectMapper objectMapper) {
|
||||
this.contextExtractor = contextExtractor;
|
||||
@@ -59,6 +61,7 @@ public class AiReplyOrchestrator {
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.filterService = filterService;
|
||||
this.rateLimitService = rateLimitService;
|
||||
this.preFilterService = preFilterService;
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -102,7 +105,7 @@ public class AiReplyOrchestrator {
|
||||
}
|
||||
// Wake word triggered: skip page-level annotation check
|
||||
if (wakeWordTriggered) {
|
||||
return checkBlockedCommenters(commentName)
|
||||
return filterService.isCommenterBlocked(commentName)
|
||||
.flatMap(blocked -> {
|
||||
if (blocked) {
|
||||
log.info("[Orchestrator] Commenter blocked, skipping wake word: {}", commentName);
|
||||
@@ -130,6 +133,45 @@ public class AiReplyOrchestrator {
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* 误报反馈专用:跳过前置过滤和去重检查,直接为已确认误报的评论生成 AI 回复。
|
||||
*
|
||||
* <p>与 {@link #processComment} 不同,此方法:
|
||||
* <ul>
|
||||
* <li>跳过前置过滤(用户已确认评论合规)</li>
|
||||
* <li>跳过去重检查(已有 FILTERED 记录,需复用)</li>
|
||||
* <li>跳过速率限制和黑名单检查(管理员主动操作)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param commentName the parent Comment name
|
||||
* @param replyName the Reply name (null for top-level comments)
|
||||
* @param isAiConversation true when this is a conversation continuation
|
||||
* @param personaName the persona name to use
|
||||
* @param recordName the existing AiCommentReply record name to update
|
||||
*/
|
||||
public Mono<Void> processFalsePositive(String commentName, String replyName,
|
||||
boolean isAiConversation, String personaName,
|
||||
String recordName) {
|
||||
log.info("[Orchestrator] Processing false-positive: comment={}, record={}", commentName, recordName);
|
||||
|
||||
return getModelName().flatMap(modelName ->
|
||||
contextExtractor.extract(commentName, replyName, isAiConversation)
|
||||
.flatMap(context -> {
|
||||
// Fetch the existing record (was FILTERED, now PENDING)
|
||||
return client.fetch(AiCommentReply.class, recordName)
|
||||
.flatMap(replyRecord ->
|
||||
sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
||||
.flatMap(sentimentResult ->
|
||||
promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName)
|
||||
.flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName, personaName))
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
.doOnError(e -> log.error("[Orchestrator] Error processing false-positive {}: {}", commentName, e.getMessage(), e))
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceed with processing after all checks have passed.
|
||||
* Handles dedup checks and conversation round limits.
|
||||
@@ -168,55 +210,29 @@ public class AiReplyOrchestrator {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the commenter is in the blocked list.
|
||||
*/
|
||||
private Mono<Boolean> checkBlockedCommenters(String commentName) {
|
||||
return client.fetch(run.halo.app.core.extension.content.Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner == null) return Mono.just(false);
|
||||
String displayName = owner.getDisplayName();
|
||||
String email = run.halo.app.core.extension.content.Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())
|
||||
? owner.getName() : "";
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return false;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
String blockedStr = node.has("blockedCommenters") ? node.get("blockedCommenters").asText("") : "";
|
||||
if (blockedStr.isBlank()) return false;
|
||||
for (String item : blockedStr.split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (!trimmed.isEmpty() && (trimmed.equalsIgnoreCase(displayName) || trimmed.equalsIgnoreCase(email))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
|
||||
String personaName) {
|
||||
return getModelName().flatMap(modelName ->
|
||||
contextExtractor.extract(commentName, replyName, isAiConversation)
|
||||
.flatMap(context -> 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))
|
||||
);
|
||||
.flatMap(context -> preFilterService.check(context.commentContent(), modelName)
|
||||
.flatMap(preFilterResult -> {
|
||||
if (!preFilterResult.passed()) {
|
||||
log.warn("[Orchestrator] Comment pre-filtered: {}, reason: {}",
|
||||
commentName, preFilterResult.reason());
|
||||
// 创建拦截记录并执行处罚(针对实际违规的 Comment 或 Reply)
|
||||
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 +590,34 @@ public class AiReplyOrchestrator {
|
||||
.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,
|
||||
String personaName) {
|
||||
AiCommentReply record = new AiCommentReply();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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)
|
||||
.doOnNext(result -> log.info("[PreFilter] AI classify returned: '{}'", result))
|
||||
.map(result -> {
|
||||
if (CLEAN.equals(result)) {
|
||||
log.info("[PreFilter] Comment passed: category={}", result);
|
||||
return new PreFilterResult(true, CLEAN, "评论合规");
|
||||
}
|
||||
// 空结果视为分类失败
|
||||
if (result == null || result.isBlank()) {
|
||||
log.warn("[PreFilter] AI classify returned empty/blank result, blocking for safety");
|
||||
return new PreFilterResult(false, MEANINGLESS, "AI分类返回空结果,安全拦截");
|
||||
}
|
||||
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(), e);
|
||||
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();
|
||||
}
|
||||
// 优先处理 Reply:AI 对话场景下违规内容来自 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) {}
|
||||
}
|
||||
@@ -58,6 +58,19 @@ public class FilterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查评论者是否在黑名单中(按 commentName 查询)。
|
||||
*/
|
||||
public Mono<Boolean> isCommenterBlocked(String commentName) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(this::checkBlockedCommenters)
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Filter] Error checking blocked commenter: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkBlockedCommenters(Comment comment) {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
|
||||
@@ -40,6 +40,16 @@ spec:
|
||||
label: 评论者黑名单
|
||||
help: "输入评论者显示名称或邮箱,多个用逗号分隔。支持正则表达式,以 regex: 开头,如 regex:^spam.*"
|
||||
value: ""
|
||||
- $formkit: switch
|
||||
name: preFilterEnabled
|
||||
label: 启用前置过滤
|
||||
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
|
||||
value: true
|
||||
- $formkit: switch
|
||||
name: preFilterPendingOnViolation
|
||||
label: 违规评论设为待审核
|
||||
help: "检测到违规评论时自动取消通过,需人工审核"
|
||||
value: true
|
||||
- group: model
|
||||
label: 模型设置
|
||||
formSchema:
|
||||
|
||||
@@ -30,4 +30,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: "1.0.4"
|
||||
version: "1.2.0"
|
||||
|
||||
+29
-11
@@ -5,16 +5,33 @@ plugins {
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot.ui'
|
||||
|
||||
// Fix Gradle 9.x compatibility with pnpm symlinks
|
||||
tasks.named('pnpmInstall') {
|
||||
doNotTrackState("pnpm symlinks are not compatible with Gradle state tracking")
|
||||
// Use system pnpm directly — avoids Windows exit code 268435659
|
||||
// caused by Gradle Worker Daemon / node-gradle downloading pnpm on Windows
|
||||
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'
|
||||
description = 'Build the UI project using pnpm'
|
||||
args = ['build']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
workingDir layout.projectDirectory
|
||||
commandLine(pnpmCmd + ['run', 'build'])
|
||||
dependsOn uiInstall
|
||||
inputs.dir(layout.projectDirectory.dir('src'))
|
||||
inputs.files(fileTree(
|
||||
dir: layout.projectDirectory,
|
||||
@@ -22,17 +39,18 @@ tasks.register('pnpmBuild', PnpmTask) {
|
||||
outputs.dir(layout.buildDirectory.dir('dist'))
|
||||
}
|
||||
|
||||
tasks.register('pnpmCheck', PnpmTask) {
|
||||
tasks.register('uiCheck', Exec) {
|
||||
group = 'verification'
|
||||
description = 'Run unit tests for the UI project using pnpm'
|
||||
args = ['test:unit']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
workingDir layout.projectDirectory
|
||||
commandLine(pnpmCmd + ['run', 'test:unit'])
|
||||
dependsOn uiInstall
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('pnpmCheck')
|
||||
dependsOn tasks.named('uiCheck')
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('pnpmBuild')
|
||||
dependsOn tasks.named('uiBuild')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 统一 API 路径常量,避免硬编码散布在多个 Vue 文件中。
|
||||
* 修改 API 路径只需在此处更新。
|
||||
*/
|
||||
export const API_BASE = '/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1'
|
||||
|
||||
// ===== 日志相关 =====
|
||||
export const API_REPLIES = `${API_BASE}/replies`
|
||||
export const apiReply = (name: string) => `${API_REPLIES}/${name}`
|
||||
export const apiReplyAction = (name: string, action: string) => `${API_REPLIES}/${name}/${action}`
|
||||
export const API_BATCH_APPROVE = `${API_REPLIES}/batch-approve`
|
||||
export const API_BATCH_REJECT = `${API_REPLIES}/batch-reject`
|
||||
export const API_BATCH_DELETE = `${API_REPLIES}/batch-delete`
|
||||
export const apiConversation = (commentId: string) => `${API_BASE}/conversation/${commentId}`
|
||||
|
||||
// ===== 概览相关 =====
|
||||
export const API_STATS = `${API_BASE}/stats`
|
||||
export const API_PERSONAS = `${API_BASE}/personas`
|
||||
export const apiPersona = (name: string) => `${API_PERSONAS}/${name}`
|
||||
export const API_HEALTH = `${API_BASE}/health`
|
||||
|
||||
// ===== 设置相关 =====
|
||||
export const API_EXPORT = `${API_BASE}/export`
|
||||
export const API_IMPORT = `${API_BASE}/import`
|
||||
export const API_COMMENTERS = `${API_BASE}/commenters`
|
||||
export const API_CLEANUP = `${API_BASE}/cleanup`
|
||||
|
||||
// ===== 评论触发 =====
|
||||
export const apiCommentTrigger = (commentName: string) => `${API_BASE}/comments/${commentName}/trigger`
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function computeGravatarHash(email: string): Promise<string> {
|
||||
if (!email || email.trim() === '') return ''
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(normalizedEmail)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function getGravatarUrl(hash: string): string {
|
||||
if (!hash) return ''
|
||||
return `https://cn.cravatar.com/avatar/${hash}`
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
<option value="FAIL">失败</option>
|
||||
<option value="PENDING">待审核</option>
|
||||
<option value="REJECTED">已拒绝</option>
|
||||
<option value="FILTERED">已拦截</option>
|
||||
<option value="FALSE_POSITIVE">误报通过</option>
|
||||
</select>
|
||||
<select v-model="filterSentiment" class="filter-select">
|
||||
<option value="">全部情感</option>
|
||||
@@ -64,6 +66,18 @@
|
||||
<span class="card-time">{{ formatDate(reply.metadata.creationTimestamp) }}</span>
|
||||
</div>
|
||||
<div class="card-text">{{ stripHtml(reply.spec.reply) || '(空)' }}</div>
|
||||
<div v-if="reply.spec.status === 'FILTERED'" class="card-filter-reason">
|
||||
<svg class="filter-icon" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
|
||||
<span class="filter-category" v-if="reply.spec.filterCategory">{{ reply.spec.filterCategory }}</span>
|
||||
<span class="filter-detail">{{ reply.spec.filterReason || '未提供具体原因' }}</span>
|
||||
<button class="btn-false-positive" @click="openFalsePositiveDialog(reply)">误报反馈</button>
|
||||
</div>
|
||||
<div v-if="reply.spec.status === 'FALSE_POSITIVE'" class="card-filter-reason">
|
||||
<svg class="filter-icon fp-icon-ok" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<span class="filter-category">误报</span>
|
||||
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
|
||||
<button class="btn-trigger-ai" @click="handleTriggerAiReply(reply)">触发AI回复</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
@@ -131,6 +145,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- 误报反馈确认弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="showFalsePositiveDialog" class="dialog-overlay" @click.self="showFalsePositiveDialog = false">
|
||||
<div class="dialog-box fp-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3>确认为误报?</h3>
|
||||
<button class="close-btn" @click="showFalsePositiveDialog = false"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="fp-dialog-body">
|
||||
<p class="fp-desc">系统检测到该评论可能包含违规内容,但您认为这是正常表达。请选择处理方式:</p>
|
||||
<div class="fp-actions">
|
||||
<button class="fp-btn fp-btn-primary" :disabled="fpLoading" @click="handleFalsePositive('aiReply')">
|
||||
<span v-if="fpLoading" class="fp-spinner"></span>
|
||||
AI 回复
|
||||
</button>
|
||||
<button class="fp-btn fp-btn-secondary" :disabled="fpLoading" @click="handleFalsePositive('approveOnly')">
|
||||
仅通过
|
||||
</button>
|
||||
<button class="fp-btn fp-btn-ghost" :disabled="fpLoading" @click="showFalsePositiveDialog = false">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -143,10 +184,11 @@ import { IconPlug } from "@halo-dev/components"
|
||||
interface AiCommentReplyItem { metadata: { name: string; creationTimestamp: string }; spec: any }
|
||||
interface ConversationMessage { type: string; owner: string; content: string; time: string; isAi: boolean; quoteOwner?: string; quoteContent?: string }
|
||||
|
||||
const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0);
|
||||
const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const batchLoading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0);
|
||||
const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
|
||||
const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
|
||||
const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
|
||||
const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
|
||||
|
||||
const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
|
||||
const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
|
||||
@@ -172,11 +214,11 @@ const openConversation = async (reply: AiCommentReplyItem) => {
|
||||
const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } }
|
||||
const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } }
|
||||
const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } }
|
||||
const batchApprove = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
|
||||
const batchReject = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
|
||||
const batchDelete = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
|
||||
const batchApprove = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
|
||||
const batchReject = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
|
||||
const batchDelete = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
|
||||
|
||||
const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝' }; return m[s] || s }
|
||||
const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝', FILTERED: '已拦截', FALSE_POSITIVE: '误报通过' }; return m[s] || s }
|
||||
const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s }
|
||||
const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : ""
|
||||
const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}`
|
||||
@@ -197,6 +239,30 @@ const renderContent = (content: string) => {
|
||||
}
|
||||
|
||||
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
|
||||
|
||||
const openFalsePositiveDialog = (reply: AiCommentReplyItem) => { falsePositiveTarget.value = reply; showFalsePositiveDialog.value = true }
|
||||
const handleFalsePositive = async (action: string) => {
|
||||
if (!falsePositiveTarget.value) return
|
||||
fpLoading.value = true
|
||||
try {
|
||||
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${falsePositiveTarget.value.metadata.name}/false-positive`, { action })
|
||||
Toast.success(action === "aiReply" ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过")
|
||||
showFalsePositiveDialog.value = false
|
||||
falsePositiveTarget.value = null
|
||||
fetchReplies()
|
||||
} catch (e: any) {
|
||||
Toast.error(e?.response?.data?.message || "操作失败")
|
||||
} finally { fpLoading.value = false }
|
||||
}
|
||||
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
|
||||
try {
|
||||
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
|
||||
Toast.success("AI回复正在后台生成")
|
||||
fetchReplies()
|
||||
} catch (e: any) {
|
||||
Toast.error(e?.response?.data?.message || "触发失败")
|
||||
}
|
||||
}
|
||||
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
|
||||
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
|
||||
onMounted(fetchReplies)
|
||||
@@ -236,6 +302,15 @@ onMounted(fetchReplies)
|
||||
.tags-wrap { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.card-time { font-size: 12px; color: #9ca3af; }
|
||||
.card-text { font-size: 14px; color: #374151; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-filter-reason { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; padding: 6px 10px; background: #fef3c7; border: 1px solid #fde68a; border-radius: 6px; font-size: 12px; color: #92400e; }
|
||||
.filter-icon { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||
.filter-category { flex-shrink: 0; padding: 1px 6px; background: #b45309; color: #fff; border-radius: 3px; font-weight: 600; font-size: 11px; line-height: 1.5; }
|
||||
.filter-detail { flex: 1; line-height: 1.5; }
|
||||
.btn-false-positive { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #b45309; border-radius: 4px; background: transparent; color: #b45309; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
|
||||
.btn-false-positive:hover { background: #b45309; color: #fff; }
|
||||
.fp-icon-ok { color: #16a34a; }
|
||||
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
|
||||
.btn-trigger-ai:hover { background: #2563eb; color: #fff; }
|
||||
.card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
|
||||
@media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } }
|
||||
.footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
@@ -251,7 +326,7 @@ onMounted(fetchReplies)
|
||||
|
||||
/* 标签体系 */
|
||||
.custom-tag { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; }
|
||||
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; }
|
||||
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; } .tag-FILTERED { background: #f1f5f9; color: #b45309; border: 1px solid #fde68a; } .tag-FALSE_POSITIVE { background: #dbeafe; color: #1d4ed8; border: 1px solid #93c5fd; }
|
||||
.tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; }
|
||||
.tag-conv { background: #f3e8ff; color: #7e22ce; }
|
||||
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
|
||||
@@ -297,4 +372,20 @@ onMounted(fetchReplies)
|
||||
.pagination { display: flex; flex-direction: column; gap: 12px; align-items: center; margin-top: 20px; font-size: 14px; color: #6b7280; }
|
||||
@media (min-width: 640px) { .pagination { flex-direction: row; justify-content: space-between; } }
|
||||
.pagination-btns { display: flex; gap: 8px; }
|
||||
|
||||
/* 误报反馈弹窗 */
|
||||
.fp-dialog { max-width: 440px; }
|
||||
.fp-dialog-body { padding: 24px; }
|
||||
.fp-desc { margin: 0 0 20px; font-size: 14px; color: #4b5563; line-height: 1.6; }
|
||||
.fp-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||
.fp-btn { padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; transition: all 0.15s; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.fp-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.fp-btn-primary { background: #2563eb; color: #fff; }
|
||||
.fp-btn-primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.fp-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
|
||||
.fp-btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
|
||||
.fp-btn-ghost { background: transparent; color: #9ca3af; }
|
||||
.fp-btn-ghost:hover:not(:disabled) { color: #6b7280; background: #f9fafb; }
|
||||
.fp-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: fp-spin 0.6s linear infinite; }
|
||||
@keyframes fp-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,14 @@
|
||||
<div class="form-field__header"><span class="form-label">评论者黑名单</span><button class="btn-link" @click="openCommenterDialog">添加评论者</button></div>
|
||||
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row__label"><span class="form-label">启用前置过滤</span><span class="form-hint">AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token</span></div>
|
||||
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过,需人工审核</span></div>
|
||||
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,10 +252,14 @@ const tabItems = [
|
||||
]
|
||||
|
||||
const promptVariables = [
|
||||
{ name: '{{persona_prompt}}', desc: '角色设定' },
|
||||
{ name: '{{comment}}', desc: '评论内容' },
|
||||
{ name: '{{article}}', desc: '文章内容' },
|
||||
{ name: '{{conversation_history}}', desc: '对话历史' },
|
||||
{ name: '{{persona_prompt}}', desc: 'AI角色人格提示词(含已启用的预设)' },
|
||||
{ name: '{{safety_prompt}}', desc: '安全规范提示词' },
|
||||
{ name: '{{post_title}}', desc: '文章标题' },
|
||||
{ name: '{{post_date}}', desc: '文章发布日期' },
|
||||
{ name: '{{comment_count}}', desc: '该文章的评论数' },
|
||||
{ name: '{{article}}', desc: '文章/页面内容(含标题)' },
|
||||
{ name: '{{conversation_history}}', desc: '对话历史上下文' },
|
||||
{ name: '{{comment}}', desc: '评论内容(含评论者名称)' },
|
||||
]
|
||||
|
||||
const promptPresets = [
|
||||
@@ -258,7 +270,7 @@ const promptPresets = [
|
||||
]
|
||||
|
||||
const settings = reactive({
|
||||
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10 },
|
||||
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", maxConversationRounds: 8, rateLimitPerMinute: 10, preFilterEnabled: true, preFilterPendingOnViolation: true },
|
||||
model: { modelName: "" },
|
||||
prompt: { customPromptTemplate: "", enabledPresets: [] as string[] },
|
||||
cleanup: { cleanupEnabled: true, retentionDays: 30 },
|
||||
|
||||
Reference in New Issue
Block a user