diff --git a/core/build.gradle b/core/build.gradle index 62fbfa8e9..1cdabfc2f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -7,6 +7,9 @@ dependencies { // Environment configuration implementation "io.temporal:temporal-envconfig:$javaSDKVersion" + // Needed for SSL sample (AdvancedTlsX509KeyManager) + implementation "io.grpc:grpc-util" + // Needed for SDK related functionality implementation(platform("com.fasterxml.jackson:jackson-bom:2.17.2")) implementation "com.fasterxml.jackson.core:jackson-databind" diff --git a/core/src/main/java/io/temporal/samples/ssl/Starter.java b/core/src/main/java/io/temporal/samples/ssl/Starter.java index c69101473..7e90c6fc9 100644 --- a/core/src/main/java/io/temporal/samples/ssl/Starter.java +++ b/core/src/main/java/io/temporal/samples/ssl/Starter.java @@ -50,12 +50,14 @@ public static void main(String[] args) throws Exception { if (refreshPeriod > 0) { AdvancedTlsX509KeyManager clientKeyManager = new AdvancedTlsX509KeyManager(); // Reload credentials every minute - clientKeyManager.updateIdentityCredentialsFromFile( - clientKeyFile, - clientCertFile, - refreshPeriod, - TimeUnit.MINUTES, - Executors.newScheduledThreadPool(1)); + @SuppressWarnings("InlineMeInliner") + var unused = + clientKeyManager.updateIdentityCredentialsFromFile( + clientKeyFile, + clientCertFile, + refreshPeriod, + TimeUnit.MINUTES, + Executors.newScheduledThreadPool(1)); sslContext = GrpcSslContexts.configure(SslContextBuilder.forClient().keyManager(clientKeyManager)) .build(); diff --git a/gradle/springai.gradle b/gradle/springai.gradle new file mode 100644 index 000000000..98ab26173 --- /dev/null +++ b/gradle/springai.gradle @@ -0,0 +1,48 @@ +// Shared configuration for all Spring AI sample modules. +// Applied via: apply from: "$rootDir/gradle/springai.gradle" + +apply plugin: 'org.springframework.boot' +apply plugin: 'io.spring.dependency-management' + +ext { + springBootVersionForSpringAi = '3.5.3' + springAiVersion = '1.1.0' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencyManagement { + imports { + mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersionForSpringAi" + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" + } +} + +dependencies { + // Temporal + implementation "io.temporal:temporal-spring-boot-starter:$javaSDKVersion" + implementation "io.temporal:temporal-spring-ai:$javaSDKVersion" + + // Spring Boot + implementation 'org.springframework.boot:spring-boot-starter' + + dependencies { + errorproneJavac('com.google.errorprone:javac:9+181-r4173-1') + errorprone('com.google.errorprone:error_prone_core:2.28.0') + } +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} + +bootRun { + standardInput = System.in +} diff --git a/settings.gradle b/settings.gradle index 65c976aaa..a7fe05f15 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,23 @@ rootProject.name = 'temporal-java-samples' include 'core' +include 'springai' +include 'springai-mcp' +include 'springai-multimodel' +include 'springai-rag' include 'springboot' include 'springboot-basic' + +// Include local sdk-java build for temporal-spring-ai (until published to Maven Central). +// temporal-spring-ai requires the plugin API (SimplePlugin) which is not yet in a released SDK, +// so we substitute all SDK modules from the local build. +includeBuild('../sdk-java') { + dependencySubstitution { + substitute module('io.temporal:temporal-spring-ai') using project(':temporal-spring-ai') + substitute module('io.temporal:temporal-sdk') using project(':temporal-sdk') + substitute module('io.temporal:temporal-serviceclient') using project(':temporal-serviceclient') + substitute module('io.temporal:temporal-spring-boot-autoconfigure') using project(':temporal-spring-boot-autoconfigure') + substitute module('io.temporal:temporal-spring-boot-starter') using project(':temporal-spring-boot-starter') + substitute module('io.temporal:temporal-testing') using project(':temporal-testing') + substitute module('io.temporal:temporal-opentracing') using project(':temporal-opentracing') + } +} diff --git a/springai-mcp/build.gradle b/springai-mcp/build.gradle new file mode 100644 index 000000000..334461917 --- /dev/null +++ b/springai-mcp/build.gradle @@ -0,0 +1,8 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-starter-mcp-client' + implementation 'org.springframework.ai:spring-ai-rag' + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} diff --git a/springai-mcp/build/bootRunMainClassName b/springai-mcp/build/bootRunMainClassName new file mode 100644 index 000000000..1e34bcf73 --- /dev/null +++ b/springai-mcp/build/bootRunMainClassName @@ -0,0 +1 @@ +io.temporal.samples.springai.mcp.McpApplication \ No newline at end of file diff --git a/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class new file mode 100644 index 000000000..fbf3cad95 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpApplication.class differ diff --git a/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class new file mode 100644 index 000000000..9d53d6b89 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflow.class differ diff --git a/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class new file mode 100644 index 000000000..b9d3ee226 Binary files /dev/null and b/springai-mcp/build/classes/java/main/io/temporal/samples/springai/mcp/McpWorkflowImpl.class differ diff --git a/springai-mcp/build/resources/main/application.yaml b/springai-mcp/build/resources/main/application.yaml new file mode 100644 index 000000000..60a48f031 --- /dev/null +++ b/springai-mcp/build/resources/main/application.yaml @@ -0,0 +1,32 @@ +spring: + main: + banner-mode: off + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + mcp: + client: + stdio: + connections: + # Filesystem MCP server - provides read_file, write_file, list_directory tools + filesystem: + command: npx + args: + - "-y" + - "@modelcontextprotocol/server-filesystem" + - "${MCP_ALLOWED_PATH:/tmp/mcp-example}" + + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: mcp-example-queue + workflow-classes: + - io.temporal.samples.springai.mcp.McpWorkflowImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpApplication.class.uniqueId0 b/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpApplication.class.uniqueId0 new file mode 100644 index 000000000..fbf3cad95 Binary files /dev/null and b/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpApplication.class.uniqueId0 differ diff --git a/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpWorkflowImpl.class.uniqueId1 b/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpWorkflowImpl.class.uniqueId1 new file mode 100644 index 000000000..b9d3ee226 Binary files /dev/null and b/springai-mcp/build/tmp/compileJava/compileTransaction/stash-dir/McpWorkflowImpl.class.uniqueId1 differ diff --git a/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin b/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin new file mode 100644 index 000000000..e1607bf69 Binary files /dev/null and b/springai-mcp/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java new file mode 100644 index 000000000..5dc44c013 --- /dev/null +++ b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpApplication.java @@ -0,0 +1,136 @@ +package io.temporal.samples.springai.mcp; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import java.util.Scanner; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; + +/** + * Example application demonstrating MCP (Model Context Protocol) integration. + * + *
This application shows how to use tools from MCP servers within Temporal workflows. It + * connects to a filesystem MCP server and provides an AI assistant that can read and write files. + * + *
+ * Commands: + * tools - List available MCP tools + * <any message> - Chat with the AI (it can use file tools) + * quit - End the chat + *+ * + *
+ * > List files in the current directory + * [AI uses list_directory tool and returns results] + * + * > Create a file called hello.txt with "Hello from MCP!" + * [AI uses write_file tool] + * + * > Read the contents of hello.txt + * [AI uses read_file tool] + *+ * + *
This workflow shows how to use tools from MCP servers within Temporal workflows. The AI model + * can call MCP tools (like file system operations) as durable activities. + */ +@WorkflowInterface +public interface McpWorkflow { + + /** + * Runs the workflow until ended. + * + * @return summary of the chat session + */ + @WorkflowMethod + String run(); + + /** + * Sends a message to the AI assistant with MCP tools available. + * + * @param message the user message + */ + @SignalMethod + void chat(String message); + + /** + * Gets the last response from the AI. + * + * @return the last response + */ + @QueryMethod + String getLastResponse(); + + /** + * Lists the available MCP tools. + * + * @return list of available tools + */ + @QueryMethod + String listTools(); + + /** Ends the chat session. */ + @SignalMethod + void end(); +} diff --git a/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java new file mode 100644 index 000000000..a3c5ae144 --- /dev/null +++ b/springai-mcp/src/main/java/io/temporal/samples/springai/mcp/McpWorkflowImpl.java @@ -0,0 +1,130 @@ +package io.temporal.samples.springai.mcp; + +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.mcp.ActivityMcpClient; +import io.temporal.springai.mcp.McpToolCallback; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.workflow.Workflow; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.tool.ToolCallback; + +/** + * Implementation of the MCP workflow. + * + *
This demonstrates how to use MCP tools from external servers within a Temporal workflow. The + * workflow: + * + *
This example uses the filesystem MCP server which provides tools like: + * + *
This demonstrates how to configure multiple AI providers in a Spring Boot application. Each + * model is registered as a separate bean with a unique name. + * + *
In workflows, these can be accessed via: + * + *
This application shows how to use different AI providers (OpenAI and Anthropic) within the + * same Temporal workflow. It provides an interactive CLI where you can send messages to different + * models. + * + *
+ * Commands: + * openai: <message> - Send to OpenAI (gpt-4o-mini) + * anthropic: <message> - Send to Anthropic (Claude) + * default: <message> - Send to default model (OpenAI) + * quit - End the chat + *+ * + *
This workflow shows how to use different AI models for different purposes within the same + * workflow. + */ +@WorkflowInterface +public interface MultiModelWorkflow { + + /** + * Runs the workflow until ended. + * + * @return summary of the chat session + */ + @WorkflowMethod + String run(); + + /** + * Sends a message to a specific model. + * + * @param modelName the name of the model to use ("fast", "smart", or "default") + * @param message the user message + */ + @SignalMethod + void chat(String modelName, String message); + + /** + * Gets the last response. + * + * @return the last response from any model + */ + @QueryMethod + String getLastResponse(); + + /** Ends the chat session. */ + @SignalMethod + void end(); +} diff --git a/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java b/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java new file mode 100644 index 000000000..55f722437 --- /dev/null +++ b/springai-multimodel/src/main/java/io/temporal/samples/springai/multimodel/MultiModelWorkflowImpl.java @@ -0,0 +1,107 @@ +package io.temporal.samples.springai.multimodel; + +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.springframework.ai.chat.client.ChatClient; + +/** + * Implementation of the multi-model workflow. + * + *
This demonstrates how to use multiple AI providers in a single workflow: + * + *
The workflow shows three ways to create ActivityChatModel: + * + *
This application shows how to use the plugin's VectorStoreActivity and EmbeddingModelActivity + * to build a durable knowledge base within Temporal workflows. + * + *
+ * Commands: + * add <id> <content> - Add a document to the knowledge base + * ask <question> - Ask a question (uses RAG) + * search <query> - Search for similar documents + * count - Show document count + * quit - End the session + *+ * + *
This workflow shows how to use VectorStoreActivity and EmbeddingModelActivity to build a + * durable knowledge base that can be queried with natural language. + */ +@WorkflowInterface +public interface RagWorkflow { + + /** + * Runs the workflow until ended. + * + * @return summary of the session + */ + @WorkflowMethod + String run(); + + /** + * Adds a document to the knowledge base. + * + * @param id unique identifier for the document + * @param content the document content + */ + @SignalMethod + void addDocument(String id, String content); + + /** + * Asks a question using RAG - retrieves relevant documents and generates an answer. + * + * @param question the question to answer + */ + @SignalMethod + void ask(String question); + + /** + * Searches for similar documents without generating an answer. + * + * @param query the search query + * @param topK number of results to return + */ + @SignalMethod + void search(String query, int topK); + + /** + * Gets the last response from the AI or search. + * + * @return the last response + */ + @QueryMethod + String getLastResponse(); + + /** + * Gets the current document count. + * + * @return number of documents in the knowledge base + */ + @QueryMethod + int getDocumentCount(); + + /** Ends the session. */ + @SignalMethod + void end(); +} diff --git a/springai-rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java b/springai-rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java new file mode 100644 index 000000000..b626775d9 --- /dev/null +++ b/springai-rag/src/main/java/io/temporal/samples/springai/rag/RagWorkflowImpl.java @@ -0,0 +1,167 @@ +package io.temporal.samples.springai.rag; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.VectorStoreActivity; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.springai.model.VectorStoreTypes; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.ai.chat.client.ChatClient; + +/** + * Implementation of the RAG workflow. + * + *
This demonstrates: + * + *
All operations are durable Temporal activities - if the worker restarts, the workflow will + * continue from where it left off. + */ +public class RagWorkflowImpl implements RagWorkflow { + + private final VectorStoreActivity vectorStore; + private final ChatClient chatClient; + + private String lastResponse = ""; + private int documentCount = 0; + private boolean ended = false; + + @WorkflowInit + public RagWorkflowImpl() { + // Create activity stubs with appropriate timeouts + ActivityOptions activityOptions = + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(2)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build(); + + this.vectorStore = Workflow.newActivityStub(VectorStoreActivity.class, activityOptions); + + // Create the chat client + ActivityChatModel chatModel = ActivityChatModel.forDefault(); + this.chatClient = + TemporalChatClient.builder(chatModel) + .defaultSystem( + """ + You are a helpful assistant that answers questions based on the provided context. + + When answering: + - Use only the information from the context provided + - If the context doesn't contain relevant information, say so + - Be concise and direct + """) + .build(); + } + + @Override + public String run() { + Workflow.await(() -> ended); + return "Session ended. Processed " + documentCount + " documents."; + } + + @Override + public void addDocument(String id, String content) { + // Create a document and add it to the vector store + // The vector store will use the embedding model to generate embeddings + VectorStoreTypes.Document doc = new VectorStoreTypes.Document(id, content); + vectorStore.addDocuments(new VectorStoreTypes.AddDocumentsInput(List.of(doc))); + + documentCount++; + lastResponse = + "Added document '" + id + "' to knowledge base. Total documents: " + documentCount; + } + + @Override + public void ask(String question) { + // Step 1: Search for relevant documents + VectorStoreTypes.SearchOutput searchResults = + vectorStore.similaritySearch(new VectorStoreTypes.SearchInput(question, 3)); + + if (searchResults.documents().isEmpty()) { + lastResponse = "No relevant documents found in the knowledge base."; + return; + } + + // Step 2: Build context from search results + String context = + searchResults.documents().stream() + .map(result -> result.document().text()) + .collect(Collectors.joining("\n\n---\n\n")); + + // Step 3: Generate answer using the context + lastResponse = + chatClient + .prompt() + .user( + u -> + u.text( + """ + Context: + {context} + + Question: {question} + + Answer based on the context above: + """) + .param("context", context) + .param("question", question)) + .call() + .content(); + } + + @Override + public void search(String query, int topK) { + VectorStoreTypes.SearchOutput searchResults = + vectorStore.similaritySearch(new VectorStoreTypes.SearchInput(query, topK)); + + if (searchResults.documents().isEmpty()) { + lastResponse = "No matching documents found."; + return; + } + + StringBuilder sb = new StringBuilder("Search results:\n\n"); + for (int i = 0; i < searchResults.documents().size(); i++) { + VectorStoreTypes.SearchResult result = searchResults.documents().get(i); + sb.append( + String.format( + "%d. [Score: %.3f] %s\n %s\n\n", + i + 1, + result.score(), + result.document().id(), + truncate(result.document().text(), 100))); + } + lastResponse = sb.toString(); + } + + @Override + public String getLastResponse() { + return lastResponse; + } + + @Override + public int getDocumentCount() { + return documentCount; + } + + @Override + public void end() { + ended = true; + } + + private String truncate(String text, int maxLength) { + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength) + "..."; + } +} diff --git a/springai-rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java b/springai-rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java new file mode 100644 index 000000000..7909e92d4 --- /dev/null +++ b/springai-rag/src/main/java/io/temporal/samples/springai/rag/VectorStoreConfig.java @@ -0,0 +1,32 @@ +package io.temporal.samples.springai.rag; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for the vector store. + * + *
This example uses Spring AI's SimpleVectorStore, an in-memory vector store that's perfect for + * demos and testing. In production, you'd use a real vector database like Pinecone, Weaviate, + * Milvus, or pgvector. + */ +@Configuration +public class VectorStoreConfig { + + /** + * Creates an in-memory vector store using the provided embedding model. + * + *
The SimpleVectorStore stores vectors in memory and uses the embedding model to convert text + * to vectors when documents are added. + * + * @param embeddingModel the embedding model to use for vectorization + * @return the configured vector store + */ + @Bean + public VectorStore vectorStore(EmbeddingModel embeddingModel) { + return SimpleVectorStore.builder(embeddingModel).build(); + } +} diff --git a/springai-rag/src/main/resources/application.yaml b/springai-rag/src/main/resources/application.yaml new file mode 100644 index 000000000..b8889cb70 --- /dev/null +++ b/springai-rag/src/main/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + main: + banner-mode: off + web-application-type: none + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + embedding: + options: + model: text-embedding-3-small + + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: rag-example-queue + workflow-classes: + - io.temporal.samples.springai.rag.RagWorkflowImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/build.gradle b/springai/build.gradle new file mode 100644 index 000000000..49fd72b0f --- /dev/null +++ b/springai/build.gradle @@ -0,0 +1,5 @@ +apply from: "$rootDir/gradle/springai.gradle" + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' +} diff --git a/springai/build/bootRunMainClassName b/springai/build/bootRunMainClassName new file mode 100644 index 000000000..86e957690 --- /dev/null +++ b/springai/build/bootRunMainClassName @@ -0,0 +1 @@ +io.temporal.samples.springai.chat.ChatExampleApplication \ No newline at end of file diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class new file mode 100644 index 000000000..070057fbd Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatExampleApplication.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class new file mode 100644 index 000000000..a71b5ee1e Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatRunner.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class new file mode 100644 index 000000000..1475c8e78 Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflow.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class new file mode 100644 index 000000000..dc15b627a Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/ChatWorkflowImpl.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class new file mode 100644 index 000000000..92d5980eb Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/StringTools.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class new file mode 100644 index 000000000..c28fb2eae Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/TimestampTools.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class new file mode 100644 index 000000000..b8fe3d9b9 Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivity.class differ diff --git a/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class new file mode 100644 index 000000000..389f7e5fc Binary files /dev/null and b/springai/build/classes/java/main/io/temporal/samples/springai/chat/WeatherActivityImpl.class differ diff --git a/springai/build/resources/main/application.yml b/springai/build/resources/main/application.yml new file mode 100644 index 000000000..93e1597e0 --- /dev/null +++ b/springai/build/resources/main/application.yml @@ -0,0 +1,25 @@ +spring: + application: + name: spring-ai-temporal-example + main: + web-application-type: none + ai: + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + temperature: 0.7 + temporal: + connection: + target: localhost:7233 + workers: + - task-queue: spring-ai-example + workflow-classes: + - io.temporal.samples.springai.chat.ChatWorkflowImpl + activity-beans: + - weatherActivityImpl + +logging: + level: + io.temporal.springai: DEBUG diff --git a/springai/build/spotless/spotlessJava/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java b/springai/build/spotless/spotlessJava/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java new file mode 100644 index 000000000..af995ad4e --- /dev/null +++ b/springai/build/spotless/spotlessJava/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java @@ -0,0 +1,115 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivity; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.time.Duration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; + +/** + * Implementation of the chat workflow using Spring AI's ChatClient with Temporal tools. + * + *
This demonstrates how to use the Spring AI plugin within a Temporal workflow: + * + *
The AI model can call: + * + *
Starts an interactive chat workflow where each AI call is a durable Temporal activity with + * automatic retries and timeout handling. + */ +@SpringBootApplication +public class ChatExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatExampleApplication.class, args); + } +} + +@Component +class ChatRunner { + + private final WorkflowClient workflowClient; + + ChatRunner(WorkflowClient workflowClient) { + this.workflowClient = workflowClient; + } + + @EventListener(ApplicationReadyEvent.class) + public void run() { + String workflowId = "chat-" + UUID.randomUUID().toString().substring(0, 8); + + System.out.println("\n==========================================="); + System.out.println(" Spring AI + Temporal Chat Demo"); + System.out.println("==========================================="); + System.out.println("Workflow ID: " + workflowId); + System.out.println("Type messages, or 'quit' to exit.\n"); + + // Start the chat workflow + ChatWorkflow workflow = + workflowClient.newWorkflowStub( + ChatWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + .setTaskQueue("spring-ai-example") + .build()); + + WorkflowClient.start(workflow::run, "You are a helpful assistant. Be concise."); + + // Get stub for the running workflow + ChatWorkflow chat = workflowClient.newWorkflowStub(ChatWorkflow.class, workflowId); + + // Interactive loop + try (Scanner scanner = new Scanner(System.in, java.nio.charset.StandardCharsets.UTF_8)) { + while (true) { + System.out.print("You: "); + String input = scanner.nextLine().trim(); + + if (input.equalsIgnoreCase("quit") || input.equalsIgnoreCase("exit")) { + chat.end(); + break; + } + + if (input.isEmpty()) { + continue; + } + + try { + String response = chat.chat(input); + System.out.println("Assistant: " + response + "\n"); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage() + "\n"); + } + } + } + + System.out.println("Goodbye!"); + System.exit(0); + } +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java new file mode 100644 index 000000000..32d70db34 --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflow.java @@ -0,0 +1,38 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * A chat workflow that maintains a conversation with an AI model. + * + *
The workflow runs until explicitly ended via the {@link #end()} signal. Messages can be sent + * via the {@link #chat(String)} update method, which returns the AI's response synchronously. + */ +@WorkflowInterface +public interface ChatWorkflow { + + /** + * Starts the chat workflow and waits until ended. + * + * @param systemPrompt the system prompt that defines the AI's behavior + * @return a summary when the chat ends + */ + @WorkflowMethod + String run(String systemPrompt); + + /** + * Sends a message to the AI and returns its response. + * + * @param message the user's message + * @return the AI's response + */ + @UpdateMethod + String chat(String message); + + /** Ends the chat session. */ + @SignalMethod + void end(); +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java new file mode 100644 index 000000000..af995ad4e --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/ChatWorkflowImpl.java @@ -0,0 +1,115 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.springai.activity.ChatModelActivity; +import io.temporal.springai.chat.TemporalChatClient; +import io.temporal.springai.model.ActivityChatModel; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import java.time.Duration; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; + +/** + * Implementation of the chat workflow using Spring AI's ChatClient with Temporal tools. + * + *
This demonstrates how to use the Spring AI plugin within a Temporal workflow: + * + *
The AI model can call: + * + *
These tools execute directly in workflow context. Since they are pure functions (same output + * for same input, no side effects), they are safe for Temporal replay without any wrapping. + */ +public class StringTools { + + @Tool(description = "Reverse a string, returning the characters in opposite order") + public String reverse(@ToolParam(description = "The string to reverse") String input) { + if (input == null) { + return null; + } + return new StringBuilder(input).reverse().toString(); + } + + @Tool(description = "Count the number of words in a text") + public int countWords(@ToolParam(description = "The text to count words in") String text) { + if (text == null || text.isBlank()) { + return 0; + } + return text.trim().split("\\s+").length; + } + + @Tool(description = "Convert text to all uppercase letters") + public String toUpperCase(@ToolParam(description = "The text to convert") String text) { + if (text == null) { + return null; + } + return text.toUpperCase(java.util.Locale.ROOT); + } + + @Tool(description = "Convert text to all lowercase letters") + public String toLowerCase(@ToolParam(description = "The text to convert") String text) { + if (text == null) { + return null; + } + return text.toLowerCase(java.util.Locale.ROOT); + } + + @Tool(description = "Check if a string is a palindrome (reads the same forwards and backwards)") + public boolean isPalindrome(@ToolParam(description = "The text to check") String text) { + if (text == null) { + return false; + } + String normalized = text.toLowerCase(java.util.Locale.ROOT).replaceAll("\\s+", ""); + String reversed = new StringBuilder(normalized).reverse().toString(); + return normalized.equals(reversed); + } +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java b/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java new file mode 100644 index 000000000..db9415888 --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/TimestampTools.java @@ -0,0 +1,100 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.springai.tool.SideEffectTool; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Side-effect tools that return non-deterministic values. + * + *
This class demonstrates the use of {@link SideEffectTool} annotation for tools that are + * non-deterministic but don't need the full durability of an activity. + * + *
Side-effect tools are wrapped in {@code Workflow.sideEffect()}, which: + * + *
Use {@code @SideEffectTool} for: + * + *
Example usage: + * + *
{@code
+ * TimestampTools timestampTools = new TimestampTools();
+ * this.chatClient = TemporalChatClient.builder(activityChatModel)
+ * .defaultTools(timestampTools) // Wrapped in sideEffect()
+ * .build();
+ * }
+ */
+@SideEffectTool
+public class TimestampTools {
+
+ private static final DateTimeFormatter FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault());
+
+ /**
+ * Gets the current date and time.
+ *
+ * This is non-deterministic (returns different values each time), but wrapped in sideEffect() + * it becomes safe for workflow replay. + * + * @return the current date and time as a formatted string + */ + @Tool(description = "Get the current date and time") + public String getCurrentDateTime() { + return FORMATTER.format(Instant.now()); + } + + /** + * Gets the current Unix timestamp in milliseconds. + * + * @return the current time in milliseconds since epoch + */ + @Tool(description = "Get the current Unix timestamp in milliseconds") + public long getCurrentTimestamp() { + return System.currentTimeMillis(); + } + + /** + * Generates a random UUID. + * + * @return a new random UUID string + */ + @Tool(description = "Generate a random UUID") + public String generateUuid() { + return UUID.randomUUID().toString(); + } + + /** + * Gets the current date and time in a specific timezone. + * + * @param timezone the timezone ID (e.g., "America/New_York", "UTC", "Europe/London") + * @return the current date and time in the specified timezone + */ + @Tool(description = "Get the current date and time in a specific timezone") + public String getDateTimeInTimezone( + @ToolParam(description = "Timezone ID (e.g., 'America/New_York', 'UTC', 'Europe/London')") + String timezone) { + try { + ZoneId zoneId = ZoneId.of(timezone); + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(zoneId); + return formatter.format(Instant.now()); + } catch (Exception e) { + return "Invalid timezone: " + timezone + ". Use formats like 'America/New_York' or 'UTC'."; + } + } +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java new file mode 100644 index 000000000..3d098564b --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivity.java @@ -0,0 +1,49 @@ +package io.temporal.samples.springai.chat; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Activity interface for weather-related operations. + * + *
This demonstrates how to combine Temporal's {@link ActivityInterface} with Spring AI's {@link + * Tool} annotation to create activity-based AI tools. + * + *
When passed to {@code TemporalChatClient.builder().defaultTools(weatherActivity)}, the AI + * model can call these methods, and they will execute as durable Temporal activities with automatic + * retries and timeout handling. + */ +@ActivityInterface +public interface WeatherActivity { + + /** + * Gets the current weather for a city. + * + *
The {@code @Tool} annotation makes this method available to the AI model, while the + * {@code @ActivityInterface} ensures it executes as a Temporal activity. + * + * @param city the name of the city + * @return a description of the current weather + */ + @Tool( + description = + "Get the current weather for a city. Returns temperature, conditions, and humidity.") + @ActivityMethod + String getWeather( + @ToolParam(description = "The name of the city (e.g., 'Seattle', 'New York')") String city); + + /** + * Gets the weather forecast for a city. + * + * @param city the name of the city + * @param days the number of days to forecast (1-7) + * @return the weather forecast + */ + @Tool(description = "Get the weather forecast for a city for the specified number of days.") + @ActivityMethod + String getForecast( + @ToolParam(description = "The name of the city") String city, + @ToolParam(description = "Number of days to forecast (1-7)") int days); +} diff --git a/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java new file mode 100644 index 000000000..03b01ee7a --- /dev/null +++ b/springai/src/main/java/io/temporal/samples/springai/chat/WeatherActivityImpl.java @@ -0,0 +1,64 @@ +package io.temporal.samples.springai.chat; + +import java.util.Map; +import java.util.Random; +import org.springframework.stereotype.Component; + +/** + * Implementation of {@link WeatherActivity}. + * + *
This is a mock implementation that returns simulated weather data. In a real application, this + * would call an external weather API. + * + *
Note: This class is registered as a Spring {@code @Component} so it can be auto-discovered.
+ * The {@code SpringAiPlugin} will register it with Temporal workers.
+ */
+@Component
+public class WeatherActivityImpl implements WeatherActivity {
+
+ // Mock weather data for demo purposes
+ private static final Map