feat: v1.0.0-beta.2 - ExtensionGetter integration, UI revamp, bug fixes
- Replace cross-ClassLoader reflection with ExtensionGetter.getEnabledExtension(AiModelService.class) - Declare optional pluginDependencies (ai-foundation?: "*") and recommended-apps annotation - Use OutputSpec.choice for structured classification (sentiment, review safety/quality) - Use GenerateTextRequest with maxRetries=2 for reliable chat generation - Add multi-turn conversation context (conversation_history placeholder) - Fix RateLimitService thread leak (implement DisposableBean) - Two-stage AI review: safety check + 1-5 quality score mapped to 0-100 - Remove redundant dashboard cards (sentiment distribution, 7-day trend, avg score) - Redesign settings page with tabbed navigation (basic/persona/model/prompt/cleanup) - Fix settings layout (move tab bar out of grid container) - Fix button icon+text alignment via :deep(.btn-content) inline-flex - Add review score grade labels (excellent/good/fair/poor) in logs
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。
|
基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。
|
||||||
|
|
||||||
> **当前版本**: v1.0.0-beta.1
|
> **当前版本**: v1.0.0-beta.2
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
@@ -70,6 +70,26 @@ pnpm dev
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.0.0-beta.2
|
||||||
|
|
||||||
|
**改进**
|
||||||
|
|
||||||
|
- 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI Foundation 的 `AiModelService`,替换原先的跨 ClassLoader 反射调用方式
|
||||||
|
- 在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系
|
||||||
|
- 新增 `store.halo.run/recommended-apps` 注解,安装后可在应用市场推荐安装 AI Foundation 插件
|
||||||
|
- 情感分析和内容审核改用 AI Foundation 结构化输出(`OutputSpec.choice`),分类更可靠
|
||||||
|
- AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误
|
||||||
|
- AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文
|
||||||
|
- 优化 AI 自审核评分机制:改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分,替代原先的二值评分
|
||||||
|
- 精简仪表盘:移除情感分布、近7日回复趋势、平均审核评分三个卡片,界面更简洁
|
||||||
|
- 优化设置页面布局:按钮统一排版并添加图标,侧边栏新增"未保存"状态指示器
|
||||||
|
- 优化日志页面评分显示:评分增加等级标签(优秀/良好/一般/较差)
|
||||||
|
|
||||||
|
**Bug 修复**
|
||||||
|
|
||||||
|
- 修复 RateLimitService 清理线程在插件停止时未关闭导致线程泄漏
|
||||||
|
- 修复内容审核提示词要求"重新生成"但代码未使用重新生成内容的问题
|
||||||
|
|
||||||
### v1.0.0-beta.1
|
### v1.0.0-beta.1
|
||||||
|
|
||||||
**新功能**
|
**新功能**
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group 'top.nxxy335.commentaiautopilot'
|
group 'top.nxxy335.commentaiautopilot'
|
||||||
version '1.0.0-beta.1'
|
version '1.0.0-beta.2-kx7m2p'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## v1.0.0-beta.2
|
||||||
|
|
||||||
|
> 2026-06-17
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- **改用 ExtensionGetter 集成 AI Foundation**:通过 Halo 官方推荐的 `ExtensionGetter.getEnabledExtension(AiModelService.class)` 获取 AI 服务,替换原先的跨 ClassLoader 反射调用方式([Issue #1](https://github.com/sunny-335/plugin-comment-ai-autopilot/issues/1))
|
||||||
|
- **声明插件依赖**:在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系,插件在未安装 AI Foundation 时仍可正常加载
|
||||||
|
- **应用市场推荐**:新增 `store.halo.run/recommended-apps` 注解,安装本插件后可在应用市场推荐安装 AI Foundation 插件
|
||||||
|
- **使用结构化输出**:情感分析和内容审核改用 AI Foundation 的 `OutputSpec.choice` 结构化输出,替换原先的字符串匹配解析,分类更可靠
|
||||||
|
- **使用 GenerateTextRequest**:AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误
|
||||||
|
- **简化 languageModel 调用**:直接传递 modelName 参数,由 SDK 处理空值(使用默认模型),无需手动判断
|
||||||
|
- **多轮对话上下文**:AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文
|
||||||
|
- **优化 AI 自审核评分机制**:审核改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分(0/30/50/70/85/100),替代原先的二值评分(0/100),评分更有区分度
|
||||||
|
- **精简仪表盘**:移除情感分布、近7日回复趋势、平均审核评分三个卡片,保留核心的回复概览和快捷操作,界面更简洁
|
||||||
|
- **优化设置页面布局**:页面头部按钮使用 VSpace 统一排版并添加图标;侧边栏保存卡片高亮显示,新增"未保存"状态指示器;保存/重置按钮添加图标
|
||||||
|
- **优化日志页面评分显示**:评分增加等级标签(优秀/良好/一般/较差),更直观
|
||||||
|
|
||||||
|
### Bug 修复
|
||||||
|
|
||||||
|
- **修复 RateLimitService 线程泄漏**:清理线程未在插件停止时关闭,实现 `DisposableBean` 正确释放资源
|
||||||
|
- **修复 ReviewService 提示词不匹配**:审核提示词要求"重新生成"但代码未使用重新生成的内容,移除误导性指令
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0.0-beta.1
|
## v1.0.0-beta.1
|
||||||
|
|
||||||
> 2026-06-16
|
> 2026-06-16
|
||||||
|
|||||||
+4
-84
@@ -216,100 +216,25 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> getStats(ServerRequest request) {
|
private Mono<ServerResponse> getStats(ServerRequest request) {
|
||||||
String range = request.queryParam("range").orElse("7");
|
|
||||||
|
|
||||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(allReplies -> {
|
.map(replies -> {
|
||||||
// 根据 range 计算截止时间
|
|
||||||
ZoneId zoneId = ZoneId.systemDefault();
|
|
||||||
LocalDate today = LocalDate.now(zoneId);
|
|
||||||
Instant cutoffInstant;
|
|
||||||
int trendDays;
|
|
||||||
|
|
||||||
if ("all".equals(range)) {
|
|
||||||
cutoffInstant = null; // 不做时间过滤
|
|
||||||
trendDays = 30; // "all" 时趋势也展示最近30天
|
|
||||||
} else {
|
|
||||||
int days = Integer.parseInt(range);
|
|
||||||
cutoffInstant = today.minusDays(days).atStartOfDay(zoneId).toInstant();
|
|
||||||
trendDays = days;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据 range 过滤记录
|
|
||||||
List<AiCommentReply> replies;
|
|
||||||
if (cutoffInstant != null) {
|
|
||||||
replies = allReplies.stream()
|
|
||||||
.filter(r -> {
|
|
||||||
Instant ts = r.getMetadata().getCreationTimestamp();
|
|
||||||
return ts != null && !ts.isBefore(cutoffInstant);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
replies = allReplies;
|
|
||||||
}
|
|
||||||
|
|
||||||
long total = replies.size();
|
long total = replies.size();
|
||||||
long passCount = replies.stream()
|
long passCount = replies.stream()
|
||||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
|
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
|
||||||
long failCount = replies.stream()
|
long failCount = replies.stream()
|
||||||
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
|
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
|
||||||
double avgScore = replies.stream()
|
|
||||||
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
|
|
||||||
.mapToInt(r -> r.getSpec().getScore())
|
|
||||||
.average().orElse(0.0);
|
|
||||||
|
|
||||||
long reviewingCount = replies.stream()
|
long reviewingCount = replies.stream()
|
||||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())
|
.filter(r -> "PASS".equals(r.getSpec().getStatus())
|
||||||
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
|
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
Map<String, Long> sentimentDistribution = new HashMap<>();
|
return new StatsResponse(total, passCount, failCount, reviewingCount);
|
||||||
sentimentDistribution.put("POSITIVE", 0L);
|
|
||||||
sentimentDistribution.put("NEUTRAL", 0L);
|
|
||||||
sentimentDistribution.put("NEGATIVE", 0L);
|
|
||||||
sentimentDistribution.put("UNKNOWN", 0L);
|
|
||||||
for (var r : replies) {
|
|
||||||
String sentiment = r.getSpec().getSentiment();
|
|
||||||
if (sentiment == null || sentiment.isBlank()) {
|
|
||||||
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
|
|
||||||
} else {
|
|
||||||
sentimentDistribution.merge(sentiment, 1L, Long::sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算 dailyTrend
|
|
||||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
Map<LocalDate, Long> dailyMap = new HashMap<>();
|
|
||||||
for (int i = 0; i < trendDays; i++) {
|
|
||||||
dailyMap.put(today.minusDays(i), 0L);
|
|
||||||
}
|
|
||||||
for (var r : replies) {
|
|
||||||
Instant timestamp = r.getMetadata().getCreationTimestamp();
|
|
||||||
if (timestamp != null) {
|
|
||||||
try {
|
|
||||||
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
|
|
||||||
if (dailyMap.containsKey(date)) {
|
|
||||||
dailyMap.merge(date, 1L, Long::sum);
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<DailyCount> dailyTrend = new ArrayList<>();
|
|
||||||
for (int i = 0; i < trendDays; i++) {
|
|
||||||
LocalDate date = today.minusDays(i);
|
|
||||||
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StatsResponse(total, passCount, failCount, avgScore,
|
|
||||||
reviewingCount, sentimentDistribution, dailyTrend);
|
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Failed to fetch stats: {}", e.getMessage());
|
log.warn("Failed to fetch stats: {}", e.getMessage());
|
||||||
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L,
|
return Mono.just(new StatsResponse(0, 0, 0, 0));
|
||||||
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
|
|
||||||
List.of()));
|
|
||||||
})
|
})
|
||||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
|
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
|
||||||
}
|
}
|
||||||
@@ -346,16 +271,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DailyCount(String date, long count) {}
|
|
||||||
|
|
||||||
public record StatsResponse(
|
public record StatsResponse(
|
||||||
long total,
|
long total,
|
||||||
long passCount,
|
long passCount,
|
||||||
long failCount,
|
long failCount,
|
||||||
double avgScore,
|
long reviewingCount
|
||||||
long reviewingCount,
|
|
||||||
Map<String, Long> sentimentDistribution,
|
|
||||||
List<DailyCount> dailyTrend
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record PersonaResponse(
|
public record PersonaResponse(
|
||||||
|
|||||||
@@ -1,85 +1,58 @@
|
|||||||
package top.nxxy335.commentaiautopilot.service;
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.aifoundation.AiModelService;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.aifoundation.chat.GenerateTextRequest;
|
||||||
|
import run.halo.aifoundation.chat.GenerateTextResult;
|
||||||
|
import run.halo.aifoundation.schema.OutputSpec;
|
||||||
|
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Foundation client that uses runtime class loading and reflection
|
* AI Foundation client that uses Halo's {@link ExtensionGetter} to obtain the
|
||||||
* to call the AI Foundation plugin's AiModelService.
|
* {@link AiModelService} extension provided by the ai-foundation plugin.
|
||||||
* <p>
|
* <p>
|
||||||
* This approach avoids classloader identity issues by loading AiModelService
|
* This is the recommended way to integrate with AI Foundation, see
|
||||||
* from ai-foundation's own classloader, so that Spring's getBeansOfType()
|
* <a href="https://github.com/halo-dev/plugin-ai-foundation/blob/main/dev/dev.md">dev guide</a>.
|
||||||
* can correctly match the implementation bean.
|
|
||||||
* <p>
|
* <p>
|
||||||
* No @ConditionalOnClass or pluginDependencies needed.
|
* Requires the following declaration in plugin.yaml:
|
||||||
* Always registered as a bean; availability is checked at runtime.
|
* <pre>
|
||||||
|
* spec:
|
||||||
|
* pluginDependencies:
|
||||||
|
* ai-foundation?: "*"
|
||||||
|
* </pre>
|
||||||
|
* The dependency is optional, so the plugin still loads when AI Foundation is
|
||||||
|
* not installed; availability is checked at runtime and all calls return empty
|
||||||
|
* in that case.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class AiFoundationClient {
|
public class AiFoundationClient {
|
||||||
|
|
||||||
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
|
private final ExtensionGetter extensionGetter;
|
||||||
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
|
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
public AiFoundationClient(ExtensionGetter extensionGetter) {
|
||||||
private final ApplicationContext applicationContext;
|
this.extensionGetter = extensionGetter;
|
||||||
|
|
||||||
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
|
|
||||||
this.client = client;
|
|
||||||
this.applicationContext = applicationContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call AI Foundation to generate a chat response using the specified model.
|
* Call AI Foundation to generate a chat response using the specified model.
|
||||||
|
* Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that
|
||||||
|
* transient model errors are retried by the SDK.
|
||||||
*
|
*
|
||||||
* @param prompt the prompt text
|
* @param prompt the prompt text
|
||||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||||
* @return the generated text, or empty if AI Foundation is unavailable
|
* @return the generated text, or empty if AI Foundation is unavailable
|
||||||
*/
|
*/
|
||||||
public Mono<String> chat(String prompt, String modelName) {
|
public Mono<String> chat(String prompt, String modelName) {
|
||||||
return isAiFoundationEnabled()
|
return aiModelService()
|
||||||
.flatMap(enabled -> {
|
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
|
||||||
if (!enabled) {
|
.flatMap(model -> model.generateText(
|
||||||
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply");
|
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
|
||||||
return Mono.empty();
|
.map(GenerateTextResult::getText))
|
||||||
}
|
|
||||||
return doChat(prompt, modelName);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found.
|
|
||||||
*/
|
|
||||||
public Mono<Boolean> isAvailable() {
|
|
||||||
return isAiFoundationEnabled()
|
|
||||||
.flatMap(enabled -> {
|
|
||||||
if (!enabled) return Mono.just(false);
|
|
||||||
return findAiModelService().hasElement();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Boolean> isAiFoundationEnabled() {
|
|
||||||
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
|
|
||||||
.map(plugin -> plugin.getSpec().getEnabled())
|
|
||||||
.defaultIfEmpty(false)
|
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
|
|
||||||
return Mono.just(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<String> doChat(String prompt, String modelName) {
|
|
||||||
return findAiModelService()
|
|
||||||
.flatMap(service -> invokeLanguageModel(service, modelName)
|
|
||||||
.flatMap(model -> invokeGenerateText(model, prompt))
|
|
||||||
)
|
|
||||||
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||||
@@ -88,132 +61,68 @@ public class AiFoundationClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get PluginManager via the pluginWrapper bean registered in our plugin context.
|
* Call AI Foundation to classify text into one of the given choices using
|
||||||
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton:
|
* structured output ({@link OutputSpec#choice(List)}).
|
||||||
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
|
* <p>
|
||||||
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance.
|
* This is the recommended way to do classification per the dev guide,
|
||||||
|
* as it is more reliable than prompt parsing.
|
||||||
|
*
|
||||||
|
* @param systemPrompt system prompt describing the task
|
||||||
|
* @param userPrompt the user input to classify
|
||||||
|
* @param choices the allowed classification values
|
||||||
|
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||||
|
* @return the selected choice string, or empty if AI Foundation is unavailable
|
||||||
*/
|
*/
|
||||||
private Object findPluginManager() {
|
public Mono<String> classify(String systemPrompt, String userPrompt,
|
||||||
try {
|
List<String> choices, String modelName) {
|
||||||
Object pluginWrapper = applicationContext.getBean("pluginWrapper");
|
return aiModelService()
|
||||||
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager");
|
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
|
||||||
getPluginManagerMethod.setAccessible(true);
|
.flatMap(model -> model.generateText(
|
||||||
Object pm = getPluginManagerMethod.invoke(pluginWrapper);
|
GenerateTextRequest.builder()
|
||||||
if (pm != null) {
|
.system(systemPrompt)
|
||||||
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName());
|
.prompt(userPrompt)
|
||||||
}
|
.output(OutputSpec.choice(choices))
|
||||||
return pm;
|
.maxRetries(2)
|
||||||
} catch (NoSuchMethodException e) {
|
.build()))
|
||||||
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage());
|
.map(result -> {
|
||||||
} catch (Exception e) {
|
Object output = result.getOutput();
|
||||||
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage());
|
return output != null ? String.valueOf(output).trim() : "";
|
||||||
}
|
}))
|
||||||
log.warn("PluginManager not found");
|
.doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
|
||||||
return null;
|
.onErrorResume(e -> {
|
||||||
|
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
|
* Check if AI Foundation is available: plugin installed and an
|
||||||
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
|
* AiModelService extension is enabled.
|
||||||
* to get the plugin's ApplicationContext.
|
|
||||||
*/
|
*/
|
||||||
private Mono<Object> findAiModelService() {
|
public Mono<Boolean> isAvailable() {
|
||||||
return Mono.fromCallable(() -> {
|
return aiModelService().hasElement()
|
||||||
Object pm = findPluginManager();
|
.onErrorResume(e -> {
|
||||||
if (pm == null) return null;
|
log.debug("AI Foundation not available: {}", e.getMessage());
|
||||||
|
return Mono.just(false);
|
||||||
// Call pm.getPlugin("ai-foundation") via reflection
|
});
|
||||||
Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class);
|
|
||||||
getPluginMethod.setAccessible(true);
|
|
||||||
Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME);
|
|
||||||
if (pluginWrapper == null) {
|
|
||||||
log.debug("ai-foundation plugin not found in PluginManager");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call pluginWrapper.getPlugin() to get the plugin instance
|
|
||||||
Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin");
|
|
||||||
getPluginInstanceMethod.setAccessible(true);
|
|
||||||
Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper);
|
|
||||||
if (pluginInstance == null) {
|
|
||||||
log.debug("ai-foundation plugin instance is null");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the plugin's ApplicationContext via reflection on SpringPlugin
|
|
||||||
// DefaultSpringPlugin is package-private, so we need setAccessible
|
|
||||||
Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext");
|
|
||||||
getCtxMethod.setAccessible(true);
|
|
||||||
ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance);
|
|
||||||
|
|
||||||
// Get the plugin classloader
|
|
||||||
Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader");
|
|
||||||
getClassLoaderMethod.setAccessible(true);
|
|
||||||
ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper);
|
|
||||||
|
|
||||||
// Load AiModelService from ai-foundation's classloader
|
|
||||||
Class<?> aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS);
|
|
||||||
|
|
||||||
// Find the AiModelService bean in ai-foundation's ApplicationContext
|
|
||||||
Map<String, ?> beans = pluginAppContext.getBeansOfType(aiModelServiceClass);
|
|
||||||
if (beans.isEmpty()) {
|
|
||||||
log.debug("AiModelService bean not found in ai-foundation's ApplicationContext");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Found AiModelService bean in ai-foundation's ApplicationContext");
|
|
||||||
Object result = beans.values().iterator().next();
|
|
||||||
return (Object) result;
|
|
||||||
}).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call service.languageModel(modelName) or service.languageModel() via reflection.
|
* Obtain the enabled AiModelService extension via ExtensionGetter.
|
||||||
* Returns Mono<LanguageModel> from ai-foundation's classloader.
|
* <p>
|
||||||
|
* Wrapped in {@link Mono#defer} with a {@link NoClassDefFoundError} guard so
|
||||||
|
* that the plugin still works when the optional ai-foundation dependency is
|
||||||
|
* not installed (the AiModelService API class is then absent from the
|
||||||
|
* classloader).
|
||||||
*/
|
*/
|
||||||
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
|
private Mono<AiModelService> aiModelService() {
|
||||||
return Mono.fromCallable(() -> {
|
return Mono.defer(() -> {
|
||||||
Method method;
|
try {
|
||||||
if (modelName != null && !modelName.isBlank()) {
|
return extensionGetter.getEnabledExtension(AiModelService.class);
|
||||||
method = service.getClass().getMethod("languageModel", String.class);
|
} catch (NoClassDefFoundError e) {
|
||||||
method.setAccessible(true);
|
log.debug("AI Foundation API not on classpath: {}", e.getMessage());
|
||||||
return method.invoke(service, modelName);
|
return Mono.empty();
|
||||||
} else {
|
|
||||||
method = service.getClass().getMethod("languageModel");
|
|
||||||
method.setAccessible(true);
|
|
||||||
return method.invoke(service);
|
|
||||||
}
|
}
|
||||||
}).flatMap(result -> {
|
|
||||||
if (result instanceof Mono<?> mono) return mono;
|
|
||||||
return Mono.justOrEmpty(result);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Call model.generateText(prompt) via reflection, then extract text from result.
|
|
||||||
* Returns the generated text string.
|
|
||||||
*/
|
|
||||||
private Mono<String> invokeGenerateText(Object model, String prompt) {
|
|
||||||
return Mono.fromCallable(() -> {
|
|
||||||
Method method = model.getClass().getMethod("generateText", String.class);
|
|
||||||
method.setAccessible(true);
|
|
||||||
return method.invoke(model, prompt);
|
|
||||||
}).flatMap(result -> {
|
|
||||||
if (result instanceof Mono<?> mono) {
|
|
||||||
return mono.map(this::extractText);
|
|
||||||
}
|
|
||||||
return Mono.justOrEmpty(extractText(result));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractText(Object result) {
|
|
||||||
if (result == null) return null;
|
|
||||||
try {
|
|
||||||
Method getText = result.getClass().getMethod("getText");
|
|
||||||
getText.setAccessible(true);
|
|
||||||
return (String) getText.invoke(result);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,55 @@ public class ContextExtractor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch previous replies in the comment thread to provide conversation history.
|
||||||
|
* Only includes replies created before the triggering reply.
|
||||||
|
*/
|
||||||
|
private Mono<String> fetchConversationHistory(String commentName, String triggerReplyName) {
|
||||||
|
if (triggerReplyName == null || triggerReplyName.isBlank()) {
|
||||||
|
return Mono.just("");
|
||||||
|
}
|
||||||
|
return client.fetch(Reply.class, triggerReplyName)
|
||||||
|
.flatMap(triggerReply -> {
|
||||||
|
var triggerTime = triggerReply.getMetadata().getCreationTimestamp();
|
||||||
|
return client.list(Reply.class,
|
||||||
|
reply -> {
|
||||||
|
if (!commentName.equals(reply.getSpec().getCommentName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (triggerReplyName.equals(reply.getMetadata().getName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Only include replies created before the trigger reply
|
||||||
|
var replyTime = reply.getMetadata().getCreationTimestamp();
|
||||||
|
return replyTime != null && triggerTime != null
|
||||||
|
&& !replyTime.isAfter(triggerTime);
|
||||||
|
},
|
||||||
|
null)
|
||||||
|
.collectList()
|
||||||
|
.map(replies -> {
|
||||||
|
if (replies.isEmpty()) return "";
|
||||||
|
// Sort by creation time
|
||||||
|
replies.sort(java.util.Comparator.comparing(
|
||||||
|
r -> r.getMetadata().getCreationTimestamp()));
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (var r : replies) {
|
||||||
|
var owner = r.getSpec().getOwner();
|
||||||
|
String name = (owner != null && owner.getDisplayName() != null)
|
||||||
|
? owner.getDisplayName() : "匿名用户";
|
||||||
|
boolean isAi = owner != null && owner.getAnnotations() != null
|
||||||
|
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||||
|
String role = isAi ? "AI" : "用户";
|
||||||
|
String content = extractReplyContent(r);
|
||||||
|
sb.append(role).append("(").append(name).append("): ")
|
||||||
|
.append(content).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
|
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
|
||||||
var commentContent = extractCommentContent(comment);
|
var commentContent = extractCommentContent(comment);
|
||||||
var commentOwner = extractCommentOwner(comment);
|
var commentOwner = extractCommentOwner(comment);
|
||||||
@@ -63,7 +112,8 @@ public class ContextExtractor {
|
|||||||
null,
|
null,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
formatPostDate(post),
|
formatPostDate(post),
|
||||||
commentCount
|
commentCount,
|
||||||
|
""
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -78,7 +128,8 @@ public class ContextExtractor {
|
|||||||
null,
|
null,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0
|
0,
|
||||||
|
""
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +144,8 @@ public class ContextExtractor {
|
|||||||
null,
|
null,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0
|
0,
|
||||||
|
""
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,55 +153,68 @@ public class ContextExtractor {
|
|||||||
var replyContent = extractReplyContent(reply);
|
var replyContent = extractReplyContent(reply);
|
||||||
var replyOwner = extractReplyOwner(reply);
|
var replyOwner = extractReplyOwner(reply);
|
||||||
var subjectRef = comment.getSpec().getSubjectRef();
|
var subjectRef = comment.getSpec().getSubjectRef();
|
||||||
|
var commentName = comment.getMetadata().getName();
|
||||||
|
var replyName = reply.getMetadata().getName();
|
||||||
|
|
||||||
|
// Fetch conversation history for AI conversations
|
||||||
|
Mono<String> historyMono = isAiConversation
|
||||||
|
? fetchConversationHistory(commentName, replyName)
|
||||||
|
: Mono.just("");
|
||||||
|
|
||||||
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
||||||
String postName = subjectRef.getName();
|
String postName = subjectRef.getName();
|
||||||
return client.fetch(Post.class, postName)
|
return client.fetch(Post.class, postName)
|
||||||
.flatMap(post -> getPostContent(postName)
|
.flatMap(post -> getPostContent(postName)
|
||||||
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
|
.flatMap(content -> getCommentCount(commentName)
|
||||||
.map(commentCount -> new CommentContext(
|
.flatMap(commentCount -> historyMono
|
||||||
comment.getMetadata().getName(),
|
.map(history -> new CommentContext(
|
||||||
postName,
|
commentName,
|
||||||
post.getSpec().getSlug(),
|
postName,
|
||||||
replyContent,
|
post.getSpec().getSlug(),
|
||||||
replyOwner,
|
replyContent,
|
||||||
post.getSpec().getTitle(),
|
replyOwner,
|
||||||
content,
|
post.getSpec().getTitle(),
|
||||||
reply.getMetadata().getName(),
|
content,
|
||||||
isAiConversation,
|
replyName,
|
||||||
formatPostDate(post),
|
isAiConversation,
|
||||||
commentCount
|
formatPostDate(post),
|
||||||
))
|
commentCount,
|
||||||
|
history
|
||||||
|
))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.defaultIfEmpty(new CommentContext(
|
.defaultIfEmpty(new CommentContext(
|
||||||
comment.getMetadata().getName(),
|
commentName,
|
||||||
postName,
|
postName,
|
||||||
"",
|
"",
|
||||||
replyContent,
|
replyContent,
|
||||||
replyOwner,
|
replyOwner,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
reply.getMetadata().getName(),
|
replyName,
|
||||||
isAiConversation,
|
isAiConversation,
|
||||||
"",
|
"",
|
||||||
0
|
0,
|
||||||
|
""
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Mono.just(new CommentContext(
|
return historyMono
|
||||||
comment.getMetadata().getName(),
|
.map(history -> new CommentContext(
|
||||||
"",
|
commentName,
|
||||||
"",
|
"",
|
||||||
replyContent,
|
"",
|
||||||
replyOwner,
|
replyContent,
|
||||||
"",
|
replyOwner,
|
||||||
"",
|
"",
|
||||||
reply.getMetadata().getName(),
|
"",
|
||||||
isAiConversation,
|
replyName,
|
||||||
"",
|
isAiConversation,
|
||||||
0
|
"",
|
||||||
));
|
0,
|
||||||
|
history
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractCommentContent(Comment comment) {
|
private String extractCommentContent(Comment comment) {
|
||||||
@@ -245,6 +310,7 @@ public class ContextExtractor {
|
|||||||
String replyTo,
|
String replyTo,
|
||||||
boolean isAiConversation,
|
boolean isAiConversation,
|
||||||
String postDate,
|
String postDate,
|
||||||
int commentCount
|
int commentCount,
|
||||||
|
String conversationHistory
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public class PromptBuilder {
|
|||||||
文章(仅供理解上下文,不要复述):
|
文章(仅供理解上下文,不要复述):
|
||||||
{{article}}
|
{{article}}
|
||||||
|
|
||||||
|
{{conversation_history}}
|
||||||
评论:
|
评论:
|
||||||
{{comment}}
|
{{comment}}
|
||||||
""";
|
""";
|
||||||
@@ -103,6 +104,7 @@ public class PromptBuilder {
|
|||||||
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
|
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
|
||||||
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
|
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
|
||||||
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
||||||
|
.replace("{{conversation_history}}", formatConversationHistory(context))
|
||||||
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
||||||
|
|
||||||
return prompt;
|
return prompt;
|
||||||
@@ -133,6 +135,7 @@ public class PromptBuilder {
|
|||||||
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
|
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
|
||||||
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
|
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
|
||||||
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
||||||
|
.replace("{{conversation_history}}", formatConversationHistory(context))
|
||||||
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
||||||
|
|
||||||
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
|
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
|
||||||
@@ -147,6 +150,18 @@ public class PromptBuilder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format conversation history for inclusion in the prompt.
|
||||||
|
* Returns empty string if no history is available.
|
||||||
|
*/
|
||||||
|
private String formatConversationHistory(ContextExtractor.CommentContext context) {
|
||||||
|
String history = context.conversationHistory();
|
||||||
|
if (history == null || history.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "对话历史(供理解上下文):\n" + history + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<String> getPromptTemplate() {
|
private Mono<String> getPromptTemplate() {
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package top.nxxy335.commentaiautopilot.service;
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.DisposableBean;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -8,15 +9,18 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class RateLimitService {
|
public class RateLimitService implements DisposableBean {
|
||||||
private final ConcurrentHashMap<Long, AtomicInteger> windowMap = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<Long, AtomicInteger> windowMap = new ConcurrentHashMap<>();
|
||||||
|
private final Thread cleanupThread;
|
||||||
|
private volatile boolean running = true;
|
||||||
|
|
||||||
public RateLimitService() {
|
public RateLimitService() {
|
||||||
// 每5分钟清理过期窗口,防止内存泄漏
|
// 每5分钟清理过期窗口,防止内存泄漏
|
||||||
Thread cleanupThread = new Thread(() -> {
|
cleanupThread = new Thread(() -> {
|
||||||
while (!Thread.currentThread().isInterrupted()) {
|
while (running && !Thread.currentThread().isInterrupted()) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(5 * 60 * 1000);
|
Thread.sleep(5 * 60 * 1000);
|
||||||
|
if (!running) break;
|
||||||
cleanup();
|
cleanup();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
@@ -55,4 +59,13 @@ public class RateLimitService {
|
|||||||
log.debug("[RateLimit] Cleaned up {} expired windows", removed);
|
log.debug("[RateLimit] Cleaned up {} expired windows", removed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroy() {
|
||||||
|
running = false;
|
||||||
|
if (cleanupThread != null) {
|
||||||
|
cleanupThread.interrupt();
|
||||||
|
}
|
||||||
|
log.info("[RateLimit] Cleanup thread stopped");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ReviewService {
|
public class ReviewService {
|
||||||
@@ -14,58 +16,135 @@ public class ReviewService {
|
|||||||
this.aiFoundationClient = aiFoundationClient;
|
this.aiFoundationClient = aiFoundationClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String REVIEW_PROMPT_TEMPLATE = """
|
/**
|
||||||
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复:
|
* Safety classification choices.
|
||||||
|
* Using OutputSpec.choice for reliable structured output.
|
||||||
|
*/
|
||||||
|
private static final String SAFE = "安全";
|
||||||
|
private static final String UNSAFE = "不安全";
|
||||||
|
private static final List<String> SAFETY_CHOICES = List.of(SAFE, UNSAFE);
|
||||||
|
|
||||||
文章:
|
/**
|
||||||
%s
|
* Quality rating choices for content quality assessment.
|
||||||
|
* Used to produce a meaningful score (1-5) instead of binary 0/100.
|
||||||
|
*/
|
||||||
|
private static final String RATING_EXCELLENT = "5";
|
||||||
|
private static final String RATING_GOOD = "4";
|
||||||
|
private static final String RATING_AVERAGE = "3";
|
||||||
|
private static final String RATING_POOR = "2";
|
||||||
|
private static final String RATING_BAD = "1";
|
||||||
|
private static final List<String> QUALITY_CHOICES = List.of(
|
||||||
|
RATING_EXCELLENT, RATING_GOOD, RATING_AVERAGE, RATING_POOR, RATING_BAD
|
||||||
|
);
|
||||||
|
|
||||||
原始评论:
|
private static final String SAFETY_SYSTEM_PROMPT = """
|
||||||
%s
|
你是内容安全审核员。请严格评估AI生成的回复内容是否安全合规。
|
||||||
|
检查以下方面:
|
||||||
|
1. 是否包含辱骂、仇恨、偏见或煽动性言论
|
||||||
|
2. 是否包含不适宜公开传播的内容
|
||||||
|
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容
|
||||||
|
只返回"安全"或"不安全"。""";
|
||||||
|
|
||||||
生成的回复:
|
private static final String QUALITY_SYSTEM_PROMPT = """
|
||||||
%s
|
你是AI回复质量评估员。请对AI生成的回复进行质量评分(1-5分)。
|
||||||
|
评分标准:
|
||||||
请检查以下内容:
|
5分:回复切题、自然流畅、语气恰当、有帮助
|
||||||
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
|
4分:回复较好,基本切题,语气自然
|
||||||
2. 是否包含不适宜公开传播的内容?
|
3分:回复一般,基本切题但略显生硬或冗长
|
||||||
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
|
2分:回复较差,偏离主题或语气不当
|
||||||
|
1分:回复很差,完全不相关或不当
|
||||||
请直接回答"安全"或"不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
|
只返回数字 1-5。""";
|
||||||
""";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review AI reply content using two-stage assessment:
|
||||||
|
* 1. Safety check via OutputSpec.choice (安全/不安全)
|
||||||
|
* 2. Quality rating via OutputSpec.choice (1-5) — only if safe
|
||||||
|
*
|
||||||
|
* <p>Score mapping (0-100):
|
||||||
|
* <ul>
|
||||||
|
* <li>Unsafe → 0 (FAIL)</li>
|
||||||
|
* <li>Rating 5 → 100 (PASS)</li>
|
||||||
|
* <li>Rating 4 → 85 (PASS)</li>
|
||||||
|
* <li>Rating 3 → 70 (PASS)</li>
|
||||||
|
* <li>Rating 2 → 50 (PASS, borderline)</li>
|
||||||
|
* <li>Rating 1 → 30 (PASS, but low quality)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
||||||
String modelName) {
|
String modelName) {
|
||||||
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
|
String userPrompt = String.format("""
|
||||||
truncate(articleContent, 2000),
|
原始评论:
|
||||||
|
%s
|
||||||
|
|
||||||
|
生成的回复:
|
||||||
|
%s
|
||||||
|
|
||||||
|
请判断以上回复是否安全合规。""",
|
||||||
truncate(commentContent, 500),
|
truncate(commentContent, 500),
|
||||||
truncate(aiReply, 500));
|
truncate(aiReply, 500));
|
||||||
|
|
||||||
return aiFoundationClient.chat(reviewPrompt, modelName)
|
// Stage 1: Safety check
|
||||||
.map(this::parseSafetyResult)
|
return aiFoundationClient.classify(SAFETY_SYSTEM_PROMPT, userPrompt, SAFETY_CHOICES, modelName)
|
||||||
|
.flatMap(safetyResult -> {
|
||||||
|
if (UNSAFE.equals(safetyResult)) {
|
||||||
|
log.warn("[Review] Content is UNSAFE");
|
||||||
|
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核不通过"));
|
||||||
|
}
|
||||||
|
if (!SAFE.equals(safetyResult)) {
|
||||||
|
log.warn("[Review] Unexpected safety result: {}, treating as unsafe", safetyResult);
|
||||||
|
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核结果异常"));
|
||||||
|
}
|
||||||
|
// Stage 2: Quality rating (only for safe content)
|
||||||
|
return rateQuality(commentContent, aiReply, modelName);
|
||||||
|
})
|
||||||
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Review failed, auto-passing: {}", e.getMessage());
|
log.warn("[Review] Review failed, auto-passing: {}", e.getMessage());
|
||||||
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
|
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReviewResult parseSafetyResult(String response) {
|
/**
|
||||||
if (response == null || response.isBlank()) {
|
* Rate the quality of a safe AI reply (1-5) and map to a 0-100 score.
|
||||||
return new ReviewResult(100, "PASS", "审核无响应,自动通过");
|
*/
|
||||||
}
|
private Mono<ReviewResult> rateQuality(String commentContent, String aiReply, String modelName) {
|
||||||
String trimmed = response.trim().toLowerCase();
|
String qualityPrompt = String.format("""
|
||||||
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) {
|
评论:
|
||||||
log.warn("AI Review: content is UNSAFE, response: {}", response);
|
%s
|
||||||
return new ReviewResult(0, "FAIL", "内容安全审核不通过");
|
|
||||||
}
|
回复:
|
||||||
if (trimmed.contains("安全") || trimmed.contains("safe")) {
|
%s
|
||||||
log.info("AI Review: content is SAFE");
|
|
||||||
return new ReviewResult(100, "PASS", "内容安全审核通过");
|
请对以上回复进行质量评分(1-5分)。""",
|
||||||
}
|
truncate(commentContent, 500),
|
||||||
// If unclear response, default to pass
|
truncate(aiReply, 500));
|
||||||
log.warn("AI Review: unclear response, auto-passing: {}", response);
|
|
||||||
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过");
|
return aiFoundationClient.classify(QUALITY_SYSTEM_PROMPT, qualityPrompt, QUALITY_CHOICES, modelName)
|
||||||
|
.map(rating -> {
|
||||||
|
int score = mapRatingToScore(rating);
|
||||||
|
String reason = "安全通过,质量评分: " + rating + "/5";
|
||||||
|
log.info("[Review] Content is SAFE, quality rating: {}/5, score: {}", rating, score);
|
||||||
|
return new ReviewResult(score, "PASS", reason);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(new ReviewResult(85, "PASS", "安全通过,质量评分默认 4/5"))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("[Review] Quality rating failed, defaulting to 85: {}", e.getMessage());
|
||||||
|
return Mono.just(new ReviewResult(85, "PASS", "安全通过,质量评分异常"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a 1-5 quality rating to a 0-100 score.
|
||||||
|
*/
|
||||||
|
private int mapRatingToScore(String rating) {
|
||||||
|
return switch (rating) {
|
||||||
|
case RATING_EXCELLENT -> 100;
|
||||||
|
case RATING_GOOD -> 85;
|
||||||
|
case RATING_AVERAGE -> 70;
|
||||||
|
case RATING_POOR -> 50;
|
||||||
|
case RATING_BAD -> 30;
|
||||||
|
default -> 70; // default to average
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private String truncate(String text, int maxLength) {
|
private String truncate(String text, int maxLength) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class SentimentService {
|
public class SentimentService {
|
||||||
@@ -20,13 +22,27 @@ public class SentimentService {
|
|||||||
public static final String NEGATIVE = "NEGATIVE";
|
public static final String NEGATIVE = "NEGATIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
private static final List<String> CHOICES = List.of(
|
||||||
String prompt = buildSentimentPrompt(commentContent);
|
SentimentResult.POSITIVE, SentimentResult.NEUTRAL, SentimentResult.NEGATIVE
|
||||||
|
);
|
||||||
|
|
||||||
return aiFoundationClient.chat(prompt, modelName)
|
/**
|
||||||
.map(response -> {
|
* Analyze sentiment using AI Foundation structured output
|
||||||
String sentiment = parseSentiment(response);
|
* ({@code OutputSpec.choice}) for reliable classification.
|
||||||
return new SentimentResult(sentiment, 1.0);
|
*/
|
||||||
|
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||||
|
String systemPrompt = "你是一个情感分析助手。请分析评论的情感倾向,只返回 POSITIVE、NEUTRAL 或 NEGATIVE 之一。";
|
||||||
|
String userPrompt = "分析以下评论的情感倾向:\n\n" + commentContent;
|
||||||
|
|
||||||
|
return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName)
|
||||||
|
.map(sentiment -> {
|
||||||
|
String upper = sentiment.toUpperCase();
|
||||||
|
// Validate against known choices; default to NEUTRAL if unexpected
|
||||||
|
if (!CHOICES.contains(upper)) {
|
||||||
|
log.warn("[Sentiment] Unexpected classification result: {}, defaulting to NEUTRAL", sentiment);
|
||||||
|
return new SentimentResult(SentimentResult.NEUTRAL, 0.0);
|
||||||
|
}
|
||||||
|
return new SentimentResult(upper, 1.0);
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
|
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
|
||||||
@@ -34,16 +50,4 @@ public class SentimentService {
|
|||||||
})
|
})
|
||||||
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildSentimentPrompt(String commentContent) {
|
|
||||||
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String parseSentiment(String response) {
|
|
||||||
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
|
|
||||||
String upper = response.trim().toUpperCase();
|
|
||||||
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
|
|
||||||
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
|
|
||||||
return SentimentResult.NEUTRAL;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ spec:
|
|||||||
- $formkit: textarea
|
- $formkit: textarea
|
||||||
name: customPromptTemplate
|
name: customPromptTemplate
|
||||||
label: 自定义Prompt模板
|
label: 自定义Prompt模板
|
||||||
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n{{conversation_history}}\n评论:\n{{comment}}"
|
||||||
- $formkit: select
|
- $formkit: select
|
||||||
name: enabledPresets
|
name: enabledPresets
|
||||||
label: 启用预设
|
label: 启用预设
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ kind: Plugin
|
|||||||
metadata:
|
metadata:
|
||||||
# The name defines how the plugin is invoked, A unique name
|
# The name defines how the plugin is invoked, A unique name
|
||||||
name: comment-ai-autopilot
|
name: comment-ai-autopilot
|
||||||
|
annotations:
|
||||||
|
# Recommend installing AI Foundation from the app store after installing this plugin
|
||||||
|
# https://www.halo.run/store/apps/app-acslk9nu
|
||||||
|
"store.halo.run/recommended-apps": '["app-acslk9nu"]'
|
||||||
spec:
|
spec:
|
||||||
enabled: true
|
enabled: true
|
||||||
requires: ">=2.25.0"
|
requires: ">=2.25.0"
|
||||||
|
pluginDependencies:
|
||||||
|
# Optional dependency: plugin still loads without AI Foundation,
|
||||||
|
# but AI features require it to be installed and enabled.
|
||||||
|
ai-foundation?: "*"
|
||||||
author:
|
author:
|
||||||
name: 暖心向阳335
|
name: 暖心向阳335
|
||||||
website: https://nxxy335.top
|
website: https://nxxy335.top
|
||||||
@@ -22,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.0.0-beta.1"
|
version: "1.0.0-beta.2-kx7m2p"
|
||||||
|
|||||||
+5
-194
@@ -108,152 +108,11 @@
|
|||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Middle: Sentiment + Trend -->
|
<!-- Quick Actions -->
|
||||||
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
|
<div class="mt-4">
|
||||||
<!-- Sentiment Distribution -->
|
|
||||||
<VCard :body-class="['!p-5']">
|
|
||||||
<h3 class="text-sm font-medium text-gray-500 mb-4">情感分布</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-2 h-2 rounded-full bg-green-500 shrink-0"></div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="text-gray-700">正面</span>
|
|
||||||
<span class="font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full bg-green-400 rounded-full transition-all duration-500"
|
|
||||||
:style="{ width: getSentimentPercent('POSITIVE') + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="text-gray-700">中性</span>
|
|
||||||
<span class="font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full bg-gray-400 rounded-full transition-all duration-500"
|
|
||||||
:style="{ width: getSentimentPercent('NEUTRAL') + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-2 h-2 rounded-full bg-red-500 shrink-0"></div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="text-gray-700">负面</span>
|
|
||||||
<span class="font-medium text-red-500">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full bg-red-400 rounded-full transition-all duration-500"
|
|
||||||
:style="{ width: getSentimentPercent('NEGATIVE') + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-2 h-2 rounded-full bg-gray-300 shrink-0"></div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<span class="text-gray-700">未知</span>
|
|
||||||
<span class="font-medium text-gray-400">{{ stats?.sentimentDistribution?.UNKNOWN || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full bg-gray-300 rounded-full transition-all duration-500"
|
|
||||||
:style="{ width: getSentimentPercent('UNKNOWN') + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- Daily Trend -->
|
|
||||||
<VCard :body-class="['!p-5']">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
|
|
||||||
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs transition-colors"
|
|
||||||
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = '7'"
|
|
||||||
>
|
|
||||||
7天
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
|
||||||
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = '30'"
|
|
||||||
>
|
|
||||||
30天
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
|
|
||||||
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
|
|
||||||
@click="range = 'all'"
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
|
|
||||||
<div
|
|
||||||
v-for="day in stats.dailyTrend"
|
|
||||||
:key="day.date"
|
|
||||||
class="flex-1 flex flex-col items-center justify-end h-full"
|
|
||||||
>
|
|
||||||
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
|
|
||||||
<div
|
|
||||||
class="w-full rounded-t-md transition-all duration-500"
|
|
||||||
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
|
|
||||||
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
|
|
||||||
></div>
|
|
||||||
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom: Score + Quick Actions -->
|
|
||||||
<div class="grid grid-cols-1 gap-4 mt-4 sm:grid-cols-2">
|
|
||||||
<!-- Avg Score -->
|
|
||||||
<VCard :body-class="['!p-5']">
|
|
||||||
<h3 class="text-sm font-medium text-gray-500 mb-3">平均审核评分</h3>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="text-4xl font-bold" :class="scoreColor">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all duration-500"
|
|
||||||
:class="scoreBarColor"
|
|
||||||
:style="{ width: (stats?.avgScore || 0) * 10 + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-[10px] text-gray-300 mt-1">
|
|
||||||
<span>0</span>
|
|
||||||
<span>5</span>
|
|
||||||
<span>10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<VCard :body-class="['!p-5']">
|
<VCard :body-class="['!p-5']">
|
||||||
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
|
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
|
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
|
||||||
@click="$router.push({ name: 'CommentAiAutopilotLogs' })"
|
@click="$router.push({ name: 'CommentAiAutopilotLogs' })"
|
||||||
@@ -299,24 +158,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue"
|
import { ref, computed, onMounted } from "vue"
|
||||||
import { axiosInstance } from "@halo-dev/api-client"
|
import { axiosInstance } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
import { IconPlug } from "@halo-dev/components"
|
||||||
|
|
||||||
interface DailyCount {
|
|
||||||
date: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatsResponse {
|
interface StatsResponse {
|
||||||
total: number
|
total: number
|
||||||
passCount: number
|
passCount: number
|
||||||
failCount: number
|
failCount: number
|
||||||
reviewingCount: number
|
reviewingCount: number
|
||||||
avgScore: number
|
|
||||||
sentimentDistribution: Record<string, number>
|
|
||||||
dailyTrend: DailyCount[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersonaResponse {
|
interface PersonaResponse {
|
||||||
@@ -331,7 +182,6 @@ interface HealthResponse {
|
|||||||
|
|
||||||
const stats = ref<StatsResponse | null>(null)
|
const stats = ref<StatsResponse | null>(null)
|
||||||
const persona = ref<PersonaResponse | null>(null)
|
const persona = ref<PersonaResponse | null>(null)
|
||||||
const range = ref("7")
|
|
||||||
const health = ref<HealthResponse | null>(null)
|
const health = ref<HealthResponse | null>(null)
|
||||||
const healthVisible = ref(true)
|
const healthVisible = ref(true)
|
||||||
|
|
||||||
@@ -340,24 +190,10 @@ const passRate = computed(() => {
|
|||||||
return Math.round((stats.value.passCount / stats.value.total) * 100)
|
return Math.round((stats.value.passCount / stats.value.total) * 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
const scoreColor = computed(() => {
|
|
||||||
const score = stats.value?.avgScore || 0
|
|
||||||
if (score >= 7) return "text-green-600"
|
|
||||||
if (score >= 4) return "text-amber-500"
|
|
||||||
return "text-red-500"
|
|
||||||
})
|
|
||||||
|
|
||||||
const scoreBarColor = computed(() => {
|
|
||||||
const score = stats.value?.avgScore || 0
|
|
||||||
if (score >= 7) return "bg-gradient-to-r from-green-400 to-green-500"
|
|
||||||
if (score >= 4) return "bg-gradient-to-r from-amber-400 to-amber-500"
|
|
||||||
return "bg-gradient-to-r from-red-400 to-red-500"
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axiosInstance.get(
|
const { data } = await axiosInstance.get(
|
||||||
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
|
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=7`,
|
||||||
)
|
)
|
||||||
stats.value = data
|
stats.value = data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -421,31 +257,6 @@ const openSettings = () => {
|
|||||||
window.location.href = "/console/comment-ai-autopilot/settings"
|
window.location.href = "/console/comment-ai-autopilot/settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSentimentPercent = (sentiment: string): number => {
|
|
||||||
const dist = stats.value?.sentimentDistribution
|
|
||||||
if (!dist) return 0
|
|
||||||
const total = Object.values(dist).reduce((a, b) => a + b, 0)
|
|
||||||
if (total === 0) return 0
|
|
||||||
return Math.round(((dist[sentiment] || 0) / total) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTrendBarHeight = (count: number): number => {
|
|
||||||
const trend = stats.value?.dailyTrend
|
|
||||||
if (!trend || trend.length === 0) return 0
|
|
||||||
const max = Math.max(...trend.map(d => d.count), 1)
|
|
||||||
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTrendDate = (dateStr: string): string => {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const parts = dateStr.split('-')
|
|
||||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(range, () => {
|
|
||||||
fetchStats()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStats()
|
fetchStats()
|
||||||
fetchPersona()
|
fetchPersona()
|
||||||
|
|||||||
@@ -152,7 +152,8 @@
|
|||||||
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
<div class="px-4 py-2.5 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4 text-xs text-gray-400">
|
<div class="flex items-center gap-4 text-xs text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium text-gray-600">{{ reply.spec.score }}</span>
|
评分 <span :class="getScoreClass(reply.spec.score)" class="font-medium">{{ reply.spec.score }}</span>
|
||||||
|
<span class="text-gray-300 ml-0.5">{{ getScoreLabel(reply.spec.score) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
|
<span v-if="reply.spec.postSlug" class="flex items-center gap-1">
|
||||||
文章
|
文章
|
||||||
@@ -508,6 +509,14 @@ const getScoreClass = (score: number) => {
|
|||||||
return "text-red-600"
|
return "text-red-600"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getScoreLabel = (score: number) => {
|
||||||
|
if (score >= 85) return "优秀"
|
||||||
|
if (score >= 70) return "良好"
|
||||||
|
if (score >= 50) return "一般"
|
||||||
|
if (score > 0) return "较差"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const getStatusClass = (status: string) => {
|
const getStatusClass = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "PASS":
|
case "PASS":
|
||||||
|
|||||||
+385
-270
@@ -5,291 +5,324 @@
|
|||||||
<IconPlug class="mr-2 self-center" />
|
<IconPlug class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VButton type="secondary" size="sm" @click="exportConfig">导出配置</VButton>
|
<VSpace spacing="sm">
|
||||||
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
|
<VButton size="sm" @click="exportConfig">
|
||||||
导入配置
|
<template #icon>
|
||||||
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
|
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||||
</label>
|
</template>
|
||||||
<VButton @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
|
导出
|
||||||
|
</VButton>
|
||||||
|
<label class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||||
|
<svg style="width:14px;height:14px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
|
||||||
|
导入
|
||||||
|
<input type="file" accept=".json" class="hidden" @change="handleImportFile" />
|
||||||
|
</label>
|
||||||
|
<VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilot' })">返回概览</VButton>
|
||||||
|
</VSpace>
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
|
|
||||||
<div class="m-4">
|
<div class="m-4">
|
||||||
<VLoading v-if="loading" />
|
<VLoading v-if="loading" />
|
||||||
|
|
||||||
<div v-if="!loading" class="settings-container">
|
<div v-if="!loading">
|
||||||
<!-- Left: Settings Sections -->
|
<!-- Tab Navigation (full width, above the grid) -->
|
||||||
<div class="settings-sections space-y-5">
|
<div class="settings-tabs">
|
||||||
<!-- ========== 基本设置 ========== -->
|
<button
|
||||||
<section class="settings-section">
|
v-for="tab in tabItems"
|
||||||
<div class="section-header section-header--blue">
|
:key="tab.value"
|
||||||
<div class="section-header__icon">
|
class="settings-tab"
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
:class="{ 'settings-tab--active': activeTab === tab.value }"
|
||||||
</div>
|
@click="activeTab = tab.value"
|
||||||
<div class="section-header__text">
|
>
|
||||||
<h3>基本设置</h3>
|
{{ tab.label }}
|
||||||
<p>控制AI回复的基本行为</p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="section-body">
|
|
||||||
<!-- Switch Row: 自动回复 -->
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-row__label">
|
|
||||||
<span class="form-label">自动回复</span>
|
|
||||||
<span class="form-hint">启用后,AI将自动回复新评论</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" v-model="settings.basic.autoReply" />
|
|
||||||
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<!-- Switch Row: 自动发布 -->
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-row__label">
|
|
||||||
<span class="form-label">自动发布</span>
|
|
||||||
<span class="form-hint">关闭后AI回复将存为草稿,需手动审核发布</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" v-model="settings.basic.autoPublish" />
|
|
||||||
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<!-- Slider: 最大对话轮次 -->
|
|
||||||
<div class="form-field">
|
|
||||||
<div class="form-field__header">
|
|
||||||
<span class="form-label">最大对话轮次</span>
|
|
||||||
<span class="form-badge">{{ settings.basic.maxConversationRounds }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="form-hint">同一评论线程中AI最多自动回复的轮次</span>
|
|
||||||
<div class="slider">
|
|
||||||
<input type="range" v-model.number="settings.basic.maxConversationRounds" min="1" max="100" class="slider__input" />
|
|
||||||
<div class="slider__marks">
|
|
||||||
<span>1</span><span>50</span><span>100</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Input: 速率限制 -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">速率限制</label>
|
|
||||||
<span class="form-hint">每分钟最大AI回复数量</span>
|
|
||||||
<input type="number" v-model.number="settings.basic.rateLimitPerMinute" min="1" max="100" class="form-input" placeholder="10" />
|
|
||||||
</div>
|
|
||||||
<!-- Slider: 最大重试次数 -->
|
|
||||||
<div class="form-field">
|
|
||||||
<div class="form-field__header">
|
|
||||||
<span class="form-label">最大重试次数</span>
|
|
||||||
<span class="form-badge">{{ settings.basic.maxRetryCount }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="form-hint">AI生成失败时的最大重试次数,采用指数退避策略</span>
|
|
||||||
<div class="slider">
|
|
||||||
<input type="range" v-model.number="settings.basic.maxRetryCount" min="1" max="10" class="slider__input" />
|
|
||||||
<div class="slider__marks">
|
|
||||||
<span>1</span><span>5</span><span>10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Textarea: 评论者黑名单 -->
|
|
||||||
<div class="form-field">
|
|
||||||
<div class="form-field__header">
|
|
||||||
<span class="form-label">评论者黑名单</span>
|
|
||||||
<button class="btn-link" @click="openCommenterDialog">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
|
||||||
添加评论者
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="form-hint">支持名称、邮箱和正则表达式。正则以 regex: 开头,如 regex:^spam.*</span>
|
|
||||||
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com, 李四"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ========== AI角色设置 ========== -->
|
<!-- Settings Grid: sections (left) + sidebar (right) -->
|
||||||
<section class="settings-section">
|
<div class="settings-container">
|
||||||
<div class="section-header section-header--purple">
|
<!-- Left: Settings Sections -->
|
||||||
<div class="section-header__icon">
|
<div class="settings-sections space-y-5">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
<!-- ========== 基本设置 ========== -->
|
||||||
</div>
|
<section v-if="activeTab === 'basic'" class="settings-section">
|
||||||
<div class="section-header__text">
|
<div class="section-header section-header--blue">
|
||||||
<h3>AI角色设置</h3>
|
<div class="section-header__icon">
|
||||||
<p>定义AI虚拟评论者的身份和风格</p>
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="section-header__text">
|
||||||
<div class="section-body">
|
<h3>基本设置</h3>
|
||||||
<!-- Persona List -->
|
<p>控制AI回复的基本行为</p>
|
||||||
<VLoading v-if="personasLoading" />
|
|
||||||
<div v-else-if="personas.length === 0" class="persona-empty">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
||||||
<span>请添加至少一个AI角色</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="persona-list">
|
|
||||||
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
|
|
||||||
<div class="persona-card__avatar">
|
|
||||||
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
|
||||||
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="persona-card__info">
|
|
||||||
<div class="persona-card__name">
|
|
||||||
{{ p.spec.displayName || '未命名' }}
|
|
||||||
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
|
||||||
</div>
|
|
||||||
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="persona-card__actions">
|
|
||||||
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
||||||
</button>
|
|
||||||
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VButton type="secondary" @click="openPersonaDialog(null)">
|
<div class="section-body">
|
||||||
<svg style="width:16px;height:16px;margin-right:4px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
<!-- Switch Row: 自动回复 -->
|
||||||
添加角色
|
<div class="form-row">
|
||||||
</VButton>
|
<div class="form-row__label">
|
||||||
</div>
|
<span class="form-label">自动回复</span>
|
||||||
</section>
|
<span class="form-hint">启用后,AI将自动回复新评论</span>
|
||||||
|
</div>
|
||||||
<!-- ========== 模型设置 ========== -->
|
<label class="toggle">
|
||||||
<section class="settings-section">
|
<input type="checkbox" v-model="settings.basic.autoReply" />
|
||||||
<div class="section-header section-header--green">
|
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
||||||
<div class="section-header__icon">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="section-header__text">
|
|
||||||
<h3>模型设置</h3>
|
|
||||||
<p>配置AI Foundation提供的模型</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section-body">
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">AI模型名称</label>
|
|
||||||
<span class="form-hint">留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型</span>
|
|
||||||
<input type="text" v-model="settings.model.modelName" class="form-input" placeholder="留空使用默认模型" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ========== Prompt设置 ========== -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="section-header section-header--amber">
|
|
||||||
<div class="section-header__icon">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="section-header__text">
|
|
||||||
<h3>Prompt设置</h3>
|
|
||||||
<p>自定义AI回复的提示词模板</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section-body">
|
|
||||||
<!-- Preset Selection -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Prompt预设</label>
|
|
||||||
<span class="form-hint">选择预设风格,可多选</span>
|
|
||||||
<div class="preset-grid">
|
|
||||||
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
|
|
||||||
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
|
|
||||||
<div class="preset-item__content">
|
|
||||||
<span class="preset-item__label">{{ p.label }}</span>
|
|
||||||
<span class="preset-item__desc">{{ p.desc }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Switch Row: 自动发布 -->
|
||||||
<div class="form-field">
|
<div class="form-row">
|
||||||
<div class="form-field__header">
|
<div class="form-row__label">
|
||||||
<label class="form-label">自定义Prompt模板</label>
|
<span class="form-label">自动发布</span>
|
||||||
<span class="form-hint--inline">留空使用默认模板</span>
|
<span class="form-hint">关闭后AI回复将存为草稿,需手动审核发布</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" v-model="settings.basic.autoPublish" />
|
||||||
|
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="var-tags">
|
<!-- Slider: 最大对话轮次 -->
|
||||||
<span v-for="v in promptVariables" :key="v.name" class="var-tag">
|
<div class="form-field">
|
||||||
<code>{{ v.name }}</code> {{ v.desc }}
|
<div class="form-field__header">
|
||||||
</span>
|
<span class="form-label">最大对话轮次</span>
|
||||||
</div>
|
<span class="form-badge">{{ settings.basic.maxConversationRounds }}</span>
|
||||||
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
|
</div>
|
||||||
</div>
|
<span class="form-hint">同一评论线程中AI最多自动回复的轮次</span>
|
||||||
</div>
|
<div class="slider">
|
||||||
</section>
|
<input type="range" v-model.number="settings.basic.maxConversationRounds" min="1" max="100" class="slider__input" />
|
||||||
|
<div class="slider__marks">
|
||||||
<!-- ========== 数据清理 ========== -->
|
<span>1</span><span>50</span><span>100</span>
|
||||||
<section class="settings-section">
|
</div>
|
||||||
<div class="section-header section-header--red">
|
|
||||||
<div class="section-header__icon">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="section-header__text">
|
|
||||||
<h3>数据清理</h3>
|
|
||||||
<p>自动清理过期的AI回复记录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section-body">
|
|
||||||
<!-- Switch: 启用自动清理 -->
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-row__label">
|
|
||||||
<span class="form-label">启用自动清理</span>
|
|
||||||
<span class="form-hint">每天自动清理超过保留天数的记录</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" />
|
|
||||||
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<!-- Slider: 保留天数 -->
|
|
||||||
<div class="form-field">
|
|
||||||
<div class="form-field__header">
|
|
||||||
<span class="form-label">保留天数</span>
|
|
||||||
<span class="form-badge">{{ settings.cleanup.retentionDays }} 天</span>
|
|
||||||
</div>
|
|
||||||
<span class="form-hint">超过此天数的AI回复记录将被自动清理</span>
|
|
||||||
<div class="slider">
|
|
||||||
<input type="range" v-model.number="settings.cleanup.retentionDays" min="1" max="365" class="slider__input" />
|
|
||||||
<div class="slider__marks">
|
|
||||||
<span>1天</span><span>180天</span><span>365天</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Input: 速率限制 -->
|
||||||
<!-- Manual Cleanup -->
|
<div class="form-field">
|
||||||
<div class="form-row form-row--bordered">
|
<label class="form-label">速率限制</label>
|
||||||
<div class="form-row__label">
|
<span class="form-hint">每分钟最大AI回复数量</span>
|
||||||
<span class="form-label">手动清理</span>
|
<input type="number" v-model.number="settings.basic.rateLimitPerMinute" min="1" max="100" class="form-input" placeholder="10" />
|
||||||
<span class="form-hint">立即执行一次清理操作</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row__action">
|
<!-- Slider: 最大重试次数 -->
|
||||||
<VButton size="sm" type="secondary" @click="performCleanup" :disabled="cleanupLoading">
|
<div class="form-field">
|
||||||
{{ cleanupLoading ? '清理中...' : '立即清理' }}
|
<div class="form-field__header">
|
||||||
|
<span class="form-label">最大重试次数</span>
|
||||||
|
<span class="form-badge">{{ settings.basic.maxRetryCount }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">AI生成失败时的最大重试次数,采用指数退避策略</span>
|
||||||
|
<div class="slider">
|
||||||
|
<input type="range" v-model.number="settings.basic.maxRetryCount" min="1" max="10" class="slider__input" />
|
||||||
|
<div class="slider__marks">
|
||||||
|
<span>1</span><span>5</span><span>10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Textarea: 评论者黑名单 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<div class="form-field__header">
|
||||||
|
<span class="form-label">评论者黑名单</span>
|
||||||
|
<button class="btn-link" @click="openCommenterDialog">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
||||||
|
添加评论者
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">支持名称、邮箱和正则表达式。正则以 regex: 开头,如 regex:^spam.*</span>
|
||||||
|
<textarea v-model="settings.basic.blockedCommenters" rows="2" class="form-textarea" placeholder="例如:张三, spam@example.com, 李四"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== AI角色设置 ========== -->
|
||||||
|
<section v-if="activeTab === 'persona'" class="settings-section">
|
||||||
|
<div class="section-header section-header--purple">
|
||||||
|
<div class="section-header__icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="section-header__text">
|
||||||
|
<h3>AI角色设置</h3>
|
||||||
|
<p>定义AI虚拟评论者的身份和风格</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<!-- Persona List -->
|
||||||
|
<VLoading v-if="personasLoading" />
|
||||||
|
<div v-else-if="personas.length === 0" class="persona-empty">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
<span>请添加至少一个AI角色</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="persona-list">
|
||||||
|
<div v-for="p in personas" :key="p.metadata.name" class="persona-card">
|
||||||
|
<div class="persona-card__avatar">
|
||||||
|
<img v-if="getPersonaAvatar(p)" :src="getPersonaAvatar(p)" alt="头像" />
|
||||||
|
<span v-else class="persona-card__avatar-fallback">{{ (p.spec.displayName || '?').charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="persona-card__info">
|
||||||
|
<div class="persona-card__name">
|
||||||
|
{{ p.spec.displayName || '未命名' }}
|
||||||
|
<span v-if="p.spec.isDefault" class="persona-card__badge">默认</span>
|
||||||
|
</div>
|
||||||
|
<div class="persona-card__prompt">{{ p.spec.prompt || '暂无提示词' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="persona-card__actions">
|
||||||
|
<button class="btn-icon" title="编辑" @click="openPersonaDialog(p)">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button v-if="!p.spec?.isDefault" class="btn-icon btn-icon--danger" title="删除" @click="deletePersona(p)">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<button v-if="!p.spec?.isDefault" class="btn-icon" title="设为默认" @click="setDefaultPersona(p)">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VButton type="secondary" @click="openPersonaDialog(null)">
|
||||||
|
<svg style="width:16px;height:16px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
|
添加角色
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 模型设置 ========== -->
|
||||||
|
<section v-if="activeTab === 'model'" class="settings-section">
|
||||||
|
<div class="section-header section-header--green">
|
||||||
|
<div class="section-header__icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="section-header__text">
|
||||||
|
<h3>模型设置</h3>
|
||||||
|
<p>配置AI Foundation提供的模型</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">AI模型名称</label>
|
||||||
|
<span class="form-hint">留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型</span>
|
||||||
|
<input type="text" v-model="settings.model.modelName" class="form-input" placeholder="留空使用默认模型" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== Prompt设置 ========== -->
|
||||||
|
<section v-if="activeTab === 'prompt'" class="settings-section">
|
||||||
|
<div class="section-header section-header--amber">
|
||||||
|
<div class="section-header__icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="section-header__text">
|
||||||
|
<h3>Prompt设置</h3>
|
||||||
|
<p>自定义AI回复的提示词模板</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<!-- Preset Selection -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">Prompt预设</label>
|
||||||
|
<span class="form-hint">选择预设风格,可多选</span>
|
||||||
|
<div class="preset-grid">
|
||||||
|
<label v-for="p in promptPresets" :key="p.key" class="preset-item" :class="{ 'preset-item--active': isPresetEnabled(p.key) }">
|
||||||
|
<input type="checkbox" :checked="isPresetEnabled(p.key)" @change="togglePreset(p.key)" class="preset-checkbox" />
|
||||||
|
<div class="preset-item__content">
|
||||||
|
<span class="preset-item__label">{{ p.label }}</span>
|
||||||
|
<span class="preset-item__desc">{{ p.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<div class="form-field__header">
|
||||||
|
<label class="form-label">自定义Prompt模板</label>
|
||||||
|
<span class="form-hint--inline">留空使用默认模板</span>
|
||||||
|
</div>
|
||||||
|
<div class="var-tags">
|
||||||
|
<span v-for="v in promptVariables" :key="v.name" class="var-tag">
|
||||||
|
<code>{{ v.name }}</code> {{ v.desc }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea v-model="settings.prompt.customPromptTemplate" rows="10" class="form-textarea form-textarea--mono" placeholder="自定义Prompt模板"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 数据清理 ========== -->
|
||||||
|
<section v-if="activeTab === 'cleanup'" class="settings-section">
|
||||||
|
<div class="section-header section-header--red">
|
||||||
|
<div class="section-header__icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="section-header__text">
|
||||||
|
<h3>数据清理</h3>
|
||||||
|
<p>自动清理过期的AI回复记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<!-- Switch: 启用自动清理 -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-row__label">
|
||||||
|
<span class="form-label">启用自动清理</span>
|
||||||
|
<span class="form-hint">每天自动清理超过保留天数的记录</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" />
|
||||||
|
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Slider: 保留天数 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<div class="form-field__header">
|
||||||
|
<span class="form-label">保留天数</span>
|
||||||
|
<span class="form-badge">{{ settings.cleanup.retentionDays }} 天</span>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">超过此天数的AI回复记录将被自动清理</span>
|
||||||
|
<div class="slider">
|
||||||
|
<input type="range" v-model.number="settings.cleanup.retentionDays" min="1" max="365" class="slider__input" />
|
||||||
|
<div class="slider__marks">
|
||||||
|
<span>1天</span><span>180天</span><span>365天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Manual Cleanup -->
|
||||||
|
<div class="form-row form-row--bordered">
|
||||||
|
<div class="form-row__label">
|
||||||
|
<span class="form-label">手动清理</span>
|
||||||
|
<span class="form-hint">立即执行一次清理操作</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row__action">
|
||||||
|
<VButton size="sm" type="secondary" @click="performCleanup" :disabled="cleanupLoading">
|
||||||
|
{{ cleanupLoading ? '清理中...' : '立即清理' }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="cleanupResult !== null" class="cleanup-result">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
清理完成,共删除 {{ cleanupResult }} 条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Sticky Save Bar -->
|
||||||
|
<div class="settings-sidebar">
|
||||||
|
<div class="settings-sidebar__inner">
|
||||||
|
<div class="sidebar-card sidebar-card--primary">
|
||||||
|
<div class="sidebar-card__header">
|
||||||
|
<h4 class="sidebar-card__title">操作</h4>
|
||||||
|
<span v-if="hasUnsavedChanges" class="sidebar-card__badge">未保存</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-card__actions">
|
||||||
|
<VButton block type="primary" @click="saveSettings" :disabled="saving">
|
||||||
|
<template v-if="!saving">
|
||||||
|
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</template>
|
||||||
|
{{ saving ? '保存中...' : '保存设置' }}
|
||||||
|
</VButton>
|
||||||
|
<VButton block @click="fetchSettings" :disabled="saving">
|
||||||
|
<svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
重置为当前值
|
||||||
</VButton>
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="cleanupResult !== null" class="cleanup-result">
|
<div v-if="activeTab === 'prompt'" class="sidebar-card">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
<h4 class="sidebar-card__title">可用模板变量</h4>
|
||||||
清理完成,共删除 {{ cleanupResult }} 条记录
|
<div class="sidebar-card__vars">
|
||||||
</div>
|
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
|
||||||
</div>
|
<code>{{ v.name }}</code>
|
||||||
</section>
|
<span>{{ v.desc }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Sticky Save Bar -->
|
|
||||||
<div class="settings-sidebar">
|
|
||||||
<div class="settings-sidebar__inner">
|
|
||||||
<div class="sidebar-card">
|
|
||||||
<h4 class="sidebar-card__title">操作</h4>
|
|
||||||
<div class="sidebar-card__actions">
|
|
||||||
<VButton block type="primary" @click="saveSettings" :disabled="saving">
|
|
||||||
{{ saving ? '保存中...' : '保存设置' }}
|
|
||||||
</VButton>
|
|
||||||
<VButton block @click="fetchSettings">重置为当前值</VButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-card">
|
|
||||||
<h4 class="sidebar-card__title">可用模板变量</h4>
|
|
||||||
<div class="sidebar-card__vars">
|
|
||||||
<div v-for="v in promptVariables" :key="v.name" class="sidebar-var">
|
|
||||||
<code>{{ v.name }}</code>
|
|
||||||
<span>{{ v.desc }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,8 +447,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, watch } from "vue"
|
import { ref, reactive, computed, onMounted, watch } from "vue"
|
||||||
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
|
import { axiosInstance, coreApiClient } from "@halo-dev/api-client"
|
||||||
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace } from "@halo-dev/components"
|
import { VPageHeader, VButton, VLoading, Toast, VModal, VSpace, IconPlug } from "@halo-dev/components"
|
||||||
import { IconPlug } from "@halo-dev/components"
|
|
||||||
|
const activeTab = ref("basic")
|
||||||
|
const tabItems = [
|
||||||
|
{ label: "基本设置", value: "basic" },
|
||||||
|
{ label: "AI角色", value: "persona" },
|
||||||
|
{ label: "模型设置", value: "model" },
|
||||||
|
{ label: "Prompt", value: "prompt" },
|
||||||
|
{ label: "数据清理", value: "cleanup" },
|
||||||
|
]
|
||||||
|
|
||||||
const promptVariables = [
|
const promptVariables = [
|
||||||
{ name: '{{persona_prompt}}', desc: '人格提示词' },
|
{ name: '{{persona_prompt}}', desc: '人格提示词' },
|
||||||
@@ -489,6 +530,12 @@ const settings = reactive({
|
|||||||
cleanup: { cleanupEnabled: true, retentionDays: 30 },
|
cleanup: { cleanupEnabled: true, retentionDays: 30 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track unsaved changes: snapshot of settings after last fetch/save
|
||||||
|
const lastSavedSnapshot = ref("")
|
||||||
|
const hasUnsavedChanges = computed(() => {
|
||||||
|
return JSON.stringify(settings) !== lastSavedSnapshot.value
|
||||||
|
})
|
||||||
|
|
||||||
const configMapName = "comment-ai-autopilot-configmap"
|
const configMapName = "comment-ai-autopilot-configmap"
|
||||||
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
|
const apiBase = "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1"
|
||||||
|
|
||||||
@@ -817,7 +864,11 @@ const fetchSettings = async () => {
|
|||||||
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
||||||
}
|
}
|
||||||
} catch (e) { console.error("Failed to fetch settings", e) }
|
} catch (e) { console.error("Failed to fetch settings", e) }
|
||||||
finally { loading.value = false }
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
// Update snapshot after fetch to reset unsaved indicator
|
||||||
|
lastSavedSnapshot.value = JSON.stringify(settings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
@@ -834,6 +885,8 @@ const saveSettings = async () => {
|
|||||||
}
|
}
|
||||||
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
|
await coreApiClient.configMap.updateConfigMap({ name: configMapName, configMap: updated })
|
||||||
Toast.success("设置已保存")
|
Toast.success("设置已保存")
|
||||||
|
// Update snapshot after save to reset unsaved indicator
|
||||||
|
lastSavedSnapshot.value = JSON.stringify(settings)
|
||||||
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
} catch (e) { console.error("Failed to save settings", e); Toast.error("保存设置失败") }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
@@ -847,6 +900,43 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ===== Layout ===== */
|
/* ===== Layout ===== */
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.settings-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.settings-tab:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.settings-tab--active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.settings-tab--active:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-container {
|
.settings-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 280px;
|
grid-template-columns: 1fr 280px;
|
||||||
@@ -1221,14 +1311,39 @@ onMounted(async () => {
|
|||||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
.sidebar-card--primary {
|
||||||
|
border-color: #dbeafe;
|
||||||
|
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
|
||||||
|
}
|
||||||
|
.sidebar-card__header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 14px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.sidebar-card__badge {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 2px 8px; font-size: 11px; font-weight: 500;
|
||||||
|
background: #fef3c7; color: #d97706; border-radius: 4px;
|
||||||
|
}
|
||||||
.sidebar-card__title {
|
.sidebar-card__title {
|
||||||
font-size: 14px; font-weight: 600; color: #1f2937;
|
font-size: 14px; font-weight: 600; color: #1f2937;
|
||||||
margin: 0 0 14px; padding-bottom: 10px;
|
margin: 0 0 14px; padding-bottom: 10px;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
|
.sidebar-card--primary .sidebar-card__title {
|
||||||
|
margin-bottom: 0; padding-bottom: 0; border-bottom: none;
|
||||||
|
}
|
||||||
.sidebar-card__actions {
|
.sidebar-card__actions {
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
}
|
}
|
||||||
|
/* Align icon + text inside VButton (btn-content wraps the default slot) */
|
||||||
|
.sidebar-card__actions :deep(.btn-content),
|
||||||
|
.section-body :deep(.btn-content) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
.sidebar-card__vars {
|
.sidebar-card__vars {
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user