feat: v1.0.0-beta.1 - AI Foundation reflection integration and draft mode fix
This commit is contained in:
+92
-78
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.endpoint;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
@@ -54,17 +53,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final AiReplyCleanupService cleanupService;
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) {
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.cleanupService = cleanupService;
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
@@ -134,8 +133,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
final Instant finalStartInstant = startInstant;
|
||||
final Instant finalEndInstant = endInstant;
|
||||
|
||||
// Check if we need in-memory filtering (keyword or date range)
|
||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|
||||
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
|
||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|
||||
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
|
||||
|
||||
if (needsMemoryFilter) {
|
||||
// Fall back to listAll + in-memory filter for complex queries
|
||||
@@ -184,30 +184,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
// Simple filters only - use server-side pagination
|
||||
// No filters - use server-side pagination directly
|
||||
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
||||
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
|
||||
var listOptions = ListOptions.builder().build();
|
||||
// Note: Halo's ListOptions fieldSelector support may be limited
|
||||
// For status and sentiment, we'll still filter in memory but with paginated data
|
||||
|
||||
return client.listBy(AiCommentReply.class, listOptions,
|
||||
PageRequestImpl.of(page - 1, size, sort))
|
||||
.map(listResult -> {
|
||||
var items = listResult.getItems();
|
||||
// Apply status/sentiment filter in memory on the current page
|
||||
var filtered = items.stream()
|
||||
.filter(r -> {
|
||||
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||
return true;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("items", filtered);
|
||||
result.put("items", listResult.getItems());
|
||||
result.put("total", listResult.getTotal());
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
@@ -438,31 +426,50 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.flatMap(record -> {
|
||||
String replyName = record.getSpec().getReplyName();
|
||||
if (replyName == null || replyName.isBlank()) {
|
||||
// Draft mode: no Reply extension exists, create one with approved=true
|
||||
return commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name);
|
||||
return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"));
|
||||
}))
|
||||
.flatMap(publishedReply -> {
|
||||
String newReplyName = publishedReply.getMetadata().getName();
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(newReplyName);
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
// Draft mode: no Reply extension exists yet.
|
||||
// First check if a Reply already exists (e.g. from a previous autoPublish=true run)
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(existingReply -> {
|
||||
// Reply already exists, just approve it
|
||||
existingReply.getSpec().setApproved(true);
|
||||
existingReply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(existingReply)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
})
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
// No existing Reply found, create a new approved one
|
||||
return commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.flatMap(publishedReply -> {
|
||||
String newReplyName = publishedReply.getMetadata().getName();
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(newReplyName);
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
})
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
}));
|
||||
} else {
|
||||
// Reply extension already exists, set approved=true
|
||||
return client.fetch(Reply.class, replyName)
|
||||
@@ -536,27 +543,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.flatMap(record -> {
|
||||
String replyName = record.getSpec().getReplyName();
|
||||
if (replyName == null || replyName.isBlank()) {
|
||||
// Draft mode: no Reply extension exists, create one with approved=true
|
||||
return commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("AI回复已存在,无法重复发布")))
|
||||
.flatMap(publishedReply -> {
|
||||
String newReplyName = publishedReply.getMetadata().getName();
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(newReplyName);
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
// Draft mode: check if Reply already exists first
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(existingReply -> {
|
||||
existingReply.getSpec().setApproved(true);
|
||||
existingReply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(existingReply)
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
});
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(existingReply.getMetadata().getName());
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException))
|
||||
));
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() ->
|
||||
commentReplyPublisher.publishReply(
|
||||
record.getSpec().getCommentId(),
|
||||
record.getSpec().getReply(),
|
||||
record.getSpec().getPostId(),
|
||||
record.getSpec().getReplyTo(),
|
||||
true,
|
||||
record.getSpec().getPersonaName()
|
||||
)
|
||||
.flatMap(publishedReply -> {
|
||||
String newReplyName = publishedReply.getMetadata().getName();
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReplyName(newReplyName);
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException));
|
||||
})
|
||||
));
|
||||
} else {
|
||||
// Reply extension already exists, set approved=true
|
||||
return client.fetch(Reply.class, replyName)
|
||||
@@ -860,21 +885,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> health(ServerRequest request) {
|
||||
AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable();
|
||||
boolean aiFoundationInstalled = aiClient != null;
|
||||
|
||||
if (!aiFoundationInstalled) {
|
||||
return ServerResponse.ok().bodyValue(
|
||||
new HealthResponse(false, false, false, "", "unhealthy"));
|
||||
}
|
||||
|
||||
// AI Foundation is installed, check if it's enabled and model is available
|
||||
return aiClient.chat("ping", null)
|
||||
.map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy"))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Health check: AI Foundation call failed: {}", e.getMessage());
|
||||
return Mono.just(new HealthResponse(true, true, false, "", "degraded"));
|
||||
})
|
||||
// AiFoundationClient is always created; availability is checked at runtime
|
||||
return aiFoundationClient.isAvailable()
|
||||
.map(available -> (HealthResponse) new HealthResponse(available, available, available, "", available ? "healthy" : "degraded"))
|
||||
.defaultIfEmpty(new HealthResponse(false, false, false, "", "unhealthy"))
|
||||
.flatMap(health -> ServerResponse.ok().bodyValue(health));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ public class AiCommentReply extends AbstractExtension {
|
||||
@Schema(description = "使用的AI角色名称")
|
||||
private String personaName;
|
||||
|
||||
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
|
||||
@Schema(description = "已发布的回复名称")
|
||||
private String replyName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class AiPersona extends AbstractExtension {
|
||||
@JsonProperty("isDefault")
|
||||
private Boolean isDefault;
|
||||
|
||||
@Schema(description = "排序优先级,数值越小越靠前")
|
||||
@Schema(description = "角色优先级,数值越小优先级越高")
|
||||
private Integer priority;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
String parentCommentName = reply.getSpec().getCommentName();
|
||||
if (parentCommentName == null || parentCommentName.isBlank()) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,6 +85,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
// No quoteReply - this is a direct reply to the top-level comment,
|
||||
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
|
||||
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,8 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
if (!isReplyToAi) {
|
||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.aifoundation.AiModelService;
|
||||
import run.halo.aifoundation.chat.LanguageModel;
|
||||
import run.halo.aifoundation.chat.GenerateTextResult;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
|
||||
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
|
||||
* AI Foundation client that uses runtime class loading and reflection
|
||||
* to call the AI Foundation plugin's AiModelService.
|
||||
* <p>
|
||||
* This approach avoids classloader identity issues by loading AiModelService
|
||||
* from ai-foundation's own classloader, so that Spring's getBeansOfType()
|
||||
* can correctly match the implementation bean.
|
||||
* <p>
|
||||
* No @ConditionalOnClass or pluginDependencies needed.
|
||||
* Always registered as a bean; availability is checked at runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class AiFoundationClient {
|
||||
|
||||
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
|
||||
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
|
||||
this.client = client;
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI Foundation to generate a chat response using the specified model.
|
||||
* Checks at runtime whether the ai-foundation plugin is installed and enabled
|
||||
* before attempting to use it.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||
@@ -44,8 +55,16 @@ public class AiFoundationClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the ai-foundation plugin is installed and enabled at runtime.
|
||||
* 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())
|
||||
@@ -57,24 +76,144 @@ public class AiFoundationClient {
|
||||
}
|
||||
|
||||
private Mono<String> doChat(String prompt, String modelName) {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> {
|
||||
Mono<LanguageModel> modelMono;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
modelMono = service.languageModel(modelName);
|
||||
} else {
|
||||
modelMono = service.languageModel();
|
||||
}
|
||||
return modelMono.flatMap(model -> model.generateText(prompt)
|
||||
.map(GenerateTextResult::getText)
|
||||
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
|
||||
text.length(), modelName != null ? modelName : "default"))
|
||||
);
|
||||
})
|
||||
return findAiModelService()
|
||||
.flatMap(service -> invokeLanguageModel(service, modelName)
|
||||
.flatMap(model -> invokeGenerateText(model, prompt))
|
||||
)
|
||||
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PluginManager via the pluginWrapper bean registered in our plugin context.
|
||||
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton:
|
||||
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
|
||||
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance.
|
||||
*/
|
||||
private Object findPluginManager() {
|
||||
try {
|
||||
Object pluginWrapper = applicationContext.getBean("pluginWrapper");
|
||||
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager");
|
||||
getPluginManagerMethod.setAccessible(true);
|
||||
Object pm = getPluginManagerMethod.invoke(pluginWrapper);
|
||||
if (pm != null) {
|
||||
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName());
|
||||
}
|
||||
return pm;
|
||||
} catch (NoSuchMethodException e) {
|
||||
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage());
|
||||
}
|
||||
log.warn("PluginManager not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
|
||||
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
|
||||
* to get the plugin's ApplicationContext.
|
||||
*/
|
||||
private Mono<Object> findAiModelService() {
|
||||
return Mono.fromCallable(() -> {
|
||||
Object pm = findPluginManager();
|
||||
if (pm == null) return null;
|
||||
|
||||
// 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.
|
||||
* Returns Mono<LanguageModel> from ai-foundation's classloader.
|
||||
*/
|
||||
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
|
||||
return Mono.fromCallable(() -> {
|
||||
Method method;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
method = service.getClass().getMethod("languageModel", String.class);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(service, modelName);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
|
||||
/**
|
||||
* Configuration that registers AiFoundationClient only when
|
||||
* AI Foundation plugin classes are available in the classloader.
|
||||
* When AI Foundation is not installed, this entire configuration is skipped.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
|
||||
public class AiFoundationConfiguration {
|
||||
|
||||
@Bean
|
||||
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
|
||||
ReactiveExtensionClient client) {
|
||||
return new AiFoundationClient(extensionGetter, client);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class AiReplyService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public AiReplyService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,12 +21,7 @@ public class AiReplyService {
|
||||
* @param modelName the model name (null for default)
|
||||
*/
|
||||
public Mono<String> generateReply(String prompt, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, cannot generate reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return client.chat(prompt, modelName)
|
||||
return aiFoundationClient.chat(prompt, modelName)
|
||||
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
|
||||
@@ -39,18 +39,6 @@ public class PromptBuilder {
|
||||
|
||||
private static final String PRESET_CONCISE = """
|
||||
【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
|
||||
""";
|
||||
|
||||
private static final String PRESET_TECHNICAL = """
|
||||
【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。
|
||||
""";
|
||||
|
||||
private static final String PRESET_ENCOURAGING = """
|
||||
【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。
|
||||
""";
|
||||
|
||||
private static final String PRESET_EDUCATIONAL = """
|
||||
【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。
|
||||
""";
|
||||
|
||||
private static final Map<String, String> PRESET_MAP = new LinkedHashMap<>();
|
||||
@@ -59,9 +47,6 @@ public class PromptBuilder {
|
||||
PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
|
||||
PRESET_MAP.put("humorous", PRESET_HUMOROUS);
|
||||
PRESET_MAP.put("concise", PRESET_CONCISE);
|
||||
PRESET_MAP.put("technical", PRESET_TECHNICAL);
|
||||
PRESET_MAP.put("encouraging", PRESET_ENCOURAGING);
|
||||
PRESET_MAP.put("educational", PRESET_EDUCATIONAL);
|
||||
}
|
||||
|
||||
private static final String SAFETY_PROMPT = """
|
||||
@@ -76,12 +61,7 @@ public class PromptBuilder {
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
|
||||
- 如果评论包含中文字符(汉字),请用中文回复
|
||||
- 如果评论包含日文假名(平假名/片假名),请用日文回复
|
||||
- 如果评论包含韩文字符,请用韩文回复
|
||||
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
|
||||
- 绝对不要用与评论不同的语言回复
|
||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class ReviewService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public ReviewService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
private static final String REVIEW_PROMPT_TEMPLATE = """
|
||||
@@ -37,18 +36,12 @@ public class ReviewService {
|
||||
|
||||
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
||||
String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
|
||||
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
|
||||
}
|
||||
|
||||
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
|
||||
truncate(articleContent, 2000),
|
||||
truncate(commentContent, 500),
|
||||
truncate(aiReply, 500));
|
||||
|
||||
return client.chat(reviewPrompt, modelName)
|
||||
return aiFoundationClient.chat(reviewPrompt, modelName)
|
||||
.map(this::parseSafetyResult)
|
||||
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
||||
.onErrorResume(e -> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -9,10 +8,10 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
public class SentimentService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
|
||||
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
public SentimentService(AiFoundationClient aiFoundationClient) {
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
}
|
||||
|
||||
public record SentimentResult(String sentiment, double confidence) {
|
||||
@@ -22,15 +21,9 @@ public class SentimentService {
|
||||
}
|
||||
|
||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
String prompt = buildSentimentPrompt(commentContent);
|
||||
|
||||
return client.chat(prompt, modelName)
|
||||
return aiFoundationClient.chat(prompt, modelName)
|
||||
.map(response -> {
|
||||
String sentiment = parseSentiment(response);
|
||||
return new SentimentResult(sentiment, 1.0);
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
name: comment-ai-autopilot
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.23.0"
|
||||
requires: ">=2.25.0"
|
||||
author:
|
||||
name: 暖心向阳335
|
||||
website: https://nxxy335.top
|
||||
@@ -22,6 +22,4 @@ spec:
|
||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.0-ygkszvd"
|
||||
pluginDependencies:
|
||||
ai-foundation: "*"
|
||||
version: "1.0.0-beta.1"
|
||||
|
||||
Reference in New Issue
Block a user