Compare commits
4
Commits
1.0.0-beta.2
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c88ad6fb9 | ||
|
|
a9dd1c14bc | ||
|
|
77e3bd36c5 | ||
|
|
7934d8c947 |
@@ -6,7 +6,24 @@ on:
|
|||||||
- published
|
- published
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Pre-cleanup: delete all existing assets from the release to avoid gh release upload failure
|
||||||
|
pre-release-cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Delete Existing Release Assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||||
|
# List all existing assets and delete them
|
||||||
|
gh release view "$TAG_NAME" --json assets --jq '.assets[].name' 2>/dev/null | while read -r filename; do
|
||||||
|
echo "Deleting existing asset: $filename"
|
||||||
|
gh release delete-asset "$TAG_NAME" "$filename" --yes 2>/dev/null || true
|
||||||
|
done
|
||||||
|
shell: bash
|
||||||
|
|
||||||
cd:
|
cd:
|
||||||
|
needs: pre-release-cleanup
|
||||||
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v4
|
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v4
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
|
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色
|
||||||
|
- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI
|
||||||
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
|
||||||
- **多语言适配** — 根据评论语言自动用对应语言回复
|
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
|
||||||
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
|
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
|
||||||
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
- **失败重试** — AI 生成失败时自动重试,指数退避策略
|
||||||
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
|
||||||
@@ -30,16 +31,18 @@
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
|
### 应用商店安装
|
||||||
|
|
||||||
|
进入 **插件** → **安装** → 应用市场搜索 **AI回评** → 安装,或前往 [Halo 应用商店](https://www.halo.run/store/apps/app-mo5tivjt) 一键安装。
|
||||||
|
|
||||||
|
### 手动安装
|
||||||
|
|
||||||
1. 前往 [Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
1. 前往 [Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
||||||
2. 登录 Halo 管理后台
|
2. 登录 Halo 管理后台
|
||||||
3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮
|
3. 进入 **插件** → **安装** → **本地上传**
|
||||||
4. 选择下载的 `.jar` 文件上传
|
4. 选择下载的 `.jar` 文件上传
|
||||||
5. 安装完成后启用插件
|
5. 安装完成后启用插件
|
||||||
|
|
||||||
::: tip
|
|
||||||
安装本插件后,应用市场会推荐安装 AI Foundation 插件(本插件的必要依赖)。
|
|
||||||
:::
|
|
||||||
|
|
||||||
## 从源码构建
|
## 从源码构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -67,65 +70,7 @@ pnpm dev
|
|||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
完整文档请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot)
|
完整文档及更新日志请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot)
|
||||||
|
|
||||||
## 更新日志
|
|
||||||
|
|
||||||
### v1.0.0-beta.2
|
|
||||||
|
|
||||||
**改进**
|
|
||||||
|
|
||||||
- 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI Foundation 的 `AiModelService`,替换原先的跨 ClassLoader 反射调用方式
|
|
||||||
- 在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系
|
|
||||||
- 新增 `store.halo.run/recommended-apps` 注解,安装后可在应用市场推荐安装 AI Foundation 插件
|
|
||||||
- 情感分析和内容审核改用 AI Foundation 结构化输出(`OutputSpec.choice`),分类更可靠
|
|
||||||
- AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误
|
|
||||||
- AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文
|
|
||||||
- 优化 AI 自审核评分机制:改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分,替代原先的二值评分
|
|
||||||
- 精简仪表盘:移除情感分布、近7日回复趋势、平均审核评分三个卡片,快捷操作精简为回复日志、插件设置、刷新数据
|
|
||||||
- 重做设置页面:基本设置、AI角色设置、模型设置、Prompt设置、数据清理各为独立页面,通过标签栏切换
|
|
||||||
- 优化设置页面布局:按钮统一排版并添加图标,侧边栏新增"未保存"状态指示器
|
|
||||||
- 优化日志页面:评分增加等级标签(优秀/良好/一般/较差),筛选下拉框修复文本与箭头重叠
|
|
||||||
- 优化 AI Foundation 状态提示宽度,与内容区宽度一致
|
|
||||||
- 优化插件文档:修复版本要求、变量名、链接等错误
|
|
||||||
|
|
||||||
**Bug 修复**
|
|
||||||
|
|
||||||
- 修复 RateLimitService 清理线程在插件停止时未关闭导致线程泄漏
|
|
||||||
- 修复内容审核提示词要求"重新生成"但代码未使用重新生成内容的问题
|
|
||||||
- 修复设置页面按钮图标和文字未在同一行显示的问题
|
|
||||||
- 修复设置页面标签栏无法点击切换的问题
|
|
||||||
- 修复设置页面配置区域布局错误的问题
|
|
||||||
|
|
||||||
### v1.0.0-beta.1
|
|
||||||
|
|
||||||
**新功能**
|
|
||||||
|
|
||||||
- 草稿模式:关闭"自动发布"后,AI 回复将保存为草稿,需站长审核后才发布
|
|
||||||
- 多 AI 角色支持:支持配置多个 AI 虚拟角色,每个角色有独立的提示词、头像和模型
|
|
||||||
- 提示词预设:内置多种回复风格预设(专业型、幽默型、简洁型等),可自由组合
|
|
||||||
- 对话历史查看:支持查看 AI 回复的完整对话上下文
|
|
||||||
|
|
||||||
**Bug 修复**
|
|
||||||
|
|
||||||
- 修复草稿模式下审批失败("AI回复已存在,无法重复发布")的问题
|
|
||||||
- 修复批量审批时同样的去重检查冲突问题
|
|
||||||
- 修复 AI Foundation 不可用的问题(`PluginManager` 无法通过 Spring 依赖注入获取)
|
|
||||||
- 修复 `DefaultSpringPlugin` 包级私有类反射访问权限问题
|
|
||||||
- 修复 CI 构建失败(`gradlew` 缺少执行权限)
|
|
||||||
|
|
||||||
**改进**
|
|
||||||
|
|
||||||
- 审批逻辑优化:先查找已有 Reply 扩展再决定创建或更新
|
|
||||||
- 移除不必要的 `AiFoundationConfiguration` 配置类
|
|
||||||
- 前端 UI 优化:移除编辑功能、简化角色排序逻辑、清理无用代码
|
|
||||||
|
|
||||||
### v0.0.3
|
|
||||||
|
|
||||||
- 增强插件可靠性与可用性
|
|
||||||
- 多 AI 角色支持
|
|
||||||
- 草稿模式初步实现
|
|
||||||
- 文档全面更新
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group 'top.nxxy335.commentaiautopilot'
|
group 'top.nxxy335.commentaiautopilot'
|
||||||
version '1.0.0-beta.2'
|
version '1.0.0'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ export default defineConfig({
|
|||||||
text: "其他",
|
text: "其他",
|
||||||
items: [
|
items: [
|
||||||
{ text: "常见问题", link: "/guide/faq" },
|
{ text: "常见问题", link: "/guide/faq" },
|
||||||
|
{ text: "更新日志", link: "/CHANGELOG" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{
|
{
|
||||||
icon: "github",
|
icon: "github",
|
||||||
link: "https://github.com/nxxy335/plugin-comment-ai-autopilot",
|
link: "https://github.com/sunny-335/plugin-comment-ai-autopilot",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
search: {
|
search: {
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
|
||||||
|
> 2026-06-18
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- **唤醒词**:评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI,二级评论同样支持
|
||||||
|
- **性别配置**:AI角色支持性别设置(男/女),AI回复时会保持对应性别身份
|
||||||
|
- **语气风格**:支持中性语气复选框,勾选后使用中性语气,取消勾选则跟随性别语气(女性温柔细腻/男性沉稳理性)
|
||||||
|
- **身份提示词强化**:角色身份信息前置到Prompt最开头(【核心身份】),安全规范中增加身份约束,确保AI始终保持角色身份
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- **优化情感分析系统**:从 3 级分类(正面/中性/负面)升级为 5 级分类(非常正面/正面/中性/负面/非常负面),情感判断更精细
|
||||||
|
- **优化日志页面 UI**:批量操作按钮重写样式,确保底色和白色文字清晰可见;搜索框添加搜索图标;重置按钮添加图标和底色
|
||||||
|
- **优化评分显示**:评分数字与等级标签之间添加间距,等级标签增加底色背景(优秀/良好/一般/较差)
|
||||||
|
- **优化状态标签**:通过状态、发布状态、情感标签统一使用带底色的标签样式
|
||||||
|
- **支持页面链接显示**:日志中新增独立页面(SinglePage)链接显示,之前仅支持文章链接
|
||||||
|
- **移动端适配**:仪表盘、配置、日志页面全面适配移动端
|
||||||
|
- **ObjectMapper 统一注入**:FilterService 和 PromptBuilder 中的 `new ObjectMapper()` 改为 Spring 构造函数注入
|
||||||
|
- **服务端过滤优化**:日志列表查询改用 `Queries.equal()` 服务端过滤 status/sentiment,减少内存过滤开销
|
||||||
|
- **新增索引**:为 AiCommentReply 扩展添加 `spec.sentiment`、`spec.published`、`spec.postKind` 索引
|
||||||
|
- **新增 postKind 字段**:区分关联内容类型(Post/SinglePage),支持页面评论的链接生成
|
||||||
|
- **PromptBuilder 情感提示**:适配 5 级情感分类,新增 VERY_POSITIVE 和 VERY_NEGATIVE 的语气提示
|
||||||
|
|
||||||
|
### Bug 修复
|
||||||
|
|
||||||
|
- **修复 ObjectMapper Bean 不存在**:Halo 插件上下文中没有自动注册 ObjectMapper Bean,创建 ObjectMapperConfiguration 手动注册
|
||||||
|
- **修复 AI 回复仍说没有性别**:将身份信息前置到 Prompt 最开头,安全规范中删除"作为AI助手"措辞,新增身份约束
|
||||||
|
- **修复唤醒词无法唤醒**:评论内容提取时对 raw 也做 HTML strip(Jsoup.clean),所有内容做 trim(),wakeWord 也做 trim()
|
||||||
|
- **修复二级评论唤醒词检查位置错误**:唤醒词检查提前到 isReplyToAi 判断之前
|
||||||
|
- **修复 SinglePage 内容获取 404**:PostContentService 不能用于 SinglePage,改用 SinglePage.getStatus().getExcerpt()
|
||||||
|
- **修复 Post/SinglePage 404 容错**:fetch 添加 onErrorResume 降级为空上下文继续处理
|
||||||
|
- **修复 Sort 参数 null 警告**:listAll 调用改为 Sort.unsorted()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0.0-beta.2
|
## v1.0.0-beta.2
|
||||||
|
|
||||||
> 2026-06-17
|
> 2026-06-17
|
||||||
|
|||||||
@@ -21,6 +21,32 @@
|
|||||||
|
|
||||||
插件启动时间之前的评论不会触发自动回复,避免安装插件后对大量历史评论批量回复。
|
插件启动时间之前的评论不会触发自动回复,避免安装插件后对大量历史评论批量回复。
|
||||||
|
|
||||||
|
## 唤醒词机制
|
||||||
|
|
||||||
|
唤醒词允许用户在评论中通过特定词语唤醒指定AI角色回复,即使该页面未启用AI回评。
|
||||||
|
|
||||||
|
### 工作方式
|
||||||
|
|
||||||
|
1. 用户发表以唤醒词开头的评论(如"小回小回你好")
|
||||||
|
2. 插件检测到唤醒词匹配,自动唤醒对应角色
|
||||||
|
3. 唤醒词后的内容作为实际评论内容传递给AI
|
||||||
|
4. AI生成回复时自动获取上下文(文章内容、对话历史等)
|
||||||
|
|
||||||
|
### 唤醒词特点
|
||||||
|
|
||||||
|
- **跨页面唤醒**:在未启用AI回评的页面也能使用唤醒词召唤AI
|
||||||
|
- **二级评论支持**:回复中同样可以使用唤醒词
|
||||||
|
- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色
|
||||||
|
- **绕过限制**:唤醒词触发时绕过页面级启用检查和"必须是回复给AI"的检查,但仍检查黑名单
|
||||||
|
|
||||||
|
### 配置唤醒词
|
||||||
|
|
||||||
|
在 **AI回评** → **插件设置** → **AI角色设置** 中,为每个角色配置唤醒词。唤醒词留空则不启用该角色的唤醒功能。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
唤醒词建议设置为容易记忆且不易与正常评论混淆的词语。
|
||||||
|
:::
|
||||||
|
|
||||||
## 对话式回复
|
## 对话式回复
|
||||||
|
|
||||||
当评论者回复AI的评论时,插件会自动提取对话上下文(最近5条回复),让AI的回复更连贯自然。
|
当评论者回复AI的评论时,插件会自动提取对话上下文(最近5条回复),让AI的回复更连贯自然。
|
||||||
|
|||||||
@@ -7,19 +7,19 @@
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
### 方式一:从 Release 下载
|
### 方式一:应用商店安装
|
||||||
|
|
||||||
|
进入 **插件** → **安装** → 应用市场搜索 **AI回评** → 安装,或前往 [Halo 应用商店](https://www.halo.run/store/apps/app-mo5tivjt) 一键安装。
|
||||||
|
|
||||||
|
### 方式二:从 Release 下载
|
||||||
|
|
||||||
1. 前往 [GitHub Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
1. 前往 [GitHub Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
|
||||||
2. 登录 Halo 管理后台
|
2. 登录 Halo 管理后台
|
||||||
3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮
|
3. 进入 **插件** → **安装** → **本地上传**
|
||||||
4. 选择下载的 `.jar` 文件上传
|
4. 选择下载的 `.jar` 文件上传
|
||||||
5. 安装完成后启用插件
|
5. 安装完成后启用插件
|
||||||
|
|
||||||
::: tip 推荐安装
|
### 方式三:从源码构建
|
||||||
安装本插件后,应用市场会推荐安装 AI Foundation 插件(本插件的必要依赖)。
|
|
||||||
:::
|
|
||||||
|
|
||||||
### 方式二:从源码构建
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
# 克隆仓库
|
||||||
|
|||||||
+13
-10
@@ -5,9 +5,11 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
|||||||
## 核心功能
|
## 核心功能
|
||||||
|
|
||||||
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
|
||||||
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
|
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色
|
||||||
|
- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI
|
||||||
|
- **性别与语气** — AI角色支持性别配置(男/女)和中性语气风格,AI回复时保持对应性别身份
|
||||||
- **多语言适配** — 根据评论语言自动用对应语言回复
|
- **多语言适配** — 根据评论语言自动用对应语言回复
|
||||||
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
|
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
|
||||||
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布
|
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布
|
||||||
- **失败重试** — AI生成失败时自动重试,指数退避策略
|
- **失败重试** — AI生成失败时自动重试,指数退避策略
|
||||||
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除
|
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除
|
||||||
@@ -25,19 +27,20 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
|
|||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
```
|
```
|
||||||
新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
新评论 → 唤醒词检查 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
|
||||||
↓ (失败)
|
↓ (失败)
|
||||||
重试 → ... → 最终失败
|
重试 → ... → 最终失败
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
1. **新评论到达** — Reconciler 监听到新评论创建事件
|
||||||
2. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中
|
2. **唤醒词检查** — 检查评论是否以某个角色的唤醒词开头,匹配则唤醒对应角色
|
||||||
3. **情感分析** — 调用AI分析评论情感倾向
|
3. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中(唤醒词触发时绕过页面级启用检查)
|
||||||
4. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
|
4. **情感分析** — 调用AI分析评论情感倾向
|
||||||
5. **AI生成** — 调用AI模型生成回复内容
|
5. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt
|
||||||
6. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
|
6. **AI生成** — 调用AI模型生成回复内容
|
||||||
7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
7. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
|
||||||
8. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
|
8. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
|
||||||
|
9. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
|
||||||
|
|
||||||
## 前置要求
|
## 前置要求
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
# AI角色
|
# AI角色
|
||||||
|
|
||||||
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
AI角色定义了回复评论的虚拟身份,包括昵称、人格、性别、语气风格和头像。
|
||||||
|
|
||||||
## 角色配置
|
## 角色配置
|
||||||
|
|
||||||
@@ -8,6 +8,28 @@ AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
|
|||||||
|
|
||||||
AI回复者的显示名称,默认为「小回」。修改后新回复将使用新昵称,已有回复不受影响。
|
AI回复者的显示名称,默认为「小回」。修改后新回复将使用新昵称,已有回复不受影响。
|
||||||
|
|
||||||
|
### 性别与语气
|
||||||
|
|
||||||
|
每个角色可以设置性别(男/女),AI回复时会保持对应性别身份。语气风格通过"中性语气"复选框控制:
|
||||||
|
|
||||||
|
- **勾选中性语气**:AI使用中性语气回复
|
||||||
|
- **取消勾选**:AI根据性别使用对应语气风格(女性→温柔细腻,男性→沉稳理性)
|
||||||
|
|
||||||
|
默认角色「小回」的性别为女,勾选中性语气。
|
||||||
|
|
||||||
|
### 唤醒词
|
||||||
|
|
||||||
|
评论以唤醒词开头可唤醒该角色回复。唤醒词功能的特点:
|
||||||
|
|
||||||
|
- **跨页面唤醒**:在未启用AI回评的页面,使用唤醒词也能召唤AI回复
|
||||||
|
- **二级评论支持**:回复中同样可以使用唤醒词唤醒指定角色
|
||||||
|
- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色
|
||||||
|
- **留空不启用**:唤醒词留空则不启用该角色的唤醒功能
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
唤醒词匹配时,插件会自动去除评论中的HTML标签并去除首尾空格,确保匹配准确。
|
||||||
|
:::
|
||||||
|
|
||||||
### 人格提示词
|
### 人格提示词
|
||||||
|
|
||||||
人格提示词定义了AI角色的性格和回复风格,是影响回复质量的关键配置。
|
人格提示词定义了AI角色的性格和回复风格,是影响回复质量的关键配置。
|
||||||
|
|||||||
+10
-6
@@ -6,14 +6,16 @@
|
|||||||
|
|
||||||
| 分类 | 说明 | AI回复语气 |
|
| 分类 | 说明 | AI回复语气 |
|
||||||
|------|------|-----------|
|
|------|------|-----------|
|
||||||
| 正面 | 评论情绪积极、友好、感谢 | 热情友好,表达感谢和共鸣 |
|
| 非常正面 | 强烈的感谢、赞美、认同(如"太棒了"、"非常感谢") | 热情洋溢,表达真诚的感谢和共鸣 |
|
||||||
| 中性 | 评论情绪平淡、普通提问 | 正常语气回复,不加额外提示 |
|
| 正面 | 友好、肯定、支持(如"不错"、"学习了") | 热情友好,表达感谢和共鸣 |
|
||||||
| 负面 | 评论情绪偏消极、不满、批评 | 理性温和,避免激化矛盾 |
|
| 中性 | 提问、讨论、陈述事实(如"请问..."、"这个怎么用") | 正常语气回复,不加额外提示 |
|
||||||
|
| 负面 | 不满、质疑、批评(如"不好用"、"有问题") | 理性温和,避免激化矛盾 |
|
||||||
|
| 非常负面 | 攻击、辱骂、极端情绪(如"垃圾"、"骗子") | 非常温和理性,避免激化矛盾,展现理解和耐心 |
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
1. 评论通过过滤检查后,调用AI对评论内容进行情感分析
|
1. 评论通过过滤检查后,调用AI对评论内容进行情感分析
|
||||||
2. AI 使用结构化输出(`OutputSpec.choice`)返回情感分类结果(POSITIVE / NEUTRAL / NEGATIVE)
|
2. AI 使用结构化输出(`OutputSpec.choice`)返回情感分类结果(VERY_POSITIVE / POSITIVE / NEUTRAL / NEGATIVE / VERY_NEGATIVE)
|
||||||
3. 如果情感分析失败(如AI不可用),默认降级为 NEUTRAL
|
3. 如果情感分析失败(如AI不可用),默认降级为 NEUTRAL
|
||||||
4. 情感结果传入 PromptBuilder,在生成Prompt时追加对应的语气提示
|
4. 情感结果传入 PromptBuilder,在生成Prompt时追加对应的语气提示
|
||||||
5. 情感结果同时记录在 `AiCommentReply` 的 `sentiment` 字段中
|
5. 情感结果同时记录在 `AiCommentReply` 的 `sentiment` 字段中
|
||||||
@@ -22,9 +24,11 @@
|
|||||||
|
|
||||||
在AI回复日志页面,每条记录会显示情感标签:
|
在AI回复日志页面,每条记录会显示情感标签:
|
||||||
|
|
||||||
- 🟢 **正面** — 绿色标签
|
- 🟢 **非常正面** — 深绿色标签
|
||||||
|
- 🟩 **正面** — 浅绿色标签
|
||||||
- ⚪ **中性** — 灰色标签
|
- ⚪ **中性** — 灰色标签
|
||||||
- 🔴 **负面** — 红色标签
|
- 🟥 **负面** — 浅红色标签
|
||||||
|
- 🔴 **非常负面** — 深红色标签
|
||||||
|
|
||||||
## 性能影响
|
## 性能影响
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,13 @@
|
|||||||
|
|
||||||
## AI角色设置
|
## AI角色设置
|
||||||
|
|
||||||
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可指定一个为默认角色。
|
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。
|
||||||
|
|
||||||
| 配置项 | 说明 | 默认值 |
|
| 配置项 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| 角色昵称 | AI回复者的显示名称 | 小回 |
|
| 角色昵称 | AI回复者的显示名称 | 小回 |
|
||||||
|
| 性别与语气 | 角色性别(男/女)+ 中性语气复选框(勾选=中性语气,取消勾选=跟随性别语气) | 女 + 中性语气 |
|
||||||
|
| 唤醒词 | 评论以此词开头则唤醒该角色回复,留空不启用 | 空 |
|
||||||
| 人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
| 人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
|
||||||
| 邮箱 | 用于 Gravatar 头像服务展示头像 | 空 |
|
| 邮箱 | 用于 Gravatar 头像服务展示头像 | 空 |
|
||||||
| 设为默认 | 将该角色设为默认角色 | 第一个角色默认 |
|
| 设为默认 | 将该角色设为默认角色 | 第一个角色默认 |
|
||||||
@@ -90,8 +92,10 @@ AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个
|
|||||||
|
|
||||||
::: tip 情感提示
|
::: tip 情感提示
|
||||||
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
|
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
|
||||||
|
- **非常正面** → 追加热情洋溢的语气提示
|
||||||
- **正面** → 追加热情友好的语气提示
|
- **正面** → 追加热情友好的语气提示
|
||||||
- **负面** → 追加理性温和的语气提示
|
- **负面** → 追加理性温和的语气提示
|
||||||
|
- **非常负面** → 追加冷静关怀的语气提示
|
||||||
- **中性** → 不追加额外提示
|
- **中性** → 不追加额外提示
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
|||||||
.indexFunc(ext -> ext.getSpec().getPostId()));
|
.indexFunc(ext -> ext.getSpec().getPostId()));
|
||||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
||||||
.indexFunc(ext -> ext.getSpec().getStatus()));
|
.indexFunc(ext -> ext.getSpec().getStatus()));
|
||||||
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.sentiment", String.class)
|
||||||
|
.indexFunc(ext -> ext.getSpec().getSentiment()));
|
||||||
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.published", String.class)
|
||||||
|
.indexFunc(ext -> String.valueOf(ext.getSpec().getPublished())));
|
||||||
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.postKind", String.class)
|
||||||
|
.indexFunc(ext -> ext.getSpec().getPostKind()));
|
||||||
});
|
});
|
||||||
schemeManager.register(AiPersona.class);
|
schemeManager.register(AiPersona.class);
|
||||||
|
|
||||||
@@ -61,6 +67,9 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
|||||||
spec.setDisplayName("小回");
|
spec.setDisplayName("小回");
|
||||||
spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。");
|
spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。");
|
||||||
spec.setEmail("");
|
spec.setEmail("");
|
||||||
|
spec.setGender("female");
|
||||||
|
spec.setNeutralVoice(true);
|
||||||
|
spec.setWakeWord("小回小回");
|
||||||
spec.setIsDefault(true);
|
spec.setIsDefault(true);
|
||||||
persona.setSpec(spec);
|
persona.setSpec(spec);
|
||||||
return client.create(persona);
|
return client.create(persona);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ObjectMapperConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ObjectMapper objectMapper() {
|
||||||
|
return new ObjectMapper();
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-78
@@ -8,7 +8,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
@@ -16,6 +15,7 @@ import run.halo.app.extension.Metadata;
|
|||||||
import run.halo.app.extension.GroupVersion;
|
import run.halo.app.extension.GroupVersion;
|
||||||
import run.halo.app.extension.ListOptions;
|
import run.halo.app.extension.ListOptions;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
|
import run.halo.app.extension.index.query.Queries;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.PageRequestImpl;
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
@@ -24,6 +24,8 @@ import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
|
|||||||
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -56,16 +58,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
private final AiFoundationClient aiFoundationClient;
|
private final AiFoundationClient aiFoundationClient;
|
||||||
private final CommentReplyPublisher commentReplyPublisher;
|
private final CommentReplyPublisher commentReplyPublisher;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) {
|
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher, ObjectMapper objectMapper, PersonaResolver personaResolver) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
this.cleanupService = cleanupService;
|
this.cleanupService = cleanupService;
|
||||||
this.aiFoundationClient = aiFoundationClient;
|
this.aiFoundationClient = aiFoundationClient;
|
||||||
this.commentReplyPublisher = commentReplyPublisher;
|
this.commentReplyPublisher = commentReplyPublisher;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
|
this.personaResolver = personaResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -133,19 +137,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
final Instant finalStartInstant = startInstant;
|
final Instant finalStartInstant = startInstant;
|
||||||
final Instant finalEndInstant = endInstant;
|
final Instant finalEndInstant = endInstant;
|
||||||
|
|
||||||
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
|
// Check if we need in-memory filtering (keyword or date range)
|
||||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|
||||||
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
|
|
||||||
|
// Build server-side query for status and sentiment (indexed fields)
|
||||||
|
var listOptionsBuilder = ListOptions.builder();
|
||||||
|
if (!statusFilter.isBlank()) {
|
||||||
|
listOptionsBuilder.andQuery(Queries.equal("spec.status", statusFilter));
|
||||||
|
}
|
||||||
|
if (!sentimentFilter.isBlank()) {
|
||||||
|
listOptionsBuilder.andQuery(Queries.equal("spec.sentiment", sentimentFilter));
|
||||||
|
}
|
||||||
|
var listOptions = listOptionsBuilder.build();
|
||||||
|
|
||||||
if (needsMemoryFilter) {
|
if (needsMemoryFilter) {
|
||||||
// Fall back to listAll + in-memory filter for complex queries
|
// Fall back to listAll + in-memory filter for keyword/date queries
|
||||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, listOptions, Sort.unsorted())
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(replies -> {
|
.map(replies -> {
|
||||||
var filtered = replies.stream()
|
var filtered = replies.stream()
|
||||||
.filter(r -> {
|
.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()) {
|
if (!keywordFilter.isBlank()) {
|
||||||
String reply = r.getSpec().getReply();
|
String reply = r.getSpec().getReply();
|
||||||
if (reply == null || !reply.contains(keywordFilter)) return false;
|
if (reply == null || !reply.contains(keywordFilter)) return false;
|
||||||
@@ -184,13 +195,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No filters - use server-side pagination directly
|
// No memory filters needed - use server-side pagination directly
|
||||||
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
||||||
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||||
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||||
|
|
||||||
var listOptions = ListOptions.builder().build();
|
|
||||||
|
|
||||||
return client.listBy(AiCommentReply.class, listOptions,
|
return client.listBy(AiCommentReply.class, listOptions,
|
||||||
PageRequestImpl.of(page - 1, size, sort))
|
PageRequestImpl.of(page - 1, size, sort))
|
||||||
.map(listResult -> {
|
.map(listResult -> {
|
||||||
@@ -246,18 +255,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.next()
|
.next()
|
||||||
.flatMap(persona -> {
|
.flatMap(persona -> {
|
||||||
String email = persona.getSpec().getEmail();
|
String email = persona.getSpec().getEmail();
|
||||||
String avatarUrl = "";
|
String avatarUrl = GravatarUtil.generateUrl(email);
|
||||||
if (email != null && !email.isBlank()) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
avatarUrl = "https://cn.cravatar.com/avatar/" + hexString;
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
return ServerResponse.ok().bodyValue(Map.of(
|
return ServerResponse.ok().bodyValue(Map.of(
|
||||||
"name", persona.getSpec().getDisplayName(),
|
"name", persona.getSpec().getDisplayName(),
|
||||||
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
|
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
|
||||||
@@ -297,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
||||||
);
|
);
|
||||||
|
|
||||||
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.list(Reply.class,
|
||||||
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
|
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
||||||
|
null)
|
||||||
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
||||||
.map(reply -> {
|
.map(reply -> {
|
||||||
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
||||||
@@ -642,9 +641,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
||||||
}
|
}
|
||||||
// Read persona name from post annotations
|
// Read persona name from post annotations
|
||||||
return getPersonaNameFromComment(commentName)
|
return personaResolver.getPersonaNameFromComment(commentName)
|
||||||
.flatMap(personaName ->
|
.flatMap(personaName ->
|
||||||
orchestrator.processComment(commentName, null, false, personaName)
|
orchestrator.processComment(commentName, null, false, personaName, false)
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -669,9 +668,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
return ServerResponse.badRequest()
|
return ServerResponse.badRequest()
|
||||||
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
||||||
}
|
}
|
||||||
return getPersonaNameFromComment(commentName)
|
return personaResolver.getPersonaNameFromComment(commentName)
|
||||||
.flatMap(personaName ->
|
.flatMap(personaName ->
|
||||||
orchestrator.processComment(commentName, replyName, true, personaName)
|
orchestrator.processComment(commentName, replyName, true, personaName, false)
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -679,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
|
||||||
|
|
||||||
private Mono<String> getPersonaNameFromComment(String commentName) {
|
|
||||||
return client.fetch(Comment.class, commentName)
|
|
||||||
.flatMap(comment -> {
|
|
||||||
var subjectRef = comment.getSpec().getSubjectRef();
|
|
||||||
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
|
||||||
return Mono.justOrEmpty(null);
|
|
||||||
}
|
|
||||||
String postName = subjectRef.getName();
|
|
||||||
return client.fetch(Post.class, postName)
|
|
||||||
.mapNotNull(post -> {
|
|
||||||
var annotations = post.getMetadata().getAnnotations();
|
|
||||||
if (annotations != null) {
|
|
||||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (persona != null && !persona.isBlank()) {
|
|
||||||
return persona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.defaultIfEmpty("");
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||||
// First try using replyName if available
|
// First try using replyName if available
|
||||||
String replyName = record.getSpec().getReplyName();
|
String replyName = record.getSpec().getReplyName();
|
||||||
@@ -748,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||||
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.list(Comment.class, null, null)
|
||||||
|
.take(1000)
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(comments -> {
|
.map(comments -> {
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
@@ -766,7 +741,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
|
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
|
||||||
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
||||||
} else if (!email.isBlank()) {
|
} else if (!email.isBlank()) {
|
||||||
avatarUrl = generateGravatarUrl(email);
|
avatarUrl = GravatarUtil.generateUrl(email);
|
||||||
}
|
}
|
||||||
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
||||||
}
|
}
|
||||||
@@ -776,26 +751,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateGravatarUrl(String email) {
|
|
||||||
try {
|
|
||||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
|
||||||
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
||||||
var hexString = new StringBuilder();
|
|
||||||
for (byte b : hashBytes) {
|
|
||||||
hexString.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return "https://cn.cravatar.com/avatar/" + hexString;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
||||||
return Mono.fromCallable(() -> {
|
return cleanupService.getRetentionDays()
|
||||||
int retentionDays = cleanupService.getRetentionDays();
|
.flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays)
|
||||||
long deleted = cleanupService.executeCleanup(retentionDays);
|
.map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays))
|
||||||
return Map.of("deletedCount", deleted, "retentionDays", retentionDays);
|
)
|
||||||
})
|
|
||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
||||||
@@ -961,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
for (var personaData : personasList) {
|
for (var personaData : personasList) {
|
||||||
importMono = importMono.then(Mono.defer(() -> {
|
importMono = importMono.then(Mono.defer(() -> {
|
||||||
try {
|
try {
|
||||||
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
||||||
var personaJson = objectMapper.writeValueAsString(personaData);
|
var personaJson = objectMapper.writeValueAsString(personaData);
|
||||||
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
||||||
var personaName = persona.getMetadata().getName();
|
var personaName = persona.getMetadata().getName();
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class AiCommentReply extends AbstractExtension {
|
|||||||
@Schema(description = "关联文章Slug,用于生成文章链接")
|
@Schema(description = "关联文章Slug,用于生成文章链接")
|
||||||
private String postSlug;
|
private String postSlug;
|
||||||
|
|
||||||
|
@Schema(description = "关联内容类型: Post/SinglePage")
|
||||||
|
private String postKind;
|
||||||
|
|
||||||
@Schema(description = "AI回复内容")
|
@Schema(description = "AI回复内容")
|
||||||
private String reply;
|
private String reply;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ public class AiPersona extends AbstractExtension {
|
|||||||
@Schema(description = "邮箱(用于Gravatar头像)")
|
@Schema(description = "邮箱(用于Gravatar头像)")
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "角色性别(male/female)")
|
||||||
|
private String gender;
|
||||||
|
|
||||||
|
@Schema(description = "是否使用中性语气,默认false即跟随性别语气")
|
||||||
|
@JsonProperty("neutralVoice")
|
||||||
|
private Boolean neutralVoice;
|
||||||
|
|
||||||
|
@Schema(description = "唤醒词,评论以此开头则唤醒该角色回复,留空则不启用唤醒")
|
||||||
|
private String wakeWord;
|
||||||
|
|
||||||
@Schema(description = "是否为默认角色")
|
@Schema(description = "是否为默认角色")
|
||||||
@JsonProperty("isDefault")
|
@JsonProperty("isDefault")
|
||||||
private Boolean isDefault;
|
private Boolean isDefault;
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package top.nxxy335.commentaiautopilot.listener;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
|
||||||
import run.halo.app.extension.controller.Controller;
|
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AiPersonaReconciler implements Reconciler<Reconciler.Request> {
|
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Result reconcile(Request request) {
|
|
||||||
return new Result(false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Controller setupWith(ControllerBuilder builder) {
|
|
||||||
return builder
|
|
||||||
.extension(new AiPersona())
|
|
||||||
.syncAllOnStart(false)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,22 +4,19 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import run.halo.app.core.extension.content.Category;
|
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
|
||||||
import run.halo.app.core.extension.content.Tag;
|
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.WakeWordService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -28,97 +25,92 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
|
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
private final WakeWordService wakeWordService;
|
||||||
|
|
||||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
|
||||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||||
|
|
||||||
// Record the time when this bean was created (plugin startup time)
|
// Record the time when this bean was created (plugin startup time)
|
||||||
private final Instant pluginStartTime = Instant.now();
|
private final Instant pluginStartTime = Instant.now();
|
||||||
|
|
||||||
// In-memory dedup lock: prevents the same comment from being processed multiple times
|
|
||||||
// even if reconcile is triggered concurrently
|
|
||||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result reconcile(Request request) {
|
public Result reconcile(Request request) {
|
||||||
var name = request.name();
|
var name = request.name();
|
||||||
|
|
||||||
// Acquire lock at the very beginning to prevent any concurrent processing
|
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||||
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
|
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
||||||
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
|
return;
|
||||||
return Result.doNotRetry();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
AtomicBoolean asyncStarted = new AtomicBoolean(false);
|
// Skip comments created before plugin startup (historical comments)
|
||||||
try {
|
var creationTime = comment.getMetadata().getCreationTimestamp();
|
||||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
||||||
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip comments created before plugin startup (historical comments)
|
|
||||||
var creationTime = comment.getMetadata().getCreationTimestamp();
|
|
||||||
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
|
||||||
log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name);
|
|
||||||
markProcessed(comment);
|
|
||||||
client.update(comment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip comments from AI persona itself
|
|
||||||
if (isAiComment(comment)) {
|
|
||||||
markProcessed(comment);
|
|
||||||
client.update(comment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup: check if we already have an AiCommentReply record for this comment
|
|
||||||
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
|
||||||
record -> name.equals(record.getSpec().getCommentId())
|
|
||||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
|
||||||
null)
|
|
||||||
.isEmpty();
|
|
||||||
|
|
||||||
if (alreadyHasRecord) {
|
|
||||||
log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name);
|
|
||||||
markProcessed(comment);
|
|
||||||
client.update(comment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as processed first to avoid re-processing
|
|
||||||
markProcessed(comment);
|
markProcessed(comment);
|
||||||
client.update(comment);
|
client.update(comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Read persona name from the post's annotations
|
// Skip comments from AI persona itself
|
||||||
String personaName = getPersonaNameFromComment(comment);
|
if (isAiComment(comment)) {
|
||||||
|
markProcessed(comment);
|
||||||
|
client.update(comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: check if we already have an AiCommentReply record for this comment
|
||||||
|
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
||||||
|
record -> name.equals(record.getSpec().getCommentId())
|
||||||
|
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||||
|
null)
|
||||||
|
.isEmpty();
|
||||||
|
|
||||||
|
if (alreadyHasRecord) {
|
||||||
|
log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name);
|
||||||
|
markProcessed(comment);
|
||||||
|
client.update(comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processed first to avoid re-processing
|
||||||
|
markProcessed(comment);
|
||||||
|
client.update(comment);
|
||||||
|
|
||||||
|
// Check for wake word in comment content
|
||||||
|
String commentContent = getCommentContent(comment);
|
||||||
|
log.info("[CommentReconciler] Wake word check for comment {}: content='{}'",
|
||||||
|
name, commentContent.length() > 80 ? commentContent.substring(0, 80) + "..." : commentContent);
|
||||||
|
var wakeMatch = wakeWordService.checkWakeWordBlocking(client, commentContent);
|
||||||
|
|
||||||
|
if (wakeMatch != null) {
|
||||||
|
// Wake word matched: trigger AI reply with the matched persona,
|
||||||
|
// bypassing normal page-level enable check
|
||||||
|
log.info("[CommentReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
|
||||||
|
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
|
||||||
|
orchestrator.processComment(name, null, false, wakeMatch.personaName(), true)
|
||||||
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
|
.subscribe(
|
||||||
|
null,
|
||||||
|
e -> log.error("[CommentReconciler] Error processing wake word comment {}: {}", name, e.getMessage(), e),
|
||||||
|
() -> log.info("[CommentReconciler] Wake word processing completed for comment: {}", name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Normal flow: read persona name from the post's annotations
|
||||||
|
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
|
||||||
|
|
||||||
// Top-level comment → always trigger AI reply
|
// Top-level comment → always trigger AI reply
|
||||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||||
asyncStarted.set(true);
|
orchestrator.processComment(name, null, false, personaName, false)
|
||||||
orchestrator.processComment(name, null, false, personaName)
|
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
.doFinally(signal -> {
|
|
||||||
processingLocks.remove(name);
|
|
||||||
log.debug("[CommentReconciler] Released processing lock for: {}", name);
|
|
||||||
})
|
|
||||||
.subscribe(
|
.subscribe(
|
||||||
null,
|
null,
|
||||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[CommentReconciler] Error in reconcile for {}: {}", name, e.getMessage(), e);
|
|
||||||
} finally {
|
|
||||||
// Only release lock here if async processing was NOT started
|
|
||||||
// (async path releases lock in doFinally)
|
|
||||||
if (!asyncStarted.get()) {
|
|
||||||
processingLocks.remove(name);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return Result.doNotRetry();
|
return Result.doNotRetry();
|
||||||
}
|
}
|
||||||
@@ -139,61 +131,6 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read persona name from the post's annotations associated with this comment.
|
|
||||||
*/
|
|
||||||
private String getPersonaNameFromComment(Comment comment) {
|
|
||||||
var subjectRef = comment.getSpec().getSubjectRef();
|
|
||||||
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
if (persona != null && !persona.isBlank()) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isProcessed(Map<String, String> annotations) {
|
private boolean isProcessed(Map<String, String> annotations) {
|
||||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||||
}
|
}
|
||||||
@@ -207,6 +144,22 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getCommentContent(Comment comment) {
|
||||||
|
if (comment.getSpec() == null) return "";
|
||||||
|
// Always strip HTML to get plain text for wake word matching
|
||||||
|
String raw = comment.getSpec().getRaw();
|
||||||
|
if (raw != null && !raw.isBlank()) {
|
||||||
|
// raw might still contain HTML in some cases, always strip
|
||||||
|
String plain = org.jsoup.Jsoup.clean(raw, org.jsoup.safety.Safelist.none()).trim();
|
||||||
|
if (!plain.isBlank()) return plain;
|
||||||
|
}
|
||||||
|
String content = comment.getSpec().getContent();
|
||||||
|
if (content != null && !content.isBlank()) {
|
||||||
|
return org.jsoup.Jsoup.clean(content, org.jsoup.safety.Safelist.none()).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Controller setupWith(ControllerBuilder builder) {
|
public Controller setupWith(ControllerBuilder builder) {
|
||||||
return builder
|
return builder
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import run.halo.app.core.extension.content.Category;
|
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
import run.halo.app.core.extension.content.Tag;
|
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.WakeWordService;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -27,11 +26,12 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
|
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
private final WakeWordService wakeWordService;
|
||||||
|
|
||||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
|
||||||
|
|
||||||
// Record the time when this bean was created (plugin startup time)
|
// Record the time when this bean was created (plugin startup time)
|
||||||
private final Instant pluginStartTime = Instant.now();
|
private final Instant pluginStartTime = Instant.now();
|
||||||
@@ -77,25 +77,21 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this reply is specifically replying to an AI reply
|
// Check for wake word FIRST - wake word can bypass "must be reply to AI" check
|
||||||
// by checking the quoteReply field
|
String replyContent = getReplyContent(reply);
|
||||||
String quoteReply = reply.getSpec().getQuoteReply();
|
log.info("[ReplyReconciler] Wake word check for reply {}: content='{}'",
|
||||||
|
name, replyContent.length() > 80 ? replyContent.substring(0, 80) + "..." : replyContent);
|
||||||
if (quoteReply == null || quoteReply.isBlank()) {
|
var wakeMatch = wakeWordService.checkWakeWordBlocking(client, replyContent);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This reply quotes another reply - check if the quoted reply is from AI
|
// This reply quotes another reply - check if the quoted reply is from AI
|
||||||
boolean isReplyToAi = isAiReply(quoteReply);
|
String quoteReply = reply.getSpec().getQuoteReply();
|
||||||
|
boolean isReplyToAi = quoteReply != null && !quoteReply.isBlank() && isAiReply(quoteReply);
|
||||||
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
|
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
|
||||||
|
|
||||||
if (!isReplyToAi) {
|
// Skip if not a reply to AI AND no wake word matched
|
||||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
if (!isReplyToAi && wakeMatch == null) {
|
||||||
|
// No quoteReply or not replying to AI, and no wake word - skip
|
||||||
|
log.debug("[ReplyReconciler] Not a reply to AI and no wake word, skipping: {}", name);
|
||||||
markProcessed(reply);
|
markProcessed(reply);
|
||||||
client.update(reply);
|
client.update(reply);
|
||||||
return;
|
return;
|
||||||
@@ -119,16 +115,32 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
markProcessed(reply);
|
markProcessed(reply);
|
||||||
client.update(reply);
|
client.update(reply);
|
||||||
|
|
||||||
// Reply to AI → trigger AI reply (conversation continuation)
|
if (wakeMatch != null) {
|
||||||
String personaName = getPersonaNameFromComment(parentCommentName);
|
// Wake word matched: trigger AI reply with the matched persona,
|
||||||
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
// bypassing the "must be reply to AI" check and page-level enable check
|
||||||
orchestrator.processComment(parentCommentName, name, true, personaName)
|
log.info("[ReplyReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
|
||||||
.subscribe(
|
orchestrator.processComment(parentCommentName, name, true, wakeMatch.personaName(), true)
|
||||||
null,
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
|
.subscribe(
|
||||||
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
|
null,
|
||||||
);
|
e -> log.error("[ReplyReconciler] Error processing wake word reply {}: {}", name, e.getMessage(), e),
|
||||||
|
() -> log.info("[ReplyReconciler] Wake word processing completed for reply: {}", name)
|
||||||
|
);
|
||||||
|
} else if (isReplyToAi) {
|
||||||
|
// Normal flow: reply to AI → trigger AI reply (conversation continuation)
|
||||||
|
String personaName = client.fetch(Comment.class, parentCommentName)
|
||||||
|
.map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment))
|
||||||
|
.orElse(null);
|
||||||
|
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
||||||
|
orchestrator.processComment(parentCommentName, name, true, personaName, false)
|
||||||
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
|
.subscribe(
|
||||||
|
null,
|
||||||
|
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
|
||||||
|
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Result.doNotRetry();
|
return Result.doNotRetry();
|
||||||
@@ -154,65 +166,6 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
.orElse(false);
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read persona name from the post's annotations associated with the parent comment.
|
|
||||||
*/
|
|
||||||
private String getPersonaNameFromComment(String commentName) {
|
|
||||||
return client.fetch(Comment.class, commentName)
|
|
||||||
.map(comment -> {
|
|
||||||
var subjectRef = comment.getSpec().getSubjectRef();
|
|
||||||
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
if (persona != null && !persona.isBlank()) {
|
|
||||||
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);
|
|
||||||
})
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isProcessed(Map<String, String> annotations) {
|
private boolean isProcessed(Map<String, String> annotations) {
|
||||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||||
}
|
}
|
||||||
@@ -226,6 +179,22 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getReplyContent(Reply reply) {
|
||||||
|
if (reply.getSpec() == null) return "";
|
||||||
|
// Always strip HTML to get plain text for wake word matching
|
||||||
|
String raw = reply.getSpec().getRaw();
|
||||||
|
if (raw != null && !raw.isBlank()) {
|
||||||
|
// raw might still contain HTML in some cases, always strip
|
||||||
|
String plain = org.jsoup.Jsoup.clean(raw, org.jsoup.safety.Safelist.none()).trim();
|
||||||
|
if (!plain.isBlank()) return plain;
|
||||||
|
}
|
||||||
|
String content = reply.getSpec().getContent();
|
||||||
|
if (content != null && !content.isBlank()) {
|
||||||
|
return org.jsoup.Jsoup.clean(content, org.jsoup.safety.Safelist.none()).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Controller setupWith(ControllerBuilder builder) {
|
public Controller setupWith(ControllerBuilder builder) {
|
||||||
return builder
|
return builder
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class AiReplyCleanupService implements DisposableBean {
|
public class AiReplyCleanupService implements DisposableBean {
|
||||||
@@ -27,9 +30,9 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
|
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public AiReplyCleanupService(ReactiveExtensionClient client) {
|
public AiReplyCleanupService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "ai-reply-cleanup");
|
Thread t = new Thread(r, "ai-reply-cleanup");
|
||||||
t.setDaemon(true);
|
t.setDaemon(true);
|
||||||
@@ -40,85 +43,85 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void dailyCleanup() {
|
public void dailyCleanup() {
|
||||||
try {
|
isCleanupEnabled()
|
||||||
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
.flatMap(enabled -> {
|
||||||
.mapNotNull(cm -> {
|
if (!Boolean.TRUE.equals(enabled)) {
|
||||||
var data = cm.getData();
|
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
||||||
if (data == null) return false;
|
return Mono.empty();
|
||||||
String cleanupJson = data.get("cleanup");
|
}
|
||||||
if (cleanupJson == null || cleanupJson.isBlank()) return true;
|
return getRetentionDays()
|
||||||
try {
|
.flatMap(retentionDays -> executeCleanup(retentionDays)
|
||||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
.doOnNext(deleted -> log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays))
|
||||||
return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true);
|
);
|
||||||
} catch (Exception e) {
|
})
|
||||||
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
|
.subscribe(
|
||||||
return true;
|
null,
|
||||||
}
|
e -> log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e)
|
||||||
})
|
);
|
||||||
.defaultIfEmpty(true)
|
|
||||||
.block();
|
|
||||||
|
|
||||||
if (!Boolean.TRUE.equals(enabled)) {
|
|
||||||
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int retentionDays = getRetentionDays();
|
|
||||||
long deleted = executeCleanup(retentionDays);
|
|
||||||
log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public long executeCleanup(int retentionDays) {
|
private Mono<Boolean> isCleanupEnabled() {
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return false;
|
||||||
|
String cleanupJson = data.get("cleanup");
|
||||||
|
if (cleanupJson == null || cleanupJson.isBlank()) return true;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||||
|
return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Long> executeCleanup(int retentionDays) {
|
||||||
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
||||||
|
|
||||||
var oldRecords = client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
.filter(r -> {
|
.filter(r -> {
|
||||||
Instant created = r.getMetadata().getCreationTimestamp();
|
Instant created = r.getMetadata().getCreationTimestamp();
|
||||||
return created != null && created.isBefore(cutoff);
|
return created != null && created.isBefore(cutoff);
|
||||||
})
|
})
|
||||||
.collectList()
|
.collectList()
|
||||||
.block();
|
.flatMap(oldRecords -> {
|
||||||
|
if (oldRecords.isEmpty()) {
|
||||||
if (oldRecords == null || oldRecords.isEmpty()) {
|
return Mono.just(0L);
|
||||||
return 0;
|
}
|
||||||
}
|
return Flux.fromIterable(oldRecords)
|
||||||
|
.flatMap(record -> client.delete(record)
|
||||||
long deleted = 0;
|
.thenReturn(1L)
|
||||||
for (var record : oldRecords) {
|
.onErrorResume(e -> {
|
||||||
try {
|
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
||||||
client.delete(record).block();
|
return Mono.just(0L);
|
||||||
deleted++;
|
})
|
||||||
} catch (Exception e) {
|
)
|
||||||
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
.reduce(0L, Long::sum);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getRetentionDays() {
|
public Mono<Integer> getRetentionDays() {
|
||||||
try {
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
.mapNotNull(cm -> {
|
||||||
.mapNotNull(cm -> {
|
var data = cm.getData();
|
||||||
var data = cm.getData();
|
if (data == null) return 30;
|
||||||
if (data == null) return 30;
|
String cleanupJson = data.get("cleanup");
|
||||||
String cleanupJson = data.get("cleanup");
|
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||||
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
try {
|
||||||
try {
|
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
||||||
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
} catch (Exception e) {
|
||||||
} catch (Exception e) {
|
return 30;
|
||||||
return 30;
|
}
|
||||||
}
|
})
|
||||||
})
|
.defaultIfEmpty(30)
|
||||||
.defaultIfEmpty(30)
|
.onErrorResume(e -> {
|
||||||
.block();
|
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
||||||
} catch (Exception e) {
|
return Mono.just(30);
|
||||||
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
});
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public class AiReplyOrchestrator {
|
|||||||
CommentReplyPublisher commentReplyPublisher,
|
CommentReplyPublisher commentReplyPublisher,
|
||||||
FilterService filterService,
|
FilterService filterService,
|
||||||
RateLimitService rateLimitService,
|
RateLimitService rateLimitService,
|
||||||
ReactiveExtensionClient client) {
|
ReactiveExtensionClient client,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.contextExtractor = contextExtractor;
|
this.contextExtractor = contextExtractor;
|
||||||
this.promptBuilder = promptBuilder;
|
this.promptBuilder = promptBuilder;
|
||||||
this.aiReplyService = aiReplyService;
|
this.aiReplyService = aiReplyService;
|
||||||
@@ -59,7 +60,7 @@ public class AiReplyOrchestrator {
|
|||||||
this.filterService = filterService;
|
this.filterService = filterService;
|
||||||
this.rateLimitService = rateLimitService;
|
this.rateLimitService = rateLimitService;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,9 +70,10 @@ public class AiReplyOrchestrator {
|
|||||||
* @param replyName the Reply name that triggered this (null for top-level comments)
|
* @param replyName the Reply name that triggered this (null for top-level comments)
|
||||||
* @param isAiConversation true when someone replied to AI's reply (conversation continuation)
|
* @param isAiConversation true when someone replied to AI's reply (conversation continuation)
|
||||||
* @param personaName the persona name to use (null for default persona)
|
* @param personaName the persona name to use (null for default persona)
|
||||||
|
* @param wakeWordTriggered true when triggered by a wake word (bypasses page-level enable check)
|
||||||
*/
|
*/
|
||||||
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation,
|
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation,
|
||||||
String personaName) {
|
String personaName, boolean wakeWordTriggered) {
|
||||||
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
||||||
|
|
||||||
// Clean up stale locks before acquiring new one
|
// Clean up stale locks before acquiring new one
|
||||||
@@ -83,12 +85,12 @@ public class AiReplyOrchestrator {
|
|||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}",
|
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}, wakeWordTriggered={}",
|
||||||
commentName, replyName, isAiConversation, personaName);
|
commentName, replyName, isAiConversation, personaName, wakeWordTriggered);
|
||||||
|
|
||||||
return isAutoReplyEnabled()
|
return isAutoReplyEnabled()
|
||||||
.flatMap(enabled -> {
|
.flatMap(enabled -> {
|
||||||
if (!enabled) {
|
if (!enabled && !wakeWordTriggered) {
|
||||||
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
|
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
@@ -98,42 +100,24 @@ public class AiReplyOrchestrator {
|
|||||||
log.info("[Orchestrator] 速率限制,跳过: {}", commentName);
|
log.info("[Orchestrator] 速率限制,跳过: {}", commentName);
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
|
// Wake word triggered: skip page-level annotation check
|
||||||
|
if (wakeWordTriggered) {
|
||||||
|
return checkBlockedCommenters(commentName)
|
||||||
|
.flatMap(blocked -> {
|
||||||
|
if (blocked) {
|
||||||
|
log.info("[Orchestrator] Commenter blocked, skipping wake word: {}", commentName);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return proceedWithProcess(commentName, replyName, isAiConversation, personaName);
|
||||||
|
});
|
||||||
|
}
|
||||||
return filterService.shouldProcess(commentName)
|
return filterService.shouldProcess(commentName)
|
||||||
.flatMap(shouldProcess -> {
|
.flatMap(shouldProcess -> {
|
||||||
if (!shouldProcess) {
|
if (!shouldProcess) {
|
||||||
log.info("[Orchestrator] Filtered out by rules: {}", commentName);
|
log.info("[Orchestrator] Filtered out by rules: {}", commentName);
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
// For top-level comments: skip if we already have ANY reply record
|
return proceedWithProcess(commentName, replyName, isAiConversation, personaName);
|
||||||
// For AI conversation: skip if we already replied to THIS specific reply
|
|
||||||
if (!isAiConversation) {
|
|
||||||
return hasExistingReply(commentName)
|
|
||||||
.flatMap(hasReply -> {
|
|
||||||
if (hasReply) {
|
|
||||||
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
return doProcess(commentName, replyName, isAiConversation, personaName);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Check conversation rounds limit
|
|
||||||
return getMaxConversationRounds()
|
|
||||||
.flatMap(maxRounds -> getConversationRounds(commentName)
|
|
||||||
.flatMap(rounds -> {
|
|
||||||
if (rounds >= maxRounds) {
|
|
||||||
log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName);
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
return hasExistingConversationReply(replyName)
|
|
||||||
.flatMap(hasReply -> {
|
|
||||||
if (hasReply) {
|
|
||||||
log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
return doProcess(commentName, replyName, isAiConversation, personaName);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -146,6 +130,81 @@ public class AiReplyOrchestrator {
|
|||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed with processing after all checks have passed.
|
||||||
|
* Handles dedup checks and conversation round limits.
|
||||||
|
*/
|
||||||
|
private Mono<Void> proceedWithProcess(String commentName, String replyName,
|
||||||
|
boolean isAiConversation, String personaName) {
|
||||||
|
// For top-level comments: skip if we already have ANY reply record
|
||||||
|
// For AI conversation: skip if we already replied to THIS specific reply
|
||||||
|
if (!isAiConversation) {
|
||||||
|
return hasExistingReply(commentName)
|
||||||
|
.flatMap(hasReply -> {
|
||||||
|
if (hasReply) {
|
||||||
|
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return doProcess(commentName, replyName, isAiConversation, personaName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check conversation rounds limit
|
||||||
|
return getMaxConversationRounds()
|
||||||
|
.flatMap(maxRounds -> getConversationRounds(commentName)
|
||||||
|
.flatMap(rounds -> {
|
||||||
|
if (rounds >= maxRounds) {
|
||||||
|
log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return hasExistingConversationReply(replyName)
|
||||||
|
.flatMap(hasReply -> {
|
||||||
|
if (hasReply) {
|
||||||
|
log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return doProcess(commentName, replyName, isAiConversation, personaName);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the commenter is in the blocked list.
|
||||||
|
*/
|
||||||
|
private Mono<Boolean> checkBlockedCommenters(String commentName) {
|
||||||
|
return client.fetch(run.halo.app.core.extension.content.Comment.class, commentName)
|
||||||
|
.flatMap(comment -> {
|
||||||
|
var owner = comment.getSpec().getOwner();
|
||||||
|
if (owner == null) return Mono.just(false);
|
||||||
|
String displayName = owner.getDisplayName();
|
||||||
|
String email = run.halo.app.core.extension.content.Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())
|
||||||
|
? owner.getName() : "";
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return false;
|
||||||
|
String basicJson = data.get("basic");
|
||||||
|
if (basicJson == null || basicJson.isBlank()) return false;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
|
String blockedStr = node.has("blockedCommenters") ? node.get("blockedCommenters").asText("") : "";
|
||||||
|
if (blockedStr.isBlank()) return false;
|
||||||
|
for (String item : blockedStr.split(",")) {
|
||||||
|
String trimmed = item.trim();
|
||||||
|
if (!trimmed.isEmpty() && (trimmed.equalsIgnoreCase(displayName) || trimmed.equalsIgnoreCase(email))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
|
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
|
||||||
String personaName) {
|
String personaName) {
|
||||||
return getModelName().flatMap(modelName ->
|
return getModelName().flatMap(modelName ->
|
||||||
@@ -524,6 +583,7 @@ public class AiReplyOrchestrator {
|
|||||||
record.getSpec().setCommentId(context.commentId());
|
record.getSpec().setCommentId(context.commentId());
|
||||||
record.getSpec().setPostId(context.postId());
|
record.getSpec().setPostId(context.postId());
|
||||||
record.getSpec().setPostSlug(context.postSlug());
|
record.getSpec().setPostSlug(context.postSlug());
|
||||||
|
record.getSpec().setPostKind(context.postKind());
|
||||||
record.getSpec().setReply("");
|
record.getSpec().setReply("");
|
||||||
record.getSpec().setScore(0);
|
record.getSpec().setScore(0);
|
||||||
record.getSpec().setStatus("PENDING");
|
record.getSpec().setStatus("PENDING");
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import run.halo.app.core.extension.content.Reply;
|
|||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
||||||
|
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -120,7 +119,7 @@ public class CommentReplyPublisher {
|
|||||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||||
// 使用Gravatar邮箱头像
|
// 使用Gravatar邮箱头像
|
||||||
if (email != null && !email.isBlank()) {
|
if (email != null && !email.isBlank()) {
|
||||||
String gravatarUrl = generateGravatarUrl(email);
|
String gravatarUrl = GravatarUtil.generateUrl(email);
|
||||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||||
}
|
}
|
||||||
owner.setAnnotations(ownerAnnotations);
|
owner.setAnnotations(ownerAnnotations);
|
||||||
@@ -181,22 +180,4 @@ public class CommentReplyPublisher {
|
|||||||
private String generateReplyName() {
|
private String generateReplyName() {
|
||||||
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
|
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Gravatar URL from email address using SHA-256 hash.
|
|
||||||
*/
|
|
||||||
private String generateGravatarUrl(String email) {
|
|
||||||
try {
|
|
||||||
var digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(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) {
|
|
||||||
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import run.halo.app.content.ContentWrapper;
|
|||||||
import run.halo.app.content.PostContentService;
|
import run.halo.app.content.PostContentService;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
import run.halo.app.core.extension.content.Post;
|
||||||
|
import run.halo.app.core.extension.content.SinglePage;
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
|
||||||
@@ -113,10 +114,15 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
formatPostDate(post),
|
formatPostDate(post),
|
||||||
commentCount,
|
commentCount,
|
||||||
""
|
"",
|
||||||
|
"Post"
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to fetch Post {}: {}", postName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
.defaultIfEmpty(new CommentContext(
|
.defaultIfEmpty(new CommentContext(
|
||||||
comment.getMetadata().getName(),
|
comment.getMetadata().getName(),
|
||||||
postName,
|
postName,
|
||||||
@@ -129,7 +135,51 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0,
|
0,
|
||||||
""
|
"",
|
||||||
|
"Post"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) {
|
||||||
|
String postName = subjectRef.getName();
|
||||||
|
return client.fetch(SinglePage.class, postName)
|
||||||
|
.flatMap(singlePage -> getSinglePageContent(postName)
|
||||||
|
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
|
||||||
|
.map(commentCount -> new CommentContext(
|
||||||
|
comment.getMetadata().getName(),
|
||||||
|
postName,
|
||||||
|
singlePage.getSpec().getSlug(),
|
||||||
|
commentContent,
|
||||||
|
commentOwner,
|
||||||
|
singlePage.getSpec().getTitle(),
|
||||||
|
content,
|
||||||
|
null,
|
||||||
|
isAiConversation,
|
||||||
|
formatSinglePageDate(singlePage),
|
||||||
|
commentCount,
|
||||||
|
"",
|
||||||
|
"SinglePage"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to fetch SinglePage {}: {}", postName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(new CommentContext(
|
||||||
|
comment.getMetadata().getName(),
|
||||||
|
postName,
|
||||||
|
"",
|
||||||
|
commentContent,
|
||||||
|
commentOwner,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
isAiConversation,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
"SinglePage"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +195,7 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0,
|
0,
|
||||||
|
"",
|
||||||
""
|
""
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -179,11 +230,16 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
formatPostDate(post),
|
formatPostDate(post),
|
||||||
commentCount,
|
commentCount,
|
||||||
history
|
history,
|
||||||
|
"Post"
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to fetch Post {} for reply: {}", postName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
.defaultIfEmpty(new CommentContext(
|
.defaultIfEmpty(new CommentContext(
|
||||||
commentName,
|
commentName,
|
||||||
postName,
|
postName,
|
||||||
@@ -196,7 +252,53 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0,
|
0,
|
||||||
""
|
"",
|
||||||
|
"Post"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) {
|
||||||
|
String postName = subjectRef.getName();
|
||||||
|
return client.fetch(SinglePage.class, postName)
|
||||||
|
.flatMap(singlePage -> getSinglePageContent(postName)
|
||||||
|
.flatMap(content -> getCommentCount(commentName)
|
||||||
|
.flatMap(commentCount -> historyMono
|
||||||
|
.map(history -> new CommentContext(
|
||||||
|
commentName,
|
||||||
|
postName,
|
||||||
|
singlePage.getSpec().getSlug(),
|
||||||
|
replyContent,
|
||||||
|
replyOwner,
|
||||||
|
singlePage.getSpec().getTitle(),
|
||||||
|
content,
|
||||||
|
replyName,
|
||||||
|
isAiConversation,
|
||||||
|
formatSinglePageDate(singlePage),
|
||||||
|
commentCount,
|
||||||
|
history,
|
||||||
|
"SinglePage"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[ContextExtractor] Failed to fetch SinglePage {} for reply: {}", postName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(new CommentContext(
|
||||||
|
commentName,
|
||||||
|
postName,
|
||||||
|
"",
|
||||||
|
replyContent,
|
||||||
|
replyOwner,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
replyName,
|
||||||
|
isAiConversation,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
"SinglePage"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +315,8 @@ public class ContextExtractor {
|
|||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0,
|
0,
|
||||||
history
|
history,
|
||||||
|
""
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +381,23 @@ public class ContextExtractor {
|
|||||||
.defaultIfEmpty("");
|
.defaultIfEmpty("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<String> getSinglePageContent(String pageName) {
|
||||||
|
// SinglePage doesn't have a dedicated ContentService in Halo API,
|
||||||
|
// and Snapshot content requires patch merging which is too complex.
|
||||||
|
// Use the excerpt from status as a fallback for context.
|
||||||
|
return client.fetch(SinglePage.class, pageName)
|
||||||
|
.mapNotNull(page -> {
|
||||||
|
if (page.getStatus() != null && page.getStatus().getExcerpt() != null) {
|
||||||
|
String excerpt = page.getStatus().getExcerpt();
|
||||||
|
if (excerpt != null && !excerpt.isBlank()) {
|
||||||
|
return excerpt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
private String formatPostDate(Post post) {
|
private String formatPostDate(Post post) {
|
||||||
var publishTime = post.getSpec().getPublishTime();
|
var publishTime = post.getSpec().getPublishTime();
|
||||||
if (publishTime != null) {
|
if (publishTime != null) {
|
||||||
@@ -290,6 +410,18 @@ public class ContextExtractor {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String formatSinglePageDate(SinglePage singlePage) {
|
||||||
|
var publishTime = singlePage.getSpec().getPublishTime();
|
||||||
|
if (publishTime != null) {
|
||||||
|
return publishTime.toString().substring(0, 10);
|
||||||
|
}
|
||||||
|
var creationTimestamp = singlePage.getMetadata().getCreationTimestamp();
|
||||||
|
if (creationTimestamp != null) {
|
||||||
|
return creationTimestamp.toString().substring(0, 10);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<Integer> getCommentCount(String commentName) {
|
private Mono<Integer> getCommentCount(String commentName) {
|
||||||
return client.list(Reply.class,
|
return client.list(Reply.class,
|
||||||
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
||||||
@@ -311,6 +443,7 @@ public class ContextExtractor {
|
|||||||
boolean isAiConversation,
|
boolean isAiConversation,
|
||||||
String postDate,
|
String postDate,
|
||||||
int commentCount,
|
int commentCount,
|
||||||
String conversationHistory
|
String conversationHistory,
|
||||||
|
String postKind
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ public class FilterService {
|
|||||||
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
||||||
private static final String GROUP_CONTENT = "content.halo.run";
|
private static final String GROUP_CONTENT = "content.halo.run";
|
||||||
|
|
||||||
public FilterService(ReactiveExtensionClient client) {
|
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Boolean> shouldProcess(Comment comment) {
|
public Mono<Boolean> shouldProcess(Comment comment) {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
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.ReactiveExtensionClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared service for resolving AI persona name from a comment's associated
|
||||||
|
* post/category/tag annotations.
|
||||||
|
*
|
||||||
|
* <p>Priority: Post annotation > Category annotation > Tag annotation
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PersonaResolver {
|
||||||
|
|
||||||
|
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient reactiveClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve persona name from a comment (reactive version).
|
||||||
|
* Reads the post's annotations, then falls back to category and tag annotations.
|
||||||
|
*
|
||||||
|
* @param commentName the Comment metadata.name
|
||||||
|
* @return the persona name, or empty string if none found
|
||||||
|
*/
|
||||||
|
public Mono<String> getPersonaNameFromComment(String commentName) {
|
||||||
|
return reactiveClient.fetch(Comment.class, commentName)
|
||||||
|
.flatMap(comment -> {
|
||||||
|
var subjectRef = comment.getSpec().getSubjectRef();
|
||||||
|
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
||||||
|
return Mono.just("");
|
||||||
|
}
|
||||||
|
String postName = subjectRef.getName();
|
||||||
|
return resolveFromPost(postName);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> resolveFromPost(String postName) {
|
||||||
|
return reactiveClient.fetch(Post.class, postName)
|
||||||
|
.flatMap(post -> {
|
||||||
|
// 1. Post annotation takes priority
|
||||||
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
|
if (annotations != null) {
|
||||||
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (persona != null && !persona.isBlank()) {
|
||||||
|
return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Category annotations
|
||||||
|
var spec = post.getSpec();
|
||||||
|
if (spec != null && spec.getCategories() != null) {
|
||||||
|
for (String categoryName : spec.getCategories()) {
|
||||||
|
var persona = resolveFromCategory(categoryName);
|
||||||
|
if (persona != null) return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Tag annotations
|
||||||
|
if (spec != null && spec.getTags() != null) {
|
||||||
|
for (String tagName : spec.getTags()) {
|
||||||
|
var persona = resolveFromTag(tagName);
|
||||||
|
if (persona != null) return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mono.just("");
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromCategory(String categoryName) {
|
||||||
|
// Use block() here because this is called from a Reconciler (sync context)
|
||||||
|
// For reactive context, the caller should use the reactive version
|
||||||
|
try {
|
||||||
|
return reactiveClient.fetch(Category.class, categoryName)
|
||||||
|
.mapNotNull(cat -> {
|
||||||
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
|
if (catAnnotations != null) {
|
||||||
|
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (catPersona != null && !catPersona.isBlank()) {
|
||||||
|
return catPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.block();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromTag(String tagName) {
|
||||||
|
try {
|
||||||
|
return reactiveClient.fetch(Tag.class, tagName)
|
||||||
|
.mapNotNull(tag -> {
|
||||||
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
|
if (tagAnnotations != null) {
|
||||||
|
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (tagPersona != null && !tagPersona.isBlank()) {
|
||||||
|
return tagPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.block();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve persona name from a comment using blocking ExtensionClient
|
||||||
|
* (for use in Reconciler sync context).
|
||||||
|
*/
|
||||||
|
public String getPersonaNameFromCommentBlocking(ExtensionClient client, Comment comment) {
|
||||||
|
var subjectRef = comment.getSpec().getSubjectRef();
|
||||||
|
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String postName = subjectRef.getName();
|
||||||
|
return client.fetch(Post.class, postName)
|
||||||
|
.map(post -> {
|
||||||
|
// 1. Post annotation
|
||||||
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
|
if (annotations != null) {
|
||||||
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (persona != null && !persona.isBlank()) {
|
||||||
|
return persona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Category annotations
|
||||||
|
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. Tag annotations
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@ public class PromptBuilder {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public PromptBuilder(ReactiveExtensionClient client) {
|
public PromptBuilder(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String PRESET_FRIENDLY = """
|
private static final String PRESET_FRIENDLY = """
|
||||||
@@ -52,8 +52,9 @@ public class PromptBuilder {
|
|||||||
private static final String SAFETY_PROMPT = """
|
private static final String SAFETY_PROMPT = """
|
||||||
【安全规范】
|
【安全规范】
|
||||||
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
|
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
|
||||||
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。"
|
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,我无法提供此类回复。"
|
||||||
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
|
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
|
||||||
|
- 身份约束:你必须在回复中保持指定的角色身份,绝不能说自己是AI、没有性别或脱离角色设定。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String DEFAULT_PROMPT_TEMPLATE = """
|
private static final String DEFAULT_PROMPT_TEMPLATE = """
|
||||||
@@ -142,8 +143,10 @@ public class PromptBuilder {
|
|||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
String sentimentHint = switch (sentiment) {
|
String sentimentHint = switch (sentiment) {
|
||||||
|
case "VERY_POSITIVE" -> "\n\n【情感提示】评论者情绪非常正面积极,请用热情洋溢的语气回复,表达真诚的感谢和共鸣。";
|
||||||
case "POSITIVE" -> "\n\n【情感提示】评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。";
|
case "POSITIVE" -> "\n\n【情感提示】评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。";
|
||||||
case "NEGATIVE" -> "\n\n【情感提示】评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。";
|
case "NEGATIVE" -> "\n\n【情感提示】评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。";
|
||||||
|
case "VERY_NEGATIVE" -> "\n\n【情感提示】评论者情绪非常负面,请用非常温和、理性的语气回复,避免任何可能激化矛盾的表达,展现充分的理解和耐心。";
|
||||||
default -> "";
|
default -> "";
|
||||||
};
|
};
|
||||||
return prompt + sentimentHint;
|
return prompt + sentimentHint;
|
||||||
@@ -192,7 +195,10 @@ public class PromptBuilder {
|
|||||||
return client.fetch(AiPersona.class, personaName)
|
return client.fetch(AiPersona.class, personaName)
|
||||||
.mapNotNull(persona -> {
|
.mapNotNull(persona -> {
|
||||||
String prompt = persona.getSpec().getPrompt();
|
String prompt = persona.getSpec().getPrompt();
|
||||||
return (prompt != null && !prompt.isBlank()) ? prompt : null;
|
if (prompt != null && !prompt.isBlank()) {
|
||||||
|
return appendStyleHint(prompt, persona.getSpec().getDisplayName(), persona.getSpec().getGender(), persona.getSpec().getNeutralVoice());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||||
}
|
}
|
||||||
@@ -203,11 +209,40 @@ public class PromptBuilder {
|
|||||||
.next()
|
.next()
|
||||||
.mapNotNull(persona -> {
|
.mapNotNull(persona -> {
|
||||||
String prompt = persona.getSpec().getPrompt();
|
String prompt = persona.getSpec().getPrompt();
|
||||||
return (prompt != null && !prompt.isBlank()) ? prompt : null;
|
if (prompt != null && !prompt.isBlank()) {
|
||||||
|
return appendStyleHint(prompt, persona.getSpec().getDisplayName(), persona.getSpec().getGender(), persona.getSpec().getNeutralVoice());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String appendStyleHint(String prompt, String displayName, String gender, Boolean neutralVoice) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// 身份信息前置到最开头 - 这是AI最先看到的内容,优先级最高
|
||||||
|
if (gender != null && !gender.isBlank()) {
|
||||||
|
String genderDesc = "female".equals(gender) ? "女生" : "男生";
|
||||||
|
sb.append("【核心身份】你叫「").append(displayName != null ? displayName : "").append("」,你是一个").append(genderDesc).append("。");
|
||||||
|
sb.append("这是你的基本身份,你必须始终以这个身份回复。");
|
||||||
|
sb.append("当有人问你性别时,你必须回答自己是").append(genderDesc).append("。");
|
||||||
|
sb.append("绝对不能说自己是AI、没有性别、或脱离角色。");
|
||||||
|
sb.append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(prompt);
|
||||||
|
|
||||||
|
// 添加语气风格提示
|
||||||
|
if (!Boolean.TRUE.equals(neutralVoice)) {
|
||||||
|
if ("female".equals(gender)) {
|
||||||
|
sb.append("\n请使用温柔、细腻的女性语气风格回复。");
|
||||||
|
} else if ("male".equals(gender)) {
|
||||||
|
sb.append("\n请使用沉稳、理性的男性语气风格回复。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<String> getEnabledPresetsPrompt() {
|
private Mono<String> getEnabledPresetsPrompt() {
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ public class SentimentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record SentimentResult(String sentiment, double confidence) {
|
public record SentimentResult(String sentiment, double confidence) {
|
||||||
|
public static final String VERY_POSITIVE = "VERY_POSITIVE";
|
||||||
public static final String POSITIVE = "POSITIVE";
|
public static final String POSITIVE = "POSITIVE";
|
||||||
public static final String NEUTRAL = "NEUTRAL";
|
public static final String NEUTRAL = "NEUTRAL";
|
||||||
public static final String NEGATIVE = "NEGATIVE";
|
public static final String NEGATIVE = "NEGATIVE";
|
||||||
|
public static final String VERY_NEGATIVE = "VERY_NEGATIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final List<String> CHOICES = List.of(
|
private static final List<String> CHOICES = List.of(
|
||||||
SentimentResult.POSITIVE, SentimentResult.NEUTRAL, SentimentResult.NEGATIVE
|
SentimentResult.VERY_POSITIVE, SentimentResult.POSITIVE,
|
||||||
|
SentimentResult.NEUTRAL, SentimentResult.NEGATIVE,
|
||||||
|
SentimentResult.VERY_NEGATIVE
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +35,15 @@ public class SentimentService {
|
|||||||
* ({@code OutputSpec.choice}) for reliable classification.
|
* ({@code OutputSpec.choice}) for reliable classification.
|
||||||
*/
|
*/
|
||||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||||
String systemPrompt = "你是一个情感分析助手。请分析评论的情感倾向,只返回 POSITIVE、NEUTRAL 或 NEGATIVE 之一。";
|
String systemPrompt = "你是一个专业的情感分析助手。请根据以下标准分析评论的情感倾向:\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "- VERY_POSITIVE:非常正面,包含强烈的感谢、赞美或认同(如\"太棒了\"、\"非常感谢\"、\"写得太好了\")\n"
|
||||||
|
+ "- POSITIVE:正面,友好、肯定或支持的态度(如\"不错\"、\"学习了\"、\"支持\")\n"
|
||||||
|
+ "- NEUTRAL:中性,提问、讨论、陈述事实,无明显情感倾向(如\"请问...\"、\"这个怎么用\"、\"我觉得\")\n"
|
||||||
|
+ "- NEGATIVE:负面,不满、质疑或批评(如\"不好用\"、\"有问题\"、\"不太行\")\n"
|
||||||
|
+ "- VERY_NEGATIVE:非常负面,攻击、辱骂或极端负面情绪(如\"垃圾\"、\"骗子\"、\"太差了\")\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "只返回 VERY_POSITIVE、POSITIVE、NEUTRAL、NEGATIVE 或 VERY_NEGATIVE 之一。";
|
||||||
String userPrompt = "分析以下评论的情感倾向:\n\n" + commentContent;
|
String userPrompt = "分析以下评论的情感倾向:\n\n" + commentContent;
|
||||||
|
|
||||||
return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName)
|
return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for checking wake words in comment content.
|
||||||
|
* A wake word is a prefix that triggers AI reply from a specific persona,
|
||||||
|
* even if the page hasn't enabled AI auto-reply.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WakeWordService {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a wake word match.
|
||||||
|
*
|
||||||
|
* @param personaName the metadata.name of the matched persona
|
||||||
|
* @param wakeWord the wake word that matched
|
||||||
|
* @param contentWithoutWakeWord the comment content with the wake word prefix removed
|
||||||
|
*/
|
||||||
|
public record WakeWordMatch(String personaName, String wakeWord, String contentWithoutWakeWord) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given content starts with any persona's wake word.
|
||||||
|
* Returns the first matching persona's info, or empty if no match.
|
||||||
|
*
|
||||||
|
* @param content the comment/reply content to check
|
||||||
|
* @return WakeWordMatch if a wake word is found, or empty Mono
|
||||||
|
*/
|
||||||
|
public Mono<WakeWordMatch> checkWakeWord(String content) {
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.list(AiPersona.class, null, null)
|
||||||
|
.filter(persona -> {
|
||||||
|
String wakeWord = persona.getSpec().getWakeWord();
|
||||||
|
return wakeWord != null && !wakeWord.isBlank() && content.startsWith(wakeWord);
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
.map(persona -> {
|
||||||
|
String wakeWord = persona.getSpec().getWakeWord();
|
||||||
|
String remaining = content.substring(wakeWord.length()).trim();
|
||||||
|
log.info("[WakeWord] Matched wake word '{}' for persona '{}'",
|
||||||
|
wakeWord, persona.getSpec().getDisplayName());
|
||||||
|
return new WakeWordMatch(persona.getMetadata().getName(), wakeWord, remaining);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking version for use in Reconciler (sync context).
|
||||||
|
* Checks if the given content starts with any persona's wake word.
|
||||||
|
*
|
||||||
|
* @param syncClient the blocking ExtensionClient
|
||||||
|
* @param content the comment/reply content to check
|
||||||
|
* @return WakeWordMatch if a wake word is found, or null
|
||||||
|
*/
|
||||||
|
public WakeWordMatch checkWakeWordBlocking(run.halo.app.extension.ExtensionClient syncClient, String content) {
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
log.info("[WakeWord] Content is null or blank, skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmedContent = content.trim();
|
||||||
|
var personas = syncClient.listAll(AiPersona.class, null, Sort.unsorted());
|
||||||
|
log.info("[WakeWord] Checking {} personas against content: '{}'", personas.size(),
|
||||||
|
trimmedContent.length() > 50 ? trimmedContent.substring(0, 50) + "..." : trimmedContent);
|
||||||
|
|
||||||
|
for (var persona : personas) {
|
||||||
|
String wakeWord = persona.getSpec().getWakeWord();
|
||||||
|
if (wakeWord == null || wakeWord.isBlank()) {
|
||||||
|
log.info("[WakeWord] Persona '{}' has no wakeWord, skipping", persona.getSpec().getDisplayName());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String trimmedWakeWord = wakeWord.trim();
|
||||||
|
log.info("[WakeWord] Checking persona '{}' with wakeWord '{}' against content starting with '{}'",
|
||||||
|
persona.getSpec().getDisplayName(), trimmedWakeWord,
|
||||||
|
trimmedContent.length() >= trimmedWakeWord.length()
|
||||||
|
? trimmedContent.substring(0, trimmedWakeWord.length()) : trimmedContent);
|
||||||
|
|
||||||
|
if (trimmedContent.startsWith(trimmedWakeWord)) {
|
||||||
|
String remaining = trimmedContent.substring(trimmedWakeWord.length()).trim();
|
||||||
|
log.info("[WakeWord] MATCHED! wakeWord='{}' for persona '{}', remaining content: '{}'",
|
||||||
|
trimmedWakeWord, persona.getSpec().getDisplayName(),
|
||||||
|
remaining.length() > 30 ? remaining.substring(0, 30) + "..." : remaining);
|
||||||
|
return new WakeWordMatch(persona.getMetadata().getName(), trimmedWakeWord, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("[WakeWord] No wake word matched");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for generating Gravatar/Cravatar avatar URLs from email addresses.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class GravatarUtil {
|
||||||
|
|
||||||
|
private static final String CRAVATAR_BASE_URL = "https://cn.cravatar.com/avatar/";
|
||||||
|
|
||||||
|
private GravatarUtil() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Cravatar URL from email address using SHA-256 hash.
|
||||||
|
*
|
||||||
|
* @param email the email address
|
||||||
|
* @return the avatar URL, or empty string if generation fails
|
||||||
|
*/
|
||||||
|
public static String generateUrl(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||||
|
var hexString = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
hexString.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return CRAVATAR_BASE_URL + hexString;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to generate Gravatar URL: {}", e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,4 +30,4 @@ spec:
|
|||||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||||
settingName: "comment-ai-autopilot-settings"
|
settingName: "comment-ai-autopilot-settings"
|
||||||
configMapName: "comment-ai-autopilot-configmap"
|
configMapName: "comment-ai-autopilot-configmap"
|
||||||
version: "1.0.0-beta.2"
|
version: "1.0.0"
|
||||||
|
|||||||
@@ -254,3 +254,27 @@ onMounted(() => {
|
|||||||
fetchHealth()
|
fetchHealth()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.comment-ai-autopilot-home :deep(.page-header) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.comment-ai-autopilot-home :deep(.page-header-actions) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.comment-ai-autopilot-home :deep(.page-header-actions .space-y-2) {
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+347
-32
@@ -11,27 +11,27 @@
|
|||||||
|
|
||||||
<!-- Batch Operation Toolbar -->
|
<!-- Batch Operation Toolbar -->
|
||||||
<div v-if="selectedNames.size > 0" class="m-4 mb-0 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5">
|
<div v-if="selectedNames.size > 0" class="m-4 mb-0 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5">
|
||||||
<span class="text-sm text-blue-700">已选择 {{ selectedNames.size }} 项</span>
|
<span class="text-sm text-blue-700 font-medium">已选择 {{ selectedNames.size }} 项</span>
|
||||||
<button
|
<button
|
||||||
class="text-xs px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition-colors"
|
class="batch-btn batch-btn--approve"
|
||||||
@click="batchApprove"
|
@click="batchApprove"
|
||||||
>
|
>
|
||||||
批量通过
|
批量通过
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-xs px-3 py-1 rounded bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
class="batch-btn batch-btn--reject"
|
||||||
@click="batchReject"
|
@click="batchReject"
|
||||||
>
|
>
|
||||||
批量拒绝
|
批量拒绝
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-xs px-3 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
class="batch-btn batch-btn--delete"
|
||||||
@click="batchDelete"
|
@click="batchDelete"
|
||||||
>
|
>
|
||||||
批量删除
|
批量删除
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-xs text-gray-500 hover:text-gray-700 ml-auto"
|
class="batch-btn batch-btn--cancel"
|
||||||
@click="selectedNames.clear(); selectAll = false"
|
@click="selectedNames.clear(); selectAll = false"
|
||||||
>
|
>
|
||||||
取消选择
|
取消选择
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="m-4 mb-0 flex items-center gap-3">
|
<div class="m-4 mb-0 flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
v-model="filterStatus"
|
v-model="filterStatus"
|
||||||
class="rounded-md border border-gray-300 pl-3 pr-8 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
class="filter-select"
|
||||||
>
|
>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
<option value="PASS">通过</option>
|
<option value="PASS">通过</option>
|
||||||
@@ -52,23 +52,33 @@
|
|||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
v-model="filterSentiment"
|
v-model="filterSentiment"
|
||||||
class="rounded-md border border-gray-300 pl-3 pr-8 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
class="filter-select"
|
||||||
>
|
>
|
||||||
<option value="">全部情感</option>
|
<option value="">全部情感</option>
|
||||||
|
<option value="VERY_POSITIVE">非常正面</option>
|
||||||
<option value="POSITIVE">正面</option>
|
<option value="POSITIVE">正面</option>
|
||||||
<option value="NEUTRAL">中性</option>
|
<option value="NEUTRAL">中性</option>
|
||||||
<option value="NEGATIVE">负面</option>
|
<option value="NEGATIVE">负面</option>
|
||||||
|
<option value="VERY_NEGATIVE">非常负面</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<div class="relative flex-1 max-w-xs">
|
||||||
v-model="filterKeyword"
|
<svg class="filter-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="text"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
placeholder="搜索回复内容..."
|
</svg>
|
||||||
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="filterKeyword"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索回复内容..."
|
||||||
|
class="filter-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="text-xs text-gray-500 hover:text-gray-700"
|
class="filter-reset-btn"
|
||||||
@click="resetFilters"
|
@click="resetFilters"
|
||||||
>
|
>
|
||||||
|
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
重置
|
重置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,26 +122,26 @@
|
|||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
class="status-tag"
|
||||||
:class="getStatusClass(reply.spec.status)"
|
:class="getStatusClass(reply.spec.status)"
|
||||||
>
|
>
|
||||||
{{ getStatusLabel(reply.spec.status) }}
|
{{ getStatusLabel(reply.spec.status) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
|
class="status-tag"
|
||||||
:class="reply.spec.published ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'"
|
:class="reply.spec.published ? 'status-tag--published' : 'status-tag--unpublished'"
|
||||||
>
|
>
|
||||||
{{ reply.spec.published ? '已发布' : '未发布' }}
|
{{ reply.spec.published ? '已发布' : '未发布' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="reply.spec.isAiConversation"
|
v-if="reply.spec.isAiConversation"
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600"
|
class="status-tag status-tag--conversation"
|
||||||
>
|
>
|
||||||
对话
|
对话
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="reply.spec.sentiment"
|
v-if="reply.spec.sentiment"
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
class="status-tag"
|
||||||
:class="getSentimentClass(reply.spec.sentiment)"
|
:class="getSentimentClass(reply.spec.sentiment)"
|
||||||
>
|
>
|
||||||
{{ getSentimentLabel(reply.spec.sentiment) }}
|
{{ getSentimentLabel(reply.spec.sentiment) }}
|
||||||
@@ -151,11 +161,11 @@
|
|||||||
<!-- Card footer: meta info + actions -->
|
<!-- Card footer: meta info + actions -->
|
||||||
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4 text-xs text-gray-400">
|
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||||
<span>
|
<span class="flex items-center gap-1.5">
|
||||||
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium">{{ reply.spec.score }}</span>
|
评分 <span :class="getScoreClass(reply.spec.score)" class="font-semibold">{{ reply.spec.score }}</span>
|
||||||
<span class="text-gray-300 ml-0.5">{{ getScoreLabel(reply.spec.score) }}</span>
|
<span class="score-label" :class="getScoreLabelClass(reply.spec.score)">{{ getScoreLabel(reply.spec.score) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
|
<span v-if="reply.spec.postSlug && reply.spec.postKind === 'Post'" class="flex items-center gap-1">
|
||||||
文章
|
文章
|
||||||
<a
|
<a
|
||||||
:href="getPostUrl(reply.spec.postSlug)"
|
:href="getPostUrl(reply.spec.postSlug)"
|
||||||
@@ -167,6 +177,18 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="reply.spec.postSlug && reply.spec.postKind === 'SinglePage'" class="flex items-center gap-1">
|
||||||
|
页面
|
||||||
|
<a
|
||||||
|
:href="getPageUrl(reply.spec.postSlug)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-500 hover:text-blue-700 hover:underline"
|
||||||
|
>{{ reply.spec.postSlug }}</a>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span v-if="reply.spec.retryCount > 0">
|
<span v-if="reply.spec.retryCount > 0">
|
||||||
重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span> 次
|
重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span> 次
|
||||||
</span>
|
</span>
|
||||||
@@ -318,6 +340,7 @@ interface AiCommentReplyItem {
|
|||||||
commentId: string
|
commentId: string
|
||||||
postId: string
|
postId: string
|
||||||
postSlug: string
|
postSlug: string
|
||||||
|
postKind: string
|
||||||
reply: string
|
reply: string
|
||||||
score: number
|
score: number
|
||||||
status: string
|
status: string
|
||||||
@@ -509,6 +532,14 @@ const getScoreClass = (score: number) => {
|
|||||||
return "text-red-600"
|
return "text-red-600"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getScoreLabelClass = (score: number) => {
|
||||||
|
if (score >= 85) return "score-label--excellent"
|
||||||
|
if (score >= 70) return "score-label--good"
|
||||||
|
if (score >= 50) return "score-label--average"
|
||||||
|
if (score > 0) return "score-label--poor"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const getScoreLabel = (score: number) => {
|
const getScoreLabel = (score: number) => {
|
||||||
if (score >= 85) return "优秀"
|
if (score >= 85) return "优秀"
|
||||||
if (score >= 70) return "良好"
|
if (score >= 70) return "良好"
|
||||||
@@ -520,15 +551,15 @@ const getScoreLabel = (score: number) => {
|
|||||||
const getStatusClass = (status: string) => {
|
const getStatusClass = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "PASS":
|
case "PASS":
|
||||||
return "bg-green-100 text-green-700"
|
return "status-tag--pass"
|
||||||
case "FAIL":
|
case "FAIL":
|
||||||
return "bg-red-100 text-red-700"
|
return "status-tag--fail"
|
||||||
case "PENDING":
|
case "PENDING":
|
||||||
return "bg-gray-100 text-gray-700"
|
return "status-tag--pending"
|
||||||
case "REJECTED":
|
case "REJECTED":
|
||||||
return "bg-red-100 text-red-700"
|
return "status-tag--rejected"
|
||||||
default:
|
default:
|
||||||
return "bg-gray-100 text-gray-700"
|
return "status-tag--pending"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,23 +580,31 @@ const getStatusLabel = (status: string) => {
|
|||||||
|
|
||||||
const getSentimentClass = (sentiment: string) => {
|
const getSentimentClass = (sentiment: string) => {
|
||||||
switch (sentiment) {
|
switch (sentiment) {
|
||||||
|
case "VERY_POSITIVE":
|
||||||
|
return "status-tag--very-positive"
|
||||||
case "POSITIVE":
|
case "POSITIVE":
|
||||||
return "bg-green-50 text-green-600"
|
return "status-tag--positive"
|
||||||
case "NEGATIVE":
|
case "NEGATIVE":
|
||||||
return "bg-red-50 text-red-600"
|
return "status-tag--negative"
|
||||||
|
case "VERY_NEGATIVE":
|
||||||
|
return "status-tag--very-negative"
|
||||||
case "NEUTRAL":
|
case "NEUTRAL":
|
||||||
return "bg-gray-100 text-gray-600"
|
return "status-tag--neutral-sentiment"
|
||||||
default:
|
default:
|
||||||
return "bg-gray-100 text-gray-600"
|
return "status-tag--neutral-sentiment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSentimentLabel = (sentiment: string) => {
|
const getSentimentLabel = (sentiment: string) => {
|
||||||
switch (sentiment) {
|
switch (sentiment) {
|
||||||
|
case "VERY_POSITIVE":
|
||||||
|
return "非常正面"
|
||||||
case "POSITIVE":
|
case "POSITIVE":
|
||||||
return "正面"
|
return "正面"
|
||||||
case "NEGATIVE":
|
case "NEGATIVE":
|
||||||
return "负面"
|
return "负面"
|
||||||
|
case "VERY_NEGATIVE":
|
||||||
|
return "非常负面"
|
||||||
case "NEUTRAL":
|
case "NEUTRAL":
|
||||||
return "中性"
|
return "中性"
|
||||||
default:
|
default:
|
||||||
@@ -582,6 +621,10 @@ const getPostUrl = (slug: string) => {
|
|||||||
return `${window.location.origin}/archives/${slug}`
|
return `${window.location.origin}/archives/${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPageUrl = (slug: string) => {
|
||||||
|
return `${window.location.origin}/pages/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip HTML tags for plain text display (card preview)
|
* Strip HTML tags for plain text display (card preview)
|
||||||
*/
|
*/
|
||||||
@@ -649,4 +692,276 @@ onMounted(fetchReplies)
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Batch Operation Buttons ===== */
|
||||||
|
.batch-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.batch-btn--approve {
|
||||||
|
background-color: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.batch-btn--approve:hover {
|
||||||
|
background-color: #15803d;
|
||||||
|
}
|
||||||
|
.batch-btn--reject {
|
||||||
|
background-color: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.batch-btn--reject:hover {
|
||||||
|
background-color: #ea580c;
|
||||||
|
}
|
||||||
|
.batch-btn--delete {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.batch-btn--delete:hover {
|
||||||
|
background-color: #b91c1c;
|
||||||
|
}
|
||||||
|
.batch-btn--cancel {
|
||||||
|
background: none;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.batch-btn--cancel:hover {
|
||||||
|
color: #374151;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Filter Bar ===== */
|
||||||
|
.filter-select {
|
||||||
|
padding: 6px 32px 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
background-size: 16px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.filter-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.filter-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px 6px 34px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.filter-reset-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-reset-btn:hover {
|
||||||
|
color: #374151;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Status Tags ===== */
|
||||||
|
.status-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.status-tag--pass {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.status-tag--fail {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.status-tag--pending {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.status-tag--rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.status-tag--published {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.status-tag--unpublished {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.status-tag--conversation {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sentiment tags */
|
||||||
|
.status-tag--very-positive {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.status-tag--positive {
|
||||||
|
background: #bbf7d0;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.status-tag--neutral-sentiment {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.status-tag--negative {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.status-tag--very-negative {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Score Label ===== */
|
||||||
|
.score-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.score-label--excellent {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.score-label--good {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.score-label--average {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.score-label--poor {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile Responsive ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.comment-ai-autopilot-logs :deep(.page-header) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch toolbar: wrap buttons */
|
||||||
|
.comment-ai-autopilot-logs .m-4.mb-0.flex {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.batch-btn--cancel {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar: stack vertically */
|
||||||
|
.comment-ai-autopilot-logs .m-4.mb-0.flex.items-center.gap-3:not(.bg-blue-50) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.filter-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.relative.flex-1.max-w-xs {
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 10;
|
||||||
|
}
|
||||||
|
.filter-reset-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card footer: stack meta and actions */
|
||||||
|
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 .flex.items-center.gap-4 {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 .flex.items-center.gap-2 {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status tags row: allow wrapping */
|
||||||
|
.comment-ai-autopilot-logs .flex.items-center.justify-between.mb-3 {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.comment-ai-autopilot-logs .flex.items-center.justify-between.mt-4 {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conversation dialog */
|
||||||
|
.comment-ai-autopilot-logs .relative.bg-white {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
margin: 16px;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -153,6 +153,10 @@
|
|||||||
<div class="persona-card__info">
|
<div class="persona-card__info">
|
||||||
<div class="persona-card__name">
|
<div class="persona-card__name">
|
||||||
{{ p.spec.displayName || '未命名' }}
|
{{ p.spec.displayName || '未命名' }}
|
||||||
|
<span v-if="p.spec.gender === 'female'" class="persona-card__badge persona-card__badge--female">女</span>
|
||||||
|
<span v-else-if="p.spec.gender === 'male'" class="persona-card__badge persona-card__badge--male">男</span>
|
||||||
|
<span v-if="p.spec.neutralVoice" class="persona-card__badge persona-card__badge--neutral-voice">中性语气</span>
|
||||||
|
<span v-if="p.spec.wakeWord" class="persona-card__badge persona-card__badge--wake-word">唤醒: {{ p.spec.wakeWord }}</span>
|
||||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
||||||
@@ -396,6 +400,33 @@
|
|||||||
<span class="form-hint">用于Gravatar头像服务,留空使用默认头像</span>
|
<span class="form-hint">用于Gravatar头像服务,留空使用默认头像</span>
|
||||||
<input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" />
|
<input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Gender & Neutral Voice -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">性别与语气</label>
|
||||||
|
<div class="gender-voice-row">
|
||||||
|
<div class="gender-select">
|
||||||
|
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'female' }">
|
||||||
|
<input type="radio" v-model="personaForm.gender" value="female" class="gender-radio" />
|
||||||
|
<span class="gender-option__label">女</span>
|
||||||
|
</label>
|
||||||
|
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'male' }">
|
||||||
|
<input type="radio" v-model="personaForm.gender" value="male" class="gender-radio" />
|
||||||
|
<span class="gender-option__label">男</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="voice-toggle">
|
||||||
|
<input type="checkbox" v-model="personaForm.neutralVoice" class="form-checkbox" id="neutralVoice" />
|
||||||
|
<label for="neutralVoice" class="text-sm text-gray-600 cursor-pointer">中性语气</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">勾选中性语气则使用中性语气,取消勾选则跟随性别语气</span>
|
||||||
|
</div>
|
||||||
|
<!-- Wake Word -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">唤醒词</label>
|
||||||
|
<span class="form-hint">评论以此词开头则唤醒该角色回复,留空不启用。</span>
|
||||||
|
<input type="text" v-model="personaForm.wakeWord" class="form-input" placeholder="如:小回小回" />
|
||||||
|
</div>
|
||||||
<!-- Prompt -->
|
<!-- Prompt -->
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">人格提示词</label>
|
<label class="form-label">人格提示词</label>
|
||||||
@@ -518,6 +549,9 @@ const personaSaving = ref(false)
|
|||||||
const personaForm = reactive({
|
const personaForm = reactive({
|
||||||
displayName: '',
|
displayName: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
gender: 'female',
|
||||||
|
neutralVoice: false,
|
||||||
|
wakeWord: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
})
|
})
|
||||||
@@ -708,11 +742,17 @@ const openPersonaDialog = async (persona: any | null) => {
|
|||||||
if (persona) {
|
if (persona) {
|
||||||
personaForm.displayName = persona.spec.displayName || ''
|
personaForm.displayName = persona.spec.displayName || ''
|
||||||
personaForm.email = persona.spec.email || ''
|
personaForm.email = persona.spec.email || ''
|
||||||
|
personaForm.gender = persona.spec.gender || 'female'
|
||||||
|
personaForm.neutralVoice = persona.spec.neutralVoice || false
|
||||||
|
personaForm.wakeWord = persona.spec.wakeWord || ''
|
||||||
personaForm.prompt = persona.spec.prompt || ''
|
personaForm.prompt = persona.spec.prompt || ''
|
||||||
personaForm.isDefault = persona.spec.isDefault || false
|
personaForm.isDefault = persona.spec.isDefault || false
|
||||||
} else {
|
} else {
|
||||||
personaForm.displayName = ''
|
personaForm.displayName = ''
|
||||||
personaForm.email = ''
|
personaForm.email = ''
|
||||||
|
personaForm.gender = 'female'
|
||||||
|
personaForm.neutralVoice = false
|
||||||
|
personaForm.wakeWord = ''
|
||||||
personaForm.prompt = ''
|
personaForm.prompt = ''
|
||||||
personaForm.isDefault = false
|
personaForm.isDefault = false
|
||||||
}
|
}
|
||||||
@@ -754,6 +794,9 @@ const savePersona = async () => {
|
|||||||
spec: {
|
spec: {
|
||||||
displayName: personaForm.displayName,
|
displayName: personaForm.displayName,
|
||||||
email: personaForm.email,
|
email: personaForm.email,
|
||||||
|
gender: personaForm.gender,
|
||||||
|
neutralVoice: personaForm.neutralVoice,
|
||||||
|
wakeWord: personaForm.wakeWord,
|
||||||
prompt: personaForm.prompt,
|
prompt: personaForm.prompt,
|
||||||
isDefault: personaForm.isDefault,
|
isDefault: personaForm.isDefault,
|
||||||
},
|
},
|
||||||
@@ -863,7 +906,10 @@ 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(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 }
|
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 {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// Update snapshot after fetch to reset unsaved indicator
|
// Update snapshot after fetch to reset unsaved indicator
|
||||||
@@ -1248,6 +1294,18 @@ onMounted(async () => {
|
|||||||
padding: 1px 8px; font-size: 11px; font-weight: 500;
|
padding: 1px 8px; font-size: 11px; font-weight: 500;
|
||||||
background: #dbeafe; color: #2563eb; border-radius: 4px;
|
background: #dbeafe; color: #2563eb; border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.persona-card__badge--female {
|
||||||
|
background: #fce7f3; color: #be185d;
|
||||||
|
}
|
||||||
|
.persona-card__badge--male {
|
||||||
|
background: #dbeafe; color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.persona-card__badge--neutral-voice {
|
||||||
|
background: #f3f4f6; color: #4b5563;
|
||||||
|
}
|
||||||
|
.persona-card__badge--wake-word {
|
||||||
|
background: #fef3c7; color: #b45309;
|
||||||
|
}
|
||||||
.persona-card__prompt {
|
.persona-card__prompt {
|
||||||
font-size: 12px; color: #6b7280; margin-top: 2px;
|
font-size: 12px; color: #6b7280; margin-top: 2px;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
@@ -1281,6 +1339,56 @@ onMounted(async () => {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-checkbox {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
accent-color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Gender Select ===== */
|
||||||
|
.gender-voice-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.gender-select {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
.gender-option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.gender-option:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.gender-option--active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.gender-radio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.gender-option__label {
|
||||||
|
font-size: 14px; font-weight: 500; color: #374151;
|
||||||
|
}
|
||||||
|
.gender-option--active .gender-option__label {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Button Link ===== */
|
/* ===== Button Link ===== */
|
||||||
.btn-link {
|
.btn-link {
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
@@ -1431,4 +1539,92 @@ onMounted(async () => {
|
|||||||
.dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; }
|
.dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; }
|
||||||
.dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; }
|
.dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; }
|
||||||
.dialog__item-add { width: 18px; height: 18px; color: #3b82f6; flex-shrink: 0; }
|
.dialog__item-add { width: 18px; height: 18px; color: #3b82f6; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ===== Mobile Responsive ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-page :deep(.page-header) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-page :deep(.page-header-actions) {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.settings-page :deep(.page-header-actions .space-x-2) {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.settings-tab {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
position: static;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
.sidebar-card__actions {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-card__actions :deep(.btn) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.section-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.form-row--bordered {
|
||||||
|
margin: 0 -16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.persona-card__prompt {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.dialog__header {
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
}
|
||||||
|
.dialog__search {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.dialog__search-icon {
|
||||||
|
left: 28px;
|
||||||
|
}
|
||||||
|
.dialog__body {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user