Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 插件。
Expand Down
42 changes: 25 additions & 17 deletions src/main/java/run/halo/live2d/Live2dSettingProcess.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 配置处理器
Expand All @@ -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
Expand All @@ -52,20 +54,23 @@ public Mono<JsonNode> getValue(String groupName, String key) {

@Override
public Mono<JsonNode> 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<String> fields) {
Expand All @@ -80,14 +85,17 @@ private void copyFields(ObjectNode target, JsonNode source, List<String> 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");
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/run/halo/live2d/agent/AgentToolService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
}
39 changes: 39 additions & 0 deletions src/main/java/run/halo/live2d/ai/HaloAiFoundationAvailability.java
Original file line number Diff line number Diff line change
@@ -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<Boolean> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
50 changes: 30 additions & 20 deletions src/main/java/run/halo/live2d/chat/AIChatServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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> aiModelService() {
return extensionGetter.getEnabledExtension(AiModelService.class);
Expand All @@ -35,27 +39,33 @@ private Mono<AiModelService> aiModelService() {
@Override
public Mono<UIMessageStreamResponse> streamChatCompletion(String modelName,
String systemMessage, UIMessageChatRequest<Void> 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.<Void>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.<Void>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);
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/run/halo/live2d/chat/AiChatEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
37 changes: 36 additions & 1 deletion src/main/resources/extensions/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main/resources/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}