diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..667aaef0c891a18c6177b09b53418bf59c6ab91f --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..40be386b981d8564ef04539edebb44dc6a389d39 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +include: + - project: osl/code/org.etsi.osl.main + ref: main + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + + - project: osl/code/org.etsi.osl.main + ref: develop + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_NAME == "develop"' + + - project: osl/code/org.etsi.osl.main + ref: $CI_COMMIT_REF_NAME + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_PROTECTED == "true" && $CI_COMMIT_REF_NAME != "main" && $CI_COMMIT_REF_NAME != "develop"' + + - project: osl/code/org.etsi.osl.main + ref: develop + file: + - ci-templates/default.yml + - ci-templates/build_unprotected.yml + rules: + - if: '$CI_COMMIT_REF_NAME != "main" && $CI_COMMIT_REF_NAME != "develop" && $CI_COMMIT_REF_PROTECTED == "false"' + +maven_build: + extends: .maven_build + +docker_build: + extends: .docker_build + needs: + - maven_build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c2979144a435dfd9333930dfe0de14b3062a7e65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM eclipse-temurin:17-jre-alpine +MAINTAINER osl.etsi.org + + +# Create application directory +RUN mkdir -p /opt/openslice/lib/ + +# Copy the built JAR from a previous pipeline +COPY /target/org.etsi.osl.mcp.backend-1.0.0.jar /opt/openslice/lib +# Expose port +EXPOSE 11880 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:11880/actuator/health || exit 1 + +# Run application +CMD ["java", "-jar", "/opt/openslice/lib/org.etsi.osl.mcp.backend-1.0.0.jar"] diff --git a/README.md b/README.md index 9aeced09a3b59764de2f6bdf42c7c1163938a585..3166e9a00fd3f5eb97419d768936c069b381e171 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,204 @@ -# org.etsi.org.mcp.backend +# org.etsi.osl.mcp.backend +A Spring Boot backend service that integrates Large Language Models (LLMs) with the Model Context Protocol (MCP) for the OpenSlice platform. It provides REST and message-driven interfaces for AI-powered question answering with access to external tools and context via MCP servers. +## Features -## Getting started +- **REST API** - `/ask` endpoint for synchronous question processing +- **Message-Driven** - ActiveMQ queue listener for asynchronous question submission +- **MCP Integration** - Connects to MCP servers (e.g., OpenSlice) to provide tools and context to LLMs +- **OAuth2 Authentication** - Keycloak-based security for MCP server access +- **HTML Response Formatting** - Markdown to HTML conversion for web compatibility +- **Local LLM Support** - Ollama integration for running open-source models locally -To make it easy for you to get started with GitLab, here's a list of recommended next steps. +## Requirements -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! +- Java 17+ +- Maven 3.9+ +- Ollama (for local LLM inference, default: `http://localhost:11434`) +- ActiveMQ broker (default: `tcp://localhost:61616`) +- Keycloak (for OAuth2, default: `http://keycloak:8080/auth/realms/openslice`) +- MCP Server (e.g., OpenSlice at `http://localhost:13015`) -## Add your files +## Quick Start -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: +### Build +```bash +mvn clean package +``` +### Run Locally +```bash +mvn spring-boot:run ``` -cd existing_repo -git remote add origin https://labs.etsi.org/rep/osl/code/org.etsi.org.mcp.backend.git -git branch -M main -git push -uf origin main + +The application starts on port **11880**. + +### Docker +```bash +docker build -t org.etsi.osl.mcp.backend:latest . +docker run -p 11880:11880 org.etsi.osl.mcp.backend:latest ``` -## Integrate with your tools +### Docker Compose (Complete Stack) +The easiest way to run the entire application stack with all dependencies: -- [ ] [Set up project integrations](https://labs.etsi.org/rep/osl/code/org.etsi.org.mcp.backend/-/settings/integrations) +```bash +docker-compose up -d +``` -## Collaborate with your team +This starts: +- Ollama LLM server (http://localhost:11434) +- OSL MCP Backend (http://localhost:11880) -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) +After services are running, pull an LLM model: +```bash +docker-compose exec ollama ollama pull gpt-oss:20b +``` -## Test and Deploy +Check service status: +```bash +docker-compose ps +``` -Use the built-in continuous integration in GitLab. +View logs: +```bash +docker-compose logs -f osl-mcp-backend +``` -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) +Stop all services: +```bash +docker-compose down +``` -*** +See `docker-compose.yml` for configuration details and environment variables. -# Editing this README +## Usage -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +### REST API Example +```bash +curl -X POST http://localhost:11880/ask \ + -H "Content-Type: application/json" \ + -d '{"question": "What is the weather today?"}' +``` -## Suggestions for a good README +Response: +```json +{ + "answer": "..." +} +``` -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +### ActiveMQ Integration +Send a message to the queue `agents/osl-mcp-backend` via ActiveMQ, and the response will be processed by the Camel route. -## Name -Choose a self-explaining name for your project. +## Configuration -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +Edit `src/main/resources/application.yaml` to customize: -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +- **Server port** - Change `server.port` +- **LLM model** - Adjust `spring.ai.ollama.chat.options.model` +- **MCP server URL** - Update `spring.ai.mcp.client.streamable-http.connections.openslice-server.url` +- **OAuth2 credentials** - Configure `spring.security.oauth2.client.registration.authserver` +- **ActiveMQ broker** - Modify `spring.activemq.brokerUrl` +- **Logging level** - Adjust `logging.level` -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +## Testing -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +```bash +mvn test +``` -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +Run a single test: +```bash +mvn test -Dtest=ClassName#testMethodName +``` -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +## Architecture -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +### High-Level Design -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +The application follows a layered architecture with three main integration points: -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +1. **REST Layer** - `ChatController` provides HTTP endpoints for synchronous question processing +2. **Message Layer** - Apache Camel routes handle asynchronous message processing from ActiveMQ +3. **AI/Context Layer** - Spring AI ChatClient combines LLM capabilities with MCP tools and context -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +### Core Components + +#### ChatController +- **Endpoint**: `POST /ask` +- **Function**: Accepts questions, invokes ChatClient, converts responses to HTML +- **Dependencies**: Spring AI ChatClient, MarkdownHelper +- **Methods**: + - `ask()` - HTTP endpoint for REST clients + - `simpleAsk()` - Internal method for Camel route integration + +#### MCP Configuration +- Sets up OAuth2-secured synchronous HTTP connections to MCP servers +- Configures authentication with Keycloak for OAuth2 client credentials flow +- Manages MCP client customization with security context providers + +#### Apache Camel Routes +- **Queue Listener**: `jms:queue:agents/[application-name]` +- **Message Flow**: ActiveMQ → Camel Route → ChatController.simpleAsk() → Response +- **Purpose**: Enables external systems to submit questions asynchronously + +#### ActiveMQ Integration +- Provides connection pooling for JMS communication +- Routes incoming messages to the backend for processing +- Allows distributed systems to queue questions for batch processing + +### Data Flow + +``` +REST Client External System + | | + v v +POST /ask ActiveMQ Queue + | | + +-----------> ChatController <--------+ + | + v + Spring AI ChatClient + | + +----------+----------+ + v v + Ollama LLM MCP Server + (Language) (Tools/Context) + | | + +----------+----------+ + v + HTML Response + | + +----------+----------+ + v v + REST Response Queue Response +``` + +### Key Dependencies + +- **Spring Boot 3.5.5** - Application framework +- **Spring AI 1.1.0** - LLM and MCP client integration +- **Apache Camel 4.0.0-RC2** - Message routing and orchestration +- **Spring Security** - OAuth2 authentication +- **CommonMark** - Markdown to HTML conversion + +### External Services + +- **Ollama** - Local LLM inference server +- **Keycloak** - OAuth2 authentication and authorization +- **ActiveMQ** - Message broker for async processing +- **MCP Server** - Provides tools and context to the LLM -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. ## License -For open source projects, say how it is licensed. -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +This project is part of OpenSlice by ETSI. See LICENSE file for details. + +## References + +- [OpenSlice](https://osl.etsi.org) +- [Spring AI Documentation](https://spring.io/projects/spring-ai) +- [Model Context Protocol](https://modelcontextprotocol.io) +- [Apache Camel](https://camel.apache.org) diff --git a/ci_settings.xml b/ci_settings.xml new file mode 100644 index 0000000000000000000000000000000000000000..69ad06ed6c63795d191555afde6ea2d1da4e133d --- /dev/null +++ b/ci_settings.xml @@ -0,0 +1,16 @@ + + + + gitlab-maven + + + + Job-Token + ${CI_JOB_TOKEN} + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..fd952ac4fcbf5e6583661860b7dcb53b255eb352 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +version: '3.8' + +services: + + # Ollama LLM Server + ollama: + image: ollama/ollama:latest + container_name: osl-ollama + ports: + - "11434:11434" + environment: + OLLAMA_HOST: 0.0.0.0:11434 + volumes: + - ollama_data:/root/.ollama + # Note: You may need to pull a model after starting: + # docker exec osl-ollama ollama pull gpt-oss:20b + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 30s + timeout: 10s + retries: 3 + + # OSL MCP Backend + osl-mcp-backend: + build: + context: . + dockerfile: Dockerfile + container_name: osl-mcp-backend + restart: always + ports: + - "11880:11880" + environment: + # Server Configuration + SERVER_PORT: 11880 + SPRING_APPLICATION_NAME: osl-mcp-backend + + # AI Configuration + SPRING_AI_OLLAMA_BASE_URL: http://ollama:11434 + SPRING_AI_OLLAMA_CHAT_MODEL: gpt-oss:20b + SPRING_AI_OLLAMA_CHAT_TEMPERATURE: 0.7 + SPRING_AI_CHAT_SYSTEM_PROMPT: "You are an OpenSlice AI assistant" + + # MCP Client Configuration + SPRING_AI_MCP_CLIENT_TYPE: SYNC + SPRING_AI_MCP_CLIENT_STREAMABLE_HTTP_CONNECTIONS_OPENSLICE_SERVER_URL: http://openslice-mcp:13015/sse + + # OAuth2/Keycloak Configuration + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://keycloak:8080/auth/realms/openslice + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: http://keycloak:8080/auth/realms/openslice + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: osapiWebClientId + + # ActiveMQ Configuration + SPRING_ACTIVEMQ_BROKER_URL: tcp://anartemis:61616?jms.watchTopicAdvisories=false + SPRING_ACTIVEMQ_USER: artemis + SPRING_ACTIVEMQ_PASSWORD: artemis + + # Logging Configuration + LOGGING_LEVEL_ROOT: INFO + LOGGING_LEVEL_OSL: DEBUG + LOGGING_LEVEL_SPRING_AI: DEBUG + depends_on: + - ollama + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11880/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + ollama_data: + +networks: + default: + name: compose_back + external: true diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..625eeb5b09a1eb63ad8a33d9b0681170cc4243be --- /dev/null +++ b/pom.xml @@ -0,0 +1,186 @@ + + + + 4.0.0 + + org.etsi.osl + org.etsi.osl.main + 2025Q4 + ../org.etsi.osl.main + + + org.etsi.osl + org.etsi.osl.mcp.backend + ${org.etsi.osl.mcp.backend.version} + org.etsi.osl.mcp.backend + + + OpenSlice by ETSI + https://osl.etsi.org + + + + 3.5.5 + 17 + 1.1.0 + 4.0.0-RC2 + + + + + gitlab-maven + https://labs.etsi.org/rep/api/v4/groups/260/-/packages/maven + + + + + gitlab-maven + ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/maven + + + gitlab-maven + ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/maven + + + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot-version} + pom + import + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + org.apache.camel.springboot + camel-spring-boot-dependencies + ${camel.version} + pom + import + + + + + + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-starter-mcp-client + + + org.springframework.ai + spring-ai-starter-model-ollama + + + org.commonmark + commonmark + 0.24.0 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springaicommunity + mcp-client-security + 0.0.4 + + + + + org.springframework.boot + spring-boot-starter-activemq + + + org.apache.activemq + activemq-amqp + test + + + org.apache.qpid + proton-j + + + + + org.messaginghub + pooled-jms + + + + + org.apache.camel.springboot + camel-spring-boot-starter + + + org.apache.activemq + activemq-pool + + + org.apache.camel + camel-activemq + + + org.apache.activemq + activemq-broker + + + + + org.apache.camel.springboot + camel-service-starter + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot-version} + + + + repackage + + + + + + + + diff --git a/src/main/java/org/etsi/osl/mcp/backend/AuthController.java b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java new file mode 100644 index 0000000000000000000000000000000000000000..16a4c108b98566ffe490cf83512961d0a886ff2c --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java @@ -0,0 +1,129 @@ +package org.etsi.osl.mcp.backend; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@RestController +public class AuthController { + + @Value("${spring.security.oauth2.client.provider.keycloak.issuer-uri:}") + private String issuerUri; + + @Autowired(required = false) + private OAuth2AuthorizedClientService authorizedClientService; + + @GetMapping("/api/user") + public Map getUserInfo(Authentication authentication) { + Map userInfo = new HashMap<>(); + + if (authentication != null && authentication.isAuthenticated()) { + userInfo.put("authenticated", true); + userInfo.put("name", authentication.getName()); + + String username = authentication.getName(); + if (authentication.getPrincipal() instanceof OidcUser oidcUser) { + userInfo.put("email", oidcUser.getEmail()); + if (oidcUser.getPreferredUsername() != null) { + username = oidcUser.getPreferredUsername(); + userInfo.put("preferredUsername", oidcUser.getPreferredUsername()); + } + } + userInfo.put("username", username); + + // Try to get OAuth2AuthorizedClient if available (from OAuth2 login) + if (authorizedClientService != null && authentication.getName() != null) { + try { + OAuth2AuthorizedClient authorizedClient = + authorizedClientService.loadAuthorizedClient("keycloak", authentication.getName()); + if (authorizedClient != null && authorizedClient.getAccessToken() != null) { + userInfo.put("access_token", authorizedClient.getAccessToken().getTokenValue()); + userInfo.put("token", authorizedClient.getAccessToken().getTokenValue()); + } + } catch (Exception e) { + // OAuth2 client not available - user authenticated via JWT Bearer token + // This is normal for API requests with Bearer tokens + } + } + } else { + userInfo.put("authenticated", false); + } + + return userInfo; + } + + @PostMapping("/api/logout") + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + performLogout(request, response, authentication); + } + + @GetMapping("/api/logout") + public void logoutGet(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + performLogout(request, response, authentication); + } + + private void performLogout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + if (authentication != null) { + new SecurityContextLogoutHandler().logout(request, null, authentication); + } + + if (request.getSession(false) != null) { + request.getSession().invalidate(); + } + + String logoutUrl = buildKeycloakLogoutUrl(request, authentication); + response.sendRedirect(logoutUrl); + } + + private String buildKeycloakLogoutUrl(HttpServletRequest request, Authentication authentication) { + String keycloakIssuer = issuerUri; + + if (authentication != null && authentication.getPrincipal() instanceof OidcUser oidcUser) { + if (oidcUser.getIssuer() != null) { + keycloakIssuer = oidcUser.getIssuer().toString(); + } + } + + if (keycloakIssuer == null || keycloakIssuer.isEmpty()) { + return "/welcome.html"; + } + + String baseUrl = request.getScheme() + "://" + request.getServerName(); + if ((request.getScheme().equals("http") && request.getServerPort() != 80) || + (request.getScheme().equals("https") && request.getServerPort() != 443)) { + baseUrl += ":" + request.getServerPort(); + } + + String postLogoutRedirectUri = baseUrl + "/welcome.html"; + + String keycloakLogoutUrl = keycloakIssuer + "/protocol/openid-connect/logout"; + + return UriComponentsBuilder + .fromUriString(keycloakLogoutUrl) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build() + .toUriString(); + } + + @GetMapping("/api/health") + public Map health() { + Map response = new HashMap<>(); + response.put("status", "UP"); + return response; + } +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/ChatController.java b/src/main/java/org/etsi/osl/mcp/backend/ChatController.java new file mode 100644 index 0000000000000000000000000000000000000000..587f4e43cf5bbc4c28638782da0187bcba328dae --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/ChatController.java @@ -0,0 +1,107 @@ +package org.etsi.osl.mcp.backend; + +import java.util.Map; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.annotation.SessionScope; + +@RestController +@SessionScope +public class ChatController { + private static final Logger log = LoggerFactory.getLogger(ChatController.class); + private final ChatClient chatClient; + private final String systemPrompt; + private final String conversationId; + + @Value("${spring.ai.chat.maxMessages}") + private final int maxMessages= 100; + + ChatMemoryRepository chatMemoryRepository; + + ChatMemory chatMemory = MessageWindowChatMemory.builder() + .chatMemoryRepository(chatMemoryRepository) + .maxMessages( maxMessages ) + .build(); + + public ChatController(ChatClient.Builder chatClientBuilder, + ToolCallbackProvider tools, + @Value("${spring.ai.chat.system-prompt}") String systemPrompt, + ChatMemory chatMemory) { + // Validate that system prompt is not empty + if (systemPrompt == null || systemPrompt.trim().isEmpty()) { + throw new IllegalArgumentException("System prompt cannot be null or empty. Please configure SPRING_AI_CHAT_SYSTEM_PROMPT with a valid value."); + } + + this.systemPrompt = systemPrompt; + this.chatClient = chatClientBuilder + .defaultAdvisors(new SimpleLoggerAdvisor(), MessageChatMemoryAdvisor.builder(chatMemory).build()) + .defaultToolCallbacks(tools) + .defaultSystem(systemPrompt) + .build(); + log.info("ChatController initialized with system prompt: {}", systemPrompt); + this.conversationId = UUID.randomUUID().toString(); + } + + @PostMapping("/ask") + public Answer ask(@RequestBody Question question) { + var response = chatClient.prompt() + .user(question.question()) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .call() + .content(); + log.debug("Response from AI: {}", response); + String markdownAnswer = formatResponse(question, response); + log.debug("Markdown formatted response: {}", markdownAnswer); + // var htmlResponse = MarkdownHelper.toHTML(markdownAnswer); + // log.debug("HTML formatted response: {}", htmlResponse); + return new Answer(markdownAnswer); + } + + + public String simpleAsk( Map headers, String question) { + var response = chatClient.prompt() + .user( question ) + .call() + .content(); + + log.debug("Response from AI: {}", response); + return response; + } + + private String formatResponse(Question question, String answer) { + return answer; +// var prompt = """ +// Following are the question and answer: +// +// Question: {question} +// +// Answer: {answer} +// +// Format the answer into plain human readable text and return only the formatted response. +// """; +// return chatClient +// .prompt() +// .user(u -> u.text(prompt) +// .param("question", question.question()) +// .param("answer", answer) +// ) +// .call() +// .content(); + } + + public record Question(String question) {} + + public record Answer(String answer) {} +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/MarkdownHelper.java b/src/main/java/org/etsi/osl/mcp/backend/MarkdownHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..77afa0468d8c856191ac247a35413f0e85f9d292 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/MarkdownHelper.java @@ -0,0 +1,15 @@ +package org.etsi.osl.mcp.backend; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +public class MarkdownHelper { + private static final Parser parser = Parser.builder().build(); + private static final HtmlRenderer renderer = HtmlRenderer.builder().build(); + + public static String toHTML(String markdownText) { + Node document = parser.parse(markdownText); + return renderer.render(document); + } +} \ No newline at end of file diff --git a/src/main/java/org/etsi/osl/mcp/backend/OslMcpClientApplication.java b/src/main/java/org/etsi/osl/mcp/backend/OslMcpClientApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..b13c84977c5d1e5c46a9522690ccffe68c48f482 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/OslMcpClientApplication.java @@ -0,0 +1,12 @@ +package org.etsi.osl.mcp.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OslMcpClientApplication { + + public static void main(String[] args) { + SpringApplication.run(OslMcpClientApplication.class, args); + } +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java new file mode 100644 index 0000000000000000000000000000000000000000..51fda1a7c5cecf5b3cd748b80f5715644c493bcb --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java @@ -0,0 +1,98 @@ +package org.etsi.osl.mcp.backend; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; + +@Configuration +@EnableWebSecurity +@Profile("!testing") +public class WebSecurityConfigKeycloak { + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + // Security filter chain for JWT Bearer token only (API endpoints) + @Bean + @Order(1) + SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher("/ask/**") + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + .csrf(CsrfConfigurer::disable) + .build(); + } + + // Security filter chain for OAuth2 login (UI) + @Bean + @Order(2) + SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests(auth -> auth + // Public static resources + .requestMatchers("/", "/index.html", "/styles.css", "/robot.svg", "/favicon.ico").permitAll() + .requestMatchers("/api/health").permitAll() + // UI-only endpoints (OAuth2 login) + .requestMatchers("/api/user", "/api/logout", "/logout").permitAll() + .anyRequest().permitAll() + ) + // OAuth2 Login for browser-based authentication (UI) + .oauth2Login(oauth2 -> oauth2 + .defaultSuccessUrl("/", true) + ) + // Logout configuration + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + ) + .csrf(CsrfConfigurer::disable) + .build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); + grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + + @Bean + public JwtDecoder jwtDecoder() { + // Eagerly initialize the JWT decoder on application startup + // This fetches the JWKS from Keycloak immediately + return NimbusJwtDecoder.withJwkSetUri(issuerUri + "/protocol/openid-connect/certs").build(); + } +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/configuration/ActiveMQComponentConfig.java b/src/main/java/org/etsi/osl/mcp/backend/configuration/ActiveMQComponentConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..a0d3fcae3c608e02281c523df75ee1767d8f9ac2 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/configuration/ActiveMQComponentConfig.java @@ -0,0 +1,41 @@ +/*- + * ========================LICENSE_START================================= + * org.etsi.osl.bugzilla + * %% + * Copyright (C) 2019 openslice.io + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.etsi.osl.mcp.backend.configuration; + +import org.apache.camel.component.activemq.ActiveMQComponent; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import jakarta.jms.ConnectionFactory; + +/** + * @author ctranoris + * + */ +@Configuration +public class ActiveMQComponentConfig { + + @Bean(name = "activemq") + public ActiveMQComponent createComponent(ConnectionFactory factory) { + ActiveMQComponent activeMQComponent = new ActiveMQComponent(); + activeMQComponent.setConnectionFactory(factory); + return activeMQComponent; + } +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java b/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..87ca06c06c60485a16289fea35de1ea306103b84 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java @@ -0,0 +1,46 @@ +package org.etsi.osl.mcp.backend.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; + +/** + * @author Daniel Garnier-Moiroux + */ +@Configuration +class McpConfiguration { + + // Disabled OAuth2 customization for MCP client - not needed for JWT-based API + // The MCP server connection doesn't need OAuth2 authentication + + // @Bean + // McpSyncHttpClientRequestCustomizer requestCustomizer(OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, + // ClientRegistrationRepository clientRegistrationRepository) { + // var registrationId = findUniqueClientRegistration(clientRegistrationRepository); + // return new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(oAuth2AuthorizedClientManager, registrationId); + // } + + // @Bean + // McpSyncClientCustomizer syncClientCustomizer() { + // return (name, syncSpec) -> syncSpec.transportContextProvider(new AuthenticationMcpTransportContextProvider()); + // } + + /** + * Returns the ID of the {@code spring.security.oauth2.client.registration}, if + * unique. + */ + private static String findUniqueClientRegistration(ClientRegistrationRepository clientRegistrationRepository) { + String registrationId; + if (!(clientRegistrationRepository instanceof InMemoryClientRegistrationRepository repo)) { + throw new IllegalStateException("Expected an InMemoryClientRegistrationRepository"); + } + var iterator = repo.iterator(); + var firstRegistration = iterator.next(); + if (iterator.hasNext()) { + throw new IllegalStateException("Expected a single Client Registration"); + } + registrationId = firstRegistration.getRegistrationId(); + return registrationId; + } + +} \ No newline at end of file diff --git a/src/main/java/org/etsi/osl/mcp/backend/configuration/PartnerRouteBuillder.java b/src/main/java/org/etsi/osl/mcp/backend/configuration/PartnerRouteBuillder.java new file mode 100644 index 0000000000000000000000000000000000000000..bd76a0a6a2eaea688fdc77d1c09fa8853b0e8d30 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/configuration/PartnerRouteBuillder.java @@ -0,0 +1,53 @@ +package org.etsi.osl.mcp.backend.configuration; + +import java.io.IOException; +import org.apache.camel.LoggingLevel; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.model.dataformat.JsonLibrary; +import org.etsi.osl.mcp.backend.ChatController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; + + +@Configuration +@Component +public class PartnerRouteBuillder extends RouteBuilder { + + @Value("${spring.application.name}") + private String compname; + + @Autowired + private ChatController chatController; + + @Override + public void configure() throws Exception { + + + String EVENT_CHAT = "jms:queue:agents/"+compname ; + + + from(EVENT_CHAT) + .log(LoggingLevel.INFO, log, EVENT_CHAT + " message received!") + .to("log:DEBUG?showBody=true&showHeaders=true") + .bean( chatController, "simpleAsk( ${headers}, ${body} )") + .convertBodyTo( String.class ); + } + + static T toJsonObj(String content, Class valueType) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.readValue(content, valueType); + } + + static String toJsonString(Object object) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return mapper.writeValueAsString(object); + } + + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cdd6bb0a215626d82996d81c1749533581ebeeeb --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,56 @@ + +server: + port: ${SERVER_PORT:11880} + +spring: + application: + name: ${SPRING_APPLICATION_NAME:osl-mcp-backend} + ai: + ollama: + base-url: ${SPRING_AI_OLLAMA_BASE_URL:http://localhost:11434} + chat: + options: + model: ${SPRING_AI_OLLAMA_CHAT_MODEL:gpt-oss:20b} + temperature: ${SPRING_AI_OLLAMA_CHAT_TEMPERATURE:0.7} + chat: + system-prompt: ${SPRING_AI_CHAT_SYSTEM_PROMPT:You are an OpenSlice AI assistant.} + maxMessages: ${SPRING_AI_CHAT_MAX_MESSAGES:100} + mcp: + client: + type: ${SPRING_AI_MCP_CLIENT_TYPE:SYNC} # or ASYNC + streamable-http: + connections: + openslice-server: + url: ${SPRING_AI_MCP_CLIENT_STREAMABLE_HTTP_CONNECTIONS_OPENSLICE_SERVER_URL:http://localhost:13015} + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://keycloak:8080/auth/realms/openslice} + client: + registration: + keycloak: + client-id: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID:osapiWebClientId} + scope: + - openid + - profile + provider: keycloak + provider: + keycloak: + issuer-uri: ${SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI:http://keycloak:8080/auth/realms/openslice} + + activemq: + brokerUrl: ${SPRING_ACTIVEMQ_BROKER_URL:tcp://localhost:61616?jms.watchTopicAdvisories=false} + user: ${SPRING_ACTIVEMQ_USER:artemis} + password: ${SPRING_ACTIVEMQ_PASSWORD:artemis} + pool: + enabled: ${SPRING_ACTIVEMQ_POOL_ENABLED:true} + max-connections: ${SPRING_ACTIVEMQ_POOL_MAX_CONNECTIONS:100} + packages: + trust-all: ${SPRING_ACTIVEMQ_PACKAGES_TRUST_ALL:true} + +logging: + level: + root: ${LOGGING_LEVEL_ROOT:INFO} + org.etsi.osl.*: ${LOGGING_LEVEL_OSL:DEBUG} + org.springframework.ai.chat.client.advisor: ${LOGGING_LEVEL_SPRING_AI:DEBUG} \ No newline at end of file diff --git a/src/main/resources/public/chat.html b/src/main/resources/public/chat.html new file mode 100644 index 0000000000000000000000000000000000000000..944c0fd38d98728af4262c2bf4a17e7c5c0e639c --- /dev/null +++ b/src/main/resources/public/chat.html @@ -0,0 +1,60 @@ + + + + + + OpenSlice Chat Assistant + + + + + + + + +
+
+
+

