Author SHA1 Message Date
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
sunny-335 e6839b4aad fix: auto-publish log showing unpublished, merge changelog, version to 1.0.0-beta.2 2026-06-17 23:13:45 +08:00
sunny-335 c3e003a572 feat: streamline dashboard, fix docs, optimize UI width
- Remove AI角色 quick action, keep only 回复日志/插件设置/刷新数据
- Fix LogsView filter/batch bar width to match content area (mx-4 mt-2 -> m-4 mb-0)
- Fix docs: Halo version 2.23->2.25, GitHub link nxxy335->sunny-335, Cravatar link, variable name {{conversation}}->{{conversation_history}}
- Add missing settings docs (maxConversationRounds, rateLimitPerMinute, enabledPresets)
- Update README: remove version display, update changelog
- Update version to 1.0.0-beta.2-r8k4n2
2026-06-17 22:03:35 +08:00
sunny-335 be6a01a06e fix: logs filter dropdown text overlapping with caret
- Change select padding from px-3 to pl-3 pr-8 to reserve space for the native dropdown arrow
- Update version to 1.0.0-beta.2-q9x3m7
2026-06-17 21:10:06 +08:00
sunny-335 767efdccbb feat: v1.0.0-beta.2 - ExtensionGetter integration, UI revamp, bug fixes
- Replace cross-ClassLoader reflection with ExtensionGetter.getEnabledExtension(AiModelService.class)
- Declare optional pluginDependencies (ai-foundation?: "*") and recommended-apps annotation
- Use OutputSpec.choice for structured classification (sentiment, review safety/quality)
- Use GenerateTextRequest with maxRetries=2 for reliable chat generation
- Add multi-turn conversation context (conversation_history placeholder)
- Fix RateLimitService thread leak (implement DisposableBean)
- Two-stage AI review: safety check + 1-5 quality score mapped to 0-100
- Remove redundant dashboard cards (sentiment distribution, 7-day trend, avg score)
- Redesign settings page with tabbed navigation (basic/persona/model/prompt/cleanup)
- Fix settings layout (move tab bar out of grid container)
- Fix button icon+text alignment via :deep(.btn-content) inline-flex
- Add review score grade labels (excellent/good/fair/poor) in logs
2026-06-17 19:40:10 +08:00
sunny-335 bf989ca3f2 docs: update README with changelog and fix license link, add CHANGELOG.md 2026-06-17 13:26:18 +08:00
40 changed files with 2569 additions and 1407 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
+16 -10
View File
@@ -4,10 +4,11 @@
## 功能特性 ## 功能特性
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 - **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色
- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复 - **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
- **失败重试** — AI 生成失败时自动重试,指数退避策略 - **失败重试** — AI 生成失败时自动重试,指数退避策略
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
@@ -15,25 +16,30 @@
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用 AI 回复,文章默认开启,页面默认关闭 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用 AI 回复,文章默认开启,页面默认关闭
- **评论者黑名单** — 支持按名称、邮箱和正则表达式屏蔽指定评论者,可从评论列表选择 - **评论者黑名单** — 支持按名称、邮箱和正则表达式屏蔽指定评论者,可从评论列表选择
- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复 - **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝 - **两阶段安全审核** — 安全检查 + 质量评分(1-5 分映射到 0-100 分),不合规内容自动拒绝
- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等) - **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数、对话历史等)
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 - **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性 - **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
- **日志筛选** — 按状态、情感筛选,关键词搜索 - **日志筛选** — 按状态、情感筛选,关键词搜索
- **数据清理** — 自动清理超过指定天数的旧记录 - **数据清理** — 自动清理超过指定天数的旧记录
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力 - **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
## 前置要求 ## 前置要求
- Halo 2.23+ - Halo 2.25+
- AI Foundation 插件(必须) - AI Foundation 插件(必须)
## 安装 ## 安装
### 应用商店安装
进入 **插件****安装** → 应用市场搜索 **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. 安装完成后启用插件
@@ -64,8 +70,8 @@ pnpm dev
## 文档 ## 文档
完整文档请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot) 完整文档及更新日志请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot)
## 许可证 ## 许可证
[GPL-3.0](./LICENSE) © 暖心向阳335 [GPL-3.0](https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE) © 暖心向阳335
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
} }
group 'top.nxxy335.commentaiautopilot' group 'top.nxxy335.commentaiautopilot'
version '1.0.0-beta.1' version '1.0.0'
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: {
+106
View File
@@ -0,0 +1,106 @@
# 更新日志
## 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
> 2026-06-17
### 改进
- **改用 ExtensionGetter 集成 AI Foundation**:通过 Halo 官方推荐的 `ExtensionGetter.getEnabledExtension(AiModelService.class)` 获取 AI 服务,替换原先的跨 ClassLoader 反射调用方式([Issue #1](https://github.com/sunny-335/plugin-comment-ai-autopilot/issues/1)
- **声明插件依赖**:在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系,插件在未安装 AI Foundation 时仍可正常加载
- **应用市场推荐**:新增 `store.halo.run/recommended-apps` 注解,安装本插件后可在应用市场推荐安装 AI Foundation 插件
- **使用结构化输出**:情感分析和内容审核改用 AI Foundation 的 `OutputSpec.choice` 结构化输出,替换原先的字符串匹配解析,分类更可靠
- **使用 GenerateTextRequest**AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误
- **多轮对话上下文**:AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文
- **优化 AI 自审核评分机制**:审核改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分(0/30/50/70/85/100),替代原先的二值评分(0/100),评分更有区分度
- **精简仪表盘**:移除情感分布、近7日回复趋势、平均审核评分三个卡片,快捷操作精简为回复日志、插件设置、刷新数据
- **重做设置页面**:基本设置、AI角色设置、模型设置、Prompt设置、数据清理各为独立页面,通过标签栏切换
- **优化设置页面布局**:按钮统一排版并添加图标,侧边栏保存卡片高亮显示,新增"未保存"状态指示器
- **优化日志页面**:评分增加等级标签(优秀/良好/一般/较差),筛选下拉框修复文本与箭头重叠
- **优化 AI Foundation 状态提示宽度**:状态提示与内容区宽度一致
- **优化插件文档**:修复版本要求(2.23→2.25)、变量名(`{{conversation}}``{{conversation_history}}`)、GitHub 链接(`nxxy335``sunny-335`)、Cravatar 链接(`cravatar.cn``cn.cravatar.com`)等错误,补充缺失的配置项文档
### Bug 修复
- **修复自动发布后日志显示未发布**:`generateAndPublish``Mono<Void>` 的 empty 信号触发 `switchIfEmpty` 导致 `publishReply` 被调用两次,第二次覆盖 `published=false`
- **修复 RateLimitService 线程泄漏**:清理线程未在插件停止时关闭,实现 `DisposableBean` 正确释放资源
- **修复 ReviewService 提示词不匹配**:审核提示词要求"重新生成"但代码未使用重新生成的内容,移除误导性指令
- **修复设置页面按钮图标文字对齐**:通过 `:deep(.btn-content)` 设置 inline-flex 布局,图标和文字并排显示
- **修复设置页面标签栏无法点击切换**:替换不工作的 VTabbar 组件为自定义按钮实现
- **修复设置页面配置区域布局错误**:将标签栏移出 grid 容器,避免挤占配置区域宽度
- **修复日志页面筛选下拉框文本与箭头重叠**:将 `px-3` 改为 `pl-3 pr-8` 为下拉箭头预留空间
---
## v1.0.0-beta.1
> 2026-06-05
### 新功能
- **自动回复**:监听新评论,自动调用 AI 生成回复
- **多语言适配**:根据评论语言自动用对应语言回复
- **情感分析**:分析评论情感倾向,根据情感调整回复语气
- **草稿模式**:关闭"自动发布"后,AI 回复将保存为草稿,需站长审核后才发布
- **多 AI 角色支持**:支持配置多个 AI 虚拟角色,每个角色有独立的提示词、头像和模型
- **提示词预设**:内置多种回复风格预设(专业型、幽默型、简洁型等),可自由组合
- **文章/页面级开关**:在文章编辑器中直接控制是否启用 AI 回复
- **评论者黑名单**:屏蔽指定评论者,不触发 AI 回复
- **安全审核**:AI 生成的内容经过安全审核,不合规内容自动拒绝
- **Prompt 模板**:支持自定义 Prompt 模板,提供多种模板变量
- **仪表盘统计**:显示回复数等统计信息
- **日志筛选搜索**:按状态、情感筛选,关键词搜索
- **数据清理**:自动清理超过指定天数的旧记录
- **AI Foundation 集成**:使用 AI Foundation 插件提供的 AI 模型能力
- **对话历史查看**:支持查看 AI 回复的完整对话上下文
### Bug 修复
- 修复草稿模式下审批失败("AI回复已存在,无法重复发布")的问题
- 修复批量审批时同样的去重检查冲突问题
- 修复 AI Foundation 不可用的问题(`PluginManager` 无法通过 Spring 依赖注入获取)
- 修复 `DefaultSpringPlugin` 包级私有类反射访问权限问题
- 修复 CI 构建失败(`gradlew` 缺少执行权限)
### 改进
- 审批逻辑优化:先查找已有 Reply 扩展再决定创建或更新
- 移除不必要的 `AiFoundationConfiguration` 配置类
- 前端 UI 优化:移除编辑功能、简化角色排序逻辑、清理无用代码
+28
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的回复更连贯自然。
@@ -31,6 +57,8 @@
|--------|------|--------| |--------|------|--------|
| 自动回复 | 是否启用自动回复功能 | 开启 | | 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
| 速率限制 | 每分钟最大AI回复数量 | 10 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
## 重试机制 ## 重试机制
+1 -1
View File
@@ -12,7 +12,7 @@
1. 确认在插件设置中填写了AI角色邮箱 1. 确认在插件设置中填写了AI角色邮箱
2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像 2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像
3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务 3. 插件使用 [Cravatar](https://cn.cravatar.com) 作为Gravatar镜像服务
## 评论没有触发AI回复? ## 评论没有触发AI回复?
+11 -3
View File
@@ -32,9 +32,17 @@
1. 进入插件设置页面 1. 进入插件设置页面
2.**基本设置** 中找到 **评论者黑名单** 2.**基本设置** 中找到 **评论者黑名单**
3. 输入评论者的显示名称邮箱,多个用逗号分隔 3. 输入评论者的显示名称邮箱或正则表达式,多个用逗号分隔
4. 保存设置 4. 保存设置
### 支持的格式
| 格式 | 示例 | 说明 |
|------|------|------|
| 名称 | `张三` | 匹配评论者的显示名称 |
| 邮箱 | `spam@example.com` | 匹配评论者邮箱(不区分大小写) |
| 正则表达式 | `regex:^spam.*` | 以 `regex:` 开头,按正则匹配 |
### 从评论列表选择 ### 从评论列表选择
1. 在黑名单输入框旁点击 **添加评论者** 按钮 1. 在黑名单输入框旁点击 **添加评论者** 按钮
@@ -45,7 +53,7 @@
### 示例 ### 示例
``` ```
张三,spam@example.com,李四 张三, spam@example.com, 李四, regex:^spam.*
``` ```
黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。 黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式则按模式匹配,匹配成功则跳过AI回复。
+11 -7
View File
@@ -2,24 +2,28 @@
## 前置要求 ## 前置要求
- Halo 2.23+ - Halo 2.25+
- AI Foundation 插件(必须) — 本插件依赖 AI Foundation 提供的AI模型能力,请先安装并配置 AI Foundation - AI Foundation 插件(必须) — 本插件通过 `ExtensionGetter` 调用 AI Foundation 提供的 `AiModelService` 扩展点,请先安装并配置 AI Foundation
## 安装 ## 安装
### 方式一:从 Release 下载 ### 方式一:应用商店安装
1. 前往 [GitHub Releases](https://github.com/nxxy335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 进入 **插件****安装** → 应用市场搜索 **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` 文件
2. 登录 Halo 管理后台 2. 登录 Halo 管理后台
3. 进入 **插件****安装**点击右上角 **安装** 按钮 3. 进入 **插件****安装****本地上传**
4. 选择下载的 `.jar` 文件上传 4. 选择下载的 `.jar` 文件上传
5. 安装完成后启用插件 5. 安装完成后启用插件
### 方式:从源码构建 ### 方式:从源码构建
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://github.com/nxxy335/plugin-comment-ai-autopilot.git git clone https://github.com/sunny-335/plugin-comment-ai-autopilot.git
cd plugin-comment-ai-autopilot cd plugin-comment-ai-autopilot
# 构建 # 构建
+22 -17
View File
@@ -5,45 +5,50 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
## 核心功能 ## 核心功能
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色
- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI
- **性别与语气** — AI角色支持性别配置(男/女)和中性语气风格,AI回复时保持对应性别身份
- **多语言适配** — 根据评论语言自动用对应语言回复 - **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布 - **草稿模式** — AI回复先存为草稿,管理员审核后再发布
- **失败重试** — AI生成失败时自动重试,指数退避策略 - **失败重试** — AI生成失败时自动重试,指数退避策略
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **批量操作** — 草稿模式下支持批量通过/拒绝/删除
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复 - **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像 - **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 - **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索 - **日志筛选搜索** — 按状态、情感筛选,关键词搜索
- **数据清理** — 自动清理超过指定天数的旧记录 - **数据清理** — 自动清理超过指定天数的旧记录
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 - **AI Foundation 集成** — 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI 服务,需安装 AI Foundation 插件
## 工作流程 ## 工作流程
``` ```
新评论 → 过滤检查 → 情感分析 → 构建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. **安全审核**对生成内容进行安全审核 6. **AI生成**调用AI模型生成回复内容
7. **发布/草稿**根据设置自动发布或存为草稿等待审核 7. **安全审核**对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100)
8. **重试**如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增 8. **发布/草稿**根据设置自动发布或存为草稿等待审核
9. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
## 前置要求 ## 前置要求
- Halo 2.23+ - Halo 2.25+
- AI Foundation 插件(必须) — 本插件依赖 AI Foundation 提供的AI模型能力 - AI Foundation 插件(必须) — 本插件通过 `ExtensionGetter` 调用 AI Foundation 提供的 `AiModelService` 扩展点
## 技术栈 ## 技术栈
- **后端**Java + Spring WebFlux + Reactive - **后端**Java + Spring WebFlux + Reactive
- **前端**Vue 3 + @halo-dev/components - **前端**Vue 3 + @halo-dev/components
- **AI**支持 AI Foundation 插件集成 - **AI**通过 AI Foundation 插件集成
+24 -2
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角色的性格和回复风格,是影响回复质量的关键配置。
@@ -31,7 +53,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成: 填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成:
1. 插件根据邮箱生成 SHA-256 哈希 1. 插件根据邮箱生成 SHA-256 哈希
2. 构造 Gravatar URL`https://cn.cravatar.com/avatar/{hash}` 2. 构造 Gravatar URL`https://cn.cravatar.com/avatar/{hash}`(使用 [Cravatar](https://cn.cravatar.com) 镜像服务)
3. 头像URL存储在评论的 `owner.annotations["avatar"]` 3. 头像URL存储在评论的 `owner.annotations["avatar"]`
::: warning ::: warning
+28 -24
View File
@@ -9,6 +9,8 @@ Prompt模板控制AI生成回复时的完整提示词结构。
{{safety_prompt}} {{safety_prompt}}
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
请回复以下评论。注意: 请回复以下评论。注意:
- 回复长度应与评论长度匹配,简短问候简短回复 - 回复长度应与评论长度匹配,简短问候简短回复
- 不要复述或总结文章内容 - 不要复述或总结文章内容
@@ -21,6 +23,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
文章(仅供理解上下文,不要复述): 文章(仅供理解上下文,不要复述):
{{article}} {{article}}
{{conversation_history}}
评论: 评论:
{{comment}} {{comment}}
``` ```
@@ -29,23 +32,26 @@ Prompt模板控制AI生成回复时的完整提示词结构。
| 变量 | 说明 | 注入时机 | | 变量 | 说明 | 注入时机 |
|------|------|---------| |------|------|---------|
| `{{persona_prompt}}` | AI角色人格提示词 | 始终注入 | | `{{persona_prompt}}` | AI角色人格提示词(含已启用的预设) | 始终注入 |
| `{{safety_prompt}}` | 安全规范提示词 | 始终注入 | | `{{safety_prompt}}` | 安全规范提示词 | 始终注入 |
| `{{sentiment_prompt}}` | 情感语气提示词 | 情感分析后自动注入,不在模板中显式使用 |
| `{{post_title}}` | 文章标题 | 始终注入 | | `{{post_title}}` | 文章标题 | 始终注入 |
| `{{post_date}}` | 文章发布日期(如 2024-01-15) | 始终注入 | | `{{post_date}}` | 文章发布日期(如 2024-01-15) | 始终注入 |
| `{{comment_count}}` | 该文章的评论数 | 始终注入 | | `{{comment_count}}` | 该文章的评论数 | 始终注入 |
| `{{article}}` | 文章/页面内容(含标题) | 始终注入 | | `{{article}}` | 文章/页面内容(含标题) | 始终注入 |
| `{{conversation_history}}` | 对话历史上下文 | 多轮对话时注入 |
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 | | `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
::: warning 变量名注意
对话上下文变量是 `{{conversation_history}}`(不是 `{{conversation}}`)。如果模板中使用了错误的变量名,该变量不会被替换。
:::
## 情感提示 ## 情感提示
情感提示由插件根据情感分析结果自动注入到Prompt,不需要在模板中手动添加: 情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
- **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。" - **正面** → 追加"评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。"
- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。" - **负面** → 追加"评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。"
- **中性** → 不注入额外提示 - **中性** → 不追加额外提示
## 安全提示 ## 安全提示
@@ -56,6 +62,17 @@ Prompt模板控制AI生成回复时的完整提示词结构。
- 不生成虚假信息 - 不生成虚假信息
- 回复内容与评论相关 - 回复内容与评论相关
## 预设风格
在 Prompt 设置页面可以多选启用预设风格,启用后预设提示词会自动合并到 `{{persona_prompt}}` 之后:
| 预设 | 说明 |
|------|------|
| 友好型 | 热情友好,多用感叹号和表情符号,口语化表达 |
| 专业型 | 专业严谨,正式语言风格,有逻辑性 |
| 幽默型 | 适当加入幽默元素,轻松诙谐但不过度 |
| 简洁型 | 非常简洁,一两句话即可,不展开讨论 |
## 自定义建议 ## 自定义建议
自定义Prompt模板时,建议: 自定义Prompt模板时,建议:
@@ -63,20 +80,7 @@ Prompt模板控制AI生成回复时的完整提示词结构。
1. 保留 `{{persona_prompt}}``{{safety_prompt}}` 变量 1. 保留 `{{persona_prompt}}``{{safety_prompt}}` 变量
2. 保留 `{{article}}``{{comment}}` 变量 2. 保留 `{{article}}``{{comment}}` 变量
3. 利用 `{{post_title}}``{{post_date}}``{{comment_count}}` 提供更丰富的上下文 3. 利用 `{{post_title}}``{{post_date}}``{{comment_count}}` 提供更丰富的上下文
4. 在变量之间添加清晰的分隔和指令 4. 保留 `{{conversation_history}}` 以支持多轮对话上下文
5. 避免让AI复述文章内容 5. 在变量之间添加清晰的分隔和指令
6. 控制回复长度和风格 6. 避免让AI复述文章内容
7. 控制回复长度和风格
## 变量使用示例
### 根据评论数调整回复风格
```
{{comment_count}}条评论说明这篇文章{{#if comment_count > 10}}很受欢迎{{/if}}。
```
### 利用发布日期
```
这篇文章发布于{{post_date}},回复时请考虑时效性。
```
+11 -7
View File
@@ -6,25 +6,29 @@
| 分类 | 说明 | AI回复语气 | | 分类 | 说明 | AI回复语气 |
|------|------|-----------| |------|------|-----------|
| 正面 | 评论情绪积极、友好、感谢 | 热情友好,表达感谢和共鸣 | | 非常正面 | 强烈的感谢、赞美、认同(如"太棒了"、"非常感谢" | 热情洋溢,表达真诚的感谢和共鸣 |
| 中性 | 评论情绪平淡、普通提问 | 正常语气回复,不加额外提示 | | 正面 | 友好、肯定、支持(如"不错"、"学习了") | 热情友好,表达感谢和共鸣 |
| 负面 | 评论情绪偏消极、不满、批评 | 理性温和,避免激化矛盾 | | 中性 | 提问、讨论、陈述事实(如"请问..."、"这个怎么用") | 正常语气回复,不加额外提示 |
| 负面 | 不满、质疑、批评(如"不好用"、"有问题" | 理性温和,避免激化矛盾 |
| 非常负面 | 攻击、辱骂、极端情绪(如"垃圾"、"骗子") | 非常温和理性,避免激化矛盾,展现理解和耐心 |
## 工作原理 ## 工作原理
1. 评论通过过滤检查后,调用AI对评论内容进行情感分析 1. 评论通过过滤检查后,调用AI对评论内容进行情感分析
2. AI返回情感分类结果(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` 字段中
## 日志展示 ## 日志展示
在AI回复日志页面,每条记录会显示情感标签: 在AI回复日志页面,每条记录会显示情感标签:
- 🟢 **正面** — 绿色标签 - 🟢 **非常正面**绿色标签
- 🟩 **正面** — 浅绿色标签
-**中性** — 灰色标签 -**中性** — 灰色标签
- 🔴 **负面** — 红色标签 - 🟥 **负面**红色标签
- 🔴 **非常负面** — 深红色标签
## 性能影响 ## 性能影响
+64 -15
View File
@@ -1,30 +1,52 @@
# 插件设置 # 插件设置
插件设置页面位于 **AI回评****插件设置**包含以下配置组 插件设置页面位于 **AI回评****插件设置**通过标签栏切换以下五个配置页面
- 基本设置
- AI角色设置
- 模型设置
- Prompt设置
- 数据清理
## 基本设置 ## 基本设置
| 配置项 | 说明 | 默认值 | | 配置项 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|
| 自动回复 | 是否启用自动回复功能 | 开启 | | 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 |
| 速率限制 | 每分钟最大AI回复数量,防止批量评论消耗过多额度 | 10 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
| 评论者黑名单 | 不触发AI回复的评论者显示名称邮箱,逗号分隔 | 空 | | 评论者黑名单 | 不触发AI回复的评论者,支持名称邮箱和正则表达式(`regex:` 开头),逗号分隔 | 空 |
::: tip 评论者黑名单
黑名单支持三种格式:
- **名称**:如 `张三`
- **邮箱**:如 `spam@example.com`(不区分大小写)
- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*`
点击"添加评论者"按钮可从已有评论列表中选择评论者自动添加到黑名单。
:::
## AI角色设置 ## AI角色设置
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。
| 配置项 | 说明 | 默认值 | | 配置项 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|
| AI角色昵称 | AI回复者的显示名称 | 小回 | | 角色昵称 | AI回复者的显示名称 | 小回 |
| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 | | 性别与语气 | 角色性别(男/女)+ 中性语气复选框(勾选=中性语气,取消勾选=跟随性别语气) | 女 + 中性语气 |
| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 | | 唤醒词 | 评论以此词开头则唤醒该角色回复,留空不启用 | 空 |
| 人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
| 邮箱 | 用于 Gravatar 头像服务展示头像 | 空 |
| 设为默认 | 将该角色设为默认角色 | 第一个角色默认 |
默认人格提示词: 默认人格提示词:
> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。 > 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
::: tip Gravatar头像 ::: tip Gravatar头像
填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。 填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成,使用 [Cravatar](https://cn.cravatar.com) 镜像。如果不填写邮箱,将使用默认头像。
::: :::
## 模型设置 ## 模型设置
@@ -42,17 +64,40 @@
| 配置项 | 说明 | 默认值 | | 配置项 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|
| 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 | | 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 |
| 启用预设 | 选择要启用的Prompt预设风格(可多选) | 空 |
### 预设风格
| 预设 | 说明 |
|------|------|
| 友好型 | 热情友好,多用感叹号和表情符号,口语化表达 |
| 专业型 | 专业严谨,正式语言风格,有逻辑性 |
| 幽默型 | 适当加入幽默元素,轻松诙谐但不过度 |
| 简洁型 | 非常简洁,一两句话即可,不展开讨论 |
预设提示词会自动合并到角色人格提示词之后。
### 模板变量 ### 模板变量
| 变量 | 说明 | | 变量 | 说明 | 注入时机 |
|------|------| |------|------|---------|
| `{{persona_prompt}}` | AI角色人格提示词 | | `{{persona_prompt}}` | AI角色人格提示词(含已启用的预设) | 始终注入 |
| `{{safety_prompt}}` | 安全规范提示词 | | `{{safety_prompt}}` | 安全规范提示词 | 始终注入 |
| `{{sentiment_prompt}}` | 情感语气提示词(自动注入 | | `{{post_title}}` | 文章标题 | 始终注入 |
| `{{article}}` | 文章内容 | | `{{post_date}}` | 文章发布日期(如 2024-01-15) | 始终注入 |
| `{{comment}}` | 评论内容 | | `{{comment_count}}` | 该文章的评论数 | 始终注入 |
| `{{conversation}}` | 对话上下文(多轮对话时) | | `{{article}}` | 文章/页面内容(含标题) | 始终注入 |
| `{{conversation_history}}` | 对话历史上下文 | 多轮对话时注入 |
| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
::: tip 情感提示
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
- **非常正面** → 追加热情洋溢的语气提示
- **正面** → 追加热情友好的语气提示
- **负面** → 追加理性温和的语气提示
- **非常负面** → 追加冷静关怀的语气提示
- **中性** → 不追加额外提示
:::
## 数据清理 ## 数据清理
@@ -64,3 +109,7 @@
::: tip ::: tip
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。 你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
::: :::
::: warning
清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。
:::
@@ -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 -> {
@@ -216,100 +225,25 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
} }
private Mono<ServerResponse> getStats(ServerRequest request) { private Mono<ServerResponse> getStats(ServerRequest request) {
String range = request.queryParam("range").orElse("7");
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
.collectList() .collectList()
.map(allReplies -> { .map(replies -> {
// 根据 range 计算截止时间
ZoneId zoneId = ZoneId.systemDefault();
LocalDate today = LocalDate.now(zoneId);
Instant cutoffInstant;
int trendDays;
if ("all".equals(range)) {
cutoffInstant = null; // 不做时间过滤
trendDays = 30; // "all" 时趋势也展示最近30天
} else {
int days = Integer.parseInt(range);
cutoffInstant = today.minusDays(days).atStartOfDay(zoneId).toInstant();
trendDays = days;
}
// 根据 range 过滤记录
List<AiCommentReply> replies;
if (cutoffInstant != null) {
replies = allReplies.stream()
.filter(r -> {
Instant ts = r.getMetadata().getCreationTimestamp();
return ts != null && !ts.isBefore(cutoffInstant);
})
.toList();
} else {
replies = allReplies;
}
long total = replies.size(); long total = replies.size();
long passCount = replies.stream() long passCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count(); .filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
long failCount = replies.stream() long failCount = replies.stream()
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count(); .filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
double avgScore = replies.stream()
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
.mapToInt(r -> r.getSpec().getScore())
.average().orElse(0.0);
long reviewingCount = replies.stream() long reviewingCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus()) .filter(r -> "PASS".equals(r.getSpec().getStatus())
&& !Boolean.TRUE.equals(r.getSpec().getPublished())) && !Boolean.TRUE.equals(r.getSpec().getPublished()))
.count(); .count();
Map<String, Long> sentimentDistribution = new HashMap<>(); return new StatsResponse(total, passCount, failCount, reviewingCount);
sentimentDistribution.put("POSITIVE", 0L);
sentimentDistribution.put("NEUTRAL", 0L);
sentimentDistribution.put("NEGATIVE", 0L);
sentimentDistribution.put("UNKNOWN", 0L);
for (var r : replies) {
String sentiment = r.getSpec().getSentiment();
if (sentiment == null || sentiment.isBlank()) {
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
} else {
sentimentDistribution.merge(sentiment, 1L, Long::sum);
}
}
// 计算 dailyTrend
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
Map<LocalDate, Long> dailyMap = new HashMap<>();
for (int i = 0; i < trendDays; i++) {
dailyMap.put(today.minusDays(i), 0L);
}
for (var r : replies) {
Instant timestamp = r.getMetadata().getCreationTimestamp();
if (timestamp != null) {
try {
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
if (dailyMap.containsKey(date)) {
dailyMap.merge(date, 1L, Long::sum);
}
} catch (Exception ignored) {
}
}
}
List<DailyCount> dailyTrend = new ArrayList<>();
for (int i = 0; i < trendDays; i++) {
LocalDate date = today.minusDays(i);
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
}
return new StatsResponse(total, passCount, failCount, avgScore,
reviewingCount, sentimentDistribution, dailyTrend);
}) })
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("Failed to fetch stats: {}", e.getMessage()); log.warn("Failed to fetch stats: {}", e.getMessage());
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L, return Mono.just(new StatsResponse(0, 0, 0, 0));
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
List.of()));
}) })
.flatMap(stats -> ServerResponse.ok().bodyValue(stats)); .flatMap(stats -> ServerResponse.ok().bodyValue(stats));
} }
@@ -321,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() : "",
@@ -346,16 +269,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
))); )));
} }
public record DailyCount(String date, long count) {}
public record StatsResponse( public record StatsResponse(
long total, long total,
long passCount, long passCount,
long failCount, long failCount,
double avgScore, long reviewingCount
long reviewingCount,
Map<String, Long> sentimentDistribution,
List<DailyCount> dailyTrend
) {} ) {}
public record PersonaResponse( public record PersonaResponse(
@@ -377,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
"comment", commentOwner, commentContent, commentTime, isCommentAi "comment", commentOwner, commentContent, commentTime, isCommentAi
); );
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted()) return client.list(Reply.class,
.filter(reply -> commentName.equals(reply.getSpec().getCommentName())) reply -> commentName.equals(reply.getSpec().getCommentName()),
null)
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp())) .sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
.map(reply -> { .map(reply -> {
var replyOwner = extractOwnerName(reply.getSpec().getOwner()); var replyOwner = extractOwnerName(reply.getSpec().getOwner());
@@ -722,9 +641,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.bodyValue(Map.of("message", "该评论已有AI回复记录")); .bodyValue(Map.of("message", "该评论已有AI回复记录"));
} }
// Read persona name from post annotations // Read persona name from post annotations
return getPersonaNameFromComment(commentName) return personaResolver.getPersonaNameFromComment(commentName)
.flatMap(personaName -> .flatMap(personaName ->
orchestrator.processComment(commentName, null, false, personaName) orchestrator.processComment(commentName, null, false, personaName, false)
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
); );
}); });
@@ -749,9 +668,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return ServerResponse.badRequest() return ServerResponse.badRequest()
.bodyValue(Map.of("message", "该回复已有AI对话记录")); .bodyValue(Map.of("message", "该回复已有AI对话记录"));
} }
return getPersonaNameFromComment(commentName) return personaResolver.getPersonaNameFromComment(commentName)
.flatMap(personaName -> .flatMap(personaName ->
orchestrator.processComment(commentName, replyName, true, personaName) orchestrator.processComment(commentName, replyName, true, personaName, false)
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
); );
}); });
@@ -759,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
private Mono<String> getPersonaNameFromComment(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return Mono.justOrEmpty(null);
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.mapNotNull(post -> {
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
return null;
});
})
.defaultIfEmpty("");
}
private Mono<Reply> findReplyForRecord(AiCommentReply record) { private Mono<Reply> findReplyForRecord(AiCommentReply record) {
// First try using replyName if available // First try using replyName if available
String replyName = record.getSpec().getReplyName(); String replyName = record.getSpec().getReplyName();
@@ -828,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
) {} ) {}
private Mono<ServerResponse> listCommenters(ServerRequest request) { private Mono<ServerResponse> listCommenters(ServerRequest request) {
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted()) return client.list(Comment.class, null, null)
.take(1000)
.collectList() .collectList()
.map(comments -> { .map(comments -> {
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
@@ -846,7 +741,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) { if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO); avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
} else if (!email.isBlank()) { } else if (!email.isBlank()) {
avatarUrl = generateGravatarUrl(email); avatarUrl = GravatarUtil.generateUrl(email);
} }
result.add(new CommenterInfo(displayName, email, avatarUrl)); result.add(new CommenterInfo(displayName, email, avatarUrl));
} }
@@ -856,26 +751,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters)); .flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
} }
private String generateGravatarUrl(String email) {
try {
var digest = java.security.MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
return "https://cn.cravatar.com/avatar/" + hexString;
} catch (Exception e) {
return "";
}
}
private Mono<ServerResponse> triggerCleanup(ServerRequest request) { private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
return Mono.fromCallable(() -> { return cleanupService.getRetentionDays()
int retentionDays = cleanupService.getRetentionDays(); .flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays)
long deleted = cleanupService.executeCleanup(retentionDays); .map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays))
return Map.of("deletedCount", deleted, "retentionDays", retentionDays); )
})
.flatMap(result -> ServerResponse.ok().bodyValue(result)) .flatMap(result -> ServerResponse.ok().bodyValue(result))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("Failed to trigger cleanup: {}", e.getMessage()); log.warn("Failed to trigger cleanup: {}", e.getMessage());
@@ -1041,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
for (var personaData : personasList) { for (var personaData : personasList) {
importMono = importMono.then(Mono.defer(() -> { importMono = importMono.then(Mono.defer(() -> {
try { try {
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
var personaJson = objectMapper.writeValueAsString(personaData); var personaJson = objectMapper.writeValueAsString(personaData);
var persona = objectMapper.readValue(personaJson, AiPersona.class); var persona = objectMapper.readValue(personaJson, AiPersona.class);
var personaName = persona.getMetadata().getName(); var personaName = persona.getMetadata().getName();
@@ -33,6 +33,9 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "关联文章Slug,用于生成文章链接") @Schema(description = "关联文章Slug,用于生成文章链接")
private String postSlug; private String postSlug;
@Schema(description = "关联内容类型: Post/SinglePage")
private String postKind;
@Schema(description = "AI回复内容") @Schema(description = "AI回复内容")
private String reply; private String reply;
@@ -34,6 +34,16 @@ public class AiPersona extends AbstractExtension {
@Schema(description = "邮箱(用于Gravatar头像)") @Schema(description = "邮箱(用于Gravatar头像)")
private String email; private String email;
@Schema(description = "角色性别(male/female")
private String gender;
@Schema(description = "是否使用中性语气,默认false即跟随性别语气")
@JsonProperty("neutralVoice")
private Boolean neutralVoice;
@Schema(description = "唤醒词,评论以此开头则唤醒该角色回复,留空则不启用唤醒")
private String wakeWord;
@Schema(description = "是否为默认角色") @Schema(description = "是否为默认角色")
@JsonProperty("isDefault") @JsonProperty("isDefault")
private Boolean isDefault; private Boolean isDefault;
@@ -1,31 +0,0 @@
package top.nxxy335.commentaiautopilot.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import top.nxxy335.commentaiautopilot.extension.AiPersona;
@Component
@Slf4j
@RequiredArgsConstructor
public class AiPersonaReconciler implements Reconciler<Reconciler.Request> {
private final ReactiveExtensionClient client;
@Override
public Result reconcile(Request request) {
return new Result(false, null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new AiPersona())
.syncAllOnStart(false)
.build();
}
}
@@ -4,22 +4,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
import top.nxxy335.commentaiautopilot.service.WakeWordService;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
@Component @Component
@Slf4j @Slf4j
@@ -28,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
@@ -1,85 +1,58 @@
package top.nxxy335.commentaiautopilot.service; package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin; import run.halo.aifoundation.AiModelService;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.aifoundation.chat.GenerateTextRequest;
import run.halo.aifoundation.chat.GenerateTextResult;
import run.halo.aifoundation.schema.OutputSpec;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import java.lang.reflect.Method; import java.util.List;
import java.util.Map;
/** /**
* AI Foundation client that uses runtime class loading and reflection * AI Foundation client that uses Halo's {@link ExtensionGetter} to obtain the
* to call the AI Foundation plugin's AiModelService. * {@link AiModelService} extension provided by the ai-foundation plugin.
* <p> * <p>
* This approach avoids classloader identity issues by loading AiModelService * This is the recommended way to integrate with AI Foundation, see
* from ai-foundation's own classloader, so that Spring's getBeansOfType() * <a href="https://github.com/halo-dev/plugin-ai-foundation/blob/main/dev/dev.md">dev guide</a>.
* can correctly match the implementation bean.
* <p> * <p>
* No @ConditionalOnClass or pluginDependencies needed. * Requires the following declaration in plugin.yaml:
* Always registered as a bean; availability is checked at runtime. * <pre>
* spec:
* pluginDependencies:
* ai-foundation?: "*"
* </pre>
* The dependency is optional, so the plugin still loads when AI Foundation is
* not installed; availability is checked at runtime and all calls return empty
* in that case.
*/ */
@Slf4j @Slf4j
@Component @Component
public class AiFoundationClient { public class AiFoundationClient {
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation"; private final ExtensionGetter extensionGetter;
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
private final ReactiveExtensionClient client; public AiFoundationClient(ExtensionGetter extensionGetter) {
private final ApplicationContext applicationContext; this.extensionGetter = extensionGetter;
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
this.client = client;
this.applicationContext = applicationContext;
} }
/** /**
* Call AI Foundation to generate a chat response using the specified model. * Call AI Foundation to generate a chat response using the specified model.
* Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that
* transient model errors are retried by the SDK.
* *
* @param prompt the prompt text * @param prompt the prompt text
* @param modelName the AiModel metadata.name, null or blank to use default model * @param modelName the AiModel metadata.name, null or blank to use default model
* @return the generated text, or empty if AI Foundation is unavailable * @return the generated text, or empty if AI Foundation is unavailable
*/ */
public Mono<String> chat(String prompt, String modelName) { public Mono<String> chat(String prompt, String modelName) {
return isAiFoundationEnabled() return aiModelService()
.flatMap(enabled -> { .flatMap(service -> service.languageModel(modelName != null ? modelName : "")
if (!enabled) { .flatMap(model -> model.generateText(
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply"); GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
return Mono.empty(); .map(GenerateTextResult::getText))
}
return doChat(prompt, modelName);
});
}
/**
* Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found.
*/
public Mono<Boolean> isAvailable() {
return isAiFoundationEnabled()
.flatMap(enabled -> {
if (!enabled) return Mono.just(false);
return findAiModelService().hasElement();
});
}
private Mono<Boolean> isAiFoundationEnabled() {
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
.map(plugin -> plugin.getSpec().getEnabled())
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
return Mono.just(false);
});
}
private Mono<String> doChat(String prompt, String modelName) {
return findAiModelService()
.flatMap(service -> invokeLanguageModel(service, modelName)
.flatMap(model -> invokeGenerateText(model, prompt))
)
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage())) .doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("AI Foundation not available: {}", e.getMessage());
@@ -88,132 +61,68 @@ public class AiFoundationClient {
} }
/** /**
* Get PluginManager via the pluginWrapper bean registered in our plugin context. * Call AI Foundation to classify text into one of the given choices using
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton: * structured output ({@link OutputSpec#choice(List)}).
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper); * <p>
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance. * This is the recommended way to do classification per the dev guide,
* as it is more reliable than prompt parsing.
*
* @param systemPrompt system prompt describing the task
* @param userPrompt the user input to classify
* @param choices the allowed classification values
* @param modelName the AiModel metadata.name, null or blank to use default model
* @return the selected choice string, or empty if AI Foundation is unavailable
*/ */
private Object findPluginManager() { public Mono<String> classify(String systemPrompt, String userPrompt,
try { List<String> choices, String modelName) {
Object pluginWrapper = applicationContext.getBean("pluginWrapper"); return aiModelService()
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager"); .flatMap(service -> service.languageModel(modelName != null ? modelName : "")
getPluginManagerMethod.setAccessible(true); .flatMap(model -> model.generateText(
Object pm = getPluginManagerMethod.invoke(pluginWrapper); GenerateTextRequest.builder()
if (pm != null) { .system(systemPrompt)
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName()); .prompt(userPrompt)
} .output(OutputSpec.choice(choices))
return pm; .maxRetries(2)
} catch (NoSuchMethodException e) { .build()))
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage()); .map(result -> {
} catch (Exception e) { Object output = result.getOutput();
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage()); return output != null ? String.valueOf(output).trim() : "";
} }))
log.warn("PluginManager not found"); .doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
return null; .onErrorResume(e -> {
} log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
/**
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
* to get the plugin's ApplicationContext.
*/
private Mono<Object> findAiModelService() {
return Mono.fromCallable(() -> {
Object pm = findPluginManager();
if (pm == null) return null;
// Call pm.getPlugin("ai-foundation") via reflection
Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class);
getPluginMethod.setAccessible(true);
Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME);
if (pluginWrapper == null) {
log.debug("ai-foundation plugin not found in PluginManager");
return null;
}
// Call pluginWrapper.getPlugin() to get the plugin instance
Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin");
getPluginInstanceMethod.setAccessible(true);
Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper);
if (pluginInstance == null) {
log.debug("ai-foundation plugin instance is null");
return null;
}
// Get the plugin's ApplicationContext via reflection on SpringPlugin
// DefaultSpringPlugin is package-private, so we need setAccessible
Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext");
getCtxMethod.setAccessible(true);
ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance);
// Get the plugin classloader
Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader");
getClassLoaderMethod.setAccessible(true);
ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper);
// Load AiModelService from ai-foundation's classloader
Class<?> aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS);
// Find the AiModelService bean in ai-foundation's ApplicationContext
Map<String, ?> beans = pluginAppContext.getBeansOfType(aiModelServiceClass);
if (beans.isEmpty()) {
log.debug("AiModelService bean not found in ai-foundation's ApplicationContext");
return null;
}
log.info("Found AiModelService bean in ai-foundation's ApplicationContext");
Object result = beans.values().iterator().next();
return (Object) result;
}).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage()));
}
/**
* Call service.languageModel(modelName) or service.languageModel() via reflection.
* Returns Mono&lt;LanguageModel&gt; from ai-foundation's classloader.
*/
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
return Mono.fromCallable(() -> {
Method method;
if (modelName != null && !modelName.isBlank()) {
method = service.getClass().getMethod("languageModel", String.class);
method.setAccessible(true);
return method.invoke(service, modelName);
} else {
method = service.getClass().getMethod("languageModel");
method.setAccessible(true);
return method.invoke(service);
}
}).flatMap(result -> {
if (result instanceof Mono<?> mono) return mono;
return Mono.justOrEmpty(result);
}); });
} }
/** /**
* Call model.generateText(prompt) via reflection, then extract text from result. * Check if AI Foundation is available: plugin installed and an
* Returns the generated text string. * AiModelService extension is enabled.
*/ */
private Mono<String> invokeGenerateText(Object model, String prompt) { public Mono<Boolean> isAvailable() {
return Mono.fromCallable(() -> { return aiModelService().hasElement()
Method method = model.getClass().getMethod("generateText", String.class); .onErrorResume(e -> {
method.setAccessible(true); log.debug("AI Foundation not available: {}", e.getMessage());
return method.invoke(model, prompt); return Mono.just(false);
}).flatMap(result -> {
if (result instanceof Mono<?> mono) {
return mono.map(this::extractText);
}
return Mono.justOrEmpty(extractText(result));
}); });
} }
private String extractText(Object result) { /**
if (result == null) return null; * Obtain the enabled AiModelService extension via ExtensionGetter.
* <p>
* Wrapped in {@link Mono#defer} with a {@link NoClassDefFoundError} guard so
* that the plugin still works when the optional ai-foundation dependency is
* not installed (the AiModelService API class is then absent from the
* classloader).
*/
private Mono<AiModelService> aiModelService() {
return Mono.defer(() -> {
try { try {
Method getText = result.getClass().getMethod("getText"); return extensionGetter.getEnabledExtension(AiModelService.class);
getText.setAccessible(true); } catch (NoClassDefFoundError e) {
return (String) getText.invoke(result); log.debug("AI Foundation API not on classpath: {}", e.getMessage());
} catch (Exception e) { return Mono.empty();
throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e);
} }
});
} }
} }
@@ -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,
@@ -231,12 +290,13 @@ public class AiReplyOrchestrator {
} }
return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName); return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
}) })
.switchIfEmpty(
publishReply(context, aiReply, replyRecord, 100, personaName)
)
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage()); // review() already handles errors internally (returns PASS),
return publishReply(context, aiReply, replyRecord, 100, personaName); // so this only fires for errors from publishReply/updateRecord.
// Do NOT re-call publishReply to avoid double-publish / overwriting published=false.
log.error("[Orchestrator] Error during review/publish for {}: {}",
context.commentId(), e.getMessage(), e);
return Mono.empty();
}); });
}); });
} }
@@ -523,6 +583,7 @@ public class AiReplyOrchestrator {
record.getSpec().setCommentId(context.commentId()); record.getSpec().setCommentId(context.commentId());
record.getSpec().setPostId(context.postId()); record.getSpec().setPostId(context.postId());
record.getSpec().setPostSlug(context.postSlug()); record.getSpec().setPostSlug(context.postSlug());
record.getSpec().setPostKind(context.postKind());
record.getSpec().setReply(""); record.getSpec().setReply("");
record.getSpec().setScore(0); record.getSpec().setScore(0);
record.getSpec().setStatus("PENDING"); record.getSpec().setStatus("PENDING");
@@ -8,9 +8,8 @@ import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import top.nxxy335.commentaiautopilot.extension.AiPersona; import top.nxxy335.commentaiautopilot.extension.AiPersona;
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -120,7 +119,7 @@ public class CommentReplyPublisher {
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
// 使用Gravatar邮箱头像 // 使用Gravatar邮箱头像
if (email != null && !email.isBlank()) { if (email != null && !email.isBlank()) {
String gravatarUrl = generateGravatarUrl(email); String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
} }
owner.setAnnotations(ownerAnnotations); owner.setAnnotations(ownerAnnotations);
@@ -181,22 +180,4 @@ public class CommentReplyPublisher {
private String generateReplyName() { private String generateReplyName() {
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8); return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
} }
/**
* Generate Gravatar URL from email address using SHA-256 hash.
*/
private String generateGravatarUrl(String email) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
return "https://cn.cravatar.com/avatar/" + hexString;
} catch (Exception e) {
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
return "";
}
}
} }
@@ -10,6 +10,7 @@ import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostContentService; import run.halo.app.content.PostContentService;
import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
@@ -42,6 +43,55 @@ public class ContextExtractor {
}); });
} }
/**
* Fetch previous replies in the comment thread to provide conversation history.
* Only includes replies created before the triggering reply.
*/
private Mono<String> fetchConversationHistory(String commentName, String triggerReplyName) {
if (triggerReplyName == null || triggerReplyName.isBlank()) {
return Mono.just("");
}
return client.fetch(Reply.class, triggerReplyName)
.flatMap(triggerReply -> {
var triggerTime = triggerReply.getMetadata().getCreationTimestamp();
return client.list(Reply.class,
reply -> {
if (!commentName.equals(reply.getSpec().getCommentName())) {
return false;
}
if (triggerReplyName.equals(reply.getMetadata().getName())) {
return false;
}
// Only include replies created before the trigger reply
var replyTime = reply.getMetadata().getCreationTimestamp();
return replyTime != null && triggerTime != null
&& !replyTime.isAfter(triggerTime);
},
null)
.collectList()
.map(replies -> {
if (replies.isEmpty()) return "";
// Sort by creation time
replies.sort(java.util.Comparator.comparing(
r -> r.getMetadata().getCreationTimestamp()));
var sb = new StringBuilder();
for (var r : replies) {
var owner = r.getSpec().getOwner();
String name = (owner != null && owner.getDisplayName() != null)
? owner.getDisplayName() : "匿名用户";
boolean isAi = owner != null && owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
String role = isAi ? "AI" : "用户";
String content = extractReplyContent(r);
sb.append(role).append("(").append(name).append("): ")
.append(content).append("\n");
}
return sb.toString();
});
})
.defaultIfEmpty("");
}
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) { private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
var commentContent = extractCommentContent(comment); var commentContent = extractCommentContent(comment);
var commentOwner = extractCommentOwner(comment); var commentOwner = extractCommentOwner(comment);
@@ -63,10 +113,16 @@ public class ContextExtractor {
null, null,
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,
@@ -78,7 +134,52 @@ public class ContextExtractor {
null, null,
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"
)); ));
} }
@@ -93,7 +194,9 @@ public class ContextExtractor {
null, null,
isAiConversation, isAiConversation,
"", "",
0 0,
"",
""
)); ));
} }
@@ -101,54 +204,119 @@ public class ContextExtractor {
var replyContent = extractReplyContent(reply); var replyContent = extractReplyContent(reply);
var replyOwner = extractReplyOwner(reply); var replyOwner = extractReplyOwner(reply);
var subjectRef = comment.getSpec().getSubjectRef(); var subjectRef = comment.getSpec().getSubjectRef();
var commentName = comment.getMetadata().getName();
var replyName = reply.getMetadata().getName();
// Fetch conversation history for AI conversations
Mono<String> historyMono = isAiConversation
? fetchConversationHistory(commentName, replyName)
: Mono.just("");
if (subjectRef != null && "Post".equals(subjectRef.getKind())) { if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
String postName = subjectRef.getName(); String postName = subjectRef.getName();
return client.fetch(Post.class, postName) return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName) .flatMap(post -> getPostContent(postName)
.flatMap(content -> getCommentCount(comment.getMetadata().getName()) .flatMap(content -> getCommentCount(commentName)
.map(commentCount -> new CommentContext( .flatMap(commentCount -> historyMono
comment.getMetadata().getName(), .map(history -> new CommentContext(
commentName,
postName, postName,
post.getSpec().getSlug(), post.getSpec().getSlug(),
replyContent, replyContent,
replyOwner, replyOwner,
post.getSpec().getTitle(), post.getSpec().getTitle(),
content, content,
reply.getMetadata().getName(), replyName,
isAiConversation, isAiConversation,
formatPostDate(post), formatPostDate(post),
commentCount commentCount,
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(
comment.getMetadata().getName(), commentName,
postName, postName,
"", "",
replyContent, replyContent,
replyOwner, replyOwner,
"", "",
"", "",
reply.getMetadata().getName(), replyName,
isAiConversation, isAiConversation,
"", "",
0 0,
"",
"Post"
)); ));
} }
return Mono.just(new CommentContext( if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) {
comment.getMetadata().getName(), 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"
));
}
return historyMono
.map(history -> new CommentContext(
commentName,
"", "",
"", "",
replyContent, replyContent,
replyOwner, replyOwner,
"", "",
"", "",
reply.getMetadata().getName(), replyName,
isAiConversation, isAiConversation,
"", "",
0 0,
history,
""
)); ));
} }
@@ -213,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) {
@@ -225,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()),
@@ -245,6 +442,8 @@ public class ContextExtractor {
String replyTo, String replyTo,
boolean isAiConversation, boolean isAiConversation,
String postDate, String postDate,
int commentCount int commentCount,
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 = """
@@ -75,6 +76,7 @@ public class PromptBuilder {
文章(仅供理解上下文,不要复述): 文章(仅供理解上下文,不要复述):
{{article}} {{article}}
{{conversation_history}}
评论: 评论:
{{comment}} {{comment}}
"""; """;
@@ -103,6 +105,7 @@ public class PromptBuilder {
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
.replace("{{comment_count}}", String.valueOf(context.commentCount())) .replace("{{comment_count}}", String.valueOf(context.commentCount()))
.replace("{{article}}", context.postTitle() + "\n" + context.postContent()) .replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{conversation_history}}", formatConversationHistory(context))
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
return prompt; return prompt;
@@ -133,20 +136,35 @@ public class PromptBuilder {
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
.replace("{{comment_count}}", String.valueOf(context.commentCount())) .replace("{{comment_count}}", String.valueOf(context.commentCount()))
.replace("{{article}}", context.postTitle() + "\n" + context.postContent()) .replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{conversation_history}}", formatConversationHistory(context))
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
if (sentiment == null || "NEUTRAL".equals(sentiment)) { if (sentiment == null || "NEUTRAL".equals(sentiment)) {
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;
}); });
} }
/**
* Format conversation history for inclusion in the prompt.
* Returns empty string if no history is available.
*/
private String formatConversationHistory(ContextExtractor.CommentContext context) {
String history = context.conversationHistory();
if (history == null || history.isBlank()) {
return "";
}
return "对话历史(供理解上下文):\n" + history + "\n";
}
private Mono<String> getPromptTemplate() { private Mono<String> getPromptTemplate() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
@@ -177,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);
} }
@@ -188,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 -> {
@@ -1,6 +1,7 @@
package top.nxxy335.commentaiautopilot.service; package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -8,15 +9,18 @@ import java.util.concurrent.atomic.AtomicInteger;
@Slf4j @Slf4j
@Component @Component
public class RateLimitService { public class RateLimitService implements DisposableBean {
private final ConcurrentHashMap<Long, AtomicInteger> windowMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Long, AtomicInteger> windowMap = new ConcurrentHashMap<>();
private final Thread cleanupThread;
private volatile boolean running = true;
public RateLimitService() { public RateLimitService() {
// 每5分钟清理过期窗口,防止内存泄漏 // 每5分钟清理过期窗口,防止内存泄漏
Thread cleanupThread = new Thread(() -> { cleanupThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) { while (running && !Thread.currentThread().isInterrupted()) {
try { try {
Thread.sleep(5 * 60 * 1000); Thread.sleep(5 * 60 * 1000);
if (!running) break;
cleanup(); cleanup();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -55,4 +59,13 @@ public class RateLimitService {
log.debug("[RateLimit] Cleaned up {} expired windows", removed); log.debug("[RateLimit] Cleaned up {} expired windows", removed);
} }
} }
@Override
public void destroy() {
running = false;
if (cleanupThread != null) {
cleanupThread.interrupt();
}
log.info("[RateLimit] Cleanup thread stopped");
}
} }
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.List;
@Component @Component
@Slf4j @Slf4j
public class ReviewService { public class ReviewService {
@@ -14,58 +16,135 @@ public class ReviewService {
this.aiFoundationClient = aiFoundationClient; this.aiFoundationClient = aiFoundationClient;
} }
private static final String REVIEW_PROMPT_TEMPLATE = """ /**
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复: * Safety classification choices.
* Using OutputSpec.choice for reliable structured output.
*/
private static final String SAFE = "安全";
private static final String UNSAFE = "不安全";
private static final List<String> SAFETY_CHOICES = List.of(SAFE, UNSAFE);
文章: /**
%s * Quality rating choices for content quality assessment.
* Used to produce a meaningful score (1-5) instead of binary 0/100.
*/
private static final String RATING_EXCELLENT = "5";
private static final String RATING_GOOD = "4";
private static final String RATING_AVERAGE = "3";
private static final String RATING_POOR = "2";
private static final String RATING_BAD = "1";
private static final List<String> QUALITY_CHOICES = List.of(
RATING_EXCELLENT, RATING_GOOD, RATING_AVERAGE, RATING_POOR, RATING_BAD
);
private static final String SAFETY_SYSTEM_PROMPT = """
你是内容安全审核员。请严格评估AI生成的回复内容是否安全合规。
检查以下方面:
1. 是否包含辱骂、仇恨、偏见或煽动性言论
2. 是否包含不适宜公开传播的内容
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容
只返回"安全""不安全"""";
private static final String QUALITY_SYSTEM_PROMPT = """
你是AI回复质量评估员。请对AI生成的回复进行质量评分(1-5分)。
评分标准:
5分:回复切题、自然流畅、语气恰当、有帮助
4分:回复较好,基本切题,语气自然
3分:回复一般,基本切题但略显生硬或冗长
2分:回复较差,偏离主题或语气不当
1分:回复很差,完全不相关或不当
只返回数字 1-5。""";
/**
* Review AI reply content using two-stage assessment:
* 1. Safety check via OutputSpec.choice (安全/不安全)
* 2. Quality rating via OutputSpec.choice (1-5) — only if safe
*
* <p>Score mapping (0-100):
* <ul>
* <li>Unsafe → 0 (FAIL)</li>
* <li>Rating 5 → 100 (PASS)</li>
* <li>Rating 4 → 85 (PASS)</li>
* <li>Rating 3 → 70 (PASS)</li>
* <li>Rating 2 → 50 (PASS, borderline)</li>
* <li>Rating 1 → 30 (PASS, but low quality)</li>
* </ul>
*/
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
String modelName) {
String userPrompt = String.format("""
原始评论: 原始评论:
%s %s
生成的回复: 生成的回复:
%s %s
请检查以下内容: 请判断以上回复是否安全合规。""",
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
2. 是否包含不适宜公开传播的内容?
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
请直接回答"安全""不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
""";
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
String modelName) {
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
truncate(articleContent, 2000),
truncate(commentContent, 500), truncate(commentContent, 500),
truncate(aiReply, 500)); truncate(aiReply, 500));
return aiFoundationClient.chat(reviewPrompt, modelName) // Stage 1: Safety check
.map(this::parseSafetyResult) return aiFoundationClient.classify(SAFETY_SYSTEM_PROMPT, userPrompt, SAFETY_CHOICES, modelName)
.flatMap(safetyResult -> {
if (UNSAFE.equals(safetyResult)) {
log.warn("[Review] Content is UNSAFE");
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核不通过"));
}
if (!SAFE.equals(safetyResult)) {
log.warn("[Review] Unexpected safety result: {}, treating as unsafe", safetyResult);
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核结果异常"));
}
// Stage 2: Quality rating (only for safe content)
return rateQuality(commentContent, aiReply, modelName);
})
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过")) .defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("Review failed, auto-passing: {}", e.getMessage()); log.warn("[Review] Review failed, auto-passing: {}", e.getMessage());
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过")); return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
}); });
} }
private ReviewResult parseSafetyResult(String response) { /**
if (response == null || response.isBlank()) { * Rate the quality of a safe AI reply (1-5) and map to a 0-100 score.
return new ReviewResult(100, "PASS", "审核无响应,自动通过"); */
private Mono<ReviewResult> rateQuality(String commentContent, String aiReply, String modelName) {
String qualityPrompt = String.format("""
评论:
%s
回复:
%s
请对以上回复进行质量评分(1-5分)。""",
truncate(commentContent, 500),
truncate(aiReply, 500));
return aiFoundationClient.classify(QUALITY_SYSTEM_PROMPT, qualityPrompt, QUALITY_CHOICES, modelName)
.map(rating -> {
int score = mapRatingToScore(rating);
String reason = "安全通过,质量评分: " + rating + "/5";
log.info("[Review] Content is SAFE, quality rating: {}/5, score: {}", rating, score);
return new ReviewResult(score, "PASS", reason);
})
.defaultIfEmpty(new ReviewResult(85, "PASS", "安全通过,质量评分默认 4/5"))
.onErrorResume(e -> {
log.warn("[Review] Quality rating failed, defaulting to 85: {}", e.getMessage());
return Mono.just(new ReviewResult(85, "PASS", "安全通过,质量评分异常"));
});
} }
String trimmed = response.trim().toLowerCase();
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) { /**
log.warn("AI Review: content is UNSAFE, response: {}", response); * Map a 1-5 quality rating to a 0-100 score.
return new ReviewResult(0, "FAIL", "内容安全审核不通过"); */
} private int mapRatingToScore(String rating) {
if (trimmed.contains("安全") || trimmed.contains("safe")) { return switch (rating) {
log.info("AI Review: content is SAFE"); case RATING_EXCELLENT -> 100;
return new ReviewResult(100, "PASS", "内容安全审核通过"); case RATING_GOOD -> 85;
} case RATING_AVERAGE -> 70;
// If unclear response, default to pass case RATING_POOR -> 50;
log.warn("AI Review: unclear response, auto-passing: {}", response); case RATING_BAD -> 30;
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过"); default -> 70; // default to average
};
} }
private String truncate(String text, int maxLength) { private String truncate(String text, int maxLength) {
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.List;
@Component @Component
@Slf4j @Slf4j
public class SentimentService { public class SentimentService {
@@ -15,18 +17,44 @@ 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";
} }
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) { private static final List<String> CHOICES = List.of(
String prompt = buildSentimentPrompt(commentContent); SentimentResult.VERY_POSITIVE, SentimentResult.POSITIVE,
SentimentResult.NEUTRAL, SentimentResult.NEGATIVE,
SentimentResult.VERY_NEGATIVE
);
return aiFoundationClient.chat(prompt, modelName) /**
.map(response -> { * Analyze sentiment using AI Foundation structured output
String sentiment = parseSentiment(response); * ({@code OutputSpec.choice}) for reliable classification.
return new SentimentResult(sentiment, 1.0); */
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
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;
return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName)
.map(sentiment -> {
String upper = sentiment.toUpperCase();
// Validate against known choices; default to NEUTRAL if unexpected
if (!CHOICES.contains(upper)) {
log.warn("[Sentiment] Unexpected classification result: {}, defaulting to NEUTRAL", sentiment);
return new SentimentResult(SentimentResult.NEUTRAL, 0.0);
}
return new SentimentResult(upper, 1.0);
}) })
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage()); log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
@@ -34,16 +62,4 @@ public class SentimentService {
}) })
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0)); .defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
} }
private String buildSentimentPrompt(String commentContent) {
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
}
private String parseSentiment(String response) {
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
String upper = response.trim().toUpperCase();
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
return SentimentResult.NEUTRAL;
}
} }
@@ -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
@@ -54,7 +54,7 @@ spec:
- $formkit: textarea - $formkit: textarea
name: customPromptTemplate name: customPromptTemplate
label: 自定义Prompt模板 label: 自定义Prompt模板
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}" value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n{{conversation_history}}\n评论:\n{{comment}}"
- $formkit: select - $formkit: select
name: enabledPresets name: enabledPresets
label: 启用预设 label: 启用预设
+9 -1
View File
@@ -5,9 +5,17 @@ kind: Plugin
metadata: metadata:
# The name defines how the plugin is invoked, A unique name # The name defines how the plugin is invoked, A unique name
name: comment-ai-autopilot name: comment-ai-autopilot
annotations:
# Recommend installing AI Foundation from the app store after installing this plugin
# https://www.halo.run/store/apps/app-acslk9nu
"store.halo.run/recommended-apps": '["app-acslk9nu"]'
spec: spec:
enabled: true enabled: true
requires: ">=2.25.0" requires: ">=2.25.0"
pluginDependencies:
# Optional dependency: plugin still loads without AI Foundation,
# but AI features require it to be installed and enabled.
ai-foundation?: "*"
author: author:
name: 暖心向阳335 name: 暖心向阳335
website: https://nxxy335.top website: https://nxxy335.top
@@ -22,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.1" version: "1.0.0"
+28 -202
View File
@@ -108,152 +108,11 @@
</VCard> </VCard>
</div> </div>
<!-- Middle: Sentiment + Trend -->
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<!-- Sentiment Distribution -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-4">情感分布</h3>
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">正面</span>
<span class="font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-green-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('POSITIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">中性</span>
<span class="font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEUTRAL') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-red-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">负面</span>
<span class="font-medium text-red-500">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-red-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEGATIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-300 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">未知</span>
<span class="font-medium text-gray-400">{{ stats?.sentimentDistribution?.UNKNOWN || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-300 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('UNKNOWN') + '%' }"
></div>
</div>
</div>
</div>
</div>
</VCard>
<!-- Daily Trend -->
<VCard :body-class="['!p-5']">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
<button
class="px-2.5 py-1 text-xs transition-colors"
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '7'"
>
7
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '30'"
>
30
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = 'all'"
>
全部
</button>
</div>
</div>
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
<div
v-for="day in stats.dailyTrend"
:key="day.date"
class="flex-1 flex flex-col items-center justify-end h-full"
>
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
<div
class="w-full rounded-t-md transition-all duration-500"
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
></div>
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
</div>
</div>
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
暂无数据
</div>
</VCard>
</div>
<!-- Bottom: Score + Quick Actions -->
<div class="grid grid-cols-1 gap-4 mt-4 sm:grid-cols-2">
<!-- Avg Score -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">平均审核评分</h3>
<div class="flex items-center gap-4">
<div class="text-4xl font-bold" :class="scoreColor">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<div class="flex-1">
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor"
:style="{ width: (stats?.avgScore || 0) * 10 + '%' }"
></div>
</div>
<div class="flex justify-between text-[10px] text-gray-300 mt-1">
<span>0</span>
<span>5</span>
<span>10</span>
</div>
</div>
</div>
</VCard>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="mt-4">
<VCard :body-class="['!p-5']"> <VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3> <h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<button <button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700" class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotLogs' })" @click="$router.push({ name: 'CommentAiAutopilotLogs' })"
@@ -273,15 +132,6 @@
</svg> </svg>
插件设置 插件设置
</button> </button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotSettings' })"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
AI角色
</button>
<button <button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700" class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="refreshData" @click="refreshData"
@@ -299,24 +149,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue" import { ref, computed, onMounted } from "vue"
import { axiosInstance } from "@halo-dev/api-client" import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components" import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components" import { IconPlug } from "@halo-dev/components"
interface DailyCount {
date: string
count: number
}
interface StatsResponse { interface StatsResponse {
total: number total: number
passCount: number passCount: number
failCount: number failCount: number
reviewingCount: number reviewingCount: number
avgScore: number
sentimentDistribution: Record<string, number>
dailyTrend: DailyCount[]
} }
interface PersonaResponse { interface PersonaResponse {
@@ -331,7 +173,6 @@ interface HealthResponse {
const stats = ref<StatsResponse | null>(null) const stats = ref<StatsResponse | null>(null)
const persona = ref<PersonaResponse | null>(null) const persona = ref<PersonaResponse | null>(null)
const range = ref("7")
const health = ref<HealthResponse | null>(null) const health = ref<HealthResponse | null>(null)
const healthVisible = ref(true) const healthVisible = ref(true)
@@ -340,24 +181,10 @@ const passRate = computed(() => {
return Math.round((stats.value.passCount / stats.value.total) * 100) return Math.round((stats.value.passCount / stats.value.total) * 100)
}) })
const scoreColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "text-green-600"
if (score >= 4) return "text-amber-500"
return "text-red-500"
})
const scoreBarColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "bg-gradient-to-r from-green-400 to-green-500"
if (score >= 4) return "bg-gradient-to-r from-amber-400 to-amber-500"
return "bg-gradient-to-r from-red-400 to-red-500"
})
const fetchStats = async () => { const fetchStats = async () => {
try { try {
const { data } = await axiosInstance.get( const { data } = await axiosInstance.get(
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`, `/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
) )
stats.value = data stats.value = data
} catch (e) { } catch (e) {
@@ -421,34 +248,33 @@ const openSettings = () => {
window.location.href = "/console/comment-ai-autopilot/settings" window.location.href = "/console/comment-ai-autopilot/settings"
} }
const getSentimentPercent = (sentiment: string): number => {
const dist = stats.value?.sentimentDistribution
if (!dist) return 0
const total = Object.values(dist).reduce((a, b) => a + b, 0)
if (total === 0) return 0
return Math.round(((dist[sentiment] || 0) / total) * 100)
}
const getTrendBarHeight = (count: number): number => {
const trend = stats.value?.dailyTrend
if (!trend || trend.length === 0) return 0
const max = Math.max(...trend.map(d => d.count), 1)
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
}
const formatTrendDate = (dateStr: string): string => {
if (!dateStr) return ''
const parts = dateStr.split('-')
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
}
watch(range, () => {
fetchStats()
})
onMounted(() => { onMounted(() => {
fetchStats() fetchStats()
fetchPersona() fetchPersona()
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>
+352 -28
View File
@@ -10,28 +10,28 @@
</VPageHeader> </VPageHeader>
<!-- Batch Operation Toolbar --> <!-- Batch Operation Toolbar -->
<div v-if="selectedNames.size > 0" class="mx-4 mt-2 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5"> <div v-if="selectedNames.size > 0" class="m-4 mb-0 flex items-center gap-3 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5">
<span class="text-sm text-blue-700">已选择 {{ selectedNames.size }} </span> <span class="text-sm text-blue-700 font-medium">已选择 {{ selectedNames.size }} </span>
<button <button
class="text-xs px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition-colors" class="batch-btn batch-btn--approve"
@click="batchApprove" @click="batchApprove"
> >
批量通过 批量通过
</button> </button>
<button <button
class="text-xs px-3 py-1 rounded bg-orange-500 text-white hover:bg-orange-600 transition-colors" class="batch-btn batch-btn--reject"
@click="batchReject" @click="batchReject"
> >
批量拒绝 批量拒绝
</button> </button>
<button <button
class="text-xs px-3 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors" class="batch-btn batch-btn--delete"
@click="batchDelete" @click="batchDelete"
> >
批量删除 批量删除
</button> </button>
<button <button
class="text-xs text-gray-500 hover:text-gray-700 ml-auto" class="batch-btn batch-btn--cancel"
@click="selectedNames.clear(); selectAll = false" @click="selectedNames.clear(); selectAll = false"
> >
取消选择 取消选择
@@ -39,10 +39,10 @@
</div> </div>
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="mx-4 mt-2 flex items-center gap-3"> <div class="m-4 mb-0 flex items-center gap-3">
<select <select
v-model="filterStatus" v-model="filterStatus"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" class="filter-select"
> >
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="PASS">通过</option> <option value="PASS">通过</option>
@@ -52,23 +52,33 @@
</select> </select>
<select <select
v-model="filterSentiment" v-model="filterSentiment"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" class="filter-select"
> >
<option value="">全部情感</option> <option value="">全部情感</option>
<option value="VERY_POSITIVE">非常正面</option>
<option value="POSITIVE">正面</option> <option value="POSITIVE">正面</option>
<option value="NEUTRAL">中性</option> <option value="NEUTRAL">中性</option>
<option value="NEGATIVE">负面</option> <option value="NEGATIVE">负面</option>
<option value="VERY_NEGATIVE">非常负面</option>
</select> </select>
<div class="relative flex-1 max-w-xs">
<svg class="filter-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input <input
v-model="filterKeyword" v-model="filterKeyword"
type="text" type="text"
placeholder="搜索回复内容..." placeholder="搜索回复内容..."
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" class="filter-input"
/> />
</div>
<button <button
class="text-xs text-gray-500 hover:text-gray-700" class="filter-reset-btn"
@click="resetFilters" @click="resetFilters"
> >
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
重置 重置
</button> </button>
</div> </div>
@@ -112,26 +122,26 @@
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5 flex-wrap"> <div class="flex items-center gap-1.5 flex-wrap">
<span <span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" class="status-tag"
:class="getStatusClass(reply.spec.status)" :class="getStatusClass(reply.spec.status)"
> >
{{ getStatusLabel(reply.spec.status) }} {{ getStatusLabel(reply.spec.status) }}
</span> </span>
<span <span
class="inline-flex items-center px-2 py-0.5 rounded text-xs" class="status-tag"
:class="reply.spec.published ? 'bg-green-50 text-green-600' : 'bg-gray-50 text-gray-400'" :class="reply.spec.published ? 'status-tag--published' : 'status-tag--unpublished'"
> >
{{ reply.spec.published ? '已发布' : '未发布' }} {{ reply.spec.published ? '已发布' : '未发布' }}
</span> </span>
<span <span
v-if="reply.spec.isAiConversation" v-if="reply.spec.isAiConversation"
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600" class="status-tag status-tag--conversation"
> >
对话 对话
</span> </span>
<span <span
v-if="reply.spec.sentiment" v-if="reply.spec.sentiment"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" class="status-tag"
:class="getSentimentClass(reply.spec.sentiment)" :class="getSentimentClass(reply.spec.sentiment)"
> >
{{ getSentimentLabel(reply.spec.sentiment) }} {{ getSentimentLabel(reply.spec.sentiment) }}
@@ -151,10 +161,11 @@
<!-- Card footer: meta info + actions --> <!-- Card footer: meta info + actions -->
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between"> <div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
<div class="flex items-center gap-4 text-xs text-gray-400"> <div class="flex items-center gap-4 text-xs text-gray-400">
<span> <span class="flex items-center gap-1.5">
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium text-gray-600">{{ reply.spec.score }}</span> 评分 <span :class="getScoreClass(reply.spec.score)" class="font-semibold">{{ reply.spec.score }}</span>
<span class="score-label" :class="getScoreLabelClass(reply.spec.score)">{{ getScoreLabel(reply.spec.score) }}</span>
</span> </span>
<span v-if="reply.spec.postSlug" class="flex items-center gap-1"> <span v-if="reply.spec.postSlug && reply.spec.postKind === 'Post'" class="flex items-center gap-1">
文章 文章
<a <a
:href="getPostUrl(reply.spec.postSlug)" :href="getPostUrl(reply.spec.postSlug)"
@@ -166,6 +177,18 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
</span> </span>
<span v-else-if="reply.spec.postSlug && reply.spec.postKind === 'SinglePage'" class="flex items-center gap-1">
页面
<a
:href="getPageUrl(reply.spec.postSlug)"
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 hover:text-blue-700 hover:underline"
>{{ reply.spec.postSlug }}</a>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
<span v-if="reply.spec.retryCount > 0"> <span v-if="reply.spec.retryCount > 0">
重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span> 重试 <span class="text-gray-600">{{ reply.spec.retryCount }}</span>
</span> </span>
@@ -317,6 +340,7 @@ interface AiCommentReplyItem {
commentId: string commentId: string
postId: string postId: string
postSlug: string postSlug: string
postKind: string
reply: string reply: string
score: number score: number
status: string status: string
@@ -508,18 +532,34 @@ const getScoreClass = (score: number) => {
return "text-red-600" return "text-red-600"
} }
const getScoreLabelClass = (score: number) => {
if (score >= 85) return "score-label--excellent"
if (score >= 70) return "score-label--good"
if (score >= 50) return "score-label--average"
if (score > 0) return "score-label--poor"
return ""
}
const getScoreLabel = (score: number) => {
if (score >= 85) return "优秀"
if (score >= 70) return "良好"
if (score >= 50) return "一般"
if (score > 0) return "较差"
return ""
}
const getStatusClass = (status: string) => { const getStatusClass = (status: string) => {
switch (status) { switch (status) {
case "PASS": case "PASS":
return "bg-green-100 text-green-700" return "status-tag--pass"
case "FAIL": case "FAIL":
return "bg-red-100 text-red-700" return "status-tag--fail"
case "PENDING": case "PENDING":
return "bg-gray-100 text-gray-700" return "status-tag--pending"
case "REJECTED": case "REJECTED":
return "bg-red-100 text-red-700" return "status-tag--rejected"
default: default:
return "bg-gray-100 text-gray-700" return "status-tag--pending"
} }
} }
@@ -540,23 +580,31 @@ const getStatusLabel = (status: string) => {
const getSentimentClass = (sentiment: string) => { const getSentimentClass = (sentiment: string) => {
switch (sentiment) { switch (sentiment) {
case "VERY_POSITIVE":
return "status-tag--very-positive"
case "POSITIVE": case "POSITIVE":
return "bg-green-50 text-green-600" return "status-tag--positive"
case "NEGATIVE": case "NEGATIVE":
return "bg-red-50 text-red-600" return "status-tag--negative"
case "VERY_NEGATIVE":
return "status-tag--very-negative"
case "NEUTRAL": case "NEUTRAL":
return "bg-gray-100 text-gray-600" return "status-tag--neutral-sentiment"
default: default:
return "bg-gray-100 text-gray-600" return "status-tag--neutral-sentiment"
} }
} }
const getSentimentLabel = (sentiment: string) => { const getSentimentLabel = (sentiment: string) => {
switch (sentiment) { switch (sentiment) {
case "VERY_POSITIVE":
return "非常正面"
case "POSITIVE": case "POSITIVE":
return "正面" return "正面"
case "NEGATIVE": case "NEGATIVE":
return "负面" return "负面"
case "VERY_NEGATIVE":
return "非常负面"
case "NEUTRAL": case "NEUTRAL":
return "中性" return "中性"
default: default:
@@ -573,6 +621,10 @@ const getPostUrl = (slug: string) => {
return `${window.location.origin}/archives/${slug}` return `${window.location.origin}/archives/${slug}`
} }
const getPageUrl = (slug: string) => {
return `${window.location.origin}/pages/${slug}`
}
/** /**
* Strip HTML tags for plain text display (card preview) * Strip HTML tags for plain text display (card preview)
*/ */
@@ -640,4 +692,276 @@ onMounted(fetchReplies)
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/* ===== Batch Operation Buttons ===== */
.batch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.5;
}
.batch-btn--approve {
background-color: #16a34a;
color: #fff;
}
.batch-btn--approve:hover {
background-color: #15803d;
}
.batch-btn--reject {
background-color: #f97316;
color: #fff;
}
.batch-btn--reject:hover {
background-color: #ea580c;
}
.batch-btn--delete {
background-color: #dc2626;
color: #fff;
}
.batch-btn--delete:hover {
background-color: #b91c1c;
}
.batch-btn--cancel {
background: none;
color: #6b7280;
margin-left: auto;
}
.batch-btn--cancel:hover {
color: #374151;
background: #f3f4f6;
}
/* ===== Filter Bar ===== */
.filter-select {
padding: 6px 32px 6px 12px;
font-size: 13px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
color: #374151;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
transition: all 0.15s ease;
}
.filter-select:focus {
border-color: #3b82f6;
background-color: #fff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #9ca3af;
pointer-events: none;
}
.filter-input {
width: 100%;
padding: 6px 12px 6px 34px;
font-size: 13px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
color: #374151;
outline: none;
transition: all 0.15s ease;
}
.filter-input:focus {
border-color: #3b82f6;
background-color: #fff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-input::placeholder {
color: #9ca3af;
}
.filter-reset-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
font-size: 13px;
color: #6b7280;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-reset-btn:hover {
color: #374151;
background: #e5e7eb;
}
/* ===== Status Tags ===== */
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.status-tag--pass {
background: #dcfce7;
color: #166534;
}
.status-tag--fail {
background: #fee2e2;
color: #991b1b;
}
.status-tag--pending {
background: #f3f4f6;
color: #374151;
}
.status-tag--rejected {
background: #fee2e2;
color: #991b1b;
}
.status-tag--published {
background: #dbeafe;
color: #1e40af;
}
.status-tag--unpublished {
background: #f3f4f6;
color: #9ca3af;
}
.status-tag--conversation {
background: #ede9fe;
color: #5b21b6;
}
/* Sentiment tags */
.status-tag--very-positive {
background: #dcfce7;
color: #166534;
}
.status-tag--positive {
background: #bbf7d0;
color: #15803d;
}
.status-tag--neutral-sentiment {
background: #f3f4f6;
color: #4b5563;
}
.status-tag--negative {
background: #fecaca;
color: #b91c1c;
}
.status-tag--very-negative {
background: #fee2e2;
color: #991b1b;
}
/* ===== Score Label ===== */
.score-label {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin-left: 4px;
}
.score-label--excellent {
background: #dcfce7;
color: #166534;
}
.score-label--good {
background: #dbeafe;
color: #1e40af;
}
.score-label--average {
background: #fef3c7;
color: #92400e;
}
.score-label--poor {
background: #fee2e2;
color: #991b1b;
}
/* ===== Mobile Responsive ===== */
@media (max-width: 768px) {
.comment-ai-autopilot-logs :deep(.page-header) {
flex-wrap: wrap;
gap: 8px;
}
/* Batch toolbar: wrap buttons */
.comment-ai-autopilot-logs .m-4.mb-0.flex {
flex-wrap: wrap;
gap: 6px;
}
.batch-btn--cancel {
margin-left: 0;
}
/* Filter bar: stack vertically */
.comment-ai-autopilot-logs .m-4.mb-0.flex.items-center.gap-3:not(.bg-blue-50) {
flex-wrap: wrap;
gap: 8px;
}
.filter-select {
flex: 1;
min-width: 120px;
}
.relative.flex-1.max-w-xs {
max-width: 100%;
flex: 1 1 100%;
order: 10;
}
.filter-reset-btn {
flex-shrink: 0;
}
/* Card footer: stack meta and actions */
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 .flex.items-center.gap-4 {
flex-wrap: wrap;
gap: 8px;
}
.comment-ai-autopilot-logs .px-4.py-2\.5.bg-gray-50 .flex.items-center.gap-2 {
align-self: flex-end;
}
/* Status tags row: allow wrapping */
.comment-ai-autopilot-logs .flex.items-center.justify-between.mb-3 {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
/* Pagination */
.comment-ai-autopilot-logs .flex.items-center.justify-between.mt-4 {
flex-direction: column;
gap: 8px;
align-items: center;
}
/* Conversation dialog */
.comment-ai-autopilot-logs .relative.bg-white {
max-width: calc(100vw - 32px);
margin: 16px;
max-height: 90vh;
}
}
</style> </style>
+328 -17
View File
@@ -5,23 +5,46 @@
<IconPlug class="mr-2 self-center" /> <IconPlug class="mr-2 self-center" />
</template> </template>
<template #actions> <template #actions>
<VButton type="secondary" size="sm" @click="exportConfig">导出配置</VButton> <VSpace spacing="sm">
<VButton size="sm" @click="exportConfig">
<template #icon>
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</template>
导出
</VButton>
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors"> <label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
导入配置 <svg style="width:14px;height:14px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
导入
<input type="file" accept=".json" class="hidden" @change="handleImportFile" /> <input type="file" accept=".json" class="hidden" @change="handleImportFile" />
</label> </label>
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton> <VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
</VSpace>
</template> </template>
</VPageHeader> </VPageHeader>
<div class="m-4"> <div class="m-4">
<VLoading v-if="loading" /> <VLoading v-if="loading" />
<div v-if="!loading" class="settings-container"> <div v-if="!loading">
<!-- Tab Navigation (full width, above the grid) -->
<div class="settings-tabs">
<button
v-for="tab in tabItems"
:key="tab.value"
class="settings-tab"
:class="{ 'settings-tab--active': activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- Settings Grid: sections (left) + sidebar (right) -->
<div class="settings-container">
<!-- Left: Settings Sections --> <!-- Left: Settings Sections -->
<div class="settings-sections space-y-5"> <div class="settings-sections space-y-5">
<!-- ========== 基本设置 ========== --> <!-- ========== 基本设置 ========== -->
<section class="settings-section"> <section v-if="activeTab === 'basic'" class="settings-section">
<div class="section-header section-header--blue"> <div class="section-header section-header--blue">
<div class="section-header__icon"> <div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
@@ -104,7 +127,7 @@
</section> </section>
<!-- ========== AI角色设置 ========== --> <!-- ========== AI角色设置 ========== -->
<section class="settings-section"> <section v-if="activeTab === 'persona'" class="settings-section">
<div class="section-header section-header--purple"> <div class="section-header section-header--purple">
<div class="section-header__icon"> <div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
@@ -130,6 +153,10 @@
<div class="persona-card__info"> <div class="persona-card__info">
<div class="persona-card__name"> <div class="persona-card__name">
{{ p.spec.displayName || '未命名' }} {{ p.spec.displayName || '未命名' }}
<span v-if="p.spec.gender === 'female'" class="persona-card__badge persona-card__badge--female"></span>
<span v-else-if="p.spec.gender === 'male'" class="persona-card__badge persona-card__badge--male"></span>
<span v-if="p.spec.neutralVoice" class="persona-card__badge persona-card__badge--neutral-voice">中性语气</span>
<span v-if="p.spec.wakeWord" class="persona-card__badge persona-card__badge--wake-word">唤醒: {{ p.spec.wakeWord }}</span>
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span> <span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
</div> </div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div> <div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
@@ -148,14 +175,14 @@
</div> </div>
</div> </div>
<VButton type="secondary" @click="openPersonaDialog(null)"> <VButton type="secondary" @click="openPersonaDialog(null)">
<svg style="width:16px;height:16px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg style="width:16px;height:16px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
添加角色 添加角色
</VButton> </VButton>
</div> </div>
</section> </section>
<!-- ========== 模型设置 ========== --> <!-- ========== 模型设置 ========== -->
<section class="settings-section"> <section v-if="activeTab === 'model'" class="settings-section">
<div class="section-header section-header--green"> <div class="section-header section-header--green">
<div class="section-header__icon"> <div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
@@ -175,7 +202,7 @@
</section> </section>
<!-- ========== Prompt设置 ========== --> <!-- ========== Prompt设置 ========== -->
<section class="settings-section"> <section v-if="activeTab === 'prompt'" class="settings-section">
<div class="section-header section-header--amber"> <div class="section-header section-header--amber">
<div class="section-header__icon"> <div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/></svg>
@@ -216,7 +243,7 @@
</section> </section>
<!-- ========== 数据清理 ========== --> <!-- ========== 数据清理 ========== -->
<section class="settings-section"> <section v-if="activeTab === 'cleanup'" class="settings-section">
<div class="section-header section-header--red"> <div class="section-header section-header--red">
<div class="section-header__icon"> <div class="section-header__icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
@@ -275,16 +302,25 @@
<!-- Right: Sticky Save Bar --> <!-- Right: Sticky Save Bar -->
<div class="settings-sidebar"> <div class="settings-sidebar">
<div class="settings-sidebar__inner"> <div class="settings-sidebar__inner">
<div class="sidebar-card"> <div class="sidebar-card sidebar-card--primary">
<div class="sidebar-card__header">
<h4 class="sidebar-card__title">操作</h4> <h4 class="sidebar-card__title">操作</h4>
<span v-if="hasUnsavedChanges" class="sidebar-card__badge">未保存</span>
</div>
<div class="sidebar-card__actions"> <div class="sidebar-card__actions">
<VButton block type="primary" @click="saveSettings" :disabled="saving"> <VButton block type="primary" @click="saveSettings" :disabled="saving">
<template v-if="!saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</template>
{{ saving ? '保存中...' : '保存设置' }} {{ saving ? '保存中...' : '保存设置' }}
</VButton> </VButton>
<VButton block @click="fetchSettings">重置为当前值</VButton> <VButton block @click="fetchSettings" :disabled="saving">
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
重置为当前值
</VButton>
</div> </div>
</div> </div>
<div class="sidebar-card"> <div v-if="activeTab === 'prompt'" class="sidebar-card">
<h4 class="sidebar-card__title">可用模板变量</h4> <h4 class="sidebar-card__title">可用模板变量</h4>
<div class="sidebar-card__vars"> <div class="sidebar-card__vars">
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var"> <div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
@@ -297,6 +333,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Commenter Selection Dialog --> <!-- Commenter Selection Dialog -->
<div v-if="showCommenterDialog" class="dialog-overlay" @click.self="showCommenterDialog = false"> <div v-if="showCommenterDialog" class="dialog-overlay" @click.self="showCommenterDialog = false">
@@ -363,6 +400,33 @@
<span class="form-hint">用于Gravatar头像服务留空使用默认头像</span> <span class="form-hint">用于Gravatar头像服务留空使用默认头像</span>
<input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" /> <input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" />
</div> </div>
<!-- Gender & Neutral Voice -->
<div class="form-field">
<label class="form-label">性别与语气</label>
<div class="gender-voice-row">
<div class="gender-select">
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'female' }">
<input type="radio" v-model="personaForm.gender" value="female" class="gender-radio" />
<span class="gender-option__label"></span>
</label>
<label class="gender-option" :class="{ 'gender-option--active': personaForm.gender === 'male' }">
<input type="radio" v-model="personaForm.gender" value="male" class="gender-radio" />
<span class="gender-option__label"></span>
</label>
</div>
<div class="voice-toggle">
<input type="checkbox" v-model="personaForm.neutralVoice" class="form-checkbox" id="neutralVoice" />
<label for="neutralVoice" class="text-sm text-gray-600 cursor-pointer">中性语气</label>
</div>
</div>
<span class="form-hint">勾选中性语气则使用中性语气取消勾选则跟随性别语气</span>
</div>
<!-- Wake Word -->
<div class="form-field">
<label class="form-label">唤醒词</label>
<span class="form-hint">评论以此词开头则唤醒该角色回复留空不启用</span>
<input type="text" v-model="personaForm.wakeWord" class="form-input" placeholder="如:小回小回" />
</div>
<!-- Prompt --> <!-- Prompt -->
<div class="form-field"> <div class="form-field">
<label class="form-label">人格提示词</label> <label class="form-label">人格提示词</label>
@@ -414,8 +478,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue" import { ref, reactive, computed, onMounted, watch } from "vue"
import { axiosInstance, coreApiClient } from "@halo-dev/api-client" import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace } from "@halo-dev/components" import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace, IconPlug } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
const activeTab = ref("basic")
const tabItems = [
{ label: "基本设置", value: "basic" },
{ label: "AI角色", value: "persona" },
{ label: "模型设置", value: "model" },
{ label: "Prompt", value: "prompt" },
{ label: "数据清理", value: "cleanup" },
]
const promptVariables = [ const promptVariables = [
{ name: '{{persona_prompt}}', desc: '人格提示词' }, { name: '{{persona_prompt}}', desc: '人格提示词' },
@@ -477,6 +549,9 @@ const personaSaving = ref(false)
const personaForm = reactive({ const personaForm = reactive({
displayName: '', displayName: '',
email: '', email: '',
gender: 'female',
neutralVoice: false,
wakeWord: '',
prompt: '', prompt: '',
isDefault: false, isDefault: false,
}) })
@@ -489,6 +564,12 @@ const settings = reactive({
cleanup: { cleanupEnabled: true, retentionDays: 30 }, cleanup: { cleanupEnabled: true, retentionDays: 30 },
}) })
// Track unsaved changes: snapshot of settings after last fetch/save
const lastSavedSnapshot = ref("")
const hasUnsavedChanges = computed(() => {
return JSON.stringify(settings) !== lastSavedSnapshot.value
})
const configMapName = "comment-ai-autopilot-configmap" const configMapName = "comment-ai-autopilot-configmap"
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1" const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
@@ -661,11 +742,17 @@ const openPersonaDialog = async (persona: any | null) => {
if (persona) { if (persona) {
personaForm.displayName = persona.spec.displayName || '' personaForm.displayName = persona.spec.displayName || ''
personaForm.email = persona.spec.email || '' personaForm.email = persona.spec.email || ''
personaForm.gender = persona.spec.gender || 'female'
personaForm.neutralVoice = persona.spec.neutralVoice || false
personaForm.wakeWord = persona.spec.wakeWord || ''
personaForm.prompt = persona.spec.prompt || '' personaForm.prompt = persona.spec.prompt || ''
personaForm.isDefault = persona.spec.isDefault || false personaForm.isDefault = persona.spec.isDefault || false
} else { } else {
personaForm.displayName = '' personaForm.displayName = ''
personaForm.email = '' personaForm.email = ''
personaForm.gender = 'female'
personaForm.neutralVoice = false
personaForm.wakeWord = ''
personaForm.prompt = '' personaForm.prompt = ''
personaForm.isDefault = false personaForm.isDefault = false
} }
@@ -707,6 +794,9 @@ const savePersona = async () => {
spec: { spec: {
displayName: personaForm.displayName, displayName: personaForm.displayName,
email: personaForm.email, email: personaForm.email,
gender: personaForm.gender,
neutralVoice: personaForm.neutralVoice,
wakeWord: personaForm.wakeWord,
prompt: personaForm.prompt, prompt: personaForm.prompt,
isDefault: personaForm.isDefault, isDefault: personaForm.isDefault,
}, },
@@ -816,8 +906,15 @@ const fetchSettings = async () => {
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) } if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 } if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
} }
} catch (e) { console.error("Failed to fetch settings", e) } } catch (e) {
finally { loading.value = false } console.error("Failed to fetch settings", e)
Toast.error("加载设置失败,请刷新页面重试")
}
finally {
loading.value = false
// Update snapshot after fetch to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
}
} }
const saveSettings = async () => { const saveSettings = async () => {
@@ -834,6 +931,8 @@ const saveSettings = async () => {
} }
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated }) await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
Toast.success("设置已保存") Toast.success("设置已保存")
// Update snapshot after save to reset unsaved indicator
lastSavedSnapshot.value = JSON.stringify(settings)
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") } } catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
finally { saving.value = false } finally { saving.value = false }
} }
@@ -847,6 +946,43 @@ onMounted(async () => {
<style scoped> <style scoped>
/* ===== Layout ===== */ /* ===== Layout ===== */
.settings-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
padding: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow-x: auto;
}
.settings-tab {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-align: center;
}
.settings-tab:hover {
background: #f3f4f6;
color: #374151;
}
.settings-tab--active {
background: #3b82f6;
color: #fff;
}
.settings-tab--active:hover {
background: #2563eb;
color: #fff;
}
.settings-container { .settings-container {
display: grid; display: grid;
grid-template-columns: 1fr 280px; grid-template-columns: 1fr 280px;
@@ -1158,6 +1294,18 @@ onMounted(async () => {
padding: 1px 8px; font-size: 11px; font-weight: 500; padding: 1px 8px; font-size: 11px; font-weight: 500;
background: #dbeafe; color: #2563eb; border-radius: 4px; background: #dbeafe; color: #2563eb; border-radius: 4px;
} }
.persona-card__badge--female {
background: #fce7f3; color: #be185d;
}
.persona-card__badge--male {
background: #dbeafe; color: #1d4ed8;
}
.persona-card__badge--neutral-voice {
background: #f3f4f6; color: #4b5563;
}
.persona-card__badge--wake-word {
background: #fef3c7; color: #b45309;
}
.persona-card__prompt { .persona-card__prompt {
font-size: 12px; color: #6b7280; margin-top: 2px; font-size: 12px; color: #6b7280; margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -1191,6 +1339,56 @@ onMounted(async () => {
border-radius: 10px; border-radius: 10px;
} }
.form-checkbox {
width: 16px; height: 16px;
accent-color: #3b82f6;
cursor: pointer;
}
/* ===== Gender Select ===== */
.gender-voice-row {
display: flex;
align-items: center;
gap: 16px;
}
.gender-select {
display: flex; gap: 8px;
}
.gender-option {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
background: #f9fafb;
}
.gender-option:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.gender-option--active {
border-color: #3b82f6;
background: #eff6ff;
}
.gender-radio {
display: none;
}
.gender-option__label {
font-size: 14px; font-weight: 500; color: #374151;
}
.gender-option--active .gender-option__label {
color: #2563eb;
}
.voice-toggle {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ===== Button Link ===== */ /* ===== Button Link ===== */
.btn-link { .btn-link {
display: inline-flex; align-items: center; gap: 4px; display: inline-flex; align-items: center; gap: 4px;
@@ -1221,14 +1419,39 @@ onMounted(async () => {
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 20px; padding: 20px;
} }
.sidebar-card--primary {
border-color: #dbeafe;
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
}
.sidebar-card__header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.sidebar-card__badge {
display: inline-flex; align-items: center;
padding: 2px 8px; font-size: 11px; font-weight: 500;
background: #fef3c7; color: #d97706; border-radius: 4px;
}
.sidebar-card__title { .sidebar-card__title {
font-size: 14px; font-weight: 600; color: #1f2937; font-size: 14px; font-weight: 600; color: #1f2937;
margin: 0 0 14px; padding-bottom: 10px; margin: 0 0 14px; padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
} }
.sidebar-card--primary .sidebar-card__title {
margin-bottom: 0; padding-bottom: 0; border-bottom: none;
}
.sidebar-card__actions { .sidebar-card__actions {
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
} }
/* Align icon + text inside VButton (btn-content wraps the default slot) */
.sidebar-card__actions :deep(.btn-content),
.section-body :deep(.btn-content) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sidebar-card__vars { .sidebar-card__vars {
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
} }
@@ -1316,4 +1539,92 @@ onMounted(async () => {
.dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; } .dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; }
.dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; } .dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; }
.dialog__item-add { width: 18px; height: 18px; color: #3b82f6; flex-shrink: 0; } .dialog__item-add { width: 18px; height: 18px; color: #3b82f6; flex-shrink: 0; }
/* ===== Mobile Responsive ===== */
@media (max-width: 768px) {
.settings-page :deep(.page-header) {
flex-wrap: wrap;
gap: 8px;
}
.settings-page :deep(.page-header-actions) {
width: 100%;
overflow-x: auto;
}
.settings-page :deep(.page-header-actions .space-x-2) {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.settings-tabs {
gap: 2px;
padding: 3px;
border-radius: 10px;
}
.settings-tab {
padding: 8px 10px;
font-size: 13px;
}
.settings-container {
grid-template-columns: 1fr;
gap: 16px;
}
.settings-sidebar {
position: static;
order: -1;
}
.sidebar-card__actions {
flex-direction: row;
gap: 8px;
}
.sidebar-card__actions :deep(.btn) {
flex: 1;
}
.section-header {
padding: 12px 16px;
}
.section-body {
padding: 16px;
}
.preset-grid {
grid-template-columns: 1fr;
}
.form-row {
padding: 10px 12px;
}
.form-row--bordered {
margin: 0 -16px;
padding: 12px 16px;
}
.persona-card {
padding: 10px 12px;
}
.persona-card__prompt {
max-width: 100%;
}
.dialog {
max-width: calc(100vw - 32px);
margin: 16px;
border-radius: 12px;
}
.dialog__header {
padding: 16px 16px 0;
}
.dialog__search {
padding: 12px 16px;
}
.dialog__search-icon {
left: 28px;
}
.dialog__body {
max-height: 260px;
}
}
</style> </style>