diff --git a/README.md b/README.md index f1877eb..84ff14e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Agent 的设计目标是:让看板娘能帮访客更快找到内容、理解 ## 下载及使用说明 > [!TIP] -> 在 2.x 版本之后,插件依赖了 [Ai Foundation 插件](https://www.halo.run/store/apps/app-acslk9nu) ,因此安装此插件前请先安装 Ai Foundation 插件。 +> 在 2.x 版本之后,AI 聊天功能可选依赖 [Halo AI Foundation 插件](https://www.halo.run/store/apps/app-acslk9nu)。未安装或未启用 Halo AI Foundation 时,Live2D 仍可正常使用,但 AI 聊天入口不会在前台启用;如需 AI 聊天,请先安装并启用 Halo AI Foundation,再在插件设置中选择对话模型。 1. 前往 Halo 应用市场,安装 [Ai Foundation 插件](https://www.halo.run/store/apps/app-acslk9nu) 插件。 diff --git a/src/main/java/run/halo/live2d/Live2dSettingProcess.java b/src/main/java/run/halo/live2d/Live2dSettingProcess.java index 006f487..cdb2451 100644 --- a/src/main/java/run/halo/live2d/Live2dSettingProcess.java +++ b/src/main/java/run/halo/live2d/Live2dSettingProcess.java @@ -13,6 +13,7 @@ import run.halo.live2d.agent.AgentSettings; import run.halo.live2d.agent.AgentToolNormalizer; import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.live2d.ai.HaloAiFoundationAvailability; /** * Live2d 配置处理器 @@ -38,6 +39,7 @@ public class Live2dSettingProcess implements Live2dSetting { private final ReactiveSettingFetcher settingFetcher; private final AgentToolNormalizer agentToolNormalizer; + private final HaloAiFoundationAvailability haloAiFoundationAvailability; private final ObjectMapper objectMapper = new ObjectMapper(); @Override @@ -52,20 +54,23 @@ public Mono getValue(String groupName, String key) { @Override public Mono getPublicConfig(String themeTipsPath) { - return settingFetcher.getValues().map(data -> { - ObjectNode objectNode = JsonNodeFactory.instance.objectNode(); - copyFields(objectNode, data.get("base"), BASE_FIELDS); - copyFields(objectNode, data.get("api"), API_FIELDS); - copyFields(objectNode, data.get("tips"), TIPS_FIELDS); - copyFields(objectNode, data.get("advanced"), ADVANCED_FIELDS); - copyAiChatFields(objectNode, data.get("aichat")); - copyAgentFields(objectNode, data.get("agent")); - copyCustomTools(objectNode, data.get("customTools")); - if (themeTipsPath != null && !themeTipsPath.isBlank()) { - objectNode.put("themeTipsPath", themeTipsPath); - } - return objectNode; - }); + return Mono.zip(settingFetcher.getValues(), haloAiFoundationAvailability.isEnabled()) + .map(tuple -> { + var data = tuple.getT1(); + var aiFoundationEnabled = tuple.getT2(); + ObjectNode objectNode = JsonNodeFactory.instance.objectNode(); + copyFields(objectNode, data.get("base"), BASE_FIELDS); + copyFields(objectNode, data.get("api"), API_FIELDS); + copyFields(objectNode, data.get("tips"), TIPS_FIELDS); + copyFields(objectNode, data.get("advanced"), ADVANCED_FIELDS); + copyAiChatFields(objectNode, data.get("aichat"), aiFoundationEnabled); + copyAgentFields(objectNode, data.get("agent")); + copyCustomTools(objectNode, data.get("customTools")); + if (themeTipsPath != null && !themeTipsPath.isBlank()) { + objectNode.put("themeTipsPath", themeTipsPath); + } + return objectNode; + }); } private void copyFields(ObjectNode target, JsonNode source, List fields) { @@ -80,14 +85,17 @@ private void copyFields(ObjectNode target, JsonNode source, List fields) }); } - private void copyAiChatFields(ObjectNode target, JsonNode source) { + private void copyAiChatFields(ObjectNode target, JsonNode source, boolean aiFoundationEnabled) { + if (!aiFoundationEnabled) { + target.put("isAiChat", false); + } if (source == null || source.isNull()) { return; } var isAiChat = source.get("isAiChat"); - if (isAiChat != null && !isAiChat.isNull()) { - target.set("isAiChat", isAiChat); + if (aiFoundationEnabled && isAiChat != null && !isAiChat.isNull()) { + target.put("isAiChat", isAiChat.asBoolean(false)); } var aiChatBaseSetting = source.get("aiChatBaseSetting"); diff --git a/src/main/java/run/halo/live2d/agent/AgentToolService.java b/src/main/java/run/halo/live2d/agent/AgentToolService.java index 0a5182b..a84b133 100644 --- a/src/main/java/run/halo/live2d/agent/AgentToolService.java +++ b/src/main/java/run/halo/live2d/agent/AgentToolService.java @@ -8,8 +8,10 @@ import org.springframework.stereotype.Component; import run.halo.aifoundation.schema.JsonSchema; import run.halo.aifoundation.tool.ToolDefinition; +import run.halo.live2d.ai.ConditionalOnHaloAiFoundation; @Component +@ConditionalOnHaloAiFoundation @RequiredArgsConstructor public class AgentToolService { private final AgentToolNormalizer normalizer; diff --git a/src/main/java/run/halo/live2d/ai/ConditionalOnHaloAiFoundation.java b/src/main/java/run/halo/live2d/ai/ConditionalOnHaloAiFoundation.java new file mode 100644 index 0000000..65a22cf --- /dev/null +++ b/src/main/java/run/halo/live2d/ai/ConditionalOnHaloAiFoundation.java @@ -0,0 +1,13 @@ +package run.halo.live2d.ai; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Conditional; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Conditional(HaloAiFoundationAvailableCondition.class) +public @interface ConditionalOnHaloAiFoundation { +} diff --git a/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailability.java b/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailability.java new file mode 100644 index 0000000..649ff55 --- /dev/null +++ b/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailability.java @@ -0,0 +1,39 @@ +package run.halo.live2d.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Plugin; +import run.halo.app.extension.ReactiveExtensionClient; + +@Component +@RequiredArgsConstructor +public class HaloAiFoundationAvailability { + + private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation"; + private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService"; + + private final ReactiveExtensionClient client; + + public Mono isEnabled() { + return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME) + .map(this::isStarted) + .defaultIfEmpty(false) + .onErrorReturn(false); + } + + public static boolean isClassPresent(ClassLoader classLoader) { + var targetClassLoader = classLoader == null + ? HaloAiFoundationAvailability.class.getClassLoader() + : classLoader; + return ClassUtils.isPresent(AI_MODEL_SERVICE_CLASS, targetClassLoader); + } + + private boolean isStarted(Plugin plugin) { + return plugin.getSpec() != null + && Boolean.TRUE.equals(plugin.getSpec().getEnabled()) + && plugin.getStatus() != null + && Plugin.Phase.STARTED.equals(plugin.getStatus().getPhase()); + } +} diff --git a/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailableCondition.java b/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailableCondition.java new file mode 100644 index 0000000..feae500 --- /dev/null +++ b/src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailableCondition.java @@ -0,0 +1,13 @@ +package run.halo.live2d.ai; + +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +class HaloAiFoundationAvailableCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return HaloAiFoundationAvailability.isClassPresent(context.getClassLoader()); + } +} diff --git a/src/main/java/run/halo/live2d/chat/AIChatServiceImpl.java b/src/main/java/run/halo/live2d/chat/AIChatServiceImpl.java index ca7f1db..9eb7f5e 100644 --- a/src/main/java/run/halo/live2d/chat/AIChatServiceImpl.java +++ b/src/main/java/run/halo/live2d/chat/AIChatServiceImpl.java @@ -20,13 +20,17 @@ import run.halo.aifoundation.tool.ToolChoice; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.live2d.agent.AgentToolSet; +import run.halo.live2d.ai.ConditionalOnHaloAiFoundation; +import run.halo.live2d.ai.HaloAiFoundationAvailability; @Slf4j @Component +@ConditionalOnHaloAiFoundation @RequiredArgsConstructor public class AIChatServiceImpl implements AiChatService { private final ExtensionGetter extensionGetter; + private final HaloAiFoundationAvailability haloAiFoundationAvailability; private Mono aiModelService() { return extensionGetter.getEnabledExtension(AiModelService.class); @@ -35,27 +39,33 @@ private Mono aiModelService() { @Override public Mono streamChatCompletion(String modelName, String systemMessage, UIMessageChatRequest chatRequest, AgentToolSet agentToolSet) { - if (StringUtils.isBlank(modelName)) { - return Mono.just(errorResponse("请先在插件设置中配置 Halo AI 模型")); - } + return haloAiFoundationAvailability.isEnabled() + .flatMap(enabled -> { + if (!enabled) { + return Mono.just(errorResponse("Halo AI Foundation 插件未安装或未启用,请联系站长")); + } + if (StringUtils.isBlank(modelName)) { + return Mono.just(errorResponse("请先在插件设置中配置 Halo AI 模型")); + } - return aiModelService() - .flatMap(service -> service.languageModel(modelName)) - .map(model -> { - var chat = UIMessageChatHandlers.streamText(options -> options - .model(model) - .chatRequest(chatRequest) - .request(builder -> builder - .system(systemMessage) - .tools(agentToolSet == null ? null : agentToolSet.tools()) - .toolChoice(agentToolSet == null || agentToolSet.tools().isEmpty() - ? ToolChoice.none() - : ToolChoice.auto()) - .stopWhen(agentToolSet == null || agentToolSet.tools().isEmpty() - ? null - : StopCondition.stepCountIs(3))) - .onError(this::resolveErrorMessage)); - return chat.response(); + return aiModelService() + .flatMap(service -> service.languageModel(modelName)) + .map(model -> { + var chat = UIMessageChatHandlers.streamText(options -> options + .model(model) + .chatRequest(chatRequest) + .request(builder -> builder + .system(systemMessage) + .tools(agentToolSet == null ? null : agentToolSet.tools()) + .toolChoice(agentToolSet == null || agentToolSet.tools().isEmpty() + ? ToolChoice.none() + : ToolChoice.auto()) + .stopWhen(agentToolSet == null || agentToolSet.tools().isEmpty() + ? null + : StopCondition.stepCountIs(3))) + .onError(this::resolveErrorMessage)); + return chat.response(); + }); }) .onErrorResume(throwable -> { logChatError(modelName, throwable); diff --git a/src/main/java/run/halo/live2d/chat/AiChatEndpoint.java b/src/main/java/run/halo/live2d/chat/AiChatEndpoint.java index b731f8a..340c855 100644 --- a/src/main/java/run/halo/live2d/chat/AiChatEndpoint.java +++ b/src/main/java/run/halo/live2d/chat/AiChatEndpoint.java @@ -35,9 +35,11 @@ import run.halo.live2d.agent.AgentAccessMode; import run.halo.live2d.agent.AgentSettings; import run.halo.live2d.agent.AgentToolService; +import run.halo.live2d.ai.ConditionalOnHaloAiFoundation; @Slf4j @Component +@ConditionalOnHaloAiFoundation @AllArgsConstructor public class AiChatEndpoint implements CustomEndpoint { diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 7a929e5..9baec93 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -167,7 +167,24 @@ spec: formSchema: - $formkit: switch label: 启用 AI 聊天功能 - help: 启用后,Live2d 将可以进行 AI 智能对话 + help: 启用后,Live2d 将可以进行 AI 智能对话。此功能需要先安装并启用 Halo AI Foundation(推荐应用),否则前台不会显示 AI 聊天入口。 + __raw__sectionsSchema: + help: + children: + - $el: span + children: "启用后,Live2d 将可以进行 AI 智能对话。此功能需要先安装并启用 " + - $el: a + children: "Halo AI Foundation" + attrs: + href: "https://www.halo.run/store/apps/app-acslk9nu" + class: "block inline" + target: "_blank" + rel: "noopener noreferrer" + style: + color: "#1890ff" + whiteSpace: "pre" + - $el: span + children: "(推荐应用),否则前台不会显示 AI 聊天入口。" name: isAiChat id: isAiChat key: isAiChat @@ -230,6 +247,24 @@ spec: - $formkit: aiModelSelector name: modelName label: 对话模型 + help: 如果这里无法选择模型,请先安装并启用 Halo AI Foundation,然后在其中配置可用的语言模型。 + __raw__sectionsSchema: + help: + children: + - $el: span + children: "如果这里无法选择模型,请先安装并启用 " + - $el: a + children: "Halo AI Foundation" + attrs: + href: "https://www.halo.run/store/apps/app-acslk9nu" + class: "block inline" + target: "_blank" + rel: "noopener noreferrer" + style: + color: "#1890ff" + whiteSpace: "pre" + - $el: span + children: ",然后在其中配置可用的语言模型。" modelType: language available: true validation: required diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 5c074a8..4a04d4d 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -4,11 +4,12 @@ metadata: name: PluginLive2d annotations: "store.halo.run/app-id": "app-oPNFQ" + "store.halo.run/recommended-apps": '["app-acslk9nu"]' spec: enabled: true requires: ">=2.25.0" pluginDependencies: - ai-foundation: "*" + ai-foundation?: "*" author: name: LIlGG website: https://lixingyong.com diff --git a/src/test/java/run/halo/live2d/ai/HaloAiFoundationAvailabilityTest.java b/src/test/java/run/halo/live2d/ai/HaloAiFoundationAvailabilityTest.java new file mode 100644 index 0000000..0851c1c --- /dev/null +++ b/src/test/java/run/halo/live2d/ai/HaloAiFoundationAvailabilityTest.java @@ -0,0 +1,68 @@ +package run.halo.live2d.ai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Plugin; +import run.halo.app.extension.ReactiveExtensionClient; + +class HaloAiFoundationAvailabilityTest { + + @Test + void returnsFalseWhenAiFoundationClassIsNotVisible() { + var isolatedClassLoader = new ClassLoader(null) { + }; + + assertThat(HaloAiFoundationAvailability.isClassPresent(isolatedClassLoader)).isFalse(); + } + + @Test + void returnsTrueWhenAiFoundationClassIsVisible() { + assertThat(HaloAiFoundationAvailability.isClassPresent(getClass().getClassLoader())) + .isTrue(); + } + + @Test + void returnsTrueOnlyWhenPluginIsEnabledAndStarted() { + var client = mock(ReactiveExtensionClient.class); + var availability = new HaloAiFoundationAvailability(client); + var plugin = plugin(true, Plugin.Phase.STARTED); + when(client.fetch(Plugin.class, "ai-foundation")).thenReturn(Mono.just(plugin)); + + assertThat(availability.isEnabled().block()).isTrue(); + } + + @Test + void returnsFalseWhenPluginIsDisabled() { + var client = mock(ReactiveExtensionClient.class); + var availability = new HaloAiFoundationAvailability(client); + var plugin = plugin(false, Plugin.Phase.STARTED); + when(client.fetch(Plugin.class, "ai-foundation")).thenReturn(Mono.just(plugin)); + + assertThat(availability.isEnabled().block()).isFalse(); + } + + @Test + void returnsFalseWhenPluginIsNotStarted() { + var client = mock(ReactiveExtensionClient.class); + var availability = new HaloAiFoundationAvailability(client); + var plugin = plugin(true, Plugin.Phase.DISABLED); + when(client.fetch(Plugin.class, "ai-foundation")).thenReturn(Mono.just(plugin)); + + assertThat(availability.isEnabled().block()).isFalse(); + } + + private Plugin plugin(boolean enabled, Plugin.Phase phase) { + var plugin = new Plugin(); + var spec = new Plugin.PluginSpec(); + spec.setEnabled(enabled); + plugin.setSpec(spec); + var status = new Plugin.PluginStatus(); + status.setPhase(phase); + plugin.setStatus(status); + return plugin; + } +}