diff --git a/README.md b/README.md index 5fc76cc..3cc576a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ ## 功能特性 -- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,支持排序,可为不同文章指定不同角色 +- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 -- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等) +- **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 -- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容,支持批量操作 +- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **失败重试** — AI 生成失败时自动重试,指数退避策略 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度 @@ -17,11 +17,10 @@ - **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复 - **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝 - **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等) -- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型、技术解答型、鼓励型、知识科普型预设风格,可多选组合 -- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息 +- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 +- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换 - **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性 -- **日志筛选** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页 -- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份 +- **日志筛选** — 按状态、情感筛选,关键词搜索 - **数据清理** — 自动清理超过指定天数的旧记录 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力 diff --git a/build.gradle b/build.gradle index 563178a..f56f01f 100644 --- a/build.gradle +++ b/build.gradle @@ -5,14 +5,14 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '0.0.0-ygkszvd' +version '1.0.0-beta.1' repositories { mavenCentral() } dependencies { - implementation platform('run.halo.tools.platform:plugin:2.23.0') + implementation platform('run.halo.tools.platform:plugin:2.25.0') compileOnly 'run.halo.app:api' compileOnly files('libs/ai-foundation-api.jar') implementation 'org.jsoup:jsoup:1.18.1' @@ -39,7 +39,7 @@ tasks.withType(JavaCompile).configureEach { tasks.register('processUiResources', Copy) { from project(':ui').layout.buildDirectory.dir('dist') - into layout.buildDirectory.dir('resources/main/console') + into layout.buildDirectory.dir('resources/main/ui') dependsOn project(':ui').tasks.named('assemble') shouldRunAfter tasks.named('processResources') } @@ -53,6 +53,6 @@ tasks.named('generatePluginComponentsIdx') { } halo { - version = '2.23' + version = '2.25' } diff --git a/docs/guide/auto-reply.md b/docs/guide/auto-reply.md index 0e825cc..854bf4e 100644 --- a/docs/guide/auto-reply.md +++ b/docs/guide/auto-reply.md @@ -31,8 +31,6 @@ |--------|------|--------| | 自动回复 | 是否启用自动回复功能 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | -| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 | -| 速率限制 | 每分钟最大AI回复数量 | 10 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | ## 重试机制 diff --git a/docs/guide/draft-mode.md b/docs/guide/draft-mode.md index 6323a5f..d86ff5a 100644 --- a/docs/guide/draft-mode.md +++ b/docs/guide/draft-mode.md @@ -9,32 +9,17 @@ ## 审核流程 1. 新评论到达后,AI生成回复内容 -2. 回复以草稿状态保存(状态为"通过",但未发布) +2. 回复以草稿状态保存(`approved = false`) 3. 在 **AI回复日志** 页面,草稿状态的记录会显示审核按钮 4. 管理员可以: - - **编辑** — 修改AI回复内容后再审核 - **审核通过** — 回复立即发布,访客可见 - **拒绝** — 删除草稿回复,记录标记为 REJECTED -## 编辑回复内容 - -草稿模式下,管理员可以在发布前编辑AI生成的回复内容: - -1. 在日志页面找到状态为"通过"且未发布的记录 -2. 点击 **编辑** 按钮 -3. 在弹出的编辑对话框中修改回复内容 -4. 点击 **保存** 完成修改 -5. 修改后可继续审核通过或拒绝 - -::: tip -编辑功能仅在草稿状态(通过但未发布)下可用。已发布的回复不支持编辑。 -::: - ## 日志页面操作 在AI回复日志页面: -- 草稿记录显示 **编辑**、**审核通过** 和 **拒绝** 按钮 +- 草稿记录显示 **审核通过** 和 **拒绝** 按钮 - 已发布的记录显示正常状态 - 被拒绝的记录显示 REJECTED 标签 @@ -52,19 +37,3 @@ ::: warning 批量操作不可撤销,请谨慎操作。 ::: - -## 草稿模式工作原理 - -草稿模式下,AI回复的处理流程与自动发布模式不同: - -| 步骤 | 自动发布模式 | 草稿模式 | -|------|------------|---------| -| AI生成回复 | ✅ | ✅ | -| 创建 Halo Reply 扩展 | ✅ 立即创建 | ❌ 不创建 | -| 保存 AiCommentReply 记录 | 状态=PASS, 已发布 | 状态=PASS, 未发布 | -| 审核通过时 | — | 创建 Reply 扩展并发布 | -| 拒绝时 | — | 标记为 REJECTED | - -::: info -草稿模式下不会创建 Halo 的 Reply 评论扩展,只有审核通过后才会创建。这意味着在审核前,评论区域不会显示任何AI回复。 -::: diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 25db149..8724a69 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -10,14 +10,10 @@ ## AI回复头像不显示? -1. 确认在AI角色设置中填写了邮箱 +1. 确认在插件设置中填写了AI角色邮箱 2. 邮箱需要在 [Gravatar](https://gravatar.com) 上注册并设置头像 3. 插件使用 [Cravatar](https://cravatar.cn) 作为Gravatar镜像服务 -::: info -由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。 -::: - ## 评论没有触发AI回复? 可能的原因: @@ -27,8 +23,6 @@ 3. **已有AI回复记录** — 同一评论不会重复触发 4. **历史评论** — 插件启动前的评论不会自动触发,可使用手动触发 5. **AI生成失败** — 检查AI模型配置和日志 -6. **速率限制** — 检查是否触发了每分钟回复数量限制 -7. **对话轮次上限** — 检查是否达到了最大对话轮次 ## 如何对历史评论触发AI回复? @@ -38,24 +32,12 @@ ## 草稿模式的回复在哪里审核? -在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"编辑"、"审核通过"和"拒绝"按钮。 - -## 如何在发布前编辑AI回复? - -1. 确保草稿模式已启用(关闭"自动发布"开关) -2. 在AI回复日志页面找到状态为"通过"且未发布的记录 -3. 点击 **编辑** 按钮修改回复内容 -4. 修改完成后点击"审核通过"发布 - -::: tip -编辑功能仅在草稿状态(通过但未发布)下可用。 -::: +在插件管理页面的 **AI回复日志** 中,草稿状态的记录会显示"审核通过"和"拒绝"按钮。 ## 如何修改AI回复的语气风格? 1. 在插件设置中修改 **AI角色人格提示词** 2. 或修改 **自定义Prompt模板** -3. 或选择不同的 **Prompt预设** 风格组合 ## AI Foundation 插件是必须的吗? @@ -81,27 +63,6 @@ 在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。 -## 黑名单支持正则表达式吗? +## 黑名单支持邮箱吗? -支持。黑名单支持三种匹配方式: -- **名称匹配**:直接输入评论者显示名称 -- **邮箱匹配**:输入邮箱地址 -- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*` - -## 如何管理多个AI角色? - -在插件设置的 **AI角色设置** 区域,可以添加、编辑、删除AI角色,也可以使用 ▲▼ 按钮调整角色顺序。每个角色有独立的昵称、人格提示词和Gravatar头像。 - -## 如何导入/导出配置? - -在插件设置页面右上角: -- 点击 **导出配置** 将当前设置和AI角色导出为 JSON 文件 -- 点击 **导入配置** 从 JSON 文件导入配置(导入前会显示确认对话框) - -## 日志页面如何按时间筛选? - -在日志页面的筛选栏中,使用日期选择器设置起始日期和结束日期,即可筛选指定时间范围内的记录。 - -## 为什么AI角色不支持自定义上传头像? - -Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,会忽略自定义头像 URL。这是 Halo 评论系统的设计限制,因此插件只支持通过 Gravatar 邮箱方式设置AI角色头像。 +支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。 diff --git a/docs/guide/filter.md b/docs/guide/filter.md index 3cee5e9..45ae346 100644 --- a/docs/guide/filter.md +++ b/docs/guide/filter.md @@ -32,21 +32,9 @@ 1. 进入插件设置页面 2. 在 **基本设置** 中找到 **评论者黑名单** -3. 输入评论者的显示名称、邮箱或正则表达式,多个用逗号分隔 +3. 输入评论者的显示名称或邮箱,多个用逗号分隔 4. 保存设置 -### 匹配方式 - -| 方式 | 格式 | 示例 | -|------|------|------| -| 名称匹配 | 直接输入名称 | `张三` | -| 邮箱匹配 | 输入邮箱地址 | `spam@example.com` | -| 正则表达式 | 以 `regex:` 开头 | `regex:^spam.*` | - -::: tip -正则表达式匹配可以批量屏蔽符合模式的评论者,例如 `regex:^bot` 会屏蔽所有以 "bot" 开头的评论者。 -::: - ### 从评论列表选择 1. 在黑名单输入框旁点击 **添加评论者** 按钮 @@ -57,7 +45,7 @@ ### 示例 ``` -张三,spam@example.com,李四,regex:^bot.* +张三,spam@example.com,李四 ``` -黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),正则表达式也会参与匹配。匹配成功则跳过AI回复。 +黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 901b297..5c5475d 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -5,19 +5,18 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 ## 核心功能 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 -- **多语言适配** — 根据评论语言自动用对应语言回复(中文、英文、日文、韩文等) +- **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 -- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持发布前编辑回复内容 +- **草稿模式** — AI回复先存为草稿,管理员审核后再发布 - **失败重试** — AI生成失败时自动重试,指数退避策略 - **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 -- **评论者黑名单** — 屏蔽指定评论者,支持名称、邮箱和正则表达式匹配 +- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复 -- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,支持多角色管理和排序 +- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像 - **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 -- **仪表盘统计** — 显示回复数、情感分布、通过率、平均审核评分等统计信息 -- **日志筛选搜索** — 按状态、情感筛选,关键词搜索,时间范围过滤,支持分页 -- **配置导入/导出** — 支持导出和导入插件配置,方便迁移和备份 +- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 +- **日志筛选搜索** — 按状态、情感筛选,关键词搜索 - **数据清理** — 自动清理超过指定天数的旧记录 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 diff --git a/docs/guide/persona.md b/docs/guide/persona.md index f382f5d..1b1ce63 100644 --- a/docs/guide/persona.md +++ b/docs/guide/persona.md @@ -1,28 +1,6 @@ # AI角色 -AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。支持创建多个角色并灵活管理。 - -## 多角色管理 - -插件支持创建多个AI角色,每个角色有独立的昵称、人格提示词和Gravatar头像。在设置页面的 **AI角色设置** 区域可以管理所有角色。 - -### 默认角色 - -系统必须保留一个默认角色,用于没有指定角色的场景。默认角色不可删除,但可以将其他角色设为默认。 - -### 角色排序 - -多角色场景下,可以使用角色卡片上的 ▲▼ 按钮调整角色顺序。排序会影响角色在列表中的展示顺序。 - -### 角色操作 - -| 操作 | 说明 | -|------|------| -| 添加角色 | 点击"添加角色"按钮创建新角色 | -| 编辑角色 | 点击编辑图标修改角色信息 | -| 删除角色 | 点击删除图标移除角色(默认角色不可删除) | -| 设为默认 | 点击星标图标将角色设为默认角色 | -| 排序 | 使用 ▲▼ 按钮调整角色顺序 | +AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。 ## 角色配置 @@ -50,11 +28,11 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 ### 邮箱与头像 -填写邮箱后,AI回复者的头像将通过 Cravatar(Gravatar 镜像)服务自动生成: +填写邮箱后,AI回复者的头像将通过 Gravatar 服务自动生成: 1. 插件根据邮箱生成 SHA-256 哈希 -2. 构造头像 URL:`https://cn.cravatar.com/avatar/{hash}` -3. Halo 评论系统通过 `email-hash` 注解自动匹配头像 +2. 构造 Gravatar URL:`https://cn.cravatar.com/avatar/{hash}` +3. 头像URL存储在评论的 `owner.annotations["avatar"]` 中 ::: warning 如果不填写邮箱,AI回复者将使用 Halo 默认头像。 @@ -62,7 +40,7 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 ### 头像预览 -在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览,方便确认头像是否正确。 +在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。 ::: tip 如果预览头像不正确,请检查: @@ -70,7 +48,3 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像 3. 头像更新可能有缓存延迟 ::: - -::: info -由于 Halo 评论系统的限制,AI角色头像仅支持通过 Gravatar 邮箱方式设置,不支持自定义上传头像。这是因为 Halo 的评论组件对匿名评论者(kind=Email)始终使用 `email-hash` 注解生成 Gravatar 头像,忽略自定义头像 URL。 -::: diff --git a/docs/guide/prompt.md b/docs/guide/prompt.md index 836e3e5..5516821 100644 --- a/docs/guide/prompt.md +++ b/docs/guide/prompt.md @@ -9,13 +9,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。 {{safety_prompt}} -【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征: -- 如果评论包含中文字符(汉字),请用中文回复 -- 如果评论包含日文假名(平假名/片假名),请用日文回复 -- 如果评论包含韩文字符,请用韩文回复 -- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复 -- 绝对不要用与评论不同的语言回复 - 请回复以下评论。注意: - 回复长度应与评论长度匹配,简短问候简短回复 - 不要复述或总结文章内容 @@ -32,20 +25,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。 {{comment}} ``` -## 多语言回复 - -默认模板中包含语言要求指令,AI会自动检测评论的语言并用相同语言回复: - -- 中文评论 → 中文回复 -- 英文评论 → 英文回复 -- 日文评论 → 日文回复 -- 韩文评论 → 韩文回复 -- 其他语言 → 根据语言特征用相同语言回复 - -::: tip -多语言回复是默认行为,无需额外配置。如果你自定义模板,建议保留语言要求部分。 -::: - ## 模板变量 | 变量 | 说明 | 注入时机 | @@ -60,26 +39,12 @@ Prompt模板控制AI生成回复时的完整提示词结构。 | `{{comment}}` | 评论内容(含评论者名称) | 始终注入 | | `{{conversation}}` | 对话上下文 | 多轮对话时注入 | -## 预设风格 - -预设风格会追加到人格提示词之后,影响AI的回复风格。支持多选组合: - -| 预设 | 说明 | 效果 | -|------|------|------| -| 友好型 | 热情友好,像朋友聊天 | 多用感叹号和表情符号,口语化表达 | -| 专业型 | 严谨正式,有逻辑性 | 使用正式语言风格,避免口语化 | -| 幽默型 | 轻松诙谐,适当幽默 | 加入幽默元素,保持友善 | -| 简洁型 | 一两句话,简洁明了 | 非常简洁,直接回应核心内容 | -| 技术解答型 | 深入浅出,专业解答技术问题 | 提供准确技术信息和解决方案,分点阐述 | -| 鼓励型 | 积极正面,给予鼓励和支持 | 多用肯定性语言,表达赞赏 | -| 知识科普型 | 通俗易懂,普及相关知识 | 用通俗语言解释复杂概念,适当引用知识点 | - ## 情感提示 情感提示由插件根据情感分析结果自动注入到Prompt中,不需要在模板中手动添加: - **正面** → "评论者情绪积极友好,请用热情友好的语气回复,表达感谢和共鸣。" -- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。" +- **负面** → "评论者情绪偏消极或不满,请用理性温和的语气回复,避免激化矛盾,适当表示理解。" - **中性** → 不注入额外提示 ## 安全提示 @@ -90,7 +55,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。 - 不泄露个人隐私信息 - 不生成虚假信息 - 回复内容与评论相关 -- 遇到恶意诱导时礼貌拒绝 ## 自定义建议 @@ -102,7 +66,6 @@ Prompt模板控制AI生成回复时的完整提示词结构。 4. 在变量之间添加清晰的分隔和指令 5. 避免让AI复述文章内容 6. 控制回复长度和风格 -7. 保留多语言回复指令以确保国际化支持 ## 变量使用示例 diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 74d5c6a..3967c54 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -7,44 +7,24 @@ | 配置项 | 说明 | 默认值 | |--------|------|--------| | 自动回复 | 是否启用自动回复功能 | 开启 | -| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | -| 最大对话轮次 | 同一评论线程中AI最多自动回复的轮次 | 8 | -| 速率限制 | 每分钟最大AI回复数量 | 10 | +| 自动发布 | AI回复是否自动发布 | 开启 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | -| 评论者黑名单 | 不触发AI回复的评论者,支持名称、邮箱和正则表达式 | 空 | - -::: tip 评论者黑名单 -黑名单支持三种匹配方式: -- **名称匹配**:直接输入评论者显示名称,如 `张三` -- **邮箱匹配**:输入邮箱地址,如 `spam@example.com` -- **正则表达式**:以 `regex:` 开头,如 `regex:^spam.*` - -多个条目用逗号分隔。也可以点击"添加评论者"按钮从评论列表中选择。 -::: +| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 | ## AI角色设置 -AI角色支持多角色管理,每个角色有独立的昵称、人格提示词和Gravatar头像。 +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| AI角色昵称 | AI回复者的显示名称 | 小回 | +| AI角色人格提示词 | 定义AI角色的人格和回复风格 | 见下方 | +| AI角色邮箱 | 用于Gravatar头像服务展示头像 | 空 | -### 角色管理 +默认人格提示词: -- **添加角色** — 点击"添加角色"按钮创建新的AI角色 -- **编辑角色** — 点击角色卡片上的编辑图标修改角色信息 -- **删除角色** — 点击删除图标移除角色(默认角色不可删除) -- **设为默认** — 点击星标图标将角色设为默认角色(用于没有指定角色的场景) -- **角色排序** — 使用 ▲▼ 按钮调整角色顺序 - -### 角色配置项 - -| 配置项 | 说明 | -|--------|------| -| 昵称 | AI回复者的显示名称 | -| 邮箱 | 用于Gravatar头像服务展示头像 | -| 人格提示词 | 定义AI角色的人格和回复风格 | -| 设为默认角色 | 是否作为默认角色使用 | +> 你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。 ::: tip Gravatar头像 -填写邮箱后,AI回复者的头像将通过 [Cravatar](https://cravatar.cn)(Gravatar 镜像)服务自动生成。在角色编辑对话框中输入邮箱后,会实时显示 Gravatar 头像预览。如果不填写邮箱,将使用默认头像。 +填写邮箱后,AI回复者的头像将通过 [Gravatar](https://gravatar.com) 服务自动生成。如果不填写邮箱,将使用默认头像。 ::: ## 模型设置 @@ -61,21 +41,8 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词 | 配置项 | 说明 | 默认值 | |--------|------|--------| -| Prompt预设 | 选择预设风格,可多选 | 无 | | 自定义Prompt模板 | AI生成回复时使用的Prompt模板 | 见下方 | -### 预设风格 - -| 预设 | 说明 | -|------|------| -| 友好型 | 热情友好,像朋友聊天 | -| 专业型 | 严谨正式,有逻辑性 | -| 幽默型 | 轻松诙谐,适当幽默 | -| 简洁型 | 一两句话,简洁明了 | -| 技术解答型 | 深入浅出,专业解答技术问题 | -| 鼓励型 | 积极正面,给予鼓励和支持 | -| 知识科普型 | 通俗易懂,普及相关知识 | - ### 模板变量 | 变量 | 说明 | @@ -85,9 +52,7 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词 | `{{sentiment_prompt}}` | 情感语气提示词(自动注入) | | `{{article}}` | 文章内容 | | `{{comment}}` | 评论内容 | -| `{{post_title}}` | 文章标题 | -| `{{post_date}}` | 文章发布日期 | -| `{{comment_count}}` | 评论数 | +| `{{conversation}}` | 对话上下文(多轮对话时) | ## 数据清理 @@ -97,16 +62,5 @@ AI角色支持多角色管理,每个角色有独立的昵称、人格提示词 | 保留天数 | 超过此天数的记录将被自动清理 | 30 | ::: tip -你也可以在数据清理区域点击"立即清理"按钮手动触发清理操作。 -::: - -## 配置导入/导出 - -设置页面右上角提供配置导入/导出功能: - -- **导出配置** — 将当前插件设置和AI角色导出为 JSON 文件 -- **导入配置** — 从 JSON 文件导入配置,导入前会显示确认对话框,包含配置内容预览 - -::: warning -导入配置将覆盖当前配置,此操作不可撤销。 +你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。 ::: diff --git a/docs/index.md b/docs/index.md index 78b7a09..4860314 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,5 +25,5 @@ features: - title: 灵活过滤 details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配 - title: 数据管理 - details: 仪表盘统计、日志筛选搜索与时间范围过滤、配置导入导出、自动清理旧记录 + details: 仪表盘统计、日志筛选搜索、自动清理旧记录 --- diff --git a/libs/ai-foundation-api.jar b/libs/ai-foundation-api.jar index 9d0e229..f6c647a 100644 Binary files a/libs/ai-foundation-api.jar and b/libs/ai-foundation-api.jar differ diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 3fa7bee..dc63c48 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -1,7 +1,6 @@ package top.nxxy335.commentaiautopilot.endpoint; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; @@ -54,17 +53,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final AiReplyOrchestrator orchestrator; private final AiReplyCleanupService cleanupService; - private final ObjectProvider aiFoundationClientProvider; + private final AiFoundationClient aiFoundationClient; private final CommentReplyPublisher commentReplyPublisher; private final ObjectMapper objectMapper; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; - public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) { + public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) { this.client = client; this.orchestrator = orchestrator; this.cleanupService = cleanupService; - this.aiFoundationClientProvider = aiFoundationClientProvider; + this.aiFoundationClient = aiFoundationClient; this.commentReplyPublisher = commentReplyPublisher; this.objectMapper = new ObjectMapper(); } @@ -134,8 +133,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { final Instant finalStartInstant = startInstant; final Instant finalEndInstant = endInstant; - // Check if we need in-memory filtering (keyword or date range) - boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null; + // Check if we need in-memory filtering (keyword, date range, status, or sentiment) + boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null + || !statusFilter.isBlank() || !sentimentFilter.isBlank(); if (needsMemoryFilter) { // Fall back to listAll + in-memory filter for complex queries @@ -184,30 +184,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .flatMap(result -> ServerResponse.ok().bodyValue(result)); } - // Simple filters only - use server-side pagination + // No filters - use server-side pagination directly Sort sort = "asc".equalsIgnoreCase(sortOrder) ? Sort.by(Sort.Order.asc("metadata.creationTimestamp")) : Sort.by(Sort.Order.desc("metadata.creationTimestamp")); var listOptions = ListOptions.builder().build(); - // Note: Halo's ListOptions fieldSelector support may be limited - // For status and sentiment, we'll still filter in memory but with paginated data return client.listBy(AiCommentReply.class, listOptions, PageRequestImpl.of(page - 1, size, sort)) .map(listResult -> { - var items = listResult.getItems(); - // Apply status/sentiment filter in memory on the current page - var filtered = items.stream() - .filter(r -> { - if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false; - if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false; - return true; - }) - .toList(); - Map result = new HashMap<>(); - result.put("items", filtered); + result.put("items", listResult.getItems()); result.put("total", listResult.getTotal()); result.put("page", page); result.put("size", size); @@ -438,31 +426,50 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .flatMap(record -> { String replyName = record.getSpec().getReplyName(); if (replyName == null || replyName.isBlank()) { - // Draft mode: no Reply extension exists, create one with approved=true - return commentReplyPublisher.publishReply( - record.getSpec().getCommentId(), - record.getSpec().getReply(), - record.getSpec().getPostId(), - record.getSpec().getReplyTo(), - true, - record.getSpec().getPersonaName() - ) - .switchIfEmpty(Mono.defer(() -> { - log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name); - return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布")); - })) - .flatMap(publishedReply -> { - String newReplyName = publishedReply.getMetadata().getName(); - return client.fetch(AiCommentReply.class, name) - .flatMap(latest -> { - latest.getSpec().setReplyName(newReplyName); - latest.getSpec().setPublished(true); - return client.update(latest); - }) + // Draft mode: no Reply extension exists yet. + // First check if a Reply already exists (e.g. from a previous autoPublish=true run) + return findReplyForRecord(record) + .flatMap(existingReply -> { + // Reply already exists, just approve it + existingReply.getSpec().setApproved(true); + existingReply.getSpec().setApprovedTime(Instant.now()); + return client.update(existingReply) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) - .filter(e -> e instanceof OptimisticLockingFailureException)); + .filter(e -> e instanceof OptimisticLockingFailureException)) + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(existingReply.getMetadata().getName()); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )) + .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); }) - .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); + .switchIfEmpty(Mono.defer(() -> { + // No existing Reply found, create a new approved one + return commentReplyPublisher.publishReply( + record.getSpec().getCommentId(), + record.getSpec().getReply(), + record.getSpec().getPostId(), + record.getSpec().getReplyTo(), + true, + record.getSpec().getPersonaName() + ) + .flatMap(publishedReply -> { + String newReplyName = publishedReply.getMetadata().getName(); + return client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(newReplyName); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)); + }) + .then(ServerResponse.ok().bodyValue(Map.of("message", "approved"))); + })); } else { // Reply extension already exists, set approved=true return client.fetch(Reply.class, replyName) @@ -536,27 +543,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .flatMap(record -> { String replyName = record.getSpec().getReplyName(); if (replyName == null || replyName.isBlank()) { - // Draft mode: no Reply extension exists, create one with approved=true - return commentReplyPublisher.publishReply( - record.getSpec().getCommentId(), - record.getSpec().getReply(), - record.getSpec().getPostId(), - record.getSpec().getReplyTo(), - true, - record.getSpec().getPersonaName() - ) - .switchIfEmpty(Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"))) - .flatMap(publishedReply -> { - String newReplyName = publishedReply.getMetadata().getName(); - return client.fetch(AiCommentReply.class, name) - .flatMap(latest -> { - latest.getSpec().setReplyName(newReplyName); - latest.getSpec().setPublished(true); - return client.update(latest); - }) + // Draft mode: check if Reply already exists first + return findReplyForRecord(record) + .flatMap(existingReply -> { + existingReply.getSpec().setApproved(true); + existingReply.getSpec().setApprovedTime(Instant.now()); + return client.update(existingReply) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) - .filter(e -> e instanceof OptimisticLockingFailureException)); - }); + .filter(e -> e instanceof OptimisticLockingFailureException)) + .then(Mono.defer(() -> client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(existingReply.getMetadata().getName()); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)) + )); + }) + .switchIfEmpty(Mono.defer(() -> + commentReplyPublisher.publishReply( + record.getSpec().getCommentId(), + record.getSpec().getReply(), + record.getSpec().getPostId(), + record.getSpec().getReplyTo(), + true, + record.getSpec().getPersonaName() + ) + .flatMap(publishedReply -> { + String newReplyName = publishedReply.getMetadata().getName(); + return client.fetch(AiCommentReply.class, name) + .flatMap(latest -> { + latest.getSpec().setReplyName(newReplyName); + latest.getSpec().setPublished(true); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException)); + }) + )); } else { // Reply extension already exists, set approved=true return client.fetch(Reply.class, replyName) @@ -860,21 +885,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } private Mono health(ServerRequest request) { - AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable(); - boolean aiFoundationInstalled = aiClient != null; - - if (!aiFoundationInstalled) { - return ServerResponse.ok().bodyValue( - new HealthResponse(false, false, false, "", "unhealthy")); - } - - // AI Foundation is installed, check if it's enabled and model is available - return aiClient.chat("ping", null) - .map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy")) - .onErrorResume(e -> { - log.debug("Health check: AI Foundation call failed: {}", e.getMessage()); - return Mono.just(new HealthResponse(true, true, false, "", "degraded")); - }) + // AiFoundationClient is always created; availability is checked at runtime + return aiFoundationClient.isAvailable() + .map(available -> (HealthResponse) new HealthResponse(available, available, available, "", available ? "healthy" : "degraded")) + .defaultIfEmpty(new HealthResponse(false, false, false, "", "unhealthy")) .flatMap(health -> ServerResponse.ok().bodyValue(health)); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java index 428c220..d9cf6f2 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java @@ -60,7 +60,7 @@ public class AiCommentReply extends AbstractExtension { @Schema(description = "使用的AI角色名称") private String personaName; - @Schema(description = "关联的Reply扩展名称,草稿模式下为空") + @Schema(description = "已发布的回复名称") private String replyName; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java index 364d937..57fa9e7 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java @@ -38,7 +38,7 @@ public class AiPersona extends AbstractExtension { @JsonProperty("isDefault") private Boolean isDefault; - @Schema(description = "排序优先级,数值越小越靠前") + @Schema(description = "角色优先级,数值越小优先级越高") private Integer priority; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java index 793eb06..6d23e6e 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java @@ -72,6 +72,8 @@ public class ReplyReconciler implements Reconciler { String parentCommentName = reply.getSpec().getCommentName(); if (parentCommentName == null || parentCommentName.isBlank()) { + markProcessed(reply); + client.update(reply); return; } @@ -83,6 +85,8 @@ public class ReplyReconciler implements Reconciler { // No quoteReply - this is a direct reply to the top-level comment, // NOT a reply to AI. Skip it (CommentReconciler handles top-level comments). log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name); + markProcessed(reply); + client.update(reply); return; } @@ -92,6 +96,8 @@ public class ReplyReconciler implements Reconciler { if (!isReplyToAi) { log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name); + markProcessed(reply); + client.update(reply); return; } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java index 740097f..c349d1f 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java @@ -1,32 +1,43 @@ package top.nxxy335.commentaiautopilot.service; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import run.halo.aifoundation.AiModelService; -import run.halo.aifoundation.chat.LanguageModel; -import run.halo.aifoundation.chat.GenerateTextResult; + +import java.lang.reflect.Method; +import java.util.Map; /** - * AI Foundation client that calls the AI Foundation plugin's AiModelService. - * Only instantiated when AI Foundation classes are available (via @ConditionalOnClass). + * AI Foundation client that uses runtime class loading and reflection + * to call the AI Foundation plugin's AiModelService. + *