OpenSlice AI Assistant

+ Ask me anything about your OpenSlice platform +
+
+
+
AI
+
+
Hi there! How can I help you today?
+
+
+
+
+
+
+ + +
+
+
+
+ + + diff --git a/src/main/resources/public/css/chat.css b/src/main/resources/public/css/chat.css new file mode 100644 index 0000000000000000000000000000000000000000..df841f1254ed0173949fdfe9d07c55f8a34a7163 --- /dev/null +++ b/src/main/resources/public/css/chat.css @@ -0,0 +1,307 @@ +/* OpenSlice Chat Assistant - Chat Page Styles */ + +body { + font-family: "Open Sans", sans-serif; + padding-top: 60px; + padding-bottom: 20px; + background-color: white; +} + +/* Navbar */ +.navbar-default { + background-color: rgba(245, 245, 245, 0.93); + border-bottom: 1px solid #e7e7e7; +} + +.navbar-brand img { + height: 25px; +} + +/* Chat Container */ +.chat-container { + max-width: 900px; + margin: 20px auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + overflow: hidden; +} + +/* Chat Header */ +.chat-header { + background-color: #7EADDB; + color: white; + padding: 20px; + border-bottom: 1px solid #6589AB; +} + +.chat-header h4 { + margin: 0; + font-weight: 600; +} + +.chat-header .online-indicator { + display: inline-block; + width: 8px; + height: 8px; + background-color: #5cb85c; + border-radius: 50%; + margin-right: 5px; +} + +/* Chat Messages */ +.chat-messages { + height: 500px; + overflow-y: auto; + padding: 20px; + background-color: #f9f9f9; +} + +.message { + margin-bottom: 20px; + display: flex; + align-items: flex-start; + clear: both; +} + +.message.incoming { + flex-direction: row; + justify-content: flex-start; + padding-right: 80px; +} + +.message.outgoing { + flex-direction: row-reverse; + justify-content: flex-start; + padding-left: 80px; +} + +.message .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #7EADDB; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + flex-shrink: 0; +} + +.message.incoming .avatar { + margin-right: 12px; +} + +.message.outgoing .avatar { + margin-left: 12px; + background-color: #6589AB; +} + +.message-wrapper { + display: flex; + flex-direction: column; + max-width: 66%; + min-width: fit-content; +} + +.message.incoming .message-wrapper { + align-items: flex-start; +} + +.message.outgoing .message-wrapper { + align-items: flex-end; +} + +.message-content { + max-width: 100%; + padding: 12px 16px; + border-radius: 8px; + word-wrap: break-word; + white-space: pre-wrap; + line-height: 1.5; +} + +.message-content p { + margin: 0; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content p + p { + margin-top: 10px; +} + +.message-content code { + background-color: rgba(0, 0, 0, 0.05); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 13px; +} + +.message-content pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 10px; + border-radius: 4px; + overflow-x: auto; + margin: 10px 0; +} + +.message-content pre code { + background-color: transparent; + padding: 0; +} + +.message-content ul, .message-content ol { + margin: 10px 0; + padding-left: 20px; +} + +.message-content li { + margin: 5px 0; +} + +.message.incoming .message-content { + background-color: white; + border: 1px solid #e0e0e0; + color: #333; +} + +.message.outgoing .message-content { + background-color: #7EADDB; + color: white; +} + +.message.outgoing .message-content code { + background-color: rgba(255, 255, 255, 0.2); +} + +.message.outgoing .message-content pre { + background-color: rgba(255, 255, 255, 0.2); +} + +.message-time { + font-size: 11px; + color: #999; + margin-top: 5px; + text-align: right; +} + +.message.incoming .message-time { + text-align: left; +} + +/* Chat Input */ +.chat-input { + padding: 20px; + background-color: white; + border-top: 1px solid #e0e0e0; +} + +.chat-input .input-group { + display: flex; +} + +.chat-input textarea { + flex: 1; + resize: none; + border: 1px solid #ddd; + border-radius: 4px 0 0 4px; + padding: 10px; + font-size: 14px; + min-height: 45px; +} + +.chat-input button { + background-color: #7EADDB; + border: none; + color: white; + padding: 0 25px; + border-radius: 0 4px 4px 0; + font-weight: 600; + transition: background-color 0.3s; +} + +.chat-input button:hover { + background-color: #6589AB; +} + +/* Tool Execution */ +.tool-execution { + background-color: #f0f8ff; + border-left: 3px solid #7EADDB; + padding: 10px; + margin-top: 8px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; +} + +.tool-name { + font-weight: bold; + color: #6589AB; +} + +/* Typing Indicator */ +.typing-indicator { + display: none; + margin-bottom: 20px; +} + +.typing-indicator.active { + display: flex; +} + +.typing-indicator .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #7EADDB; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + flex-shrink: 0; + margin-right: 12px; +} + +.typing-indicator .dots { + background-color: white; + border: 1px solid #e0e0e0; + padding: 12px 20px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.typing-indicator .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #7EADDB; + animation: typing 1.4s infinite; +} + +.typing-indicator .dot:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-indicator .dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.4; + } + 30% { + transform: translateY(-10px); + opacity: 1; + } +} diff --git a/src/main/resources/public/css/welcome.css b/src/main/resources/public/css/welcome.css new file mode 100644 index 0000000000000000000000000000000000000000..c27aa166186ac0a649cab185f6d78bdcfabee097 --- /dev/null +++ b/src/main/resources/public/css/welcome.css @@ -0,0 +1,110 @@ +/* OpenSlice Chat Assistant - Welcome Page Styles */ + +body { + font-family: 'Open Sans', sans-serif; + margin: 0; + padding: 60px 0 40px 0; + min-height: 100vh; + background-color: white; +} + +/* Navbar */ +.navbar-default { + background-color: rgba(245, 245, 245, 0.93); + border-bottom: 1px solid #e7e7e7; +} + +.navbar-brand img { + height: 25px; +} + +/* Welcome Section */ +.welcome-section { + background: linear-gradient(135deg, #989DC3 0%, #7EADDB 100%); + padding: 80px 0; + text-align: center; + color: #FCFCFF; + margin-top: 0; + min-height: 500px; +} + +.welcome-section h1 { + font-size: 48px; + font-weight: 300; + margin-bottom: 20px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); +} + +.welcome-section .subtitle { + font-size: 22px; + margin-bottom: 30px; + font-weight: 300; +} + +.login-message { + background: rgba(255, 255, 255, 0.95); + color: #d9534f; + padding: 15px 30px; + border-radius: 4px; + display: inline-block; + margin-bottom: 30px; + font-weight: 600; + font-size: 16px; +} + +.btn-login { + background-color: #7EADDB; + color: white; + border: none; + padding: 15px 40px; + font-size: 18px; + font-weight: 600; + border-radius: 4px; + transition: all 0.3s; +} + +.btn-login:hover { + background-color: #6589AB; + color: white; + box-shadow: 0 4px 8px rgba(0,0,0,0.2); + transform: translateY(-2px); +} + +/* Features Section */ +.features-section { + background-color: white; + padding: 60px 0; +} + +.features-section h2 { + text-align: center; + font-size: 36px; + font-weight: 300; + margin-bottom: 50px; + color: #333; +} + +.feature-box { + text-align: center; + padding: 30px 20px; + margin-bottom: 30px; +} + +.feature-icon { + font-size: 60px; + margin-bottom: 20px; + color: #7EADDB; +} + +.feature-box h3 { + font-size: 22px; + font-weight: 600; + margin-bottom: 15px; + color: #333; +} + +.feature-box p { + font-size: 15px; + color: #666; + line-height: 1.6; +} diff --git a/src/main/resources/public/images/logo_clear.png b/src/main/resources/public/images/logo_clear.png new file mode 100644 index 0000000000000000000000000000000000000000..2fa5efc135209ce66cee07ea7a76dc0a4cff9244 Binary files /dev/null and b/src/main/resources/public/images/logo_clear.png differ diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html new file mode 100644 index 0000000000000000000000000000000000000000..688ce8324487c245459a78470c7dfb6a956386b1 --- /dev/null +++ b/src/main/resources/public/index.html @@ -0,0 +1,192 @@ + + + + + + OpenSlice Chat Assistant + + + + + + + + + + // Login button handler + $('#loginBtn').click(function() { + window.location.href = '/oauth2/authorization/keycloak'; + }); + + // Logout button handler + $('#logoutBtn').click(function() { + $.ajax({ + type: "POST", + url: "/api/logout", + dataType: 'json' + }) + .done(function(response) { + console.log("Logged out:", response); + accessToken = null; + currentUser = null; + updateAuthUI(); + addMessage("You have been logged out.", false); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.error("Logout error:", textStatus, errorThrown); + }); + }); + }); + + function updateAuthUI() { + if (currentUser && currentUser.authenticated) { + const displayName = currentUser.preferredUsername || currentUser.name || currentUser.email; + $('#userName').text(displayName); + $('#loginBtn').hide(); + $('#logoutBtn').show(); + } else { + $('#userName').text(''); + $('#loginBtn').show(); + $('#logoutBtn').hide(); + } + } + + function checkAuthStatus() { + $.ajax({ + type: "GET", + url: "/api/user", + dataType: 'json' + }) + .done(function(response) { + console.log("Auth status:", response); + currentUser = response; + if (response.authenticated && response.token) { + accessToken = response.token; + console.log("User authenticated"); + } + updateAuthUI(); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.log("User not authenticated"); + currentUser = { authenticated: false }; + updateAuthUI(); + }); + } + + // Simple function to add a new message + function addMessage(content, isOutgoing = false) { + const messagesContainer = document.getElementById('chatMessages'); + const now = new Date(); + const timeString = now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + + const messageDiv = document.createElement('div'); + messageDiv.className = `message d-flex ${isOutgoing ? 'outgoing flex-row-reverse' : 'incoming'}`; + + const avatarContent = isOutgoing ? 'Me' : robotSvg; + + messageDiv.innerHTML = ` +
${avatarContent}
+
+
+ ${content} +
+
${timeString}
+
+ `; + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // Handle auto-resize of textarea + const textarea = document.querySelector('.message-textarea'); + textarea.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = (this.scrollHeight) + 'px'; + // Set max height + if (this.scrollHeight > 100) { + this.style.height = '100px'; + } + }); + + // Add event listener to send button + document.querySelector('.btn-primary').addEventListener('click', function() { + const textarea = document.querySelector('.message-textarea'); + const message = textarea.value.trim(); + + if (message) { + addMessage(message, true); + textarea.value = ''; + textarea.style.height = '50px'; // Reset height + + // Build AJAX request with authorization header if token is available + const ajaxConfig = { + type: "POST", + url: "/ask", + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ 'question': message }) + }; + + // Add Authorization header if we have a token + if (accessToken) { + ajaxConfig.headers = { + 'Authorization': 'Bearer ' + accessToken + }; + } + + $.ajax(ajaxConfig) + .done(function(response) { + console.log("Success:", response); + addMessage(response.answer); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.error("Error:", textStatus, errorThrown); + if (jqXHR.status === 401 || jqXHR.status === 403) { + // Redirect to login + console.log("Unauthorized, redirecting to login..."); + window.location.href = '/oauth2/authorization/keycloak'; + } else { + alert("Error fetching data: " + (jqXHR.responseText || textStatus)); + } + }); + } + }); + + // Add event listener for pressing Enter key (without shift for newline) + document.querySelector('.message-textarea').addEventListener('keydown', function(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // Prevent default newline + document.querySelector('.btn-primary').click(); + } + }); + + + \ No newline at end of file diff --git a/src/main/resources/public/js/chat.js b/src/main/resources/public/js/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..a7550129fa1918601f6faac6b9bad99effba3bbd --- /dev/null +++ b/src/main/resources/public/js/chat.js @@ -0,0 +1,186 @@ +/** + * OpenSlice Chat Assistant - Chat Page JavaScript + */ + +var accessToken = null; +var currentUser = null; + +$(document).ready(function() { + initializeChat(); +}); + +/** + * Initialize chat page + */ +function initializeChat() { + checkAuthStatus(); + $("#initialTime").text(getCurrentTime()); + $("#sendBtn").click(sendMessage); + + // Handle Enter key in textarea + $(".message-textarea").keypress(function(e) { + if (e.which === 13 && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Handle logout + $("#logoutBtn").click(function(e) { + e.preventDefault(); + logout(); + }); +} + +/** + * Check user authentication status + */ +function checkAuthStatus() { + $.ajax({ + type: "GET", + url: "/api/user", + dataType: "json" + }) + .done(function(response) { + if (response.authenticated) { + currentUser = response.username; + accessToken = response.access_token; + $("#userName").text("Welcome, " + currentUser); + } else { + window.location.href = "/welcome.html"; + } + }) + .fail(function() { + window.location.href = "/welcome.html"; + }); +} + +/** + * Logout user + */ +function logout() { + // Simply navigate to logout endpoint which will handle the Keycloak logout and redirect + window.location.href = "/api/logout"; +} + +/** + * Send message to chat + */ +function sendMessage() { + var messageText = $(".message-textarea").val().trim(); + if (!messageText) return; + + // Add user message + addMessage(messageText, "outgoing", currentUser || "You"); + $(".message-textarea").val(""); + + // Show typing indicator + showTypingIndicator(); + + // Send to backend + $.ajax({ + type: "POST", + url: "/ask", + contentType: "application/json", + headers: accessToken ? { "Authorization": "Bearer " + accessToken } : {}, + data: JSON.stringify({ question: messageText }), + dataType: "json" + }) + .done(function(response) { + hideTypingIndicator(); + + var aiMessage = response.answer || "I'm sorry, I couldn't process that request."; + addMessage(aiMessage, "incoming", "AI"); + + // Show tool executions if any + if (response.toolExecutions && response.toolExecutions.length > 0) { + response.toolExecutions.forEach(function(tool) { + addToolExecution(tool); + }); + } + }) + .fail(function(xhr) { + hideTypingIndicator(); + + var errorMsg = "Error: " + (xhr.responseJSON ? xhr.responseJSON.error : xhr.statusText || "Unknown error"); + addMessage(errorMsg, "incoming", "AI"); + }); +} + +/** + * Show typing indicator + */ +function showTypingIndicator() { + var typingHtml = '
' + + '
AI
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + $("#chatMessages").append(typingHtml); + scrollToBottom(); +} + +/** + * Hide typing indicator + */ +function hideTypingIndicator() { + $("#typingIndicator").remove(); +} + +/** + * Add message to chat + * @param {string} text - Message text (HTML for AI, plain text for user) + * @param {string} type - "incoming" or "outgoing" + * @param {string} sender - Sender name + */ +function addMessage(text, type, sender) { + var messageDiv = $("
").addClass("message " + type); + var avatar = $("
").addClass("avatar").text(sender.substring(0, 2).toUpperCase()); + var contentWrapper = $("
").addClass("message-wrapper"); + var content = $("
").addClass("message-content"); + + // AI responses are HTML (markdown converted), user messages are plain text + if (type === "incoming") { + content.html(text); + } else { + // For outgoing messages, normalize whitespace but preserve intentional line breaks + var normalizedText = text.replace(/\r\n/g, '\n'); + content.text(normalizedText); + } + + var time = $("
").addClass("message-time").text(getCurrentTime()); + + contentWrapper.append(content).append(time); + messageDiv.append(avatar).append(contentWrapper); + $("#chatMessages").append(messageDiv); + scrollToBottom(); +} + +/** + * Add tool execution info to chat + */ +function addToolExecution(tool) { + var toolDiv = $("
").addClass("tool-execution"); + toolDiv.html('Tool: ' + tool.name + '
Status: ' + tool.status); + $("#chatMessages").append(toolDiv); + scrollToBottom(); +} + +/** + * Scroll chat to bottom + */ +function scrollToBottom() { + var chatMessages = $("#chatMessages"); + chatMessages.scrollTop(chatMessages[0].scrollHeight); +} + +/** + * Get current time formatted + */ +function getCurrentTime() { + var now = new Date(); + return now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); +} diff --git a/src/main/resources/public/js/welcome.js b/src/main/resources/public/js/welcome.js new file mode 100644 index 0000000000000000000000000000000000000000..baf8d0d058118908bdd96dc75424c53ddd68ac19 --- /dev/null +++ b/src/main/resources/public/js/welcome.js @@ -0,0 +1,29 @@ +/** + * OpenSlice Chat Assistant - Welcome Page JavaScript + */ + +$(document).ready(function() { + checkAuthenticationStatus(); +}); + +/** + * Check if user is already authenticated + * If authenticated, redirect to chat page + */ +function checkAuthenticationStatus() { + $.ajax({ + type: "GET", + url: "/api/user", + dataType: 'json' + }) + .done(function(response) { + if (response.authenticated) { + console.log("User already authenticated, redirecting to chat..."); + window.location.href = '/chat.html'; + } + }) + .fail(function() { + // Expected for unauthenticated users - stay on welcome page + console.log("User not authenticated"); + }); +} diff --git a/src/main/resources/public/welcome.html b/src/main/resources/public/welcome.html new file mode 100644 index 0000000000000000000000000000000000000000..825313b514b8d9c1802bb9a66fe5d52f19faa8e2 --- /dev/null +++ b/src/main/resources/public/welcome.html @@ -0,0 +1,95 @@ + + + + + + Welcome - OpenSlice Chat Assistant + + + + + + + + + + + + + + + + + + + +
+
+

Welcome to OpenSlice Chat Assistant

+

AI-powered assistance for your OpenSlice platform

+ + + +
+ + +
+
+ + +
+
+

What you can do

+
+
+
+
💬
+

Natural Language Queries

+

Ask questions about your services, catalogs, and deployments using natural language. Get instant answers powered by AI.

+
+
+
+
+
🔧
+

Intelligent Tool Execution

+

Let AI help you interact with TMF APIs and manage resources efficiently. Automate complex workflows with simple commands.

+
+
+
+
+
📊
+

Real-time Information

+

Get instant answers about your platform status, resource utilization, and system health in real-time.

+
+
+
+
+
+ + + + + diff --git a/src/main/resources/static/robot.svg b/src/main/resources/static/robot.svg new file mode 100644 index 0000000000000000000000000000000000000000..b8fa6bac148e502b339685b9acfe03faf6ea9516 --- /dev/null +++ b/src/main/resources/static/robot.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..50c6b9cb3f15e515de1692e789a2dce37b80b986 --- /dev/null +++ b/src/main/resources/static/styles.css @@ -0,0 +1,96 @@ +body { + background-color: #f4f7f6; + height: 100vh; +} +.chat-container { + height: 85vh; + max-height: 85vh; + border-radius: 15px; + background-color: #fff; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} +.chat-header { + border-radius: 15px 15px 0 0; + background-color: #7269ef; + color: white; + padding: 15px; +} +.chat-messages { + height: calc(100% - 140px); + overflow-y: auto; + padding: 20px; +} +.message { + margin-bottom: 20px; + max-width: 80%; +} +.message-content { + padding: 12px 15px; + border-radius: 15px; + display: inline-block; +} +.incoming .message-content { + background-color: #f5f6fa; + color: #343a40; +} +.outgoing { + margin-left: auto; +} +.outgoing .message-content { + background-color: #7269ef; + color: white; +} +.message-time { + font-size: 0.75rem; + color: #6c757d; + margin-top: 5px; +} +.chat-input { + padding: 15px; + background-color: #fff; + border-top: 1px solid #e9ecef; + border-radius: 0 0 15px 15px; + position: absolute; + bottom: 0; + width: 100%; +} +.online-indicator { + width: 10px; + height: 10px; + background-color: #28a745; + border-radius: 50%; + display: inline-block; + margin-right: 5px; +} +.avatar { + min-width: 40px; + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; + background-color: #e9ecef; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #495057; + flex-shrink: 0; + overflow: hidden; + padding: 0; +} +.avatar svg { + width: 30px; + height: 30px; +} +.outgoing .avatar { + margin-right: 0; + margin-left: 10px; + background-color: #7269ef; + color: white; +} +.message-textarea { + resize: none; + overflow: auto; + height: 50px; + max-height: 100px; +} \ No newline at end of file