diff --git a/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..d054f706 --- /dev/null +++ b/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,18 @@ +# +# Copyright 2023-present the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +org.springframework.ai.model.google.genai.autoconfigure.chat.GoogleGenAiChatAutoConfiguration +org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiEmbeddingConnectionAutoConfiguration +org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiTextEmbeddingAutoConfiguration diff --git a/app/src/main/java/ai/javaclaw/JavaClawApplication.java b/app/src/main/java/ai/javaclaw/JavaClawApplication.java index e396ce6c..0f80245a 100644 --- a/app/src/main/java/ai/javaclaw/JavaClawApplication.java +++ b/app/src/main/java/ai/javaclaw/JavaClawApplication.java @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.DefaultApplicationArguments; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; @@ -44,12 +45,16 @@ public void run(ApplicationArguments args) throws Exception { @EventListener public void on(ConfigurationChangedEvent configurationChangedEvent) { - ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class); + Thread thread = new Thread(() -> { try { + ApplicationArguments args = new DefaultApplicationArguments(); + if(applicationContext != null){ + args = applicationContext.getBean(ApplicationArguments.class); + applicationContext.close(); + } Thread.sleep(2000); - applicationContext.close(); applicationContext = SpringApplication.run(JavaClawApplication.class, args.getSourceArgs()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/app/src/main/java/ai/javaclaw/api/AgentCreateController.java b/app/src/main/java/ai/javaclaw/api/AgentCreateController.java new file mode 100644 index 00000000..2c634628 --- /dev/null +++ b/app/src/main/java/ai/javaclaw/api/AgentCreateController.java @@ -0,0 +1,264 @@ +package ai.javaclaw.api; + +import ai.javaclaw.agent.AgentRegistry; +import ai.javaclaw.agent.AgentWorkspaceResolver; +import ai.javaclaw.agent.ConfiguredAgent; +import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import ai.javaclaw.onboarding.AgentOnboardingProviders; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Controller +public class AgentCreateController { + + private final AgentRegistry agentRegistry; + private final AgentOnboardingProviders agentOnboardingProviders; + private final ConfigurationManager configurationManager; + private final AgentWorkspaceResolver agentWorkspaceResolver; + + public AgentCreateController(AgentRegistry agentRegistry, + AgentOnboardingProviders agentOnboardingProviders, + ConfigurationManager configurationManager, + AgentWorkspaceResolver agentWorkspaceResolver) { + this.agentRegistry = agentRegistry; + this.agentOnboardingProviders = agentOnboardingProviders; + this.configurationManager = configurationManager; + this.agentWorkspaceResolver = agentWorkspaceResolver; + } + + @GetMapping("/agents/new") + public String newAgent(Model model, + @RequestParam(required = false) String provider, + @RequestParam(required = false) String agentId, + @RequestParam(required = false) String modelName, + @RequestParam(required = false) String apiKey, + @RequestParam(required = false) String baseUrl, + @RequestParam(required = false, defaultValue = "false") boolean setDefault, + @RequestParam(required = false) String error) { + model.addAttribute("providers", agentOnboardingProviders.getAll()); + model.addAttribute("selectedProvider", provider == null ? "" : provider.trim()); + model.addAttribute("agentId", agentId == null ? "" : agentId.trim()); + model.addAttribute("modelName", modelName == null ? "" : modelName.trim()); + model.addAttribute("apiKey", apiKey == null ? "" : apiKey.trim()); + model.addAttribute("baseUrl", baseUrl == null ? "" : baseUrl.trim()); + model.addAttribute("setDefault", setDefault); + model.addAttribute("error", error); + model.addAttribute("editing", false); + model.addAttribute("pageTitle", "Add Agent"); + model.addAttribute("pageSubtitle", "Create a new runtime agent without running the onboarding wizard."); + model.addAttribute("formAction", "/agents/new"); + model.addAttribute("submitLabel", "Create Agent"); + return "agents-new"; + } + + @GetMapping("/agents/{agentId}/edit") + public String editAgent(@PathVariable String agentId, + Model model, + @RequestParam(required = false) String error) { + ConfiguredAgent configuredAgent = agentRegistry.findAgent(agentId).orElse(null); + if (configuredAgent == null) { + return "redirect:/agents"; + } + + model.addAttribute("providers", agentOnboardingProviders.getAll()); + model.addAttribute("selectedProvider", configuredAgent.provider()); + model.addAttribute("agentId", configuredAgent.id()); + model.addAttribute("modelName", configuredAgent.model()); + model.addAttribute("apiKey", ""); + model.addAttribute("baseUrl", configuredAgent.baseUrl() == null ? "" : configuredAgent.baseUrl()); + model.addAttribute("setDefault", configuredAgent.id().equals(agentRegistry.getDefaultAgentId())); + model.addAttribute("error", error); + model.addAttribute("editing", true); + model.addAttribute("pageTitle", "Edit Agent"); + model.addAttribute("pageSubtitle", "Update an existing runtime agent configuration."); + model.addAttribute("formAction", "/agents/" + url(configuredAgent.id()) + "/edit"); + model.addAttribute("submitLabel", "Save Changes"); + return "agents-new"; + } + + @PostMapping("/agents/new") + public String createAgent(@RequestParam Map formParams, HttpServletRequest request) throws IOException { + String providerId = trim(formParams.get("provider")); + String agentId = trim(formParams.get("agentId")); + String modelName = trim(formParams.get("model")); + String apiKey = trim(formParams.get("apiKey")); + String baseUrl = trim(formParams.get("baseUrl")); + boolean setDefault = "on".equalsIgnoreCase(formParams.getOrDefault("setDefault", "")); + + if (providerId.isBlank()) { + return redirectBack("Choose one of the supported providers to continue.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + if (agentId.isBlank()) { + return redirectBack("Enter a unique agent id to continue.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + if (!agentId.matches("[a-zA-Z0-9][a-zA-Z0-9_-]*")) { + return redirectBack("Agent id may contain only letters, numbers, dashes, and underscores.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + + Optional providerOpt = agentOnboardingProviders.findById(providerId); + if (providerOpt.isEmpty()) { + return redirectBack("Selected provider is no longer available.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + AgentOnboardingProvider provider = providerOpt.get(); + + if (modelName.isBlank()) { + return redirectBack("Enter a model to continue.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + if (provider.requiresApiKey() && apiKey.isBlank()) { + return redirectBack("Enter an API key to continue.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + + if (agentIdAlreadyConfigured(agentId) || agentRegistry.findAgent(agentId).isPresent()) { + return redirectBack("Agent id is already configured. Choose a different id.", providerId, agentId, modelName, apiKey, baseUrl, setDefault); + } + + Map props = new LinkedHashMap<>(); + props.put(runtimeAgentKey(agentId, "enabled"), true); + props.put(runtimeAgentKey(agentId, "provider"), provider.getId()); + props.put(runtimeAgentKey(agentId, "model"), modelName); + props.put(runtimeAgentKey(agentId, "api-key"), apiKey); + + String workspacePath = agentWorkspaceResolver.defaultWorkspacePath(agentId).toString(); + props.put(runtimeAgentKey(agentId, "workspace"), workspacePath); + + provider.runtimeModelProperties().forEach((key, value) -> { + if ("base-url".equals(key) && !baseUrl.isBlank()) { + props.put(runtimeAgentKey(agentId, key), baseUrl); + } else { + props.put(runtimeAgentKey(agentId, key), value); + } + }); + if (!baseUrl.isBlank() && !provider.runtimeModelProperties().containsKey("base-url")) { + props.put(runtimeAgentKey(agentId, "base-url"), baseUrl); + } + + if (setDefault) { + props.put("agent.agents.default", agentId); + } + + configurationManager.updateProperties(props); + + agentWorkspaceResolver.initializeWorkspace(Path.of(workspacePath)); + + return "redirect:/agents"; + } + + @PostMapping("/agents/{agentId}/edit") + public String updateAgent(@PathVariable String agentId, @RequestParam Map formParams) throws IOException { + ConfiguredAgent configuredAgent = agentRegistry.findAgent(agentId).orElse(null); + if (configuredAgent == null) { + return "redirect:/agents"; + } + + String providerId = trim(formParams.get("provider")); + String modelName = trim(formParams.get("model")); + String apiKey = trim(formParams.get("apiKey")); + String baseUrl = trim(formParams.get("baseUrl")); + boolean setDefault = "on".equalsIgnoreCase(formParams.getOrDefault("setDefault", "")); + + if (providerId.isBlank()) { + return "redirect:/agents/" + url(agentId) + "/edit?error=" + url("Choose one of the supported providers to continue."); + } + + Optional providerOpt = agentOnboardingProviders.findById(providerId); + if (providerOpt.isEmpty()) { + return "redirect:/agents/" + url(agentId) + "/edit?error=" + url("Selected provider is no longer available."); + } + AgentOnboardingProvider provider = providerOpt.get(); + + if (modelName.isBlank()) { + return "redirect:/agents/" + url(agentId) + "/edit?error=" + url("Enter a model to continue."); + } + + String effectiveApiKey = apiKey.isBlank() ? (configuredAgent.apiKey() == null ? "" : configuredAgent.apiKey()) : apiKey; + if (provider.requiresApiKey() && effectiveApiKey.isBlank()) { + return "redirect:/agents/" + url(agentId) + "/edit?error=" + url("Enter an API key to continue."); + } + + Map props = new LinkedHashMap<>(); + props.put(runtimeAgentKey(agentId, "enabled"), true); + props.put(runtimeAgentKey(agentId, "provider"), provider.getId()); + props.put(runtimeAgentKey(agentId, "model"), modelName); + props.put(runtimeAgentKey(agentId, "api-key"), effectiveApiKey); + + String workspacePath = configuredAgent.workspacePath(); + if (workspacePath == null || workspacePath.isBlank()) { + workspacePath = agentWorkspaceResolver.defaultWorkspacePath(agentId).toString(); + } + props.put(runtimeAgentKey(agentId, "workspace"), workspacePath); + + String effectiveBaseUrl = baseUrl.isBlank() ? (configuredAgent.baseUrl() == null ? "" : configuredAgent.baseUrl()) : baseUrl; + if (!effectiveBaseUrl.isBlank()) { + props.put(runtimeAgentKey(agentId, "base-url"), effectiveBaseUrl); + } + + provider.runtimeModelProperties().forEach((key, value) -> { + if ("base-url".equals(key)) { + return; + } + props.put(runtimeAgentKey(agentId, key), value); + }); + + if (setDefault) { + props.put("agent.agents.default", agentId); + } + + configurationManager.updateProperties(props); + return "redirect:/agents"; + } + + private boolean agentIdAlreadyConfigured(String agentId) throws IOException { + Map config = configurationManager.readApplicationYaml(); + Object agentSection = config.get("agent"); + if (!(agentSection instanceof Map agentMap)) return false; + Object agentsSection = ((Map) agentMap).get("agents"); + if (!(agentsSection instanceof Map agentsMap)) return false; + Object itemsSection = ((Map) agentsMap).get("items"); + if (!(itemsSection instanceof Map itemsMap)) return false; + return itemsMap.containsKey(agentId); + } + + private static String runtimeAgentKey(String agentId, String suffix) { + return "agent.agents.items." + agentId + "." + suffix; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String redirectBack(String error, + String provider, + String agentId, + String modelName, + String apiKey, + String baseUrl, + boolean setDefault) { + return "redirect:/agents/new?error=" + url(error) + + "&provider=" + url(provider) + + "&agentId=" + url(agentId) + + "&modelName=" + url(modelName) + + "&apiKey=" + url(apiKey) + + "&baseUrl=" + url(baseUrl) + + "&setDefault=" + setDefault; + } + + private static String url(String value) { + try { + return java.net.URLEncoder.encode(value == null ? "" : value, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/ai/javaclaw/api/AgentsController.java b/app/src/main/java/ai/javaclaw/api/AgentsController.java new file mode 100644 index 00000000..a539e36f --- /dev/null +++ b/app/src/main/java/ai/javaclaw/api/AgentsController.java @@ -0,0 +1,54 @@ +package ai.javaclaw.api; + +import ai.javaclaw.agent.AgentRegistry; +import ai.javaclaw.agent.AgentWorkspaceResolver; +import ai.javaclaw.agent.ConfiguredAgent; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.nio.file.Path; +import java.util.List; + +@Controller +public class AgentsController { + + private final AgentRegistry agentRegistry; + private final AgentWorkspaceResolver agentWorkspaceResolver; + + public AgentsController(AgentRegistry agentRegistry, AgentWorkspaceResolver agentWorkspaceResolver) { + this.agentRegistry = agentRegistry; + this.agentWorkspaceResolver = agentWorkspaceResolver; + } + + @GetMapping("/agents") + public String agents(Model model) { + List agents = agentRegistry.getAgents().stream() + .map(configuredAgent -> new AgentView( + configuredAgent.id(), + configuredAgent.provider(), + configuredAgent.model(), + resolveWorkspace(configuredAgent), + configuredAgent.id().equals(agentRegistry.getDefaultAgentId()) + )) + .toList(); + + model.addAttribute("agents", agents); + model.addAttribute("hasAgents", !agents.isEmpty()); + return "agents"; + } + + private String resolveWorkspace(ConfiguredAgent configuredAgent) { + Path workspacePath = agentWorkspaceResolver.resolveWorkspacePath(configuredAgent.workspacePath(), configuredAgent.id()); + return workspacePath.toString(); + } + + public record AgentView( + String id, + String provider, + String model, + String workspace, + boolean isDefault + ) { + } +} diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index 2050c312..316b57c6 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -1,6 +1,9 @@ package ai.javaclaw.chat; import ai.javaclaw.agent.Agent; +import ai.javaclaw.agent.AgentConversationId; +import ai.javaclaw.agent.AgentRegistry; +import ai.javaclaw.agent.ConfiguredAgent; import ai.javaclaw.channels.Channel; import ai.javaclaw.channels.ChannelMessageReceivedEvent; import ai.javaclaw.channels.ChannelRegistry; @@ -36,13 +39,15 @@ public class ChatChannel implements Channel { private static final Logger log = LoggerFactory.getLogger(ChatChannel.class); private final Agent agent; + private final AgentRegistry agentRegistry; private final ChannelRegistry channelRegistry; private final ChatMemoryRepository chatMemoryRepository; private final ConcurrentLinkedQueue pendingMessages = new ConcurrentLinkedQueue<>(); private final AtomicReference wsSession = new AtomicReference<>(); - public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository) { + public ChatChannel(Agent agent, AgentRegistry agentRegistry, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository) { this.agent = agent; + this.agentRegistry = agentRegistry; this.channelRegistry = channelRegistry; this.chatMemoryRepository = chatMemoryRepository; channelRegistry.registerChannel(this); @@ -96,10 +101,24 @@ public void sendMessage(String message) { /** * Returns all known conversation IDs, always with "web" first. */ - public List conversationIds() { + public List agentIds() { + List ids = agentRegistry.getAgents().stream().map(ConfiguredAgent::id).toList(); + if (!ids.isEmpty()) { + return ids; + } + return List.of(agentRegistry.getDefaultAgentId()); + } + + public String defaultAgentId() { + return agentRegistry.getDefaultAgentId(); + } + + public List conversationIds(String agentId) { List result = new ArrayList<>(); result.add("web"); chatMemoryRepository.findConversationIds().stream() + .filter(id -> agentId.equals(AgentConversationId.agentId(id))) + .map(AgentConversationId::rawConversationId) .filter(id -> !id.equals("web")) .forEach(result::add); return result; @@ -109,8 +128,8 @@ public List conversationIds() { * Loads conversation history for the given conversationId as HTML bubbles. * Returns a single welcome bubble if no history exists yet. */ - public List loadHistoryAsHtml(String conversationId) { - List history = chatMemoryRepository.findByConversationId(conversationId); + public List loadHistoryAsHtml(String agentId, String conversationId) { + List history = chatMemoryRepository.findByConversationId(AgentConversationId.scoped(agentId, conversationId)); if (history.isEmpty()) { return List.of(ChatHtml.agentBubble("Hi! I'm your JavaClaw assistant. How can I help you today?")); } @@ -125,9 +144,9 @@ public List loadHistoryAsHtml(String conversationId) { /** * Handles a chat message from the web UI for the given conversationId. */ - public String chat(String conversationId, String message) { + public String chat(String agentId, String conversationId, String message) { channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message)); - return agent.respondTo(conversationId, message); + return agent.respondTo(agentId, conversationId, message); } private static String buildBackgroundMessageHtml(String text) { diff --git a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java index c6416c80..b4a21210 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatHtml.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatHtml.java @@ -34,11 +34,11 @@ public static String typingDots() { """; } - public static String chatInputArea(String conversationId) { + public static String chatInputArea(String agentId, String conversationId) { if ("web".equals(conversationId)) { return """
+ hx-vals='js:{"type": "userMessage", "agentId": document.getElementById("agent-select") ? document.getElementById("agent-select").value : "%s", "conversationId": document.getElementById("channel-select") ? document.getElementById("channel-select").value : "web"}'>
-
-
- -
-
-
+ {% autoescape false %}{{ inputAreaHtml }}{% endautoescape %} @@ -221,7 +30,6 @@ {% endblock %} {% block scripts %} - ") )); - List bubbles = chatChannel.loadHistoryAsHtml("web"); + List bubbles = chatChannel.loadHistoryAsHtml("openai-main", "web"); assertThat(bubbles.get(0)).doesNotContain("