diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e2e9f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + cache: 'gradle' + + - name: Run tests + run: ./gradlew test diff --git a/AGENTS.md b/AGENTS.md index b4152a0..dbfe843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,13 +26,22 @@ This project represents a Java version of OpenClaw. OpenClaw is an open-source, ``` root -├── base/ ← Core: agent, tasks, tools, channels, config -├── app/ ← Spring Boot entry point, onboarding UI, web routes, chat channel -└── channels/ - └── telegram/ ← Telegram long-poll channel +├── base/ ← Core: agent, tasks, tools, channels, config +├── app/ ← Spring Boot entry point, onboarding UI, web routes, chat channel +├── providers/ +│ ├── anthropic/ ← Anthropic (Claude) provider + Claude Code OAuth support +│ ├── openai/ ← OpenAI (GPT) provider +│ ├── ollama/ ← Ollama local provider (no API key required) +│ └── google/ ← Google Gen AI (Gemini) provider +└── plugins/ + ├── telegram/ ← Telegram long-poll channel + ├── brave/ ← Brave web search tool + └── playwright/ ← Playwright browser tool ``` -`app` depends on `base` + `channels:telegram`. `ChatChannel` lives inside `app/`. +`app` depends on `base` + all `providers/` + all `plugins/`. `ChatChannel` lives inside `app/`. + +Each **provider** implements `AgentOnboardingProvider` (in `base`) and is auto-discovered by Spring. Each **plugin** is an optional Spring Boot auto-configuration module that contributes tools or channels. --- @@ -96,10 +105,16 @@ User/Agent → TaskManager.create() | `MCP Tools` | `SyncMcpToolCallbackProvider` | | `BraveWebSearchTool` | 15 results (only if Brave API key configured) | -**Supported LLM Providers** (`SupportedProvider.java`): -- `OLLAMA` — local, no API key required -- `OPENAI` — GPT-5.4 -- `ANTHROPIC` — Claude Sonnet 4.6 +**Supported LLM Providers** — each lives in its own `providers//` module and implements `AgentOnboardingProvider`: + +| Provider | Module | Default Model | API Key | +|---|---|---|---| +| `anthropic` | `providers/anthropic` | `claude-sonnet-4-6` | Required (or Claude Code OAuth) | +| `openai` | `providers/openai` | `gpt-5.4` | Required | +| `ollama` | `providers/ollama` | `qwen3.5:27b` | Not required (local) | +| `google.genai` | `providers/google` | `gemini-3-flash-preview` | Required | + +The `AnthropicAgentOnboardingProvider` additionally supports a **system-wide token** via `AnthropicClaudeCodeOAuthTokenExtractor` — if a Claude Code OAuth token is found locally, it is offered as a zero-config option during onboarding. --- @@ -146,13 +161,13 @@ Incoming message → ChannelMessageReceivedEvent (channel name, message text) - **htmx v2.0.8 (https://htmx.org/docs/):** htmx is a strong fit for this app because it keeps the interaction model server-driven: the server returns HTML fragments, not JSON, and htmx swaps them into the DOM. We are using `hx-boost` which "boosts" normal anchors and form tags to use AJAX instead (preventing reloading of css and js). This has the nice fallback that, if the user does not have javascript enabled, the site will continue to work. Both Bulma and htmx are already included in `base.html.peb`. ### Onboarding UI -Entry point: `GET /index` → `IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. 7-step session-based flow: +Entry point: `GET /index` → `IndexController.java` (redirects to `/onboarding/`) → `OnboardingController.java`. Session-based flow: 1. Welcome -2. Provider selection (Ollama / OpenAI / Anthropic) -3. Credentials (API key + model) +2. Provider selection — dynamically populated from all `AgentOnboardingProvider` beans (Anthropic, OpenAI, Ollama, Google Gen AI, + any future providers) +3. Credentials (API key + model — skipped for providers where `requiresApiKey()` is `false`) 4. `AGENT.md` editor (system prompt customization) 5. MCP servers configuration (optional) -6. Telegram bot token + allowed username (optional) +6. Plugin-contributed steps (e.g. Telegram bot token, Brave API key, Playwright) — injected by each plugin's `OnboardingProvider` 7. Complete summary Templates: `templates/onboarding/` (index + 7 step partials). Saves config via `ConfigurationManager.updateProperty()`. @@ -176,5 +191,6 @@ Templates: `templates/onboarding/` (index + 7 step partials). Saves config via ` ## Tests - `base/src/test/` — `TaskManagerTest`: task creation, file naming, JobRunr integration (in-memory storage + background server). -- `channels/telegram/src/test/` — `TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked). +- `plugins/telegram/src/test/` — `TelegramChannelTest`: unauthorized user rejection, authorized message flow (mocked). +- `providers/anthropic/src/test/` — `AnthropicClaudeCodeBackendTest`: Claude Code OAuth token extraction. - `app/src/test/` — `OnboardingControllerTest`: session-based workflow; `JavaClawApplicationTests`: full Spring context load with Testcontainers. \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 6a26ddd..685a01b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,11 @@ plugins { dependencies { implementation project(':base') + implementation project(':providers:anthropic') + implementation project(':providers:google') + implementation project(':providers:ollama') + implementation project(':providers:openai') + implementation project(':plugins:telegram') implementation project(':plugins:playwright') implementation project(':plugins:brave') diff --git a/app/src/main/java/ai/javaclaw/SupportedProvider.java b/app/src/main/java/ai/javaclaw/SupportedProvider.java deleted file mode 100644 index 691c05b..0000000 --- a/app/src/main/java/ai/javaclaw/SupportedProvider.java +++ /dev/null @@ -1,88 +0,0 @@ -package ai.javaclaw; - -import ai.javaclaw.providers.anthropic.AnthropicClaudeCodeOAuthTokenExtractor; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static ai.javaclaw.providers.anthropic.AnthropticClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER; - -public enum SupportedProvider { - - OLLAMA("ollama", "Ollama", "Local-first setup. No API key required.", false, "qwen3.5:27b"), - OPENAI("openai", "OpenAI", "Uses OpenAI API key for ChatGPT as an agent.", true, "gpt-5.4"), - ANTHROPIC("anthropic", "Anthropic", "Uses Claude Code or Anthropic credentials for Claude-based chat", true, "claude-sonnet-4-6") { - @Override - public Optional systemWideToken() { - Optional token = AnthropicClaudeCodeOAuthTokenExtractor.getToken(); - if (token.isEmpty()) return Optional.empty(); - - return Optional.of(new SystemWideToken("Claude Code", CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER)); - } - }; - - private final String id; - private final String label; - private final String slogan; - private final boolean requiresApiKey; - private final String defaultModel; - - SupportedProvider(String id, String label, String slogan, boolean requiresApiKey, String defaultModel) { - this.id = id; - this.label = label; - this.slogan = slogan; - this.requiresApiKey = requiresApiKey; - this.defaultModel = defaultModel; - } - - public String id() { - return id; - } - - public String getId() { - return id; - } - - public String label() { - return label; - } - - public String slogan() { - return slogan; - } - - public boolean requiresApiKey() { - return requiresApiKey; - } - - public String defaultModel() { - return defaultModel; - } - - public Optional systemWideToken() { - return Optional.empty(); - } - - public String createPropertyKey(String propertySuffix) { - return "spring.ai." + id() + "." + propertySuffix; - } - - public void saveProperty(Map properties, String propertySuffix, String value) { - if (value == null || value.isBlank()) return; - properties.put(createPropertyKey(propertySuffix), value); - } - - public static List supportedAgents() { - return Arrays.asList(values()); - } - - public static Optional from(String value) { - return Arrays.stream(values()) - .filter(provider -> provider.id.equalsIgnoreCase(value)) - .findFirst(); - } - - public record SystemWideToken(String name, String token) {} -} \ No newline at end of file diff --git a/app/src/main/java/ai/javaclaw/onboarding/api/OnboardingController.java b/app/src/main/java/ai/javaclaw/onboarding/api/OnboardingController.java index 2c59bfd..0183e0e 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/api/OnboardingController.java +++ b/app/src/main/java/ai/javaclaw/onboarding/api/OnboardingController.java @@ -1,10 +1,9 @@ package ai.javaclaw.onboarding.api; -import ai.javaclaw.SupportedProvider; import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.onboarding.AgentOnboardingProviders; import ai.javaclaw.onboarding.OnboardingProvider; import jakarta.servlet.http.HttpSession; -import org.springframework.core.env.Environment; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -24,12 +23,12 @@ public class OnboardingController { private static final String COMPLETE_STEP_ID = "complete"; private static final String ONBOARDING_TEMPLATE = "onboarding/index"; - private final Environment environment; + private final AgentOnboardingProviders agentOnboardingProviders; private final ConfigurationManager configurationManager; private final List steps; - public OnboardingController(Environment environment, ConfigurationManager configurationManager, List steps) { - this.environment = environment; + public OnboardingController(AgentOnboardingProviders agentOnboardingProviders, ConfigurationManager configurationManager, List steps) { + this.agentOnboardingProviders = agentOnboardingProviders; this.configurationManager = configurationManager; this.steps = steps; } @@ -41,7 +40,7 @@ public String onboarding() { @GetMapping("/onboarding/{stepId}") public String getStep(@PathVariable String stepId, HttpSession session, Model model) { - OnboardingProvider onboardingProvider = findProvider(stepId); + OnboardingProvider onboardingProvider = findOnboardingProvider(stepId); if (onboardingProvider == null) { return "redirect:/onboarding/" + steps.getFirst().getStepId(); } @@ -49,7 +48,8 @@ public String getStep(@PathVariable String stepId, HttpSession session, Model mo // When arriving at the complete step via GET (e.g. by skipping the last optional step), // save configuration if the session still holds onboarding data. if (COMPLETE_STEP_ID.equals(stepId) && session.getAttribute("onboarding.provider") != null) { - String providerLabel = saveAndComplete(session); + String providerLabel = agentOnboardingProviders.getById((String) session.getAttribute("onboarding.provider")).getLabel(); + saveAndComplete(session); if (providerLabel != null) model.addAttribute("providerLabel", providerLabel); } @@ -72,7 +72,7 @@ public String getStep(@PathVariable String stepId, HttpSession session, Model mo @PostMapping("/onboarding/{stepId}") public String postStep(@PathVariable String stepId, @RequestParam Map formParams, HttpSession session, RedirectAttributes redirectAttrs) { - OnboardingProvider provider = findProvider(stepId); + OnboardingProvider provider = findOnboardingProvider(stepId); if (provider == null) { return "redirect:/onboarding/" + steps.getFirst().getStepId(); } @@ -88,17 +88,16 @@ public String postStep(@PathVariable String stepId, @RequestParam Map finalSession = sessionToMap(session); - String providerId = (String) finalSession.getOrDefault("onboarding.provider", ""); - String providerLabel = SupportedProvider.from(providerId).map(SupportedProvider::label).orElse(null); try { for (OnboardingProvider p : steps) { p.saveConfiguration(finalSession, configurationManager); @@ -107,10 +106,9 @@ private String saveAndComplete(HttpSession session) { throw new RuntimeException("Failed to save onboarding configuration", e); } clearOnboardingSession(session); - return providerLabel; } - private OnboardingProvider findProvider(String stepId) { + private OnboardingProvider findOnboardingProvider(String stepId) { return steps.stream() .filter(p -> p.getStepId().equals(stepId)) .findFirst() diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java index abbb148..7f97889 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java @@ -1,7 +1,8 @@ package ai.javaclaw.onboarding.steps; -import ai.javaclaw.SupportedProvider; import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import ai.javaclaw.onboarding.AgentOnboardingProviders; import ai.javaclaw.onboarding.OnboardingProvider; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -19,9 +20,11 @@ public class S2_ProviderStep implements OnboardingProvider { static final String SESSION_MODEL = "onboarding.model"; static final String SESSION_API_KEY = "onboarding.apiKey"; + private final AgentOnboardingProviders agentOnboardingProviders; private final Environment env; - public S2_ProviderStep(Environment env) { + public S2_ProviderStep(AgentOnboardingProviders agentOnboardingProviders, Environment env) { + this.agentOnboardingProviders = agentOnboardingProviders; this.env = env; } @@ -36,7 +39,7 @@ public S2_ProviderStep(Environment env) { @Override public void prepareModel(Map session, Map model) { - model.put("providers", SupportedProvider.supportedAgents()); + model.put("providers", agentOnboardingProviders.getAll()); model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", ""))); } @@ -46,17 +49,14 @@ public String processStep(Map formParams, Map se if (providerId == null || providerId.isBlank()) { return "Choose one of the supported providers to continue."; } - SupportedProvider provider = SupportedProvider.from(providerId).orElse(null); - if (provider == null) { - return "Choose one of the supported providers to continue."; - } + AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId); // Clear downstream session state when provider changes String currentProvider = (String) session.get(SESSION_PROVIDER); - if (!provider.id().equals(currentProvider)) { + if (!agentOnboardingProvider.getId().equals(currentProvider)) { session.remove(SESSION_MODEL); session.remove(SESSION_API_KEY); } - session.put(SESSION_PROVIDER, provider.id()); + session.put(SESSION_PROVIDER, agentOnboardingProvider.getId()); return null; } @@ -66,13 +66,11 @@ public void saveConfiguration(Map session, ConfigurationManager String model = (String) session.get(SESSION_MODEL); String apiKey = (String) session.getOrDefault(SESSION_API_KEY, ""); - SupportedProvider provider = SupportedProvider.from(providerId).orElse(null); - if (provider == null) return; - + AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId); Map props = new LinkedHashMap<>(); - provider.saveProperty(props, "chat.options.model", model); - provider.saveProperty(props, "api-key", apiKey); - props.put("spring.ai.model.chat", provider.id()); + agentOnboardingProvider.saveProperty(props, "chat.options.model", model); + agentOnboardingProvider.saveProperty(props, "api-key", apiKey); + props.put("spring.ai.model.chat", agentOnboardingProvider.getId().replace(".", "-")); configurationManager.updateProperties(props); } } diff --git a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java index cca34c2..ab76499 100644 --- a/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java +++ b/app/src/main/java/ai/javaclaw/onboarding/steps/S3_CredentialsStep.java @@ -1,6 +1,8 @@ package ai.javaclaw.onboarding.steps; -import ai.javaclaw.SupportedProvider; +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import ai.javaclaw.onboarding.AgentOnboardingProvider.SystemWideToken; +import ai.javaclaw.onboarding.AgentOnboardingProviders; import ai.javaclaw.onboarding.OnboardingProvider; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -13,9 +15,11 @@ public class S3_CredentialsStep implements OnboardingProvider { private final Environment env; + private final AgentOnboardingProviders agentOnboardingProviders; - public S3_CredentialsStep(Environment env) { + public S3_CredentialsStep(Environment env, AgentOnboardingProviders agentOnboardingProviders) { this.env = env; + this.agentOnboardingProviders = agentOnboardingProviders; } @Override @@ -30,14 +34,14 @@ public S3_CredentialsStep(Environment env) { @Override public void prepareModel(Map session, Map model) { String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); - SupportedProvider provider = SupportedProvider.from(providerId).orElse(null); + AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null); if (provider == null) return; String currentModel = (String) session.get(S2_ProviderStep.SESSION_MODEL); String existingModel = env.getProperty(provider.createPropertyKey("chat.options.model"), ""); String existingApiKey = env.getProperty(provider.createPropertyKey("api-key"), ""); - model.put("selectedProvider", provider.id()); - model.put("providerLabel", provider.label()); + model.put("selectedProvider", provider.getId()); + model.put("providerLabel", provider.getLabel()); model.put("providerApiPropertyKey", provider.createPropertyKey("api-key")); model.put("chatModelPropertyKey", provider.createPropertyKey("chat.options.model")); model.put("requiresApiKey", provider.requiresApiKey()); @@ -48,8 +52,8 @@ public void prepareModel(Map session, Map model) @Override public String processStep(Map formParams, Map session) { - String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, ""); - SupportedProvider provider = SupportedProvider.from(providerId).orElse(null); + String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); + AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null); if (provider == null) { return "Provider selection is missing. Please go back and select a provider."; } @@ -62,7 +66,7 @@ public String processStep(Map formParams, Map se } if ("true".equals(formParams.get("useSystemToken"))) { - SupportedProvider.SystemWideToken sysToken = provider.systemWideToken().orElse(null); + SystemWideToken sysToken = provider.systemWideToken().orElse(null); if (sysToken == null) { return "System token is no longer available. Please enter your API key manually."; } @@ -77,4 +81,9 @@ public String processStep(Map formParams, Map se session.put(S2_ProviderStep.SESSION_API_KEY, apiKey); return null; } + + AgentOnboardingProvider getAgentProvider(Map session) { + String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")); + return agentOnboardingProviders.getById(providerId); + } } diff --git a/app/src/test/java/ai/javaclaw/api/OnboardingControllerTest.java b/app/src/test/java/ai/javaclaw/api/OnboardingControllerTest.java index 95a54dc..c14439b 100644 --- a/app/src/test/java/ai/javaclaw/api/OnboardingControllerTest.java +++ b/app/src/test/java/ai/javaclaw/api/OnboardingControllerTest.java @@ -1,6 +1,7 @@ package ai.javaclaw.api; import ai.javaclaw.configuration.ConfigurationManager; +import ai.javaclaw.onboarding.AgentOnboardingProviders; import ai.javaclaw.onboarding.api.OnboardingController; import ai.javaclaw.onboarding.steps.S1_WelcomeStep; import ai.javaclaw.onboarding.steps.S2_ProviderStep; @@ -19,9 +20,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(value = OnboardingController.class, properties = { - "agent.workspace=file:../workspace" -}) +@WebMvcTest(value = OnboardingController.class, properties = {"agent.workspace=file:../workspace"}) @Import({S1_WelcomeStep.class, S2_ProviderStep.class, S3_CredentialsStep.class, S4_AgentMdStep.class, S6_CompleteStep.class}) class OnboardingControllerTest { @@ -31,6 +30,9 @@ class OnboardingControllerTest { @MockitoBean private ConfigurationManager configurationManager; + @MockitoBean + private AgentOnboardingProviders agentOnboardingProviders; + @Test void providerSubmissionWithoutSelectionShowsFlashError() throws Exception { mockMvc.perform(post("/onboarding/provider")) diff --git a/base/build.gradle b/base/build.gradle index f650c6d..9114197 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -14,9 +14,6 @@ dependencies { implementation 'org.springframework.ai:spring-ai-client-chat' implementation 'org.springframework.ai:spring-ai-starter-mcp-client' - implementation 'org.springframework.ai:spring-ai-starter-model-anthropic' - implementation 'org.springframework.ai:spring-ai-starter-model-ollama' - implementation 'org.springframework.ai:spring-ai-starter-model-openai' implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT' runtimeOnly 'com.h2database:h2' diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index 46d627b..f818d3e 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -8,7 +8,6 @@ import ai.javaclaw.tools.McpTool; import ai.javaclaw.tools.TaskTool; import org.springaicommunity.agent.tools.FileSystemTools; -import org.springaicommunity.agent.tools.ShellTools; import org.springaicommunity.agent.tools.SkillsTool; import org.springaicommunity.agent.tools.SmartWebFetchTool; import org.springframework.ai.chat.client.ChatClient; @@ -24,6 +23,7 @@ import org.springframework.ai.chat.model.Generation; import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -59,6 +59,12 @@ public TaskTool taskTool(TaskManager taskManager) { return TaskTool.builder().taskManager(taskManager).build(); } + @Bean + public ChatClient.Builder chatClientBuilder(ObjectProvider chatModelProvider) { + ChatModel chatModel = chatModelProvider.getIfUnique(() -> prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect."))))); + return ChatClient.builder(chatModel); + } + @Bean @DependsOn({"mcpHeaderCustomizer"}) public ChatClient chatClient(ChatClient.Builder chatClientBuilder, @@ -87,7 +93,7 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder, CheckListTool.builder().build(), McpTool.builder().configurationManager(configurationManager).build(), //Bash execution tool - ShellTools.builder().build(),// built-in shell tools + //ShellTools.builder().build(),// built-in shell tools // Read, Write and Edit files tool FileSystemTools.builder().build(),// built-in file system tools // Smart web fetch tool diff --git a/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java new file mode 100644 index 0000000..e4919d6 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProvider.java @@ -0,0 +1,32 @@ +package ai.javaclaw.onboarding; + +import java.util.Map; +import java.util.Optional; + +public interface AgentOnboardingProvider { + + String getId(); + + String getLabel(); + + String slogan(); + + boolean requiresApiKey(); + + String defaultModel(); + + default Optional systemWideToken() { + return Optional.empty(); + } + + default String createPropertyKey(String propertySuffix) { + return "spring.ai." + getId() + "." + propertySuffix; + } + + default void saveProperty(Map properties, String propertySuffix, String value) { + if (value == null || value.isBlank()) return; + properties.put(createPropertyKey(propertySuffix), value); + } + + record SystemWideToken(String name, String token) {} +} diff --git a/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProviders.java b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProviders.java new file mode 100644 index 0000000..60750d3 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/onboarding/AgentOnboardingProviders.java @@ -0,0 +1,32 @@ +package ai.javaclaw.onboarding; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.SequencedSet; + +@Component +public class AgentOnboardingProviders { + + private final SequencedSet agentOnboardingProviders; + + public AgentOnboardingProviders(SequencedSet agentOnboardingProviders) { + this.agentOnboardingProviders = agentOnboardingProviders; + } + + public List getAll() { + return new ArrayList<>(agentOnboardingProviders); + } + + public Optional findById(String value) { + return agentOnboardingProviders.stream() + .filter(provider -> value.equalsIgnoreCase(provider.getId())) + .findFirst(); + } + + public AgentOnboardingProvider getById(String value) { + return findById(value).orElseThrow(); + } +} diff --git a/base/src/main/java/ai/javaclaw/providers/AgentProvider.java b/base/src/main/java/ai/javaclaw/providers/AgentProvider.java new file mode 100644 index 0000000..793fa60 --- /dev/null +++ b/base/src/main/java/ai/javaclaw/providers/AgentProvider.java @@ -0,0 +1,30 @@ +package ai.javaclaw.providers; + +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.util.SequencedCollection; + +@Component +public class AgentProvider { + + private final Environment environment; + private final SequencedCollection agentOnboardingProviders; + private final SequencedCollection chatModelProviders; + + public AgentProvider(Environment environment, SequencedCollection agentOnboardingProviders, SequencedCollection chatModelProviders) { + this.environment = environment; + this.agentOnboardingProviders = agentOnboardingProviders; + this.chatModelProviders = chatModelProviders.stream().filter(this::isConfigured).toList(); + } + + private boolean isConfigured(ChatModel chatModel) { + return false; + } + + public ChatModel getDefaultChatModel() { + return chatModelProviders.getFirst(); + } +} diff --git a/plugins/brave/build.gradle b/plugins/brave/build.gradle index 5c7ed78..f835856 100644 --- a/plugins/brave/build.gradle +++ b/plugins/brave/build.gradle @@ -8,5 +8,6 @@ dependencies { implementation 'org.springaicommunity:spring-ai-agent-utils:0.6.0-SNAPSHOT' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-restclient-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/providers/anthropic/build.gradle b/providers/anthropic/build.gradle new file mode 100644 index 0000000..4c45e2b --- /dev/null +++ b/providers/anthropic/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-starter-model-anthropic' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java new file mode 100644 index 0000000..8c7ea5e --- /dev/null +++ b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicAgentOnboardingProvider.java @@ -0,0 +1,44 @@ +package ai.javaclaw.providers.anthropic; + +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +import static ai.javaclaw.providers.anthropic.AnthropticClaudeCodeConfiguration.CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER; + +@Component +public class AnthropicAgentOnboardingProvider implements AgentOnboardingProvider { + @Override + public String getId() { + return "anthropic"; + } + + @Override + public String getLabel() { + return "Anthropic"; + } + + @Override + public String slogan() { + return "Uses your existing Claude Code or Anthropic API-key for Claude-based chat"; + } + + @Override + public boolean requiresApiKey() { + return true; + } + + @Override + public String defaultModel() { + return "claude-sonnet-4-6"; + } + + @Override + public Optional systemWideToken() { + Optional token = AnthropicClaudeCodeOAuthTokenExtractor.getToken(); + if (token.isEmpty()) return Optional.empty(); + + return Optional.of(new SystemWideToken("Claude Code", CLAUDE_CODE_OATH_TOKEN_PLACEHOLDER)); + } +} diff --git a/base/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackend.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackend.java similarity index 100% rename from base/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackend.java rename to providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackend.java diff --git a/base/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeOAuthTokenExtractor.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeOAuthTokenExtractor.java similarity index 100% rename from base/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeOAuthTokenExtractor.java rename to providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeOAuthTokenExtractor.java diff --git a/base/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java b/providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java similarity index 100% rename from base/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java rename to providers/anthropic/src/main/java/ai/javaclaw/providers/anthropic/AnthropticClaudeCodeConfiguration.java diff --git a/base/src/test/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackendTest.java b/providers/anthropic/src/test/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackendTest.java similarity index 100% rename from base/src/test/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackendTest.java rename to providers/anthropic/src/test/java/ai/javaclaw/providers/anthropic/AnthropicClaudeCodeBackendTest.java diff --git a/providers/google/build.gradle b/providers/google/build.gradle new file mode 100644 index 0000000..c0792c2 --- /dev/null +++ b/providers/google/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAIAgentOnboardingProvider.java b/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAIAgentOnboardingProvider.java new file mode 100644 index 0000000..8f656b2 --- /dev/null +++ b/providers/google/src/main/java/ai/javaclaw/providers/google/genai/GoogleGenAIAgentOnboardingProvider.java @@ -0,0 +1,33 @@ +package ai.javaclaw.providers.google.genai; + +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import org.springframework.stereotype.Component; + +@Component +public class GoogleGenAIAgentOnboardingProvider implements AgentOnboardingProvider { + + @Override + public String getId() { + return "google.genai"; + } + + @Override + public String getLabel() { + return "Google Gen AI"; + } + + @Override + public String slogan() { + return "Use Google Gen AI (like Gemini) as an agent"; + } + + @Override + public boolean requiresApiKey() { + return true; + } + + @Override + public String defaultModel() { + return "gemini-3-flash-preview"; + } +} diff --git a/providers/ollama/build.gradle b/providers/ollama/build.gradle new file mode 100644 index 0000000..e437083 --- /dev/null +++ b/providers/ollama/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-starter-model-ollama' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaAgentOnboardingProvider.java b/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaAgentOnboardingProvider.java new file mode 100644 index 0000000..155d81a --- /dev/null +++ b/providers/ollama/src/main/java/ai/javaclaw/providers/ollama/OllamaAgentOnboardingProvider.java @@ -0,0 +1,33 @@ +package ai.javaclaw.providers.ollama; + +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import org.springframework.stereotype.Component; + +@Component +public class OllamaAgentOnboardingProvider implements AgentOnboardingProvider { + + @Override + public String getId() { + return "ollama"; + } + + @Override + public String getLabel() { + return "Ollama"; + } + + @Override + public String slogan() { + return "Local-first setup. No API key required."; + } + + @Override + public boolean requiresApiKey() { + return false; + } + + @Override + public String defaultModel() { + return "qwen3.5:27b"; + } +} diff --git a/providers/openai/build.gradle b/providers/openai/build.gradle new file mode 100644 index 0000000..1bd8de5 --- /dev/null +++ b/providers/openai/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' +} + +dependencies { + implementation project(':base') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAIAgentOnboardingProvider.java b/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAIAgentOnboardingProvider.java new file mode 100644 index 0000000..f981f98 --- /dev/null +++ b/providers/openai/src/main/java/ai/javaclaw/providers/openai/OpenAIAgentOnboardingProvider.java @@ -0,0 +1,33 @@ +package ai.javaclaw.providers.openai; + +import ai.javaclaw.onboarding.AgentOnboardingProvider; +import org.springframework.stereotype.Component; + +@Component +public class OpenAIAgentOnboardingProvider implements AgentOnboardingProvider { + + @Override + public String getId() { + return "openai"; + } + + @Override + public String getLabel() { + return "OpenAI"; + } + + @Override + public String slogan() { + return "Uses OpenAI API key for ChatGPT as an agent."; + } + + @Override + public boolean requiresApiKey() { + return true; + } + + @Override + public String defaultModel() { + return "gpt-5.4"; + } +} diff --git a/settings.gradle b/settings.gradle index 4e8fd8a..6bdc834 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,5 +5,10 @@ include 'plugins' include 'plugins:telegram' include 'plugins:playwright' include 'plugins:brave' +include 'providers' +include 'providers:anthropic' +include 'providers:google' +include 'providers:ollama' +include 'providers:openai' include 'app'