+ * This approach avoids classloader identity issues by loading AiModelService + * from ai-foundation's own classloader, so that Spring's getBeansOfType() + * can correctly match the implementation bean. + *

+ * No @ConditionalOnClass or pluginDependencies needed. + * Always registered as a bean; availability is checked at runtime. */ @Slf4j -@RequiredArgsConstructor +@Component public class AiFoundationClient { private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation"; + private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService"; - private final ExtensionGetter extensionGetter; private final ReactiveExtensionClient client; + private final ApplicationContext applicationContext; + + public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) { + this.client = client; + this.applicationContext = applicationContext; + } /** * Call AI Foundation to generate a chat response using the specified model. - * Checks at runtime whether the ai-foundation plugin is installed and enabled - * before attempting to use it. * * @param prompt the prompt text * @param modelName the AiModel metadata.name, null or blank to use default model @@ -44,8 +55,16 @@ public class AiFoundationClient { } /** - * Check if the ai-foundation plugin is installed and enabled at runtime. + * Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found. */ + public Mono isAvailable() { + return isAiFoundationEnabled() + .flatMap(enabled -> { + if (!enabled) return Mono.just(false); + return findAiModelService().hasElement(); + }); + } + private Mono isAiFoundationEnabled() { return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME) .map(plugin -> plugin.getSpec().getEnabled()) @@ -57,24 +76,144 @@ public class AiFoundationClient { } private Mono doChat(String prompt, String modelName) { - return extensionGetter.getEnabledExtension(AiModelService.class) - .flatMap(service -> { - Mono modelMono; - if (modelName != null && !modelName.isBlank()) { - modelMono = service.languageModel(modelName); - } else { - modelMono = service.languageModel(); - } - return modelMono.flatMap(model -> model.generateText(prompt) - .map(GenerateTextResult::getText) - .doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'", - text.length(), modelName != null ? modelName : "default")) - ); - }) + return findAiModelService() + .flatMap(service -> invokeLanguageModel(service, modelName) + .flatMap(model -> invokeGenerateText(model, prompt)) + ) .doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage())) .onErrorResume(e -> { log.warn("AI Foundation not available: {}", e.getMessage()); return Mono.empty(); }); } + + /** + * Get PluginManager via the pluginWrapper bean registered in our plugin context. + * Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton: + * beanFactory.registerSingleton("pluginWrapper", pluginWrapper); + * Then PluginWrapper.getPluginManager() gives us the PluginManager instance. + */ + private Object findPluginManager() { + try { + Object pluginWrapper = applicationContext.getBean("pluginWrapper"); + Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager"); + getPluginManagerMethod.setAccessible(true); + Object pm = getPluginManagerMethod.invoke(pluginWrapper); + if (pm != null) { + log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName()); + } + return pm; + } catch (NoSuchMethodException e) { + log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage()); + } catch (Exception e) { + log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage()); + } + log.warn("PluginManager not found"); + return null; + } + + /** + * Find the AiModelService bean from ai-foundation's PluginApplicationContext. + * Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection + * to get the plugin's ApplicationContext. + */ + private Mono 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 beans = pluginAppContext.getBeansOfType(aiModelServiceClass); + if (beans.isEmpty()) { + log.debug("AiModelService bean not found in ai-foundation's ApplicationContext"); + return null; + } + + log.info("Found AiModelService bean in ai-foundation's ApplicationContext"); + Object result = beans.values().iterator().next(); + return (Object) result; + }).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage())); + } + + /** + * Call service.languageModel(modelName) or service.languageModel() via reflection. + * Returns Mono<LanguageModel> from ai-foundation's classloader. + */ + private Mono invokeLanguageModel(Object service, String modelName) { + return Mono.fromCallable(() -> { + Method method; + if (modelName != null && !modelName.isBlank()) { + method = service.getClass().getMethod("languageModel", String.class); + method.setAccessible(true); + return method.invoke(service, modelName); + } else { + method = service.getClass().getMethod("languageModel"); + method.setAccessible(true); + return method.invoke(service); + } + }).flatMap(result -> { + if (result instanceof Mono mono) return mono; + return Mono.justOrEmpty(result); + }); + } + + /** + * Call model.generateText(prompt) via reflection, then extract text from result. + * Returns the generated text string. + */ + private Mono invokeGenerateText(Object model, String prompt) { + return Mono.fromCallable(() -> { + Method method = model.getClass().getMethod("generateText", String.class); + method.setAccessible(true); + return method.invoke(model, prompt); + }).flatMap(result -> { + if (result instanceof Mono mono) { + return mono.map(this::extractText); + } + return Mono.justOrEmpty(extractText(result)); + }); + } + + private String extractText(Object result) { + if (result == null) return null; + try { + Method getText = result.getClass().getMethod("getText"); + getText.setAccessible(true); + return (String) getText.invoke(result); + } catch (Exception e) { + throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e); + } + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationConfiguration.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationConfiguration.java deleted file mode 100644 index af05cfc..0000000 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationConfiguration.java +++ /dev/null @@ -1,23 +0,0 @@ -package top.nxxy335.commentaiautopilot.service; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.plugin.extensionpoint.ExtensionGetter; - -/** - * Configuration that registers AiFoundationClient only when - * AI Foundation plugin classes are available in the classloader. - * When AI Foundation is not installed, this entire configuration is skipped. - */ -@Configuration -@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService") -public class AiFoundationConfiguration { - - @Bean - public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter, - ReactiveExtensionClient client) { - return new AiFoundationClient(extensionGetter, client); - } -} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyService.java index c3065f7..9111358 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyService.java @@ -1,7 +1,6 @@ package top.nxxy335.commentaiautopilot.service; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @@ -9,10 +8,10 @@ import reactor.core.publisher.Mono; @Slf4j public class AiReplyService { - private final ObjectProvider aiFoundationClientProvider; + private final AiFoundationClient aiFoundationClient; - public AiReplyService(ObjectProvider aiFoundationClientProvider) { - this.aiFoundationClientProvider = aiFoundationClientProvider; + public AiReplyService(AiFoundationClient aiFoundationClient) { + this.aiFoundationClient = aiFoundationClient; } /** @@ -22,12 +21,7 @@ public class AiReplyService { * @param modelName the model name (null for default) */ public Mono generateReply(String prompt, String modelName) { - AiFoundationClient client = aiFoundationClientProvider.getIfAvailable(); - if (client == null) { - log.warn("AI Foundation plugin is not installed, cannot generate reply"); - return Mono.empty(); - } - return client.chat(prompt, modelName) + return aiFoundationClient.chat(prompt, modelName) .doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage())) .onErrorResume(e -> { log.warn("AI Foundation not available: {}", e.getMessage()); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index 5adbfc7..1755cc3 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -39,18 +39,6 @@ public class PromptBuilder { private static final String PRESET_CONCISE = """ 【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。 -"""; - - private static final String PRESET_TECHNICAL = """ -【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。 -"""; - - private static final String PRESET_ENCOURAGING = """ -【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。 -"""; - - private static final String PRESET_EDUCATIONAL = """ -【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。 """; private static final Map PRESET_MAP = new LinkedHashMap<>(); @@ -59,9 +47,6 @@ public class PromptBuilder { PRESET_MAP.put("professional", PRESET_PROFESSIONAL); PRESET_MAP.put("humorous", PRESET_HUMOROUS); PRESET_MAP.put("concise", PRESET_CONCISE); - PRESET_MAP.put("technical", PRESET_TECHNICAL); - PRESET_MAP.put("encouraging", PRESET_ENCOURAGING); - PRESET_MAP.put("educational", PRESET_EDUCATIONAL); } private static final String SAFETY_PROMPT = """ @@ -76,12 +61,7 @@ public class PromptBuilder { {{safety_prompt}} - 【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征: -- 如果评论包含中文字符(汉字),请用中文回复 -- 如果评论包含日文假名(平假名/片假名),请用日文回复 -- 如果评论包含韩文字符,请用韩文回复 -- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复 -- 绝对不要用与评论不同的语言回复 + 【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。 请回复以下评论。注意: - 回复长度应与评论长度匹配,简短问候简短回复 diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java index c9ec5ce..367dc72 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java @@ -1,7 +1,6 @@ package top.nxxy335.commentaiautopilot.service; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @@ -9,10 +8,10 @@ import reactor.core.publisher.Mono; @Slf4j public class ReviewService { - private final ObjectProvider aiFoundationClientProvider; + private final AiFoundationClient aiFoundationClient; - public ReviewService(ObjectProvider aiFoundationClientProvider) { - this.aiFoundationClientProvider = aiFoundationClientProvider; + public ReviewService(AiFoundationClient aiFoundationClient) { + this.aiFoundationClient = aiFoundationClient; } private static final String REVIEW_PROMPT_TEMPLATE = """ @@ -37,18 +36,12 @@ public class ReviewService { public Mono review(String articleContent, String commentContent, String aiReply, String modelName) { - AiFoundationClient client = aiFoundationClientProvider.getIfAvailable(); - if (client == null) { - log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)"); - return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过")); - } - String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE, truncate(articleContent, 2000), truncate(commentContent, 500), truncate(aiReply, 500)); - return client.chat(reviewPrompt, modelName) + return aiFoundationClient.chat(reviewPrompt, modelName) .map(this::parseSafetyResult) .defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过")) .onErrorResume(e -> { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java index afc5b48..bf0b51b 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java @@ -1,7 +1,6 @@ package top.nxxy335.commentaiautopilot.service; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @@ -9,10 +8,10 @@ import reactor.core.publisher.Mono; @Slf4j public class SentimentService { - private final ObjectProvider aiFoundationClientProvider; + private final AiFoundationClient aiFoundationClient; - public SentimentService(ObjectProvider aiFoundationClientProvider) { - this.aiFoundationClientProvider = aiFoundationClientProvider; + public SentimentService(AiFoundationClient aiFoundationClient) { + this.aiFoundationClient = aiFoundationClient; } public record SentimentResult(String sentiment, double confidence) { @@ -22,15 +21,9 @@ public class SentimentService { } public Mono analyzeSentiment(String commentContent, String modelName) { - AiFoundationClient client = aiFoundationClientProvider.getIfAvailable(); - if (client == null) { - log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL"); - return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0)); - } - String prompt = buildSentimentPrompt(commentContent); - return client.chat(prompt, modelName) + return aiFoundationClient.chat(prompt, modelName) .map(response -> { String sentiment = parseSentiment(response); return new SentimentResult(sentiment, 1.0); diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 7074152..457a6ab 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -7,7 +7,7 @@ metadata: name: comment-ai-autopilot spec: enabled: true - requires: ">=2.23.0" + requires: ">=2.25.0" author: name: 暖心向阳335 website: https://nxxy335.top @@ -22,6 +22,4 @@ spec: url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" settingName: "comment-ai-autopilot-settings" configMapName: "comment-ai-autopilot-configmap" - version: "0.0.0-ygkszvd" - pluginDependencies: - ai-foundation: "*" + version: "1.0.0-beta.1" diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue index b4b3399..5be0f66 100644 --- a/ui/src/views/HomeView.vue +++ b/ui/src/views/HomeView.vue @@ -177,6 +177,53 @@ + + +
+

近7日回复趋势

+
+ + + +
+
+
+
+
{{ day.count }}
+
+
{{ formatTrendDate(day.date) }}
+
+
+
+ 暂无数据 +
+
@@ -252,11 +299,16 @@