Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
615935d947 |
@@ -1,5 +1,28 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## v1.2.1
|
||||||
|
|
||||||
|
> 2026-07-01
|
||||||
|
|
||||||
|
### Bug 修复
|
||||||
|
|
||||||
|
- **修复 `AiReplyOrchestrator.retryOrFail` 重试计数失效** — `.then()` 丢弃了更新后的记录导致 `retryCount` 始终为 0,AI 生成失败时陷入无限重试。改为 `.flatMap()` 传递更新后的记录
|
||||||
|
- **修复误报反馈"AI 回复"被空字符串覆盖** — `.subscribe()` 在异步流程中过早触发,导致 AI 回复生成完成后被空字符串覆盖。改为在 `.doOnSuccess()` 中触发异步生成
|
||||||
|
- **修复 `PersonaResolver` 在响应式上下文中使用 `.block()`** — 调用阻塞方法会阻塞 Reactor 线程。改为返回 `Mono<String>` 并使用 `Flux.concatMap().next()` 替代 for 循环
|
||||||
|
- **修复 `penalizeComment`/`penalizeReply` 缺少乐观锁重试** — 并发更新 Comment/Reply 时可能静默失败。添加 `Retry.backoff(3, 100ms)` 重试
|
||||||
|
- **修复 `approveOriginalComment` 缺少乐观锁重试** — 同上,添加 `Retry.backoff(3, 100ms)` 重试
|
||||||
|
- **修复误报反馈端点无法重试 `FAIL` 状态记录** — 仅接受 `FILTERED` 和 `FALSE_POSITIVE` 状态,AI 生成失败的记录无法重试。现接受 `FAIL` 状态
|
||||||
|
- **修复 `tag-NEUTRAL` 缺少 CSS 样式** — 中性情感标签无样式显示。补充样式定义
|
||||||
|
- **修复 `handleTriggerAiReply` 缺少加载保护** — 触发 AI 回复按钮可被重复点击导致重复提交。添加 loading 状态
|
||||||
|
- **修复 `filterKeyword` 输入未做防抖** — 每次按键都触发搜索,性能开销大。添加 300ms 防抖
|
||||||
|
- **修复 `performCleanup` 逻辑错误** — 清理逻辑存在判断错误
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- **优化 `extractChoice` 分类匹配优先级** — 优先匹配违规类别(advertising/abuse/sensitive/meaningless),再匹配 `normal`,避免正常评论被误判为违规类别
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.2.0
|
## v1.2.0
|
||||||
|
|
||||||
> 2026-06-25
|
> 2026-06-25
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
version=1.2.0
|
version=1.2.1
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
+14
-9
@@ -1060,10 +1060,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
|
|
||||||
return client.fetch(AiCommentReply.class, name)
|
return client.fetch(AiCommentReply.class, name)
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
if (!"FILTERED".equals(record.getSpec().getStatus())
|
String currentStatus = record.getSpec().getStatus();
|
||||||
&& !"FALSE_POSITIVE".equals(record.getSpec().getStatus())) {
|
// 允许:FILTERED(拦截误报)、FALSE_POSITIVE(已通过但可触发AI)、FAIL(AI生成失败可重试)
|
||||||
|
if (!"FILTERED".equals(currentStatus)
|
||||||
|
&& !"FALSE_POSITIVE".equals(currentStatus)
|
||||||
|
&& !"FAIL".equals(currentStatus)) {
|
||||||
return ServerResponse.badRequest()
|
return ServerResponse.badRequest()
|
||||||
.bodyValue(Map.of("message", "仅已拦截或误报通过状态的记录可进行误报反馈"));
|
.bodyValue(Map.of("message", "仅已拦截、误报通过或AI生成失败的记录可进行此操作"));
|
||||||
}
|
}
|
||||||
|
|
||||||
String commentName = record.getSpec().getCommentId();
|
String commentName = record.getSpec().getCommentId();
|
||||||
@@ -1092,11 +1095,15 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||||
.then());
|
.then());
|
||||||
|
|
||||||
// 3. 异步触发 AI 回复(不阻塞 HTTP 响应)
|
// 3. 异步触发 AI 回复(在记录更新完成后,不阻塞 HTTP 响应)
|
||||||
// 使用 processFalsePositive 跳过前置过滤和去重检查
|
// 使用 processFalsePositive 跳过前置过滤和去重检查
|
||||||
|
final boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation());
|
||||||
|
final String recordName = record.getMetadata().getName();
|
||||||
|
|
||||||
|
return approveMono
|
||||||
|
.then(updateRecordMono)
|
||||||
|
.doOnSuccess(v -> {
|
||||||
if ("aiReply".equals(action)) {
|
if ("aiReply".equals(action)) {
|
||||||
boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation());
|
|
||||||
String recordName = record.getMetadata().getName();
|
|
||||||
personaResolver.getPersonaNameFromComment(commentName)
|
personaResolver.getPersonaNameFromComment(commentName)
|
||||||
.flatMap(personaName ->
|
.flatMap(personaName ->
|
||||||
orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName)
|
orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName)
|
||||||
@@ -1107,9 +1114,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
() -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName)
|
() -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
return approveMono
|
|
||||||
.then(updateRecordMono)
|
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of(
|
.then(ServerResponse.ok().bodyValue(Map.of(
|
||||||
"message", "aiReply".equals(action) ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过"
|
"message", "aiReply".equals(action) ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过"
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ class AiFoundationDelegate {
|
|||||||
/**
|
/**
|
||||||
* 从 chat 响应文本中提取匹配的分类值。
|
* 从 chat 响应文本中提取匹配的分类值。
|
||||||
* 优先精确匹配,其次包含匹配。
|
* 优先精确匹配,其次包含匹配。
|
||||||
|
* 包含匹配时优先匹配违规类别(广告/辱骂/敏感/无意义),最后才匹配"正常",
|
||||||
|
* 避免 AI 解释性文本中同时出现"正常"和违规词时误判为"正常"。
|
||||||
* 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。
|
* 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。
|
||||||
*/
|
*/
|
||||||
static String extractChoice(String text, List<String> choices) {
|
static String extractChoice(String text, List<String> choices) {
|
||||||
@@ -136,10 +138,16 @@ class AiFoundationDelegate {
|
|||||||
for (String choice : choices) {
|
for (String choice : choices) {
|
||||||
if (trimmed.equals(choice)) return choice;
|
if (trimmed.equals(choice)) return choice;
|
||||||
}
|
}
|
||||||
// 包含匹配(响应可能包含额外文字,如"该评论属于:广告")
|
// 包含匹配:先匹配违规类别,最后匹配"正常"
|
||||||
|
// 避免"该评论属于广告,不是正常评论"被误匹配为"正常"
|
||||||
for (String choice : choices) {
|
for (String choice : choices) {
|
||||||
|
if ("正常".equals(choice)) continue;
|
||||||
if (trimmed.contains(choice)) return choice;
|
if (trimmed.contains(choice)) return choice;
|
||||||
}
|
}
|
||||||
|
// 最后检查"正常"
|
||||||
|
for (String choice : choices) {
|
||||||
|
if ("正常".equals(choice) && trimmed.contains(choice)) return choice;
|
||||||
|
}
|
||||||
// 无匹配,返回空字符串触发安全拦截
|
// 无匹配,返回空字符串触发安全拦截
|
||||||
log.warn("[Delegate] No matching choice found in response: '{}', returning empty for safety", trimmed);
|
log.warn("[Delegate] No matching choice found in response: '{}', returning empty for safety", trimmed);
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ public class AiReplyOrchestrator {
|
|||||||
// Update retryCount and reset status to PENDING
|
// Update retryCount and reset status to PENDING
|
||||||
return updateRecordForRetry(replyRecord, newRetryCount)
|
return updateRecordForRetry(replyRecord, newRetryCount)
|
||||||
.delayElement(Duration.ofSeconds(delaySeconds))
|
.delayElement(Duration.ofSeconds(delaySeconds))
|
||||||
.then(retryGenerate(context, replyRecord, modelName, personaName));
|
.flatMap(updated -> retryGenerate(context, updated, modelName, personaName));
|
||||||
} else {
|
} else {
|
||||||
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
||||||
maxRetry, context.commentId(), reason);
|
maxRetry, context.commentId(), reason);
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.safety.Safelist;
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Reply;
|
import run.halo.app.core.extension.content.Reply;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -178,15 +181,18 @@ public class CommentPreFilterService {
|
|||||||
spec.setApproved(false);
|
spec.setApproved(false);
|
||||||
spec.setApprovedTime(null);
|
spec.setApprovedTime(null);
|
||||||
return client.update(comment)
|
return client.update(comment)
|
||||||
.doOnSuccess(c -> log.info("[PreFilter] Comment {} set to pending for violation", commentName))
|
.doOnSuccess(c -> log.info("[PreFilter] Comment {} set to pending for violation", commentName));
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.warn("[PreFilter] Failed to penalize comment {}: {}", commentName, e.getMessage());
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
log.debug("[PreFilter] Comment {} already unapproved, skip penalize", commentName);
|
log.debug("[PreFilter] Comment {} already unapproved, skip penalize", commentName);
|
||||||
return Mono.<Comment>empty();
|
return Mono.<Comment>empty();
|
||||||
})
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance)
|
||||||
|
.doBeforeRetry(sig -> log.debug("[PreFilter] Retrying penalizeComment {} (attempt {})", commentName, sig.totalRetries() + 1)))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[PreFilter] Failed to penalize comment {} after retries: {}", commentName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,15 +207,18 @@ public class CommentPreFilterService {
|
|||||||
spec.setApproved(false);
|
spec.setApproved(false);
|
||||||
spec.setApprovedTime(null);
|
spec.setApprovedTime(null);
|
||||||
return client.update(reply)
|
return client.update(reply)
|
||||||
.doOnSuccess(r -> log.info("[PreFilter] Reply {} set to pending for violation", replyName))
|
.doOnSuccess(r -> log.info("[PreFilter] Reply {} set to pending for violation", replyName));
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.warn("[PreFilter] Failed to penalize reply {}: {}", replyName, e.getMessage());
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
log.debug("[PreFilter] Reply {} already unapproved, skip penalize", replyName);
|
log.debug("[PreFilter] Reply {} already unapproved, skip penalize", replyName);
|
||||||
return Mono.<Reply>empty();
|
return Mono.<Reply>empty();
|
||||||
})
|
})
|
||||||
|
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance)
|
||||||
|
.doBeforeRetry(sig -> log.debug("[PreFilter] Retrying penalizeReply {} (attempt {})", replyName, sig.totalRetries() + 1)))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[PreFilter] Failed to penalize reply {} after retries: {}", replyName, e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import run.halo.app.core.extension.content.Post;
|
|||||||
import run.halo.app.core.extension.content.Tag;
|
import run.halo.app.core.extension.content.Tag;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared service for resolving AI persona name from a comment's associated
|
* Shared service for resolving AI persona name from a comment's associated
|
||||||
* post/category/tag annotations.
|
* post/category/tag annotations.
|
||||||
@@ -57,30 +60,47 @@ public class PersonaResolver {
|
|||||||
return Mono.just(persona);
|
return Mono.just(persona);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Category annotations
|
// 2. Category annotations (check sequentially, return first match)
|
||||||
var spec = post.getSpec();
|
var spec = post.getSpec();
|
||||||
if (spec != null && spec.getCategories() != null) {
|
List<String> categories = (spec != null && spec.getCategories() != null)
|
||||||
for (String categoryName : spec.getCategories()) {
|
? spec.getCategories() : List.of();
|
||||||
var persona = resolveFromCategory(categoryName);
|
// 3. Tag annotations (fallback if no category match)
|
||||||
if (persona != null) return Mono.just(persona);
|
List<String> tags = (spec != null && spec.getTags() != null)
|
||||||
}
|
? spec.getTags() : List.of();
|
||||||
}
|
|
||||||
// 3. Tag annotations
|
return resolveFromCategories(categories)
|
||||||
if (spec != null && spec.getTags() != null) {
|
.switchIfEmpty(resolveFromTags(tags));
|
||||||
for (String tagName : spec.getTags()) {
|
|
||||||
var persona = resolveFromTag(tagName);
|
|
||||||
if (persona != null) return Mono.just(persona);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Mono.just("");
|
|
||||||
})
|
})
|
||||||
.defaultIfEmpty("");
|
.defaultIfEmpty("");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveFromCategory(String categoryName) {
|
/**
|
||||||
// Use block() here because this is called from a Reconciler (sync context)
|
* Sequentially check category annotations, returning the first non-empty persona.
|
||||||
// For reactive context, the caller should use the reactive version
|
* Uses concatMap to preserve order and short-circuit on first match.
|
||||||
try {
|
*/
|
||||||
|
private Mono<String> resolveFromCategories(List<String> categoryNames) {
|
||||||
|
if (categoryNames == null || categoryNames.isEmpty()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Flux.fromIterable(categoryNames)
|
||||||
|
.concatMap(this::resolveFromCategory)
|
||||||
|
.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequentially check tag annotations, returning the first non-empty persona.
|
||||||
|
* Uses concatMap to preserve order and short-circuit on first match.
|
||||||
|
*/
|
||||||
|
private Mono<String> resolveFromTags(List<String> tagNames) {
|
||||||
|
if (tagNames == null || tagNames.isEmpty()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Flux.fromIterable(tagNames)
|
||||||
|
.concatMap(this::resolveFromTag)
|
||||||
|
.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> resolveFromCategory(String categoryName) {
|
||||||
return reactiveClient.fetch(Category.class, categoryName)
|
return reactiveClient.fetch(Category.class, categoryName)
|
||||||
.mapNotNull(cat -> {
|
.mapNotNull(cat -> {
|
||||||
var catAnnotations = cat.getMetadata().getAnnotations();
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
@@ -92,14 +112,13 @@ public class PersonaResolver {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.block();
|
.onErrorResume(e -> {
|
||||||
} catch (Exception e) {
|
log.warn("Failed to resolve persona from category {}: {}", categoryName, e.getMessage());
|
||||||
return null;
|
return Mono.empty();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveFromTag(String tagName) {
|
private Mono<String> resolveFromTag(String tagName) {
|
||||||
try {
|
|
||||||
return reactiveClient.fetch(Tag.class, tagName)
|
return reactiveClient.fetch(Tag.class, tagName)
|
||||||
.mapNotNull(tag -> {
|
.mapNotNull(tag -> {
|
||||||
var tagAnnotations = tag.getMetadata().getAnnotations();
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
@@ -111,10 +130,10 @@ public class PersonaResolver {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.block();
|
.onErrorResume(e -> {
|
||||||
} catch (Exception e) {
|
log.warn("Failed to resolve persona from tag {}: {}", tagName, e.getMessage());
|
||||||
return null;
|
return Mono.empty();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.2.0"
|
version: "1.2.1"
|
||||||
|
|||||||
@@ -76,7 +76,10 @@
|
|||||||
<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>
|
<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-category">误报</span>
|
||||||
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
|
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
|
||||||
<button class="btn-trigger-ai" @click="handleTriggerAiReply(reply)">触发AI回复</button>
|
<button class="btn-trigger-ai" :disabled="triggerAiLoadingName === reply.metadata.name" @click="handleTriggerAiReply(reply)">
|
||||||
|
<span v-if="triggerAiLoadingName === reply.metadata.name" class="fp-spinner"></span>
|
||||||
|
触发AI回复
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +179,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue"
|
import { ref, onMounted, onUnmounted, watch } from "vue"
|
||||||
import { axiosInstance } from "@halo-dev/api-client"
|
import { axiosInstance } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
import { IconPlug } from "@halo-dev/components"
|
||||||
@@ -189,6 +192,7 @@ 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 showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
|
||||||
|
const triggerAiLoadingName = ref<string | null>(null);
|
||||||
|
|
||||||
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 } }
|
||||||
@@ -255,17 +259,26 @@ const handleFalsePositive = async (action: string) => {
|
|||||||
} finally { fpLoading.value = false }
|
} finally { fpLoading.value = false }
|
||||||
}
|
}
|
||||||
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
|
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
|
||||||
|
if (triggerAiLoadingName.value) return
|
||||||
|
triggerAiLoadingName.value = reply.metadata.name
|
||||||
try {
|
try {
|
||||||
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
|
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
|
||||||
Toast.success("AI回复正在后台生成")
|
Toast.success("AI回复正在后台生成")
|
||||||
fetchReplies()
|
fetchReplies()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Toast.error(e?.response?.data?.message || "触发失败")
|
Toast.error(e?.response?.data?.message || "触发失败")
|
||||||
|
} finally { triggerAiLoadingName.value = null }
|
||||||
}
|
}
|
||||||
}
|
// 状态/情感筛选立即触发;关键词输入防抖 300ms 避免每次按键都请求
|
||||||
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
|
watch([filterStatus, filterSentiment], () => { page.value = 1; fetchReplies() })
|
||||||
|
let keywordDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(filterKeyword, () => {
|
||||||
|
if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer)
|
||||||
|
keywordDebounceTimer = setTimeout(() => { page.value = 1; fetchReplies() }, 300)
|
||||||
|
})
|
||||||
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
|
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
|
||||||
onMounted(fetchReplies)
|
onMounted(fetchReplies)
|
||||||
|
onUnmounted(() => { if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -309,8 +322,10 @@ onMounted(fetchReplies)
|
|||||||
.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 { 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; }
|
.btn-false-positive:hover { background: #b45309; color: #fff; }
|
||||||
.fp-icon-ok { color: #16a34a; }
|
.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 { 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; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.btn-trigger-ai:hover { background: #2563eb; color: #fff; }
|
.btn-trigger-ai:hover:not(:disabled) { background: #2563eb; color: #fff; }
|
||||||
|
.btn-trigger-ai:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.btn-trigger-ai .fp-spinner { width: 11px; height: 11px; border-color: rgba(37,99,235,0.3); border-top-color: #2563eb; }
|
||||||
.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; }
|
||||||
@@ -329,7 +344,7 @@ onMounted(fetchReplies)
|
|||||||
.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-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-NEUTRAL { background: #f3f4f6; color: #4b5563; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
/* 对话弹窗与响应式气泡 */
|
/* 对话弹窗与响应式气泡 */
|
||||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(2px); padding: 16px; box-sizing: border-box; }
|
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(2px); padding: 16px; box-sizing: border-box; }
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ const filteredCommenters = computed(() => { const kw = commenterSearch.value.tri
|
|||||||
const openCommenterDialog = async () => { showCommenterDialog.value = true; commenterLoading.value = true; try { const { data } = await axiosInstance.get(`${apiBase}/commenters`); commenterList.value = data.items || data } catch(e) { commenterList.value = [] } finally { commenterLoading.value = false } }
|
const openCommenterDialog = async () => { showCommenterDialog.value = true; commenterLoading.value = true; try { const { data } = await axiosInstance.get(`${apiBase}/commenters`); commenterList.value = data.items || data } catch(e) { commenterList.value = [] } finally { commenterLoading.value = false } }
|
||||||
const addCommenter = (c: any) => { const v = c.email || c.displayName; const cur = settings.basic.blockedCommenters.split(",").map(s=>s.trim()).filter(Boolean); if(cur.includes(v)) return; cur.push(v); settings.basic.blockedCommenters = cur.join(","); Toast.success("已添加"); showCommenterDialog.value = false }
|
const addCommenter = (c: any) => { const v = c.email || c.displayName; const cur = settings.basic.blockedCommenters.split(",").map(s=>s.trim()).filter(Boolean); if(cur.includes(v)) return; cur.push(v); settings.basic.blockedCommenters = cur.join(","); Toast.success("已添加"); showCommenterDialog.value = false }
|
||||||
const cleanupLoading = ref(false); const cleanupResult = ref<number | null>(null)
|
const cleanupLoading = ref(false); const cleanupResult = ref<number | null>(null)
|
||||||
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = data.deletedCount ?? data ?? 0; Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
|
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = typeof data === 'number' ? data : (data?.deletedCount ?? 0); Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
|
||||||
|
|
||||||
// Persona
|
// Persona
|
||||||
const personasApiBase = `${apiBase}/personas`
|
const personasApiBase = `${apiBase}/personas`
|
||||||
|
|||||||
Reference in New Issue
Block a user