feat: v1.0.0 - 唤醒词、性别配置、身份强化、移动端适配、Bug修复

This commit is contained in:
sunny-335
2026-06-18 22:40:00 +08:00
parent a9dd1c14bc
commit 2c88ad6fb9
22 changed files with 787 additions and 106 deletions
+9 -6
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
}
group 'top.nxxy335.commentaiautopilot'
version '1.0.0-b26cea'
version '1.0.0'
repositories {
mavenCentral()
+19 -1
View File
@@ -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 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
+26
View File
@@ -21,6 +21,32 @@
插件启动时间之前的评论不会触发自动回复,避免安装插件后对大量历史评论批量回复。
## 唤醒词机制
唤醒词允许用户在评论中通过特定词语唤醒指定AI角色回复,即使该页面未启用AI回评。
### 工作方式
1. 用户发表以唤醒词开头的评论(如"小回小回你好")
2. 插件检测到唤醒词匹配,自动唤醒对应角色
3. 唤醒词后的内容作为实际评论内容传递给AI
4. AI生成回复时自动获取上下文(文章内容、对话历史等)
### 唤醒词特点
- **跨页面唤醒**:在未启用AI回评的页面也能使用唤醒词召唤AI
- **二级评论支持**:回复中同样可以使用唤醒词
- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色
- **绕过限制**:唤醒词触发时绕过页面级启用检查和"必须是回复给AI"的检查,但仍检查黑名单
### 配置唤醒词
**AI回评****插件设置****AI角色设置** 中,为每个角色配置唤醒词。唤醒词留空则不启用该角色的唤醒功能。
::: tip
唤醒词建议设置为容易记忆且不易与正常评论混淆的词语。
:::
## 对话式回复
当评论者回复AI的评论时,插件会自动提取对话上下文(最近5条回复),让AI的回复更连贯自然。
+7 -7
View File
@@ -7,19 +7,19 @@
## 安装
### 方式一:从 Release 下载
### 方式一:应用商店安装
进入 **插件****安装** → 应用市场搜索 **AI回评** → 安装,或前往 [Halo 应用商店](https://www.halo.run/store/apps/app-mo5tivjt) 一键安装。
### 方式二:从 Release 下载
1. 前往 [GitHub Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
2. 登录 Halo 管理后台
3. 进入 **插件****安装**点击右上角 **安装** 按钮
3. 进入 **插件****安装****本地上传**
4. 选择下载的 `.jar` 文件上传
5. 安装完成后启用插件
::: tip 推荐安装
安装本插件后,应用市场会推荐安装 AI Foundation 插件(本插件的必要依赖)。
:::
### 方式二:从源码构建
### 方式三:从源码构建
```bash
# 克隆仓库
+12 -9
View File
@@ -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 次),每次重试间隔递增
## 前置要求
+23 -1
View File
@@ -1,6 +1,6 @@
# AI角色
AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
AI角色定义了回复评论的虚拟身份,包括昵称、人格、性别、语气风格和头像。
## 角色配置
@@ -8,6 +8,28 @@ AI角色定义了回复评论的虚拟身份,包括昵称、人格和头像。
AI回复者的显示名称,默认为「小回」。修改后新回复将使用新昵称,已有回复不受影响。
### 性别与语气
每个角色可以设置性别(男/女),AI回复时会保持对应性别身份。语气风格通过"中性语气"复选框控制:
- **勾选中性语气**:AI使用中性语气回复
- **取消勾选**:AI根据性别使用对应语气风格(女性→温柔细腻,男性→沉稳理性)
默认角色「小回」的性别为女,勾选中性语气。
### 唤醒词
评论以唤醒词开头可唤醒该角色回复。唤醒词功能的特点:
- **跨页面唤醒**:在未启用AI回评的页面,使用唤醒词也能召唤AI回复
- **二级评论支持**:回复中同样可以使用唤醒词唤醒指定角色
- **独立唤醒**:每个角色有独立的唤醒词,可以唤醒不同角色
- **留空不启用**:唤醒词留空则不启用该角色的唤醒功能
::: tip
唤醒词匹配时,插件会自动去除评论中的HTML标签并去除首尾空格,确保匹配准确。
:::
### 人格提示词
人格提示词定义了AI角色的性格和回复风格,是影响回复质量的关键配置。
+5 -1
View File
@@ -30,11 +30,13 @@
## AI角色设置
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可指定一个为默认角色。
AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个角色有独立的昵称、人格提示词、性别、语气风格和 Gravatar 头像,可指定一个为默认角色。
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 角色昵称 | AI回复者的显示名称 | 小回 |
| 性别与语气 | 角色性别(男/女)+ 中性语气复选框(勾选=中性语气,取消勾选=跟随性别语气) | 女 + 中性语气 |
| 唤醒词 | 评论以此词开头则唤醒该角色回复,留空不启用 | 空 |
| 人格提示词 | 定义AI角色的人格和回复风格 | 见下方 |
| 邮箱 | 用于 Gravatar 头像服务展示头像 | 空 |
| 设为默认 | 将该角色设为默认角色 | 第一个角色默认 |
@@ -90,8 +92,10 @@ AI角色定义了回复评论的虚拟身份。支持创建多个角色,每个
::: tip 情感提示
情感提示由插件根据情感分析结果自动追加到 Prompt 末尾,不需要在模板中手动添加:
- **非常正面** → 追加热情洋溢的语气提示
- **正面** → 追加热情友好的语气提示
- **负面** → 追加理性温和的语气提示
- **非常负面** → 追加冷静关怀的语气提示
- **中性** → 不追加额外提示
:::
@@ -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);
@@ -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();
}
}
@@ -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对话回复")))
);
});
@@ -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;
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
markProcessed(comment);
client.update(comment);
// Read persona name from the post's annotations
// Check for wake word in comment content
String commentContent = getCommentContent(comment);
log.info("[CommentReconciler] Wake word check for comment {}: content='{}'",
name, commentContent.length() > 80 ? commentContent.substring(0, 80) + "..." : commentContent);
var wakeMatch = wakeWordService.checkWakeWordBlocking(client, commentContent);
if (wakeMatch != null) {
// Wake word matched: trigger AI reply with the matched persona,
// bypassing normal page-level enable check
log.info("[CommentReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
orchestrator.processComment(name, null, false, wakeMatch.personaName(), true)
.subscribeOn(Schedulers.boundedElastic())
.subscribe(
null,
e -> log.error("[CommentReconciler] Error processing wake word comment {}: {}", name, e.getMessage(), e),
() -> log.info("[CommentReconciler] Wake word processing completed for comment: {}", name)
);
} else {
// Normal flow: read persona name from the post's annotations
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
// Top-level comment → always trigger AI reply
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
orchestrator.processComment(name, null, false, 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<Reconciler.Request> {
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
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<Reconciler.Request> {
markProcessed(reply);
client.update(reply);
// Reply to AI → trigger AI reply (conversation continuation)
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)
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<Reconciler.Request> {
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
@@ -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<Void> 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,12 +100,42 @@ 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();
}
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 AI conversation: skip if we already replied to THIS specific reply
if (!isAiConversation) {
@@ -135,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))
.doFinally(signal -> {
// Always release the lock when processing completes
processingLocks.remove(lockKey);
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
.defaultIfEmpty(false);
})
.then();
.defaultIfEmpty(false);
}
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
@@ -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<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) {
var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
@@ -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<String> getEnabledPresetsPrompt() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> {
@@ -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;
}
}
+1 -1
View File
@@ -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"
+24
View File
@@ -254,3 +254,27 @@ onMounted(() => {
fetchHealth()
})
</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>
+70
View File
@@ -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;
}
}
</style>
+193
View File
@@ -153,6 +153,10 @@
<div class="persona-card__info">
<div class="persona-card__name">
{{ 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>
</div>
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
@@ -396,6 +400,33 @@
<span class="form-hint">用于Gravatar头像服务留空使用默认头像</span>
<input type="email" v-model="personaForm.email" class="form-input" placeholder="ai@example.com" />
</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 -->
<div class="form-field">
<label class="form-label">人格提示词</label>
@@ -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;
}
}
</style>