4 Commits
16 changed files with 532 additions and 64 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复 - **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/无意义内容,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态 - **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/乱码,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态
- **误报反馈** — 被误拦截的评论可进行误报反馈,支持"AI回复"和"仅通过"两种处理方式,"仅通过"后可随时补触发 AI 回复
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
- **失败重试** — AI 生成失败时自动重试,指数退避策略 - **失败重试** — AI 生成失败时自动重试,指数退避策略
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
+56
View File
@@ -1,5 +1,61 @@
# 更新日志 # 更新日志
## v1.2.0
> 2026-06-25
### 新增
- **误报反馈功能** — 被拦截的评论可进行误报反馈,支持两种处理方式:
- **AI 回复**:标记为通过 + 触发 AI 生成回复
- **仅通过**:仅标记为通过,不生成回复
- **误报通过状态** — 新增 `FALSE_POSITIVE` 状态,"仅通过"的记录显示为"误报通过",不显示"通过/拒绝"按钮
- **触发 AI 回复按钮** — "误报通过"状态的记录可随时点击"触发AI回复"按钮补生成 AI 回复
- **上下文优先判断原则** — 前置过滤 AI 提示词重写,遵循五条核心原则:上下文优先、口语化宽容、恶意导向判定、宁放勿杀、闲聊不算无意义
### Bug 修复
- **修复误报反馈"AI 回复"被前置过滤再次拦截** — `processComment()` 始终调用 `preFilterService.check()`,用户已确认为误报的评论会被再次拦截。新增 `processFalsePositive()` 方法跳过前置过滤和去重检查
- **修复误报反馈"AI 回复"被去重检查拦截** — `hasExistingReply()` 找到已有的 FILTERED→PENDING 记录导致 AI 回复无法生成。`processFalsePositive()` 复用已有记录,不经过去重检查
- **修复误报反馈"AI 回复"导致全站崩溃** — `processComment()` 同步等待 AI 生成完成,HTTP 请求长时间不返回。改为 `.subscribe()` 异步执行,API 立即返回
- **修复误报反馈"仅通过"后显示通过/拒绝按钮** — "仅通过"将记录设为 `status=PASS, published=false, reply=""`,导致显示"通过/拒绝"按钮且内容为空。改为 `status=FALSE_POSITIVE`
- **修复 `extractChoice` 无匹配时返回原始文本** — AI 返回非预期文本时被误判为违规类别。改为返回空字符串触发安全拦截
- **修复 `approveOriginalComment` 缺少乐观锁重试** — 并发更新 Comment/Reply 时可能静默失败。添加 `Retry.backoff(3, 100ms)` 重试
### 改进
- **消除 `checkBlockedCommenters` 重复代码** — `FilterService` 新增 `isCommenterBlocked(commentName)` 公共方法,`AiReplyOrchestrator` 改为调用它
- **前端批量操作防重复提交** — 批量通过/拒绝/删除按钮添加 `batchLoading` 状态,操作期间禁用按钮
---
## v1.1.2
> 2026-06-24
### Bug 修复
- **修复 AI 分类完全不可用** — `classifyWithChoice``classifyWithChat` 均使用了 `GenerateTextRequest.Builder.system()` 方法,而该方法在当前 AI Foundation 版本中不被支持或导致运行时错误,导致所有评论均被拦截并显示"AI分类服务不可用,安全拦截"。现改为将 system prompt 合并到 user prompt 中,与可用的 `chat()` 方法保持一致的调用方式
- **修复 `classifyWithChoice` NPE** — `.map()` 返回 `null` 时触发 Reactor 内部 NullPointerException,改为 `.flatMap()` + `Mono.empty()` 正确触发 fallback
### 改进
- **分类调用诊断日志增强** — 在 `AiFoundationDelegate``AiFoundationClient``CommentPreFilterService` 中增加关键诊断日志(分类开始、fallback 触发、分类结果、异常详情),便于排查分类链路问题
- **AI 分类空结果处理** — 当 AI 返回空字符串时单独拦截,区别于"服务不可用"场景
---
## v1.1.1
> 2026-06-24
### 改进
- **"无意义"分类范围收窄** — 与文章主题无关的闲聊、灌水、打招呼不再被判为"无意义",仅纯乱码和无意义字符堆砌(如随机符号、键盘乱敲)才归类为"无意义"
- **AI 分类降级方案** — 当 `OutputSpec.choice` 结构化输出不被模型支持时,自动退回到普通 chat 调用并从响应文本中提取分类值(`classifyWithChat` fallback
---
## v1.1.0 ## v1.1.0
> 2026-06-23 > 2026-06-23
+24
View File
@@ -92,8 +92,32 @@
前置过滤默认启用。AI 会对评论进行分类判断,若 AI 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。 前置过滤默认启用。AI 会对评论进行分类判断,若 AI 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。
## 所有评论都显示"AI分类服务不可用,安全拦截"怎么办?
这表示 AI 分类调用链路存在问题,可能的原因:
1. **AI Foundation 插件未安装或未启用** — 请确保 AI Foundation 插件已正确安装并启用
2. **AI Foundation 中未配置模型** — 请在 AI Foundation 中配置至少一个 AI 模型
3. **模型名称配置错误** — 检查插件设置中的模型名称是否与 AI Foundation 中的 AiModel 资源名称一致,留空则使用默认模型
4. **AI Foundation 版本过旧** — 请确保使用最新版本的 AI Foundation 插件
::: tip 排查步骤
1. 检查插件设置页面顶部的 AI Foundation 连接状态
2. 查看插件日志中 `[Delegate]``[PreFilter]` 前缀的诊断信息
3. 确认 AI 回复功能(非前置过滤)是否正常工作 — 如果 AI 回复也无法生成,说明是 AI Foundation 连接问题
:::
## 被前置过滤拦截的评论会怎样? ## 被前置过滤拦截的评论会怎样?
1. **停止生成 AI 回复** — 不会消耗后续 Token 1. **停止生成 AI 回复** — 不会消耗后续 Token
2. **创建拦截记录** — 在日志页显示为"已拦截"状态,标注分类标签(如"辱骂攻击")和详细原因(含评论内容摘要) 2. **创建拦截记录** — 在日志页显示为"已拦截"状态,标注分类标签(如"辱骂攻击")和详细原因(含评论内容摘要)
3. **自动设为待审核** — 原评论的 `approved` 会被置为 `false`,前端不再展示该评论,需人工判断后审核通过 3. **自动设为待审核** — 原评论的 `approved` 会被置为 `false`,前端不再展示该评论,需人工判断后审核通过
## 被误拦截的评论怎么处理?
在日志页的"已拦截"记录右侧,点击 **误报反馈** 按钮,可选择:
- **AI 回复** — 标记为误报 + 自动通过评论 + 触发 AI 生成回复
- **仅通过** — 仅标记为误报 + 自动通过评论,不生成 AI 回复
选择"仅通过"后,记录状态变为"误报通过",可随时点击 **触发AI回复** 按钮补生成 AI 回复。
+2 -1
View File
@@ -15,7 +15,8 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **批量操作** — 草稿模式下支持批量通过/拒绝/删除
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式 - **评论者黑名单** — 屏蔽指定评论者,不触发AI回复,支持名称、邮箱和正则表达式
- **前置过滤(合规检测)** — AI回复前对评论进行合规性分类,自动拦截广告/辱骂/敏感/无意义内容,节省Token;可选将违规评论设为待审核状态 - **前置过滤(合规检测)** — AI回复前对评论进行合规性分类,自动拦截广告/辱骂/敏感/乱码内容,节省Token;可选将违规评论设为待审核状态
- **误报反馈** — 被误拦截的评论可进行误报反馈,支持"AI回复"和"仅通过"两种处理方式,"仅通过"后可随时补触发 AI 回复
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复
- **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝 - **安全审核** — AI生成的内容经过两阶段安全审核(安全检查 + 质量评分),不合规内容自动拒绝
- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 - **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
+3 -1
View File
@@ -39,7 +39,7 @@
- **广告**:包含推广链接、产品推销、引流信息等 - **广告**:包含推广链接、产品推销、引流信息等
- **辱骂攻击**:包含辱骂、人身攻击、恶意挑衅、歧视性言论等 - **辱骂攻击**:包含辱骂、人身攻击、恶意挑衅、歧视性言论等
- **敏感内容**:涉及政治敏感、违法违规、色情暴力等 - **敏感内容**:涉及政治敏感、违法违规、色情暴力等
- **无意义**:纯乱码、无意义字符堆砌、与文章完全无关的废话 - **无意义**:纯乱码、无意义字符堆砌(如随机符号、键盘乱敲)
对于非"正常"类别的评论,插件会: 对于非"正常"类别的评论,插件会:
@@ -47,6 +47,8 @@
2. 创建一条 `FILTERED` 状态的日志记录(可在日志页通过"已拦截"状态筛选查看) 2. 创建一条 `FILTERED` 状态的日志记录(可在日志页通过"已拦截"状态筛选查看)
3. 若启用"违规评论设为待审核",会自动将原评论的 `approved` 置为 `false`,使其进入待审核队列,需人工判断后审核通过 3. 若启用"违规评论设为待审核",会自动将原评论的 `approved` 置为 `false`,使其进入待审核队列,需人工判断后审核通过
被误拦截的评论可在日志页点击 **误报反馈** 按钮处理,支持"AI 回复"和"仅通过"两种方式。选择"仅通过"后记录变为"误报通过"状态,可随时点击"触发AI回复"按钮补生成回复。
::: warning ::: warning
前置过滤依赖 AI Foundation 插件进行分类判断,会额外消耗少量 Token。若 AI 服务不可用或分类失败,为安全起见将拦截评论而非放行,防止违规内容漏网。 前置过滤依赖 AI Foundation 插件进行分类判断,会额外消耗少量 Token。若 AI 服务不可用或分类失败,为安全起见将拦截评论而非放行,防止违规内容漏网。
::: :::
+1 -1
View File
@@ -1,4 +1,4 @@
version=1.1.0 version=1.2.0
# Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks # Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks
org.gradle.daemon=false org.gradle.daemon=false
@@ -101,6 +101,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.POST("/import", this::importConfig) .POST("/import", this::importConfig)
// 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展) // 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展)
.PUT("/replies/{name}/content", this::updateReplyContent) .PUT("/replies/{name}/content", this::updateReplyContent)
// 误报反馈:将拦截记录标记为误报,可选触发AI回复
.POST("/replies/{name}/false-positive", this::falsePositive)
.build(); .build();
} }
@@ -1035,4 +1037,126 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
}); });
} }
/**
* 误报反馈:将被拦截的评论标记为误报(正常),并可选触发 AI 回复。
*
* 请求体:{ "action": "aiReply" | "approveOnly" }
* - aiReply: 将评论审核状态设为已通过 + 触发 AI 生成回复
* - approveOnly: 仅将评论审核状态设为已通过,不触发 AI 回复
*/
private Mono<ServerResponse> falsePositive(ServerRequest request) {
var name = request.pathVariable("name");
return request.bodyToMono(String.class)
.flatMap(body -> {
String actionStr;
try {
JsonNode node = objectMapper.readTree(body);
actionStr = node.has("action") ? node.get("action").asText("approveOnly") : "approveOnly";
} catch (Exception e) {
actionStr = "approveOnly";
}
final String action = actionStr;
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
if (!"FILTERED".equals(record.getSpec().getStatus())
&& !"FALSE_POSITIVE".equals(record.getSpec().getStatus())) {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "仅已拦截或误报通过状态的记录可进行误报反馈"));
}
String commentName = record.getSpec().getCommentId();
String replyName = record.getSpec().getReplyTo();
// 1. 将原评论/回复的审核状态设为已通过
Mono<Void> approveMono = approveOriginalComment(commentName, replyName);
// 2. 更新 AiCommentReply 记录状态
Mono<Void> updateRecordMono = Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setFilterCategory("误报");
latest.getSpec().setFilterReason("用户确认为误报,已通过");
if ("aiReply".equals(action)) {
latest.getSpec().setStatus("PENDING");
latest.getSpec().setReply("");
} else {
// 仅通过:使用 FALSE_POSITIVE 状态,区别于 PASS
// 避免前端显示"通过/拒绝"按钮和"未发布"标签
latest.getSpec().setStatus("FALSE_POSITIVE");
latest.getSpec().setPublished(false);
}
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.then());
// 3. 异步触发 AI 回复(不阻塞 HTTP 响应)
// 使用 processFalsePositive 跳过前置过滤和去重检查
if ("aiReply".equals(action)) {
boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation());
String recordName = record.getMetadata().getName();
personaResolver.getPersonaNameFromComment(commentName)
.flatMap(personaName ->
orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName)
)
.subscribe(
null,
err -> log.warn("[FalsePositive] AI reply trigger failed for {}: {}", commentName, err.getMessage()),
() -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName)
);
}
return approveMono
.then(updateRecordMono)
.then(ServerResponse.ok().bodyValue(Map.of(
"message", "aiReply".equals(action) ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过"
)));
})
.switchIfEmpty(ServerResponse.notFound().build());
});
}
/**
* 将被拦截评论的原 Comment 或 Reply 审核状态设为已通过。
*/
private Mono<Void> approveOriginalComment(String commentName, String replyName) {
// 优先处理 Reply(AI 对话场景下违规内容来自 Reply)
if (replyName != null && !replyName.isBlank()) {
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
var spec = reply.getSpec();
if (spec != null && !Boolean.TRUE.equals(spec.getApproved())) {
spec.setApproved(true);
spec.setApprovedTime(Instant.now());
return client.update(reply)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.doOnSuccess(r -> log.info("[FalsePositive] Reply {} approved", replyName))
.then();
}
return Mono.empty();
})
.switchIfEmpty(Mono.defer(() -> approveComment(commentName)));
}
return approveComment(commentName);
}
private Mono<Void> approveComment(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var spec = comment.getSpec();
if (spec != null && !Boolean.TRUE.equals(spec.getApproved())) {
spec.setApproved(true);
spec.setApprovedTime(Instant.now());
return client.update(comment)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.doOnSuccess(c -> log.info("[FalsePositive] Comment {} approved", commentName))
.then();
}
return Mono.empty();
});
}
} }
@@ -70,12 +70,12 @@ public class AiFoundationClient {
try { try {
return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName); return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName);
} catch (NoClassDefFoundError e) { } catch (NoClassDefFoundError e) {
log.debug("AI Foundation API not on classpath: {}", e.getMessage()); log.warn("[Client] AI Foundation API not on classpath (classify): {}", e.getMessage());
return Mono.empty(); return Mono.empty();
} }
}) })
.onErrorResume(NoClassDefFoundError.class, e -> { .onErrorResume(NoClassDefFoundError.class, e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("[Client] AI Foundation NoClassDefFoundError during classify: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
}); });
} }
@@ -33,40 +33,123 @@ class AiFoundationDelegate {
.flatMap(model -> model.generateText( .flatMap(model -> model.generateText(
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build())) GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
.map(GenerateTextResult::getText)) .map(GenerateTextResult::getText))
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage())) .doOnError(e -> log.error("[Delegate] chat call failed: {}", e.getMessage()))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("[Delegate] chat not available: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
}); });
} }
/**
* 使用 AI 进行文本分类。
* 优先使用 OutputSpec.choice 结构化输出,失败时退回到普通 chat 并解析响应。
*/
static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt, static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) { String userPrompt, List<String> choices, String modelName) {
log.info("[Delegate] Starting classification, modelName='{}'", modelName);
return classifyWithChoice(extensionGetter, systemPrompt, userPrompt, choices, modelName)
.switchIfEmpty(
Mono.defer(() -> {
log.info("[Delegate] classifyWithChoice returned empty, falling back to classifyWithChat");
return classifyWithChat(extensionGetter, systemPrompt, userPrompt, choices, modelName);
})
)
.doOnNext(result -> log.info("[Delegate] Classification succeeded: '{}'", result))
.doOnSuccess(result -> {
if (result == null) {
log.warn("[Delegate] Classification completed with no result (both methods returned empty)");
}
});
}
/**
* 使用 OutputSpec.choice 结构化输出分类(部分模型不支持)。
* 注意:不使用 system() 方法,因为部分 AI Foundation 版本可能不支持,
* 将 system prompt 合并到 user prompt 中。
*/
private static Mono<String> classifyWithChoice(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) {
// 合并 system prompt 和 user prompt,避免使用 system() 方法
String combinedPrompt = systemPrompt + "\n\n" + userPrompt;
return extensionGetter.getEnabledExtension(AiModelService.class) return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> service.languageModel(modelName != null ? modelName : "") .flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText( .flatMap(model -> model.generateText(
GenerateTextRequest.builder() GenerateTextRequest.builder()
.system(systemPrompt) .prompt(combinedPrompt)
.prompt(userPrompt)
.output(OutputSpec.choice(choices)) .output(OutputSpec.choice(choices))
.maxRetries(2) .maxRetries(2)
.build())) .build()))
.map(result -> { .flatMap(result -> {
Object output = result.getOutput(); Object output = result.getOutput();
return output != null ? String.valueOf(output).trim() : ""; if (output != null) {
String value = String.valueOf(output).trim();
if (!value.isEmpty()) {
log.debug("[Delegate] classifyWithChoice got output: '{}'", value);
return Mono.just(value);
}
}
// output 为空可能是模型不支持结构化输出,返回 empty 触发 fallback
log.info("[Delegate] classifyWithChoice: output is null/empty, triggering fallback");
return Mono.empty();
})) }))
.doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("[Delegate] classifyWithChoice failed, will fallback to chat: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
}); });
} }
/**
* 使用普通 chat 调用进行分类,从响应文本中提取匹配的分类值。
* 作为 OutputSpec.choice 不可用时的降级方案。
* 不使用 system() 方法,将 system prompt 合并到 user prompt 中,
* 与可用的 chat() 方法保持一致的调用方式。
*/
private static Mono<String> classifyWithChat(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) {
// 合并 system prompt 和 user prompt,与 chat() 方法保持一致的调用方式
String combinedPrompt = systemPrompt + "\n\n" + userPrompt;
return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder()
.prompt(combinedPrompt)
.maxRetries(2)
.build()))
.map(GenerateTextResult::getText)
.map(text -> extractChoice(text, choices)))
.doOnError(e -> log.error("[Delegate] classifyWithChat failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("[Delegate] classifyWithChat error: {}", e.getMessage());
return Mono.empty();
});
}
/**
* 从 chat 响应文本中提取匹配的分类值。
* 优先精确匹配,其次包含匹配。
* 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。
*/
static String extractChoice(String text, List<String> choices) {
if (text == null || text.isBlank()) return "";
String trimmed = text.trim();
// 精确匹配
for (String choice : choices) {
if (trimmed.equals(choice)) return choice;
}
// 包含匹配(响应可能包含额外文字,如"该评论属于:广告")
for (String choice : choices) {
if (trimmed.contains(choice)) return choice;
}
// 无匹配,返回空字符串触发安全拦截
log.warn("[Delegate] No matching choice found in response: '{}', returning empty for safety", trimmed);
return "";
}
static Mono<Boolean> isAvailable(ExtensionGetter extensionGetter) { static Mono<Boolean> isAvailable(ExtensionGetter extensionGetter) {
return extensionGetter.getEnabledExtension(AiModelService.class) return extensionGetter.getEnabledExtension(AiModelService.class)
.hasElement() .hasElement()
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage()); log.debug("[Delegate] AI Foundation not available: {}", e.getMessage());
return Mono.just(false); return Mono.just(false);
}); });
} }
@@ -105,7 +105,7 @@ public class AiReplyOrchestrator {
} }
// Wake word triggered: skip page-level annotation check // Wake word triggered: skip page-level annotation check
if (wakeWordTriggered) { if (wakeWordTriggered) {
return checkBlockedCommenters(commentName) return filterService.isCommenterBlocked(commentName)
.flatMap(blocked -> { .flatMap(blocked -> {
if (blocked) { if (blocked) {
log.info("[Orchestrator] Commenter blocked, skipping wake word: {}", commentName); log.info("[Orchestrator] Commenter blocked, skipping wake word: {}", commentName);
@@ -133,6 +133,45 @@ public class AiReplyOrchestrator {
.then(); .then();
} }
/**
* 误报反馈专用:跳过前置过滤和去重检查,直接为已确认误报的评论生成 AI 回复。
*
* <p>与 {@link #processComment} 不同,此方法:
* <ul>
* <li>跳过前置过滤(用户已确认评论合规)</li>
* <li>跳过去重检查(已有 FILTERED 记录,需复用)</li>
* <li>跳过速率限制和黑名单检查(管理员主动操作)</li>
* </ul>
*
* @param commentName the parent Comment name
* @param replyName the Reply name (null for top-level comments)
* @param isAiConversation true when this is a conversation continuation
* @param personaName the persona name to use
* @param recordName the existing AiCommentReply record name to update
*/
public Mono<Void> processFalsePositive(String commentName, String replyName,
boolean isAiConversation, String personaName,
String recordName) {
log.info("[Orchestrator] Processing false-positive: comment={}, record={}", commentName, recordName);
return getModelName().flatMap(modelName ->
contextExtractor.extract(commentName, replyName, isAiConversation)
.flatMap(context -> {
// Fetch the existing record (was FILTERED, now PENDING)
return client.fetch(AiCommentReply.class, recordName)
.flatMap(replyRecord ->
sentimentService.analyzeSentiment(context.commentContent(), modelName)
.flatMap(sentimentResult ->
promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName)
.flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName, personaName))
)
);
})
)
.doOnError(e -> log.error("[Orchestrator] Error processing false-positive {}: {}", commentName, e.getMessage(), e))
.then();
}
/** /**
* Proceed with processing after all checks have passed. * Proceed with processing after all checks have passed.
* Handles dedup checks and conversation round limits. * Handles dedup checks and conversation round limits.
@@ -171,43 +210,6 @@ 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;
}
})
.defaultIfEmpty(false);
})
.defaultIfEmpty(false);
}
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation, private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
String personaName) { String personaName) {
return getModelName().flatMap(modelName -> return getModelName().flatMap(modelName ->
@@ -44,16 +44,48 @@ public class CommentPreFilterService {
SPAM, "检测到推广链接、产品推销或引流信息", SPAM, "检测到推广链接、产品推销或引流信息",
ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论", ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论",
SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容", SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容",
MEANINGLESS, "检测到纯乱码无意义字符或与文章完全无关的废话" MEANINGLESS, "检测到纯乱码无意义字符堆砌"
); );
private static final String CLASSIFY_SYSTEM_PROMPT = """ private static final String CLASSIFY_SYSTEM_PROMPT = """
你是评论内容合规检测员。请判断以下评论属于哪个类别: 你是评论内容合规检测员。请判断以下评论属于哪个类别:
- 正常:正常的评论、提问、讨论、赞美等
类别定义:
- 正常:正常的评论、提问、讨论、赞美、闲聊等,即使与文章主题无关也算正常
- 广告:包含推广链接、产品推销、引流信息等 - 广告:包含推广链接、产品推销、引流信息等
- 辱骂攻击:包含辱骂、人身攻击、恶意挑衅、歧视性言论等 - 辱骂攻击:包含辱骂、人身攻击、恶意挑衅、歧视性言论等
- 敏感内容:涉及政治敏感、违法违规、色情暴力等 - 敏感内容:涉及政治敏感、违法违规、色情暴力等
- 无意义:纯乱码、无意义字符堆砌、与文章完全无关的废话 - 无意义:纯乱码、无意义字符堆砌(如随机符号、键盘乱敲)
═══════════════════════════════════════
核心判断原则(必须严格遵守):
═══════════════════════════════════════
【原则一:上下文优先】
绝对禁止仅凭单个词汇进行机械拦截。必须结合整句话的语境、语气和前后文逻辑进行综合判断。一个词是否违规,取决于它在句子中的功能,而非词汇本身。
【原则二:口语化宽容】
中文互联网存在大量口语化简写、谐音和省略表达。如果某个词在特定语境下明显是中性词或亲属称谓的口语化表达,且整句无攻击性、无恶意,必须判定为"正常"
常见口语化中性用法示例:
- "他妈" → 可能是"他妈妈"的简称,如"小轩是他妈的朋友"=小轩是他妈妈的朋友 → 正常
- "你妹" → 可能是"你妹妹"的简称,如"你妹在哪上学"=你妹妹在哪上学 → 正常
- "卧槽" → 可能是语气词表示惊讶,如"卧槽这也太强了"=哇塞这也太厉害了 → 正常
- "牛逼" → 口语化赞美,如"这文章写得牛逼" → 正常
- "" → 语气词表示无奈或惊讶,如"靠又忘了" → 正常
【原则三:恶意导向判定】
只有当词汇被明确用作辱骂、人身攻击、引战或带有较强负面情绪时,才判定为"辱骂攻击"
恶意用法示例(这些才应判为"辱骂攻击"):
- "你他妈的" → 直接对他人进行辱骂 → 辱骂攻击
- "你妹的" → 带有攻击性的语气词 → 辱骂攻击
- "傻逼" → 直接辱骂他人 → 辱骂攻击
【原则四:宁放勿杀】
当你无法确定评论是否违规时,应判定为"正常"而非"辱骂攻击"。误杀正常评论比漏判违规评论的负面影响更大。
【原则五:闲聊不算无意义】
与文章主题无关的闲聊、灌水、打招呼等属于"正常",不要误判为"无意义"
只返回类别名称,不要返回其他内容。"""; 只返回类别名称,不要返回其他内容。""";
public CommentPreFilterService(ReactiveExtensionClient client, public CommentPreFilterService(ReactiveExtensionClient client,
@@ -85,11 +117,17 @@ public class CommentPreFilterService {
log.info("[PreFilter] Checking comment (enabled=true): {}", truncated.substring(0, Math.min(50, truncated.length()))); log.info("[PreFilter] Checking comment (enabled=true): {}", truncated.substring(0, Math.min(50, truncated.length())));
return aiFoundationClient.classify(CLASSIFY_SYSTEM_PROMPT, userPrompt, CLASSIFY_CHOICES, modelName) return aiFoundationClient.classify(CLASSIFY_SYSTEM_PROMPT, userPrompt, CLASSIFY_CHOICES, modelName)
.doOnNext(result -> log.info("[PreFilter] AI classify returned: '{}'", result))
.map(result -> { .map(result -> {
if (CLEAN.equals(result)) { if (CLEAN.equals(result)) {
log.info("[PreFilter] Comment passed: category={}", result); log.info("[PreFilter] Comment passed: category={}", result);
return new PreFilterResult(true, CLEAN, "评论合规"); return new PreFilterResult(true, CLEAN, "评论合规");
} }
// 空结果视为分类失败
if (result == null || result.isBlank()) {
log.warn("[PreFilter] AI classify returned empty/blank result, blocking for safety");
return new PreFilterResult(false, MEANINGLESS, "AI分类返回空结果,安全拦截");
}
String desc = CATEGORY_DESCRIPTIONS.getOrDefault(result, "检测到违规内容"); String desc = CATEGORY_DESCRIPTIONS.getOrDefault(result, "检测到违规内容");
String snippet = truncated.substring(0, Math.min(50, truncated.length())); String snippet = truncated.substring(0, Math.min(50, truncated.length()));
String reason = desc + " — 「" + snippet + ""; String reason = desc + " — 「" + snippet + "";
@@ -99,7 +137,7 @@ public class CommentPreFilterService {
// 分类失败时拦截评论(安全优先),而非放行 // 分类失败时拦截评论(安全优先),而非放行
.defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截")) .defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截"))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("[PreFilter] Detection error, BLOCKING comment for safety: {}", e.getMessage()); log.warn("[PreFilter] Detection error, BLOCKING comment for safety: {}", e.getMessage(), e);
return Mono.just(new PreFilterResult(false, MEANINGLESS, "AI分类服务异常,安全拦截")); return Mono.just(new PreFilterResult(false, MEANINGLESS, "AI分类服务异常,安全拦截"));
}); });
}); });
@@ -58,6 +58,19 @@ public class FilterService {
}); });
} }
/**
* 检查评论者是否在黑名单中(按 commentName 查询)。
*/
public Mono<Boolean> isCommenterBlocked(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(this::checkBlockedCommenters)
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.warn("[Filter] Error checking blocked commenter: {}", e.getMessage());
return Mono.just(false);
});
}
private Mono<Boolean> checkBlockedCommenters(Comment comment) { private Mono<Boolean> checkBlockedCommenters(Comment comment) {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
+1 -1
View File
@@ -30,4 +30,4 @@ spec:
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings" settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "1.1.0" version: "1.2.0"
+29
View File
@@ -0,0 +1,29 @@
/**
* 统一 API 路径常量,避免硬编码散布在多个 Vue 文件中。
* 修改 API 路径只需在此处更新。
*/
export const API_BASE = '/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1'
// ===== 日志相关 =====
export const API_REPLIES = `${API_BASE}/replies`
export const apiReply = (name: string) => `${API_REPLIES}/${name}`
export const apiReplyAction = (name: string, action: string) => `${API_REPLIES}/${name}/${action}`
export const API_BATCH_APPROVE = `${API_REPLIES}/batch-approve`
export const API_BATCH_REJECT = `${API_REPLIES}/batch-reject`
export const API_BATCH_DELETE = `${API_REPLIES}/batch-delete`
export const apiConversation = (commentId: string) => `${API_BASE}/conversation/${commentId}`
// ===== 概览相关 =====
export const API_STATS = `${API_BASE}/stats`
export const API_PERSONAS = `${API_BASE}/personas`
export const apiPersona = (name: string) => `${API_PERSONAS}/${name}`
export const API_HEALTH = `${API_BASE}/health`
// ===== 设置相关 =====
export const API_EXPORT = `${API_BASE}/export`
export const API_IMPORT = `${API_BASE}/import`
export const API_COMMENTERS = `${API_BASE}/commenters`
export const API_CLEANUP = `${API_BASE}/cleanup`
// ===== 评论触发 =====
export const apiCommentTrigger = (commentName: string) => `${API_BASE}/comments/${commentName}/trigger`
+14
View File
@@ -0,0 +1,14 @@
export async function computeGravatarHash(email: string): Promise<string> {
if (!email || email.trim() === '') return ''
const normalizedEmail = email.trim().toLowerCase()
const encoder = new TextEncoder()
const data = encoder.encode(normalizedEmail)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export function getGravatarUrl(hash: string): string {
if (!hash) return ''
return `https://cn.cravatar.com/avatar/${hash}`
}
+87 -6
View File
@@ -27,6 +27,7 @@
<option value="PENDING">待审核</option> <option value="PENDING">待审核</option>
<option value="REJECTED">已拒绝</option> <option value="REJECTED">已拒绝</option>
<option value="FILTERED">已拦截</option> <option value="FILTERED">已拦截</option>
<option value="FALSE_POSITIVE">误报通过</option>
</select> </select>
<select v-model="filterSentiment" class="filter-select"> <select v-model="filterSentiment" class="filter-select">
<option value="">全部情感</option> <option value="">全部情感</option>
@@ -69,6 +70,13 @@
<svg class="filter-icon" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg> <svg class="filter-icon" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>
<span class="filter-category" v-if="reply.spec.filterCategory">{{ reply.spec.filterCategory }}</span> <span class="filter-category" v-if="reply.spec.filterCategory">{{ reply.spec.filterCategory }}</span>
<span class="filter-detail">{{ reply.spec.filterReason || '未提供具体原因' }}</span> <span class="filter-detail">{{ reply.spec.filterReason || '未提供具体原因' }}</span>
<button class="btn-false-positive" @click="openFalsePositiveDialog(reply)">误报反馈</button>
</div>
<div v-if="reply.spec.status === 'FALSE_POSITIVE'" class="card-filter-reason">
<svg class="filter-icon fp-icon-ok" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="filter-category">误报</span>
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
<button class="btn-trigger-ai" @click="handleTriggerAiReply(reply)">触发AI回复</button>
</div> </div>
</div> </div>
</div> </div>
@@ -137,6 +145,33 @@
</div> </div>
</div> </div>
</teleport> </teleport>
<!-- 误报反馈确认弹窗 -->
<teleport to="body">
<div v-if="showFalsePositiveDialog" class="dialog-overlay" @click.self="showFalsePositiveDialog = false">
<div class="dialog-box fp-dialog">
<div class="dialog-header">
<h3>确认为误报</h3>
<button class="close-btn" @click="showFalsePositiveDialog = false"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="24" height="24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg></button>
</div>
<div class="fp-dialog-body">
<p class="fp-desc">系统检测到该评论可能包含违规内容但您认为这是正常表达请选择处理方式</p>
<div class="fp-actions">
<button class="fp-btn fp-btn-primary" :disabled="fpLoading" @click="handleFalsePositive('aiReply')">
<span v-if="fpLoading" class="fp-spinner"></span>
AI 回复
</button>
<button class="fp-btn fp-btn-secondary" :disabled="fpLoading" @click="handleFalsePositive('approveOnly')">
仅通过
</button>
<button class="fp-btn fp-btn-ghost" :disabled="fpLoading" @click="showFalsePositiveDialog = false">
取消
</button>
</div>
</div>
</div>
</div>
</teleport>
</div> </div>
</template> </template>
@@ -149,10 +184,11 @@ import { IconPlug } from "@halo-dev/components"
interface AiCommentReplyItem { metadata: { name: string; creationTimestamp: string }; spec: any } interface AiCommentReplyItem { metadata: { name: string; creationTimestamp: string }; spec: any }
interface ConversationMessage { type: string; owner: string; content: string; time: string; isAi: boolean; quoteOwner?: string; quoteContent?: string } interface ConversationMessage { type: string; owner: string; content: string; time: string; isAi: boolean; quoteOwner?: string; quoteContent?: string }
const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0); const replies = ref<AiCommentReplyItem[]>([]); const loading = ref(false); const batchLoading = ref(false); const page = ref(1); const size = ref(20); const total = ref(0); const totalPages = ref(0);
const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false); const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref(""); const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]); const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) } const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } } const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
@@ -178,11 +214,11 @@ const openConversation = async (reply: AiCommentReplyItem) => {
const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } } const handleDelete = async (name: string) => { try { await axiosInstance.delete(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}`); Toast.success("删除成功"); fetchReplies() } catch (e) { Toast.error("删除失败") } }
const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } } const handleApprove = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/approve`); Toast.success("审核通过"); fetchReplies() } catch (e) { Toast.error("审核失败") } }
const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } } const handleReject = async (name: string) => { try { await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${name}/reject`); Toast.success("已拒绝"); fetchReplies() } catch (e) { Toast.error("拒绝失败") } }
const batchApprove = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } } const batchApprove = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-approve", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
const batchReject = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } } const batchReject = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-reject", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
const batchDelete = async () => { if(!selectedNames.value.size) return; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } } const batchDelete = async () => { if(!selectedNames.value.size||batchLoading.value) return; batchLoading.value=true; try { await axiosInstance.post("/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/batch-delete", { names: Array.from(selectedNames.value) }); Toast.success("成功"); selectedNames.value.clear(); selectAll.value=false; fetchReplies() } catch(e) { Toast.error("失败") } finally { batchLoading.value=false } }
const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝', FILTERED: '已拦截' }; return m[s] || s } const getStatusLabel = (s: string) => { const m:any = { PASS: '通过', FAIL: '失败', PENDING: '待审', REJECTED: '拒绝', FILTERED: '已拦截', FALSE_POSITIVE: '误报通过' }; return m[s] || s }
const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s } const getSentimentLabel = (s: string) => { const m:any = { VERY_POSITIVE: '极好', POSITIVE: '正面', NEUTRAL: '中性', NEGATIVE: '负面', VERY_NEGATIVE: '极差' }; return m[s] || s }
const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : "" const formatDate = (ts: string) => ts ? new Date(ts).toLocaleString("zh-CN") : ""
const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}` const getPostUrl = (slug: string) => `${window.location.origin}/archives/${slug}`
@@ -203,6 +239,30 @@ const renderContent = (content: string) => {
} }
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() } const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
const openFalsePositiveDialog = (reply: AiCommentReplyItem) => { falsePositiveTarget.value = reply; showFalsePositiveDialog.value = true }
const handleFalsePositive = async (action: string) => {
if (!falsePositiveTarget.value) return
fpLoading.value = true
try {
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${falsePositiveTarget.value.metadata.name}/false-positive`, { action })
Toast.success(action === "aiReply" ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过")
showFalsePositiveDialog.value = false
falsePositiveTarget.value = null
fetchReplies()
} catch (e: any) {
Toast.error(e?.response?.data?.message || "操作失败")
} finally { fpLoading.value = false }
}
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
try {
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
Toast.success("AI回复正在后台生成")
fetchReplies()
} catch (e: any) {
Toast.error(e?.response?.data?.message || "触发失败")
}
}
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() }) watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() }) watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
onMounted(fetchReplies) onMounted(fetchReplies)
@@ -246,6 +306,11 @@ onMounted(fetchReplies)
.filter-icon { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; } .filter-icon { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
.filter-category { flex-shrink: 0; padding: 1px 6px; background: #b45309; color: #fff; border-radius: 3px; font-weight: 600; font-size: 11px; line-height: 1.5; } .filter-category { flex-shrink: 0; padding: 1px 6px; background: #b45309; color: #fff; border-radius: 3px; font-weight: 600; font-size: 11px; line-height: 1.5; }
.filter-detail { flex: 1; line-height: 1.5; } .filter-detail { flex: 1; line-height: 1.5; }
.btn-false-positive { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #b45309; border-radius: 4px; background: transparent; color: #b45309; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-false-positive:hover { background: #b45309; color: #fff; }
.fp-icon-ok { color: #16a34a; }
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-trigger-ai:hover { background: #2563eb; color: #fff; }
.card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; } .card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
@media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } } @media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } }
.footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; } .footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; }
@@ -261,7 +326,7 @@ onMounted(fetchReplies)
/* 标签体系 */ /* 标签体系 */
.custom-tag { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; } .custom-tag { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; }
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; } .tag-FILTERED { background: #f1f5f9; color: #b45309; border: 1px solid #fde68a; } .tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; } .tag-FILTERED { background: #f1f5f9; color: #b45309; border: 1px solid #fde68a; } .tag-FALSE_POSITIVE { background: #dbeafe; color: #1d4ed8; border: 1px solid #93c5fd; }
.tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; } .tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; }
.tag-conv { background: #f3e8ff; color: #7e22ce; } .tag-conv { background: #f3e8ff; color: #7e22ce; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; } .tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
@@ -307,4 +372,20 @@ onMounted(fetchReplies)
.pagination { display: flex; flex-direction: column; gap: 12px; align-items: center; margin-top: 20px; font-size: 14px; color: #6b7280; } .pagination { display: flex; flex-direction: column; gap: 12px; align-items: center; margin-top: 20px; font-size: 14px; color: #6b7280; }
@media (min-width: 640px) { .pagination { flex-direction: row; justify-content: space-between; } } @media (min-width: 640px) { .pagination { flex-direction: row; justify-content: space-between; } }
.pagination-btns { display: flex; gap: 8px; } .pagination-btns { display: flex; gap: 8px; }
/* 误报反馈弹窗 */
.fp-dialog { max-width: 440px; }
.fp-dialog-body { padding: 24px; }
.fp-desc { margin: 0 0 20px; font-size: 14px; color: #4b5563; line-height: 1.6; }
.fp-actions { display: flex; flex-direction: column; gap: 10px; }
.fp-btn { padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; transition: all 0.15s; display: flex; align-items: center; justify-content: center; gap: 6px; }
.fp-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.fp-btn-primary { background: #2563eb; color: #fff; }
.fp-btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.fp-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
.fp-btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
.fp-btn-ghost { background: transparent; color: #9ca3af; }
.fp-btn-ghost:hover:not(:disabled) { color: #6b7280; background: #f9fafb; }
.fp-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: fp-spin 0.6s linear infinite; }
@keyframes fp-spin { to { transform: rotate(360deg); } }
</style> </style>