From 2c88ad6fb96d56bc91a17474e1bc2f9bc5125826 Mon Sep 17 00:00:00 2001 From: sunny-335 Date: Thu, 18 Jun 2026 22:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.0.0=20-=20=E5=94=A4=E9=86=92?= =?UTF-8?q?=E8=AF=8D=E3=80=81=E6=80=A7=E5=88=AB=E9=85=8D=E7=BD=AE=E3=80=81?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=BC=BA=E5=8C=96=E3=80=81=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=E3=80=81Bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +- build.gradle | 2 +- docs/CHANGELOG.md | 20 +- docs/guide/auto-reply.md | 26 +++ docs/guide/installation.md | 14 +- docs/guide/introduction.md | 21 +- docs/guide/persona.md | 24 ++- docs/guide/settings.md | 6 +- .../CommentAiAutopilotPlugin.java | 3 + .../ObjectMapperConfiguration.java | 14 ++ .../endpoint/CommentAiAutopilotEndpoint.java | 4 +- .../extension/AiPersona.java | 10 + .../listener/CommentReconciler.java | 60 +++++- .../listener/ReplyReconciler.java | 82 +++++--- .../service/AiReplyOrchestrator.java | 126 +++++++++--- .../service/ContextExtractor.java | 37 +++- .../service/PromptBuilder.java | 39 +++- .../service/WakeWordService.java | 101 +++++++++ src/main/resources/plugin.yaml | 2 +- ui/src/views/HomeView.vue | 24 +++ ui/src/views/LogsView.vue | 70 +++++++ ui/src/views/SettingsView.vue | 193 ++++++++++++++++++ 22 files changed, 787 insertions(+), 106 deletions(-) create mode 100644 src/main/java/top/nxxy335/commentaiautopilot/ObjectMapperConfiguration.java create mode 100644 src/main/java/top/nxxy335/commentaiautopilot/service/WakeWordService.java diff --git a/README.md b/README.md index b0d2546..53ebd6a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ ## 功能特性 -- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 +- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色 +- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 @@ -30,16 +31,18 @@ ## 安装 +### 应用商店安装 + +进入 **插件** → **安装** → 应用市场搜索 **AI回评** → 安装,或前往 [Halo 应用商店](https://www.halo.run/store/apps/app-mo5tivjt) 一键安装。 + +### 手动安装 + 1. 前往 [Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 2. 登录 Halo 管理后台 -3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮 +3. 进入 **插件** → **安装** → **本地上传** 4. 选择下载的 `.jar` 文件上传 5. 安装完成后启用插件 -::: tip -安装本插件后,应用市场会推荐安装 AI Foundation 插件(本插件的必要依赖)。 -::: - ## 从源码构建 ```bash diff --git a/build.gradle b/build.gradle index 2336a4d..6af1ed1 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '1.0.0-b26cea' +version '1.0.0' repositories { mavenCentral() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e365980..591a024 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,9 +1,16 @@ # 更新日志 -## v1.0.0-b26cea +## v1.0.0 > 2026-06-18 +### 新功能 + +- **唤醒词**:评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI,二级评论同样支持 +- **性别配置**:AI角色支持性别设置(男/女),AI回复时会保持对应性别身份 +- **语气风格**:支持中性语气复选框,勾选后使用中性语气,取消勾选则跟随性别语气(女性温柔细腻/男性沉稳理性) +- **身份提示词强化**:角色身份信息前置到Prompt最开头(【核心身份】),安全规范中增加身份约束,确保AI始终保持角色身份 + ### 改进 - **优化情感分析系统**:从 3 级分类(正面/中性/负面)升级为 5 级分类(非常正面/正面/中性/负面/非常负面),情感判断更精细 @@ -11,12 +18,23 @@ - **优化评分显示**:评分数字与等级标签之间添加间距,等级标签增加底色背景(优秀/良好/一般/较差) - **优化状态标签**:通过状态、发布状态、情感标签统一使用带底色的标签样式 - **支持页面链接显示**:日志中新增独立页面(SinglePage)链接显示,之前仅支持文章链接 +- **移动端适配**:仪表盘、配置、日志页面全面适配移动端 - **ObjectMapper 统一注入**:FilterService 和 PromptBuilder 中的 `new ObjectMapper()` 改为 Spring 构造函数注入 - **服务端过滤优化**:日志列表查询改用 `Queries.equal()` 服务端过滤 status/sentiment,减少内存过滤开销 - **新增索引**:为 AiCommentReply 扩展添加 `spec.sentiment`、`spec.published`、`spec.postKind` 索引 - **新增 postKind 字段**:区分关联内容类型(Post/SinglePage),支持页面评论的链接生成 - **PromptBuilder 情感提示**:适配 5 级情感分类,新增 VERY_POSITIVE 和 VERY_NEGATIVE 的语气提示 +### Bug 修复 + +- **修复 ObjectMapper Bean 不存在**:Halo 插件上下文中没有自动注册 ObjectMapper Bean,创建 ObjectMapperConfiguration 手动注册 +- **修复 AI 回复仍说没有性别**:将身份信息前置到 Prompt 最开头,安全规范中删除"作为AI助手"措辞,新增身份约束 +- **修复唤醒词无法唤醒**:评论内容提取时对 raw 也做 HTML strip(Jsoup.clean),所有内容做 trim(),wakeWord 也做 trim() +- **修复二级评论唤醒词检查位置错误**:唤醒词检查提前到 isReplyToAi 判断之前 +- **修复 SinglePage 内容获取 404**:PostContentService 不能用于 SinglePage,改用 SinglePage.getStatus().getExcerpt() +- **修复 Post/SinglePage 404 容错**:fetch 添加 onErrorResume 降级为空上下文继续处理 +- **修复 Sort 参数 null 警告**:listAll 调用改为 Sort.unsorted() + --- ## v1.0.0-beta.2 diff --git a/docs/guide/auto-reply.md b/docs/guide/auto-reply.md index 0e825cc..a099131 100644 --- a/docs/guide/auto-reply.md +++ b/docs/guide/auto-reply.md @@ -21,6 +21,32 @@ 插件启动时间之前的评论不会触发自动回复,避免安装插件后对大量历史评论批量回复。 +## 唤醒词机制 + +唤醒词允许用户在评论中通过特定词语唤醒指定AI角色回复,即使该页面未启用AI回评。 + +### 工作方式 + +1. 用户发表以唤醒词开头的评论(如"小回小回你好") +2. 插件检测到唤醒词匹配,自动唤醒对应角色 +3. 唤醒词后的内容作为实际评论内容传递给AI +4. AI生成回复时自动获取上下文(文章内容、对话历史等) + +### 唤醒词特点 + +- **跨页面唤醒**:在未启用AI回评的页面也能使用唤醒词召唤AI +- **二级评论支持**:回复中同样可以使用唤醒词 +- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色 +- **绕过限制**:唤醒词触发时绕过页面级启用检查和"必须是回复给AI"的检查,但仍检查黑名单 + +### 配置唤醒词 + +在 **AI回评** → **插件设置** → **AI角色设置** 中,为每个角色配置唤醒词。唤醒词留空则不启用该角色的唤醒功能。 + +::: tip +唤醒词建议设置为容易记忆且不易与正常评论混淆的词语。 +::: + ## 对话式回复 当评论者回复AI的评论时,插件会自动提取对话上下文(最近5条回复),让AI的回复更连贯自然。 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 3aa2cde..1552dc7 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -7,19 +7,19 @@ ## 安装 -### 方式一:从 Release 下载 +### 方式一:应用商店安装 + +进入 **插件** → **安装** → 应用市场搜索 **AI回评** → 安装,或前往 [Halo 应用商店](https://www.halo.run/store/apps/app-mo5tivjt) 一键安装。 + +### 方式二:从 Release 下载 1. 前往 [GitHub Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 2. 登录 Halo 管理后台 -3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮 +3. 进入 **插件** → **安装** → **本地上传** 4. 选择下载的 `.jar` 文件上传 5. 安装完成后启用插件 -::: tip 推荐安装 -安装本插件后,应用市场会推荐安装 AI Foundation 插件(本插件的必要依赖)。 -::: - -### 方式二:从源码构建 +### 方式三:从源码构建 ```bash # 克隆仓库 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index a6f6c45..7351d0d 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -5,7 +5,9 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 ## 核心功能 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 -- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 +- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可为不同文章指定不同角色 +- **唤醒词** — 评论以唤醒词开头可唤醒指定角色回复,支持自定义唤醒词,可在未启用AI回评的页面使用唤醒词召唤AI +- **性别与语气** — AI角色支持性别配置(男/女)和中性语气风格,AI回复时保持对应性别身份 - **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 - **草稿模式** — AI回复先存为草稿,管理员审核后再发布 @@ -25,19 +27,20 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 ## 工作流程 ``` -新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿 +新评论 → 唤醒词检查 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿 ↓ (失败) 重试 → ... → 最终失败 ``` 1. **新评论到达** — Reconciler 监听到新评论创建事件 -2. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中 -3. **情感分析** — 调用AI分析评论情感倾向 -4. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt -5. **AI生成** — 调用AI模型生成回复内容 -6. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100) -7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核 -8. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增 +2. **唤醒词检查** — 检查评论是否以某个角色的唤醒词开头,匹配则唤醒对应角色 +3. **过滤检查** — 检查文章/页面是否启用AI回复、评论者是否在黑名单中(唤醒词触发时绕过页面级启用检查) +4. **情感分析** — 调用AI分析评论情感倾向 +5. **构建Prompt** — 结合AI角色人格、情感提示、文章内容、评论上下文构建Prompt +6. **AI生成** — 调用AI模型生成回复内容 +7. **安全审核** — 对生成内容进行两阶段审核(安全检查 + 质量评分 1-5 分映射到 0-100) +8. **发布/草稿** — 根据设置自动发布或存为草稿等待审核 +9. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增 ## 前置要求 diff --git a/docs/guide/persona.md b/docs/guide/persona.md index 1397926..bf33fc6 100644 --- a/docs/guide/persona.md +++ b/docs/guide/persona.md @@ -1,6 +1,6 @@ # AI角色 -AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。 +AI角色定义了回复评论的虚拟身份,包括昵称、人格、性别、语气风格和头像。 ## 角色配置 @@ -8,6 +8,28 @@ AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。 AI回复者的显示名称,默认为「小回」。修改后新回复将使用新昵称,已有回复不受影响。 +### 性别与语气 + +每个角色可以设置性别(男/女),AI回复时会保持对应性别身份。语气风格通过"中性语气"复选框控制: + +- **勾选中性语气**:AI使用中性语气回复 +- **取消勾选**:AI根据性别使用对应语气风格(女性→温柔细腻,男性→沉稳理性) + +默认角色「小回」的性别为女,勾选中性语气。 + +### 唤醒词 + +评论以唤醒词开头可唤醒该角色回复。唤醒词功能的特点: + +- **跨页面唤醒**:在未启用AI回评的页面,使用唤醒词也能召唤AI回复 +- **二级评论支持**:回复中同样可以使用唤醒词唤醒指定角色 +- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色 +- **留空不启用**:唤醒词留空则不启用该角色的唤醒功能 + +::: tip +唤醒词匹配时,插件会自动去除评论中的HTML标签并去除首尾空格,确保匹配准确。 +::: + ### 人格提示词 人格提示词定义了AI角色的性格和回复风格,是影响回复质量的关键配置。 diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 5862b78..be8b939 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -30,11 +30,13 @@ ## AI角色设置 -AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可指定一个为默认角色。 +AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。 | 配置项 | 说明 | 默认值 | |--------|------|--------| | 角色昵称 | AI回复者的显示名称 | 小回 | +| 性别与语气 | 角色性别(男/女)+ 中性语气复选框(勾选=中性语气,取消勾选=跟随性别语气) | 女 + 中性语气 | +| 唤醒词 | 评论以此词开头则唤醒该角色回复,留空不启用 | 空 | | 人格提示词 | 定义AI角色的人格和回复风格 | 见下方 | | 邮箱 | 用于 Gravatar 头像服务展示头像 | 空 | | 设为默认 | 将该角色设为默认角色 | 第一个角色默认 | @@ -90,8 +92,10 @@ AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个 ::: tip 情感提示 情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加: +- **非常正面** → 追加热情洋溢的语气提示 - **正面** → 追加热情友好的语气提示 - **负面** → 追加理性温和的语气提示 +- **非常负面** → 追加冷静关怀的语气提示 - **中性** → 不追加额外提示 ::: diff --git a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java index 469e763..f2af727 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java @@ -67,6 +67,9 @@ public class CommentAiAutopilotPlugin extends BasePlugin { spec.setDisplayName("小回"); spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。"); spec.setEmail(""); + spec.setGender("female"); + spec.setNeutralVoice(true); + spec.setWakeWord("小回小回"); spec.setIsDefault(true); persona.setSpec(spec); return client.create(persona); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/ObjectMapperConfiguration.java b/src/main/java/top/nxxy335/commentaiautopilot/ObjectMapperConfiguration.java new file mode 100644 index 0000000..6a7895b --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/ObjectMapperConfiguration.java @@ -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(); + } +} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 3dcd71a..4910205 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -643,7 +643,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { // Read persona name from post annotations return personaResolver.getPersonaNameFromComment(commentName) .flatMap(personaName -> - orchestrator.processComment(commentName, null, false, personaName) + orchestrator.processComment(commentName, null, false, personaName, false) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))) ); }); @@ -670,7 +670,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } return personaResolver.getPersonaNameFromComment(commentName) .flatMap(personaName -> - orchestrator.processComment(commentName, replyName, true, personaName) + orchestrator.processComment(commentName, replyName, true, personaName, false) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))) ); }); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java index 57fa9e7..9b95e10 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java @@ -34,6 +34,16 @@ public class AiPersona extends AbstractExtension { @Schema(description = "邮箱(用于Gravatar头像)") 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 = "是否为默认角色") @JsonProperty("isDefault") private Boolean isDefault; diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java index d4e2f31..10861e9 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java @@ -12,6 +12,7 @@ import run.halo.app.extension.controller.Reconciler; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.PersonaResolver; +import top.nxxy335.commentaiautopilot.service.WakeWordService; import java.time.Instant; import java.util.HashMap; @@ -25,6 +26,7 @@ public class CommentReconciler implements Reconciler { private final ExtensionClient client; 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 AI_PERSONA_OWNER_PREFIX = "ai-persona-"; @@ -76,18 +78,38 @@ public class CommentReconciler implements Reconciler { markProcessed(comment); client.update(comment); - // Read persona name from the post's annotations - String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment); + // Check for wake word in comment content + String commentContent = getCommentContent(comment); + log.info("[CommentReconciler] Wake word check for comment {}: content='{}'", + name, commentContent.length() > 80 ? commentContent.substring(0, 80) + "..." : commentContent); + var wakeMatch = wakeWordService.checkWakeWordBlocking(client, commentContent); - // Top-level comment → always trigger AI reply - log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); - orchestrator.processComment(name, null, false, personaName) - .subscribeOn(Schedulers.boundedElastic()) - .subscribe( - null, - e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e), - () -> log.info("[CommentReconciler] Processing completed for comment: {}", name) - ); + 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 + log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); + orchestrator.processComment(name, null, false, personaName, false) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe( + null, + e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e), + () -> log.info("[CommentReconciler] Processing completed for comment: {}", name) + ); + } }); return Result.doNotRetry(); @@ -122,6 +144,22 @@ public class CommentReconciler implements Reconciler { 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 public Controller setupWith(ControllerBuilder builder) { return builder diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java index 268ea73..a0d9967 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java @@ -13,6 +13,7 @@ import run.halo.app.extension.controller.Reconciler; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.PersonaResolver; +import top.nxxy335.commentaiautopilot.service.WakeWordService; import java.time.Instant; import java.util.HashMap; @@ -26,6 +27,7 @@ public class ReplyReconciler implements Reconciler { private final ExtensionClient client; 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 AI_PERSONA_OWNER_PREFIX = "ai-persona-"; @@ -75,25 +77,21 @@ public class ReplyReconciler implements Reconciler { return; } - // Check if this reply is specifically replying to an AI reply - // by checking the quoteReply field - String quoteReply = reply.getSpec().getQuoteReply(); - - if (quoteReply == null || quoteReply.isBlank()) { - // 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; - } + // Check for wake word FIRST - wake word can bypass "must be reply to AI" check + String replyContent = getReplyContent(reply); + log.info("[ReplyReconciler] Wake word check for reply {}: content='{}'", + name, replyContent.length() > 80 ? replyContent.substring(0, 80) + "..." : replyContent); + var wakeMatch = wakeWordService.checkWakeWordBlocking(client, replyContent); // 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); - if (!isReplyToAi) { - log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name); + // Skip if not a reply to AI AND no wake word matched + 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); client.update(reply); return; @@ -117,18 +115,32 @@ public class ReplyReconciler implements Reconciler { markProcessed(reply); client.update(reply); - // Reply to AI → trigger AI reply (conversation continuation) - String personaName = client.fetch(Comment.class, parentCommentName) - .map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment)) - .orElse(null); - log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName); - orchestrator.processComment(parentCommentName, name, true, personaName) - .subscribeOn(Schedulers.boundedElastic()) - .subscribe( - null, - e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e), - () -> log.info("[ReplyReconciler] Processing completed for reply: {}", name) - ); + if (wakeMatch != null) { + // 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); + orchestrator.processComment(parentCommentName, name, true, personaName, false) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe( + null, + e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e), + () -> log.info("[ReplyReconciler] Processing completed for reply: {}", name) + ); + } }); return Result.doNotRetry(); @@ -167,6 +179,22 @@ public class ReplyReconciler implements Reconciler { 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 public Controller setupWith(ControllerBuilder builder) { return builder diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index 29b8fc6..8dfde09 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -70,9 +70,10 @@ public class AiReplyOrchestrator { * @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 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 processComment(String commentName, String replyName, boolean isAiConversation, - String personaName) { + String personaName, boolean wakeWordTriggered) { String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top"; // Clean up stale locks before acquiring new one @@ -84,12 +85,12 @@ public class AiReplyOrchestrator { return Mono.empty(); } - log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}", - commentName, replyName, isAiConversation, personaName); + log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}, wakeWordTriggered={}", + commentName, replyName, isAiConversation, personaName, wakeWordTriggered); return isAutoReplyEnabled() .flatMap(enabled -> { - if (!enabled) { + if (!enabled && !wakeWordTriggered) { log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName); return Mono.empty(); } @@ -99,42 +100,24 @@ public class AiReplyOrchestrator { log.info("[Orchestrator] 速率限制,跳过: {}", commentName); 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) .flatMap(shouldProcess -> { if (!shouldProcess) { log.info("[Orchestrator] Filtered out by rules: {}", commentName); return Mono.empty(); } - // For top-level comments: skip if we already have ANY reply record - // For AI conversation: skip if we already replied to THIS specific reply - if (!isAiConversation) { - return hasExistingReply(commentName) - .flatMap(hasReply -> { - if (hasReply) { - log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName); - return Mono.empty(); - } - return doProcess(commentName, replyName, isAiConversation, personaName); - }); - } - // Check conversation rounds limit - return getMaxConversationRounds() - .flatMap(maxRounds -> getConversationRounds(commentName) - .flatMap(rounds -> { - if (rounds >= maxRounds) { - log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName); - return Mono.empty(); - } - return hasExistingConversationReply(replyName) - .flatMap(hasReply -> { - if (hasReply) { - log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName); - return Mono.empty(); - } - return doProcess(commentName, replyName, isAiConversation, personaName); - }); - }) - ); + return proceedWithProcess(commentName, replyName, isAiConversation, personaName); }); }); }) @@ -147,6 +130,81 @@ public class AiReplyOrchestrator { .then(); } + /** + * Proceed with processing after all checks have passed. + * Handles dedup checks and conversation round limits. + */ + private Mono proceedWithProcess(String commentName, String replyName, + boolean isAiConversation, String personaName) { + // For top-level comments: skip if we already have ANY reply record + // For AI conversation: skip if we already replied to THIS specific reply + if (!isAiConversation) { + return hasExistingReply(commentName) + .flatMap(hasReply -> { + if (hasReply) { + log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName); + return Mono.empty(); + } + return doProcess(commentName, replyName, isAiConversation, personaName); + }); + } + // Check conversation rounds limit + return getMaxConversationRounds() + .flatMap(maxRounds -> getConversationRounds(commentName) + .flatMap(rounds -> { + if (rounds >= maxRounds) { + log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName); + return Mono.empty(); + } + return hasExistingConversationReply(replyName) + .flatMap(hasReply -> { + if (hasReply) { + log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName); + return Mono.empty(); + } + return doProcess(commentName, replyName, isAiConversation, personaName); + }); + }) + ); + } + + /** + * Check if the commenter is in the blocked list. + */ + private Mono checkBlockedCommenters(String commentName) { + return client.fetch(run.halo.app.core.extension.content.Comment.class, commentName) + .flatMap(comment -> { + var owner = comment.getSpec().getOwner(); + if (owner == null) return Mono.just(false); + String displayName = owner.getDisplayName(); + String email = run.halo.app.core.extension.content.Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind()) + ? owner.getName() : ""; + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return false; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return false; + try { + JsonNode node = objectMapper.readTree(basicJson); + String blockedStr = node.has("blockedCommenters") ? node.get("blockedCommenters").asText("") : ""; + if (blockedStr.isBlank()) return false; + for (String item : blockedStr.split(",")) { + String trimmed = item.trim(); + if (!trimmed.isEmpty() && (trimmed.equalsIgnoreCase(displayName) || trimmed.equalsIgnoreCase(email))) { + return true; + } + } + return false; + } catch (Exception e) { + return false; + } + }) + .defaultIfEmpty(false); + }) + .defaultIfEmpty(false); + } + private Mono doProcess(String commentName, String replyName, boolean isAiConversation, String personaName) { return getModelName().flatMap(modelName -> diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java index 9fa8e67..53ae82a 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java @@ -119,6 +119,10 @@ public class ContextExtractor { )) ) ) + .onErrorResume(e -> { + log.warn("[ContextExtractor] Failed to fetch Post {}: {}", postName, e.getMessage()); + return Mono.empty(); + }) .defaultIfEmpty(new CommentContext( comment.getMetadata().getName(), postName, @@ -139,7 +143,7 @@ public class ContextExtractor { if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) { String postName = subjectRef.getName(); return client.fetch(SinglePage.class, postName) - .flatMap(singlePage -> getPostContent(postName) + .flatMap(singlePage -> getSinglePageContent(postName) .flatMap(content -> getCommentCount(comment.getMetadata().getName()) .map(commentCount -> new CommentContext( comment.getMetadata().getName(), @@ -158,6 +162,10 @@ public class ContextExtractor { )) ) ) + .onErrorResume(e -> { + log.warn("[ContextExtractor] Failed to fetch SinglePage {}: {}", postName, e.getMessage()); + return Mono.empty(); + }) .defaultIfEmpty(new CommentContext( comment.getMetadata().getName(), postName, @@ -228,6 +236,10 @@ public class ContextExtractor { ) ) ) + .onErrorResume(e -> { + log.warn("[ContextExtractor] Failed to fetch Post {} for reply: {}", postName, e.getMessage()); + return Mono.empty(); + }) .defaultIfEmpty(new CommentContext( commentName, postName, @@ -248,7 +260,7 @@ public class ContextExtractor { if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) { String postName = subjectRef.getName(); return client.fetch(SinglePage.class, postName) - .flatMap(singlePage -> getPostContent(postName) + .flatMap(singlePage -> getSinglePageContent(postName) .flatMap(content -> getCommentCount(commentName) .flatMap(commentCount -> historyMono .map(history -> new CommentContext( @@ -269,6 +281,10 @@ public class ContextExtractor { ) ) ) + .onErrorResume(e -> { + log.warn("[ContextExtractor] Failed to fetch SinglePage {} for reply: {}", postName, e.getMessage()); + return Mono.empty(); + }) .defaultIfEmpty(new CommentContext( commentName, postName, @@ -365,6 +381,23 @@ public class ContextExtractor { .defaultIfEmpty(""); } + private Mono 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) { var publishTime = post.getSpec().getPublishTime(); if (publishTime != null) { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index c9dcd55..dcdafc5 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -52,8 +52,9 @@ public class PromptBuilder { private static final String SAFETY_PROMPT = """ 【安全规范】 - 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。 - - 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。" + - 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,我无法提供此类回复。" - 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。 + - 身份约束:你必须在回复中保持指定的角色身份,绝不能说自己是AI、没有性别或脱离角色设定。 """; private static final String DEFAULT_PROMPT_TEMPLATE = """ @@ -194,7 +195,10 @@ public class PromptBuilder { return client.fetch(AiPersona.class, personaName) .mapNotNull(persona -> { 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); } @@ -205,11 +209,40 @@ public class PromptBuilder { .next() .mapNotNull(persona -> { 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); } + 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 getEnabledPresetsPrompt() { return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .mapNotNull(cm -> { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/WakeWordService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/WakeWordService.java new file mode 100644 index 0000000..61ba7af --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/WakeWordService.java @@ -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 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; + } +} diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 2ad1b40..cea8919 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -30,4 +30,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: "1.0.0-b26cea" + version: "1.0.0" diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue index 3befa5b..ed061d8 100644 --- a/ui/src/views/HomeView.vue +++ b/ui/src/views/HomeView.vue @@ -254,3 +254,27 @@ onMounted(() => { fetchHealth() }) + + diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index aff3a9b..ff337eb 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -894,4 +894,74 @@ onMounted(fetchReplies) 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; + } +} diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index 82f3cfd..3f7e84e 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -153,6 +153,10 @@
{{ p.spec.displayName || '未命名' }} + + + 中性语气 + 唤醒: {{ p.spec.wakeWord }} 默认
{{ p.spec.prompt || '暂无提示词' }}
@@ -396,6 +400,33 @@ 用于Gravatar头像服务,留空使用默认头像
+ +
+ +
+
+ + +
+
+ + +
+
+ 勾选中性语气则使用中性语气,取消勾选则跟随性别语气 +
+ +
+ + 评论以此词开头则唤醒该角色回复,留空不启用。 + +
@@ -518,6 +549,9 @@ const personaSaving = ref(false) const personaForm = reactive({ displayName: '', email: '', + gender: 'female', + neutralVoice: false, + wakeWord: '', prompt: '', isDefault: false, }) @@ -708,11 +742,17 @@ const openPersonaDialog = async (persona: any | null) => { if (persona) { personaForm.displayName = persona.spec.displayName || '' 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.isDefault = persona.spec.isDefault || false } else { personaForm.displayName = '' personaForm.email = '' + personaForm.gender = 'female' + personaForm.neutralVoice = false + personaForm.wakeWord = '' personaForm.prompt = '' personaForm.isDefault = false } @@ -754,6 +794,9 @@ const savePersona = async () => { spec: { displayName: personaForm.displayName, email: personaForm.email, + gender: personaForm.gender, + neutralVoice: personaForm.neutralVoice, + wakeWord: personaForm.wakeWord, prompt: personaForm.prompt, isDefault: personaForm.isDefault, }, @@ -1251,6 +1294,18 @@ onMounted(async () => { padding: 1px 8px; font-size: 11px; font-weight: 500; 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 { font-size: 12px; color: #6b7280; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1284,6 +1339,56 @@ onMounted(async () => { 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 ===== */ .btn-link { display: inline-flex; align-items: center; gap: 4px; @@ -1434,4 +1539,92 @@ onMounted(async () => { .dialog__item-name { font-size: 14px; font-weight: 500; color: #1f2937; } .dialog__item-email { font-size: 12px; color: #9ca3af; margin-top: 1px; } .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; + } +}