Skip to content
Open
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
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, form.model(), 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