6 Commits
Author SHA1 Message Date
bbb-lsy07andbbb-lsy07 1a2732fe19 feat: 彻底重构后台 UI 与对话上下文引用模块,全面优化移动端适配 (v1.0.4) (#6)
* feat: 对话气泡增加引用摘要模块,解决多用户混杂交谈上下文不清晰问题

- 后端 ConversationMessage Record 新增 quoteOwner/quoteContent 字段
- 后端 getConversation 方法重写,构建 Reply 映射字典溯源引用关系
- 前端 ConversationMessage 类型定义新增 quoteOwner/quoteContent
- 前端新增 truncateQuote 截断方法(复用 stripHtml,限30字符)
- 前端对话气泡模板渲染灰色引用条(bg-black/5 + border-l-2)

* chore: 补充 .gitignore 规则(*.jar、ui/dist 等)

* feat: Markdown 引用注入法 - AI回复自动拼接引用块(主题无关通用方案)

- 后端 CommentReplyPublisher.doPublish 重写,发布前查询被回复对象并拼接 Markdown Blockquote
- 新增 buildQuoteMarkdown 辅助方法,Jsoup 清除 HTML 后截断 40 字符生成引用
- 前端 LogsView 恢复简洁气泡模板,移除 quoteOwner/quoteContent 前端引用逻辑
- renderContent 新增换行符处理,确保 Markdown 引用块正确渲染

* style: 重写 LogsView.vue - 纯 Tailwind 标签替代 Emoji,移除 300+ 行自定义 CSS

- 状态/情感标签改用纯色 Tailwind 背景标签,去除所有 Emoji
- 删除 300+ 行自定义 CSS,全部替换为 Tailwind 原子类
- 对话弹窗 Markdown 引用块正则提取,去除气泡和机器人 Emoji
- 优化移动端响应式布局,解决排版错位问题

* style: 重写 SettingsView.vue - 纯 Tailwind 栅格布局,移除自定义 CSS

- 标签导航改用 Tailwind flex + overflow-x-auto
- 所有设置面板(basic/persona/model/prompt/cleanup)改用 Tailwind 原子类
- 开关改用 peer-checked 伪类实现,移除自定义 toggle CSS
- 滑块刻度改用 flex justify-between 实现
- 侧边栏 lg:sticky lg:top-24,移动端自然折叠到底部
- 弹窗(评论者选择/角色编辑)改用 fixed inset-0 + backdrop-blur
- 删除 600+ 行自定义 CSS

* refactor: 返璞归真 - 剥离 Markdown 注入,利用 Halo 原生层级回复

- 后端 CommentReplyPublisher 删除 buildQuoteMarkdown 和 Markdown 拼接逻辑
- AI 回复直接存入纯净文本,由 Halo 原生 quoteReply 字段渲染前台层级关系
- 前端 ConversationMessage 恢复 quoteOwner/quoteContent 字段
- 前端对话弹窗添加原生 Tailwind 引用摘要框(灰色 border-l-[3px])
- 简化 renderContent,删除 Markdown 引用正则匹配

* chore: 版本号升级至 1.0.1,强制刷新 Halo 前端缓存

- plugin.yaml version: 1.0.0 -> 1.0.1
- build.gradle version: 1.0.0 -> 1.0.1
- LogsView truncateQuote/renderContent 增加历史 Markdown 引用文本清理正则
- 防止旧版测试数据 (💬 **@某人**:) 在界面套娃显示

* style: 彻底重写 LogsView & SettingsView - 原生 Scoped CSS 替代 Tailwind

- LogsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- SettingsView.vue: 移除所有 Tailwind 类,改用 <style scoped> 原生 CSS
- 标签配色、气泡样式、引用框全部使用纯 CSS 实现,避免 Halo 主题冲突
- 版本号升级至 1.0.2 强制刷新前端缓存

* feat: SettingsView 完整功能版 - AI角色/数据清理/导入导出

- 5个设置面板:基本设置、AI角色、模型设置、Prompt、数据清理
- AI角色:CRUD、Gravatar头像、性别/唤醒词/默认角色
- 数据清理:自动清理开关、保留天数滑块、手动清理
- 导入导出:JSON配置导入导出
- 评论者黑名单弹窗选择
- 全部使用原生 Scoped CSS

* chore: 版本号升级至 1.0.3

* v1.0.4: 美化 LogsView 和 SettingsView UI,优化引用框样式与移动端适配

---------

Co-authored-by: bbb-lsy07 <bbb-lsy07@users.noreply.github.com>
2026-06-21 12:20:25 +08:00
sunny-335 633f3ff588 fix: CD pre-release-cleanup fails when no assets exist 2026-06-18 22:56:35 +08:00
sunny-335 2c88ad6fb9 feat: v1.0.0 - 唤醒词、性别配置、身份强化、移动端适配、Bug修复 2026-06-18 22:40:00 +08:00
sunny-335 a9dd1c14bc feat: 5级情感分析、日志UI优化、页面链接支持、v1.0.0-b26cea 2026-06-18 19:58:46 +08:00
sunny-335 77e3bd36c5 refactor: 全面优化完善 - ObjectMapper注入、索引优化、代码去重、Bug修复 2026-06-18 19:42:30 +08:00
sunny-335 7934d8c947 fix: pre-cleanup release assets to avoid gh upload conflict 2026-06-18 13:52:38 +08:00
34 changed files with 1717 additions and 2482 deletions
+22
View File
@@ -6,7 +6,29 @@ 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 }}"
# Capture asset list first to avoid pipefail issues
ASSETS=$(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' 2>/dev/null || true)
if [ -n "$ASSETS" ]; then
echo "$ASSETS" | while read -r filename; do
echo "Deleting existing asset: $filename"
gh release delete-asset "$TAG_NAME" "$filename" --yes 2>/dev/null || true
done
else
echo "No existing assets to delete"
fi
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
+7
View File
@@ -63,6 +63,7 @@ lerna-debug.log*
*.ctxt *.ctxt
### Package Files ### Package Files
*.jar
*.war *.war
*.nar *.nar
*.ear *.ear
@@ -70,6 +71,12 @@ lerna-debug.log*
*.tar.gz *.tar.gz
*.rar *.rar
### UI build output
ui/dist/
ui/dist-ssr/
ui/*.local
ui/.eslintcache
### Local file ### Local file
application-local.yml application-local.yml
application-local.yaml application-local.yaml
+11 -66
View File
@@ -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
View File
@@ -5,7 +5,7 @@ plugins {
} }
group 'top.nxxy335.commentaiautopilot' group 'top.nxxy335.commentaiautopilot'
version '1.0.0-beta.2' version '1.0.4'
repositories { repositories {
mavenCentral() mavenCentral()
+2 -1
View File
@@ -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: {
+37
View File
@@ -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 stripJsoup.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
+26
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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回复日志页面,每条记录会显示情感标签:
- 🟢 **正面** — 绿色标签 - 🟢 **非常正面**绿色标签
- 🟩 **正面** — 浅绿色标签
-**中性** — 灰色标签 -**中性** — 灰色标签
- 🔴 **负面** — 红色标签 - 🟥 **负面**红色标签
- 🔴 **非常负面** — 深红色标签
## 性能影响 ## 性能影响
+5 -1
View File
@@ -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();
}
}
@@ -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() : "",
@@ -293,25 +291,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp()); var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp());
var isCommentAi = isAiOwner(comment.getSpec().getOwner()); var isCommentAi = isAiOwner(comment.getSpec().getOwner());
// 首条评论没有引用对象
var commentMsg = new ConversationMessage( var commentMsg = new ConversationMessage(
"comment", commentOwner, commentContent, commentTime, isCommentAi "comment", commentOwner, commentContent, commentTime, isCommentAi, null, null
); );
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 -> { .collectList() // 收集为List以便统一处理引用映射
.map(replyList -> {
List<ConversationMessage> messages = new ArrayList<>();
messages.add(commentMsg);
// 构建 Reply 的映射字典,方便查找引用关系
Map<String, Reply> replyMap = new HashMap<>();
for (Reply r : replyList) {
replyMap.put(r.getMetadata().getName(), r);
}
for (Reply reply : replyList) {
var replyOwner = extractOwnerName(reply.getSpec().getOwner()); var replyOwner = extractOwnerName(reply.getSpec().getOwner());
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent()); var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp()); var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
var isAi = isAiOwner(reply.getSpec().getOwner()); var isAi = isAiOwner(reply.getSpec().getOwner());
return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi);
}) String quoteOwner = null;
.collectList() String quoteContent = null;
.map(replyList -> {
List<ConversationMessage> messages = new ArrayList<>(); // 获取引用的 Reply 名称 (Halo中如果为空,代表直接回复顶级 Comment)
messages.add(commentMsg); String quoteReplyName = reply.getSpec().getQuoteReply();
messages.addAll(replyList); if (quoteReplyName != null && !quoteReplyName.isBlank()) {
Reply quotedReply = replyMap.get(quoteReplyName);
if (quotedReply != null) {
quoteOwner = extractOwnerName(quotedReply.getSpec().getOwner());
quoteContent = extractContent(quotedReply.getSpec().getRaw(), quotedReply.getSpec().getContent());
}
} else {
// 没有 quoteReply 表示直接回复首条评论
quoteOwner = commentOwner;
quoteContent = commentContent;
}
messages.add(new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi, quoteOwner, quoteContent));
}
return messages; return messages;
}); });
}) })
@@ -642,9 +666,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 +693,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 +703,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();
@@ -738,7 +737,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
String owner, String owner,
String content, String content,
String time, String time,
boolean isAi boolean isAi,
String quoteOwner,
String quoteContent
) {} ) {}
public record CommenterInfo( public record CommenterInfo(
@@ -748,7 +749,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 +768,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 +778,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 +948,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,31 +25,20 @@ 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
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
return Result.doNotRetry();
}
AtomicBoolean asyncStarted = new AtomicBoolean(false);
try {
client.fetch(Comment.class, name).ifPresent(comment -> { client.fetch(Comment.class, name).ifPresent(comment -> {
if (isProcessed(comment.getMetadata().getAnnotations())) { if (isProcessed(comment.getMetadata().getAnnotations())) {
return; return;
@@ -92,33 +78,39 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
markProcessed(comment); markProcessed(comment);
client.update(comment); client.update(comment);
// Read persona name from the post's annotations // Check for wake word in comment content
String personaName = getPersonaNameFromComment(comment); 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,
// bypassing the "must be reply to AI" check and page-level enable check
log.info("[ReplyReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
orchestrator.processComment(parentCommentName, name, true, wakeMatch.personaName(), true)
.subscribeOn(Schedulers.boundedElastic())
.subscribe(
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); log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
orchestrator.processComment(parentCommentName, name, true, personaName) orchestrator.processComment(parentCommentName, name, true, personaName, false)
.subscribeOn(Schedulers.boundedElastic()) .subscribeOn(Schedulers.boundedElastic())
.subscribe( .subscribe(
null, null,
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e), e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name) () -> 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,8 +43,25 @@ public class AiReplyCleanupService implements DisposableBean {
} }
public void dailyCleanup() { public void dailyCleanup() {
try { isCleanupEnabled()
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .flatMap(enabled -> {
if (!Boolean.TRUE.equals(enabled)) {
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
return Mono.empty();
}
return getRetentionDays()
.flatMap(retentionDays -> executeCleanup(retentionDays)
.doOnNext(deleted -> log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays))
);
})
.subscribe(
null,
e -> log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e)
);
}
private Mono<Boolean> isCleanupEnabled() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
var data = cm.getData(); var data = cm.getData();
if (data == null) return false; if (data == null) return false;
@@ -55,51 +75,35 @@ public class AiReplyCleanupService implements DisposableBean {
return true; return true;
} }
}) })
.defaultIfEmpty(true) .defaultIfEmpty(true);
.block();
if (!Boolean.TRUE.equals(enabled)) {
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
return;
} }
int retentionDays = getRetentionDays(); public Mono<Long> executeCleanup(int retentionDays) {
long deleted = executeCleanup(retentionDays);
log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays);
} catch (Exception e) {
log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e);
}
}
public long executeCleanup(int retentionDays) {
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS); 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)
long deleted = 0; .flatMap(record -> client.delete(record)
for (var record : oldRecords) { .thenReturn(1L)
try { .onErrorResume(e -> {
client.delete(record).block();
deleted++;
} catch (Exception e) {
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage()); log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
} return Mono.just(0L);
} })
return deleted; )
.reduce(0L, Long::sum);
});
} }
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();
@@ -114,11 +118,10 @@ public class AiReplyCleanupService implements DisposableBean {
} }
}) })
.defaultIfEmpty(30) .defaultIfEmpty(30)
.block(); .onErrorResume(e -> {
} catch (Exception e) {
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage()); log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
return 30; return Mono.just(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,12 +100,42 @@ 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();
} }
return proceedWithProcess(commentName, replyName, isAiConversation, personaName);
});
});
})
.doOnError(e -> log.error("[Orchestrator] Error processing comment {}: {}", commentName, e.getMessage(), e))
.doFinally(signal -> {
// Always release the lock when processing completes
processingLocks.remove(lockKey);
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
})
.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 top-level comments: skip if we already have ANY reply record
// For AI conversation: skip if we already replied to THIS specific reply // For AI conversation: skip if we already replied to THIS specific reply
if (!isAiConversation) { if (!isAiConversation) {
@@ -134,16 +166,43 @@ public class AiReplyOrchestrator {
}); });
}) })
); );
}); }
});
/**
* 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;
}
}) })
.doOnError(e -> log.error("[Orchestrator] Error processing comment {}: {}", commentName, e.getMessage(), e)) .defaultIfEmpty(false);
.doFinally(signal -> {
// Always release the lock when processing completes
processingLocks.remove(lockKey);
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
}) })
.then(); .defaultIfEmpty(false);
} }
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation, private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
@@ -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;
@@ -81,7 +80,10 @@ public class CommentReplyPublisher {
private Mono<Reply> doPublish(String parentCommentName, String replyContent, private Mono<Reply> doPublish(String parentCommentName, String replyContent,
String postName, String quoteReplyName, boolean autoPublish, String postName, String quoteReplyName, boolean autoPublish,
String personaName) { String personaName) {
return resolvePersona(personaName).flatMap(persona -> {
// 解析 AI 角色并直接发布纯净的回复内容
return resolvePersona(personaName)
.flatMap(persona -> {
String displayName = persona.displayName(); String displayName = persona.displayName();
String email = persona.email(); String email = persona.email();
@@ -92,8 +94,11 @@ public class CommentReplyPublisher {
var spec = reply.getSpec(); var spec = reply.getSpec();
spec.setCommentName(parentCommentName); spec.setCommentName(parentCommentName);
// 直接存入纯净的 AI 回复内容,不加任何 Markdown 前缀
spec.setRaw(replyContent); spec.setRaw(replyContent);
spec.setContent(replyContent); spec.setContent(replyContent);
spec.setApproved(autoPublish); spec.setApproved(autoPublish);
if (autoPublish) { if (autoPublish) {
spec.setApprovedTime(Instant.now()); spec.setApprovedTime(Instant.now());
@@ -103,6 +108,7 @@ public class CommentReplyPublisher {
spec.setAllowNotification(false); spec.setAllowNotification(false);
spec.setHidden(false); spec.setHidden(false);
// Halo 原生评论组件正是靠这个字段来渲染 "回复 @某人" 的
if (quoteReplyName != null && !quoteReplyName.isBlank()) { if (quoteReplyName != null && !quoteReplyName.isBlank()) {
spec.setQuoteReply(quoteReplyName); spec.setQuoteReply(quoteReplyName);
} }
@@ -118,24 +124,17 @@ public class CommentReplyPublisher {
Map<String, String> ownerAnnotations = new HashMap<>(); Map<String, String> ownerAnnotations = new HashMap<>();
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
// 使用Gravatar邮箱头像
if (email != null && !email.isBlank()) { if (email != null && !email.isBlank()) {
String gravatarUrl = generateGravatarUrl(email); String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
} }
owner.setAnnotations(ownerAnnotations); owner.setAnnotations(ownerAnnotations);
spec.setOwner(owner); spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}", log.info("[Publisher] Creating reply for comment: {}, content length: {}", parentCommentName, replyContent.length());
parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations);
return client.create(reply) return client.create(reply)
.doOnSuccess(created -> { .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}", displayName, parentCommentName))
var createdOwner = created.getSpec().getOwner();
log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}",
displayName, parentCommentName, quoteReplyName,
createdOwner != null ? createdOwner.getAnnotations() : "null");
})
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
}); });
} }
@@ -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 &gt; Category annotation &gt; 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 "";
}
}
}
+1 -1
View File
@@ -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.4"
+24
View File
@@ -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>
+213 -565
View File
@@ -1,303 +1,134 @@
<template> <template>
<div class="comment-ai-autopilot-logs"> <div class="logs-container">
<VPageHeader title="AI回复日志"> <VPageHeader title="AI回复日志">
<template #icon> <template #icon><IconPlug class="header-icon" /></template>
<IconPlug class="mr-2 self-center" />
</template>
<template #actions> <template #actions>
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton> <VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
</template> </template>
</VPageHeader> </VPageHeader>
<!-- 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="toolbar batch-toolbar">
<span class="text-sm text-blue-700">已选择 {{ selectedNames.size }} </span> <span class="batch-text">已选择 {{ selectedNames.size }} </span>
<button <div class="batch-actions">
class="text-xs px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition-colors" <button class="btn-batch btn-pass" @click="batchApprove">批量通过</button>
@click="batchApprove" <button class="btn-batch btn-reject" @click="batchReject">批量拒绝</button>
> <button class="btn-batch btn-delete" @click="batchDelete">批量删除</button>
批量通过 <button class="btn-batch btn-cancel" @click="selectedNames.clear(); selectAll = false">取消选择</button>
</button> </div>
<button
class="text-xs px-3 py-1 rounded bg-orange-500 text-white hover:bg-orange-600 transition-colors"
@click="batchReject"
>
批量拒绝
</button>
<button
class="text-xs px-3 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
@click="batchDelete"
>
批量删除
</button>
<button
class="text-xs text-gray-500 hover:text-gray-700 ml-auto"
@click="selectedNames.clear(); selectAll = false"
>
取消选择
</button>
</div> </div>
<!-- Filter Bar --> <!-- 过滤工具栏 -->
<div class="m-4 mb-0 flex items-center gap-3"> <div class="toolbar filter-toolbar">
<select <select v-model="filterStatus" class="filter-select">
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"
>
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="PASS">通过</option> <option value="PASS">通过</option>
<option value="FAIL">失败</option> <option value="FAIL">失败</option>
<option value="PENDING">待审核</option> <option value="PENDING">待审核</option>
<option value="REJECTED">已拒绝</option> <option value="REJECTED">已拒绝</option>
</select> </select>
<select <select v-model="filterSentiment" class="filter-select">
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"
>
<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 <input v-model="filterKeyword" type="text" placeholder="搜索回复内容..." class="filter-input" />
v-model="filterKeyword" <button class="btn-reset" @click="resetFilters">重置</button>
type="text"
placeholder="搜索回复内容..."
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
class="text-xs text-gray-500 hover:text-gray-700"
@click="resetFilters"
>
重置
</button>
</div> </div>
<div class="m-4"> <!-- 列表区 -->
<div class="list-area">
<VLoading v-if="loading" /> <VLoading v-if="loading" />
<div v-else-if="replies.length === 0" class="empty-state">暂无记录</div>
<div v-else-if="replies.length === 0" class="flex flex-col items-center justify-center py-16 text-gray-400"> <div v-else class="reply-list">
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="select-all-wrap">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <input type="checkbox" :checked="selectAll" @change="toggleSelectAll" />
</svg> <span>全选本页</span>
<span>暂无AI回复记录</span>
</div> </div>
<div v-else class="space-y-3"> <div v-for="reply in replies" :key="reply.metadata.name" class="reply-card">
<div v-if="replies.length > 0" class="flex items-center gap-2 mb-2 px-1"> <div class="card-main">
<input <input type="checkbox" :checked="selectedNames.has(reply.metadata.name)" @change="toggleSelect(reply.metadata.name)" />
type="checkbox" <div class="card-content">
:checked="selectAll" <div class="card-header">
@change="toggleSelectAll" <div class="tags-wrap">
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" <span class="custom-tag" :class="'tag-' + reply.spec.status">{{ getStatusLabel(reply.spec.status) }}</span>
/> <span class="custom-tag" :class="reply.spec.published ? 'tag-published' : 'tag-draft'">{{ reply.spec.published ? '已发布' : '未发布' }}</span>
<span class="text-xs text-gray-500">全选</span> <span v-if="reply.spec.isAiConversation" class="custom-tag tag-conv">对话</span>
<span v-if="reply.spec.sentiment" class="custom-tag" :class="'tag-' + reply.spec.sentiment">{{ getSentimentLabel(reply.spec.sentiment) }}</span>
</div> </div>
<div <span class="card-time">{{ formatDate(reply.metadata.creationTimestamp) }}</span>
v-for="reply in replies" </div>
:key="reply.metadata.name" <div class="card-text">{{ stripHtml(reply.spec.reply) || '(空)' }}</div>
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-sm transition-all" </div>
> </div>
<!-- Card body --> <div class="card-footer">
<div class="p-4"> <div class="footer-info">
<div class="flex items-start gap-3"> <span>评分: <strong>{{ reply.spec.score }}</strong></span>
<input <span v-if="reply.spec.postSlug">
type="checkbox" 关联: <a :href="getPostUrl(reply.spec.postSlug)" target="_blank" class="post-link">{{ reply.spec.postSlug }}</a>
:checked="selectedNames.has(reply.metadata.name)"
@change="toggleSelect(reply.metadata.name)"
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
/>
<div class="flex-1 min-w-0">
<!-- Row 1: Status tags + time -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5 flex-wrap">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getStatusClass(reply.spec.status)"
>
{{ getStatusLabel(reply.spec.status) }}
</span>
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
:class="reply.spec.published ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'"
>
{{ reply.spec.published ? '已发布' : '未发布' }}
</span>
<span
v-if="reply.spec.isAiConversation"
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600"
>
对话
</span>
<span
v-if="reply.spec.sentiment"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="getSentimentClass(reply.spec.sentiment)"
>
{{ getSentimentLabel(reply.spec.sentiment) }}
</span> </span>
<span v-if="reply.spec.retryCount > 0" class="retry-text">重试 {{ reply.spec.retryCount }} </span>
</div> </div>
<span class="text-xs text-gray-400 flex-shrink-0 ml-2">{{ formatDate(reply.metadata.creationTimestamp) }}</span> <div class="footer-actions">
</div>
<!-- Row 2: AI reply content (truncated) -->
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3 mt-2">
{{ stripHtml(reply.spec.reply) || '(空)' }}
</div>
</div>
</div>
</div>
<!-- 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="flex items-center gap-4 text-xs text-gray-400">
<span>
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium">{{ reply.spec.score }}</span>
<span class="text-gray-300 ml-0.5">{{ getScoreLabel(reply.spec.score) }}</span>
</span>
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
文章
<a
:href="getPostUrl(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 class="text-gray-600">{{ reply.spec.retryCount }}</span>
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="reply.spec.status === 'PASS' && !reply.spec.published"> <template v-if="reply.spec.status === 'PASS' && !reply.spec.published">
<button <button class="action-btn pass" @click="handleApprove(reply.metadata.name)">通过</button>
class="inline-flex items-center gap-1 text-xs text-green-600 hover:text-green-800 transition-colors px-2 py-1 rounded hover:bg-green-50" <button class="action-btn reject" @click="handleReject(reply.metadata.name)">拒绝</button>
@click="handleApprove(reply.metadata.name)"
>
审核通过
</button>
<button
class="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors px-2 py-1 rounded hover:bg-red-50"
@click="handleReject(reply.metadata.name)"
>
拒绝
</button>
</template> </template>
<button <button class="action-btn view" @click="openConversation(reply)">查看对话</button>
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:text-blue-700 transition-colors px-2 py-1 rounded hover:bg-blue-50" <button class="action-btn delete" @click="handleDelete(reply.metadata.name)">删除</button>
@click="openConversation(reply)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
查看
</button>
<button
class="text-xs text-red-400 hover:text-red-600 transition-colors px-2 py-1 rounded hover:bg-red-50"
@click="handleDelete(reply.metadata.name)"
>
删除
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Pagination --> <div v-if="totalPages > 1" class="pagination">
<div v-if="totalPages > 1" class="flex items-center justify-between mt-4 px-1"> <span> {{ total }} </span>
<span class="text-xs text-gray-400"> {{ total }} </span> <div class="pagination-btns">
<div class="flex gap-2">
<VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton> <VButton size="sm" :disabled="page <= 1" @click="page--">上一页</VButton>
<VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton> <VButton size="sm" :disabled="page >= totalPages" @click="page++">下一页</VButton>
</div> </div>
</div> </div>
</div> </div>
<!-- Conversation Dialog --> <!-- 完整对话弹窗 -->
<teleport to="body"> <teleport to="body">
<div <div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
v-if="showDialog" <div class="dialog-box">
class="fixed inset-0 z-[9999] flex items-center justify-center" <div class="dialog-header">
> <h3>对话上下文</h3>
<!-- Overlay --> <button class="close-btn" @click="showDialog = false"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>
<div
class="absolute inset-0 bg-black/40"
@click="showDialog = false"
></div>
<!-- Dialog -->
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] mx-4 flex flex-col overflow-hidden">
<!-- Dialog header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<h3 class="text-base font-semibold text-gray-800">完整对话</h3>
</div> </div>
<button <div class="dialog-body">
class="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
@click="showDialog = false"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Dialog body: conversation -->
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<VLoading v-if="conversationLoading" /> <VLoading v-if="conversationLoading" />
<div v-else-if="conversationMessages.length === 0" class="empty-state">暂无内容</div>
<div v-else class="chat-container">
<div v-for="(msg, idx) in conversationMessages" :key="idx" class="chat-row" :class="msg.isAi ? 'row-ai' : 'row-user'">
<div class="chat-message">
<div class="chat-owner">{{ msg.owner }}</div>
<div class="chat-bubble" :class="msg.isAi ? 'bubble-ai' : 'bubble-user'">
<div v-else-if="conversationMessages.length === 0" class="text-center text-gray-400 py-8"> <!-- 现代化的精美引用框 (无左边框) -->
暂无对话内容 <div v-if="msg.quoteOwner && msg.quoteContent" class="quote-box">
<div class="quote-header">
<svg class="quote-icon" fill="currentColor" viewBox="0 0 24 24"><path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>
<span class="quote-owner">{{ msg.quoteOwner }}</span>
</div>
<div class="quote-content">{{ truncateQuote(msg.quoteContent) }}</div>
</div> </div>
<div v-else> <div class="chat-text" v-html="renderContent(msg.content)"></div>
<div
v-for="(msg, idx) in conversationMessages"
:key="idx"
class="flex"
:class="msg.isAi ? 'justify-start' : 'justify-end'"
>
<div class="max-w-[80%]">
<!-- Owner name -->
<div
class="text-xs mb-1"
:class="msg.isAi ? 'text-blue-500' : 'text-gray-500'"
>
<span class="font-medium">{{ msg.owner }}</span>
</div> </div>
<!-- Bubble --> <div class="chat-time">{{ formatDate(msg.time) }}</div>
<div
class="rounded-2xl px-4 py-2.5 text-sm leading-relaxed break-words"
:class="msg.isAi
? 'bg-blue-50 text-gray-800 rounded-tl-md'
: 'bg-gray-100 text-gray-800 rounded-tr-md'"
v-html="renderContent(msg.content)"
></div>
<!-- Time -->
<div class="text-[10px] text-gray-300 mt-1" :class="msg.isAi ? 'text-left' : 'text-right'">
{{ formatDate(msg.time) }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Dialog footer -->
<div class="px-5 py-3 border-t border-gray-100 flex justify-end">
<button
class="px-4 py-1.5 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="showDialog = false"
>
关闭
</button>
</div>
</div>
</div> </div>
</teleport> </teleport>
</div> </div>
@@ -309,344 +140,161 @@ import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components" import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components" import { IconPlug } from "@halo-dev/components"
interface AiCommentReplyItem { interface AiCommentReplyItem { metadata: { name: string; creationTimestamp: string }; spec: any }
metadata: { interface ConversationMessage { type: string; owner: string; content: string; time: string; isAi: boolean; quoteOwner?: string; quoteContent?: string }
name: string
creationTimestamp: string
}
spec: {
commentId: string
postId: string
postSlug: string
reply: string
score: number
status: string
retryCount: number
replyTo: string
isAiConversation: boolean
published: boolean
sentiment: string | null
}
}
interface ConversationMessage { const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0);
type: string const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
owner: string const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
content: string const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
time: string
isAi: boolean
}
const replies = ref<AiCommentReplyItem[]>([]) const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const loading = ref(false) const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
const page = ref(1)
const size = ref(20)
const total = ref(0)
const totalPages = ref(0)
// Selection state
const selectedNames = ref<Set<string>>(new Set())
const selectAll = ref(false)
// Filter state
const filterStatus = ref("")
const filterSentiment = ref("")
const filterKeyword = ref("")
const toggleSelect = (name: string) => {
if (selectedNames.value.has(name)) {
selectedNames.value.delete(name)
} else {
selectedNames.value.add(name)
}
// Update selectAll state
selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name))
}
const toggleSelectAll = () => {
if (selectAll.value) {
selectedNames.value.clear()
selectAll.value = false
} else {
selectedNames.value = new Set(replies.value.map(r => r.metadata.name))
selectAll.value = true
}
}
// Conversation dialog state
const showDialog = ref(false)
const conversationLoading = ref(false)
const conversationMessages = ref<ConversationMessage[]>([])
const fetchReplies = async () => { const fetchReplies = async () => {
loading.value = true loading.value = true;
try { try {
const params: Record<string, string | number> = { page: page.value, size: size.value } const params: any = { page: page.value, size: size.value }
if (filterStatus.value) params.status = filterStatus.value if (filterStatus.value) params.status = filterStatus.value; if (filterSentiment.value) params.sentiment = filterSentiment.value; if (filterKeyword.value) params.keyword = filterKeyword.value;
if (filterSentiment.value) params.sentiment = filterSentiment.value const { data } = await axiosInstance.get("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", { params })
if (filterKeyword.value) params.keyword = filterKeyword.value replies.value = data.items || []; total.value = data.total || 0; totalPages.value = Math.ceil(total.value / size.value)
const { data } = await axiosInstance.get( } catch (e) { Toast.error("获取数据失败") } finally { loading.value = false }
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
{ params },
)
replies.value = data.items || []
total.value = data.total || 0
totalPages.value = Math.ceil(total.value / size.value)
} catch (e) {
console.error("Failed to fetch replies", e)
} finally {
loading.value = false
}
} }
const openConversation = async (reply: AiCommentReplyItem) => { const openConversation = async (reply: AiCommentReplyItem) => {
showDialog.value = true showDialog.value = true; conversationLoading.value = true; conversationMessages.value = []
conversationLoading.value = true
conversationMessages.value = []
try { try {
const { data } = await axiosInstance.get( const { data } = await axiosInstance.get(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/conversation/${reply.spec.commentId}`)
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/conversation/${reply.spec.commentId}`,
)
conversationMessages.value = data.messages || [] conversationMessages.value = data.messages || []
} catch (e) { } catch (e) { Toast.error("获取对话失败") } finally { conversationLoading.value = false }
console.error("Failed to fetch conversation", e)
Toast.error("获取对话失败")
} finally {
conversationLoading.value = false
}
} }
const handleDelete = async (name: string) => { const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } }
try { const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } }
await axiosInstance.delete( const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } }
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`, const batchApprove = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
) const batchReject = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
Toast.success("删除成功") const batchDelete = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } }
fetchReplies()
} catch (e) { const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝' }; return m[s] || s }
console.error("Failed to delete reply", e) const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s }
Toast.error("删除失败") const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : ""
} const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}`
const stripHtml = (html: string) => html ? html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim() : ""
const truncateQuote = (content: string, length = 35) => {
if (!content) return ""
let plain = stripHtml(content)
plain = plain.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, '').trim()
return plain.length > length ? plain.substring(0, length) + "..." : plain
} }
const handleApprove = async (name: string) => {
try {
await axiosInstance.post(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`,
)
Toast.success("审核通过")
fetchReplies()
} catch (e) {
console.error("Failed to approve reply", e)
Toast.error("审核操作失败")
}
}
const handleReject = async (name: string) => {
try {
await axiosInstance.post(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`,
)
Toast.success("已拒绝")
fetchReplies()
} catch (e) {
console.error("Failed to reject reply", e)
Toast.error("拒绝操作失败")
}
}
const batchApprove = async () => {
if (selectedNames.value.size === 0) return
try {
await axiosInstance.post(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve",
{ names: Array.from(selectedNames.value) }
)
Toast.success("批量审核通过成功")
selectedNames.value.clear()
selectAll.value = false
fetchReplies()
} catch (e) {
Toast.error("批量审核操作失败")
}
}
const batchReject = async () => {
if (selectedNames.value.size === 0) return
try {
await axiosInstance.post(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject",
{ names: Array.from(selectedNames.value) }
)
Toast.success("批量拒绝成功")
selectedNames.value.clear()
selectAll.value = false
fetchReplies()
} catch (e) {
Toast.error("批量拒绝操作失败")
}
}
const batchDelete = async () => {
if (selectedNames.value.size === 0) return
try {
await axiosInstance.post(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete",
{ names: Array.from(selectedNames.value) }
)
Toast.success("批量删除成功")
selectedNames.value.clear()
selectAll.value = false
fetchReplies()
} catch (e) {
Toast.error("批量删除操作失败")
}
}
const getScoreClass = (score: number) => {
if (score >= 85) return "text-green-600"
if (score >= 60) return "text-yellow-600"
return "text-red-600"
}
const getScoreLabel = (score: number) => {
if (score >= 85) return "优秀"
if (score >= 70) return "良好"
if (score >= 50) return "一般"
if (score > 0) return "较差"
return ""
}
const getStatusClass = (status: string) => {
switch (status) {
case "PASS":
return "bg-green-100 text-green-700"
case "FAIL":
return "bg-red-100 text-red-700"
case "PENDING":
return "bg-gray-100 text-gray-700"
case "REJECTED":
return "bg-red-100 text-red-700"
default:
return "bg-gray-100 text-gray-700"
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case "PASS":
return "通过"
case "FAIL":
return "失败"
case "PENDING":
return "待处理"
case "REJECTED":
return "已拒绝"
default:
return status
}
}
const getSentimentClass = (sentiment: string) => {
switch (sentiment) {
case "POSITIVE":
return "bg-green-50 text-green-600"
case "NEGATIVE":
return "bg-red-50 text-red-600"
case "NEUTRAL":
return "bg-gray-100 text-gray-600"
default:
return "bg-gray-100 text-gray-600"
}
}
const getSentimentLabel = (sentiment: string) => {
switch (sentiment) {
case "POSITIVE":
return "正面"
case "NEGATIVE":
return "负面"
case "NEUTRAL":
return "中性"
default:
return sentiment
}
}
const formatDate = (timestamp: string) => {
if (!timestamp) return ""
return new Date(timestamp).toLocaleString("zh-CN")
}
const getPostUrl = (slug: string) => {
return `${window.location.origin}/archives/${slug}`
}
/**
* Strip HTML tags for plain text display (card preview)
*/
const stripHtml = (html: string) => {
if (!html) return ""
return html
.replace(/<p[^>]*>/gi, "")
.replace(/<\/p>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim()
}
/**
* Sanitize and render HTML content for conversation bubbles.
* Only allows safe inline tags, strips dangerous elements.
*/
const renderContent = (content: string) => { const renderContent = (content: string) => {
if (!content) return "<span class='text-gray-400'>(空)</span>" if (!content) return "<span style='opacity:0.5'>(空)</span>"
return content let parsed = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "") parsed = parsed.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[:]\s*/gm, "")
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "") return parsed.replace(/\n/g, "<br/>")
.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "")
.replace(/<embed[^>]*>/gi, "")
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, "")
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, "")
.replace(/on\w+\s*=\s*[^\s>]*/gi, "")
.replace(/<p[^>]*>/gi, "<p style='margin:0 0 0.5em 0'>")
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
} }
const resetFilters = () => { const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
filterStatus.value = "" watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
filterSentiment.value = "" watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
filterKeyword.value = ""
page.value = 1
fetchReplies()
}
watch([filterStatus, filterSentiment, filterKeyword], () => {
page.value = 1
fetchReplies()
})
watch(page, () => {
selectedNames.value.clear()
selectAll.value = false
fetchReplies()
})
onMounted(fetchReplies) onMounted(fetchReplies)
</script> </script>
<style scoped> <style scoped>
.line-clamp-2 { /* 全局基础布局 */
display: -webkit-box; .logs-container { padding-bottom: 20px; }
-webkit-line-clamp: 2; .header-icon { margin-right: 8px; align-self: center; }
-webkit-box-orient: vertical;
overflow: hidden; /* 响应式工具栏 */
} .toolbar { display: flex; flex-direction: column; gap: 12px; margin: 16px; align-items: stretch; }
.line-clamp-3 { @media (min-width: 768px) { .toolbar { flex-direction: row; align-items: center; } }
display: -webkit-box; .batch-toolbar { background: #eff6ff; padding: 12px 16px; border-radius: 8px; border: 1px solid #bfdbfe; }
-webkit-line-clamp: 3; .batch-text { font-size: 14px; color: #1d4ed8; font-weight: bold; }
-webkit-box-orient: vertical; .batch-actions { display: flex; flex-wrap: wrap; gap: 8px; width: 100%; }
overflow: hidden; @media (min-width: 768px) { .batch-actions { width: auto; margin-left: auto; } }
} .btn-batch { padding: 6px 12px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; color: #fff; white-space: nowrap; }
.btn-pass { background: #16a34a; } .btn-reject { background: #f97316; } .btn-delete { background: #dc2626; }
.btn-cancel { background: transparent; color: #6b7280; border: 1px solid #d1d5db; }
.filter-select, .filter-input { width: 100%; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; outline: none; }
@media (min-width: 768px) { .filter-select { width: auto; min-width: 120px; } .filter-input { flex: 1; } }
.btn-reset { padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; cursor: pointer; white-space: nowrap; width: 100%; }
@media (min-width: 768px) { .btn-reset { width: auto; } }
/* 列表区 */
.list-area { margin: 16px; }
.empty-state { text-align: center; padding: 60px 0; color: #9ca3af; font-size: 14px; }
.reply-list { display: flex; flex-direction: column; gap: 16px; }
.select-all-wrap { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #6b7280; padding: 0 4px; }
.reply-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,0.02); }
.card-main { display: flex; gap: 12px; padding: 16px; align-items: flex-start; }
.card-main input { margin-top: 4px; }
.card-content { flex: 1; min-width: 0; }
.card-header { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
@media (min-width: 640px) { .card-header { flex-direction: row; justify-content: space-between; align-items: center; } }
.tags-wrap { display: flex; gap: 6px; flex-wrap: wrap; }
.card-time { font-size: 12px; color: #9ca3af; }
.card-text { font-size: 14px; color: #374151; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
@media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } }
.footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; }
.post-link { color: #3b82f6; text-decoration: none; } .post-link:hover { text-decoration: underline; }
.retry-text { color: #f59e0b; }
.footer-actions { display: flex; flex-wrap: wrap; gap: 8px; width: 100%; justify-content: flex-end; }
@media (min-width: 640px) { .footer-actions { width: auto; } }
.action-btn { padding: 4px 10px; border-radius: 4px; font-size: 12px; border: none; cursor: pointer; white-space: nowrap; }
.action-btn.pass { background: #dcfce7; color: #16a34a; }
.action-btn.reject { background: #fee2e2; color: #dc2626; }
.action-btn.view { background: #dbeafe; color: #2563eb; }
.action-btn.delete { background: #e5e7eb; color: #4b5563; }
/* 标签体系 */
.custom-tag { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; }
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; }
.tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; }
.tag-conv { background: #f3e8ff; color: #7e22ce; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
/* 对话弹窗与响应式气泡 */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(2px); padding: 16px; box-sizing: border-box; }
.dialog-box { width: 100%; max-width: 600px; background: #fff; border-radius: 16px; display: flex; flex-direction: column; max-height: 90vh; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #f3f4f6; }
.dialog-header h3 { margin: 0; font-size: 16px; font-weight: bold; }
.close-btn { background: none; border: none; cursor: pointer; color: #9ca3af; padding: 0; display: flex; align-items: center; justify-content: center; }
.close-btn:hover { color: #4b5563; }
.dialog-body { padding: 20px; overflow-y: auto; background: #f8fafc; flex: 1; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; }
.chat-container { display: flex; flex-direction: column; gap: 20px; }
.chat-row { display: flex; width: 100%; }
.row-ai { justify-content: flex-start; }
.row-user { justify-content: flex-end; }
.chat-message { max-width: 90%; display: flex; flex-direction: column; }
@media (min-width: 640px) { .chat-message { max-width: 75%; } }
.chat-owner { font-size: 12px; margin-bottom: 6px; font-weight: bold; }
.row-ai .chat-owner { color: #2563eb; text-align: left; margin-left: 4px; }
.row-user .chat-owner { color: #6b7280; text-align: right; margin-right: 4px; }
.chat-time { font-size: 11px; color: #9ca3af; margin-top: 6px; }
.row-ai .chat-time { text-align: left; margin-left: 4px; }
.row-user .chat-time { text-align: right; margin-right: 4px; }
/* 气泡样式 */
.chat-bubble { padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.6; word-wrap: break-word; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.bubble-ai { background: #ffffff; color: #1f2937; border: 1px solid #e2e8f0; border-top-left-radius: 2px; }
.bubble-user { background: #2563eb; color: #ffffff; border-top-right-radius: 2px; }
/* 全新精美引用框 (无左黑条,微信风格) */
.quote-box { margin-bottom: 8px; padding: 8px 10px; border-radius: 8px; font-size: 12px; display: block; width: 100%; box-sizing: border-box; }
.bubble-ai .quote-box { background: rgba(0,0,0,0.04); color: #6b7280; }
.bubble-user .quote-box { background: rgba(255,255,255,0.15); color: #d1d5db; }
.quote-header { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
.quote-icon { width: 12px; height: 12px; opacity: 0.7; }
.quote-owner { font-weight: 600; font-size: 11px; }
.bubble-ai .quote-owner { color: #374151; }
.bubble-user .quote-owner { color: #ffffff; }
.quote-content { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; }
.pagination { display: flex; flex-direction: column; gap: 12px; align-items: center; margin-top: 20px; font-size: 14px; color: #6b7280; }
@media (min-width: 640px) { .pagination { flex-direction: row; justify-content: space-between; } }
.pagination-btns { display: flex; gap: 8px; }
</style> </style>
File diff suppressed because it is too large Load Diff