feat: 增强插件可靠性与可用性
- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读 - 实现失败重试机制,指数退避策略 - 评论者黑名单支持邮箱匹配,可从评论列表选择 - 日志页面支持按状态/情感筛选和关键词搜索 - AI角色邮箱配置增加 Gravatar 头像实时预览 - 插件依赖声明修正:ai-foundation 改为必须依赖 - 新增定时清理旧记录功能,可配置保留天数 - 更新 README 和 VitePress 文档
This commit is contained in:
@@ -1,35 +1,65 @@
|
|||||||
# comment-ai-autopilot
|
# AI回评 / Comment AI Autopilot
|
||||||
|
|
||||||
comment-ai-autopilot - Halo 插件
|
基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复。
|
||||||
|
|
||||||
## 简介
|
## 功能特性
|
||||||
|
|
||||||
这是一个基于 Halo 的插件项目。
|
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
||||||
|
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||||
|
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
||||||
|
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||||
|
- **失败重试** — AI生成失败时自动重试,指数退避策略
|
||||||
|
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
|
||||||
|
- **评论者黑名单** — 支持按名称和邮箱屏蔽指定评论者,可从评论列表选择
|
||||||
|
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
|
||||||
|
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,设置页面实时预览头像
|
||||||
|
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
|
||||||
|
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表
|
||||||
|
- **日志筛选** — 按状态、情感筛选,关键词搜索
|
||||||
|
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||||
|
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
|
||||||
|
|
||||||
## 开发环境
|
## 前置要求
|
||||||
|
|
||||||
- Java 21+
|
- Halo 2.23+
|
||||||
- Node.js 18+
|
- AI Foundation 插件(必须)
|
||||||
- pnpm
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. 前往 [Releases](https://github.com/暖心向阳335/comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
||||||
|
2. 登录 Halo 管理后台
|
||||||
|
3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮
|
||||||
|
4. 选择下载的 `.jar` 文件上传
|
||||||
|
5. 安装完成后启用插件
|
||||||
|
|
||||||
|
## 从源码构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/暖心向阳335/comment-ai-autopilot.git
|
||||||
|
cd plugin-comment-ai-autopilot
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
./gradlew build -x test
|
||||||
|
|
||||||
|
# 构建产物位于 build/libs/ 目录
|
||||||
|
```
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启用插件
|
# 启用插件开发服务器
|
||||||
./gradlew haloServer
|
./gradlew haloServer
|
||||||
|
|
||||||
# 开发前端
|
# 开发前端
|
||||||
cd ui
|
cd ui
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 构建
|
## 文档
|
||||||
|
|
||||||
```bash
|
完整文档请访问 [AI回评文档站](https://暖心向阳335.github.io/comment-ai-autopilot/)
|
||||||
./gradlew build
|
|
||||||
```
|
|
||||||
|
|
||||||
构建完成后,可以在 `build/libs` 目录找到插件 jar 文件。
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
{ text: "情感分析", link: "/guide/sentiment" },
|
{ text: "情感分析", link: "/guide/sentiment" },
|
||||||
{ text: "过滤规则", link: "/guide/filter" },
|
{ text: "过滤规则", link: "/guide/filter" },
|
||||||
{ text: "手动触发", link: "/guide/manual-trigger" },
|
{ text: "手动触发", link: "/guide/manual-trigger" },
|
||||||
|
{ text: "数据清理", link: "/guide/cleanup" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,3 +32,16 @@
|
|||||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||||
|
|
||||||
|
## 重试机制
|
||||||
|
|
||||||
|
当AI生成失败(如服务不可用、生成空内容、审核不通过)时,插件会自动重试:
|
||||||
|
|
||||||
|
1. 每次重试递增 `retryCount`
|
||||||
|
2. 重试间隔采用指数退避策略:第1次等5秒,第2次等15秒,第3次等30秒
|
||||||
|
3. 超过最大重试次数后标记为最终失败(FAIL)
|
||||||
|
4. 重试期间记录状态为 PENDING
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
重试次数由 **最大重试次数** 配置项控制,默认为3次。
|
||||||
|
:::
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 数据清理
|
||||||
|
|
||||||
|
数据清理功能可以自动删除过期的AI回复记录,防止数据库无限增长。
|
||||||
|
|
||||||
|
## 自动清理
|
||||||
|
|
||||||
|
插件每天自动执行一次清理任务,删除超过保留天数的 `AiCommentReply` 记录。
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 启用自动清理 | 是否开启自动清理 | 开启 |
|
||||||
|
| 保留天数 | 超过此天数的记录将被清理 | 30 |
|
||||||
|
|
||||||
|
配置路径:**插件设置** → **数据清理**
|
||||||
|
|
||||||
|
## 手动清理
|
||||||
|
|
||||||
|
在数据清理页面点击 **立即清理** 按钮,可以立即执行一次清理操作。清理完成后会显示删除的记录数量。
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
清理操作不可撤销,请根据实际需求设置合理的保留天数。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 清理范围
|
||||||
|
|
||||||
|
清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。已发布的评论不受影响。
|
||||||
@@ -22,3 +22,18 @@
|
|||||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
||||||
- 已发布的记录显示正常状态
|
- 已发布的记录显示正常状态
|
||||||
- 被拒绝的记录显示 REJECTED 标签
|
- 被拒绝的记录显示 REJECTED 标签
|
||||||
|
|
||||||
|
## 批量操作
|
||||||
|
|
||||||
|
当日志页面有多条草稿记录时,可以使用批量操作功能:
|
||||||
|
|
||||||
|
1. 勾选要操作的记录(支持全选)
|
||||||
|
2. 选中后顶部显示批量操作工具栏
|
||||||
|
3. 支持的批量操作:
|
||||||
|
- **批量通过** — 一次性审核通过多条草稿
|
||||||
|
- **批量拒绝** — 一次性拒绝多条草稿
|
||||||
|
- **批量删除** — 一次性删除多条记录
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
批量操作不可撤销,请谨慎操作。
|
||||||
|
:::
|
||||||
|
|||||||
@@ -50,3 +50,19 @@
|
|||||||
## 插件升级后设置丢失了?
|
## 插件升级后设置丢失了?
|
||||||
|
|
||||||
插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。
|
插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。
|
||||||
|
|
||||||
|
## AI生成失败后会怎样?
|
||||||
|
|
||||||
|
插件会自动重试,重试次数由"最大重试次数"配置控制(默认3次)。重试间隔递增(5秒、15秒、30秒)。超过最大重试次数后标记为失败。
|
||||||
|
|
||||||
|
## 如何批量审核草稿回复?
|
||||||
|
|
||||||
|
在AI回复日志页面,勾选多条记录后,使用顶部的批量操作工具栏进行批量通过、拒绝或删除。
|
||||||
|
|
||||||
|
## 旧记录太多怎么办?
|
||||||
|
|
||||||
|
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
|
||||||
|
|
||||||
|
## 黑名单支持邮箱吗?
|
||||||
|
|
||||||
|
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
|
||||||
|
|||||||
+10
-3
@@ -32,13 +32,20 @@
|
|||||||
|
|
||||||
1. 进入插件设置页面
|
1. 进入插件设置页面
|
||||||
2. 在 **基本设置** 中找到 **评论者黑名单**
|
2. 在 **基本设置** 中找到 **评论者黑名单**
|
||||||
3. 输入评论者的显示名称,多个用逗号分隔
|
3. 输入评论者的显示名称或邮箱,多个用逗号分隔
|
||||||
4. 保存设置
|
4. 保存设置
|
||||||
|
|
||||||
|
### 从评论列表选择
|
||||||
|
|
||||||
|
1. 在黑名单输入框旁点击 **添加评论者** 按钮
|
||||||
|
2. 弹出评论者列表对话框
|
||||||
|
3. 搜索并选择要屏蔽的评论者
|
||||||
|
4. 选中后自动添加到黑名单
|
||||||
|
|
||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```
|
```
|
||||||
张三,李四,王五
|
张三,spam@example.com,李四
|
||||||
```
|
```
|
||||||
|
|
||||||
黑名单中的评论者发布评论时,插件会跳过AI回复,并在日志中记录过滤原因。
|
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。
|
||||||
|
|||||||
@@ -5,19 +5,27 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
|||||||
## 核心功能
|
## 核心功能
|
||||||
|
|
||||||
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
||||||
|
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
||||||
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布
|
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布
|
||||||
|
- **失败重试** — AI生成失败时自动重试,指数退避策略
|
||||||
|
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除
|
||||||
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
|
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
|
||||||
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复
|
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复
|
||||||
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
|
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
|
||||||
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像
|
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像
|
||||||
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
|
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
|
||||||
|
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表
|
||||||
|
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索
|
||||||
|
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
|
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
```
|
```
|
||||||
新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
||||||
|
↓ (失败)
|
||||||
|
重试 → ... → 最终失败
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
||||||
@@ -27,6 +35,7 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
|||||||
5. **AI生成** — 调用AI模型生成回复内容
|
5. **AI生成** — 调用AI模型生成回复内容
|
||||||
6. **安全审核** — 对生成内容进行安全审核
|
6. **安全审核** — 对生成内容进行安全审核
|
||||||
7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
||||||
|
8. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
|
||||||
|
|
||||||
## 前置要求
|
## 前置要求
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,14 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
|||||||
::: warning
|
::: warning
|
||||||
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### 头像预览
|
||||||
|
|
||||||
|
在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
如果预览头像不正确,请检查:
|
||||||
|
1. 邮箱是否拼写正确
|
||||||
|
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
|
||||||
|
3. 头像更新可能有缓存延迟
|
||||||
|
:::
|
||||||
|
|||||||
+12
-1
@@ -9,7 +9,7 @@
|
|||||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||||
| 自动发布 | AI回复是否自动发布 | 开启 |
|
| 自动发布 | AI回复是否自动发布 | 开启 |
|
||||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||||
| 评论者黑名单 | 不触发AI回复的评论者显示名称,逗号分隔 | 空 |
|
| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 |
|
||||||
|
|
||||||
## AI角色设置
|
## AI角色设置
|
||||||
|
|
||||||
@@ -53,3 +53,14 @@
|
|||||||
| `{{article}}` | 文章内容 |
|
| `{{article}}` | 文章内容 |
|
||||||
| `{{comment}}` | 评论内容 |
|
| `{{comment}}` | 评论内容 |
|
||||||
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
||||||
|
|
||||||
|
## 数据清理
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 启用自动清理 | 是否自动清理过期的AI回复记录 | 开启 |
|
||||||
|
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
|
||||||
|
:::
|
||||||
|
|||||||
+7
-3
@@ -15,11 +15,15 @@ hero:
|
|||||||
|
|
||||||
features:
|
features:
|
||||||
- title: 自动回复
|
- title: 自动回复
|
||||||
details: 监听新评论,自动调用AI生成回复,支持对话式上下文
|
details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试
|
||||||
|
- title: 多语言适配
|
||||||
|
details: 根据评论语言自动用对应语言回复,中文评论中文回复,英文评论英文回复
|
||||||
- title: 情感分析
|
- title: 情感分析
|
||||||
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
|
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
|
||||||
- title: 草稿模式
|
- title: 草稿模式
|
||||||
details: AI回复先存为草稿,管理员审核后再发布
|
details: AI回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||||
- title: 灵活过滤
|
- title: 灵活过滤
|
||||||
details: 文章/页面级开关控制,评论者黑名单
|
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
||||||
|
- title: 数据管理
|
||||||
|
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
|
||||||
---
|
---
|
||||||
|
|||||||
+95
-4
@@ -16,6 +16,7 @@ import run.halo.app.extension.ListOptions;
|
|||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.PageRequestImpl;
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@@ -29,8 +30,10 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||||
|
|
||||||
@@ -40,13 +43,15 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
|
private final AiReplyCleanupService cleanupService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) {
|
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
|
this.cleanupService = cleanupService;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = new ObjectMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.POST("/replies/{name}/reject", this::rejectReply)
|
.POST("/replies/{name}/reject", this::rejectReply)
|
||||||
.POST("/comments/{commentName}/trigger", this::triggerReply)
|
.POST("/comments/{commentName}/trigger", this::triggerReply)
|
||||||
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
|
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
|
||||||
|
.GET("/commenters", this::listCommenters)
|
||||||
|
.POST("/cleanup", this::triggerCleanup)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +83,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
private Mono<ServerResponse> listReplies(ServerRequest request) {
|
private Mono<ServerResponse> listReplies(ServerRequest request) {
|
||||||
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||||
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||||
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
var statusFilter = request.queryParam("status").orElse("");
|
||||||
var pageable = PageRequestImpl.of(page, size, sort);
|
var sentimentFilter = request.queryParam("sentiment").orElse("");
|
||||||
|
var keywordFilter = request.queryParam("keyword").orElse("");
|
||||||
|
|
||||||
return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable)
|
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
|
.collectList()
|
||||||
|
.map(replies -> {
|
||||||
|
var filtered = replies.stream()
|
||||||
|
.filter(r -> {
|
||||||
|
if (!statusFilter.isBlank()
|
||||||
|
&& !statusFilter.equals(r.getSpec().getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!sentimentFilter.isBlank()
|
||||||
|
&& !sentimentFilter.equals(r.getSpec().getSentiment())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!keywordFilter.isBlank()) {
|
||||||
|
String reply = r.getSpec().getReply();
|
||||||
|
if (reply == null || !reply.contains(keywordFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparing(
|
||||||
|
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int total = filtered.size();
|
||||||
|
int fromIndex = (page - 1) * size;
|
||||||
|
int toIndex = Math.min(fromIndex + size, total);
|
||||||
|
List<AiCommentReply> pageContent = fromIndex < total
|
||||||
|
? filtered.subList(fromIndex, toIndex) : List.of();
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("items", pageContent);
|
||||||
|
result.put("total", total);
|
||||||
|
result.put("page", page);
|
||||||
|
result.put("size", size);
|
||||||
|
result.put("totalPages", (int) Math.ceil((double) total / size));
|
||||||
|
result.put("first", page == 1);
|
||||||
|
result.put("last", toIndex >= total);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,4 +545,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
String time,
|
String time,
|
||||||
boolean isAi
|
boolean isAi
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public record CommenterInfo(
|
||||||
|
String displayName,
|
||||||
|
String email
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||||
|
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
|
.collectList()
|
||||||
|
.map(comments -> {
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
List<CommenterInfo> result = new ArrayList<>();
|
||||||
|
for (var comment : comments) {
|
||||||
|
var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null;
|
||||||
|
if (owner == null) continue;
|
||||||
|
String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : "";
|
||||||
|
String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null
|
||||||
|
? owner.getName() : "";
|
||||||
|
String key = displayName.toLowerCase() + "|" + email.toLowerCase();
|
||||||
|
if (seen.add(key)) {
|
||||||
|
result.add(new CommenterInfo(displayName, email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
int retentionDays = cleanupService.getRetentionDays();
|
||||||
|
long deleted = cleanupService.executeCleanup(retentionDays);
|
||||||
|
return Map.of("deletedCount", deleted, "retentionDays", retentionDays);
|
||||||
|
})
|
||||||
|
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
||||||
|
return ServerResponse.status(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.bodyValue(Map.of("message", "清理失败: " + e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.DisposableBean;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class AiReplyCleanupService implements DisposableBean {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
|
public AiReplyCleanupService(ReactiveExtensionClient client) {
|
||||||
|
this.client = client;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "ai-reply-cleanup");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
// Schedule daily cleanup: initial delay 1 minute, then every 24 hours
|
||||||
|
this.scheduler.scheduleAtFixedRate(this::dailyCleanup, 1, 24 * 60, TimeUnit.MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void dailyCleanup() {
|
||||||
|
try {
|
||||||
|
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return false;
|
||||||
|
String cleanupJson = data.get("cleanup");
|
||||||
|
if (cleanupJson == null || cleanupJson.isBlank()) return true;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||||
|
return node.has("cleanupEnabled") && node.get("cleanupEnabled").asBoolean(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(true)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(enabled)) {
|
||||||
|
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int retentionDays = getRetentionDays();
|
||||||
|
long deleted = executeCleanup(retentionDays);
|
||||||
|
log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long executeCleanup(int retentionDays) {
|
||||||
|
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
||||||
|
|
||||||
|
var oldRecords = client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
|
.filter(r -> {
|
||||||
|
Instant created = r.getMetadata().getCreationTimestamp();
|
||||||
|
return created != null && created.isBefore(cutoff);
|
||||||
|
})
|
||||||
|
.collectList()
|
||||||
|
.block();
|
||||||
|
|
||||||
|
if (oldRecords == null || oldRecords.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
long deleted = 0;
|
||||||
|
for (var record : oldRecords) {
|
||||||
|
try {
|
||||||
|
client.delete(record).block();
|
||||||
|
deleted++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRetentionDays() {
|
||||||
|
try {
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return 30;
|
||||||
|
String cleanupJson = data.get("cleanup");
|
||||||
|
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||||
|
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(30)
|
||||||
|
.block();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroy() {
|
||||||
|
scheduler.shutdown();
|
||||||
|
try {
|
||||||
|
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
package top.nxxy335.commentaiautopilot.service;
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.dao.OptimisticLockingFailureException;
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.util.retry.Retry;
|
import reactor.util.retry.Retry;
|
||||||
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -17,9 +18,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AiReplyOrchestrator {
|
public class AiReplyOrchestrator {
|
||||||
|
|
||||||
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
private final ContextExtractor contextExtractor;
|
private final ContextExtractor contextExtractor;
|
||||||
private final PromptBuilder promptBuilder;
|
private final PromptBuilder promptBuilder;
|
||||||
private final AiReplyService aiReplyService;
|
private final AiReplyService aiReplyService;
|
||||||
@@ -28,12 +30,31 @@ public class AiReplyOrchestrator {
|
|||||||
private final CommentReplyPublisher commentReplyPublisher;
|
private final CommentReplyPublisher commentReplyPublisher;
|
||||||
private final FilterService filterService;
|
private final FilterService filterService;
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final ReactiveSettingFetcher settingFetcher;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// In-memory dedup: tracks which comment/reply is currently being processed
|
// In-memory dedup: tracks which comment/reply is currently being processed
|
||||||
// Prevents duplicate replies when Reconciler fires multiple times
|
// Prevents duplicate replies when Reconciler fires multiple times
|
||||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public AiReplyOrchestrator(ContextExtractor contextExtractor,
|
||||||
|
PromptBuilder promptBuilder,
|
||||||
|
AiReplyService aiReplyService,
|
||||||
|
SentimentService sentimentService,
|
||||||
|
ReviewService reviewService,
|
||||||
|
CommentReplyPublisher commentReplyPublisher,
|
||||||
|
FilterService filterService,
|
||||||
|
ReactiveExtensionClient client) {
|
||||||
|
this.contextExtractor = contextExtractor;
|
||||||
|
this.promptBuilder = promptBuilder;
|
||||||
|
this.aiReplyService = aiReplyService;
|
||||||
|
this.sentimentService = sentimentService;
|
||||||
|
this.reviewService = reviewService;
|
||||||
|
this.commentReplyPublisher = commentReplyPublisher;
|
||||||
|
this.filterService = filterService;
|
||||||
|
this.client = client;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a new comment or reply.
|
* Process a new comment or reply.
|
||||||
*
|
*
|
||||||
@@ -151,6 +172,7 @@ public class AiReplyOrchestrator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate AI reply, optionally review it, then publish.
|
* Generate AI reply, optionally review it, then publish.
|
||||||
|
* Includes retry logic for empty AI replies and review failures.
|
||||||
*/
|
*/
|
||||||
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
|
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
|
||||||
AiCommentReply replyRecord, String modelName) {
|
AiCommentReply replyRecord, String modelName) {
|
||||||
@@ -159,7 +181,7 @@ public class AiReplyOrchestrator {
|
|||||||
.flatMap(aiReply -> {
|
.flatMap(aiReply -> {
|
||||||
if (aiReply.isBlank()) {
|
if (aiReply.isBlank()) {
|
||||||
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
|
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
|
||||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
return retryOrFail(replyRecord, context, modelName, "AI generated empty reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("[Orchestrator] AI generated reply for {}: {} chars",
|
log.info("[Orchestrator] AI generated reply for {}: {} chars",
|
||||||
@@ -172,7 +194,9 @@ public class AiReplyOrchestrator {
|
|||||||
if ("FAIL".equals(reviewResult.status())) {
|
if ("FAIL".equals(reviewResult.status())) {
|
||||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||||
context.commentId());
|
context.commentId());
|
||||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then();
|
// Save the failed reply content, then retry
|
||||||
|
return updateRecord(replyRecord, aiReply, 0, "FAIL", false)
|
||||||
|
.then(retryOrFail(replyRecord, context, modelName, "Content safety review failed"));
|
||||||
}
|
}
|
||||||
return publishReply(context, aiReply, replyRecord, reviewResult.score());
|
return publishReply(context, aiReply, replyRecord, reviewResult.score());
|
||||||
})
|
})
|
||||||
@@ -186,6 +210,73 @@ public class AiReplyOrchestrator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to retry or mark as final FAIL.
|
||||||
|
* If retryCount < maxRetryCount, increment retryCount, set status to PENDING,
|
||||||
|
* delay with exponential backoff, then re-execute the generate+review+publish flow.
|
||||||
|
* Otherwise, mark as final FAIL.
|
||||||
|
*/
|
||||||
|
private Mono<Void> retryOrFail(AiCommentReply replyRecord,
|
||||||
|
ContextExtractor.CommentContext context,
|
||||||
|
String modelName,
|
||||||
|
String reason) {
|
||||||
|
return getMaxRetryCount().flatMap(maxRetry -> {
|
||||||
|
int currentRetryCount = replyRecord.getSpec().getRetryCount() != null
|
||||||
|
? replyRecord.getSpec().getRetryCount() : 0;
|
||||||
|
|
||||||
|
if (currentRetryCount < maxRetry) {
|
||||||
|
int newRetryCount = currentRetryCount + 1;
|
||||||
|
long delaySeconds = 5L * (1L << currentRetryCount); // 5 * 2^retryCount
|
||||||
|
log.info("[Orchestrator] Retrying ({}/{}) for {} after {}s, reason: {}",
|
||||||
|
newRetryCount, maxRetry, context.commentId(), delaySeconds, reason);
|
||||||
|
|
||||||
|
// Update retryCount and reset status to PENDING
|
||||||
|
return updateRecordForRetry(replyRecord, newRetryCount)
|
||||||
|
.delayElement(Duration.ofSeconds(delaySeconds))
|
||||||
|
.then(retryGenerate(context, replyRecord, modelName));
|
||||||
|
} else {
|
||||||
|
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
||||||
|
maxRetry, context.commentId(), reason);
|
||||||
|
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-execute the core generate+review+publish flow for a retry.
|
||||||
|
* Rebuilds the prompt from context and sentiment, then calls generateAndPublish again.
|
||||||
|
*/
|
||||||
|
private Mono<Void> retryGenerate(ContextExtractor.CommentContext context,
|
||||||
|
AiCommentReply replyRecord,
|
||||||
|
String modelName) {
|
||||||
|
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
||||||
|
.flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment())
|
||||||
|
.flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the record's retryCount and reset status to PENDING for a retry attempt.
|
||||||
|
*/
|
||||||
|
private Mono<AiCommentReply> updateRecordForRetry(AiCommentReply record, int newRetryCount) {
|
||||||
|
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||||
|
.flatMap(latest -> {
|
||||||
|
latest.getSpec().setRetryCount(newRetryCount);
|
||||||
|
latest.getSpec().setStatus("PENDING");
|
||||||
|
latest.getSpec().setReply("");
|
||||||
|
latest.getSpec().setScore(0);
|
||||||
|
latest.getSpec().setPublished(false);
|
||||||
|
return client.update(latest);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException)
|
||||||
|
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying retry-update for {} due to optimistic lock",
|
||||||
|
record.getMetadata().getName()))
|
||||||
|
)
|
||||||
|
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated for retry: retryCount={}",
|
||||||
|
record.getMetadata().getName(), newRetryCount));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish the reply and update the record to PASS + published=true.
|
* Publish the reply and update the record to PASS + published=true.
|
||||||
*/
|
*/
|
||||||
@@ -205,41 +296,104 @@ public class AiReplyOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<String> getModelName() {
|
private Mono<String> getModelName() {
|
||||||
return settingFetcher.getSettingValue("model")
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.map(node -> {
|
.mapNotNull(cm -> {
|
||||||
var nameNode = node.get("modelName");
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String modelJson = data.get("model");
|
||||||
|
if (modelJson == null || modelJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(modelJson);
|
||||||
|
JsonNode nameNode = node.get("modelName");
|
||||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||||
return nameNode.asText();
|
return nameNode.asText();
|
||||||
}
|
}
|
||||||
return "";
|
} catch (Exception e) {
|
||||||
|
log.warn("[Orchestrator] Failed to parse modelName from ConfigMap: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage());
|
log.debug("[Orchestrator] Failed to fetch model setting from ConfigMap: {}", e.getMessage());
|
||||||
return Mono.just("");
|
return Mono.empty();
|
||||||
})
|
})
|
||||||
.defaultIfEmpty("");
|
.defaultIfEmpty("");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Boolean> isAutoReplyEnabled() {
|
private Mono<Boolean> isAutoReplyEnabled() {
|
||||||
return settingFetcher.getSettingValue("basic")
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true))
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String basicJson = data.get("basic");
|
||||||
|
if (basicJson == null || basicJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
|
if (!node.has("autoReply")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return node.get("autoReply").asBoolean(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Orchestrator] Failed to parse autoReply from ConfigMap: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage());
|
log.debug("[Orchestrator] Failed to fetch autoReply setting from ConfigMap: {}", e.getMessage());
|
||||||
return Mono.just(true);
|
return Mono.empty();
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(true);
|
.defaultIfEmpty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Boolean> isAutoPublishEnabled() {
|
private Mono<Boolean> isAutoPublishEnabled() {
|
||||||
return settingFetcher.getSettingValue("basic")
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true))
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String basicJson = data.get("basic");
|
||||||
|
if (basicJson == null || basicJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
|
if (!node.has("autoPublish")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return node.get("autoPublish").asBoolean(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Orchestrator] Failed to parse autoPublish from ConfigMap: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage());
|
log.debug("[Orchestrator] Failed to fetch autoPublish setting from ConfigMap: {}", e.getMessage());
|
||||||
return Mono.just(true);
|
return Mono.empty();
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(true);
|
.defaultIfEmpty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<Integer> getMaxRetryCount() {
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String basicJson = data.get("basic");
|
||||||
|
if (basicJson == null || basicJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
|
if (node.has("maxRetryCount")) {
|
||||||
|
return node.get("maxRetryCount").asInt(3);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Orchestrator] Failed to parse maxRetryCount from ConfigMap: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.debug("[Orchestrator] Failed to fetch maxRetryCount setting from ConfigMap: {}", e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(3);
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
|
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
|
||||||
AiCommentReply record = new AiCommentReply();
|
AiCommentReply record = new AiCommentReply();
|
||||||
record.setMetadata(new Metadata());
|
record.setMetadata(new Metadata());
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ public class FilterService {
|
|||||||
? node.get("blockedCommenters").asText("") : "";
|
? node.get("blockedCommenters").asText("") : "";
|
||||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
||||||
String commenterName = getCommenterDisplayName(comment);
|
String commenterName = getCommenterDisplayName(comment);
|
||||||
if (isInList(commenterName, blockedCommenters)) {
|
String commenterEmail = getCommenterEmail(comment);
|
||||||
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName);
|
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
|
||||||
|
log.info("[Filter] Commenter '{}' (email: '{}') is in blocked list, skipping", commenterName, commenterEmail);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -130,6 +131,16 @@ public class FilterService {
|
|||||||
return displayName != null ? displayName : "";
|
return displayName != null ? displayName : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getCommenterEmail(Comment comment) {
|
||||||
|
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||||
|
var owner = comment.getSpec().getOwner();
|
||||||
|
if ("EMAIL".equals(owner.getKind())) {
|
||||||
|
var name = owner.getName();
|
||||||
|
return name != null ? name : "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> parseList(String str) {
|
private List<String> parseList(String str) {
|
||||||
if (str == null || str.isBlank()) return Collections.emptyList();
|
if (str == null || str.isBlank()) return Collections.emptyList();
|
||||||
return Arrays.stream(str.split(","))
|
return Arrays.stream(str.split(","))
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package top.nxxy335.commentaiautopilot.service;
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
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.app.plugin.ReactiveSettingFetcher;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PromptBuilder {
|
public class PromptBuilder {
|
||||||
|
|
||||||
private final ReactiveSettingFetcher settingFetcher;
|
private final ReactiveExtensionClient client;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
|
public PromptBuilder(ReactiveExtensionClient client) {
|
||||||
|
this.client = client;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
}
|
||||||
|
|
||||||
private static final String SAFETY_PROMPT = """
|
private static final String SAFETY_PROMPT = """
|
||||||
【安全规范】
|
【安全规范】
|
||||||
@@ -76,13 +84,22 @@ public class PromptBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<String> getPromptTemplate() {
|
private Mono<String> getPromptTemplate() {
|
||||||
return settingFetcher.getSettingValue("prompt")
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.map(node -> {
|
.mapNotNull(cm -> {
|
||||||
var templateNode = node.get("customPromptTemplate");
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String promptJson = data.get("prompt");
|
||||||
|
if (promptJson == null || promptJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(promptJson);
|
||||||
|
JsonNode templateNode = node.get("customPromptTemplate");
|
||||||
if (templateNode != null && !templateNode.asText().isBlank()) {
|
if (templateNode != null && !templateNode.asText().isBlank()) {
|
||||||
return templateNode.asText();
|
return templateNode.asText();
|
||||||
}
|
}
|
||||||
return DEFAULT_PROMPT_TEMPLATE;
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse customPromptTemplate from ConfigMap: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
|
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
|
||||||
@@ -92,13 +109,22 @@ public class PromptBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<String> getPersonaPrompt() {
|
private Mono<String> getPersonaPrompt() {
|
||||||
return settingFetcher.getSettingValue("persona")
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.map(node -> {
|
.mapNotNull(cm -> {
|
||||||
var promptNode = node.get("personaPrompt");
|
var data = cm.getData();
|
||||||
|
if (data == null) return null;
|
||||||
|
String personaJson = data.get("persona");
|
||||||
|
if (personaJson == null || personaJson.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(personaJson);
|
||||||
|
JsonNode promptNode = node.get("personaPrompt");
|
||||||
if (promptNode != null && !promptNode.asText().isBlank()) {
|
if (promptNode != null && !promptNode.asText().isBlank()) {
|
||||||
return promptNode.asText();
|
return promptNode.asText();
|
||||||
}
|
}
|
||||||
return DEFAULT_PERSONA_PROMPT;
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse personaPrompt from ConfigMap: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
|
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ spec:
|
|||||||
- $formkit: textarea
|
- $formkit: textarea
|
||||||
name: blockedCommenters
|
name: blockedCommenters
|
||||||
label: 评论者黑名单
|
label: 评论者黑名单
|
||||||
help: 输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复
|
help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复
|
||||||
value: ""
|
value: ""
|
||||||
- group: persona
|
- group: persona
|
||||||
label: AI角色设置
|
label: AI角色设置
|
||||||
@@ -57,4 +57,18 @@ spec:
|
|||||||
name: customPromptTemplate
|
name: customPromptTemplate
|
||||||
label: 自定义Prompt模板
|
label: 自定义Prompt模板
|
||||||
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
||||||
|
- group: cleanup
|
||||||
|
label: 数据清理
|
||||||
|
formSchema:
|
||||||
|
- $formkit: switch
|
||||||
|
name: cleanupEnabled
|
||||||
|
label: 启用自动清理
|
||||||
|
value: true
|
||||||
|
- $formkit: number
|
||||||
|
name: retentionDays
|
||||||
|
label: 保留天数
|
||||||
|
help: 超过此天数的AI回复记录将被自动清理
|
||||||
|
value: 30
|
||||||
|
min: 1
|
||||||
|
max: 365
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ spec:
|
|||||||
configMapName: "comment-ai-autopilot-configmap"
|
configMapName: "comment-ai-autopilot-configmap"
|
||||||
version: "0.0.1-w5s2t7"
|
version: "0.0.1-w5s2t7"
|
||||||
pluginDependencies:
|
pluginDependencies:
|
||||||
ai-foundation?: "*"
|
ai-foundation: "*"
|
||||||
|
|||||||
@@ -38,6 +38,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="mx-4 mt-2 flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
v-model="filterStatus"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="PASS">通过</option>
|
||||||
|
<option value="FAIL">失败</option>
|
||||||
|
<option value="PENDING">待审核</option>
|
||||||
|
<option value="REJECTED">已拒绝</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="filterSentiment"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">全部情感</option>
|
||||||
|
<option value="POSITIVE">正面</option>
|
||||||
|
<option value="NEUTRAL">中性</option>
|
||||||
|
<option value="NEGATIVE">负面</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="filterKeyword"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索回复内容..."
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="m-4">
|
<div class="m-4">
|
||||||
<VLoading v-if="loading" />
|
<VLoading v-if="loading" />
|
||||||
|
|
||||||
@@ -312,6 +347,11 @@ const totalPages = ref(0)
|
|||||||
const selectedNames = ref<Set<string>>(new Set())
|
const selectedNames = ref<Set<string>>(new Set())
|
||||||
const selectAll = ref(false)
|
const selectAll = ref(false)
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const filterStatus = ref("")
|
||||||
|
const filterSentiment = ref("")
|
||||||
|
const filterKeyword = ref("")
|
||||||
|
|
||||||
const toggleSelect = (name: string) => {
|
const toggleSelect = (name: string) => {
|
||||||
if (selectedNames.value.has(name)) {
|
if (selectedNames.value.has(name)) {
|
||||||
selectedNames.value.delete(name)
|
selectedNames.value.delete(name)
|
||||||
@@ -340,9 +380,13 @@ const conversationMessages = ref<ConversationMessage[]>([])
|
|||||||
const fetchReplies = async () => {
|
const fetchReplies = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const params: Record<string, string | number> = { page: page.value, size: size.value }
|
||||||
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
|
if (filterSentiment.value) params.sentiment = filterSentiment.value
|
||||||
|
if (filterKeyword.value) params.keyword = filterKeyword.value
|
||||||
const { data } = await axiosInstance.get(
|
const { data } = await axiosInstance.get(
|
||||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
||||||
{ params: { page: page.value, size: size.value } },
|
{ params },
|
||||||
)
|
)
|
||||||
replies.value = data.items || []
|
replies.value = data.items || []
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
@@ -561,6 +605,19 @@ const renderContent = (content: string) => {
|
|||||||
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
|
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterStatus.value = ""
|
||||||
|
filterSentiment.value = ""
|
||||||
|
filterKeyword.value = ""
|
||||||
|
page.value = 1
|
||||||
|
fetchReplies()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterStatus, filterSentiment, filterKeyword], () => {
|
||||||
|
page.value = 1
|
||||||
|
fetchReplies()
|
||||||
|
})
|
||||||
|
|
||||||
watch(page, () => {
|
watch(page, () => {
|
||||||
selectedNames.value.clear()
|
selectedNames.value.clear()
|
||||||
selectAll.value = false
|
selectAll.value = false
|
||||||
|
|||||||
@@ -62,8 +62,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<label class="font-medium">评论者黑名单</label>
|
<label class="font-medium">评论者黑名单</label>
|
||||||
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复</div>
|
<VButton size="sm" @click="openCommenterDialog">
|
||||||
|
添加评论者
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="settings.basic.blockedCommenters"
|
v-model="settings.basic.blockedCommenters"
|
||||||
rows="3"
|
rows="3"
|
||||||
@@ -87,12 +92,30 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="font-medium">AI角色邮箱</label>
|
<label class="font-medium">AI角色邮箱</label>
|
||||||
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像,留空则使用默认头像</div>
|
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像,留空则使用默认头像</div>
|
||||||
|
<div class="mt-1 flex items-start gap-4">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
v-model="settings.persona.personaEmail"
|
v-model="settings.persona.personaEmail"
|
||||||
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
class="block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
placeholder="ai@example.com"
|
placeholder="ai@example.com"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-if="avatarUrl"
|
||||||
|
class="h-12 w-12 overflow-hidden rounded-full border border-gray-200"
|
||||||
|
>
|
||||||
|
<img :src="avatarUrl" alt="头像预览" class="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-gray-100"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="font-medium">AI角色人格提示词</label>
|
<label class="font-medium">AI角色人格提示词</label>
|
||||||
@@ -138,6 +161,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cleanup Settings -->
|
||||||
|
<div v-if="activeTab === 'cleanup' && !loading" class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">启用自动清理</div>
|
||||||
|
<div class="text-sm text-gray-500">启用后,将自动清理过期的AI回复记录</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" class="peer sr-only" />
|
||||||
|
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="font-medium">保留天数</label>
|
||||||
|
<div class="mt-1 text-sm text-gray-500">超过保留天数的AI回复记录将被自动清理</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="settings.cleanup.retentionDays"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<VButton @click="performCleanup" :disabled="cleanupLoading">
|
||||||
|
{{ cleanupLoading ? '清理中...' : '立即清理' }}
|
||||||
|
</VButton>
|
||||||
|
<div v-if="cleanupResult !== null" class="mt-2 text-sm text-green-600">
|
||||||
|
清理完成,共删除 {{ cleanupResult }} 条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div v-if="!loading" class="mt-6 flex justify-end">
|
<div v-if="!loading" class="mt-6 flex justify-end">
|
||||||
<VButton type="primary" @click="saveSettings" :disabled="saving">
|
<VButton type="primary" @click="saveSettings" :disabled="saving">
|
||||||
@@ -147,11 +203,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Commenter Selection Dialog -->
|
||||||
|
<div
|
||||||
|
v-if="showCommenterDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
@click.self="showCommenterDialog = false"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-lg rounded-lg bg-white shadow-xl">
|
||||||
|
<div class="border-b px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium">选择评论者</h3>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showCommenterDialog = false"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="commenterSearch"
|
||||||
|
type="text"
|
||||||
|
class="mt-3 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="搜索评论者名称或邮箱..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-80 overflow-y-auto px-6 py-3">
|
||||||
|
<VLoading v-if="commenterLoading" />
|
||||||
|
<div v-else-if="filteredCommenters.length === 0" class="py-8 text-center text-sm text-gray-500">
|
||||||
|
暂无评论者数据
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="commenter in filteredCommenters"
|
||||||
|
:key="commenter.name + commenter.email"
|
||||||
|
class="flex items-center justify-between rounded-md border border-gray-100 px-4 py-3 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate font-medium text-sm">{{ commenter.name }}</div>
|
||||||
|
<div v-if="commenter.email" class="truncate text-xs text-gray-500">{{ commenter.email }}</div>
|
||||||
|
</div>
|
||||||
|
<VButton size="sm" @click="addCommenter(commenter)">
|
||||||
|
添加
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from "vue"
|
import { ref, reactive, computed, onMounted, watch } from "vue"
|
||||||
import { axiosInstance } from "@halo-dev/api-client"
|
import { axiosInstance } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
|
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
import { IconPlug } from "@halo-dev/components"
|
||||||
@@ -161,12 +267,27 @@ const tabs = [
|
|||||||
{ key: "persona", label: "AI角色设置" },
|
{ key: "persona", label: "AI角色设置" },
|
||||||
{ key: "model", label: "模型设置" },
|
{ key: "model", label: "模型设置" },
|
||||||
{ key: "prompt", label: "Prompt设置" },
|
{ key: "prompt", label: "Prompt设置" },
|
||||||
|
{ key: "cleanup", label: "数据清理" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref("basic")
|
const activeTab = ref("basic")
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Commenter dialog state
|
||||||
|
const showCommenterDialog = ref(false)
|
||||||
|
const commenterList = ref<{ name: string; email: string }[]>([])
|
||||||
|
const commenterSearch = ref("")
|
||||||
|
const commenterLoading = ref(false)
|
||||||
|
|
||||||
|
// Avatar preview state
|
||||||
|
const avatarUrl = ref("")
|
||||||
|
let avatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// Cleanup state
|
||||||
|
const cleanupLoading = ref(false)
|
||||||
|
const cleanupResult = ref<number | null>(null)
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
basic: {
|
basic: {
|
||||||
autoReply: true,
|
autoReply: true,
|
||||||
@@ -185,10 +306,114 @@ const settings = reactive({
|
|||||||
prompt: {
|
prompt: {
|
||||||
customPromptTemplate: "",
|
customPromptTemplate: "",
|
||||||
},
|
},
|
||||||
|
cleanup: {
|
||||||
|
cleanupEnabled: true,
|
||||||
|
retentionDays: 30,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const configMapName = "comment-ai-autopilot-configmap"
|
const configMapName = "comment-ai-autopilot-configmap"
|
||||||
|
|
||||||
|
// --- Task 4: Commenter blacklist enhancement ---
|
||||||
|
|
||||||
|
const openCommenterDialog = async () => {
|
||||||
|
showCommenterDialog.value = true
|
||||||
|
commenterSearch.value = ""
|
||||||
|
commenterLoading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get(
|
||||||
|
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/commenters",
|
||||||
|
)
|
||||||
|
commenterList.value = Array.isArray(data) ? data : (data.items || [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch commenters", e)
|
||||||
|
Toast.error("获取评论者列表失败")
|
||||||
|
commenterList.value = []
|
||||||
|
} finally {
|
||||||
|
commenterLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCommenters = computed(() => {
|
||||||
|
const keyword = commenterSearch.value.trim().toLowerCase()
|
||||||
|
if (!keyword) return commenterList.value
|
||||||
|
return commenterList.value.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(keyword) ||
|
||||||
|
(c.email && c.email.toLowerCase().includes(keyword)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCommenter = (commenter: { name: string; email: string }) => {
|
||||||
|
const value = commenter.email || commenter.name
|
||||||
|
if (!value) return
|
||||||
|
const current = settings.basic.blockedCommenters
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (current.includes(value)) {
|
||||||
|
Toast.info("该评论者已在黑名单中")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.push(value)
|
||||||
|
settings.basic.blockedCommenters = current.join(",")
|
||||||
|
Toast.success("已添加到黑名单")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Task 7: Avatar preview ---
|
||||||
|
|
||||||
|
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||||
|
const normalized = email.trim().toLowerCase()
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(normalized)
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settings.persona.personaEmail,
|
||||||
|
(newEmail) => {
|
||||||
|
if (avatarDebounceTimer) {
|
||||||
|
clearTimeout(avatarDebounceTimer)
|
||||||
|
}
|
||||||
|
if (!newEmail || !newEmail.trim()) {
|
||||||
|
avatarUrl.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
avatarDebounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const hash = await computeGravatarHash(newEmail)
|
||||||
|
avatarUrl.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to compute Gravatar hash", e)
|
||||||
|
avatarUrl.value = ""
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Task 10: Cleanup ---
|
||||||
|
|
||||||
|
const performCleanup = async () => {
|
||||||
|
cleanupLoading.value = true
|
||||||
|
cleanupResult.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.post(
|
||||||
|
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/cleanup",
|
||||||
|
)
|
||||||
|
cleanupResult.value = data.deletedCount ?? data ?? 0
|
||||||
|
Toast.success(`清理完成,共删除 ${cleanupResult.value} 条记录`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to perform cleanup", e)
|
||||||
|
Toast.error("清理失败")
|
||||||
|
} finally {
|
||||||
|
cleanupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings fetch & save ---
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -214,7 +439,10 @@ const fetchSettings = async () => {
|
|||||||
if (d.prompt) {
|
if (d.prompt) {
|
||||||
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
|
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
|
||||||
}
|
}
|
||||||
|
if (d.cleanup) {
|
||||||
|
settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false
|
||||||
|
settings.cleanup.retentionDays = d.cleanup.retentionDays || 30
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch settings", e)
|
console.error("Failed to fetch settings", e)
|
||||||
@@ -251,7 +479,10 @@ const saveSettings = async () => {
|
|||||||
prompt: {
|
prompt: {
|
||||||
customPromptTemplate: settings.prompt.customPromptTemplate,
|
customPromptTemplate: settings.prompt.customPromptTemplate,
|
||||||
},
|
},
|
||||||
|
cleanup: {
|
||||||
|
cleanupEnabled: settings.cleanup.cleanupEnabled,
|
||||||
|
retentionDays: settings.cleanup.retentionDays,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await axiosInstance.put(
|
await axiosInstance.put(
|
||||||
|
|||||||
Reference in New Issue
Block a user