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 回复数量,防止批量评论消耗过多额度
|
||||
@@ -17,10 +17,11 @@
|
||||
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
|
||||
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
|
||||
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
|
||||
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合
|
||||
- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息
|
||||
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页
|
||||
- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份
|
||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot'
|
||||
version '0.0.1-t5w8r3'
|
||||
version '0.0.0-ygkszvd'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
|
||||
## 重试机制
|
||||
|
||||
@@ -9,17 +9,32 @@
|
||||
## 审核流程
|
||||
|
||||
1. 新评论到达后,AI生成回复内容
|
||||
2. 回复以草稿状态保存(`approved = false`)
|
||||
2. 回复以草稿状态保存(状态为"通过",但未发布)
|
||||
3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮
|
||||
4. 管理员可以:
|
||||
- **编辑** — 修改AI回复内容后再审核
|
||||
- **审核通过** — 回复立即发布,访客可见
|
||||
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
|
||||
|
||||
## 编辑回复内容
|
||||
|
||||
草稿模式下,管理员可以在发布前编辑AI生成的回复内容:
|
||||
|
||||
1. 在日志页面找到状态为"通过"且未发布的记录
|
||||
2. 点击 **编辑** 按钮
|
||||
3. 在弹出的编辑对话框中修改回复内容
|
||||
4. 点击 **保存** 完成修改
|
||||
5. 修改后可继续审核通过或拒绝
|
||||
|
||||
::: tip
|
||||
编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。
|
||||
:::
|
||||
|
||||
## 日志页面操作
|
||||
|
||||
在AI回复日志页面:
|
||||
|
||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
||||
- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮
|
||||
- 已发布的记录显示正常状态
|
||||
- 被拒绝的记录显示 REJECTED 标签
|
||||
|
||||
@@ -37,3 +52,19 @@
|
||||
::: warning
|
||||
批量操作不可撤销,请谨慎操作。
|
||||
:::
|
||||
|
||||
## 草稿模式工作原理
|
||||
|
||||
草稿模式下,AI回复的处理流程与自动发布模式不同:
|
||||
|
||||
| 步骤 | 自动发布模式 | 草稿模式 |
|
||||
|------|------------|---------|
|
||||
| AI生成回复 | ✅ | ✅ |
|
||||
| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 |
|
||||
| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 |
|
||||
| 审核通过时 | — | 创建 Reply 扩展并发布 |
|
||||
| 拒绝时 | — | 标记为 REJECTED |
|
||||
|
||||
::: info
|
||||
草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。
|
||||
:::
|
||||
|
||||
+43
-4
@@ -10,10 +10,14 @@
|
||||
|
||||
## AI回复头像不显示?
|
||||
|
||||
1. 确认在插件设置中填写了AI角色邮箱
|
||||
1. 确认在AI角色设置中填写了邮箱
|
||||
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
|
||||
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
|
||||
|
||||
::: info
|
||||
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。
|
||||
:::
|
||||
|
||||
## 评论没有触发AI回复?
|
||||
|
||||
可能的原因:
|
||||
@@ -23,6 +27,8 @@
|
||||
3. **已有AI回复记录** — 同一评论不会重复触发
|
||||
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
||||
5. **AI生成失败** — 检查AI模型配置和日志
|
||||
6. **速率限制** — 检查是否触发了每分钟回复数量限制
|
||||
7. **对话轮次上限** — 检查是否达到了最大对话轮次
|
||||
|
||||
## 如何对历史评论触发AI回复?
|
||||
|
||||
@@ -32,12 +38,24 @@
|
||||
|
||||
## 草稿模式的回复在哪里审核?
|
||||
|
||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。
|
||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。
|
||||
|
||||
## 如何在发布前编辑AI回复?
|
||||
|
||||
1. 确保草稿模式已启用(关闭"自动发布"开关)
|
||||
2. 在AI回复日志页面找到状态为"通过"且未发布的记录
|
||||
3. 点击 **编辑** 按钮修改回复内容
|
||||
4. 修改完成后点击"审核通过"发布
|
||||
|
||||
::: tip
|
||||
编辑功能仅在草稿状态(通过但未发布)下可用。
|
||||
:::
|
||||
|
||||
## 如何修改AI回复的语气风格?
|
||||
|
||||
1. 在插件设置中修改 **AI角色人格提示词**
|
||||
2. 或修改 **自定义Prompt模板**
|
||||
3. 或选择不同的 **Prompt预设** 风格组合
|
||||
|
||||
## AI Foundation 插件是必须的吗?
|
||||
|
||||
@@ -63,6 +81,27 @@
|
||||
|
||||
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认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. 进入插件设置页面
|
||||
2. 在 **基本设置** 中找到 **评论者黑名单**
|
||||
3. 输入评论者的显示名称或邮箱,多个用逗号分隔
|
||||
3. 输入评论者的显示名称、邮箱或正则表达式,多个用逗号分隔
|
||||
4. 保存设置
|
||||
|
||||
### 匹配方式
|
||||
|
||||
| 方式 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| 名称匹配 | 直接输入名称 | `张三` |
|
||||
| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` |
|
||||
| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` |
|
||||
|
||||
::: tip
|
||||
正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。
|
||||
:::
|
||||
|
||||
### 从评论列表选择
|
||||
|
||||
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回复者的昵称、人格提示词和Gravatar头像
|
||||
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,支持多角色管理和排序
|
||||
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
|
||||
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表
|
||||
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索
|
||||
- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息
|
||||
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页
|
||||
- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份
|
||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
|
||||
|
||||
|
||||
+31
-5
@@ -1,6 +1,28 @@
|
||||
# AI角色
|
||||
|
||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。
|
||||
|
||||
## 多角色管理
|
||||
|
||||
插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。
|
||||
|
||||
### 默认角色
|
||||
|
||||
系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。
|
||||
|
||||
### 角色排序
|
||||
|
||||
多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。
|
||||
|
||||
### 角色操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 添加角色 | 点击"添加角色"按钮创建新角色 |
|
||||
| 编辑角色 | 点击编辑图标修改角色信息 |
|
||||
| 删除角色 | 点击删除图标移除角色(默认角色不可删除) |
|
||||
| 设为默认 | 点击星标图标将角色设为默认角色 |
|
||||
| 排序 | 使用 ▲▼ 按钮调整角色顺序 |
|
||||
|
||||
## 角色配置
|
||||
|
||||
@@ -28,11 +50,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
|
||||
### 邮箱与头像
|
||||
|
||||
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
|
||||
填写邮箱后,AI回复者的头像将通过 Cravatar(Gravatar 镜像)服务自动生成:
|
||||
|
||||
1. 插件根据邮箱生成 SHA-256 哈希
|
||||
2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||
3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中
|
||||
2. 构造头像 URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||
3. Halo 评论系统通过 `email-hash` 注解自动匹配头像
|
||||
|
||||
::: warning
|
||||
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
||||
@@ -40,7 +62,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
|
||||
### 头像预览
|
||||
|
||||
在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||
在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||
|
||||
::: tip
|
||||
如果预览头像不正确,请检查:
|
||||
@@ -48,3 +70,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
|
||||
3. 头像更新可能有缓存延迟
|
||||
:::
|
||||
|
||||
::: info
|
||||
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。
|
||||
:::
|
||||
|
||||
+38
-1
@@ -9,6 +9,13 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||
- 如果评论包含中文字符(汉字),请用中文回复
|
||||
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||
- 如果评论包含韩文字符,请用韩文回复
|
||||
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||
- 绝对不要用与评论不同的语言回复
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
- 不要复述或总结文章内容
|
||||
@@ -25,6 +32,20 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
{{comment}}
|
||||
```
|
||||
|
||||
## 多语言回复
|
||||
|
||||
默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复:
|
||||
|
||||
- 中文评论 → 中文回复
|
||||
- 英文评论 → 英文回复
|
||||
- 日文评论 → 日文回复
|
||||
- 韩文评论 → 韩文回复
|
||||
- 其他语言 → 根据语言特征用相同语言回复
|
||||
|
||||
::: tip
|
||||
多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。
|
||||
:::
|
||||
|
||||
## 模板变量
|
||||
|
||||
| 变量 | 说明 | 注入时机 |
|
||||
@@ -39,12 +60,26 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
|
||||
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
|
||||
|
||||
## 预设风格
|
||||
|
||||
预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合:
|
||||
|
||||
| 预设 | 说明 | 效果 |
|
||||
|------|------|------|
|
||||
| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 |
|
||||
| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 |
|
||||
| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 |
|
||||
| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 |
|
||||
| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 |
|
||||
| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 |
|
||||
| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 |
|
||||
|
||||
## 情感提示
|
||||
|
||||
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
|
||||
|
||||
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
|
||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。"
|
||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
|
||||
- **中性** → 不注入额外提示
|
||||
|
||||
## 安全提示
|
||||
@@ -55,6 +90,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
- 不泄露个人隐私信息
|
||||
- 不生成虚假信息
|
||||
- 回复内容与评论相关
|
||||
- 遇到恶意诱导时礼貌拒绝
|
||||
|
||||
## 自定义建议
|
||||
|
||||
@@ -66,6 +102,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
4. 在变量之间添加清晰的分隔和指令
|
||||
5. 避免让AI复述文章内容
|
||||
6. 控制回复长度和风格
|
||||
7. 保留多语言回复指令以确保国际化支持
|
||||
|
||||
## 变量使用示例
|
||||
|
||||
|
||||
+58
-12
@@ -7,24 +7,44 @@
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 |
|
||||
| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式 | 空 |
|
||||
|
||||
::: tip 评论者黑名单
|
||||
黑名单支持三种匹配方式:
|
||||
- **名称匹配**:直接输入评论者显示名称,如 `张三`
|
||||
- **邮箱匹配**:输入邮箱地址,如 `spam@example.com`
|
||||
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
|
||||
|
||||
多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。
|
||||
:::
|
||||
|
||||
## AI角色设置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| AI角色昵称 | AI回复者的显示名称 | 小回 |
|
||||
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
||||
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 |
|
||||
AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。
|
||||
|
||||
默认人格提示词:
|
||||
### 角色管理
|
||||
|
||||
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
- **添加角色** — 点击"添加角色"按钮创建新的AI角色
|
||||
- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息
|
||||
- **删除角色** — 点击删除图标移除角色(默认角色不可删除)
|
||||
- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景)
|
||||
- **角色排序** — 使用 ▲▼ 按钮调整角色顺序
|
||||
|
||||
### 角色配置项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| 昵称 | AI回复者的显示名称 |
|
||||
| 邮箱 | 用于Gravatar头像服务展示头像 |
|
||||
| 人格提示词 | 定义AI角色的人格和回复风格 |
|
||||
| 设为默认角色 | 是否作为默认角色使用 |
|
||||
|
||||
::: tip Gravatar头像
|
||||
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。
|
||||
填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。
|
||||
:::
|
||||
|
||||
## 模型设置
|
||||
@@ -41,8 +61,21 @@
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Prompt预设 | 选择预设风格,可多选 | 无 |
|
||||
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
|
||||
|
||||
### 预设风格
|
||||
|
||||
| 预设 | 说明 |
|
||||
|------|------|
|
||||
| 友好型 | 热情友好,像朋友聊天 |
|
||||
| 专业型 | 严谨正式,有逻辑性 |
|
||||
| 幽默型 | 轻松诙谐,适当幽默 |
|
||||
| 简洁型 | 一两句话,简洁明了 |
|
||||
| 技术解答型 | 深入浅出,专业解答技术问题 |
|
||||
| 鼓励型 | 积极正面,给予鼓励和支持 |
|
||||
| 知识科普型 | 通俗易懂,普及相关知识 |
|
||||
|
||||
### 模板变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
@@ -52,7 +85,9 @@
|
||||
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
|
||||
| `{{article}}` | 文章内容 |
|
||||
| `{{comment}}` | 评论内容 |
|
||||
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
||||
| `{{post_title}}` | 文章标题 |
|
||||
| `{{post_date}}` | 文章发布日期 |
|
||||
| `{{comment_count}}` | 评论数 |
|
||||
|
||||
## 数据清理
|
||||
|
||||
@@ -62,5 +97,16 @@
|
||||
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
|
||||
|
||||
::: tip
|
||||
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
|
||||
你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。
|
||||
:::
|
||||
|
||||
## 配置导入/导出
|
||||
|
||||
设置页面右上角提供配置导入/导出功能:
|
||||
|
||||
- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件
|
||||
- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览
|
||||
|
||||
::: warning
|
||||
导入配置将覆盖当前配置,此操作不可撤销。
|
||||
:::
|
||||
|
||||
+1
-1
@@ -25,5 +25,5 @@ features:
|
||||
- title: 灵活过滤
|
||||
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
||||
- title: 数据管理
|
||||
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
|
||||
details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录
|
||||
---
|
||||
|
||||
+436
-86
@@ -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.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
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.AiReplyCleanupService;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
@@ -49,15 +55,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final AiReplyCleanupService cleanupService;
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.cleanupService = cleanupService;
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@@ -84,6 +92,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.POST("/personas", this::createPersona)
|
||||
.PUT("/personas/{name}", this::updatePersona)
|
||||
.DELETE("/personas/{name}", this::deletePersona)
|
||||
// 导出配置
|
||||
.GET("/export", this::exportConfig)
|
||||
// 导入配置
|
||||
.POST("/import", this::importConfig)
|
||||
// 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展)
|
||||
.PUT("/replies/{name}/content", this::updateReplyContent)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -98,48 +112,108 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
var statusFilter = request.queryParam("status").orElse("");
|
||||
var sentimentFilter = request.queryParam("sentiment").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");
|
||||
|
||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.collectList()
|
||||
.map(replies -> {
|
||||
var filtered = replies.stream()
|
||||
.filter(r -> {
|
||||
if (!statusFilter.isBlank()
|
||||
&& !statusFilter.equals(r.getSpec().getStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (!sentimentFilter.isBlank()
|
||||
&& !sentimentFilter.equals(r.getSpec().getSentiment())) {
|
||||
return false;
|
||||
}
|
||||
if (!keywordFilter.isBlank()) {
|
||||
String reply = r.getSpec().getReply();
|
||||
if (reply == null || !reply.contains(keywordFilter)) {
|
||||
return false;
|
||||
// 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())
|
||||
.collectList()
|
||||
.map(replies -> {
|
||||
var filtered = replies.stream()
|
||||
.filter(r -> {
|
||||
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||
if (!keywordFilter.isBlank()) {
|
||||
String reply = r.getSpec().getReply();
|
||||
if (reply == null || !reply.contains(keywordFilter)) return false;
|
||||
}
|
||||
}
|
||||
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;
|
||||
})
|
||||
.sorted(Comparator.comparing(
|
||||
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
||||
Comparator.nullsLast("asc".equalsIgnoreCase(sortOrder)
|
||||
? Comparator.<Instant>naturalOrder() : Comparator.<Instant>reverseOrder())
|
||||
))
|
||||
.toList();
|
||||
|
||||
int total = filtered.size();
|
||||
int fromIndex = (page - 1) * size;
|
||||
int toIndex = Math.min(fromIndex + size, total);
|
||||
List<AiCommentReply> pageContent = fromIndex < total
|
||||
? filtered.subList(fromIndex, toIndex) : List.of();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("items", pageContent);
|
||||
result.put("total", total);
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
result.put("totalPages", (int) Math.ceil((double) total / size));
|
||||
result.put("first", page == 1);
|
||||
result.put("last", toIndex >= total);
|
||||
return result;
|
||||
})
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
// 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;
|
||||
})
|
||||
.sorted(Comparator.comparing(
|
||||
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
))
|
||||
.toList();
|
||||
|
||||
int total = filtered.size();
|
||||
int fromIndex = (page - 1) * size;
|
||||
int toIndex = Math.min(fromIndex + size, total);
|
||||
List<AiCommentReply> pageContent = fromIndex < total
|
||||
? filtered.subList(fromIndex, toIndex) : List.of();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("items", pageContent);
|
||||
result.put("total", total);
|
||||
result.put("items", filtered);
|
||||
result.put("total", listResult.getTotal());
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
result.put("totalPages", (int) Math.ceil((double) total / size));
|
||||
result.put("totalPages", (int) Math.ceil((double) listResult.getTotal() / size));
|
||||
result.put("first", page == 1);
|
||||
result.put("last", toIndex >= total);
|
||||
result.put("last", page >= (int) Math.ceil((double) listResult.getTotal() / size));
|
||||
return result;
|
||||
})
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
@@ -362,22 +436,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Find the corresponding Reply and set approved=true
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
String replyName = record.getSpec().getReplyName();
|
||||
if (replyName == null || replyName.isBlank()) {
|
||||
// Draft mode: no Reply extension exists, create one with approved=true
|
||||
return commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name);
|
||||
return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"));
|
||||
}))
|
||||
.flatMap(publishedReply -> {
|
||||
String newReplyName = publishedReply.getMetadata().getName();
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(newReplyName);
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.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 -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
@@ -386,18 +491,28 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Delete the draft Reply if it exists
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record status to REJECTED
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
String replyName = record.getSpec().getReplyName();
|
||||
Mono<Void> deleteReplyMono;
|
||||
if (replyName != null && !replyName.isBlank()) {
|
||||
// Reply extension exists, delete it
|
||||
deleteReplyMono = client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then();
|
||||
} else {
|
||||
// No Reply extension in draft mode, nothing to delete
|
||||
deleteReplyMono = Mono.empty();
|
||||
}
|
||||
return deleteReplyMono
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
latest.getSpec().setReplyName(null);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
@@ -418,19 +533,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.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 -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
));
|
||||
}
|
||||
})
|
||||
.thenReturn(true)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
@@ -462,16 +609,29 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.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))
|
||||
.then();
|
||||
} else {
|
||||
deleteReplyMono = Mono.empty();
|
||||
}
|
||||
return deleteReplyMono
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
latest.getSpec().setReplyName(null);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
))
|
||||
.thenReturn(true);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
@@ -600,8 +760,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
}
|
||||
|
||||
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||
// Find the Reply that belongs to the same comment and was created by AI
|
||||
// If the record has a quoteReply, match by that too for precision
|
||||
// First try using replyName if available
|
||||
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,
|
||||
reply -> {
|
||||
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
|
||||
@@ -634,7 +798,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
|
||||
public record CommenterInfo(
|
||||
String displayName,
|
||||
String email
|
||||
String email,
|
||||
String avatarUrl
|
||||
) {}
|
||||
|
||||
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||
@@ -647,11 +812,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null;
|
||||
if (owner == null) continue;
|
||||
String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : "";
|
||||
String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null
|
||||
String email = Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()) && owner.getName() != null
|
||||
? owner.getName() : "";
|
||||
String key = displayName.toLowerCase() + "|" + email.toLowerCase();
|
||||
if (seen.add(key)) {
|
||||
result.add(new CommenterInfo(displayName, email));
|
||||
// 优先使用 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;
|
||||
@@ -659,6 +831,20 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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) {
|
||||
return Mono.fromCallable(() -> {
|
||||
int retentionDays = cleanupService.getRetentionDays();
|
||||
@@ -703,6 +889,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private Mono<ServerResponse> listPersonas(ServerRequest request) {
|
||||
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -742,6 +935,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
existing.setSpec(updatedPersona.getSpec());
|
||||
return client.update(existing);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.flatMap(saved -> ServerResponse.ok().bodyValue(saved))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to update persona {}: {}", name, e.getMessage());
|
||||
@@ -765,4 +960,159 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
})
|
||||
.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角色名称")
|
||||
private String personaName;
|
||||
|
||||
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
|
||||
private String replyName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,8 @@ public class AiPersona extends AbstractExtension {
|
||||
@Schema(description = "是否为默认角色")
|
||||
@JsonProperty("isDefault")
|
||||
private Boolean isDefault;
|
||||
|
||||
@Schema(description = "排序优先级,数值越小越靠前")
|
||||
private Integer priority;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
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.Post;
|
||||
import run.halo.app.core.extension.content.Tag;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
@@ -148,6 +150,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.map(post -> {
|
||||
// 1. 文章注解优先
|
||||
var annotations = post.getMetadata().getAnnotations();
|
||||
if (annotations != null) {
|
||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||
@@ -155,6 +158,37 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
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;
|
||||
})
|
||||
.orElse(null);
|
||||
|
||||
@@ -4,9 +4,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
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.Post;
|
||||
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.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
@@ -159,6 +161,7 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.map(post -> {
|
||||
// 1. 文章注解优先
|
||||
var annotations = post.getMetadata().getAnnotations();
|
||||
if (annotations != null) {
|
||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||
@@ -166,6 +169,37 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
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;
|
||||
})
|
||||
.orElse(null);
|
||||
|
||||
@@ -36,7 +36,10 @@ public class AiReplyOrchestrator {
|
||||
|
||||
// In-memory dedup: tracks which comment/reply is currently being processed
|
||||
// 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,
|
||||
PromptBuilder promptBuilder,
|
||||
@@ -71,8 +74,11 @@ public class AiReplyOrchestrator {
|
||||
String personaName) {
|
||||
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
|
||||
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
|
||||
if (processingLocks.putIfAbsent(lockKey, System.currentTimeMillis()) != null) {
|
||||
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
|
||||
return Mono.empty();
|
||||
}
|
||||
@@ -220,7 +226,7 @@ public class AiReplyOrchestrator {
|
||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||
context.commentId());
|
||||
// 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"));
|
||||
}
|
||||
return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
|
||||
@@ -263,7 +269,7 @@ public class AiReplyOrchestrator {
|
||||
} else {
|
||||
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. 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,
|
||||
AiCommentReply replyRecord, int score, String personaName) {
|
||||
return isAutoPublishEnabled()
|
||||
.flatMap(autoPublish -> {
|
||||
return commentReplyPublisher.publishReply(
|
||||
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName)
|
||||
.flatMap(publishedReply -> {
|
||||
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
|
||||
})
|
||||
.flatMap(updated -> Mono.empty());
|
||||
if (autoPublish) {
|
||||
// Auto publish: create Reply extension and save replyName
|
||||
return commentReplyPublisher.publishReply(
|
||||
context.commentId(), aiReply, context.postId(), context.replyTo(), true, personaName)
|
||||
.flatMap(publishedReply -> {
|
||||
String replyName = publishedReply.getMetadata().getName();
|
||||
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());
|
||||
} 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();
|
||||
}
|
||||
@@ -515,15 +538,17 @@ public class AiReplyOrchestrator {
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
|
||||
int score, String status, boolean published) {
|
||||
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published);
|
||||
int score, String status, boolean published,
|
||||
String replyName) {
|
||||
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}, replyName={}",
|
||||
record.getMetadata().getName(), status, score, published, replyName);
|
||||
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReply(reply);
|
||||
latest.getSpec().setScore(score);
|
||||
latest.getSpec().setStatus(status);
|
||||
latest.getSpec().setPublished(published);
|
||||
latest.getSpec().setReplyName(replyName);
|
||||
return client.update(latest);
|
||||
})
|
||||
.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",
|
||||
record.getMetadata().getName()))
|
||||
)
|
||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published));
|
||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}, replyName={}",
|
||||
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<>();
|
||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||
// 使用Gravatar邮箱头像
|
||||
if (email != null && !email.isBlank()) {
|
||||
String gravatarUrl = generateGravatarUrl(email);
|
||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||
@@ -125,9 +126,16 @@ public class CommentReplyPublisher {
|
||||
owner.setAnnotations(ownerAnnotations);
|
||||
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)
|
||||
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
|
||||
displayName, parentCommentName, quoteReplyName))
|
||||
.doOnSuccess(created -> {
|
||||
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()));
|
||||
});
|
||||
}
|
||||
@@ -142,10 +150,14 @@ public class CommentReplyPublisher {
|
||||
private Mono<ResolvedPersona> resolvePersona(String personaName) {
|
||||
if (personaName != null && !personaName.isBlank()) {
|
||||
return client.fetch(AiPersona.class, personaName)
|
||||
.map(p -> new ResolvedPersona(
|
||||
p.getSpec().getDisplayName(),
|
||||
p.getSpec().getEmail()
|
||||
))
|
||||
.map(p -> {
|
||||
log.info("[Publisher] Resolved persona by name: {}, email={}",
|
||||
personaName, p.getSpec().getEmail());
|
||||
return new ResolvedPersona(
|
||||
p.getSpec().getDisplayName(),
|
||||
p.getSpec().getEmail()
|
||||
);
|
||||
})
|
||||
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
||||
}
|
||||
// Find default persona
|
||||
@@ -153,10 +165,14 @@ public class CommentReplyPublisher {
|
||||
persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
|
||||
null)
|
||||
.next()
|
||||
.map(p -> new ResolvedPersona(
|
||||
p.getSpec().getDisplayName(),
|
||||
p.getSpec().getEmail()
|
||||
))
|
||||
.map(p -> {
|
||||
log.info("[Publisher] Resolved default persona: {}, email={}",
|
||||
p.getMetadata().getName(), p.getSpec().getEmail());
|
||||
return new ResolvedPersona(
|
||||
p.getSpec().getDisplayName(),
|
||||
p.getSpec().getEmail()
|
||||
);
|
||||
})
|
||||
.defaultIfEmpty(new ResolvedPersona("小回", ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ public class FilterService {
|
||||
private String getCommenterEmail(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if ("EMAIL".equals(owner.getKind())) {
|
||||
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
|
||||
var name = owner.getName();
|
||||
return name != null ? name : "";
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ public class PromptBuilder {
|
||||
|
||||
private static final String PRESET_CONCISE = """
|
||||
【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
|
||||
""";
|
||||
|
||||
private static final String PRESET_TECHNICAL = """
|
||||
【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。
|
||||
""";
|
||||
|
||||
private static final String PRESET_ENCOURAGING = """
|
||||
【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。
|
||||
""";
|
||||
|
||||
private static final String PRESET_EDUCATIONAL = """
|
||||
【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。
|
||||
""";
|
||||
|
||||
private static final Map<String, String> PRESET_MAP = new LinkedHashMap<>();
|
||||
@@ -47,6 +59,9 @@ public class PromptBuilder {
|
||||
PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
|
||||
PRESET_MAP.put("humorous", PRESET_HUMOROUS);
|
||||
PRESET_MAP.put("concise", PRESET_CONCISE);
|
||||
PRESET_MAP.put("technical", PRESET_TECHNICAL);
|
||||
PRESET_MAP.put("encouraging", PRESET_ENCOURAGING);
|
||||
PRESET_MAP.put("educational", PRESET_EDUCATIONAL);
|
||||
}
|
||||
|
||||
private static final String SAFETY_PROMPT = """
|
||||
@@ -61,7 +76,12 @@ public class PromptBuilder {
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
||||
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||
- 如果评论包含中文字符(汉字),请用中文回复
|
||||
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||
- 如果评论包含韩文字符,请用韩文回复
|
||||
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||
- 绝对不要用与评论不同的语言回复
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
|
||||
@@ -37,3 +37,33 @@ spec:
|
||||
label: AI角色
|
||||
help: 选择该页面使用的AI回复角色名称,留空使用默认角色
|
||||
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"]
|
||||
resources: ["*"]
|
||||
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"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.1-t5w8r3"
|
||||
version: "0.0.0-ygkszvd"
|
||||
pluginDependencies:
|
||||
ai-foundation: "*"
|
||||
|
||||
+10
-78
@@ -177,53 +177,6 @@
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- Daily Trend -->
|
||||
<VCard :body-class="['!p-5']">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
|
||||
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs transition-colors"
|
||||
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = '7'"
|
||||
>
|
||||
7天
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
||||
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = '30'"
|
||||
>
|
||||
30天
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
||||
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
||||
@click="range = 'all'"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
|
||||
<div
|
||||
v-for="day in stats.dailyTrend"
|
||||
:key="day.date"
|
||||
class="flex-1 flex flex-col items-center justify-end h-full"
|
||||
>
|
||||
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
|
||||
<div
|
||||
class="w-full rounded-t-md transition-all duration-500"
|
||||
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
|
||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
||||
></div>
|
||||
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
|
||||
暂无数据
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Score + Quick Actions -->
|
||||
@@ -299,16 +252,11 @@
|
||||
</template>
|
||||
|
||||
<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 { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
interface DailyCount {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
total: number
|
||||
passCount: number
|
||||
@@ -316,7 +264,6 @@ interface StatsResponse {
|
||||
reviewingCount: number
|
||||
avgScore: number
|
||||
sentimentDistribution: Record<string, number>
|
||||
dailyTrend: DailyCount[]
|
||||
}
|
||||
|
||||
interface PersonaResponse {
|
||||
@@ -331,7 +278,6 @@ interface HealthResponse {
|
||||
|
||||
const stats = ref<StatsResponse | null>(null)
|
||||
const persona = ref<PersonaResponse | null>(null)
|
||||
const range = ref("7")
|
||||
const health = ref<HealthResponse | null>(null)
|
||||
const healthVisible = ref(true)
|
||||
|
||||
@@ -357,7 +303,7 @@ const scoreBarColor = computed(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
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
|
||||
} catch (e) {
|
||||
@@ -367,9 +313,11 @@ const fetchStats = async () => {
|
||||
|
||||
const computeGravatarHash = async (email: string): Promise<string> => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
const data = new TextEncoder().encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const fetchPersona = async () => {
|
||||
@@ -378,14 +326,15 @@ const fetchPersona = async () => {
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
|
||||
)
|
||||
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]
|
||||
if (defaultPersona) {
|
||||
persona.value = {
|
||||
name: defaultPersona.spec?.displayName || '未命名',
|
||||
prompt: defaultPersona.spec?.prompt || '',
|
||||
avatar: '', // 需要单独计算
|
||||
avatar: '',
|
||||
}
|
||||
// 计算 Gravatar
|
||||
// 使用Gravatar邮箱头像
|
||||
const email = defaultPersona.spec?.email
|
||||
if (email) {
|
||||
const hash = await computeGravatarHash(email)
|
||||
@@ -426,23 +375,6 @@ const getSentimentPercent = (sentiment: string): number => {
|
||||
return Math.round(((dist[sentiment] || 0) / total) * 100)
|
||||
}
|
||||
|
||||
const getTrendBarHeight = (count: number): number => {
|
||||
const trend = stats.value?.dailyTrend
|
||||
if (!trend || trend.length === 0) return 0
|
||||
const max = Math.max(...trend.map(d => d.count), 1)
|
||||
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
|
||||
}
|
||||
|
||||
const formatTrendDate = (dateStr: string): string => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
watch(range, () => {
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchPersona()
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
placeholder="搜索回复内容..."
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<input v-model="filterStartDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
<span class="text-xs text-gray-400">至</span>
|
||||
<input v-model="filterEndDate" type="date" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
<button
|
||||
class="text-xs text-gray-500 hover:text-gray-700"
|
||||
@click="resetFilters"
|
||||
@@ -172,6 +175,12 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors px-2 py-1 rounded hover:bg-blue-50"
|
||||
@click="openEditDialog(reply)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50"
|
||||
@click="handleApprove(reply.metadata.name)"
|
||||
@@ -299,6 +308,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<teleport to="body">
|
||||
<div v-if="showEditDialog" class="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showEditDialog = false"></div>
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-800">编辑AI回复</h3>
|
||||
<button class="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100" @click="showEditDialog = false">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 px-5 py-4">
|
||||
<textarea v-model="editContent" rows="8" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 resize-y" placeholder="输入回复内容..."></textarea>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex justify-end gap-2">
|
||||
<button class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg" @click="showEditDialog = false">取消</button>
|
||||
<button class="px-4 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50" :disabled="editSaving" @click="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -351,6 +382,8 @@ const selectAll = ref(false)
|
||||
const filterStatus = ref("")
|
||||
const filterSentiment = ref("")
|
||||
const filterKeyword = ref("")
|
||||
const filterStartDate = ref("")
|
||||
const filterEndDate = ref("")
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
if (selectedNames.value.has(name)) {
|
||||
@@ -377,6 +410,12 @@ const showDialog = ref(false)
|
||||
const conversationLoading = ref(false)
|
||||
const conversationMessages = ref<ConversationMessage[]>([])
|
||||
|
||||
// Edit dialog state
|
||||
const showEditDialog = ref(false)
|
||||
const editingReply = ref<AiCommentReplyItem | null>(null)
|
||||
const editContent = ref("")
|
||||
const editSaving = ref(false)
|
||||
|
||||
const fetchReplies = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -384,6 +423,8 @@ const fetchReplies = async () => {
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSentiment.value) params.sentiment = filterSentiment.value
|
||||
if (filterKeyword.value) params.keyword = filterKeyword.value
|
||||
if (filterStartDate.value) params.startDate = filterStartDate.value
|
||||
if (filterEndDate.value) params.endDate = filterEndDate.value
|
||||
const { data } = await axiosInstance.get(
|
||||
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
|
||||
{ params },
|
||||
@@ -393,6 +434,7 @@ const fetchReplies = async () => {
|
||||
totalPages.value = Math.ceil(total.value / size.value)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch replies", e)
|
||||
Toast.error("获取回复列表失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -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) => {
|
||||
try {
|
||||
await axiosInstance.delete(
|
||||
@@ -609,11 +675,13 @@ const resetFilters = () => {
|
||||
filterStatus.value = ""
|
||||
filterSentiment.value = ""
|
||||
filterKeyword.value = ""
|
||||
filterStartDate.value = ""
|
||||
filterEndDate.value = ""
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
}
|
||||
|
||||
watch([filterStatus, filterSentiment, filterKeyword], () => {
|
||||
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
})
|
||||
|
||||
+248
-40
@@ -5,6 +5,11 @@
|
||||
<IconPlug class="mr-2 self-center" />
|
||||
</template>
|
||||
<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>
|
||||
</template>
|
||||
</VPageHeader>
|
||||
@@ -117,26 +122,38 @@
|
||||
<span>请添加至少一个AI角色</span>
|
||||
</div>
|
||||
<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">
|
||||
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
||||
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="persona-card__info">
|
||||
<div class="persona-card__name">
|
||||
{{ p.spec.displayName || '未命名' }}
|
||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
{{ persona.spec.displayName || '未命名' }}
|
||||
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
</div>
|
||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
||||
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -313,10 +330,13 @@
|
||||
<span>暂无评论者数据</span>
|
||||
</div>
|
||||
<div v-else class="dialog__list">
|
||||
<button v-for="c in filteredCommenters" :key="c.name + c.email" class="dialog__item" @click="addCommenter(c)">
|
||||
<div class="dialog__item-avatar">{{ c.name?.charAt(0) || '?' }}</div>
|
||||
<button v-for="c in filteredCommenters" :key="c.displayName + c.email" class="dialog__item" @click="addCommenter(c)">
|
||||
<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-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>
|
||||
<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 -->
|
||||
<div class="persona-dialog-preview">
|
||||
<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>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#6b7280">Gravatar头像预览</div>
|
||||
<div style="font-size:13px;color:#6b7280">{{ personaDialogAvatar ? 'Gravatar头像预览' : '未设置邮箱,将使用默认头像' }}</div>
|
||||
</div>
|
||||
<!-- Nickname -->
|
||||
<div class="form-field">
|
||||
@@ -382,13 +402,31 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue"
|
||||
import { axiosInstance } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
||||
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
|
||||
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace } from "@halo-dev/components"
|
||||
import { IconPlug } from "@halo-dev/components"
|
||||
|
||||
const promptVariables = [
|
||||
@@ -406,6 +444,9 @@ const promptPresets = [
|
||||
{ key: 'professional', label: '专业型', desc: '严谨正式,有逻辑性' },
|
||||
{ key: 'humorous', label: '幽默型', desc: '轻松诙谐,适当幽默' },
|
||||
{ key: 'concise', label: '简洁型', desc: '一两句话,简洁明了' },
|
||||
{ key: 'technical', label: '技术解答型', desc: '深入浅出,专业解答技术问题' },
|
||||
{ key: 'encouraging', label: '鼓励型', desc: '积极正面,给予鼓励和支持' },
|
||||
{ key: 'educational', label: '知识科普型', desc: '通俗易懂,普及相关知识' },
|
||||
]
|
||||
|
||||
const enabledPresetKeys = computed({
|
||||
@@ -434,7 +475,7 @@ const isPresetEnabled = (key: string) => enabledPresetKeys.value.includes(key)
|
||||
const loading = ref(false)
|
||||
const saving = 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 commenterLoading = ref(false)
|
||||
const cleanupLoading = ref(false)
|
||||
@@ -455,7 +496,6 @@ const personaForm = reactive({
|
||||
isDefault: false,
|
||||
})
|
||||
const personaDialogAvatar = ref('')
|
||||
let personaAvatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const settings = reactive({
|
||||
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 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 () => {
|
||||
showCommenterDialog.value = true
|
||||
@@ -485,11 +591,11 @@ const openCommenterDialog = async () => {
|
||||
const filteredCommenters = computed(() => {
|
||||
const kw = commenterSearch.value.trim().toLowerCase()
|
||||
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 value = commenter.email || commenter.name
|
||||
const addCommenter = (commenter: { displayName: string; email: string }) => {
|
||||
const value = commenter.email || commenter.displayName
|
||||
if (!value) return
|
||||
const current = settings.basic.blockedCommenters.split(",").map(s => s.trim()).filter(Boolean)
|
||||
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 normalized = email.trim().toLowerCase()
|
||||
const data = new TextEncoder().encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(normalized)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const performCleanup = async () => {
|
||||
@@ -526,6 +634,7 @@ const fetchPersonas = async () => {
|
||||
const { data } = await axiosInstance.get(personasApiBase)
|
||||
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) => {
|
||||
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) {
|
||||
console.error("Failed to fetch personas", e)
|
||||
Toast.error("获取角色列表失败")
|
||||
personas.value = []
|
||||
} finally {
|
||||
personasLoading.value = false
|
||||
@@ -542,7 +652,6 @@ const fetchPersonas = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getPersonaAvatar = (persona: any) => {
|
||||
if (persona.spec?.email) {
|
||||
// Compute synchronously from cached hash if available
|
||||
return persona._avatarUrl || ''
|
||||
}
|
||||
return ''
|
||||
@@ -563,7 +672,7 @@ const computePersonaAvatars = async () => {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const openPersonaDialog = (persona: any | null) => {
|
||||
const openPersonaDialog = async (persona: any | null) => {
|
||||
personaEditing.value = persona
|
||||
if (persona) {
|
||||
personaForm.displayName = persona.spec.displayName || ''
|
||||
@@ -578,9 +687,10 @@ const openPersonaDialog = (persona: any | null) => {
|
||||
}
|
||||
personaDialogAvatar.value = ''
|
||||
if (personaForm.email) {
|
||||
computeGravatarHash(personaForm.email).then(hash => {
|
||||
try {
|
||||
const hash = await computeGravatarHash(personaForm.email)
|
||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||
}).catch(() => { personaDialogAvatar.value = '' })
|
||||
} catch { personaDialogAvatar.value = '' }
|
||||
}
|
||||
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) => {
|
||||
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
|
||||
let emailDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => personaForm.email, (newEmail) => {
|
||||
if (personaAvatarDebounceTimer) clearTimeout(personaAvatarDebounceTimer)
|
||||
if (emailDebounceTimer) clearTimeout(emailDebounceTimer)
|
||||
if (!newEmail || !newEmail.trim()) { personaDialogAvatar.value = ''; return }
|
||||
personaAvatarDebounceTimer = setTimeout(async () => {
|
||||
emailDebounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const hash = await computeGravatarHash(newEmail)
|
||||
personaDialogAvatar.value = `https://cn.cravatar.com/avatar/${hash}`
|
||||
@@ -696,28 +858,47 @@ watch(() => personaForm.email, (newEmail) => {
|
||||
}, 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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
||||
const { data } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||
if (data.data) {
|
||||
const d = data.data
|
||||
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 }
|
||||
if (d.model) { settings.model.modelName = d.model.modelName || "" }
|
||||
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) }
|
||||
if (d.cleanup) { settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = d.cleanup.retentionDays || 30 }
|
||||
const d = data.data as Record<string, unknown>
|
||||
const basic = parseConfigSection(d, 'basic') as Record<string, unknown>
|
||||
const model = parseConfigSection(d, 'model') as Record<string, unknown>
|
||||
const prompt = parseConfigSection(d, 'prompt') as Record<string, unknown>
|
||||
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 }
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const { data: latest } = await axiosInstance.get(`/api/v1alpha1/configmaps/${configMapName}`)
|
||||
const { data: latest } = await coreApiClient.configMap.getConfigMap({ name: configMapName })
|
||||
const updated = { ...latest }
|
||||
updated.data = { ...updated.data, basic: { ...settings.basic }, model: { ...settings.model }, prompt: { ...settings.prompt }, cleanup: { ...settings.cleanup } }
|
||||
await axiosInstance.put(`/api/v1alpha1/configmaps/${configMapName}`, updated)
|
||||
updated.data = {
|
||||
...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("设置已保存")
|
||||
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
||||
finally { saving.value = false }
|
||||
@@ -741,6 +922,33 @@ onMounted(async () => {
|
||||
@media (max-width: 1024px) {
|
||||
.settings-container { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.settings-sidebar {
|
||||
order: -1;
|
||||
}
|
||||
.persona-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.settings-section .form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.settings-section .form-row__label {
|
||||
min-width: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.settings-section .form-input,
|
||||
.settings-section .form-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.preset-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Section ===== */
|
||||
.settings-section {
|
||||
|
||||
Reference in New Issue
Block a user