feat: 草稿模式修复、回复编辑、预设扩展、多语言回复、文档全面更新
- 修复草稿模式下仍自动发布评论的Bug - 新增草稿模式下编辑AI回复内容功能 - 新增3个Prompt预设(技术解答型/鼓励型/知识科普型) - 新增多语言回复指令 - 新增日志页面时间范围筛选 - 新增AI角色排序功能 - 新增配置导入/导出功能 - 修复processingLocks内存泄漏 - 修复OptimisticLockingFailureException缺少重试 - 修复publishReply空Mono导致审核挂起 - 移除近7日回复趋势卡片 - 版本号更新至0.0.0-ygkszvd - 全面更新插件文档
This commit is contained in:
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
|
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,支持排序,可为不同文章指定不同角色
|
||||||
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
||||||
- **多语言适配** — 根据评论语言自动用对应语言回复
|
- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等)
|
||||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
||||||
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
|
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容,支持批量操作
|
||||||
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
||||||
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
||||||
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
|
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
|
||||||
@@ -17,10 +17,11 @@
|
|||||||
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
|
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
|
||||||
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
|
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
|
||||||
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
|
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
|
||||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
|
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合
|
||||||
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
|
- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息
|
||||||
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
|
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
|
||||||
- **日志筛选** — 按状态、情感筛选,关键词搜索
|
- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页
|
||||||
|
- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份
|
||||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
|
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group 'top.nxxy335.commentaiautopilot'
|
group 'top.nxxy335.commentaiautopilot'
|
||||||
version '0.0.1-t5w8r3'
|
version '0.0.0-ygkszvd'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||||
|
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||||
|
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||||
|
|
||||||
## 重试机制
|
## 重试机制
|
||||||
|
|||||||
@@ -9,17 +9,32 @@
|
|||||||
## 审核流程
|
## 审核流程
|
||||||
|
|
||||||
1. 新评论到达后,AI生成回复内容
|
1. 新评论到达后,AI生成回复内容
|
||||||
2. 回复以草稿状态保存(`approved = false`)
|
2. 回复以草稿状态保存(状态为"通过",但未发布)
|
||||||
3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮
|
3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮
|
||||||
4. 管理员可以:
|
4. 管理员可以:
|
||||||
|
- **编辑** — 修改AI回复内容后再审核
|
||||||
- **审核通过** — 回复立即发布,访客可见
|
- **审核通过** — 回复立即发布,访客可见
|
||||||
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
|
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
|
||||||
|
|
||||||
|
## 编辑回复内容
|
||||||
|
|
||||||
|
草稿模式下,管理员可以在发布前编辑AI生成的回复内容:
|
||||||
|
|
||||||
|
1. 在日志页面找到状态为"通过"且未发布的记录
|
||||||
|
2. 点击 **编辑** 按钮
|
||||||
|
3. 在弹出的编辑对话框中修改回复内容
|
||||||
|
4. 点击 **保存** 完成修改
|
||||||
|
5. 修改后可继续审核通过或拒绝
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。
|
||||||
|
:::
|
||||||
|
|
||||||
## 日志页面操作
|
## 日志页面操作
|
||||||
|
|
||||||
在AI回复日志页面:
|
在AI回复日志页面:
|
||||||
|
|
||||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮
|
||||||
- 已发布的记录显示正常状态
|
- 已发布的记录显示正常状态
|
||||||
- 被拒绝的记录显示 REJECTED 标签
|
- 被拒绝的记录显示 REJECTED 标签
|
||||||
|
|
||||||
@@ -37,3 +52,19 @@
|
|||||||
::: warning
|
::: warning
|
||||||
批量操作不可撤销,请谨慎操作。
|
批量操作不可撤销,请谨慎操作。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## 草稿模式工作原理
|
||||||
|
|
||||||
|
草稿模式下,AI回复的处理流程与自动发布模式不同:
|
||||||
|
|
||||||
|
| 步骤 | 自动发布模式 | 草稿模式 |
|
||||||
|
|------|------------|---------|
|
||||||
|
| AI生成回复 | ✅ | ✅ |
|
||||||
|
| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 |
|
||||||
|
| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 |
|
||||||
|
| 审核通过时 | — | 创建 Reply 扩展并发布 |
|
||||||
|
| 拒绝时 | — | 标记为 REJECTED |
|
||||||
|
|
||||||
|
::: info
|
||||||
|
草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。
|
||||||
|
:::
|
||||||
|
|||||||
+43
-4
@@ -10,10 +10,14 @@
|
|||||||
|
|
||||||
## AI回复头像不显示?
|
## AI回复头像不显示?
|
||||||
|
|
||||||
1. 确认在插件设置中填写了AI角色邮箱
|
1. 确认在AI角色设置中填写了邮箱
|
||||||
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
|
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
|
||||||
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
|
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
|
||||||
|
|
||||||
|
::: info
|
||||||
|
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。
|
||||||
|
:::
|
||||||
|
|
||||||
## 评论没有触发AI回复?
|
## 评论没有触发AI回复?
|
||||||
|
|
||||||
可能的原因:
|
可能的原因:
|
||||||
@@ -23,6 +27,8 @@
|
|||||||
3. **已有AI回复记录** — 同一评论不会重复触发
|
3. **已有AI回复记录** — 同一评论不会重复触发
|
||||||
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
||||||
5. **AI生成失败** — 检查AI模型配置和日志
|
5. **AI生成失败** — 检查AI模型配置和日志
|
||||||
|
6. **速率限制** — 检查是否触发了每分钟回复数量限制
|
||||||
|
7. **对话轮次上限** — 检查是否达到了最大对话轮次
|
||||||
|
|
||||||
## 如何对历史评论触发AI回复?
|
## 如何对历史评论触发AI回复?
|
||||||
|
|
||||||
@@ -32,12 +38,24 @@
|
|||||||
|
|
||||||
## 草稿模式的回复在哪里审核?
|
## 草稿模式的回复在哪里审核?
|
||||||
|
|
||||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。
|
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。
|
||||||
|
|
||||||
|
## 如何在发布前编辑AI回复?
|
||||||
|
|
||||||
|
1. 确保草稿模式已启用(关闭"自动发布"开关)
|
||||||
|
2. 在AI回复日志页面找到状态为"通过"且未发布的记录
|
||||||
|
3. 点击 **编辑** 按钮修改回复内容
|
||||||
|
4. 修改完成后点击"审核通过"发布
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
编辑功能仅在草稿状态(通过但未发布)下可用。
|
||||||
|
:::
|
||||||
|
|
||||||
## 如何修改AI回复的语气风格?
|
## 如何修改AI回复的语气风格?
|
||||||
|
|
||||||
1. 在插件设置中修改 **AI角色人格提示词**
|
1. 在插件设置中修改 **AI角色人格提示词**
|
||||||
2. 或修改 **自定义Prompt模板**
|
2. 或修改 **自定义Prompt模板**
|
||||||
|
3. 或选择不同的 **Prompt预设** 风格组合
|
||||||
|
|
||||||
## AI Foundation 插件是必须的吗?
|
## AI Foundation 插件是必须的吗?
|
||||||
|
|
||||||
@@ -63,6 +81,27 @@
|
|||||||
|
|
||||||
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
|
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
|
||||||
|
|
||||||
## 黑名单支持邮箱吗?
|
## 黑名单支持正则表达式吗?
|
||||||
|
|
||||||
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
|
支持。黑名单支持三种匹配方式:
|
||||||
|
- **名称匹配**:直接输入评论者显示名称
|
||||||
|
- **邮箱匹配**:输入邮箱地址
|
||||||
|
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
|
||||||
|
|
||||||
|
## 如何管理多个AI角色?
|
||||||
|
|
||||||
|
在插件设置的 **AI角色设置** 区域,可以添加、编辑、删除AI角色,也可以使用 ▲▼ 按钮调整角色顺序。每个角色有独立的昵称、人格提示词和Gravatar头像。
|
||||||
|
|
||||||
|
## 如何导入/导出配置?
|
||||||
|
|
||||||
|
在插件设置页面右上角:
|
||||||
|
- 点击 **导出配置** 将当前设置和AI角色导出为 JSON 文件
|
||||||
|
- 点击 **导入配置** 从 JSON 文件导入配置(导入前会显示确认对话框)
|
||||||
|
|
||||||
|
## 日志页面如何按时间筛选?
|
||||||
|
|
||||||
|
在日志页面的筛选栏中,使用日期选择器设置起始日期和结束日期,即可筛选指定时间范围内的记录。
|
||||||
|
|
||||||
|
## 为什么AI角色不支持自定义上传头像?
|
||||||
|
|
||||||
|
Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,会忽略自定义头像 URL。这是 Halo 评论系统的设计限制,因此插件只支持通过 Gravatar 邮箱方式设置AI角色头像。
|
||||||
|
|||||||
+15
-3
@@ -32,9 +32,21 @@
|
|||||||
|
|
||||||
1. 进入插件设置页面
|
1. 进入插件设置页面
|
||||||
2. 在 **基本设置** 中找到 **评论者黑名单**
|
2. 在 **基本设置** 中找到 **评论者黑名单**
|
||||||
3. 输入评论者的显示名称或邮箱,多个用逗号分隔
|
3. 输入评论者的显示名称、邮箱或正则表达式,多个用逗号分隔
|
||||||
4. 保存设置
|
4. 保存设置
|
||||||
|
|
||||||
|
### 匹配方式
|
||||||
|
|
||||||
|
| 方式 | 格式 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 名称匹配 | 直接输入名称 | `张三` |
|
||||||
|
| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` |
|
||||||
|
| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` |
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。
|
||||||
|
:::
|
||||||
|
|
||||||
### 从评论列表选择
|
### 从评论列表选择
|
||||||
|
|
||||||
1. 在黑名单输入框旁点击 **添加评论者** 按钮
|
1. 在黑名单输入框旁点击 **添加评论者** 按钮
|
||||||
@@ -45,7 +57,7 @@
|
|||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```
|
```
|
||||||
张三,spam@example.com,李四
|
张三,spam@example.com,李四,regex:^bot.*
|
||||||
```
|
```
|
||||||
|
|
||||||
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。
|
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式也会参与匹配。匹配成功则跳过AI回复。
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ 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模型能力
|
||||||
|
|
||||||
|
|||||||
+31
-5
@@ -1,6 +1,28 @@
|
|||||||
# AI角色
|
# AI角色
|
||||||
|
|
||||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。
|
||||||
|
|
||||||
|
## 多角色管理
|
||||||
|
|
||||||
|
插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。
|
||||||
|
|
||||||
|
### 默认角色
|
||||||
|
|
||||||
|
系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。
|
||||||
|
|
||||||
|
### 角色排序
|
||||||
|
|
||||||
|
多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。
|
||||||
|
|
||||||
|
### 角色操作
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 添加角色 | 点击"添加角色"按钮创建新角色 |
|
||||||
|
| 编辑角色 | 点击编辑图标修改角色信息 |
|
||||||
|
| 删除角色 | 点击删除图标移除角色(默认角色不可删除) |
|
||||||
|
| 设为默认 | 点击星标图标将角色设为默认角色 |
|
||||||
|
| 排序 | 使用 ▲▼ 按钮调整角色顺序 |
|
||||||
|
|
||||||
## 角色配置
|
## 角色配置
|
||||||
|
|
||||||
@@ -28,11 +50,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
|||||||
|
|
||||||
### 邮箱与头像
|
### 邮箱与头像
|
||||||
|
|
||||||
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
|
填写邮箱后,AI回复者的头像将通过 Cravatar(Gravatar 镜像)服务自动生成:
|
||||||
|
|
||||||
1. 插件根据邮箱生成 SHA-256 哈希
|
1. 插件根据邮箱生成 SHA-256 哈希
|
||||||
2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}`
|
2. 构造头像 URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||||
3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中
|
3. Halo 评论系统通过 `email-hash` 注解自动匹配头像
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
||||||
@@ -40,7 +62,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
|||||||
|
|
||||||
### 头像预览
|
### 头像预览
|
||||||
|
|
||||||
在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
如果预览头像不正确,请检查:
|
如果预览头像不正确,请检查:
|
||||||
@@ -48,3 +70,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
|||||||
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
|
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
|
||||||
3. 头像更新可能有缓存延迟
|
3. 头像更新可能有缓存延迟
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
::: info
|
||||||
|
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。
|
||||||
|
:::
|
||||||
|
|||||||
+38
-1
@@ -9,6 +9,13 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
|||||||
|
|
||||||
{{safety_prompt}}
|
{{safety_prompt}}
|
||||||
|
|
||||||
|
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||||
|
- 如果评论包含中文字符(汉字),请用中文回复
|
||||||
|
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||||
|
- 如果评论包含韩文字符,请用韩文回复
|
||||||
|
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||||
|
- 绝对不要用与评论不同的语言回复
|
||||||
|
|
||||||
请回复以下评论。注意:
|
请回复以下评论。注意:
|
||||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||||
- 不要复述或总结文章内容
|
- 不要复述或总结文章内容
|
||||||
@@ -25,6 +32,20 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
|||||||
{{comment}}
|
{{comment}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 多语言回复
|
||||||
|
|
||||||
|
默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复:
|
||||||
|
|
||||||
|
- 中文评论 → 中文回复
|
||||||
|
- 英文评论 → 英文回复
|
||||||
|
- 日文评论 → 日文回复
|
||||||
|
- 韩文评论 → 韩文回复
|
||||||
|
- 其他语言 → 根据语言特征用相同语言回复
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。
|
||||||
|
:::
|
||||||
|
|
||||||
## 模板变量
|
## 模板变量
|
||||||
|
|
||||||
| 变量 | 说明 | 注入时机 |
|
| 变量 | 说明 | 注入时机 |
|
||||||
@@ -39,12 +60,26 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
|||||||
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
|
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
|
||||||
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
|
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
|
||||||
|
|
||||||
|
## 预设风格
|
||||||
|
|
||||||
|
预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合:
|
||||||
|
|
||||||
|
| 预设 | 说明 | 效果 |
|
||||||
|
|------|------|------|
|
||||||
|
| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 |
|
||||||
|
| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 |
|
||||||
|
| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 |
|
||||||
|
| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 |
|
||||||
|
| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 |
|
||||||
|
| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 |
|
||||||
|
| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 |
|
||||||
|
|
||||||
## 情感提示
|
## 情感提示
|
||||||
|
|
||||||
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
|
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
|
||||||
|
|
||||||
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
|
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
|
||||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。"
|
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
|
||||||
- **中性** → 不注入额外提示
|
- **中性** → 不注入额外提示
|
||||||
|
|
||||||
## 安全提示
|
## 安全提示
|
||||||
@@ -55,6 +90,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
|||||||
- 不泄露个人隐私信息
|
- 不泄露个人隐私信息
|
||||||
- 不生成虚假信息
|
- 不生成虚假信息
|
||||||
- 回复内容与评论相关
|
- 回复内容与评论相关
|
||||||
|
- 遇到恶意诱导时礼貌拒绝
|
||||||
|
|
||||||
## 自定义建议
|
## 自定义建议
|
||||||
|
|
||||||
@@ -66,6 +102,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
|||||||
4. 在变量之间添加清晰的分隔和指令
|
4. 在变量之间添加清晰的分隔和指令
|
||||||
5. 避免让AI复述文章内容
|
5. 避免让AI复述文章内容
|
||||||
6. 控制回复长度和风格
|
6. 控制回复长度和风格
|
||||||
|
7. 保留多语言回复指令以确保国际化支持
|
||||||
|
|
||||||
## 变量使用示例
|
## 变量使用示例
|
||||||
|
|
||||||
|
|||||||
+58
-12
@@ -7,24 +7,44 @@
|
|||||||
| 配置项 | 说明 | 默认值 |
|
| 配置项 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||||
| 自动发布 | AI回复是否自动发布 | 开启 |
|
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||||
|
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||||
|
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||||
| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 |
|
| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式 | 空 |
|
||||||
|
|
||||||
|
::: tip 评论者黑名单
|
||||||
|
黑名单支持三种匹配方式:
|
||||||
|
- **名称匹配**:直接输入评论者显示名称,如 `张三`
|
||||||
|
- **邮箱匹配**:输入邮箱地址,如 `spam@example.com`
|
||||||
|
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
|
||||||
|
|
||||||
|
多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。
|
||||||
|
:::
|
||||||
|
|
||||||
## AI角色设置
|
## AI角色设置
|
||||||
|
|
||||||
| 配置项 | 说明 | 默认值 |
|
AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。
|
||||||
|--------|------|--------|
|
|
||||||
| AI角色昵称 | AI回复者的显示名称 | 小回 |
|
|
||||||
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
|
||||||
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 |
|
|
||||||
|
|
||||||
默认人格提示词:
|
### 角色管理
|
||||||
|
|
||||||
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
- **添加角色** — 点击"添加角色"按钮创建新的AI角色
|
||||||
|
- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息
|
||||||
|
- **删除角色** — 点击删除图标移除角色(默认角色不可删除)
|
||||||
|
- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景)
|
||||||
|
- **角色排序** — 使用 ▲▼ 按钮调整角色顺序
|
||||||
|
|
||||||
|
### 角色配置项
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 昵称 | AI回复者的显示名称 |
|
||||||
|
| 邮箱 | 用于Gravatar头像服务展示头像 |
|
||||||
|
| 人格提示词 | 定义AI角色的人格和回复风格 |
|
||||||
|
| 设为默认角色 | 是否作为默认角色使用 |
|
||||||
|
|
||||||
::: tip Gravatar头像
|
::: tip Gravatar头像
|
||||||
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。
|
填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## 模型设置
|
## 模型设置
|
||||||
@@ -41,8 +61,21 @@
|
|||||||
|
|
||||||
| 配置项 | 说明 | 默认值 |
|
| 配置项 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
|
| Prompt预设 | 选择预设风格,可多选 | 无 |
|
||||||
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
|
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
|
||||||
|
|
||||||
|
### 预设风格
|
||||||
|
|
||||||
|
| 预设 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 友好型 | 热情友好,像朋友聊天 |
|
||||||
|
| 专业型 | 严谨正式,有逻辑性 |
|
||||||
|
| 幽默型 | 轻松诙谐,适当幽默 |
|
||||||
|
| 简洁型 | 一两句话,简洁明了 |
|
||||||
|
| 技术解答型 | 深入浅出,专业解答技术问题 |
|
||||||
|
| 鼓励型 | 积极正面,给予鼓励和支持 |
|
||||||
|
| 知识科普型 | 通俗易懂,普及相关知识 |
|
||||||
|
|
||||||
### 模板变量
|
### 模板变量
|
||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
@@ -52,7 +85,9 @@
|
|||||||
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
|
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
|
||||||
| `{{article}}` | 文章内容 |
|
| `{{article}}` | 文章内容 |
|
||||||
| `{{comment}}` | 评论内容 |
|
| `{{comment}}` | 评论内容 |
|
||||||
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
| `{{post_title}}` | 文章标题 |
|
||||||
|
| `{{post_date}}` | 文章发布日期 |
|
||||||
|
| `{{comment_count}}` | 评论数 |
|
||||||
|
|
||||||
## 数据清理
|
## 数据清理
|
||||||
|
|
||||||
@@ -62,5 +97,16 @@
|
|||||||
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
|
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
|
你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 配置导入/导出
|
||||||
|
|
||||||
|
设置页面右上角提供配置导入/导出功能:
|
||||||
|
|
||||||
|
- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件
|
||||||
|
- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
导入配置将覆盖当前配置,此操作不可撤销。
|
||||||
:::
|
:::
|
||||||
|
|||||||
+1
-1
@@ -25,5 +25,5 @@ features:
|
|||||||
- title: 灵活过滤
|
- title: 灵活过滤
|
||||||
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
||||||
- title: 数据管理
|
- title: 数据管理
|
||||||
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
|
details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录
|
||||||
---
|
---
|
||||||
|
|||||||
+388
-38
@@ -13,8 +13,10 @@ import run.halo.app.core.extension.content.Post;
|
|||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.GroupVersion;
|
import run.halo.app.extension.GroupVersion;
|
||||||
import run.halo.app.extension.ListOptions;
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
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;
|
||||||
@@ -22,11 +24,15 @@ import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
|||||||
import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
|
import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@@ -49,15 +55,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
private final AiReplyCleanupService cleanupService;
|
private final AiReplyCleanupService cleanupService;
|
||||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||||
|
private final CommentReplyPublisher commentReplyPublisher;
|
||||||
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, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
this.cleanupService = cleanupService;
|
this.cleanupService = cleanupService;
|
||||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||||
|
this.commentReplyPublisher = commentReplyPublisher;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = new ObjectMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +92,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.POST("/personas", this::createPersona)
|
.POST("/personas", this::createPersona)
|
||||||
.PUT("/personas/{name}", this::updatePersona)
|
.PUT("/personas/{name}", this::updatePersona)
|
||||||
.DELETE("/personas/{name}", this::deletePersona)
|
.DELETE("/personas/{name}", this::deletePersona)
|
||||||
|
// 导出配置
|
||||||
|
.GET("/export", this::exportConfig)
|
||||||
|
// 导入配置
|
||||||
|
.POST("/import", this::importConfig)
|
||||||
|
// 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展)
|
||||||
|
.PUT("/replies/{name}/content", this::updateReplyContent)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,31 +112,56 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
var statusFilter = request.queryParam("status").orElse("");
|
var statusFilter = request.queryParam("status").orElse("");
|
||||||
var sentimentFilter = request.queryParam("sentiment").orElse("");
|
var sentimentFilter = request.queryParam("sentiment").orElse("");
|
||||||
var keywordFilter = request.queryParam("keyword").orElse("");
|
var keywordFilter = request.queryParam("keyword").orElse("");
|
||||||
|
var startDateStr = request.queryParam("startDate").orElse("");
|
||||||
|
var endDateStr = request.queryParam("endDate").orElse("");
|
||||||
|
var sortOrder = request.queryParam("sortOrder").orElse("desc");
|
||||||
|
|
||||||
|
// Parse date filters
|
||||||
|
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
ZoneId zoneId = ZoneId.systemDefault();
|
||||||
|
Instant startInstant = null;
|
||||||
|
Instant endInstant = null;
|
||||||
|
try {
|
||||||
|
if (!startDateStr.isBlank()) {
|
||||||
|
startInstant = LocalDate.parse(startDateStr, dateFormatter).atStartOfDay(zoneId).toInstant();
|
||||||
|
}
|
||||||
|
if (!endDateStr.isBlank()) {
|
||||||
|
endInstant = LocalDate.parse(endDateStr, dateFormatter).plusDays(1).atStartOfDay(zoneId).toInstant();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse date filter: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
final Instant finalStartInstant = startInstant;
|
||||||
|
final Instant finalEndInstant = endInstant;
|
||||||
|
|
||||||
|
// Check if we need in-memory filtering (keyword or date range)
|
||||||
|
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|
||||||
|
|
||||||
|
if (needsMemoryFilter) {
|
||||||
|
// Fall back to listAll + in-memory filter for complex queries
|
||||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(replies -> {
|
.map(replies -> {
|
||||||
var filtered = replies.stream()
|
var filtered = replies.stream()
|
||||||
.filter(r -> {
|
.filter(r -> {
|
||||||
if (!statusFilter.isBlank()
|
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||||
&& !statusFilter.equals(r.getSpec().getStatus())) {
|
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!sentimentFilter.isBlank()
|
|
||||||
&& !sentimentFilter.equals(r.getSpec().getSentiment())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!keywordFilter.isBlank()) {
|
if (!keywordFilter.isBlank()) {
|
||||||
String reply = r.getSpec().getReply();
|
String reply = r.getSpec().getReply();
|
||||||
if (reply == null || !reply.contains(keywordFilter)) {
|
if (reply == null || !reply.contains(keywordFilter)) return false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
if (finalStartInstant != null || finalEndInstant != null) {
|
||||||
|
Instant creationTs = r.getMetadata().getCreationTimestamp();
|
||||||
|
if (creationTs == null) return false;
|
||||||
|
if (finalStartInstant != null && creationTs.isBefore(finalStartInstant)) return false;
|
||||||
|
if (finalEndInstant != null && !creationTs.isBefore(finalEndInstant)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sorted(Comparator.comparing(
|
.sorted(Comparator.comparing(
|
||||||
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
||||||
Comparator.nullsLast(Comparator.reverseOrder())
|
Comparator.nullsLast("asc".equalsIgnoreCase(sortOrder)
|
||||||
|
? Comparator.<Instant>naturalOrder() : Comparator.<Instant>reverseOrder())
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -145,6 +184,41 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple filters only - use server-side pagination
|
||||||
|
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
||||||
|
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||||
|
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||||
|
|
||||||
|
var listOptions = ListOptions.builder().build();
|
||||||
|
// Note: Halo's ListOptions fieldSelector support may be limited
|
||||||
|
// For status and sentiment, we'll still filter in memory but with paginated data
|
||||||
|
|
||||||
|
return client.listBy(AiCommentReply.class, listOptions,
|
||||||
|
PageRequestImpl.of(page - 1, size, sort))
|
||||||
|
.map(listResult -> {
|
||||||
|
var items = listResult.getItems();
|
||||||
|
// Apply status/sentiment filter in memory on the current page
|
||||||
|
var filtered = items.stream()
|
||||||
|
.filter(r -> {
|
||||||
|
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||||
|
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("items", filtered);
|
||||||
|
result.put("total", listResult.getTotal());
|
||||||
|
result.put("page", page);
|
||||||
|
result.put("size", size);
|
||||||
|
result.put("totalPages", (int) Math.ceil((double) listResult.getTotal() / size));
|
||||||
|
result.put("first", page == 1);
|
||||||
|
result.put("last", page >= (int) Math.ceil((double) listResult.getTotal() / size));
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> deleteReply(ServerRequest request) {
|
private Mono<ServerResponse> deleteReply(ServerRequest request) {
|
||||||
var name = request.pathVariable("name");
|
var name = request.pathVariable("name");
|
||||||
return client.fetch(AiCommentReply.class, name)
|
return client.fetch(AiCommentReply.class, name)
|
||||||
@@ -362,22 +436,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
var name = request.pathVariable("name");
|
var name = request.pathVariable("name");
|
||||||
return client.fetch(AiCommentReply.class, name)
|
return client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
// Find the corresponding Reply and set approved=true
|
String replyName = record.getSpec().getReplyName();
|
||||||
return findReplyForRecord(record)
|
if (replyName == null || replyName.isBlank()) {
|
||||||
|
// Draft mode: no Reply extension exists, create one with approved=true
|
||||||
|
return commentReplyPublisher.publishReply(
|
||||||
|
record.getSpec().getCommentId(),
|
||||||
|
record.getSpec().getReply(),
|
||||||
|
record.getSpec().getPostId(),
|
||||||
|
record.getSpec().getReplyTo(),
|
||||||
|
true,
|
||||||
|
record.getSpec().getPersonaName()
|
||||||
|
)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name);
|
||||||
|
return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"));
|
||||||
|
}))
|
||||||
|
.flatMap(publishedReply -> {
|
||||||
|
String newReplyName = publishedReply.getMetadata().getName();
|
||||||
|
return client.fetch(AiCommentReply.class, name)
|
||||||
|
.flatMap(latest -> {
|
||||||
|
latest.getSpec().setReplyName(newReplyName);
|
||||||
|
latest.getSpec().setPublished(true);
|
||||||
|
return client.update(latest);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||||
|
})
|
||||||
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||||
|
} else {
|
||||||
|
// Reply extension already exists, set approved=true
|
||||||
|
return client.fetch(Reply.class, replyName)
|
||||||
.flatMap(reply -> {
|
.flatMap(reply -> {
|
||||||
reply.getSpec().setApproved(true);
|
reply.getSpec().setApproved(true);
|
||||||
reply.getSpec().setApprovedTime(Instant.now());
|
reply.getSpec().setApprovedTime(Instant.now());
|
||||||
return client.update(reply);
|
return client.update(reply);
|
||||||
})
|
})
|
||||||
.then(Mono.defer(() -> {
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
// Update AiCommentReply record
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
return client.fetch(AiCommentReply.class, name)
|
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(latest -> {
|
.flatMap(latest -> {
|
||||||
latest.getSpec().setPublished(true);
|
latest.getSpec().setPublished(true);
|
||||||
return client.update(latest);
|
return client.update(latest);
|
||||||
});
|
})
|
||||||
}))
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
))
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
}
|
}
|
||||||
@@ -386,18 +491,28 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
var name = request.pathVariable("name");
|
var name = request.pathVariable("name");
|
||||||
return client.fetch(AiCommentReply.class, name)
|
return client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
// Delete the draft Reply if it exists
|
String replyName = record.getSpec().getReplyName();
|
||||||
return findReplyForRecord(record)
|
Mono<Void> deleteReplyMono;
|
||||||
|
if (replyName != null && !replyName.isBlank()) {
|
||||||
|
// Reply extension exists, delete it
|
||||||
|
deleteReplyMono = client.fetch(Reply.class, replyName)
|
||||||
.flatMap(reply -> client.delete(reply))
|
.flatMap(reply -> client.delete(reply))
|
||||||
.then(Mono.defer(() -> {
|
.then();
|
||||||
// Update AiCommentReply record status to REJECTED
|
} else {
|
||||||
return client.fetch(AiCommentReply.class, name)
|
// No Reply extension in draft mode, nothing to delete
|
||||||
|
deleteReplyMono = Mono.empty();
|
||||||
|
}
|
||||||
|
return deleteReplyMono
|
||||||
|
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(latest -> {
|
.flatMap(latest -> {
|
||||||
latest.getSpec().setStatus("REJECTED");
|
latest.getSpec().setStatus("REJECTED");
|
||||||
latest.getSpec().setPublished(false);
|
latest.getSpec().setPublished(false);
|
||||||
|
latest.getSpec().setReplyName(null);
|
||||||
return client.update(latest);
|
return client.update(latest);
|
||||||
});
|
})
|
||||||
}))
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
))
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
|
||||||
})
|
})
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
@@ -418,19 +533,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
return Flux.fromIterable(names)
|
return Flux.fromIterable(names)
|
||||||
.flatMap(name ->
|
.flatMap(name ->
|
||||||
client.fetch(AiCommentReply.class, name)
|
client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(record -> findReplyForRecord(record)
|
.flatMap(record -> {
|
||||||
|
String replyName = record.getSpec().getReplyName();
|
||||||
|
if (replyName == null || replyName.isBlank()) {
|
||||||
|
// Draft mode: no Reply extension exists, create one with approved=true
|
||||||
|
return commentReplyPublisher.publishReply(
|
||||||
|
record.getSpec().getCommentId(),
|
||||||
|
record.getSpec().getReply(),
|
||||||
|
record.getSpec().getPostId(),
|
||||||
|
record.getSpec().getReplyTo(),
|
||||||
|
true,
|
||||||
|
record.getSpec().getPersonaName()
|
||||||
|
)
|
||||||
|
.switchIfEmpty(Mono.error(new IllegalStateException("AI回复已存在,无法重复发布")))
|
||||||
|
.flatMap(publishedReply -> {
|
||||||
|
String newReplyName = publishedReply.getMetadata().getName();
|
||||||
|
return client.fetch(AiCommentReply.class, name)
|
||||||
|
.flatMap(latest -> {
|
||||||
|
latest.getSpec().setReplyName(newReplyName);
|
||||||
|
latest.getSpec().setPublished(true);
|
||||||
|
return client.update(latest);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Reply extension already exists, set approved=true
|
||||||
|
return client.fetch(Reply.class, replyName)
|
||||||
.flatMap(reply -> {
|
.flatMap(reply -> {
|
||||||
reply.getSpec().setApproved(true);
|
reply.getSpec().setApproved(true);
|
||||||
reply.getSpec().setApprovedTime(Instant.now());
|
reply.getSpec().setApprovedTime(Instant.now());
|
||||||
return client.update(reply);
|
return client.update(reply);
|
||||||
})
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(latest -> {
|
.flatMap(latest -> {
|
||||||
latest.getSpec().setPublished(true);
|
latest.getSpec().setPublished(true);
|
||||||
return client.update(latest);
|
return client.update(latest);
|
||||||
})))
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})
|
||||||
.thenReturn(true)
|
.thenReturn(true)
|
||||||
)
|
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
|
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
|
||||||
return Mono.just(false);
|
return Mono.just(false);
|
||||||
@@ -462,16 +609,29 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
return Flux.fromIterable(names)
|
return Flux.fromIterable(names)
|
||||||
.flatMap(name ->
|
.flatMap(name ->
|
||||||
client.fetch(AiCommentReply.class, name)
|
client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(record -> findReplyForRecord(record)
|
.flatMap(record -> {
|
||||||
|
String replyName = record.getSpec().getReplyName();
|
||||||
|
Mono<Void> deleteReplyMono;
|
||||||
|
if (replyName != null && !replyName.isBlank()) {
|
||||||
|
deleteReplyMono = client.fetch(Reply.class, replyName)
|
||||||
.flatMap(reply -> client.delete(reply))
|
.flatMap(reply -> client.delete(reply))
|
||||||
|
.then();
|
||||||
|
} else {
|
||||||
|
deleteReplyMono = Mono.empty();
|
||||||
|
}
|
||||||
|
return deleteReplyMono
|
||||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(latest -> {
|
.flatMap(latest -> {
|
||||||
latest.getSpec().setStatus("REJECTED");
|
latest.getSpec().setStatus("REJECTED");
|
||||||
latest.getSpec().setPublished(false);
|
latest.getSpec().setPublished(false);
|
||||||
|
latest.getSpec().setReplyName(null);
|
||||||
return client.update(latest);
|
return client.update(latest);
|
||||||
})))
|
})
|
||||||
.thenReturn(true)
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
)
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
))
|
||||||
|
.thenReturn(true);
|
||||||
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
|
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
|
||||||
return Mono.just(false);
|
return Mono.just(false);
|
||||||
@@ -600,8 +760,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||||
// Find the Reply that belongs to the same comment and was created by AI
|
// First try using replyName if available
|
||||||
// If the record has a quoteReply, match by that too for precision
|
String replyName = record.getSpec().getReplyName();
|
||||||
|
if (replyName != null && !replyName.isBlank()) {
|
||||||
|
return client.fetch(Reply.class, replyName);
|
||||||
|
}
|
||||||
|
// Fallback: find the Reply by commentName + owner annotations
|
||||||
return client.list(Reply.class,
|
return client.list(Reply.class,
|
||||||
reply -> {
|
reply -> {
|
||||||
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
|
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
|
||||||
@@ -634,7 +798,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
|
|
||||||
public record CommenterInfo(
|
public record CommenterInfo(
|
||||||
String displayName,
|
String displayName,
|
||||||
String email
|
String email,
|
||||||
|
String avatarUrl
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||||
@@ -647,11 +812,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null;
|
var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null;
|
||||||
if (owner == null) continue;
|
if (owner == null) continue;
|
||||||
String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : "";
|
String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : "";
|
||||||
String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null
|
String email = Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()) && owner.getName() != null
|
||||||
? owner.getName() : "";
|
? owner.getName() : "";
|
||||||
String key = displayName.toLowerCase() + "|" + email.toLowerCase();
|
String key = displayName.toLowerCase() + "|" + email.toLowerCase();
|
||||||
if (seen.add(key)) {
|
if (seen.add(key)) {
|
||||||
result.add(new CommenterInfo(displayName, email));
|
// 优先使用 owner 注解中的头像,否则用邮箱生成 Gravatar
|
||||||
|
String avatarUrl = "";
|
||||||
|
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
|
||||||
|
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
||||||
|
} else if (!email.isBlank()) {
|
||||||
|
avatarUrl = generateGravatarUrl(email);
|
||||||
|
}
|
||||||
|
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -659,6 +831,20 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String generateGravatarUrl(String email) {
|
||||||
|
try {
|
||||||
|
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||||
|
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
var hexString = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
hexString.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return "https://cn.cravatar.com/avatar/" + hexString;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
||||||
return Mono.fromCallable(() -> {
|
return Mono.fromCallable(() -> {
|
||||||
int retentionDays = cleanupService.getRetentionDays();
|
int retentionDays = cleanupService.getRetentionDays();
|
||||||
@@ -703,6 +889,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
private Mono<ServerResponse> listPersonas(ServerRequest request) {
|
private Mono<ServerResponse> listPersonas(ServerRequest request) {
|
||||||
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
.collectList()
|
.collectList()
|
||||||
|
.map(personas -> personas.stream()
|
||||||
|
.sorted(Comparator.comparing(
|
||||||
|
(AiPersona p) -> p.getSpec() != null ? p.getSpec().getPriority() : null,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
)
|
||||||
.flatMap(personas -> ServerResponse.ok().bodyValue(personas));
|
.flatMap(personas -> ServerResponse.ok().bodyValue(personas));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,6 +935,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
existing.setSpec(updatedPersona.getSpec());
|
existing.setSpec(updatedPersona.getSpec());
|
||||||
return client.update(existing);
|
return client.update(existing);
|
||||||
})
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
.flatMap(saved -> ServerResponse.ok().bodyValue(saved))
|
.flatMap(saved -> ServerResponse.ok().bodyValue(saved))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Failed to update persona {}: {}", name, e.getMessage());
|
log.warn("Failed to update persona {}: {}", name, e.getMessage());
|
||||||
@@ -765,4 +960,159 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
})
|
})
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> exportConfig(ServerRequest request) {
|
||||||
|
var result = new java.util.LinkedHashMap<String, Object>();
|
||||||
|
// 导出 ConfigMap
|
||||||
|
return client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.map(configMap -> {
|
||||||
|
result.put("configMap", configMap.getData());
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(result)
|
||||||
|
.flatMap(r -> {
|
||||||
|
// 导出所有 AiPersona
|
||||||
|
return client.list(AiPersona.class, null, null)
|
||||||
|
.collectList()
|
||||||
|
.map(personas -> {
|
||||||
|
r.put("personas", personas);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.flatMap(r -> ServerResponse.ok()
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> importConfig(ServerRequest request) {
|
||||||
|
return request.bodyToMono(java.util.Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
if (body == null || !body.containsKey("configMap") && !body.containsKey("personas")) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(java.util.Map.of("error", "无效的配置格式"));
|
||||||
|
}
|
||||||
|
var results = new java.util.ArrayList<String>();
|
||||||
|
Mono<Void> importMono = Mono.empty();
|
||||||
|
|
||||||
|
// 导入 ConfigMap
|
||||||
|
if (body.containsKey("configMap")) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var configMapData = (java.util.Map<String, String>) body.get("configMap");
|
||||||
|
importMono = importMono.then(
|
||||||
|
client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setData(configMapData);
|
||||||
|
return client.update(existing)
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
.doOnSuccess(v -> results.add("ConfigMap 已更新"))
|
||||||
|
.then();
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
run.halo.app.extension.ConfigMap cm = new run.halo.app.extension.ConfigMap();
|
||||||
|
cm.setMetadata(new run.halo.app.extension.Metadata());
|
||||||
|
cm.getMetadata().setName(CONFIG_MAP_NAME);
|
||||||
|
cm.setData(configMapData);
|
||||||
|
return client.create(cm)
|
||||||
|
.doOnSuccess(v -> results.add("ConfigMap 已创建"))
|
||||||
|
.then();
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入 AiPersona
|
||||||
|
if (body.containsKey("personas")) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var personasList = (java.util.List<java.util.Map<String, Object>>) body.get("personas");
|
||||||
|
for (var personaData : personasList) {
|
||||||
|
importMono = importMono.then(Mono.defer(() -> {
|
||||||
|
try {
|
||||||
|
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
var personaJson = objectMapper.writeValueAsString(personaData);
|
||||||
|
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
||||||
|
var personaName = persona.getMetadata().getName();
|
||||||
|
return client.fetch(AiPersona.class, personaName)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
persona.getMetadata().setVersion(existing.getMetadata().getVersion());
|
||||||
|
return client.update(persona)
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
.doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已更新"))
|
||||||
|
.then();
|
||||||
|
})
|
||||||
|
.switchIfEmpty(client.create(persona)
|
||||||
|
.doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已创建"))
|
||||||
|
.then());
|
||||||
|
} catch (Exception e) {
|
||||||
|
results.add("导入角色失败: " + e.getMessage());
|
||||||
|
return Mono.<Void>empty();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return importMono.then(
|
||||||
|
ServerResponse.ok().bodyValue(java.util.Map.of("results", results))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> ServerResponse.badRequest()
|
||||||
|
.bodyValue(java.util.Map.of("error", "导入失败: " + e.getMessage())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> updateReplyContent(ServerRequest request) {
|
||||||
|
var name = request.pathVariable("name");
|
||||||
|
return request.bodyToMono(String.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
String newReply;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(body);
|
||||||
|
JsonNode replyNode = node.get("reply");
|
||||||
|
if (replyNode == null || replyNode.asText().isBlank()) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("message", "reply 字段不能为空"));
|
||||||
|
}
|
||||||
|
newReply = replyNode.asText();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("message", "请求体格式错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.fetch(AiCommentReply.class, name)
|
||||||
|
.flatMap(record -> {
|
||||||
|
// Only allow when published is false (draft mode)
|
||||||
|
if (Boolean.TRUE.equals(record.getSpec().getPublished())) {
|
||||||
|
return ServerResponse.badRequest()
|
||||||
|
.bodyValue(Map.of("message", "已发布的回复不可编辑"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update AiCommentReply.spec.reply
|
||||||
|
return client.fetch(AiCommentReply.class, name)
|
||||||
|
.flatMap(latest -> {
|
||||||
|
latest.getSpec().setReply(newReply);
|
||||||
|
return client.update(latest);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
.flatMap(updatedRecord -> {
|
||||||
|
String replyName = updatedRecord.getSpec().getReplyName();
|
||||||
|
if (replyName != null && !replyName.isBlank()) {
|
||||||
|
// Reply extension exists, update its content too
|
||||||
|
return client.fetch(Reply.class, replyName)
|
||||||
|
.flatMap(reply -> {
|
||||||
|
reply.getSpec().setRaw(newReply);
|
||||||
|
reply.getSpec().setContent(newReply);
|
||||||
|
return client.update(reply);
|
||||||
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
|
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)));
|
||||||
|
}
|
||||||
|
// Draft mode: no Reply extension, only update AiCommentReply
|
||||||
|
return Mono.just(updatedRecord);
|
||||||
|
})
|
||||||
|
.flatMap(finalRecord -> ServerResponse.ok().bodyValue(finalRecord));
|
||||||
|
})
|
||||||
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,5 +59,8 @@ public class AiCommentReply extends AbstractExtension {
|
|||||||
|
|
||||||
@Schema(description = "使用的AI角色名称")
|
@Schema(description = "使用的AI角色名称")
|
||||||
private String personaName;
|
private String personaName;
|
||||||
|
|
||||||
|
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
|
||||||
|
private String replyName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,5 +37,8 @@ public class AiPersona extends AbstractExtension {
|
|||||||
@Schema(description = "是否为默认角色")
|
@Schema(description = "是否为默认角色")
|
||||||
@JsonProperty("isDefault")
|
@JsonProperty("isDefault")
|
||||||
private Boolean isDefault;
|
private Boolean isDefault;
|
||||||
|
|
||||||
|
@Schema(description = "排序优先级,数值越小越靠前")
|
||||||
|
private Integer priority;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import run.halo.app.core.extension.content.Category;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
import run.halo.app.core.extension.content.Post;
|
||||||
|
import run.halo.app.core.extension.content.Tag;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
@@ -148,6 +150,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
String postName = subjectRef.getName();
|
String postName = subjectRef.getName();
|
||||||
return client.fetch(Post.class, postName)
|
return client.fetch(Post.class, postName)
|
||||||
.map(post -> {
|
.map(post -> {
|
||||||
|
// 1. 文章注解优先
|
||||||
var annotations = post.getMetadata().getAnnotations();
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
if (annotations != null) {
|
if (annotations != null) {
|
||||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
@@ -155,6 +158,37 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
return persona;
|
return persona;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 2. 分类注解
|
||||||
|
var spec = post.getSpec();
|
||||||
|
if (spec != null && spec.getCategories() != null) {
|
||||||
|
for (String categoryName : spec.getCategories()) {
|
||||||
|
var cat = client.fetch(Category.class, categoryName).orElse(null);
|
||||||
|
if (cat != null) {
|
||||||
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
|
if (catAnnotations != null) {
|
||||||
|
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (catPersona != null && !catPersona.isBlank()) {
|
||||||
|
return catPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. 标签注解
|
||||||
|
if (spec != null && spec.getTags() != null) {
|
||||||
|
for (String tagName : spec.getTags()) {
|
||||||
|
var tag = client.fetch(Tag.class, tagName).orElse(null);
|
||||||
|
if (tag != null) {
|
||||||
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
|
if (tagAnnotations != null) {
|
||||||
|
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (tagPersona != null && !tagPersona.isBlank()) {
|
||||||
|
return tagPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import run.halo.app.core.extension.content.Category;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
import run.halo.app.core.extension.content.Post;
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
|
import run.halo.app.core.extension.content.Tag;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
@@ -159,6 +161,7 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
String postName = subjectRef.getName();
|
String postName = subjectRef.getName();
|
||||||
return client.fetch(Post.class, postName)
|
return client.fetch(Post.class, postName)
|
||||||
.map(post -> {
|
.map(post -> {
|
||||||
|
// 1. 文章注解优先
|
||||||
var annotations = post.getMetadata().getAnnotations();
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
if (annotations != null) {
|
if (annotations != null) {
|
||||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
@@ -166,6 +169,37 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
return persona;
|
return persona;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 2. 分类注解
|
||||||
|
var spec = post.getSpec();
|
||||||
|
if (spec != null && spec.getCategories() != null) {
|
||||||
|
for (String categoryName : spec.getCategories()) {
|
||||||
|
var cat = client.fetch(Category.class, categoryName).orElse(null);
|
||||||
|
if (cat != null) {
|
||||||
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
|
if (catAnnotations != null) {
|
||||||
|
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (catPersona != null && !catPersona.isBlank()) {
|
||||||
|
return catPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. 标签注解
|
||||||
|
if (spec != null && spec.getTags() != null) {
|
||||||
|
for (String tagName : spec.getTags()) {
|
||||||
|
var tag = client.fetch(Tag.class, tagName).orElse(null);
|
||||||
|
if (tag != null) {
|
||||||
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
|
if (tagAnnotations != null) {
|
||||||
|
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (tagPersona != null && !tagPersona.isBlank()) {
|
||||||
|
return tagPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ public class AiReplyOrchestrator {
|
|||||||
|
|
||||||
// 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<>();
|
// Value is the timestamp when the lock was acquired, used for leak detection
|
||||||
|
private final ConcurrentHashMap<String, Long> processingLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static final long LOCK_EXPIRY_MS = 2 * 60 * 1000L; // 2 minutes
|
||||||
|
|
||||||
public AiReplyOrchestrator(ContextExtractor contextExtractor,
|
public AiReplyOrchestrator(ContextExtractor contextExtractor,
|
||||||
PromptBuilder promptBuilder,
|
PromptBuilder promptBuilder,
|
||||||
@@ -71,8 +74,11 @@ public class AiReplyOrchestrator {
|
|||||||
String personaName) {
|
String personaName) {
|
||||||
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
||||||
|
|
||||||
|
// Clean up stale locks before acquiring new one
|
||||||
|
cleanupStaleLocks();
|
||||||
|
|
||||||
// In-memory dedup: if already processing, skip immediately
|
// In-memory dedup: if already processing, skip immediately
|
||||||
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
|
if (processingLocks.putIfAbsent(lockKey, System.currentTimeMillis()) != null) {
|
||||||
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
|
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
@@ -220,7 +226,7 @@ public class AiReplyOrchestrator {
|
|||||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||||
context.commentId());
|
context.commentId());
|
||||||
// Save the failed reply content, then retry
|
// Save the failed reply content, then retry
|
||||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false)
|
return updateRecord(replyRecord, aiReply, 0, "FAIL", false, null)
|
||||||
.then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed"));
|
.then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed"));
|
||||||
}
|
}
|
||||||
return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
|
return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
|
||||||
@@ -263,7 +269,7 @@ public class AiReplyOrchestrator {
|
|||||||
} else {
|
} else {
|
||||||
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
||||||
maxRetry, context.commentId(), reason);
|
maxRetry, context.commentId(), reason);
|
||||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
return updateRecord(replyRecord, "", 0, "FAIL", false, null).then();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,19 +311,36 @@ public class AiReplyOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish the reply and update the record to PASS + published=true.
|
* Publish the reply and update the record.
|
||||||
|
* When autoPublish=true: create Reply extension and save replyName.
|
||||||
|
* When autoPublish=false (draft mode): do NOT create Reply extension, only save AI reply content.
|
||||||
*/
|
*/
|
||||||
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
|
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
|
||||||
AiCommentReply replyRecord, int score, String personaName) {
|
AiCommentReply replyRecord, int score, String personaName) {
|
||||||
return isAutoPublishEnabled()
|
return isAutoPublishEnabled()
|
||||||
.flatMap(autoPublish -> {
|
.flatMap(autoPublish -> {
|
||||||
|
if (autoPublish) {
|
||||||
|
// Auto publish: create Reply extension and save replyName
|
||||||
return commentReplyPublisher.publishReply(
|
return commentReplyPublisher.publishReply(
|
||||||
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName)
|
context.commentId(), aiReply, context.postId(), context.replyTo(), true, personaName)
|
||||||
.flatMap(publishedReply -> {
|
.flatMap(publishedReply -> {
|
||||||
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
|
String replyName = publishedReply.getMetadata().getName();
|
||||||
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
|
log.info("[Orchestrator] Reply published for: {}, replyName={}", context.commentId(), replyName);
|
||||||
|
return updateRecord(replyRecord, aiReply, score, "PASS", true, replyName);
|
||||||
})
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
// publishReply returned empty (dedup: AI reply already exists)
|
||||||
|
// Fallback to draft mode to avoid leaving record in PENDING state
|
||||||
|
log.warn("[Orchestrator] publishReply returned empty for {}, falling back to draft mode", context.commentId());
|
||||||
|
return updateRecord(replyRecord, aiReply, score, "PASS", false, null);
|
||||||
|
}))
|
||||||
.flatMap(updated -> Mono.empty());
|
.flatMap(updated -> Mono.empty());
|
||||||
|
} else {
|
||||||
|
// Draft mode: do NOT create Reply extension, only save AI reply content
|
||||||
|
log.info("[Orchestrator] Draft mode: saving reply content without creating Reply extension for: {}", context.commentId());
|
||||||
|
return updateRecord(replyRecord, aiReply, score, "PASS", false, null)
|
||||||
|
.flatMap(updated -> Mono.empty());
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
@@ -515,15 +538,17 @@ public class AiReplyOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
|
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
|
||||||
int score, String status, boolean published) {
|
int score, String status, boolean published,
|
||||||
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
|
String replyName) {
|
||||||
record.getMetadata().getName(), status, score, published);
|
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}, replyName={}",
|
||||||
|
record.getMetadata().getName(), status, score, published, replyName);
|
||||||
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||||
.flatMap(latest -> {
|
.flatMap(latest -> {
|
||||||
latest.getSpec().setReply(reply);
|
latest.getSpec().setReply(reply);
|
||||||
latest.getSpec().setScore(score);
|
latest.getSpec().setScore(score);
|
||||||
latest.getSpec().setStatus(status);
|
latest.getSpec().setStatus(status);
|
||||||
latest.getSpec().setPublished(published);
|
latest.getSpec().setPublished(published);
|
||||||
|
latest.getSpec().setReplyName(replyName);
|
||||||
return client.update(latest);
|
return client.update(latest);
|
||||||
})
|
})
|
||||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
@@ -531,7 +556,24 @@ public class AiReplyOrchestrator {
|
|||||||
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
|
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
|
||||||
record.getMetadata().getName()))
|
record.getMetadata().getName()))
|
||||||
)
|
)
|
||||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
|
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}, replyName={}",
|
||||||
record.getMetadata().getName(), status, score, published));
|
record.getMetadata().getName(), status, score, published, replyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale locks that have been held longer than LOCK_EXPIRY_MS.
|
||||||
|
* This prevents memory leaks in case of unexpected errors or cancellations
|
||||||
|
* that bypass the doFinally cleanup.
|
||||||
|
*/
|
||||||
|
private void cleanupStaleLocks() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
processingLocks.entrySet().removeIf(entry -> {
|
||||||
|
long age = now - entry.getValue();
|
||||||
|
if (age > LOCK_EXPIRY_MS) {
|
||||||
|
log.warn("[Orchestrator] Removing stale lock: {} (held for {}ms)", entry.getKey(), age);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ public class CommentReplyPublisher {
|
|||||||
|
|
||||||
Map<String, String> ownerAnnotations = new HashMap<>();
|
Map<String, String> ownerAnnotations = new HashMap<>();
|
||||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||||
|
// 使用Gravatar邮箱头像
|
||||||
if (email != null && !email.isBlank()) {
|
if (email != null && !email.isBlank()) {
|
||||||
String gravatarUrl = generateGravatarUrl(email);
|
String gravatarUrl = generateGravatarUrl(email);
|
||||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||||
@@ -125,9 +126,16 @@ public class CommentReplyPublisher {
|
|||||||
owner.setAnnotations(ownerAnnotations);
|
owner.setAnnotations(ownerAnnotations);
|
||||||
spec.setOwner(owner);
|
spec.setOwner(owner);
|
||||||
|
|
||||||
|
log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}",
|
||||||
|
parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations);
|
||||||
|
|
||||||
return client.create(reply)
|
return client.create(reply)
|
||||||
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
|
.doOnSuccess(created -> {
|
||||||
displayName, parentCommentName, quoteReplyName))
|
var createdOwner = created.getSpec().getOwner();
|
||||||
|
log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}",
|
||||||
|
displayName, parentCommentName, quoteReplyName,
|
||||||
|
createdOwner != null ? createdOwner.getAnnotations() : "null");
|
||||||
|
})
|
||||||
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
|
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -142,10 +150,14 @@ public class CommentReplyPublisher {
|
|||||||
private Mono<ResolvedPersona> resolvePersona(String personaName) {
|
private Mono<ResolvedPersona> resolvePersona(String personaName) {
|
||||||
if (personaName != null && !personaName.isBlank()) {
|
if (personaName != null && !personaName.isBlank()) {
|
||||||
return client.fetch(AiPersona.class, personaName)
|
return client.fetch(AiPersona.class, personaName)
|
||||||
.map(p -> new ResolvedPersona(
|
.map(p -> {
|
||||||
|
log.info("[Publisher] Resolved persona by name: {}, email={}",
|
||||||
|
personaName, p.getSpec().getEmail());
|
||||||
|
return new ResolvedPersona(
|
||||||
p.getSpec().getDisplayName(),
|
p.getSpec().getDisplayName(),
|
||||||
p.getSpec().getEmail()
|
p.getSpec().getEmail()
|
||||||
))
|
);
|
||||||
|
})
|
||||||
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
||||||
}
|
}
|
||||||
// Find default persona
|
// Find default persona
|
||||||
@@ -153,10 +165,14 @@ public class CommentReplyPublisher {
|
|||||||
persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
|
persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
|
||||||
null)
|
null)
|
||||||
.next()
|
.next()
|
||||||
.map(p -> new ResolvedPersona(
|
.map(p -> {
|
||||||
|
log.info("[Publisher] Resolved default persona: {}, email={}",
|
||||||
|
p.getMetadata().getName(), p.getSpec().getEmail());
|
||||||
|
return new ResolvedPersona(
|
||||||
p.getSpec().getDisplayName(),
|
p.getSpec().getDisplayName(),
|
||||||
p.getSpec().getEmail()
|
p.getSpec().getEmail()
|
||||||
))
|
);
|
||||||
|
})
|
||||||
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public class FilterService {
|
|||||||
private String getCommenterEmail(Comment comment) {
|
private String getCommenterEmail(Comment comment) {
|
||||||
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||||
var owner = comment.getSpec().getOwner();
|
var owner = comment.getSpec().getOwner();
|
||||||
if ("EMAIL".equals(owner.getKind())) {
|
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
|
||||||
var name = owner.getName();
|
var name = owner.getName();
|
||||||
return name != null ? name : "";
|
return name != null ? name : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ public class PromptBuilder {
|
|||||||
|
|
||||||
private static final String PRESET_CONCISE = """
|
private static final String PRESET_CONCISE = """
|
||||||
【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
|
【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String PRESET_TECHNICAL = """
|
||||||
|
【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String PRESET_ENCOURAGING = """
|
||||||
|
【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String PRESET_EDUCATIONAL = """
|
||||||
|
【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final Map<String, String> PRESET_MAP = new LinkedHashMap<>();
|
private static final Map<String, String> PRESET_MAP = new LinkedHashMap<>();
|
||||||
@@ -47,6 +59,9 @@ public class PromptBuilder {
|
|||||||
PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
|
PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
|
||||||
PRESET_MAP.put("humorous", PRESET_HUMOROUS);
|
PRESET_MAP.put("humorous", PRESET_HUMOROUS);
|
||||||
PRESET_MAP.put("concise", PRESET_CONCISE);
|
PRESET_MAP.put("concise", PRESET_CONCISE);
|
||||||
|
PRESET_MAP.put("technical", PRESET_TECHNICAL);
|
||||||
|
PRESET_MAP.put("encouraging", PRESET_ENCOURAGING);
|
||||||
|
PRESET_MAP.put("educational", PRESET_EDUCATIONAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String SAFETY_PROMPT = """
|
private static final String SAFETY_PROMPT = """
|
||||||
@@ -61,7 +76,12 @@ public class PromptBuilder {
|
|||||||
|
|
||||||
{{safety_prompt}}
|
{{safety_prompt}}
|
||||||
|
|
||||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||||
|
- 如果评论包含中文字符(汉字),请用中文回复
|
||||||
|
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||||
|
- 如果评论包含韩文字符,请用韩文回复
|
||||||
|
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||||
|
- 绝对不要用与评论不同的语言回复
|
||||||
|
|
||||||
请回复以下评论。注意:
|
请回复以下评论。注意:
|
||||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||||
|
|||||||
@@ -37,3 +37,33 @@ spec:
|
|||||||
label: AI角色
|
label: AI角色
|
||||||
help: 选择该页面使用的AI回复角色名称,留空使用默认角色
|
help: 选择该页面使用的AI回复角色名称,留空使用默认角色
|
||||||
value: ""
|
value: ""
|
||||||
|
---
|
||||||
|
apiVersion: v1alpha1
|
||||||
|
kind: AnnotationSetting
|
||||||
|
metadata:
|
||||||
|
name: comment-ai-autopilot-category-annotation-setting
|
||||||
|
spec:
|
||||||
|
targetRef:
|
||||||
|
group: content.halo.run
|
||||||
|
kind: Category
|
||||||
|
formSchema:
|
||||||
|
- $formkit: text
|
||||||
|
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||||
|
label: AI角色
|
||||||
|
help: 选择该分类下文章使用的AI回复角色名称,留空使用默认角色
|
||||||
|
value: ""
|
||||||
|
---
|
||||||
|
apiVersion: v1alpha1
|
||||||
|
kind: AnnotationSetting
|
||||||
|
metadata:
|
||||||
|
name: comment-ai-autopilot-tag-annotation-setting
|
||||||
|
spec:
|
||||||
|
targetRef:
|
||||||
|
group: content.halo.run
|
||||||
|
kind: Tag
|
||||||
|
formSchema:
|
||||||
|
- $formkit: text
|
||||||
|
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||||
|
label: AI角色
|
||||||
|
help: 选择该标签下文章使用的AI回复角色名称,留空使用默认角色
|
||||||
|
value: ""
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ rules:
|
|||||||
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
||||||
resources: ["*"]
|
resources: ["*"]
|
||||||
verbs: ["*"]
|
verbs: ["*"]
|
||||||
|
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
||||||
|
resources: ["export"]
|
||||||
|
verbs: ["get"]
|
||||||
|
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
||||||
|
resources: ["import"]
|
||||||
|
verbs: ["create"]
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ spec:
|
|||||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||||
settingName: "comment-ai-autopilot-settings"
|
settingName: "comment-ai-autopilot-settings"
|
||||||
configMapName: "comment-ai-autopilot-configmap"
|
configMapName: "comment-ai-autopilot-configmap"
|
||||||
version: "0.0.1-t5w8r3"
|
version: "0.0.0-ygkszvd"
|
||||||
pluginDependencies:
|
pluginDependencies:
|
||||||
ai-foundation: "*"
|
ai-foundation: "*"
|
||||||
|
|||||||
+10
-78
@@ -177,53 +177,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<!-- Daily Trend -->
|
|
||||||
<VCard :body-class="['!p-5']">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
|
|
||||||
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs transition-colors"
|
|
||||||
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = '7'"
|
|
||||||
>
|
|
||||||
7天
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
|
||||||
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = '30'"
|
|
||||||
>
|
|
||||||
30天
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
|
||||||
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = 'all'"
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
|
|
||||||
<div
|
|
||||||
v-for="day in stats.dailyTrend"
|
|
||||||
:key="day.date"
|
|
||||||
class="flex-1 flex flex-col items-center justify-end h-full"
|
|
||||||
>
|
|
||||||
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
|
|
||||||
<div
|
|
||||||
class="w-full rounded-t-md transition-all duration-500"
|
|
||||||
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
|
|
||||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
|
||||||
></div>
|
|
||||||
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom: Score + Quick Actions -->
|
<!-- Bottom: Score + Quick Actions -->
|
||||||
@@ -299,16 +252,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue"
|
import { ref, computed, onMounted } from "vue"
|
||||||
import { axiosInstance } from "@halo-dev/api-client"
|
import { axiosInstance } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
import { IconPlug } from "@halo-dev/components"
|
||||||
|
|
||||||
interface DailyCount {
|
|
||||||
date: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatsResponse {
|
interface StatsResponse {
|
||||||
total: number
|
total: number
|
||||||
passCount: number
|
passCount: number
|
||||||
@@ -316,7 +264,6 @@ interface StatsResponse {
|
|||||||
reviewingCount: number
|
reviewingCount: number
|
||||||
avgScore: number
|
avgScore: number
|
||||||
sentimentDistribution: Record<string, number>
|
sentimentDistribution: Record<string, number>
|
||||||
dailyTrend: DailyCount[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersonaResponse {
|
interface PersonaResponse {
|
||||||
@@ -331,7 +278,6 @@ interface HealthResponse {
|
|||||||
|
|
||||||
const stats = ref<StatsResponse | null>(null)
|
const stats = ref<StatsResponse | null>(null)
|
||||||
const persona = ref<PersonaResponse | null>(null)
|
const persona = ref<PersonaResponse | null>(null)
|
||||||
const range = ref("7")
|
|
||||||
const health = ref<HealthResponse | null>(null)
|
const health = ref<HealthResponse | null>(null)
|
||||||
const healthVisible = ref(true)
|
const healthVisible = ref(true)
|
||||||
|
|
||||||
@@ -357,7 +303,7 @@ const scoreBarColor = computed(() => {
|
|||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axiosInstance.get(
|
const { data } = await axiosInstance.get(
|
||||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
|
||||||
)
|
)
|
||||||
stats.value = data
|
stats.value = data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -367,9 +313,11 @@ const fetchStats = async () => {
|
|||||||
|
|
||||||
const computeGravatarHash = async (email: string): Promise<string> => {
|
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||||
const normalized = email.trim().toLowerCase()
|
const normalized = email.trim().toLowerCase()
|
||||||
const data = new TextEncoder().encode(normalized)
|
const encoder = new TextEncoder()
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
const data = encoder.encode(normalized)
|
||||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
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('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPersona = async () => {
|
const fetchPersona = async () => {
|
||||||
@@ -378,14 +326,15 @@ const fetchPersona = async () => {
|
|||||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
|
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
|
||||||
)
|
)
|
||||||
const personas = Array.isArray(data) ? data : (data.items || [])
|
const personas = Array.isArray(data) ? data : (data.items || [])
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const defaultPersona = personas.find((p: any) => p.spec?.isDefault) || personas[0]
|
const defaultPersona = personas.find((p: any) => p.spec?.isDefault) || personas[0]
|
||||||
if (defaultPersona) {
|
if (defaultPersona) {
|
||||||
persona.value = {
|
persona.value = {
|
||||||
name: defaultPersona.spec?.displayName || '未命名',
|
name: defaultPersona.spec?.displayName || '未命名',
|
||||||
prompt: defaultPersona.spec?.prompt || '',
|
prompt: defaultPersona.spec?.prompt || '',
|
||||||
avatar: '', // 需要单独计算
|
avatar: '',
|
||||||
}
|
}
|
||||||
// 计算 Gravatar
|
// 使用Gravatar邮箱头像
|
||||||
const email = defaultPersona.spec?.email
|
const email = defaultPersona.spec?.email
|
||||||
if (email) {
|
if (email) {
|
||||||
const hash = await computeGravatarHash(email)
|
const hash = await computeGravatarHash(email)
|
||||||
@@ -426,23 +375,6 @@ const getSentimentPercent = (sentiment: string): number => {
|
|||||||
return Math.round(((dist[sentiment] || 0) / total) * 100)
|
return Math.round(((dist[sentiment] || 0) / total) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrendBarHeight = (count: number): number => {
|
|
||||||
const trend = stats.value?.dailyTrend
|
|
||||||
if (!trend || trend.length === 0) return 0
|
|
||||||
const max = Math.max(...trend.map(d => d.count), 1)
|
|
||||||
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTrendDate = (dateStr: string): string => {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const parts = dateStr.split('-')
|
|
||||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(range, () => {
|
|
||||||
fetchStats()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStats()
|
fetchStats()
|
||||||
fetchPersona()
|
fetchPersona()
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
placeholder="搜索回复内容..."
|
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"
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
|
<input v-model="filterStartDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
|
<span class="text-xs text-gray-400">至</span>
|
||||||
|
<input v-model="filterEndDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
<button
|
<button
|
||||||
class="text-xs text-gray-500 hover:text-gray-700"
|
class="text-xs text-gray-500 hover:text-gray-700"
|
||||||
@click="resetFilters"
|
@click="resetFilters"
|
||||||
@@ -172,6 +175,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
|
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors px-2 py-1 rounded hover:bg-blue-50"
|
||||||
|
@click="openEditDialog(reply)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
||||||
@click="handleApprove(reply.metadata.name)"
|
@click="handleApprove(reply.metadata.name)"
|
||||||
@@ -299,6 +308,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="showEditDialog" class="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/40" @click="showEditDialog = false"></div>
|
||||||
|
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800">编辑AI回复</h3>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100" @click="showEditDialog = false">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 px-5 py-4">
|
||||||
|
<textarea v-model="editContent" rows="8" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 resize-y" placeholder="输入回复内容..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
|
||||||
|
<button class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg" @click="showEditDialog = false">取消</button>
|
||||||
|
<button class="px-4 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50" :disabled="editSaving" @click="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -351,6 +382,8 @@ const selectAll = ref(false)
|
|||||||
const filterStatus = ref("")
|
const filterStatus = ref("")
|
||||||
const filterSentiment = ref("")
|
const filterSentiment = ref("")
|
||||||
const filterKeyword = ref("")
|
const filterKeyword = ref("")
|
||||||
|
const filterStartDate = ref("")
|
||||||
|
const filterEndDate = ref("")
|
||||||
|
|
||||||
const toggleSelect = (name: string) => {
|
const toggleSelect = (name: string) => {
|
||||||
if (selectedNames.value.has(name)) {
|
if (selectedNames.value.has(name)) {
|
||||||
@@ -377,6 +410,12 @@ const showDialog = ref(false)
|
|||||||
const conversationLoading = ref(false)
|
const conversationLoading = ref(false)
|
||||||
const conversationMessages = ref<ConversationMessage[]>([])
|
const conversationMessages = ref<ConversationMessage[]>([])
|
||||||
|
|
||||||
|
// Edit dialog state
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const editingReply = ref<AiCommentReplyItem | null>(null)
|
||||||
|
const editContent = ref("")
|
||||||
|
const editSaving = ref(false)
|
||||||
|
|
||||||
const fetchReplies = async () => {
|
const fetchReplies = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -384,6 +423,8 @@ const fetchReplies = async () => {
|
|||||||
if (filterStatus.value) params.status = filterStatus.value
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
if (filterSentiment.value) params.sentiment = filterSentiment.value
|
if (filterSentiment.value) params.sentiment = filterSentiment.value
|
||||||
if (filterKeyword.value) params.keyword = filterKeyword.value
|
if (filterKeyword.value) params.keyword = filterKeyword.value
|
||||||
|
if (filterStartDate.value) params.startDate = filterStartDate.value
|
||||||
|
if (filterEndDate.value) params.endDate = filterEndDate.value
|
||||||
const { data } = await axiosInstance.get(
|
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 },
|
{ params },
|
||||||
@@ -393,6 +434,7 @@ const fetchReplies = async () => {
|
|||||||
totalPages.value = Math.ceil(total.value / size.value)
|
totalPages.value = Math.ceil(total.value / size.value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch replies", e)
|
console.error("Failed to fetch replies", e)
|
||||||
|
Toast.error("获取回复列表失败")
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -415,6 +457,30 @@ const openConversation = async (reply: AiCommentReplyItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (reply: AiCommentReplyItem) => {
|
||||||
|
editingReply.value = reply
|
||||||
|
editContent.value = stripHtml(reply.spec.reply || "")
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editingReply.value || !editContent.value.trim()) return
|
||||||
|
editSaving.value = true
|
||||||
|
try {
|
||||||
|
await axiosInstance.put(
|
||||||
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${editingReply.value.metadata.name}/content`,
|
||||||
|
{ reply: editContent.value }
|
||||||
|
)
|
||||||
|
Toast.success("保存成功")
|
||||||
|
showEditDialog.value = false
|
||||||
|
fetchReplies()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error("保存失败")
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
await axiosInstance.delete(
|
await axiosInstance.delete(
|
||||||
@@ -609,11 +675,13 @@ const resetFilters = () => {
|
|||||||
filterStatus.value = ""
|
filterStatus.value = ""
|
||||||
filterSentiment.value = ""
|
filterSentiment.value = ""
|
||||||
filterKeyword.value = ""
|
filterKeyword.value = ""
|
||||||
|
filterStartDate.value = ""
|
||||||
|
filterEndDate.value = ""
|
||||||
page.value = 1
|
page.value = 1
|
||||||
fetchReplies()
|
fetchReplies()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([filterStatus, filterSentiment, filterKeyword], () => {
|
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
fetchReplies()
|
fetchReplies()
|
||||||
})
|
})
|
||||||
|
|||||||
+248
-40
@@ -5,6 +5,11 @@
|
|||||||
<IconPlug class="mr-2 self-center" />
|
<IconPlug class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<VButton type="secondary" size="sm" @click="exportConfig">导出配置</VButton>
|
||||||
|
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||||
|
导入配置
|
||||||
|
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
|
||||||
|
</label>
|
||||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
|
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
@@ -117,26 +122,38 @@
|
|||||||
<span>请添加至少一个AI角色</span>
|
<span>请添加至少一个AI角色</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="persona-list">
|
<div v-else class="persona-list">
|
||||||
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
|
<div v-for="(persona, index) in personas" :key="persona.metadata.name" class="persona-card">
|
||||||
<div class="persona-card__avatar">
|
<div class="persona-card__avatar">
|
||||||
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
|
||||||
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="persona-card__info">
|
<div class="persona-card__info">
|
||||||
<div class="persona-card__name">
|
<div class="persona-card__name">
|
||||||
{{ p.spec.displayName || '未命名' }}
|
{{ persona.spec.displayName || '未命名' }}
|
||||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="persona-card__actions">
|
<div class="persona-card__actions">
|
||||||
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
|
<button
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="movePersonaUp(index)"
|
||||||
|
title="上移"
|
||||||
|
>▲</button>
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600 px-1 py-0.5"
|
||||||
|
:disabled="index === personas.length - 1"
|
||||||
|
@click="movePersonaDown(index)"
|
||||||
|
title="下移"
|
||||||
|
>▼</button>
|
||||||
|
<button class="btn-icon" title="编辑" @click="openPersonaDialog(persona)">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
|
<button v-if="!persona.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(persona)">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
|
<button v-if="!persona.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(persona)">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,10 +330,13 @@
|
|||||||
<span>暂无评论者数据</span>
|
<span>暂无评论者数据</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="dialog__list">
|
<div v-else class="dialog__list">
|
||||||
<button v-for="c in filteredCommenters" :key="c.name + c.email" class="dialog__item" @click="addCommenter(c)">
|
<button v-for="c in filteredCommenters" :key="c.displayName + c.email" class="dialog__item" @click="addCommenter(c)">
|
||||||
<div class="dialog__item-avatar">{{ c.name?.charAt(0) || '?' }}</div>
|
<div class="dialog__item-avatar">
|
||||||
|
<img v-if="c.avatarUrl" :src="c.avatarUrl" alt="" class="w-full h-full object-cover rounded-full" />
|
||||||
|
<span v-else>{{ c.displayName?.charAt(0) || '?' }}</span>
|
||||||
|
</div>
|
||||||
<div class="dialog__item-info">
|
<div class="dialog__item-info">
|
||||||
<div class="dialog__item-name">{{ c.name }}</div>
|
<div class="dialog__item-name">{{ c.displayName }}</div>
|
||||||
<div v-if="c.email" class="dialog__item-email">{{ c.email }}</div>
|
<div v-if="c.email" class="dialog__item-email">{{ c.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg class="dialog__item-add" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
<svg class="dialog__item-add" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
@@ -339,10 +359,10 @@
|
|||||||
<!-- Avatar Preview -->
|
<!-- Avatar Preview -->
|
||||||
<div class="persona-dialog-preview">
|
<div class="persona-dialog-preview">
|
||||||
<div class="persona-card__avatar">
|
<div class="persona-card__avatar">
|
||||||
<img v-if="personaDialogAvatar" :src="personaDialogAvatar" alt="头像" />
|
<img v-if="personaDialogAvatar" :src="personaDialogAvatar" alt="Gravatar头像" />
|
||||||
<span v-else class="persona-card__avatar-fallback">{{ (personaForm.displayName || '?').charAt(0) }}</span>
|
<span v-else class="persona-card__avatar-fallback">{{ (personaForm.displayName || '?').charAt(0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;color:#6b7280">Gravatar头像预览</div>
|
<div style="font-size:13px;color:#6b7280">{{ personaDialogAvatar ? 'Gravatar头像预览' : '未设置邮箱,将使用默认头像' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Nickname -->
|
<!-- Nickname -->
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@@ -382,13 +402,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 导入确认对话框 -->
|
||||||
|
<VModal v-model:visible="showImportConfirm" title="确认导入配置">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-600">导入将覆盖当前配置,此操作不可撤销。确定要继续吗?</p>
|
||||||
|
<div v-if="importFileData" class="text-xs text-gray-500">
|
||||||
|
<p v-if="importFileData.configMap">- 包含插件设置</p>
|
||||||
|
<p v-if="importFileData.personas">- 包含 {{ importFileData.personas.length }} 个AI角色</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton @click="showImportConfirm = false">取消</VButton>
|
||||||
|
<VButton type="primary" :loading="importLoading" @click="confirmImport">确认导入</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, watch } from "vue"
|
import { ref, reactive, computed, onMounted, watch } from "vue"
|
||||||
import { axiosInstance } from "@halo-dev/api-client"
|
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
import { IconPlug } from "@halo-dev/components"
|
||||||
|
|
||||||
const promptVariables = [
|
const promptVariables = [
|
||||||
@@ -406,6 +444,9 @@ const promptPresets = [
|
|||||||
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
|
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
|
||||||
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
|
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
|
||||||
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
|
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
|
||||||
|
{ key: 'technical', label: '技术解答型', desc: '深入浅出,专业解答技术问题' },
|
||||||
|
{ key: 'encouraging', label: '鼓励型', desc: '积极正面,给予鼓励和支持' },
|
||||||
|
{ key: 'educational', label: '知识科普型', desc: '通俗易懂,普及相关知识' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const enabledPresetKeys = computed({
|
const enabledPresetKeys = computed({
|
||||||
@@ -434,7 +475,7 @@ const isPresetEnabled = (key: string) => enabledPresetKeys.value.includes(key)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const showCommenterDialog = ref(false)
|
const showCommenterDialog = ref(false)
|
||||||
const commenterList = ref<{ name: string; email: string }[]>([])
|
const commenterList = ref<{ displayName: string; email: string; avatarUrl: string }[]>([])
|
||||||
const commenterSearch = ref("")
|
const commenterSearch = ref("")
|
||||||
const commenterLoading = ref(false)
|
const commenterLoading = ref(false)
|
||||||
const cleanupLoading = ref(false)
|
const cleanupLoading = ref(false)
|
||||||
@@ -455,7 +496,6 @@ const personaForm = reactive({
|
|||||||
isDefault: false,
|
isDefault: false,
|
||||||
})
|
})
|
||||||
const personaDialogAvatar = ref('')
|
const personaDialogAvatar = ref('')
|
||||||
let personaAvatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const settings = reactive({
|
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 },
|
||||||
@@ -465,6 +505,72 @@ const settings = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const configMapName = "comment-ai-autopilot-configmap"
|
const configMapName = "comment-ai-autopilot-configmap"
|
||||||
|
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
|
||||||
|
|
||||||
|
// ===== Export / Import =====
|
||||||
|
const exportConfig = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axiosInstance.get(`${apiBase}/export`)
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `comment-ai-autopilot-config-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
Toast.success('配置已导出')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to export config', e)
|
||||||
|
Toast.error('导出配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importLoading = ref(false)
|
||||||
|
const showImportConfirm = ref(false)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const importFileData = ref<any>(null)
|
||||||
|
|
||||||
|
const handleImportFile = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
if (!input.files || input.files.length === 0) return
|
||||||
|
const file = input.files[0]
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.target?.result as string)
|
||||||
|
if (!data.configMap && !data.personas) {
|
||||||
|
Toast.error('无效的配置文件格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
importFileData.value = data
|
||||||
|
showImportConfirm.value = true
|
||||||
|
} catch {
|
||||||
|
Toast.error('无法解析配置文件')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
// 重置 input 以允许重复选择同一文件
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmImport = async () => {
|
||||||
|
if (!importFileData.value) return
|
||||||
|
importLoading.value = true
|
||||||
|
try {
|
||||||
|
await axiosInstance.post(`${apiBase}/import`, importFileData.value)
|
||||||
|
Toast.success('配置已导入')
|
||||||
|
showImportConfirm.value = false
|
||||||
|
importFileData.value = null
|
||||||
|
// 刷新数据
|
||||||
|
await fetchPersonas()
|
||||||
|
await computePersonaAvatars()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to import config', e)
|
||||||
|
Toast.error('导入配置失败')
|
||||||
|
} finally {
|
||||||
|
importLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCommenterDialog = async () => {
|
const openCommenterDialog = async () => {
|
||||||
showCommenterDialog.value = true
|
showCommenterDialog.value = true
|
||||||
@@ -485,11 +591,11 @@ const openCommenterDialog = async () => {
|
|||||||
const filteredCommenters = computed(() => {
|
const filteredCommenters = computed(() => {
|
||||||
const kw = commenterSearch.value.trim().toLowerCase()
|
const kw = commenterSearch.value.trim().toLowerCase()
|
||||||
if (!kw) return commenterList.value
|
if (!kw) return commenterList.value
|
||||||
return commenterList.value.filter(c => c.name.toLowerCase().includes(kw) || (c.email && c.email.toLowerCase().includes(kw)))
|
return commenterList.value.filter(c => c.displayName.toLowerCase().includes(kw) || (c.email && c.email.toLowerCase().includes(kw)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const addCommenter = (commenter: { name: string; email: string }) => {
|
const addCommenter = (commenter: { displayName: string; email: string }) => {
|
||||||
const value = commenter.email || commenter.name
|
const value = commenter.email || commenter.displayName
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const current = settings.basic.blockedCommenters.split(",").map(s => s.trim()).filter(Boolean)
|
const current = settings.basic.blockedCommenters.split(",").map(s => s.trim()).filter(Boolean)
|
||||||
if (current.includes(value)) { Toast.info("该评论者已在黑名单中"); return }
|
if (current.includes(value)) { Toast.info("该评论者已在黑名单中"); return }
|
||||||
@@ -501,9 +607,11 @@ const addCommenter = (commenter: { name: string; email: string }) => {
|
|||||||
|
|
||||||
const computeGravatarHash = async (email: string): Promise<string> => {
|
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||||
const normalized = email.trim().toLowerCase()
|
const normalized = email.trim().toLowerCase()
|
||||||
const data = new TextEncoder().encode(normalized)
|
const encoder = new TextEncoder()
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
const data = encoder.encode(normalized)
|
||||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
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('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const performCleanup = async () => {
|
const performCleanup = async () => {
|
||||||
@@ -526,6 +634,7 @@ const fetchPersonas = async () => {
|
|||||||
const { data } = await axiosInstance.get(personasApiBase)
|
const { data } = await axiosInstance.get(personasApiBase)
|
||||||
const list = Array.isArray(data) ? data : (data.items || [])
|
const list = Array.isArray(data) ? data : (data.items || [])
|
||||||
// 默认角色排到最上方
|
// 默认角色排到最上方
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
personas.value = list.sort((a: any, b: any) => {
|
personas.value = list.sort((a: any, b: any) => {
|
||||||
if (a.spec?.isDefault && !b.spec?.isDefault) return -1
|
if (a.spec?.isDefault && !b.spec?.isDefault) return -1
|
||||||
if (!a.spec?.isDefault && b.spec?.isDefault) return 1
|
if (!a.spec?.isDefault && b.spec?.isDefault) return 1
|
||||||
@@ -533,6 +642,7 @@ const fetchPersonas = async () => {
|
|||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch personas", e)
|
console.error("Failed to fetch personas", e)
|
||||||
|
Toast.error("获取角色列表失败")
|
||||||
personas.value = []
|
personas.value = []
|
||||||
} finally {
|
} finally {
|
||||||
personasLoading.value = false
|
personasLoading.value = false
|
||||||
@@ -542,7 +652,6 @@ const fetchPersonas = async () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const getPersonaAvatar = (persona: any) => {
|
const getPersonaAvatar = (persona: any) => {
|
||||||
if (persona.spec?.email) {
|
if (persona.spec?.email) {
|
||||||
// Compute synchronously from cached hash if available
|
|
||||||
return persona._avatarUrl || ''
|
return persona._avatarUrl || ''
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
@@ -563,7 +672,7 @@ const computePersonaAvatars = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const openPersonaDialog = (persona: any | null) => {
|
const openPersonaDialog = async (persona: any | null) => {
|
||||||
personaEditing.value = persona
|
personaEditing.value = persona
|
||||||
if (persona) {
|
if (persona) {
|
||||||
personaForm.displayName = persona.spec.displayName || ''
|
personaForm.displayName = persona.spec.displayName || ''
|
||||||
@@ -578,9 +687,10 @@ const openPersonaDialog = (persona: any | null) => {
|
|||||||
}
|
}
|
||||||
personaDialogAvatar.value = ''
|
personaDialogAvatar.value = ''
|
||||||
if (personaForm.email) {
|
if (personaForm.email) {
|
||||||
computeGravatarHash(personaForm.email).then(hash => {
|
try {
|
||||||
|
const hash = await computeGravatarHash(personaForm.email)
|
||||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||||
}).catch(() => { personaDialogAvatar.value = '' })
|
} catch { personaDialogAvatar.value = '' }
|
||||||
}
|
}
|
||||||
showPersonaDialog.value = true
|
showPersonaDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -662,6 +772,7 @@ const deletePersona = async (persona: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const setDefaultPersona = async (persona: any) => {
|
const setDefaultPersona = async (persona: any) => {
|
||||||
try {
|
try {
|
||||||
// 先取消当前默认角色:获取最新数据后更新
|
// 先取消当前默认角色:获取最新数据后更新
|
||||||
@@ -684,11 +795,62 @@ const setDefaultPersona = async (persona: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const movePersonaUp = async (index: number) => {
|
||||||
|
if (index <= 0) return
|
||||||
|
const current = personas.value[index]
|
||||||
|
const prev = personas.value[index - 1]
|
||||||
|
// Swap priorities
|
||||||
|
const currentPriority = current.spec?.priority ?? index
|
||||||
|
const prevPriority = prev.spec?.priority ?? (index - 1)
|
||||||
|
try {
|
||||||
|
// Update both personas
|
||||||
|
await Promise.all([
|
||||||
|
axiosInstance.put(
|
||||||
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
|
||||||
|
{ ...current, spec: { ...current.spec, priority: prevPriority } }
|
||||||
|
),
|
||||||
|
axiosInstance.put(
|
||||||
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${prev.metadata.name}`,
|
||||||
|
{ ...prev, spec: { ...prev.spec, priority: currentPriority } }
|
||||||
|
)
|
||||||
|
])
|
||||||
|
Toast.success("排序已更新")
|
||||||
|
fetchPersonas()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error("排序更新失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const movePersonaDown = async (index: number) => {
|
||||||
|
if (index >= personas.value.length - 1) return
|
||||||
|
const current = personas.value[index]
|
||||||
|
const next = personas.value[index + 1]
|
||||||
|
const currentPriority = current.spec?.priority ?? index
|
||||||
|
const nextPriority = next.spec?.priority ?? (index + 1)
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
axiosInstance.put(
|
||||||
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${current.metadata.name}`,
|
||||||
|
{ ...current, spec: { ...current.spec, priority: nextPriority } }
|
||||||
|
),
|
||||||
|
axiosInstance.put(
|
||||||
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas/${next.metadata.name}`,
|
||||||
|
{ ...next, spec: { ...next.spec, priority: currentPriority } }
|
||||||
|
)
|
||||||
|
])
|
||||||
|
Toast.success("排序已更新")
|
||||||
|
fetchPersonas()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error("排序更新失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Watch persona dialog email for Gravatar preview
|
// Watch persona dialog email for Gravatar preview
|
||||||
|
let emailDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
watch(() => personaForm.email, (newEmail) => {
|
watch(() => personaForm.email, (newEmail) => {
|
||||||
if (personaAvatarDebounceTimer) clearTimeout(personaAvatarDebounceTimer)
|
if (emailDebounceTimer) clearTimeout(emailDebounceTimer)
|
||||||
if (!newEmail || !newEmail.trim()) { personaDialogAvatar.value = ''; return }
|
if (!newEmail || !newEmail.trim()) { personaDialogAvatar.value = ''; return }
|
||||||
personaAvatarDebounceTimer = setTimeout(async () => {
|
emailDebounceTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const hash = await computeGravatarHash(newEmail)
|
const hash = await computeGravatarHash(newEmail)
|
||||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||||
@@ -696,28 +858,47 @@ watch(() => personaForm.email, (newEmail) => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parseConfigSection = (data: Record<string, unknown>, key: string): Record<string, unknown> => {
|
||||||
|
const val = data[key]
|
||||||
|
if (!val) return {}
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
try { return JSON.parse(val) } catch { return {} }
|
||||||
|
}
|
||||||
|
return val as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
const { data } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
const d = data.data
|
const d = data.data as Record<string, unknown>
|
||||||
if (d.basic) { settings.basic.autoReply = d.basic.autoReply !== false; settings.basic.autoPublish = d.basic.autoPublish !== false; settings.basic.maxRetryCount = d.basic.maxRetryCount || 3; settings.basic.blockedCommenters = d.basic.blockedCommenters || ""; settings.basic.maxConversationRounds = d.basic.maxConversationRounds || 8; settings.basic.rateLimitPerMinute = d.basic.rateLimitPerMinute || 10 }
|
const basic = parseConfigSection(d, 'basic') as Record<string, unknown>
|
||||||
if (d.model) { settings.model.modelName = d.model.modelName || "" }
|
const model = parseConfigSection(d, 'model') as Record<string, unknown>
|
||||||
if (d.prompt) { settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""; settings.prompt.enabledPresets = Array.isArray(d.prompt.enabledPresets) ? d.prompt.enabledPresets : (d.prompt.enabledPresets || "").split(",").map((s: string) => s.trim()).filter(Boolean) }
|
const prompt = parseConfigSection(d, 'prompt') as Record<string, unknown>
|
||||||
if (d.cleanup) { settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = d.cleanup.retentionDays || 30 }
|
const cleanup = parseConfigSection(d, 'cleanup') as Record<string, unknown>
|
||||||
|
if (Object.keys(basic).length) { settings.basic.autoReply = basic.autoReply !== false; settings.basic.autoPublish = basic.autoPublish !== false; settings.basic.maxRetryCount = (basic.maxRetryCount as number) || 3; settings.basic.blockedCommenters = (basic.blockedCommenters as string) || ""; settings.basic.maxConversationRounds = (basic.maxConversationRounds as number) || 8; settings.basic.rateLimitPerMinute = (basic.rateLimitPerMinute as number) || 10 }
|
||||||
|
if (Object.keys(model).length) { settings.model.modelName = (model.modelName as string) || "" }
|
||||||
|
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
|
||||||
|
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
||||||
}
|
}
|
||||||
} catch (e) { console.error("Failed to fetch settings", e) }
|
} catch (e) { console.error("Failed to fetch settings", e); Toast.error("获取设置失败") }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const { data: latest } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
const { data: latest } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||||
const updated = { ...latest }
|
const updated = { ...latest }
|
||||||
updated.data = { ...updated.data, basic: { ...settings.basic }, model: { ...settings.model }, prompt: { ...settings.prompt }, cleanup: { ...settings.cleanup } }
|
updated.data = {
|
||||||
await axiosInstance.put(`/api/v1alpha1/configmaps/${configMapName}`, updated)
|
...updated.data,
|
||||||
|
basic: JSON.stringify(settings.basic),
|
||||||
|
model: JSON.stringify(settings.model),
|
||||||
|
prompt: JSON.stringify(settings.prompt),
|
||||||
|
cleanup: JSON.stringify(settings.cleanup),
|
||||||
|
}
|
||||||
|
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
|
||||||
Toast.success("设置已保存")
|
Toast.success("设置已保存")
|
||||||
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
@@ -741,6 +922,33 @@ onMounted(async () => {
|
|||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.settings-container { grid-template-columns: 1fr; }
|
.settings-container { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.settings-sidebar {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
.persona-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.settings-section .form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.settings-section .form-row__label {
|
||||||
|
min-width: auto;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.settings-section .form-input,
|
||||||
|
.settings-section .form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.preset-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Section ===== */
|
/* ===== Section ===== */
|
||||||
.settings-section {
|
.settings-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user