diff --git a/src/main/java/de/neitzel/storyteller/business/SettingsDto.java b/src/main/java/de/neitzel/storyteller/business/SettingsDto.java new file mode 100644 index 0000000..9b243a5 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/SettingsDto.java @@ -0,0 +1,69 @@ +package de.neitzel.storyteller.business; + +/** + * Effective settings for the current user (DB overrides + config defaults). + * Used by story generation and the settings API response. + */ +public class SettingsDto { + + /** Ollama server base URL. */ + private String ollamaBaseUrl; + /** Model name to use for generation. */ + private String ollamaModel; + /** Context character limit before compaction. */ + private int contextCharLimit; + + /** + * Returns the Ollama base URL. + * + * @return base URL + */ + public String getOllamaBaseUrl() { + return ollamaBaseUrl; + } + + /** + * Sets the Ollama base URL. + * + * @param ollamaBaseUrl base URL + */ + public void setOllamaBaseUrl(final String ollamaBaseUrl) { + this.ollamaBaseUrl = ollamaBaseUrl; + } + + /** + * Returns the selected model name. + * + * @return model name + */ + public String getOllamaModel() { + return ollamaModel; + } + + /** + * Sets the model name. + * + * @param ollamaModel model name + */ + public void setOllamaModel(final String ollamaModel) { + this.ollamaModel = ollamaModel; + } + + /** + * Returns the context character limit. + * + * @return limit + */ + public int getContextCharLimit() { + return contextCharLimit; + } + + /** + * Sets the context character limit. + * + * @param contextCharLimit limit + */ + public void setContextCharLimit(final int contextCharLimit) { + this.contextCharLimit = contextCharLimit; + } +} diff --git a/src/main/java/de/neitzel/storyteller/business/SettingsService.java b/src/main/java/de/neitzel/storyteller/business/SettingsService.java new file mode 100644 index 0000000..c73c87b --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/SettingsService.java @@ -0,0 +1,203 @@ +package de.neitzel.storyteller.business; + +import de.neitzel.storyteller.data.entity.SettingEntity; +import de.neitzel.storyteller.data.repository.SettingRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import java.util.Optional; + +/** + * Resolves effective settings (DB overrides + config defaults) and updates user/global settings. + */ +@ApplicationScoped +public class SettingsService { + + /** Global key: Ollama base URL. */ + public static final String KEY_OLLAMA_BASE_URL = "ollama.base_url"; + /** Global key: default model. */ + public static final String KEY_OLLAMA_DEFAULT_MODEL = "ollama.default_model"; + /** Global key: context character limit. */ + public static final String KEY_CONTEXT_CHAR_LIMIT = "storyteller.context_char_limit"; + /** Per-user key: selected model override. */ + public static final String KEY_OLLAMA_MODEL = "ollama.model"; + + private static final int MIN_CONTEXT_LIMIT = 1000; + private static final int MAX_CONTEXT_LIMIT = 200_000; + + private final SettingRepository settingRepository; + + @ConfigProperty(name = "storyteller.ollama.base-url", defaultValue = "http://localhost:11434") + String defaultOllamaBaseUrl; + + @ConfigProperty(name = "storyteller.ollama.default-model", defaultValue = "llama3.2") + String defaultOllamaModel; + + @ConfigProperty(name = "storyteller.context-char-limit", defaultValue = "12000") + int defaultContextCharLimit; + + /** + * Constructs the service with the setting repository. + * Config defaults are injected via {@link ConfigProperty}. + * + * @param settingRepository setting persistence + */ + @Inject + public SettingsService(final SettingRepository settingRepository) { + this.settingRepository = settingRepository; + } + + /** + * Constructor for tests: allows passing default values when config is not available. + * + * @param settingRepository setting persistence + * @param defaultOllamaBaseUrl default base URL + * @param defaultOllamaModel default model + * @param defaultContextCharLimit default context limit + */ + public SettingsService(final SettingRepository settingRepository, + final String defaultOllamaBaseUrl, + final String defaultOllamaModel, + final int defaultContextCharLimit) { + this.settingRepository = settingRepository; + this.defaultOllamaBaseUrl = defaultOllamaBaseUrl; + this.defaultOllamaModel = defaultOllamaModel; + this.defaultContextCharLimit = defaultContextCharLimit; + } + + /** + * Returns merged settings for the given user (DB overrides + config defaults). + * + * @param userId current user id + * @return effective settings + */ + public SettingsDto getMergedSettings(final Long userId) { + final SettingsDto dto = new SettingsDto(); + dto.setOllamaBaseUrl(getString(KEY_OLLAMA_BASE_URL, null, defaultOllamaBaseUrl)); + dto.setOllamaModel(getString(KEY_OLLAMA_MODEL, userId, null)); + if (dto.getOllamaModel() == null || dto.getOllamaModel().isBlank()) { + dto.setOllamaModel(getString(KEY_OLLAMA_DEFAULT_MODEL, null, defaultOllamaModel)); + } + final String limitStr = getString(KEY_CONTEXT_CHAR_LIMIT, null, String.valueOf(defaultContextCharLimit)); + dto.setContextCharLimit(parseContextLimit(limitStr)); + return dto; + } + + /** + * Updates a setting. Global keys require admin; ollama.model is per-user. + * + * @param key setting key + * @param value new value (null or blank to remove) + * @param userId current user id + * @param isAdmin whether the current user is admin + * @throws WebApplicationException 403 if non-admin tries to set a global key + */ + public void updateSetting(final String key, final String value, final Long userId, final boolean isAdmin) { + if (KEY_OLLAMA_BASE_URL.equals(key) || KEY_OLLAMA_DEFAULT_MODEL.equals(key) || KEY_CONTEXT_CHAR_LIMIT.equals(key)) { + if (!isAdmin) { + throw new WebApplicationException("Only admins can change this setting", Response.Status.FORBIDDEN); + } + upsert(key, value, null); + return; + } + if (KEY_OLLAMA_MODEL.equals(key)) { + upsert(key, value, userId); + return; + } + throw new WebApplicationException("Unknown setting key", Response.Status.BAD_REQUEST); + } + + /** + * Updates settings from the API request (ollamaBaseUrl, ollamaModel, contextCharLimit). + * Validates URL and context limit; enforces admin for global keys. + * + * @param ollamaBaseUrl optional new base URL (admin only) + * @param ollamaModel optional new model (current user) + * @param contextCharLimit optional new limit (admin only) + * @param userId current user id + * @param isAdmin whether current user is admin + */ + @Transactional + public void updateFromRequest(final String ollamaBaseUrl, final String ollamaModel, + final Integer contextCharLimit, final Long userId, final boolean isAdmin) { + if (ollamaBaseUrl != null) { + if (!isAdmin) { + throw new WebApplicationException("Only admins can change Ollama URL", Response.Status.FORBIDDEN); + } + if (!isValidUrl(ollamaBaseUrl)) { + throw new WebApplicationException("Invalid Ollama base URL", Response.Status.BAD_REQUEST); + } + upsert(KEY_OLLAMA_BASE_URL, ollamaBaseUrl.isBlank() ? null : ollamaBaseUrl.strip(), null); + } + if (ollamaModel != null) { + upsert(KEY_OLLAMA_MODEL, ollamaModel.isBlank() ? null : ollamaModel.strip(), userId); + } + if (contextCharLimit != null) { + if (!isAdmin) { + throw new WebApplicationException("Only admins can change context limit", Response.Status.FORBIDDEN); + } + if (contextCharLimit < MIN_CONTEXT_LIMIT || contextCharLimit > MAX_CONTEXT_LIMIT) { + throw new WebApplicationException("Context limit must be between " + MIN_CONTEXT_LIMIT + " and " + MAX_CONTEXT_LIMIT, Response.Status.BAD_REQUEST); + } + upsert(KEY_CONTEXT_CHAR_LIMIT, String.valueOf(contextCharLimit), null); + } + } + + private String getString(final String key, final Long userId, final String fallback) { + final Optional global = settingRepository.findByKeyAndUserId(key, null); + if (userId != null) { + final Optional user = settingRepository.findByKeyAndUserId(key, userId); + if (user.isPresent() && user.get().getValue() != null && !user.get().getValue().isBlank()) { + return user.get().getValue(); + } + } + if (global.isPresent() && global.get().getValue() != null && !global.get().getValue().isBlank()) { + return global.get().getValue(); + } + return fallback; + } + + private void upsert(final String key, final String value, final Long userId) { + settingRepository.findByKeyAndUserId(key, userId).ifPresentOrElse( + existing -> { + if (value == null || value.isBlank()) { + settingRepository.delete(existing); + } else { + existing.setValue(value); + } + }, + () -> { + if (value != null && !value.isBlank()) { + final SettingEntity e = new SettingEntity(); + e.setKey(key); + e.setValue(value); + e.setUserId(userId); + settingRepository.persist(e); + } + } + ); + } + + private static boolean isValidUrl(final String url) { + if (url == null || url.isBlank()) return true; + try { + final String s = url.strip(); + return s.startsWith("http://") || s.startsWith("https://"); + } catch (Exception e) { + return false; + } + } + + private static int parseContextLimit(final String s) { + if (s == null || s.isBlank()) return 12_000; + try { + final int v = Integer.parseInt(s.strip()); + return Math.max(MIN_CONTEXT_LIMIT, Math.min(MAX_CONTEXT_LIMIT, v)); + } catch (NumberFormatException e) { + return 12_000; + } + } +} diff --git a/src/main/java/de/neitzel/storyteller/business/StoryService.java b/src/main/java/de/neitzel/storyteller/business/StoryService.java index 8ad0bf3..39b6c2b 100644 --- a/src/main/java/de/neitzel/storyteller/business/StoryService.java +++ b/src/main/java/de/neitzel/storyteller/business/StoryService.java @@ -6,6 +6,8 @@ import de.neitzel.storyteller.data.entity.StoryEntity; import de.neitzel.storyteller.data.entity.StoryStepEntity; import de.neitzel.storyteller.data.repository.ScenarioRepository; import de.neitzel.storyteller.data.repository.StoryCharacterRepository; +import de.neitzel.storyteller.common.ollama.OllamaClient; +import de.neitzel.storyteller.common.ollama.OllamaUnavailableException; import de.neitzel.storyteller.data.repository.StoryRepository; import de.neitzel.storyteller.data.repository.StoryStepRepository; import de.neitzel.storyteller.fascade.model.CharacterDto; @@ -30,11 +32,8 @@ import java.util.List; @ApplicationScoped public class StoryService { - /** - * Approximate character budget before compaction kicks in. - * In a real system this would be configurable and model-specific. - */ - private static final int CONTEXT_CHAR_LIMIT = 12_000; + /** Approximate tokens per character for num_ctx. */ + private static final int CHARS_PER_TOKEN = 4; /** Story persistence. */ private final StoryRepository storyRepository; @@ -44,23 +43,33 @@ public class StoryService { private final StoryStepRepository stepRepository; /** Scenario persistence (for initial data on story start). */ private final ScenarioRepository scenarioRepository; + /** Settings resolution for Ollama URL, model, and context limit. */ + private final SettingsService settingsService; + /** Ollama API client for story generation. */ + private final OllamaClient ollamaClient; /** - * Constructs the service with required repositories. + * Constructs the service with required repositories and AI dependencies. * * @param storyRepository story persistence * @param characterRepository character persistence * @param stepRepository step persistence * @param scenarioRepository scenario persistence + * @param settingsService settings for Ollama and context limit + * @param ollamaClient Ollama API client */ public StoryService(final StoryRepository storyRepository, final StoryCharacterRepository characterRepository, final StoryStepRepository stepRepository, - final ScenarioRepository scenarioRepository) { + final ScenarioRepository scenarioRepository, + final SettingsService settingsService, + final OllamaClient ollamaClient) { this.storyRepository = storyRepository; this.characterRepository = characterRepository; this.stepRepository = stepRepository; this.scenarioRepository = scenarioRepository; + this.settingsService = settingsService; + this.ollamaClient = ollamaClient; } /** @@ -126,7 +135,8 @@ public class StoryService { characterRepository.persist(ch); }); - final String opening = generateAiContinuation(entity, List.of(), null); + final SettingsDto settings = settingsService.getMergedSettings(userId); + final String opening = generateAiContinuation(entity, List.of(), null, settings); final StoryStepEntity step = new StoryStepEntity(); step.setStoryId(entity.getId()); step.setStepNumber(1); @@ -235,10 +245,13 @@ public class StoryService { replaceCharacters(story, request.getCurrentCharacters()); } + final SettingsDto settings = settingsService.getMergedSettings(userId); + final int contextCharLimit = settings.getContextCharLimit(); + List unmerged = stepRepository.findUnmergedByStory(storyId); boolean compacted = false; - while (estimateContextSize(story, unmerged, request.getDirection()) > CONTEXT_CHAR_LIMIT + while (estimateContextSize(story, unmerged, request.getDirection()) > contextCharLimit && unmerged.size() > 1) { compacted = true; final int half = Math.max(1, unmerged.size() / 2); @@ -253,7 +266,7 @@ public class StoryService { story.setUpdatedAt(LocalDateTime.now()); final int nextNumber = stepRepository.maxStepNumber(storyId) + 1; - final String content = generateAiContinuation(story, unmerged, request.getDirection()); + final String content = generateAiContinuation(story, unmerged, request.getDirection(), settings); final StoryStepEntity step = new StoryStepEntity(); step.setStoryId(storyId); step.setStepNumber(nextNumber); @@ -308,15 +321,50 @@ public class StoryService { } /** - * AI stub: generates the next story segment from the current context. - * In production this would call an LLM. + * Generates the next story segment via Ollama using the current scene, characters, + * unmerged steps, and user direction. + * + * @param story current story entity + * @param unmerged unmerged story steps + * @param direction user direction (may be null for opening) + * @param settings effective Ollama URL, model, and context limit + * @return generated text + * @throws OllamaUnavailableException if Ollama is unreachable or returns an error */ private String generateAiContinuation(final StoryEntity story, final List unmerged, - final String direction) { - return "[AI continuation] Direction: " + direction - + "\nScene: " + truncate(story.getCurrentSceneDescription(), 120) - + "\nUnmerged steps: " + unmerged.size(); + final String direction, + final SettingsDto settings) { + final StringBuilder prompt = new StringBuilder(); + prompt.append("You are a creative story writer. Based on the current scene, characters, "); + prompt.append("and any recent story steps, write the next segment of the story."); + if (direction != null && !direction.isBlank()) { + prompt.append(" The user wants: ").append(direction.trim()); + } + prompt.append("\n\nCurrent scene:\n").append(story.getCurrentSceneDescription()); + prompt.append("\n\nCharacters in this scene:\n"); + for (final StoryCharacterEntity ch : story.getCurrentSceneCharacters()) { + prompt.append("- ").append(ch.getName()).append(" (").append(ch.getRole()).append(")"); + if (ch.getDescription() != null && !ch.getDescription().isBlank()) { + prompt.append(": ").append(ch.getDescription()); + } + prompt.append("\n"); + } + if (!unmerged.isEmpty()) { + prompt.append("\nRecent story steps (not yet merged into scene):\n"); + for (final StoryStepEntity s : unmerged) { + prompt.append("- ").append(s.getContent()).append("\n"); + } + } + prompt.append("\nWrite the next story segment (narrative only, no meta-commentary):"); + + final int numCtx = Math.max(2048, settings.getContextCharLimit() / CHARS_PER_TOKEN); + return ollamaClient.generate( + settings.getOllamaBaseUrl(), + settings.getOllamaModel(), + prompt.toString(), + numCtx + ); } /** Truncates text for display in stubs. */ diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaClient.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaClient.java new file mode 100644 index 0000000..9d53093 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaClient.java @@ -0,0 +1,111 @@ +package de.neitzel.storyteller.common.ollama; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Stateless HTTP client for Ollama API: list models and generate text. + * Base URL and model are passed per call (filled from config/settings). + */ +@ApplicationScoped +public class OllamaClient { + + private static final Duration LIST_TIMEOUT = Duration.ofSeconds(10); + private static final Duration GENERATE_TIMEOUT = Duration.ofSeconds(120); + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + /** + * Constructs the client with default HTTP client and Jackson mapper. + * + * @param objectMapper JSON mapper for request/response bodies + */ + public OllamaClient(final ObjectMapper objectMapper) { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + this.objectMapper = objectMapper; + } + + /** + * Fetches available models from the given Ollama base URL. + * + * @param baseUrl base URL of the Ollama server (e.g. http://localhost:11434) + * @return list of model info; never null + * @throws OllamaUnavailableException if the request fails or Ollama is unreachable + */ + public List listModels(final String baseUrl) { + final URI uri = URI.create(normalizeBase(baseUrl) + "api/tags"); + final HttpRequest request = HttpRequest.newBuilder(uri) + .GET() + .timeout(LIST_TIMEOUT) + .header("Accept", "application/json") + .build(); + try { + final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new OllamaUnavailableException("Ollama returned " + response.statusCode() + " for list models"); + } + final OllamaTagsResponse body = objectMapper.readValue(response.body(), OllamaTagsResponse.class); + return body.getModels(); + } catch (final OllamaUnavailableException e) { + throw e; + } catch (final Exception e) { + throw new OllamaUnavailableException("Ollama unreachable: " + e.getMessage(), e); + } + } + + /** + * Generates text using the given model and prompt. + * + * @param baseUrl base URL of the Ollama server + * @param model model name (e.g. llama3.2) + * @param prompt full prompt text + * @param numContextTokens context window size in tokens (e.g. contextCharLimit / 4) + * @return generated response text + * @throws OllamaUnavailableException if the request fails or Ollama is unreachable + */ + public String generate(final String baseUrl, final String model, final String prompt, final int numContextTokens) { + final URI uri = URI.create(normalizeBase(baseUrl) + "api/generate"); + final OllamaGenerateRequest body = new OllamaGenerateRequest(); + body.setModel(model); + body.setPrompt(prompt); + body.setStream(false); + body.setOptions(Map.of("num_ctx", numContextTokens)); + try { + final String json = objectMapper.writeValueAsString(body); + final HttpRequest request = HttpRequest.newBuilder(uri) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .timeout(GENERATE_TIMEOUT) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .build(); + final HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new OllamaUnavailableException("Ollama returned " + response.statusCode() + " for generate"); + } + final OllamaGenerateResponse parsed = objectMapper.readValue(response.body(), OllamaGenerateResponse.class); + return parsed.getResponse() != null ? parsed.getResponse() : ""; + } catch (final OllamaUnavailableException e) { + throw e; + } catch (final Exception e) { + throw new OllamaUnavailableException("Ollama generate failed: " + e.getMessage(), e); + } + } + + private static String normalizeBase(final String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + return "http://localhost:11434/"; + } + final String s = baseUrl.strip(); + return s.endsWith("/") ? s : s + "/"; + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateRequest.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateRequest.java new file mode 100644 index 0000000..976c858 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateRequest.java @@ -0,0 +1,91 @@ +package de.neitzel.storyteller.common.ollama; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Request body for Ollama POST /api/generate. + */ +@JsonInclude(Include.NON_NULL) +public class OllamaGenerateRequest { + + private String model; + private String prompt; + private Boolean stream = Boolean.FALSE; + private Map options; + + /** + * Returns the model name. + * + * @return model name + */ + public String getModel() { + return model; + } + + /** + * Sets the model name. + * + * @param model model name + */ + public void setModel(final String model) { + this.model = model; + } + + /** + * Returns the prompt text. + * + * @return prompt + */ + public String getPrompt() { + return prompt; + } + + /** + * Sets the prompt. + * + * @param prompt prompt text + */ + public void setPrompt(final String prompt) { + this.prompt = prompt; + } + + /** + * Returns whether to stream the response. + * + * @return stream flag + */ + public Boolean getStream() { + return stream; + } + + /** + * Sets the stream flag. + * + * @param stream stream flag + */ + public void setStream(final Boolean stream) { + this.stream = stream; + } + + /** + * Returns optional model options (e.g. num_ctx). + * + * @return options map + */ + @JsonProperty("options") + public Map getOptions() { + return options; + } + + /** + * Sets the options map. + * + * @param options options map + */ + public void setOptions(final Map options) { + this.options = options; + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateResponse.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateResponse.java new file mode 100644 index 0000000..1219436 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaGenerateResponse.java @@ -0,0 +1,32 @@ +package de.neitzel.storyteller.common.ollama; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response body of Ollama POST /api/generate (non-streaming). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaGenerateResponse { + + private String response; + + /** + * Returns the generated text. + * + * @return response text + */ + public String getResponse() { + return response; + } + + /** + * Sets the response text. + * + * @param response response text + */ + @JsonProperty("response") + public void setResponse(final String response) { + this.response = response; + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaModelInfo.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaModelInfo.java new file mode 100644 index 0000000..c4653aa --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaModelInfo.java @@ -0,0 +1,32 @@ +package de.neitzel.storyteller.common.ollama; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO for a single model in the Ollama /api/tags response. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaModelInfo { + + private String name; + + /** + * Returns the model name (e.g. "llama3.2:latest"). + * + * @return model name + */ + public String getName() { + return name; + } + + /** + * Sets the model name. + * + * @param name model name + */ + @JsonProperty("name") + public void setName(final String name) { + this.name = name; + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaTagsResponse.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaTagsResponse.java new file mode 100644 index 0000000..95b7abd --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaTagsResponse.java @@ -0,0 +1,34 @@ +package de.neitzel.storyteller.common.ollama; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.List; + +/** + * Response body of Ollama GET /api/tags. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaTagsResponse { + + private List models = Collections.emptyList(); + + /** + * Returns the list of available models. + * + * @return list of model info + */ + public List getModels() { + return models; + } + + /** + * Sets the list of models. + * + * @param models list of model info + */ + @JsonProperty("models") + public void setModels(final List models) { + this.models = models != null ? models : Collections.emptyList(); + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/ollama/OllamaUnavailableException.java b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaUnavailableException.java new file mode 100644 index 0000000..ea70310 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/ollama/OllamaUnavailableException.java @@ -0,0 +1,27 @@ +package de.neitzel.storyteller.common.ollama; + +/** + * Thrown when the Ollama service is unreachable or returns an error. + * Allows the API layer to return 503 or a user-friendly message. + */ +public class OllamaUnavailableException extends RuntimeException { + + /** + * Constructs with a message. + * + * @param message error message + */ + public OllamaUnavailableException(final String message) { + super(message); + } + + /** + * Constructs with a message and cause. + * + * @param message error message + * @param cause cause + */ + public OllamaUnavailableException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/SettingEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/SettingEntity.java new file mode 100644 index 0000000..c9a3d02 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/SettingEntity.java @@ -0,0 +1,70 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * JPA entity for a single setting (global or per-user). + * user_id = null means global setting. + */ +@Entity +@Table(name = "setting") +public class SettingEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Setting key (e.g. ollama.base_url, ollama.model). Mapped as setting_key to avoid H2 reserved word. */ + @Column(name = "setting_key", nullable = false, length = 128) + private String key; + + /** Setting value. Mapped as setting_value to avoid H2 reserved word. */ + @Lob + @Column(name = "setting_value") + private String value; + + /** User id for per-user settings; null for global. */ + @Column(name = "user_id") + private Long userId; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return setting key */ + public String getKey() { return key; } + /** @param key setting key */ + public void setKey(final String key) { this.key = key; } + + /** @return setting value */ + public String getValue() { return value; } + /** @param value setting value */ + public void setValue(final String value) { this.value = value; } + + /** @return user id or null for global */ + public Long getUserId() { return userId; } + /** @param userId user id or null */ + public void setUserId(final Long userId) { this.userId = userId; } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SettingEntity that = (SettingEntity) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/SettingRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/SettingRepository.java new file mode 100644 index 0000000..b083eab --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/SettingRepository.java @@ -0,0 +1,41 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.SettingEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; + +/** + * Panache repository for {@link SettingEntity} (global and per-user settings). + */ +@ApplicationScoped +public class SettingRepository implements PanacheRepository { + + /** + * Finds a setting by key and optional user id. + * + * @param key setting key + * @param userId user id, or null for global + * @return optional containing the setting if found + */ + public Optional findByKeyAndUserId(final String key, final Long userId) { + if (userId == null) { + return find("key = ?1 and userId is null", key).firstResultOptional(); + } + return find("key = ?1 and userId = ?2", key, userId).firstResultOptional(); + } + + /** + * Deletes the setting for the given key and user id (if present). + * + * @param key setting key + * @param userId user id, or null for global + */ + public void deleteByKeyAndUserId(final String key, final Long userId) { + if (userId == null) { + delete("key = ?1 and userId is null", key); + } else { + delete("key = ?1 and userId = ?2", key, userId); + } + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/OllamaUnavailableExceptionMapper.java b/src/main/java/de/neitzel/storyteller/fascade/rest/OllamaUnavailableExceptionMapper.java new file mode 100644 index 0000000..66e162b --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/OllamaUnavailableExceptionMapper.java @@ -0,0 +1,22 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.common.ollama.OllamaUnavailableException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +/** + * Maps {@link OllamaUnavailableException} to HTTP 503 so the UI can show + * a clear message (e.g. "Ollama unavailable"). + */ +@Provider +public class OllamaUnavailableExceptionMapper implements ExceptionMapper { + + /** {@inheritDoc} */ + @Override + public Response toResponse(final OllamaUnavailableException exception) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(exception.getMessage()) + .build(); + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/SettingsResource.java b/src/main/java/de/neitzel/storyteller/fascade/rest/SettingsResource.java new file mode 100644 index 0000000..04ed478 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/SettingsResource.java @@ -0,0 +1,81 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.business.SettingsDto; +import de.neitzel.storyteller.business.SettingsService; +import de.neitzel.storyteller.common.ollama.OllamaClient; +import de.neitzel.storyteller.common.ollama.OllamaModelInfo; +import de.neitzel.storyteller.common.security.AuthContext; +import de.neitzel.storyteller.fascade.api.SettingsApi; +import de.neitzel.storyteller.fascade.model.Settings; +import de.neitzel.storyteller.fascade.model.UpdateSettingsRequest; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; + +/** + * REST facade for application and user settings. Exposes Ollama URL, model, context limit, + * and available models from the configured Ollama server. + */ +@ApplicationScoped +@RolesAllowed({"user", "admin"}) +public class SettingsResource implements SettingsApi { + + private final SettingsService settingsService; + private final OllamaClient ollamaClient; + private final AuthContext authContext; + + /** + * Constructs the resource with dependencies. + * + * @param settingsService settings merge and update + * @param ollamaClient client to list Ollama models + * @param authContext current user and admin flag + */ + public SettingsResource(final SettingsService settingsService, + final OllamaClient ollamaClient, + final AuthContext authContext) { + this.settingsService = settingsService; + this.ollamaClient = ollamaClient; + this.authContext = authContext; + } + + /** {@inheritDoc} */ + @Override + public Settings getSettings() { + final Long userId = authContext.getUserId(); + final SettingsDto merged = settingsService.getMergedSettings(userId); + List availableModels = new ArrayList<>(); + try { + final List models = ollamaClient.listModels(merged.getOllamaBaseUrl()); + for (final OllamaModelInfo m : models) { + availableModels.add(new de.neitzel.storyteller.fascade.model.OllamaModelInfo().name(m.getName())); + } + } catch (Exception e) { + // Leave availableModels empty; UI can show "Ollama unreachable" + } + return new Settings() + .ollamaBaseUrl(merged.getOllamaBaseUrl()) + .ollamaModel(merged.getOllamaModel()) + .contextCharLimit(merged.getContextCharLimit()) + .availableModels(availableModels); + } + + /** {@inheritDoc} */ + @Override + public Settings updateSettings(final UpdateSettingsRequest updateSettingsRequest) { + if (updateSettingsRequest == null) { + return getSettings(); + } + final Long userId = authContext.getUserId(); + final boolean isAdmin = authContext.isAdmin(); + settingsService.updateFromRequest( + updateSettingsRequest.getOllamaBaseUrl(), + updateSettingsRequest.getOllamaModel(), + updateSettingsRequest.getContextCharLimit(), + userId, + isAdmin + ); + return getSettings(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7f6f3f7..738b7e7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,3 +22,8 @@ quarkus.http.auth.permission.protected.paths=/api/* quarkus.http.auth.permission.protected.policy=authenticated quarkus.http.auth.permission.static.paths=/* quarkus.http.auth.permission.static.policy=permit + +# Ollama integration (defaults; overridable via settings API) +storyteller.ollama.base-url=http://localhost:11434 +storyteller.ollama.default-model=llama3.2 +storyteller.context-char-limit=12000 diff --git a/src/main/resources/db/migration/db.changelog-master.yaml b/src/main/resources/db/migration/db.changelog-master.yaml index 764068a..3bc88cd 100644 --- a/src/main/resources/db/migration/db.changelog-master.yaml +++ b/src/main/resources/db/migration/db.changelog-master.yaml @@ -253,6 +253,68 @@ databaseChangeLog: constraints: nullable: false + - changeSet: + id: 006-create-setting + author: storyteller + comment: Key-value settings; user_id NULL = global, else per-user + changes: + - createTable: + tableName: setting + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: setting_key + type: VARCHAR(128) + constraints: + nullable: false + - column: + name: setting_value + type: TEXT + - column: + name: user_id + type: BIGINT + constraints: + foreignKeyName: fk_setting_user + references: app_user(id) + + - changeSet: + id: 006b-rename-setting-key + author: storyteller + comment: Rename reserved word column key to setting_key for H2 compatibility + preConditions: + - onFail: MARK_RAN + - columnExists: + tableName: setting + columnName: key + changes: + - renameColumn: + tableName: setting + oldColumnName: key + newColumnName: setting_key + columnDataType: VARCHAR(128) + + - changeSet: + id: 006c-rename-setting-value + author: storyteller + comment: Rename reserved word column value to setting_value for H2 compatibility + preConditions: + - onFail: MARK_RAN + - columnExists: + tableName: setting + columnName: value + changes: + - renameColumn: + tableName: setting + oldColumnName: value + newColumnName: setting_value + columnDataType: TEXT + - changeSet: id: 005-seed-admin author: storyteller diff --git a/src/main/resources/openapi/story-teller-api.yaml b/src/main/resources/openapi/story-teller-api.yaml index c29a178..2ae6dd1 100644 --- a/src/main/resources/openapi/story-teller-api.yaml +++ b/src/main/resources/openapi/story-teller-api.yaml @@ -10,6 +10,7 @@ tags: - name: Users - name: Scenarios - name: Stories + - name: Settings paths: /auth/login: post: @@ -152,6 +153,32 @@ paths: responses: '204': description: Scenario deleted. + /settings: + get: + tags: [Settings] + operationId: getSettings + responses: + '200': + description: Current settings and available Ollama models. + content: + application/json: + schema: + $ref: '#/components/schemas/Settings' + put: + tags: [Settings] + operationId: updateSettings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSettingsRequest' + responses: + '200': + description: Settings updated; returns current merged settings. + content: + application/json: + schema: + $ref: '#/components/schemas/Settings' /stories: get: tags: [Stories] @@ -505,3 +532,36 @@ components: items: $ref: '#/components/schemas/CharacterDto' description: Updated characters after compaction, if compacted is true. + Settings: + type: object + required: [ollamaBaseUrl, ollamaModel, contextCharLimit, availableModels] + properties: + ollamaBaseUrl: + type: string + description: Ollama server base URL. + ollamaModel: + type: string + description: Selected model name for story generation. + contextCharLimit: + type: integer + description: Context character limit before compaction. + availableModels: + type: array + items: + $ref: '#/components/schemas/OllamaModelInfo' + description: Models returned by Ollama /api/tags (empty if unreachable). + OllamaModelInfo: + type: object + required: [name] + properties: + name: + type: string + UpdateSettingsRequest: + type: object + properties: + ollamaBaseUrl: + type: string + ollamaModel: + type: string + contextCharLimit: + type: integer diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx index f6894fc..55a1cd8 100644 --- a/src/main/web/src/App.tsx +++ b/src/main/web/src/App.tsx @@ -7,6 +7,7 @@ import { ScenariosPage } from "./pages/ScenariosPage"; import { StoriesPage } from "./pages/StoriesPage"; import { StoryWorkspacePage } from "./pages/StoryWorkspacePage"; import { AdminUsersPage } from "./pages/AdminUsersPage"; +import { SettingsPage } from "./pages/SettingsPage"; /** * Root application shell with navigation and route definitions. @@ -22,6 +23,7 @@ export default function App() { )} @@ -43,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/main/web/src/api/client.ts b/src/main/web/src/api/client.ts index 20bf90d..cf6f0c0 100644 --- a/src/main/web/src/api/client.ts +++ b/src/main/web/src/api/client.ts @@ -2,6 +2,7 @@ import { AuthApi, Configuration, ScenariosApi, + SettingsApi, StoriesApi, UsersApi, } from "./generated/src"; @@ -34,6 +35,7 @@ export function getApiClient() { auth: new AuthApi(new Configuration({ basePath: "/api" })), users: new UsersApi(config), scenarios: new ScenariosApi(config), + settings: new SettingsApi(config), stories: new StoriesApi(config), }; } diff --git a/src/main/web/src/pages/SettingsPage.tsx b/src/main/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..e574754 --- /dev/null +++ b/src/main/web/src/pages/SettingsPage.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from "react"; +import type { Settings } from "../api/generated/src"; +import { getApiClient } from "../api/client"; +import { useAuth } from "../context/AuthContext"; + +/** + * Settings page: Ollama base URL, model selection, and context limit. + * Admin can change URL and context limit; any user can set their model. + */ +export function SettingsPage() { + const { auth } = useAuth(); + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const [ollamaBaseUrl, setOllamaBaseUrl] = useState(""); + const [ollamaModel, setOllamaModel] = useState(""); + const [contextCharLimit, setContextCharLimit] = useState(12000); + + const load = useCallback(async () => { + try { + setError(null); + const api = getApiClient(); + const s = await api.settings.getSettings(); + setSettings(s); + setOllamaBaseUrl(s.ollamaBaseUrl); + setOllamaModel(s.ollamaModel); + setContextCharLimit(s.contextCharLimit); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load settings."); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + setSaved(false); + try { + const api = getApiClient(); + const updated = await api.settings.updateSettings({ + updateSettingsRequest: { + ollamaBaseUrl, + ollamaModel: ollamaModel || undefined, + contextCharLimit, + }, + }); + setSettings(updated); + setOllamaBaseUrl(updated.ollamaBaseUrl); + setOllamaModel(updated.ollamaModel); + setContextCharLimit(updated.contextCharLimit); + setSaved(true); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to save settings."); + } finally { + setSaving(false); + } + }; + + if (!settings) { + return ( +
+ {error &&

{error}

} +

Loading settings...

+
+ ); + } + + const noModels = settings.availableModels.length === 0; + + return ( +
+
+

Settings

+
+ {error &&

{error}

} + {saved &&

Settings saved.

} + {noModels && ( +

+ Ollama unreachable or no models. Check the base URL and ensure Ollama is running.{" "} + +

+ )} + +
+

Ollama

+ + + +

Context

+ + +
+ + +
+
+
+ ); +} diff --git a/src/main/web/src/styles.css b/src/main/web/src/styles.css index 106bad3..53d4578 100644 --- a/src/main/web/src/styles.css +++ b/src/main/web/src/styles.css @@ -110,6 +110,7 @@ legend { font-weight: 600; font-size: .9rem; } /* Utilities */ .muted { color: var(--muted); font-size: .9rem; } .error { color: var(--danger); margin-bottom: .75rem; font-weight: 500; } +.success { color: var(--success, #2e7d32); margin-bottom: .75rem; font-weight: 500; } /* Login */ .page-center { diff --git a/src/main/web/tsconfig.app.tsbuildinfo b/src/main/web/tsconfig.app.tsbuildinfo index 88e825b..d5ef339 100644 --- a/src/main/web/tsconfig.app.tsbuildinfo +++ b/src/main/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/generated/src/index.ts","./src/api/generated/src/runtime.ts","./src/api/generated/src/apis/authapi.ts","./src/api/generated/src/apis/scenariosapi.ts","./src/api/generated/src/apis/storiesapi.ts","./src/api/generated/src/apis/usersapi.ts","./src/api/generated/src/apis/index.ts","./src/api/generated/src/models/character.ts","./src/api/generated/src/models/characterdto.ts","./src/api/generated/src/models/createscenariorequest.ts","./src/api/generated/src/models/createuserrequest.ts","./src/api/generated/src/models/generatesteprequest.ts","./src/api/generated/src/models/generatestepresponse.ts","./src/api/generated/src/models/loginrequest.ts","./src/api/generated/src/models/loginresponse.ts","./src/api/generated/src/models/scenario.ts","./src/api/generated/src/models/startstoryrequest.ts","./src/api/generated/src/models/story.ts","./src/api/generated/src/models/storystep.ts","./src/api/generated/src/models/updatescenariorequest.ts","./src/api/generated/src/models/updatesteprequest.ts","./src/api/generated/src/models/updatestoryrequest.ts","./src/api/generated/src/models/updateuserrequest.ts","./src/api/generated/src/models/user.ts","./src/api/generated/src/models/index.ts","./src/components/protectedroute.tsx","./src/context/authcontext.tsx","./src/pages/adminuserspage.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/scenariospage.tsx","./src/pages/storiespage.tsx","./src/pages/storyworkspacepage.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/generated/src/index.ts","./src/api/generated/src/runtime.ts","./src/api/generated/src/apis/authapi.ts","./src/api/generated/src/apis/scenariosapi.ts","./src/api/generated/src/apis/settingsapi.ts","./src/api/generated/src/apis/storiesapi.ts","./src/api/generated/src/apis/usersapi.ts","./src/api/generated/src/apis/index.ts","./src/api/generated/src/models/character.ts","./src/api/generated/src/models/characterdto.ts","./src/api/generated/src/models/createscenariorequest.ts","./src/api/generated/src/models/createuserrequest.ts","./src/api/generated/src/models/generatesteprequest.ts","./src/api/generated/src/models/generatestepresponse.ts","./src/api/generated/src/models/loginrequest.ts","./src/api/generated/src/models/loginresponse.ts","./src/api/generated/src/models/ollamamodelinfo.ts","./src/api/generated/src/models/scenario.ts","./src/api/generated/src/models/settings.ts","./src/api/generated/src/models/startstoryrequest.ts","./src/api/generated/src/models/story.ts","./src/api/generated/src/models/storystep.ts","./src/api/generated/src/models/updatescenariorequest.ts","./src/api/generated/src/models/updatesettingsrequest.ts","./src/api/generated/src/models/updatesteprequest.ts","./src/api/generated/src/models/updatestoryrequest.ts","./src/api/generated/src/models/updateuserrequest.ts","./src/api/generated/src/models/user.ts","./src/api/generated/src/models/index.ts","./src/components/protectedroute.tsx","./src/context/authcontext.tsx","./src/pages/adminuserspage.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/scenariospage.tsx","./src/pages/settingspage.tsx","./src/pages/storiespage.tsx","./src/pages/storyworkspacepage.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/test/java/de/neitzel/storyteller/business/SettingsServiceTest.java b/src/test/java/de/neitzel/storyteller/business/SettingsServiceTest.java new file mode 100644 index 0000000..196f0f2 --- /dev/null +++ b/src/test/java/de/neitzel/storyteller/business/SettingsServiceTest.java @@ -0,0 +1,98 @@ +package de.neitzel.storyteller.business; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; + +import de.neitzel.storyteller.data.entity.SettingEntity; +import de.neitzel.storyteller.data.repository.SettingRepository; +import jakarta.ws.rs.WebApplicationException; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link SettingsService}. + */ +class SettingsServiceTest { + + private SettingRepository settingRepository; + private SettingsService settingsService; + + @BeforeEach + void setUp() { + settingRepository = Mockito.mock(SettingRepository.class); + settingsService = new SettingsService(settingRepository, + "http://localhost:11434", "llama3.2", 12_000); + } + + /** Merged settings return config defaults when no DB overrides exist. */ + @Test + void getMergedSettingsReturnsDefaultsWhenEmpty() { + when(settingRepository.findByKeyAndUserId(anyString(), any())).thenReturn(Optional.empty()); + + final SettingsDto dto = settingsService.getMergedSettings(5L); + + assertNotNull(dto.getOllamaBaseUrl()); + assertTrue(dto.getOllamaBaseUrl().contains("11434")); + assertNotNull(dto.getOllamaModel()); + assertTrue(dto.getContextCharLimit() >= 1000); + } + + /** User model override is applied when present in DB. */ + @Test + void getMergedSettingsUsesUserModelOverride() { + when(settingRepository.findByKeyAndUserId(anyString(), isNull())) + .thenReturn(Optional.empty()); + when(settingRepository.findByKeyAndUserId(eq(SettingsService.KEY_OLLAMA_MODEL), eq(5L))) + .thenReturn(Optional.of(entity(SettingsService.KEY_OLLAMA_MODEL, "llama3.1", 5L))); + + final SettingsDto dto = settingsService.getMergedSettings(5L); + + assertEquals("llama3.1", dto.getOllamaModel()); + } + + /** Non-admin cannot update global base URL. */ + @Test + void updateFromRequestBaseUrlRequiresAdmin() { + assertThrows(WebApplicationException.class, () -> + settingsService.updateFromRequest("http://other:11434", null, null, 5L, false)); + verify(settingRepository, never()).findByKeyAndUserId(anyString(), any()); + } + + /** Admin can update base URL. */ + @Test + void updateFromRequestAdminCanSetBaseUrl() { + when(settingRepository.findByKeyAndUserId(eq(SettingsService.KEY_OLLAMA_BASE_URL), isNull())) + .thenReturn(Optional.empty()); + + settingsService.updateFromRequest("http://ollama:11434", null, null, 1L, true); + + verify(settingRepository).persist(argThat((SettingEntity e) -> + SettingsService.KEY_OLLAMA_BASE_URL.equals(e.getKey()) && "http://ollama:11434".equals(e.getValue()) && e.getUserId() == null)); + } + + /** Any user can update their model. */ + @Test + void updateFromRequestUserCanSetModel() { + when(settingRepository.findByKeyAndUserId(eq(SettingsService.KEY_OLLAMA_MODEL), eq(5L))) + .thenReturn(Optional.empty()); + + settingsService.updateFromRequest(null, "llama3.1", null, 5L, false); + + verify(settingRepository).persist(argThat((SettingEntity e) -> + SettingsService.KEY_OLLAMA_MODEL.equals(e.getKey()) && "llama3.1".equals(e.getValue()) && Long.valueOf(5L).equals(e.getUserId()))); + } + + private static SettingEntity entity(final String key, final String value, final Long userId) { + final SettingEntity e = new SettingEntity(); + e.setKey(key); + e.setValue(value); + e.setUserId(userId); + return e; + } +} diff --git a/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java b/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java index 61a1a17..69510f8 100644 --- a/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java +++ b/src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java @@ -2,9 +2,12 @@ package de.neitzel.storyteller.business; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import de.neitzel.storyteller.common.ollama.OllamaClient; import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity; import de.neitzel.storyteller.data.entity.ScenarioEntity; import de.neitzel.storyteller.data.entity.StoryCharacterEntity; @@ -35,6 +38,8 @@ class StoryServiceTest { private StoryCharacterRepository characterRepository; private StoryStepRepository stepRepository; private ScenarioRepository scenarioRepository; + private SettingsService settingsService; + private OllamaClient ollamaClient; private EntityManager entityManager; private StoryService storyService; @@ -44,10 +49,19 @@ class StoryServiceTest { characterRepository = Mockito.mock(StoryCharacterRepository.class); stepRepository = Mockito.mock(StoryStepRepository.class); scenarioRepository = Mockito.mock(ScenarioRepository.class); + settingsService = Mockito.mock(SettingsService.class); + ollamaClient = Mockito.mock(OllamaClient.class); entityManager = Mockito.mock(EntityManager.class); - storyService = new StoryService(storyRepository, characterRepository, stepRepository, scenarioRepository); + storyService = new StoryService(storyRepository, characterRepository, stepRepository, scenarioRepository, + settingsService, ollamaClient); when(storyRepository.getEntityManager()).thenReturn(entityManager); + final SettingsDto defaultSettings = new SettingsDto(); + defaultSettings.setOllamaBaseUrl("http://localhost:11434"); + defaultSettings.setOllamaModel("llama3.2"); + defaultSettings.setContextCharLimit(12_000); + when(settingsService.getMergedSettings(any(Long.class))).thenReturn(defaultSettings); + when(ollamaClient.generate(anyString(), anyString(), anyString(), anyInt())).thenReturn("Generated opening text."); } /** Starting a story copies the scenario's scene and characters and generates an opening step. */ @@ -145,7 +159,10 @@ class StoryServiceTest { assertFalse(resp.getCompacted()); assertNotNull(resp.getStep()); assertEquals(1, resp.getStep().getStepNumber()); + assertEquals("Generated opening text.", resp.getStep().getContent()); verify(stepRepository).persist(any(StoryStepEntity.class)); + verify(settingsService).getMergedSettings(5L); + verify(ollamaClient).generate(anyString(), eq("llama3.2"), anyString(), anyInt()); } /** Listing stories for a user delegates to repository. */ diff --git a/src/test/java/de/neitzel/storyteller/common/ollama/OllamaClientTest.java b/src/test/java/de/neitzel/storyteller/common/ollama/OllamaClientTest.java new file mode 100644 index 0000000..8c4b903 --- /dev/null +++ b/src/test/java/de/neitzel/storyteller/common/ollama/OllamaClientTest.java @@ -0,0 +1,30 @@ +package de.neitzel.storyteller.common.ollama; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OllamaClient}. + * Verifies that unreachable or invalid URLs result in {@link OllamaUnavailableException}. + */ +class OllamaClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final OllamaClient client = new OllamaClient(objectMapper); + + /** List models with unreachable URL throws OllamaUnavailableException. */ + @Test + void listModelsUnreachableThrows() { + assertThrows(OllamaUnavailableException.class, () -> + client.listModels("http://127.0.0.1:19999")); + } + + /** Generate with unreachable URL throws OllamaUnavailableException. */ + @Test + void generateUnreachableThrows() { + assertThrows(OllamaUnavailableException.class, () -> + client.generate("http://127.0.0.1:19999", "llama3.2", "Hello", 2048)); + } +}