diff --git a/spring-ai/README.md b/spring-ai/README.md new file mode 100644 index 000000000..3212e67a9 --- /dev/null +++ b/spring-ai/README.md @@ -0,0 +1,143 @@ +# Spring AI Application + +A Spring Boot application powered by **Spring AI** that provides AI-driven capabilities including chat completion, text summarization, translation, code analysis, and image generation via OpenAI. + +## Features + +- **Chat Completion** - Conversational AI with streaming support +- **Text Summarization** - Condense long text into concise summaries +- **Translation** - Translate text between 10+ languages +- **Code Analysis** - Analyze code for descriptions, issues, and complexity +- **Image Generation** - Generate images from text prompts using DALL-E 3 +- **Web UI** - Clean, tabbed browser interface for all features + +## Tech Stack + +- Java 17 +- Spring Boot 3.4.1 +- Spring AI 1.0.0-M5 (OpenAI) +- Thymeleaf (Web UI) +- Maven + +## Prerequisites + +- Java 17+ +- Maven 3.6+ +- OpenAI API key + +## Getting Started + +### 1. Navigate to the spring-ai directory + +```bash +cd spring-ai +``` + +### 2. Set your OpenAI API key + +```bash +export SPRING_AI_OPENAI_API_KEY=your-api-key-here +``` + +### 3. Build and run + +```bash +mvn clean install +mvn spring-boot:run +``` + +### 4. Access the application + +Open [http://localhost:8080](http://localhost:8080) in your browser. + +## API Endpoints + +### Chat + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/chat?message=...` | Simple chat | +| `POST` | `/api/chat` | Chat with options (model, temperature) | +| `GET` | `/api/chat/stream?message=...` | Streaming chat (SSE) | +| `POST` | `/api/chat/summarize` | Summarize text | +| `POST` | `/api/chat/translate` | Translate text | +| `POST` | `/api/chat/analyze-code` | Analyze code | + +### Image + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/image/generate` | Generate image from prompt | + +### Example Requests + +**Chat:** +```bash +curl http://localhost:8080/api/chat?message=Hello + +curl -X POST http://localhost:8080/api/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "Explain Spring AI", "model": "gpt-4o-mini", "temperature": 0.7}' +``` + +**Summarize:** +```bash +curl -X POST http://localhost:8080/api/chat/summarize \ + -H "Content-Type: application/json" \ + -d '{"text": "Long text to summarize..."}' +``` + +**Translate:** +```bash +curl -X POST http://localhost:8080/api/chat/translate \ + -H "Content-Type: application/json" \ + -d '{"text": "Hello world", "targetLanguage": "Spanish"}' +``` + +**Image Generation:** +```bash +curl -X POST http://localhost:8080/api/image/generate \ + -H "Content-Type: application/json" \ + -d '{"prompt": "A sunset over mountains", "width": 1024, "height": 1024}' +``` + +## Configuration + +Key properties in `application.properties`: + +| Property | Default | Description | +|----------|---------|-------------| +| `spring.ai.openai.api-key` | - | Your OpenAI API key | +| `spring.ai.openai.chat.options.model` | `gpt-4o-mini` | Chat model | +| `spring.ai.openai.chat.options.temperature` | `0.7` | Response creativity | +| `spring.ai.openai.image.options.model` | `dall-e-3` | Image model | +| `server.port` | `8080` | Server port | + +## Project Structure + +``` +spring-ai/ + src/main/java/com/example/springai/ + ├── SpringAiApplication.java # Main application entry point + ├── config/ + │ └── AiConfig.java # CORS and web configuration + ├── controller/ + │ ├── ChatController.java # Chat REST API endpoints + │ ├── ImageController.java # Image generation endpoint + │ └── WebController.java # Web UI controller + ├── model/ + │ ├── ChatRequest.java # Chat request DTO + │ ├── ChatResponse.java # Chat response DTO + │ ├── ImageRequest.java # Image request DTO + │ └── ImageResponse.java # Image response DTO + └── service/ + ├── ChatService.java # Chat/AI business logic + └── ImageService.java # Image generation logic +``` + +## Running Tests + +```bash +cd spring-ai +mvn test +``` diff --git a/spring-ai/pom.xml b/spring-ai/pom.xml new file mode 100644 index 000000000..937599beb --- /dev/null +++ b/spring-ai/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.1 + + + + com.example + spring-ai-app + 0.0.1-SNAPSHOT + Spring AI Application + A Spring Boot application with Spring AI integration for chat, image generation, and embeddings + + + 17 + 1.0.0-M5 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + ${spring-ai.version} + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/spring-ai/src/main/java/com/example/springai/SpringAiApplication.java b/spring-ai/src/main/java/com/example/springai/SpringAiApplication.java new file mode 100644 index 000000000..c2116fb41 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/SpringAiApplication.java @@ -0,0 +1,12 @@ +package com.example.springai; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringAiApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringAiApplication.class, args); + } +} diff --git a/spring-ai/src/main/java/com/example/springai/config/AiConfig.java b/spring-ai/src/main/java/com/example/springai/config/AiConfig.java new file mode 100644 index 000000000..5d7ab7398 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/config/AiConfig.java @@ -0,0 +1,17 @@ +package com.example.springai.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class AiConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } +} diff --git a/spring-ai/src/main/java/com/example/springai/controller/ChatController.java b/spring-ai/src/main/java/com/example/springai/controller/ChatController.java new file mode 100644 index 000000000..5fe564b4d --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/controller/ChatController.java @@ -0,0 +1,61 @@ +package com.example.springai.controller; + +import com.example.springai.model.ChatRequest; +import com.example.springai.model.ChatResponse; +import com.example.springai.service.ChatService; +import jakarta.validation.Valid; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +@RestController +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatService chatService; + + public ChatController(ChatService chatService) { + this.chatService = chatService; + } + + @GetMapping + public ChatResponse chat(@RequestParam String message) { + return chatService.chat(message); + } + + @PostMapping + public ChatResponse chatPost(@Valid @RequestBody ChatRequest request) { + return chatService.chatWithOptions( + request.getMessage(), request.getModel(), request.getTemperature()); + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux chatStream(@RequestParam String message) { + return chatService.chatStream(message); + } + + @PostMapping("/summarize") + public ChatResponse summarize(@RequestBody Map request) { + String text = request.get("text"); + return chatService.summarize(text); + } + + @PostMapping("/translate") + public ChatResponse translate(@RequestBody Map request) { + String text = request.get("text"); + String targetLanguage = request.getOrDefault("targetLanguage", "English"); + return chatService.translate(text, targetLanguage); + } + + @PostMapping("/analyze-code") + public ChatResponse analyzeCode(@RequestBody Map request) { + String code = request.get("code"); + return chatService.analyzeCode(code); + } +} diff --git a/spring-ai/src/main/java/com/example/springai/controller/ImageController.java b/spring-ai/src/main/java/com/example/springai/controller/ImageController.java new file mode 100644 index 000000000..821843159 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/controller/ImageController.java @@ -0,0 +1,27 @@ +package com.example.springai.controller; + +import com.example.springai.model.ImageRequest; +import com.example.springai.model.ImageResponse; +import com.example.springai.service.ImageService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/image") +public class ImageController { + + private final ImageService imageService; + + public ImageController(ImageService imageService) { + this.imageService = imageService; + } + + @PostMapping("/generate") + public ImageResponse generateImage(@Valid @RequestBody ImageRequest request) { + return imageService.generateImage( + request.getPrompt(), request.getWidth(), request.getHeight()); + } +} diff --git a/spring-ai/src/main/java/com/example/springai/controller/WebController.java b/spring-ai/src/main/java/com/example/springai/controller/WebController.java new file mode 100644 index 000000000..46cf40986 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/controller/WebController.java @@ -0,0 +1,13 @@ +package com.example.springai.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class WebController { + + @GetMapping("/") + public String index() { + return "index"; + } +} diff --git a/spring-ai/src/main/java/com/example/springai/model/ChatRequest.java b/spring-ai/src/main/java/com/example/springai/model/ChatRequest.java new file mode 100644 index 000000000..625e2f750 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/model/ChatRequest.java @@ -0,0 +1,43 @@ +package com.example.springai.model; + +import jakarta.validation.constraints.NotBlank; + +public class ChatRequest { + + @NotBlank(message = "Message must not be blank") + private String message; + + private String model; + + private Double temperature; + + public ChatRequest() {} + + public ChatRequest(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } +} diff --git a/spring-ai/src/main/java/com/example/springai/model/ChatResponse.java b/spring-ai/src/main/java/com/example/springai/model/ChatResponse.java new file mode 100644 index 000000000..812016e80 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/model/ChatResponse.java @@ -0,0 +1,42 @@ +package com.example.springai.model; + +import java.time.Instant; + +public class ChatResponse { + + private String response; + private String model; + private Instant timestamp; + + public ChatResponse() {} + + public ChatResponse(String response, String model) { + this.response = response; + this.model = model; + this.timestamp = Instant.now(); + } + + public String getResponse() { + return response; + } + + public void setResponse(String response) { + this.response = response; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } +} diff --git a/spring-ai/src/main/java/com/example/springai/model/ImageRequest.java b/spring-ai/src/main/java/com/example/springai/model/ImageRequest.java new file mode 100644 index 000000000..32daa8c3d --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/model/ImageRequest.java @@ -0,0 +1,43 @@ +package com.example.springai.model; + +import jakarta.validation.constraints.NotBlank; + +public class ImageRequest { + + @NotBlank(message = "Prompt must not be blank") + private String prompt; + + private Integer width; + + private Integer height; + + public ImageRequest() {} + + public ImageRequest(String prompt) { + this.prompt = prompt; + } + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public Integer getWidth() { + return width; + } + + public void setWidth(Integer width) { + this.width = width; + } + + public Integer getHeight() { + return height; + } + + public void setHeight(Integer height) { + this.height = height; + } +} diff --git a/spring-ai/src/main/java/com/example/springai/model/ImageResponse.java b/spring-ai/src/main/java/com/example/springai/model/ImageResponse.java new file mode 100644 index 000000000..51ee25fc8 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/model/ImageResponse.java @@ -0,0 +1,42 @@ +package com.example.springai.model; + +import java.time.Instant; + +public class ImageResponse { + + private String url; + private String revisedPrompt; + private Instant timestamp; + + public ImageResponse() {} + + public ImageResponse(String url, String revisedPrompt) { + this.url = url; + this.revisedPrompt = revisedPrompt; + this.timestamp = Instant.now(); + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getRevisedPrompt() { + return revisedPrompt; + } + + public void setRevisedPrompt(String revisedPrompt) { + this.revisedPrompt = revisedPrompt; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } +} diff --git a/spring-ai/src/main/java/com/example/springai/service/ChatService.java b/spring-ai/src/main/java/com/example/springai/service/ChatService.java new file mode 100644 index 000000000..91e0011b3 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/service/ChatService.java @@ -0,0 +1,93 @@ +package com.example.springai.service; + +import com.example.springai.model.ChatResponse; +import java.util.List; +import java.util.Map; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +@Service +public class ChatService { + + private final ChatClient chatClient; + private final ChatModel chatModel; + + @Value("${spring.ai.openai.chat.options.model:gpt-4o-mini}") + private String defaultModel; + + public ChatService(ChatClient.Builder chatClientBuilder, ChatModel chatModel) { + this.chatClient = chatClientBuilder.build(); + this.chatModel = chatModel; + } + + public ChatResponse chat(String userMessage) { + String response = chatClient.prompt().user(userMessage).call().content(); + return new ChatResponse(response, defaultModel); + } + + public ChatResponse chatWithOptions(String userMessage, String model, Double temperature) { + String modelToUse = (model != null && !model.isBlank()) ? model : defaultModel; + double tempToUse = (temperature != null) ? temperature : 0.7; + + OpenAiChatOptions options = + OpenAiChatOptions.builder() + .model(modelToUse) + .temperature(tempToUse) + .build(); + + Prompt prompt = new Prompt(userMessage, options); + String response = chatModel.call(prompt).getResult().getOutput().getText(); + return new ChatResponse(response, modelToUse); + } + + public ChatResponse chatWithSystemPrompt( + String systemPrompt, String userMessage, String model) { + String modelToUse = (model != null && !model.isBlank()) ? model : defaultModel; + + List messages = + List.of(new SystemMessage(systemPrompt), new UserMessage(userMessage)); + + OpenAiChatOptions options = OpenAiChatOptions.builder().model(modelToUse).build(); + + Prompt prompt = new Prompt(messages, options); + String response = chatModel.call(prompt).getResult().getOutput().getText(); + return new ChatResponse(response, modelToUse); + } + + public Flux chatStream(String userMessage) { + return chatClient.prompt().user(userMessage).stream().content(); + } + + public ChatResponse summarize(String text) { + String systemPrompt = + "You are a summarization assistant. Provide a concise summary of the given text."; + return chatWithSystemPrompt(systemPrompt, text, null); + } + + public ChatResponse translate(String text, String targetLanguage) { + String systemPrompt = + String.format( + "You are a translation assistant. Translate the given text to %s." + + " Only return the translation, nothing else.", + targetLanguage); + return chatWithSystemPrompt(systemPrompt, text, null); + } + + public ChatResponse analyzeCode(String code) { + String systemPrompt = + "You are a code analysis assistant. Analyze the given code and provide:" + + " 1) A brief description of what it does," + + " 2) Any potential issues or improvements," + + " 3) Time and space complexity if applicable."; + return chatWithSystemPrompt(systemPrompt, code, null); + } +} diff --git a/spring-ai/src/main/java/com/example/springai/service/ImageService.java b/spring-ai/src/main/java/com/example/springai/service/ImageService.java new file mode 100644 index 000000000..6f524e358 --- /dev/null +++ b/spring-ai/src/main/java/com/example/springai/service/ImageService.java @@ -0,0 +1,40 @@ +package com.example.springai.service; + +import com.example.springai.model.ImageResponse; +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.stereotype.Service; + +@Service +public class ImageService { + + private final ImageModel imageModel; + + public ImageService(ImageModel imageModel) { + this.imageModel = imageModel; + } + + public ImageResponse generateImage(String prompt, Integer width, Integer height) { + int w = (width != null) ? width : 1024; + int h = (height != null) ? height : 1024; + + OpenAiImageOptions options = + OpenAiImageOptions.builder() + .model("dall-e-3") + .quality("standard") + .width(w) + .height(h) + .N(1) + .build(); + + ImagePrompt imagePrompt = new ImagePrompt(prompt, options); + ImageGeneration result = imageModel.call(imagePrompt).getResult(); + + String url = result.getOutput().getUrl(); + String revisedPrompt = result.getOutput().toString(); + + return new ImageResponse(url, revisedPrompt); + } +} diff --git a/spring-ai/src/main/resources/application.properties b/spring-ai/src/main/resources/application.properties new file mode 100644 index 000000000..7c0b302ba --- /dev/null +++ b/spring-ai/src/main/resources/application.properties @@ -0,0 +1,17 @@ +# Spring AI Application Configuration +spring.application.name=spring-ai-app +server.port=8080 + +# OpenAI Configuration +# Set your API key via environment variable: SPRING_AI_OPENAI_API_KEY +spring.ai.openai.api-key=${SPRING_AI_OPENAI_API_KEY:your-api-key-here} +spring.ai.openai.chat.options.model=gpt-4o-mini +spring.ai.openai.chat.options.temperature=0.7 +spring.ai.openai.image.options.model=dall-e-3 + +# Actuator +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=when-authorized + +# Logging +logging.level.org.springframework.ai=DEBUG diff --git a/spring-ai/src/main/resources/static/css/style.css b/spring-ai/src/main/resources/static/css/style.css new file mode 100644 index 000000000..61f229705 --- /dev/null +++ b/spring-ai/src/main/resources/static/css/style.css @@ -0,0 +1,194 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + color: #333; + min-height: 100vh; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 24px; +} + +header h1 { + font-size: 2rem; + color: #1a73e8; +} + +.subtitle { + color: #666; + margin-top: 4px; +} + +.tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; + border-bottom: 2px solid #e0e0e0; + padding-bottom: 0; +} + +.tab { + padding: 10px 20px; + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: #666; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; +} + +.tab:hover { + color: #1a73e8; +} + +.tab.active { + color: #1a73e8; + border-bottom-color: #1a73e8; + font-weight: 600; +} + +.tab-content { + display: none; + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.tab-content.active { + display: block; +} + +.messages { + min-height: 300px; + max-height: 500px; + overflow-y: auto; + padding: 16px; + background: #fafafa; + border-radius: 8px; + margin-bottom: 16px; +} + +.message { + margin-bottom: 12px; + padding: 10px 14px; + border-radius: 8px; + max-width: 80%; + line-height: 1.5; + white-space: pre-wrap; +} + +.message.user { + background: #1a73e8; + color: #fff; + margin-left: auto; +} + +.message.assistant { + background: #e8e8e8; + color: #333; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +textarea { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: #1a73e8; +} + +.code-area { + font-family: 'Courier New', monospace; + font-size: 13px; + background: #1e1e1e; + color: #d4d4d4; +} + +.controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.controls label { + font-size: 14px; + color: #666; + cursor: pointer; +} + +.btn-primary { + padding: 10px 24px; + background: #1a73e8; + color: #fff; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover { + background: #1557b0; +} + +.btn-primary:disabled { + background: #ccc; + cursor: not-allowed; +} + +select { + padding: 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + margin: 8px 0; +} + +.result { + margin-top: 16px; + padding: 16px; + background: #fafafa; + border-radius: 8px; + line-height: 1.6; + white-space: pre-wrap; + min-height: 60px; +} + +.loading { + color: #999; + font-style: italic; +} + +.error { + color: #d93025; + font-weight: 500; +} diff --git a/spring-ai/src/main/resources/static/js/app.js b/spring-ai/src/main/resources/static/js/app.js new file mode 100644 index 000000000..9073397fc --- /dev/null +++ b/spring-ai/src/main/resources/static/js/app.js @@ -0,0 +1,180 @@ +document.addEventListener('DOMContentLoaded', () => { + // Tab switching + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(tab.dataset.tab).classList.add('active'); + }); + }); + + // Chat + const chatMessages = document.getElementById('chat-messages'); + const chatInput = document.getElementById('chat-input'); + const chatSend = document.getElementById('chat-send'); + const streamToggle = document.getElementById('stream-toggle'); + + function addMessage(text, role) { + const div = document.createElement('div'); + div.className = `message ${role}`; + div.textContent = text; + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; + return div; + } + + chatSend.addEventListener('click', async () => { + const message = chatInput.value.trim(); + if (!message) return; + + addMessage(message, 'user'); + chatInput.value = ''; + chatSend.disabled = true; + + if (streamToggle.checked) { + const assistantDiv = addMessage('', 'assistant'); + try { + const response = await fetch(`/api/chat/stream?message=${encodeURIComponent(message)}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let content = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + for (const line of lines) { + if (line.startsWith('data:')) { + content += line.substring(5); + assistantDiv.textContent = content; + chatMessages.scrollTop = chatMessages.scrollHeight; + } + } + } + } catch (err) { + assistantDiv.textContent = 'Error: ' + err.message; + assistantDiv.classList.add('error'); + } + } else { + const loadingDiv = addMessage('Thinking...', 'assistant'); + loadingDiv.classList.add('loading'); + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }); + const data = await response.json(); + loadingDiv.textContent = data.response; + loadingDiv.classList.remove('loading'); + } catch (err) { + loadingDiv.textContent = 'Error: ' + err.message; + loadingDiv.classList.remove('loading'); + loadingDiv.classList.add('error'); + } + } + chatSend.disabled = false; + }); + + chatInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + chatSend.click(); + } + }); + + // Summarize + document.getElementById('summarize-send').addEventListener('click', async () => { + const text = document.getElementById('summarize-input').value.trim(); + if (!text) return; + const result = document.getElementById('summarize-result'); + result.textContent = 'Summarizing...'; + result.className = 'result loading'; + try { + const response = await fetch('/api/chat/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + const data = await response.json(); + result.textContent = data.response; + result.className = 'result'; + } catch (err) { + result.textContent = 'Error: ' + err.message; + result.className = 'result error'; + } + }); + + // Translate + document.getElementById('translate-send').addEventListener('click', async () => { + const text = document.getElementById('translate-input').value.trim(); + const targetLanguage = document.getElementById('translate-lang').value; + if (!text) return; + const result = document.getElementById('translate-result'); + result.textContent = 'Translating...'; + result.className = 'result loading'; + try { + const response = await fetch('/api/chat/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, targetLanguage }) + }); + const data = await response.json(); + result.textContent = data.response; + result.className = 'result'; + } catch (err) { + result.textContent = 'Error: ' + err.message; + result.className = 'result error'; + } + }); + + // Code Analysis + document.getElementById('code-send').addEventListener('click', async () => { + const code = document.getElementById('code-input').value.trim(); + if (!code) return; + const result = document.getElementById('code-result'); + result.textContent = 'Analyzing...'; + result.className = 'result loading'; + try { + const response = await fetch('/api/chat/analyze-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }) + }); + const data = await response.json(); + result.textContent = data.response; + result.className = 'result'; + } catch (err) { + result.textContent = 'Error: ' + err.message; + result.className = 'result error'; + } + }); + + // Image Generation + document.getElementById('image-send').addEventListener('click', async () => { + const prompt = document.getElementById('image-input').value.trim(); + if (!prompt) return; + const result = document.getElementById('image-result'); + const img = document.getElementById('generated-image'); + img.style.display = 'none'; + result.textContent = 'Generating image...'; + result.className = 'result loading'; + try { + const response = await fetch('/api/image/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }) + }); + const data = await response.json(); + result.textContent = ''; + result.className = 'result'; + img.src = data.url; + img.style.display = 'block'; + } catch (err) { + result.textContent = 'Error: ' + err.message; + result.className = 'result error'; + } + }); +}); diff --git a/spring-ai/src/main/resources/templates/index.html b/spring-ai/src/main/resources/templates/index.html new file mode 100644 index 000000000..a64053b49 --- /dev/null +++ b/spring-ai/src/main/resources/templates/index.html @@ -0,0 +1,83 @@ + + + + + + Spring AI Chat + + + +
+
+

