Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 30 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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/<name>/` 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.

---

Expand Down Expand Up @@ -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()`.
Expand All @@ -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.
5 changes: 5 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
88 changes: 0 additions & 88 deletions app/src/main/java/ai/javaclaw/SupportedProvider.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<OnboardingProvider> steps;

public OnboardingController(Environment environment, ConfigurationManager configurationManager, List<OnboardingProvider> steps) {
this.environment = environment;
public OnboardingController(AgentOnboardingProviders agentOnboardingProviders, ConfigurationManager configurationManager, List<OnboardingProvider> steps) {
this.agentOnboardingProviders = agentOnboardingProviders;
this.configurationManager = configurationManager;
this.steps = steps;
}
Expand All @@ -41,15 +40,16 @@ 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();
}

// 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);
}

Expand All @@ -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<String, String> formParams, HttpSession session, RedirectAttributes redirectAttrs) {
OnboardingProvider provider = findProvider(stepId);
OnboardingProvider provider = findOnboardingProvider(stepId);
if (provider == null) {
return "redirect:/onboarding/" + steps.getFirst().getStepId();
}
Expand All @@ -88,17 +88,16 @@ public String postStep(@PathVariable String stepId, @RequestParam Map<String, St

String nextId = nextStepId(stepId);
if (COMPLETE_STEP_ID.equals(nextId)) {
String providerLabel = saveAndComplete(session);
String providerLabel = agentOnboardingProviders.getById((String) session.getAttribute("onboarding.provider")).getLabel();
if (providerLabel != null) redirectAttrs.addFlashAttribute("providerLabel", providerLabel);
saveAndComplete(session);
}

return "redirect:/onboarding/" + (nextId != null ? nextId : COMPLETE_STEP_ID);
}

private String saveAndComplete(HttpSession session) {
private void saveAndComplete(HttpSession session) {
Map<String, Object> 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);
Expand All @@ -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()
Expand Down
28 changes: 13 additions & 15 deletions app/src/main/java/ai/javaclaw/onboarding/steps/S2_ProviderStep.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}

Expand All @@ -36,7 +39,7 @@ public S2_ProviderStep(Environment env) {

@Override
public void prepareModel(Map<String, Object> session, Map<String, Object> model) {
model.put("providers", SupportedProvider.supportedAgents());
model.put("providers", agentOnboardingProviders.getAll());
model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")));
}

Expand All @@ -46,17 +49,14 @@ public String processStep(Map<String, String> formParams, Map<String, Object> 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;
}

Expand All @@ -66,13 +66,11 @@ public void saveConfiguration(Map<String, Object> 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<String, Object> 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);
}
}
Loading
Loading