feat: v1.0.0-beta.1 - AI Foundation reflection integration and draft mode fix
This commit is contained in:
@@ -4,11 +4,11 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,支持排序,可为不同文章指定不同角色
|
||||
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
|
||||
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
||||
- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等)
|
||||
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
||||
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容,支持批量操作
|
||||
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
||||
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
||||
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
|
||||
@@ -17,11 +17,10 @@
|
||||
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
|
||||
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
|
||||
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合
|
||||
- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息
|
||||
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
|
||||
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
|
||||
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页
|
||||
- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份
|
||||
- **日志筛选** — 按状态、情感筛选,关键词搜索
|
||||
- **数据清理** — 自动清理超过指定天数的旧记录
|
||||
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
|
||||
|
||||
|
||||
+4
-4
@@ -5,14 +5,14 @@ plugins {
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot'
|
||||
version '0.0.0-ygkszvd'
|
||||
version '1.0.0-beta.1'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('run.halo.tools.platform:plugin:2.23.0')
|
||||
implementation platform('run.halo.tools.platform:plugin:2.25.0')
|
||||
compileOnly 'run.halo.app:api'
|
||||
compileOnly files('libs/ai-foundation-api.jar')
|
||||
implementation 'org.jsoup:jsoup:1.18.1'
|
||||
@@ -39,7 +39,7 @@ tasks.withType(JavaCompile).configureEach {
|
||||
|
||||
tasks.register('processUiResources', Copy) {
|
||||
from project(':ui').layout.buildDirectory.dir('dist')
|
||||
into layout.buildDirectory.dir('resources/main/console')
|
||||
into layout.buildDirectory.dir('resources/main/ui')
|
||||
dependsOn project(':ui').tasks.named('assemble')
|
||||
shouldRunAfter tasks.named('processResources')
|
||||
}
|
||||
@@ -53,6 +53,6 @@ tasks.named('generatePluginComponentsIdx') {
|
||||
}
|
||||
|
||||
halo {
|
||||
version = '2.23'
|
||||
version = '2.25'
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
|
||||
## 重试机制
|
||||
|
||||
@@ -9,32 +9,17 @@
|
||||
## 审核流程
|
||||
|
||||
1. 新评论到达后,AI生成回复内容
|
||||
2. 回复以草稿状态保存(状态为"通过",但未发布)
|
||||
2. 回复以草稿状态保存(`approved = false`)
|
||||
3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮
|
||||
4. 管理员可以:
|
||||
- **编辑** — 修改AI回复内容后再审核
|
||||
- **审核通过** — 回复立即发布,访客可见
|
||||
- **拒绝** — 删除草稿回复,记录标记为 REJECTED
|
||||
|
||||
## 编辑回复内容
|
||||
|
||||
草稿模式下,管理员可以在发布前编辑AI生成的回复内容:
|
||||
|
||||
1. 在日志页面找到状态为"通过"且未发布的记录
|
||||
2. 点击 **编辑** 按钮
|
||||
3. 在弹出的编辑对话框中修改回复内容
|
||||
4. 点击 **保存** 完成修改
|
||||
5. 修改后可继续审核通过或拒绝
|
||||
|
||||
::: tip
|
||||
编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。
|
||||
:::
|
||||
|
||||
## 日志页面操作
|
||||
|
||||
在AI回复日志页面:
|
||||
|
||||
- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮
|
||||
- 草稿记录显示 **审核通过** 和 **拒绝** 按钮
|
||||
- 已发布的记录显示正常状态
|
||||
- 被拒绝的记录显示 REJECTED 标签
|
||||
|
||||
@@ -52,19 +37,3 @@
|
||||
::: warning
|
||||
批量操作不可撤销,请谨慎操作。
|
||||
:::
|
||||
|
||||
## 草稿模式工作原理
|
||||
|
||||
草稿模式下,AI回复的处理流程与自动发布模式不同:
|
||||
|
||||
| 步骤 | 自动发布模式 | 草稿模式 |
|
||||
|------|------------|---------|
|
||||
| AI生成回复 | ✅ | ✅ |
|
||||
| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 |
|
||||
| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 |
|
||||
| 审核通过时 | — | 创建 Reply 扩展并发布 |
|
||||
| 拒绝时 | — | 标记为 REJECTED |
|
||||
|
||||
::: info
|
||||
草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。
|
||||
:::
|
||||
|
||||
+4
-43
@@ -10,14 +10,10 @@
|
||||
|
||||
## AI回复头像不显示?
|
||||
|
||||
1. 确认在AI角色设置中填写了邮箱
|
||||
1. 确认在插件设置中填写了AI角色邮箱
|
||||
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
|
||||
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务
|
||||
|
||||
::: info
|
||||
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。
|
||||
:::
|
||||
|
||||
## 评论没有触发AI回复?
|
||||
|
||||
可能的原因:
|
||||
@@ -27,8 +23,6 @@
|
||||
3. **已有AI回复记录** — 同一评论不会重复触发
|
||||
4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发
|
||||
5. **AI生成失败** — 检查AI模型配置和日志
|
||||
6. **速率限制** — 检查是否触发了每分钟回复数量限制
|
||||
7. **对话轮次上限** — 检查是否达到了最大对话轮次
|
||||
|
||||
## 如何对历史评论触发AI回复?
|
||||
|
||||
@@ -38,24 +32,12 @@
|
||||
|
||||
## 草稿模式的回复在哪里审核?
|
||||
|
||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。
|
||||
|
||||
## 如何在发布前编辑AI回复?
|
||||
|
||||
1. 确保草稿模式已启用(关闭"自动发布"开关)
|
||||
2. 在AI回复日志页面找到状态为"通过"且未发布的记录
|
||||
3. 点击 **编辑** 按钮修改回复内容
|
||||
4. 修改完成后点击"审核通过"发布
|
||||
|
||||
::: tip
|
||||
编辑功能仅在草稿状态(通过但未发布)下可用。
|
||||
:::
|
||||
在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。
|
||||
|
||||
## 如何修改AI回复的语气风格?
|
||||
|
||||
1. 在插件设置中修改 **AI角色人格提示词**
|
||||
2. 或修改 **自定义Prompt模板**
|
||||
3. 或选择不同的 **Prompt预设** 风格组合
|
||||
|
||||
## AI Foundation 插件是必须的吗?
|
||||
|
||||
@@ -81,27 +63,6 @@
|
||||
|
||||
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
|
||||
|
||||
## 黑名单支持正则表达式吗?
|
||||
## 黑名单支持邮箱吗?
|
||||
|
||||
支持。黑名单支持三种匹配方式:
|
||||
- **名称匹配**:直接输入评论者显示名称
|
||||
- **邮箱匹配**:输入邮箱地址
|
||||
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
|
||||
|
||||
## 如何管理多个AI角色?
|
||||
|
||||
在插件设置的 **AI角色设置** 区域,可以添加、编辑、删除AI角色,也可以使用 ▲▼ 按钮调整角色顺序。每个角色有独立的昵称、人格提示词和Gravatar头像。
|
||||
|
||||
## 如何导入/导出配置?
|
||||
|
||||
在插件设置页面右上角:
|
||||
- 点击 **导出配置** 将当前设置和AI角色导出为 JSON 文件
|
||||
- 点击 **导入配置** 从 JSON 文件导入配置(导入前会显示确认对话框)
|
||||
|
||||
## 日志页面如何按时间筛选?
|
||||
|
||||
在日志页面的筛选栏中,使用日期选择器设置起始日期和结束日期,即可筛选指定时间范围内的记录。
|
||||
|
||||
## 为什么AI角色不支持自定义上传头像?
|
||||
|
||||
Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,会忽略自定义头像 URL。这是 Halo 评论系统的设计限制,因此插件只支持通过 Gravatar 邮箱方式设置AI角色头像。
|
||||
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
|
||||
|
||||
+3
-15
@@ -32,21 +32,9 @@
|
||||
|
||||
1. 进入插件设置页面
|
||||
2. 在 **基本设置** 中找到 **评论者黑名单**
|
||||
3. 输入评论者的显示名称、邮箱或正则表达式,多个用逗号分隔
|
||||
3. 输入评论者的显示名称或邮箱,多个用逗号分隔
|
||||
4. 保存设置
|
||||
|
||||
### 匹配方式
|
||||
|
||||
| 方式 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| 名称匹配 | 直接输入名称 | `张三` |
|
||||
| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` |
|
||||
| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` |
|
||||
|
||||
::: tip
|
||||
正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。
|
||||
:::
|
||||
|
||||
### 从评论列表选择
|
||||
|
||||
1. 在黑名单输入框旁点击 **添加评论者** 按钮
|
||||
@@ -57,7 +45,7 @@
|
||||
### 示例
|
||||
|
||||
```
|
||||
张三,spam@example.com,李四,regex:^bot.*
|
||||
张三,spam@example.com,李四
|
||||
```
|
||||
|
||||
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式也会参与匹配。匹配成功则跳过AI回复。
|
||||
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。
|
||||
|
||||
@@ -5,19 +5,18 @@ 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模型能力
|
||||
|
||||
|
||||
+5
-31
@@ -1,28 +1,6 @@
|
||||
# AI角色
|
||||
|
||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。
|
||||
|
||||
## 多角色管理
|
||||
|
||||
插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。
|
||||
|
||||
### 默认角色
|
||||
|
||||
系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。
|
||||
|
||||
### 角色排序
|
||||
|
||||
多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。
|
||||
|
||||
### 角色操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 添加角色 | 点击"添加角色"按钮创建新角色 |
|
||||
| 编辑角色 | 点击编辑图标修改角色信息 |
|
||||
| 删除角色 | 点击删除图标移除角色(默认角色不可删除) |
|
||||
| 设为默认 | 点击星标图标将角色设为默认角色 |
|
||||
| 排序 | 使用 ▲▼ 按钮调整角色顺序 |
|
||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
||||
|
||||
## 角色配置
|
||||
|
||||
@@ -50,11 +28,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
|
||||
### 邮箱与头像
|
||||
|
||||
填写邮箱后,AI回复者的头像将通过 Cravatar(Gravatar 镜像)服务自动生成:
|
||||
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
|
||||
|
||||
1. 插件根据邮箱生成 SHA-256 哈希
|
||||
2. 构造头像 URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||
3. Halo 评论系统通过 `email-hash` 注解自动匹配头像
|
||||
2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}`
|
||||
3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中
|
||||
|
||||
::: warning
|
||||
如果不填写邮箱,AI回复者将使用 Halo 默认头像。
|
||||
@@ -62,7 +40,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
|
||||
### 头像预览
|
||||
|
||||
在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||
在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
|
||||
|
||||
::: tip
|
||||
如果预览头像不正确,请检查:
|
||||
@@ -70,7 +48,3 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
|
||||
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
|
||||
3. 头像更新可能有缓存延迟
|
||||
:::
|
||||
|
||||
::: info
|
||||
由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。
|
||||
:::
|
||||
|
||||
+1
-38
@@ -9,13 +9,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||
- 如果评论包含中文字符(汉字),请用中文回复
|
||||
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||
- 如果评论包含韩文字符,请用韩文回复
|
||||
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||
- 绝对不要用与评论不同的语言回复
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
- 不要复述或总结文章内容
|
||||
@@ -32,20 +25,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
{{comment}}
|
||||
```
|
||||
|
||||
## 多语言回复
|
||||
|
||||
默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复:
|
||||
|
||||
- 中文评论 → 中文回复
|
||||
- 英文评论 → 英文回复
|
||||
- 日文评论 → 日文回复
|
||||
- 韩文评论 → 韩文回复
|
||||
- 其他语言 → 根据语言特征用相同语言回复
|
||||
|
||||
::: tip
|
||||
多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。
|
||||
:::
|
||||
|
||||
## 模板变量
|
||||
|
||||
| 变量 | 说明 | 注入时机 |
|
||||
@@ -60,26 +39,12 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
|
||||
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
|
||||
|
||||
## 预设风格
|
||||
|
||||
预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合:
|
||||
|
||||
| 预设 | 说明 | 效果 |
|
||||
|------|------|------|
|
||||
| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 |
|
||||
| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 |
|
||||
| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 |
|
||||
| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 |
|
||||
| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 |
|
||||
| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 |
|
||||
| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 |
|
||||
|
||||
## 情感提示
|
||||
|
||||
情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加:
|
||||
|
||||
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。"
|
||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
|
||||
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。"
|
||||
- **中性** → 不注入额外提示
|
||||
|
||||
## 安全提示
|
||||
@@ -90,7 +55,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
- 不泄露个人隐私信息
|
||||
- 不生成虚假信息
|
||||
- 回复内容与评论相关
|
||||
- 遇到恶意诱导时礼貌拒绝
|
||||
|
||||
## 自定义建议
|
||||
|
||||
@@ -102,7 +66,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。
|
||||
4. 在变量之间添加清晰的分隔和指令
|
||||
5. 避免让AI复述文章内容
|
||||
6. 控制回复长度和风格
|
||||
7. 保留多语言回复指令以确保国际化支持
|
||||
|
||||
## 变量使用示例
|
||||
|
||||
|
||||
+12
-58
@@ -7,44 +7,24 @@
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 自动回复 | 是否启用自动回复功能 | 开启 |
|
||||
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
|
||||
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
|
||||
| 速率限制 | 每分钟最大AI回复数量 | 10 |
|
||||
| 自动发布 | AI回复是否自动发布 | 开启 |
|
||||
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
|
||||
| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式 | 空 |
|
||||
|
||||
::: tip 评论者黑名单
|
||||
黑名单支持三种匹配方式:
|
||||
- **名称匹配**:直接输入评论者显示名称,如 `张三`
|
||||
- **邮箱匹配**:输入邮箱地址,如 `spam@example.com`
|
||||
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
|
||||
|
||||
多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。
|
||||
:::
|
||||
| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 |
|
||||
|
||||
## AI角色设置
|
||||
|
||||
AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| AI角色昵称 | AI回复者的显示名称 | 小回 |
|
||||
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
||||
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 |
|
||||
|
||||
### 角色管理
|
||||
默认人格提示词:
|
||||
|
||||
- **添加角色** — 点击"添加角色"按钮创建新的AI角色
|
||||
- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息
|
||||
- **删除角色** — 点击删除图标移除角色(默认角色不可删除)
|
||||
- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景)
|
||||
- **角色排序** — 使用 ▲▼ 按钮调整角色顺序
|
||||
|
||||
### 角色配置项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| 昵称 | AI回复者的显示名称 |
|
||||
| 邮箱 | 用于Gravatar头像服务展示头像 |
|
||||
| 人格提示词 | 定义AI角色的人格和回复风格 |
|
||||
| 设为默认角色 | 是否作为默认角色使用 |
|
||||
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
|
||||
::: tip Gravatar头像
|
||||
填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。
|
||||
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。
|
||||
:::
|
||||
|
||||
## 模型设置
|
||||
@@ -61,21 +41,8 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Prompt预设 | 选择预设风格,可多选 | 无 |
|
||||
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
|
||||
|
||||
### 预设风格
|
||||
|
||||
| 预设 | 说明 |
|
||||
|------|------|
|
||||
| 友好型 | 热情友好,像朋友聊天 |
|
||||
| 专业型 | 严谨正式,有逻辑性 |
|
||||
| 幽默型 | 轻松诙谐,适当幽默 |
|
||||
| 简洁型 | 一两句话,简洁明了 |
|
||||
| 技术解答型 | 深入浅出,专业解答技术问题 |
|
||||
| 鼓励型 | 积极正面,给予鼓励和支持 |
|
||||
| 知识科普型 | 通俗易懂,普及相关知识 |
|
||||
|
||||
### 模板变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
@@ -85,9 +52,7 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
|
||||
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入) |
|
||||
| `{{article}}` | 文章内容 |
|
||||
| `{{comment}}` | 评论内容 |
|
||||
| `{{post_title}}` | 文章标题 |
|
||||
| `{{post_date}}` | 文章发布日期 |
|
||||
| `{{comment_count}}` | 评论数 |
|
||||
| `{{conversation}}` | 对话上下文(多轮对话时) |
|
||||
|
||||
## 数据清理
|
||||
|
||||
@@ -97,16 +62,5 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词
|
||||
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
|
||||
|
||||
::: tip
|
||||
你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。
|
||||
:::
|
||||
|
||||
## 配置导入/导出
|
||||
|
||||
设置页面右上角提供配置导入/导出功能:
|
||||
|
||||
- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件
|
||||
- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览
|
||||
|
||||
::: warning
|
||||
导入配置将覆盖当前配置,此操作不可撤销。
|
||||
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
|
||||
:::
|
||||
|
||||
+1
-1
@@ -25,5 +25,5 @@ features:
|
||||
- title: 灵活过滤
|
||||
details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
|
||||
- title: 数据管理
|
||||
details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录
|
||||
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
|
||||
---
|
||||
|
||||
Binary file not shown.
+92
-78
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.endpoint;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
@@ -54,17 +53,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final AiReplyCleanupService cleanupService;
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
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, CommentReplyPublisher commentReplyPublisher) {
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.cleanupService = cleanupService;
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
@@ -134,8 +133,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
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;
|
||||
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
|
||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|
||||
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
|
||||
|
||||
if (needsMemoryFilter) {
|
||||
// Fall back to listAll + in-memory filter for complex queries
|
||||
@@ -184,30 +184,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
// Simple filters only - use server-side pagination
|
||||
// No filters - use server-side pagination directly
|
||||
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
||||
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
|
||||
var listOptions = ListOptions.builder().build();
|
||||
// Note: Halo's ListOptions fieldSelector support may be limited
|
||||
// For status and sentiment, we'll still filter in memory but with paginated data
|
||||
|
||||
return client.listBy(AiCommentReply.class, listOptions,
|
||||
PageRequestImpl.of(page - 1, size, sort))
|
||||
.map(listResult -> {
|
||||
var items = listResult.getItems();
|
||||
// Apply status/sentiment filter in memory on the current page
|
||||
var filtered = items.stream()
|
||||
.filter(r -> {
|
||||
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||
return true;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("items", filtered);
|
||||
result.put("items", listResult.getItems());
|
||||
result.put("total", listResult.getTotal());
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
@@ -438,31 +426,50 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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.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);
|
||||
})
|
||||
// Draft mode: no Reply extension exists yet.
|
||||
// First check if a Reply already exists (e.g. from a previous autoPublish=true run)
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(existingReply -> {
|
||||
// Reply already exists, just approve it
|
||||
existingReply.getSpec().setApproved(true);
|
||||
existingReply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(existingReply)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
|
||||
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")));
|
||||
})
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
// No existing Reply found, create a new approved one
|
||||
return commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.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)
|
||||
@@ -536,27 +543,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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);
|
||||
})
|
||||
// Draft mode: check if Reply already exists first
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(existingReply -> {
|
||||
existingReply.getSpec().setApproved(true);
|
||||
existingReply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(existingReply)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
});
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
));
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() ->
|
||||
commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.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)
|
||||
@@ -860,21 +885,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> health(ServerRequest request) {
|
||||
AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable();
|
||||
boolean aiFoundationInstalled = aiClient != null;
|
||||
|
||||
if (!aiFoundationInstalled) {
|
||||
return ServerResponse.ok().bodyValue(
|
||||
new HealthResponse(false, false, false, "", "unhealthy"));
|
||||
}
|
||||
|
||||
// AI Foundation is installed, check if it's enabled and model is available
|
||||
return aiClient.chat("ping", null)
|
||||
.map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy"))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Health check: AI Foundation call failed: {}", e.getMessage());
|
||||
return Mono.just(new HealthResponse(true, true, false, "", "degraded"));
|
||||
})
|
||||
// AiFoundationClient is always created; availability is checked at runtime
|
||||
return aiFoundationClient.isAvailable()
|
||||
.map(available -> (HealthResponse) new HealthResponse(available, available, available, "", available ? "healthy" : "degraded"))
|
||||
.defaultIfEmpty(new HealthResponse(false, false, false, "", "unhealthy"))
|
||||
.flatMap(health -> ServerResponse.ok().bodyValue(health));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ public class AiCommentReply extends AbstractExtension {
|
||||
@Schema(description = "使用的AI角色名称")
|
||||
private String personaName;
|
||||
|
||||
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
|
||||
@Schema(description = "已发布的回复名称")
|
||||
private String replyName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class AiPersona extends AbstractExtension {
|
||||
@JsonProperty("isDefault")
|
||||
private Boolean isDefault;
|
||||
|
||||
@Schema(description = "排序优先级,数值越小越靠前")
|
||||
@Schema(description = "角色优先级,数值越小优先级越高")
|
||||
private Integer priority;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
String parentCommentName = reply.getSpec().getCommentName();
|
||||
if (parentCommentName == null || parentCommentName.isBlank()) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,6 +85,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
// No quoteReply - this is a direct reply to the top-level comment,
|
||||
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
|
||||
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
if (!isReplyToAi) {
|
||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.aifoundation.AiModelService;
|
||||
import run.halo.aifoundation.chat.LanguageModel;
|
||||
import run.halo.aifoundation.chat.GenerateTextResult;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
|
||||
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
|
||||
* AI Foundation client that uses runtime class loading and reflection
|
||||
* to call the AI Foundation plugin's AiModelService.
|
||||
* <p>
|
||||
* This approach avoids classloader identity issues by loading AiModelService
|
||||
* from ai-foundation's own classloader, so that Spring's getBeansOfType()
|
||||
* can correctly match the implementation bean.
|
||||
* <p>
|
||||
* No @ConditionalOnClass or pluginDependencies needed.
|
||||
* Always registered as a bean; availability is checked at runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class AiFoundationClient {
|
||||
|
||||
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
|
||||
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
|
||||
this.client = client;
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI Foundation to generate a chat response using the specified model.
|
||||
* Checks at runtime whether the ai-foundation plugin is installed and enabled
|
||||
* before attempting to use it.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||
@@ -44,8 +55,16 @@ public class AiFoundationClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the ai-foundation plugin is installed and enabled at runtime.
|
||||
* Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found.
|
||||
*/
|
||||
public Mono<Boolean> isAvailable() {
|
||||
return isAiFoundationEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) return Mono.just(false);
|
||||
return findAiModelService().hasElement();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAiFoundationEnabled() {
|
||||
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
|
||||
.map(plugin -> plugin.getSpec().getEnabled())
|
||||
@@ -57,24 +76,144 @@ public class AiFoundationClient {
|
||||
}
|
||||
|
||||
private Mono<String> doChat(String prompt, String modelName) {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> {
|
||||
Mono<LanguageModel> modelMono;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
modelMono = service.languageModel(modelName);
|
||||
} else {
|
||||
modelMono = service.languageModel();
|
||||
}
|
||||
return modelMono.flatMap(model -> model.generateText(prompt)
|
||||
.map(GenerateTextResult::getText)
|
||||
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
|
||||
text.length(), modelName != null ? modelName : "default"))
|
||||
);
|
||||
})
|
||||
return findAiModelService()
|
||||
.flatMap(service -> invokeLanguageModel(service, modelName)
|
||||
.flatMap(model -> invokeGenerateText(model, prompt))
|
||||
)
|
||||
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PluginManager via the pluginWrapper bean registered in our plugin context.
|
||||
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton:
|
||||
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
|
||||
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance.
|
||||
*/
|
||||
private Object findPluginManager() {
|
||||
try {
|
||||
Object pluginWrapper = applicationContext.getBean("pluginWrapper");
|
||||
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager");
|
||||
getPluginManagerMethod.setAccessible(true);
|
||||
Object pm = getPluginManagerMethod.invoke(pluginWrapper);
|
||||
if (pm != null) {
|
||||
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName());
|
||||
}
|
||||
return pm;
|
||||
} catch (NoSuchMethodException e) {
|
||||
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage());
|
||||
}
|
||||
log.warn("PluginManager not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
|
||||
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
|
||||
* to get the plugin's ApplicationContext.
|
||||
*/
|
||||
private Mono<Object> findAiModelService() {
|
||||
return Mono.fromCallable(() -> {
|
||||
Object pm = findPluginManager();
|
||||
if (pm == null) return null;
|
||||
|
||||
// Call pm.getPlugin("ai-foundation") via reflection
|
||||
Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class);
|
||||
getPluginMethod.setAccessible(true);
|
||||
Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME);
|
||||
if (pluginWrapper == null) {
|
||||
log.debug("ai-foundation plugin not found in PluginManager");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Call pluginWrapper.getPlugin() to get the plugin instance
|
||||
Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin");
|
||||
getPluginInstanceMethod.setAccessible(true);
|
||||
Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper);
|
||||
if (pluginInstance == null) {
|
||||
log.debug("ai-foundation plugin instance is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the plugin's ApplicationContext via reflection on SpringPlugin
|
||||
// DefaultSpringPlugin is package-private, so we need setAccessible
|
||||
Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext");
|
||||
getCtxMethod.setAccessible(true);
|
||||
ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance);
|
||||
|
||||
// Get the plugin classloader
|
||||
Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader");
|
||||
getClassLoaderMethod.setAccessible(true);
|
||||
ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper);
|
||||
|
||||
// Load AiModelService from ai-foundation's classloader
|
||||
Class<?> aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS);
|
||||
|
||||
// Find the AiModelService bean in ai-foundation's ApplicationContext
|
||||
Map<String, ?> beans = pluginAppContext.getBeansOfType(aiModelServiceClass);
|
||||
if (beans.isEmpty()) {
|
||||
log.debug("AiModelService bean not found in ai-foundation's ApplicationContext");
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info("Found AiModelService bean in ai-foundation's ApplicationContext");
|
||||
Object result = beans.values().iterator().next();
|
||||
return (Object) result;
|
||||
}).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call service.languageModel(modelName) or service.languageModel() via reflection.
|
||||
* Returns Mono<LanguageModel> from ai-foundation's classloader.
|
||||
*/
|
||||
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
|
||||
return Mono.fromCallable(() -> {
|
||||
Method method;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
method = service.getClass().getMethod("languageModel", String.class);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(service, modelName);
|
||||
} else {
|
||||
method = service.getClass().getMethod("languageModel");
|
||||
method.setAccessible(true);
|
||||
return method.invoke(service);
|
||||
}
|
||||
}).flatMap(result -> {
|
||||
if (result instanceof Mono<?> mono) return mono;
|
||||
return Mono.justOrEmpty(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call model.generateText(prompt) via reflection, then extract text from result.
|
||||
* Returns the generated text string.
|
||||
*/
|
||||
private Mono<String> invokeGenerateText(Object model, String prompt) {
|
||||
return Mono.fromCallable(() -> {
|
||||
Method method = model.getClass().getMethod("generateText", String.class);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(model, prompt);
|
||||
}).flatMap(result -> {
|
||||
if (result instanceof Mono<?> mono) {
|
||||
return mono.map(this::extractText);
|
||||
}
|
||||
return Mono.justOrEmpty(extractText(result));
|
||||
});
|
||||
}
|
||||
|
||||
private String extractText(Object result) {
|
||||
if (result == null) return null;
|
||||
try {
|
||||
Method getText = result.getClass().getMethod("getText");
|
||||
getText.setAccessible(true);
|
||||
return (String) getText.invoke(result);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
|
||||
/**
|
||||
* Configuration that registers AiFoundationClient only when
|
||||
* AI Foundation plugin classes are available in the classloader.
|
||||
* When AI Foundation is not installed, this entire configuration is skipped.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
|
||||
public class AiFoundationConfiguration {
|
||||
|
||||
@Bean
|
||||
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
|
||||
ReactiveExtensionClient client) {
|
||||
return new AiFoundationClient(extensionGetter, client);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class AiReplyService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public AiReplyService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,12 +21,7 @@ public class AiReplyService {
|
||||
* @param modelName the model name (null for default)
|
||||
*/
|
||||
public Mono<String> generateReply(String prompt, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, cannot generate reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return client.chat(prompt, modelName)
|
||||
return aiFoundationClient.chat(prompt, modelName)
|
||||
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
|
||||
@@ -39,18 +39,6 @@ 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<>();
|
||||
@@ -59,9 +47,6 @@ 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 = """
|
||||
@@ -76,12 +61,7 @@ public class PromptBuilder {
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||
- 如果评论包含中文字符(汉字),请用中文回复
|
||||
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||
- 如果评论包含韩文字符,请用韩文回复
|
||||
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||
- 绝对不要用与评论不同的语言回复
|
||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class ReviewService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public ReviewService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
private static final String REVIEW_PROMPT_TEMPLATE = """
|
||||
@@ -37,18 +36,12 @@ public class ReviewService {
|
||||
|
||||
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
||||
String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
|
||||
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
|
||||
}
|
||||
|
||||
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
|
||||
truncate(articleContent, 2000),
|
||||
truncate(commentContent, 500),
|
||||
truncate(aiReply, 500));
|
||||
|
||||
return client.chat(reviewPrompt, modelName)
|
||||
return aiFoundationClient.chat(reviewPrompt, modelName)
|
||||
.map(this::parseSafetyResult)
|
||||
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
||||
.onErrorResume(e -> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class SentimentService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public SentimentService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
public record SentimentResult(String sentiment, double confidence) {
|
||||
@@ -22,15 +21,9 @@ public class SentimentService {
|
||||
}
|
||||
|
||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
String prompt = buildSentimentPrompt(commentContent);
|
||||
|
||||
return client.chat(prompt, modelName)
|
||||
return aiFoundationClient.chat(prompt, modelName)
|
||||
.map(response -> {
|
||||
String sentiment = parseSentiment(response);
|
||||
return new SentimentResult(sentiment, 1.0);
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
name: comment-ai-autopilot
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.23.0"
|
||||
requires: ">=2.25.0"
|
||||
author:
|
||||
name: 暖心向阳335
|
||||
website: https://nxxy335.top
|
||||
@@ -22,6 +22,4 @@ spec:
|
||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.0-ygkszvd"
|
||||
pluginDependencies:
|
||||
ai-foundation: "*"
|
||||
version: "1.0.0-beta.1"
|
||||
|
||||
@@ -177,6 +177,53 @@
|
||||
</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 -->
|
||||
@@ -252,11 +299,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import { ref, computed, onMounted, watch } 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
|
||||
@@ -264,6 +316,7 @@ interface StatsResponse {
|
||||
reviewingCount: number
|
||||
avgScore: number
|
||||
sentimentDistribution: Record<string, number>
|
||||
dailyTrend: DailyCount[]
|
||||
}
|
||||
|
||||
interface PersonaResponse {
|
||||
@@ -278,6 +331,7 @@ 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)
|
||||
|
||||
@@ -303,7 +357,7 @@ const scoreBarColor = computed(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
|
||||
)
|
||||
stats.value = data
|
||||
} catch (e) {
|
||||
@@ -375,6 +429,23 @@ 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,9 +65,6 @@
|
||||
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"
|
||||
@@ -175,12 +172,6 @@
|
||||
</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)"
|
||||
@@ -308,28 +299,6 @@
|
||||
</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>
|
||||
|
||||
@@ -382,8 +351,6 @@ 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)) {
|
||||
@@ -410,12 +377,6 @@ 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 {
|
||||
@@ -423,8 +384,6 @@ 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 },
|
||||
@@ -434,7 +393,6 @@ 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
|
||||
}
|
||||
@@ -457,34 +415,10 @@ 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(
|
||||
`/apis/comment-ai-autopilot.nxxy335.top/v1alpha1/aicommentreplies/${name}`,
|
||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`,
|
||||
)
|
||||
Toast.success("删除成功")
|
||||
fetchReplies()
|
||||
@@ -675,13 +609,11 @@ const resetFilters = () => {
|
||||
filterStatus.value = ""
|
||||
filterSentiment.value = ""
|
||||
filterKeyword.value = ""
|
||||
filterStartDate.value = ""
|
||||
filterEndDate.value = ""
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
}
|
||||
|
||||
watch([filterStatus, filterSentiment, filterKeyword, filterStartDate, filterEndDate], () => {
|
||||
watch([filterStatus, filterSentiment, filterKeyword], () => {
|
||||
page.value = 1
|
||||
fetchReplies()
|
||||
})
|
||||
|
||||
+11
-104
@@ -122,38 +122,26 @@
|
||||
<span>请添加至少一个AI角色</span>
|
||||
</div>
|
||||
<div v-else class="persona-list">
|
||||
<div v-for="(persona, index) in personas" :key="persona.metadata.name" class="persona-card">
|
||||
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
|
||||
<div class="persona-card__avatar">
|
||||
<img v-if="getPersonaAvatar(persona)" :src="getPersonaAvatar(persona)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (persona.spec.displayName || '?').charAt(0) }}</span>
|
||||
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
||||
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="persona-card__info">
|
||||
<div class="persona-card__name">
|
||||
{{ persona.spec.displayName || '未命名' }}
|
||||
<span v-if="persona.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
{{ p.spec.displayName || '未命名' }}
|
||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
||||
</div>
|
||||
<div class="persona-card__prompt">{{ persona.spec.prompt || '暂无提示词' }}</div>
|
||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
||||
</div>
|
||||
<div class="persona-card__actions">
|
||||
<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)">
|
||||
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
|
||||
<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="!persona.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(persona)">
|
||||
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
|
||||
<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="!persona.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(persona)">
|
||||
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
|
||||
<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>
|
||||
@@ -444,9 +432,6 @@ 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({
|
||||
@@ -642,7 +627,6 @@ const fetchPersonas = async () => {
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch personas", e)
|
||||
Toast.error("获取角色列表失败")
|
||||
personas.value = []
|
||||
} finally {
|
||||
personasLoading.value = false
|
||||
@@ -727,7 +711,7 @@ const savePersona = async () => {
|
||||
isDefault: personaForm.isDefault,
|
||||
},
|
||||
apiVersion: 'comment-ai-autopilot.nxxy335.top/v1alpha1',
|
||||
kind: 'Persona',
|
||||
kind: 'AiPersona',
|
||||
metadata: personaEditing.value
|
||||
? { name: personaEditing.value.metadata.name }
|
||||
: { generateName: 'persona-' },
|
||||
@@ -795,56 +779,6 @@ 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) => {
|
||||
@@ -882,7 +816,7 @@ const fetchSettings = async () => {
|
||||
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); Toast.error("获取设置失败") }
|
||||
} catch (e) { console.error("Failed to fetch settings", e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -922,33 +856,6 @@ 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