Spring AI Chat

+

Powered by Spring AI & OpenAI

+
+ + + + +
+
+
+ +
+ + +
+
+
+ + +
+ + +
+
+ + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ + +
+ +
+
+
+ + + + diff --git a/spring-ai/src/test/java/com/example/springai/SpringAiApplicationTests.java b/spring-ai/src/test/java/com/example/springai/SpringAiApplicationTests.java new file mode 100644 index 000000000..a7d8f14d8 --- /dev/null +++ b/spring-ai/src/test/java/com/example/springai/SpringAiApplicationTests.java @@ -0,0 +1,17 @@ +package com.example.springai; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource( + properties = { + "spring.ai.openai.api-key=test-key", + "spring.ai.openai.chat.options.model=gpt-4o-mini" + }) +class SpringAiApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/spring-ai/src/test/java/com/example/springai/controller/ChatControllerTest.java b/spring-ai/src/test/java/com/example/springai/controller/ChatControllerTest.java new file mode 100644 index 000000000..2507ffe93 --- /dev/null +++ b/spring-ai/src/test/java/com/example/springai/controller/ChatControllerTest.java @@ -0,0 +1,99 @@ +package com.example.springai.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.springai.model.ChatResponse; +import com.example.springai.service.ChatService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChatController.class) +class ChatControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private ChatService chatService; + + @Test + void chatGet_shouldReturnResponse() throws Exception { + when(chatService.chat("Hello")).thenReturn(new ChatResponse("Hi there!", "gpt-4o-mini")); + + mockMvc.perform(get("/api/chat").param("message", "Hello")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.response").value("Hi there!")) + .andExpect(jsonPath("$.model").value("gpt-4o-mini")); + } + + @Test + void chatPost_shouldReturnResponse() throws Exception { + when(chatService.chatWithOptions(eq("Hello"), any(), any())) + .thenReturn(new ChatResponse("Hi there!", "gpt-4o-mini")); + + mockMvc.perform( + post("/api/chat") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\": \"Hello\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.response").value("Hi there!")); + } + + @Test + void chatPost_withBlankMessage_shouldReturnBadRequest() throws Exception { + mockMvc.perform( + post("/api/chat") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\": \"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void summarize_shouldReturnSummary() throws Exception { + when(chatService.summarize(any())) + .thenReturn(new ChatResponse("Short summary.", "gpt-4o-mini")); + + mockMvc.perform( + post("/api/chat/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"text\": \"Long text to summarize\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.response").value("Short summary.")); + } + + @Test + void translate_shouldReturnTranslation() throws Exception { + when(chatService.translate(any(), eq("Spanish"))) + .thenReturn(new ChatResponse("Hola", "gpt-4o-mini")); + + mockMvc.perform( + post("/api/chat/translate") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"text\": \"Hello\", \"targetLanguage\":" + + " \"Spanish\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.response").value("Hola")); + } + + @Test + void analyzeCode_shouldReturnAnalysis() throws Exception { + when(chatService.analyzeCode(any())) + .thenReturn(new ChatResponse("This is a simple function.", "gpt-4o-mini")); + + mockMvc.perform( + post("/api/chat/analyze-code") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\": \"int x = 1;\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.response").value("This is a simple function.")); + } +} diff --git a/spring-ai/src/test/java/com/example/springai/controller/ImageControllerTest.java b/spring-ai/src/test/java/com/example/springai/controller/ImageControllerTest.java new file mode 100644 index 000000000..1d13d1959 --- /dev/null +++ b/spring-ai/src/test/java/com/example/springai/controller/ImageControllerTest.java @@ -0,0 +1,46 @@ +package com.example.springai.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.springai.model.ImageResponse; +import com.example.springai.service.ImageService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ImageController.class) +class ImageControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private ImageService imageService; + + @Test + void generateImage_shouldReturnImageUrl() throws Exception { + when(imageService.generateImage(any(), any(), any())) + .thenReturn(new ImageResponse("https://example.com/image.png", "A cat")); + + mockMvc.perform( + post("/api/image/generate") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"prompt\": \"A cute cat\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").value("https://example.com/image.png")); + } + + @Test + void generateImage_withBlankPrompt_shouldReturnBadRequest() throws Exception { + mockMvc.perform( + post("/api/image/generate") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"prompt\": \"\"}")) + .andExpect(status().isBadRequest()); + } +}