Skip to content
2 changes: 1 addition & 1 deletion app/src/main/java/ai/javaclaw/JavaClawApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public void run(ApplicationArguments args) throws Exception {
@EventListener
public void on(ConfigurationChangedEvent configurationChangedEvent) {
ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class);

Thread thread = new Thread(() -> {
try {
Thread.sleep(2000);
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/java/ai/javaclaw/api/IndexController.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ public IndexController(Environment environment) {

@GetMapping({"/", "/index"})
public String index() {
String provider = environment.getProperty("spring.ai.model.chat", "unknown");
boolean providerConfigured = environment.getProperty("agent.llm.providers.default.provider") != null;
boolean onboardingCompleted = environment.getProperty("agent.onboarding.completed", Boolean.class, false);
if (!"unknown".equals(provider) || onboardingCompleted) {
if (providerConfigured || onboardingCompleted) {
return "redirect:/chat";
}
return "redirect:/onboarding/";
}

@GetMapping("/settings/agents")
public String agents() {
return "settings/agents";
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.javaclaw.onboarding.steps;

import ai.javaclaw.configuration.ConfigurationManager;
import ai.javaclaw.llm.LlmProviderProperties;
import ai.javaclaw.onboarding.AgentOnboardingProvider;
import ai.javaclaw.onboarding.AgentOnboardingProviders;
import ai.javaclaw.onboarding.OnboardingProvider;
Expand Down Expand Up @@ -40,7 +41,7 @@ public S2_ProviderStep(AgentOnboardingProviders agentOnboardingProviders, Enviro
@Override
public void prepareModel(Map<String, Object> session, Map<String, Object> model) {
model.put("providers", agentOnboardingProviders.getAll());
model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", "")));
model.put("selectedProvider", session.getOrDefault(SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", "")));
}

@Override
Expand All @@ -63,14 +64,24 @@ public String processStep(Map<String, String> formParams, Map<String, Object> se
@Override
public void saveConfiguration(Map<String, Object> session, ConfigurationManager configurationManager) throws IOException {
String providerId = (String) session.get(SESSION_PROVIDER);
if (providerId == null || providerId.isBlank()) {
return;
}
String model = (String) session.get(SESSION_MODEL);
String apiKey = (String) session.getOrDefault(SESSION_API_KEY, "");

AgentOnboardingProvider agentOnboardingProvider = agentOnboardingProviders.getById(providerId);

// The single provider configured during onboarding becomes the "default" named provider.
String base = "agent.llm.providers." + LlmProviderProperties.DEFAULT_PROVIDER_NAME;
Map<String, Object> props = new LinkedHashMap<>();
agentOnboardingProvider.saveProperty(props, "chat.options.model", model);
agentOnboardingProvider.saveProperty(props, "api-key", apiKey);
props.put("spring.ai.model.chat", agentOnboardingProvider.getId().replace(".", "-"));
props.put(base + ".provider", agentOnboardingProvider.getId());
if (model != null && !model.isBlank()) {
props.put(base + ".model", model);
}
if (apiKey != null && !apiKey.isBlank()) {
props.put(base + ".api-key", apiKey);
}
configurationManager.updateProperties(props);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.javaclaw.onboarding.steps;

import ai.javaclaw.llm.LlmProviderProperties;
import ai.javaclaw.onboarding.AgentOnboardingProvider;
import ai.javaclaw.onboarding.AgentOnboardingProvider.SystemWideToken;
import ai.javaclaw.onboarding.AgentOnboardingProviders;
Expand Down Expand Up @@ -33,17 +34,22 @@ public S3_CredentialsStep(Environment env, AgentOnboardingProviders agentOnboard

@Override
public void prepareModel(Map<String, Object> session, Map<String, Object> model) {
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", ""));
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", ""));
AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null);
if (provider == null) return;

// Onboarding configures the single "default" provider under agent.llm.providers.default.
String base = "agent.llm.providers." + LlmProviderProperties.DEFAULT_PROVIDER_NAME;
String modelKey = base + ".model";
String apiKeyKey = base + ".api-key";

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"), "");
String existingModel = env.getProperty(modelKey, "");
String existingApiKey = env.getProperty(apiKeyKey, "");
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("providerApiPropertyKey", apiKeyKey);
model.put("chatModelPropertyKey", modelKey);
model.put("requiresApiKey", provider.requiresApiKey());
model.put("apiKey", session.getOrDefault(S2_ProviderStep.SESSION_API_KEY, existingApiKey));
model.put("model", currentModel != null && !currentModel.isBlank() ? currentModel : (!existingModel.isBlank() ? existingModel : provider.defaultModel()));
Expand All @@ -52,7 +58,7 @@ public void prepareModel(Map<String, Object> session, Map<String, Object> model)

@Override
public String processStep(Map<String, String> formParams, Map<String, Object> session) {
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", ""));
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", ""));
AgentOnboardingProvider provider = agentOnboardingProviders.findById(providerId).orElse(null);
if (provider == null) {
return "Provider selection is missing. Please go back and select a provider.";
Expand Down Expand Up @@ -83,7 +89,7 @@ public String processStep(Map<String, String> formParams, Map<String, Object> se
}

AgentOnboardingProvider getAgentProvider(Map<String, Object> session) {
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("spring.ai.model.chat", ""));
String providerId = (String) session.getOrDefault(S2_ProviderStep.SESSION_PROVIDER, env.getProperty("agent.llm.providers.default.provider", ""));
return agentOnboardingProviders.getById(providerId);
}
}
231 changes: 231 additions & 0 deletions app/src/main/java/ai/javaclaw/providers/api/SubagentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package ai.javaclaw.providers.api;

import ai.javaclaw.configuration.ConfigurationManager;
import ai.javaclaw.llm.LlmProviderProperties;
import ai.javaclaw.llm.LlmProviderProperties.ProviderConfig;
import ai.javaclaw.llm.SubagentStore;
import ai.javaclaw.llm.SubagentStore.Subagent;
import ai.javaclaw.onboarding.AgentOnboardingProvider;
import ai.javaclaw.onboarding.AgentOnboardingProviders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

/**
* REST API for the agents page. Each agent's structured configuration (provider type, model, API key)
* is stored in {@code application.yaml} under {@code agent.llm.providers.<name>}, and its description +
* instructions are stored in {@code workspace/agents/<name>.md}. The agent's subagent file routes to
* its own provider entry (its {@code model:} frontmatter is the agent name), so it runs on its own
* provider/model/key. Provider options for the dropdown come from the supported
* {@link AgentOnboardingProvider} beans.
*/
@RestController
@RequestMapping("/api/agents")
public class SubagentController {

private static final Pattern NAME_PATTERN = Pattern.compile("[a-z0-9-]+");
private static final String PROVIDERS_PREFIX = "agent.llm.providers";

private final SubagentStore store;
private final AgentOnboardingProviders providers;
private final LlmProviderProperties providerProperties;
private final ConfigurationManager configurationManager;

public SubagentController(SubagentStore store,
AgentOnboardingProviders providers,
LlmProviderProperties providerProperties,
ConfigurationManager configurationManager) {
this.store = store;
this.providers = providers;
this.providerProperties = providerProperties;
this.configurationManager = configurationManager;
}

public record AgentSummary(String name, String provider, String providerLabel, String model, String description) {
}

public record AgentDetail(String name, String provider, String providerLabel, String model, String apiKeyMasked,
String description, String content) {
}

/** Request body for create/update. */
public record AgentForm(String name, String provider, String model, String apiKey, String description,
String content) {
}

/** A selectable LLM provider for the dropdown. */
public record ProviderOption(String id, String label, String defaultModel) {
}

@GetMapping("/options/providers")
public List<ProviderOption> providerOptions() {
return providers.getAll().stream()
.map(p -> new ProviderOption(p.getId(), p.getLabel(), p.defaultModel()))
.toList();
}

@GetMapping
public List<AgentSummary> list() {
return store.list().stream().map(s -> {
ProviderConfig config = providerProperties.getProviders().get(s.name());
String provider = config != null ? config.getProvider() : null;
String model = config != null ? config.getModel() : null;
return new AgentSummary(s.name(), provider, labelFor(provider), model, s.description());
}).toList();
}

@GetMapping("/{name}")
public ResponseEntity<AgentDetail> get(@PathVariable String name) {
if (!isValidName(name)) {
return ResponseEntity.notFound().build();
}
return store.get(name)
.map(s -> {
ProviderConfig config = providerProperties.getProviders().get(name);
String provider = config != null ? config.getProvider() : null;
String model = config != null ? config.getModel() : null;
String maskedKey = config != null ? maskApiKey(config.getApiKey()) : "";
return ResponseEntity.ok(new AgentDetail(s.name(), provider, labelFor(provider), model,
maskedKey, s.description(), s.content()));
})
.orElseGet(() -> ResponseEntity.notFound().build());
}

@PostMapping
public ResponseEntity<?> create(@RequestBody AgentForm form) {
String name = form.name() == null ? "" : form.name().trim();
if (name.isBlank() || !NAME_PATTERN.matcher(name).matches()) {
return badRequest("Agent name must match [a-z0-9-]+");
}
if (providerProperties.getProviders().containsKey(name) || store.exists(name)) {
return badRequest("An agent named '" + name + "' already exists");
}
String error = validateProvider(form.provider());
if (error != null) {
return badRequest(error);
}
return save(name, form, HttpStatus.CREATED);
}

@PutMapping("/{name}")
public ResponseEntity<?> update(@PathVariable String name, @RequestBody AgentForm form) {
if (!isValidName(name) || !store.exists(name)) {
return ResponseEntity.notFound().build();
}
String error = validateProvider(form.provider());
if (error != null) {
return badRequest(error);
}
return save(name, form, HttpStatus.OK);
}

@DeleteMapping("/{name}")
public ResponseEntity<?> delete(@PathVariable String name) {
if (!isValidName(name)) {
return ResponseEntity.notFound().build();
}
try {
boolean removedFile = store.delete(name);
boolean removedConfig = providerProperties.getProviders().containsKey(name);
if (!removedFile && !removedConfig) {
return ResponseEntity.notFound().build();
}
// Removing the provider entry fires a configuration change that rebuilds routing.
configurationManager.removeProperty(PROVIDERS_PREFIX + "." + name);
return ResponseEntity.noContent().build();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(error("Failed to delete agent: " + e.getMessage()));
}
}

private ResponseEntity<?> save(String name, AgentForm form, HttpStatus status) {
try {
// 1) Instructions + description -> workspace/agents/<name>.md. The subagent routes to its
// own provider entry, so its routing model is the agent name.
store.save(new Subagent(name, name, form.description(), form.content()));

// 2) Structured config -> application.yaml under agent.llm.providers.<name>. This fires a
// configuration change that rebuilds the chat client registry + subagent routing.
String base = PROVIDERS_PREFIX + "." + name;
Map<String, Object> props = new LinkedHashMap<>();
props.put(base + ".provider", form.provider());
if (notBlank(form.model())) {
props.put(base + ".model", form.model().trim());
}
if (notBlank(form.apiKey())) {
props.put(base + ".api-key", form.apiKey().trim());
}
configurationManager.updateProperties(props);

ProviderConfig saved = providerProperties.getProviders().get(name);
String maskedKey = saved != null ? maskApiKey(saved.getApiKey()) : maskApiKey(form.apiKey());
return ResponseEntity.status(status).body(new AgentDetail(name, form.provider(), labelFor(form.provider()),
orEmpty(form.model()), maskedKey, orEmpty(form.description()), orEmpty(form.content())));
} catch (IOException e) {
return ResponseEntity.internalServerError().body(error("Failed to save agent: " + e.getMessage()));
}
}

private String validateProvider(String provider) {
if (provider == null || provider.isBlank()) {
return "A provider is required";
}
if (providers.findById(provider).isEmpty()) {
return "Unknown provider: " + provider;
}
return null;
}

private boolean isValidName(String name) {
return name != null && NAME_PATTERN.matcher(name).matches();
}

private String labelFor(String providerId) {
if (providerId == null || providerId.isBlank()) {
return providerId;
}
return providers.findById(providerId).map(AgentOnboardingProvider::getLabel).orElse(providerId);
}

private static String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.isBlank()) {
return "";
}
String key = apiKey.trim();
if (key.length() <= 7) {
return "••••";
}
return key.substring(0, 3) + "..." + key.substring(key.length() - 4);
}

private static boolean notBlank(String value) {
return value != null && !value.isBlank();
}

private static String orEmpty(String value) {
return value == null ? "" : value;
}

private static ResponseEntity<Map<String, Object>> badRequest(String message) {
return ResponseEntity.badRequest().body(error(message));
}

private static Map<String, Object> error(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", message);
return body;
}
}
7 changes: 6 additions & 1 deletion app/src/main/resources/templates/chat.html.peb
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@
<div class="select is-small is-dark" id="channel-selector"></div>
</div>
<div class="navbar-item">
<a href="/onboarding" class="button is-small is-dark">
<a href="/settings/agents" class="button is-small is-dark" hx-boost="false">
<span>Agents</span>
</a>
</div>
<div class="navbar-item">
<a href="/onboarding" class="button is-small is-dark" hx-boost="false">
<span>Settings</span>
</a>
</div>
Expand Down
Loading
Loading