diff --git a/camelBase/pom.xml b/camelBase/pom.xml index f08d6eb2..72b89160 100644 --- a/camelBase/pom.xml +++ b/camelBase/pom.xml @@ -271,7 +271,23 @@ org.apache.camel - camel-log + camel-langchain4j-agent + + + org.apache.camel + camel-ai-tool + + + org.apache.camel + camel-langchain4j-tools + + + org.apache.camel + camel-langchain4j-web-search + + + org.apache.camel + camel-mustache org.apache.camel diff --git a/commonBase/pom.xml b/commonBase/pom.xml index a0938ffe..686062ac 100644 --- a/commonBase/pom.xml +++ b/commonBase/pom.xml @@ -307,7 +307,11 @@ langchain4j-google-ai-gemini ${langchain4j.version} - + + dev.langchain4j + langchain4j-web-search-engine-tavily + ${langchain4j-tavily.version} + org.springframework.ai diff --git a/dil/src/main/java/org/assimbly/dil/blocks/connections/Connection.java b/dil/src/main/java/org/assimbly/dil/blocks/connections/Connection.java index 2b44c831..9c5eccb2 100644 --- a/dil/src/main/java/org/assimbly/dil/blocks/connections/Connection.java +++ b/dil/src/main/java/org/assimbly/dil/blocks/connections/Connection.java @@ -2,6 +2,7 @@ import org.assimbly.dil.blocks.connections.ai.LangChain4jAgentConnection; import org.assimbly.dil.blocks.connections.ai.LangChain4jConnection; +import org.assimbly.dil.blocks.connections.ai.LangChain4jWebSearchConnection; import org.assimbly.dil.blocks.connections.ai.SpringAiConnection; import org.assimbly.dil.blocks.connections.auth.BasicAuthentication; import org.assimbly.dil.blocks.connections.auth.MutualSSL; @@ -73,10 +74,10 @@ private void startConnection() throws Exception{ case "rabbitmq", "spring-rabbitmq" -> new RabbitMQConnection(context, decryptedProperties, connectionId, "spring-rabbitmq").start(); - case "springai" -> + case "springaichat" -> new SpringAiConnection(context, decryptedProperties, connectionId).start(); - case "langchain4j" -> + case "langchain4jchat" -> new LangChain4jConnection(context, decryptedProperties, connectionId).start(); case "ibmq" -> @@ -94,9 +95,12 @@ private void startConnection() throws Exception{ case "imaps" -> log.debug("Imaps connection will be configured on the component"); - case "langchain4j-agent" -> + case "langchain4jagent" -> new LangChain4jAgentConnection(context, decryptedProperties, connectionId).start(); + case "langchain4j-web-search" -> + new LangChain4jWebSearchConnection(context, decryptedProperties, connectionId).start(); + default -> throw new IllegalArgumentException("Connection parameters for connection " + connectionType + " are not implemented"); } diff --git a/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jAgentConnection.java b/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jAgentConnection.java index 22294316..0e499c43 100644 --- a/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jAgentConnection.java +++ b/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jAgentConnection.java @@ -12,7 +12,11 @@ import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; +import dev.langchain4j.web.search.WebSearchEngine; +import dev.langchain4j.web.search.WebSearchTool; +import dev.langchain4j.web.search.tavily.TavilyWebSearchEngine; import java.time.Duration; +import java.util.List; public class LangChain4jAgentConnection { @@ -25,6 +29,8 @@ public class LangChain4jAgentConnection { private String apiKey; private String modelName; private String timeout; + private String webSearchApiKey; + private String maxMessages; public LangChain4jAgentConnection(CamelContext context, EncryptableProperties properties, String connectionId) { this.context = context; @@ -47,6 +53,11 @@ private void setFields() { apiKey = properties.getProperty("connection." + connectionId + ".apikey"); modelName = properties.getProperty("connection." + connectionId + ".modelname"); timeout = properties.getProperty("connection." + connectionId + ".timeout"); + webSearchApiKey = properties.getProperty("connection." + connectionId + ".websearchapikey"); + if (webSearchApiKey == null || webSearchApiKey.isEmpty()) { + webSearchApiKey = properties.getProperty("connection." + connectionId + ".tavilyapikey"); + } + maxMessages = properties.getProperty("connection." + connectionId + ".maxmessages"); } private boolean checkConnection() { @@ -67,7 +78,7 @@ private void setConnection() { apiKey != null ? apiKey.length() : 0, apiKey != null && apiKey.length() >= 5 ? apiKey.substring(0, 5) : "N/A"); - String resolvedModel = (modelName != null && !modelName.isEmpty()) ? modelName : "gemini-2.5-flash"; + String resolvedModel = (modelName != null && !modelName.isEmpty()) ? modelName : "gemini-3.6-flash"; long resolvedTimeout = 10; if (timeout != null && !timeout.isEmpty()) { try { @@ -77,23 +88,58 @@ private void setConnection() { } } + int resolvedMaxMessages = 100; + if (maxMessages != null && !maxMessages.isEmpty()) { + try { + resolvedMaxMessages = Integer.parseInt(maxMessages); + } catch (NumberFormatException e) { + log.warn("Invalid maxMessages value '{}', using default 100", maxMessages); + } + } + ChatModel chatModel = GoogleAiGeminiChatModel.builder() .apiKey(apiKey) .modelName(resolvedModel) .timeout(Duration.ofSeconds(resolvedTimeout)) + .returnThinking(true) + .sendThinking(true) .build(); + final int finalMaxMessages = resolvedMaxMessages; InMemoryChatMemoryStore chatMemoryStore = new InMemoryChatMemoryStore(); - ChatMemoryProvider chatMemoryProvider = memoryId -> MessageWindowChatMemory.builder() - .id(memoryId) - .maxMessages(100) - .chatMemoryStore(chatMemoryStore) - .build(); + java.util.Map memories = java.util.Collections.synchronizedMap( + new java.util.LinkedHashMap(100, 0.75f, true) { + @Override + protected boolean removeEldestEntry(java.util.Map.Entry eldest) { + return size() > 1000; + } + } + ); + ChatMemoryProvider chatMemoryProvider = memoryId -> { + synchronized (memories) { + return memories.computeIfAbsent(memoryId, id -> + MessageWindowChatMemory.builder() + .id(id) + .maxMessages(finalMaxMessages) + .chatMemoryStore(chatMemoryStore) + .build() + ); + } + }; AgentConfiguration config = new AgentConfiguration() .withChatModel(chatModel) .withChatMemoryProvider(chatMemoryProvider); + if (webSearchApiKey != null && !webSearchApiKey.isEmpty()) { + log.info("Attaching Tavily WebSearchTool to LangChain4j Agent with connection id={}", connectionId); + WebSearchEngine webSearchEngine = TavilyWebSearchEngine.builder() + .apiKey(webSearchApiKey) + .build(); + WebSearchTool webSearchTool = WebSearchTool.from(webSearchEngine); + config.withCustomTools(List.of(webSearchTool)); + } + Agent agent = new AgentWithMemory(config); context.getRegistry().bind(connectionId, agent); diff --git a/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jWebSearchConnection.java b/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jWebSearchConnection.java new file mode 100644 index 00000000..6cf03e8f --- /dev/null +++ b/dil/src/main/java/org/assimbly/dil/blocks/connections/ai/LangChain4jWebSearchConnection.java @@ -0,0 +1,64 @@ +package org.assimbly.dil.blocks.connections.ai; + +import org.apache.camel.CamelContext; +import org.jasypt.properties.EncryptableProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import dev.langchain4j.web.search.WebSearchEngine; +import dev.langchain4j.web.search.tavily.TavilyWebSearchEngine; + +public class LangChain4jWebSearchConnection { + + protected Logger log = LoggerFactory.getLogger(getClass()); + + private final CamelContext context; + private final EncryptableProperties properties; + private final String connectionId; + + private String apiKey; + + public LangChain4jWebSearchConnection(CamelContext context, EncryptableProperties properties, String connectionId) { + this.context = context; + this.properties = properties; + this.connectionId = connectionId; + } + + public void start() { + setFields(); + + if (checkConnection()) { + log.info("Creating new LangChain4j Web Search connection with id={}", connectionId); + setConnection(); + } else { + log.info("Reuse LangChain4j Web Search connection with id={}", connectionId); + } + } + + private void setFields() { + apiKey = properties.getProperty("connection." + connectionId + ".apikey"); + } + + private boolean checkConnection() { + Object isRegistered = context.getRegistry().lookupByName(connectionId); + if (isRegistered != null) { + return false; + } + + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalArgumentException("LangChain4j Web Search connection parameters are invalid. apikey is required"); + } + + return true; + } + + private void setConnection() { + log.info("Setting up Tavily WebSearchEngine connection with id={}", connectionId); + + WebSearchEngine webSearchEngine = TavilyWebSearchEngine.builder() + .apiKey(apiKey) + .build(); + + context.getRegistry().bind(connectionId, webSearchEngine); + log.info("Successfully bound WebSearchEngine bean with id={} to the Camel registry", connectionId); + } +} diff --git a/dil/src/main/java/org/assimbly/dil/transpiler/marshalling/core/RouteTemplate.java b/dil/src/main/java/org/assimbly/dil/transpiler/marshalling/core/RouteTemplate.java index dae38e34..87e7ae47 100644 --- a/dil/src/main/java/org/assimbly/dil/transpiler/marshalling/core/RouteTemplate.java +++ b/dil/src/main/java/org/assimbly/dil/transpiler/marshalling/core/RouteTemplate.java @@ -459,7 +459,7 @@ private void createTemplateId(String uri,String type){ } if(templateExists(templateName)){ - templateId = templateName; + templateId = resolveTemplateName(templateName); }else if(uri.startsWith("block")){ String componentName = path; componentName = componentName.toLowerCase(); @@ -474,7 +474,30 @@ private void createTemplateId(String uri,String type){ private boolean templateExists(String templateName) { String fullTemplateName = templateName + ".kamelet.yaml"; - return CustomKameletCatalog.getNames().contains(fullTemplateName); + if (CustomKameletCatalog.getNames().contains(fullTemplateName)) { + return true; + } + String targetStripped = fullTemplateName.replace("-", ""); + for (String name : CustomKameletCatalog.getNames()) { + if (name.replace("-", "").equalsIgnoreCase(targetStripped)) { + return true; + } + } + return false; + } + + private String resolveTemplateName(String templateName) { + String fullTemplateName = templateName + ".kamelet.yaml"; + if (CustomKameletCatalog.getNames().contains(fullTemplateName)) { + return templateName; + } + String targetStripped = fullTemplateName.replace("-", ""); + for (String name : CustomKameletCatalog.getNames()) { + if (name.replace("-", "").equalsIgnoreCase(targetStripped)) { + return name.substring(0, name.indexOf(".kamelet.yaml")); + } + } + return templateName; } diff --git a/dil/src/main/resources/kamelets/langchain4j-tool-weather-source.kamelet.yaml b/dil/src/main/resources/kamelets/langchain4j-tool-weather-source.kamelet.yaml new file mode 100644 index 00000000..a8a0cc64 --- /dev/null +++ b/dil/src/main/resources/kamelets/langchain4j-tool-weather-source.kamelet.yaml @@ -0,0 +1,26 @@ +apiVersion: "camel.apache.org/v1" +kind: "Kamelet" +metadata: + name: "langchain4j-tool-weather-source" + labels: + camel.apache.org/kamelet.type: "source" +spec: + definition: + title: "LangChain4j Weather Tool" + description: "Returns a placeholder weather forecast for a given city." + type: "object" + properties: + city: + title: "City" + description: "Name of the city to get weather for" + type: "string" + default: "" + dependencies: + - "camel:ai-tool" + template: + route: + from: + uri: "ai-tool:weather?description=Returns+a+placeholder+weather+forecast+for+a+given+city&tags=weather" + steps: + - setBody: + simple: "Weather for ${header.city} is sunny with 25°C temperature." diff --git a/dil/src/main/resources/kamelets/langchain4j-web-search-action.kamelet.yaml b/dil/src/main/resources/kamelets/langchain4j-web-search-action.kamelet.yaml new file mode 100644 index 00000000..08ad04e6 --- /dev/null +++ b/dil/src/main/resources/kamelets/langchain4j-web-search-action.kamelet.yaml @@ -0,0 +1,73 @@ +apiVersion: "camel.apache.org/v1" +kind: "Kamelet" +metadata: + name: "langchain4j-web-search-action" + labels: + camel.apache.org/kamelet.type: "action" + +spec: + definition: + title: "langchain4j-web-search action" + description: "Web Search component for LangChain4j" + type: "object" + properties: + in: + title: "Source Endpoint" + type: "string" + default: "kamelet:source" + out: + title: "Sink Endpoint" + type: "string" + default: "kamelet:sink" + sink: + title: "Sink" + type: "boolean" + default: false + routeId: + title: "Route ID" + type: "string" + routeConfigurationId: + title: "Route Configuration ID" + type: "string" + default: "0" + searchId: + title: "Search ID" + type: "string" + default: "search" + webSearchEngine: + title: "Web Search Engine Bean Name" + description: "Name of the WebSearchEngine bean in the Camel registry (e.g. #webSearchEngine)" + type: "string" + default: "#webSearchEngine" + maxResults: + title: "Max Results" + description: "Maximum number of search results to return" + type: "integer" + default: 1 + resultType: + title: "Result Type" + description: "Result type (CONTENT, SNIPPET, or LANGCHAIN4J_WEB_SEARCH_ORGANIC_RESULT)" + type: "string" + default: "SNIPPET" + + dependencies: + - "camel:kamelet" + - "camel:langchain4j-web-search" + + template: + route: + routeConfigurationId: "{{routeConfigurationId}}" + from: + uri: "{{in}}" + steps: + - step: + id: "{{routeId}}" + steps: + - convertBodyTo: + type: String + - to: + uri: "langchain4j-web-search:{{searchId}}?webSearchEngine={{webSearchEngine}}&maxResults={{maxResults}}&resultType={{resultType}}" + - to: + disabled: "{{sink}}" + uri: "{{out}}" + \ No newline at end of file diff --git a/dil/src/main/resources/kamelets/langchain4jagent-action.kamelet.yaml b/dil/src/main/resources/kamelets/langchain4jagent-action.kamelet.yaml index e3b31a2e..a54a4d23 100644 --- a/dil/src/main/resources/kamelets/langchain4jagent-action.kamelet.yaml +++ b/dil/src/main/resources/kamelets/langchain4jagent-action.kamelet.yaml @@ -19,6 +19,10 @@ spec: title: "Sink Endpoint" type: "string" default: "kamelet:sink" + sink: + title: "Sink" + type: "boolean" + default: false routeId: title: "Route ID" type: "string" @@ -32,12 +36,24 @@ spec: default: "geminiAgent" memoryId: title: "Memory ID" - description: "The memory ID to identify the conversation session. Can be a Camel Simple expression." + description: "The memory ID to identify the conversation session. Can be a Camel Simple expression or static ID." + type: "string" + default: "" + systemMessage: + title: "System Message" + description: "The system message / instructions for the AI Agent." + type: "string" + default: "" + tags: + title: "Tags" + description: "Tags for discovering and calling Camel route tools (LangChain4j Tools / Function Calling)" type: "string" default: "" dependencies: - "camel:kamelet" + - "camel:langchain4j-agent" + - "camel:ai-tool" template: route: @@ -56,13 +72,31 @@ spec: steps: - setHeader: name: "CamelLangChain4jAgentMemoryId" - simple: "{{memoryId}}" + simple: "{{?memoryId}}" otherwise: steps: - - setHeader: - name: "CamelLangChain4jAgentMemoryId" - simple: "${exchangeId}" + - choice: + when: + - simple: "${header.memoryId} != null" + steps: + - setHeader: + name: "CamelLangChain4jAgentMemoryId" + simple: "${header.memoryId}" + otherwise: + steps: + - setHeader: + name: "CamelLangChain4jAgentMemoryId" + simple: "default" + - choice: + when: + - simple: "'{{?systemMessage}}' != ''" + steps: + - setHeader: + name: "CamelLangChain4jAgentSystemMessage" + simple: "{{?systemMessage}}" - to: - uri: "langchain4j-agent://{{routeId}}?agent=#{{agent}}" + uri: "langchain4j-agent://{{routeId}}?agent=#{{agent}}&tags={{?tags}}" - to: + disabled: "{{sink}}" uri: "{{out}}" + diff --git a/dil/src/main/resources/kamelets/langchain4jchat-action.kamelet.yaml b/dil/src/main/resources/kamelets/langchain4jchat-action.kamelet.yaml index b02e85f5..ef011232 100644 --- a/dil/src/main/resources/kamelets/langchain4jchat-action.kamelet.yaml +++ b/dil/src/main/resources/kamelets/langchain4jchat-action.kamelet.yaml @@ -26,6 +26,10 @@ spec: title: "Sink Endpoint" type: "string" default: "kamelet:sink" + sink: + title: "Sink" + type: "boolean" + default: false routeId: title: "Route ID" type: "string" @@ -49,4 +53,6 @@ spec: - to: uri: "langchain4j-chat://{{routeId}}?chatModel=#{{chatModel}}" - to: + disabled: "{{sink}}" uri: "{{out}}" + diff --git a/dil/src/main/resources/kamelets/springaichat-action.kamelet.yaml b/dil/src/main/resources/kamelets/springaichat-action.kamelet.yaml index b8e3e17a..baea4d60 100644 --- a/dil/src/main/resources/kamelets/springaichat-action.kamelet.yaml +++ b/dil/src/main/resources/kamelets/springaichat-action.kamelet.yaml @@ -13,12 +13,17 @@ spec: properties: chatModel: title: "Chat Model Bean Name" - description: "Name of the Spring AI ChatModel bean in the Camel registry (e.g. #SpringAiChatModel)" + description: "Name of the Spring AI ChatModel bean in the Camel registry (e.g. SpringAiChatModel)" type: "string" default: "SpringAiChatModel" memoryId: title: "Memory ID" - description: "Conversation session identifier to activate chat memory. Can be a fixed value or a Camel Simple expression (e.g. ${header.sessionId}). Required when chatMemory is set." + description: "Conversation session identifier to activate chat memory. Can be a fixed value or a Camel Simple expression." + type: "string" + default: "" + tags: + title: "Tags" + description: "Tags for discovering and calling Camel route tools (Spring AI Function Calling)" type: "string" default: "" in: @@ -27,6 +32,10 @@ spec: out: type: "string" default: "kamelet:sink" + sink: + title: "Sink" + type: "boolean" + default: false routeId: title: "Route ID" type: "string" @@ -51,17 +60,29 @@ spec: steps: - convertBodyTo: type: String - - choice: when: - simple: "'{{?memoryId}}' != ''" steps: - setHeader: name: "CamelSpringAiChatConversationId" - simple: "{{memoryId}}" - - - to: - uri: "spring-ai-chat://{{routeId}}?chatModel=#{{chatModel}}&chatMemory=#{{chatModel}}-memory" - + simple: "{{?memoryId}}" + otherwise: + steps: + - choice: + when: + - simple: "${header.memoryId} != null" + steps: + - setHeader: + name: "CamelSpringAiChatConversationId" + simple: "${header.memoryId}" + otherwise: + steps: + - setHeader: + name: "CamelSpringAiChatConversationId" + simple: "default" - to: - uri: "{{out}}" \ No newline at end of file + uri: "spring-ai-chat://{{routeId}}?chatModel=#{{chatModel}}&chatMemory=#{{chatModel}}-memory&tags={{?tags}}" + - to: + disabled: "{{sink}}" + uri: "{{out}}" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5da305e2..09556810 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ 2.6.1 4.22.0 1.19.0 + 0.36.2 1.6.3 3.1.0 2.0.18