a first working version

This commit is contained in:
Konrad Neitzel 2026-02-25 18:20:14 +01:00
parent fa9d2d3136
commit 6c6a75e594
24 changed files with 1306 additions and 18 deletions

View File

@ -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;
}
}

View File

@ -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<SettingEntity> global = settingRepository.findByKeyAndUserId(key, null);
if (userId != null) {
final Optional<SettingEntity> 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;
}
}
}

View File

@ -6,6 +6,8 @@ import de.neitzel.storyteller.data.entity.StoryEntity;
import de.neitzel.storyteller.data.entity.StoryStepEntity; import de.neitzel.storyteller.data.entity.StoryStepEntity;
import de.neitzel.storyteller.data.repository.ScenarioRepository; import de.neitzel.storyteller.data.repository.ScenarioRepository;
import de.neitzel.storyteller.data.repository.StoryCharacterRepository; 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.StoryRepository;
import de.neitzel.storyteller.data.repository.StoryStepRepository; import de.neitzel.storyteller.data.repository.StoryStepRepository;
import de.neitzel.storyteller.fascade.model.CharacterDto; import de.neitzel.storyteller.fascade.model.CharacterDto;
@ -30,11 +32,8 @@ import java.util.List;
@ApplicationScoped @ApplicationScoped
public class StoryService { public class StoryService {
/** /** Approximate tokens per character for num_ctx. */
* Approximate character budget before compaction kicks in. private static final int CHARS_PER_TOKEN = 4;
* In a real system this would be configurable and model-specific.
*/
private static final int CONTEXT_CHAR_LIMIT = 12_000;
/** Story persistence. */ /** Story persistence. */
private final StoryRepository storyRepository; private final StoryRepository storyRepository;
@ -44,23 +43,33 @@ public class StoryService {
private final StoryStepRepository stepRepository; private final StoryStepRepository stepRepository;
/** Scenario persistence (for initial data on story start). */ /** Scenario persistence (for initial data on story start). */
private final ScenarioRepository scenarioRepository; 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 storyRepository story persistence
* @param characterRepository character persistence * @param characterRepository character persistence
* @param stepRepository step persistence * @param stepRepository step persistence
* @param scenarioRepository scenario persistence * @param scenarioRepository scenario persistence
* @param settingsService settings for Ollama and context limit
* @param ollamaClient Ollama API client
*/ */
public StoryService(final StoryRepository storyRepository, public StoryService(final StoryRepository storyRepository,
final StoryCharacterRepository characterRepository, final StoryCharacterRepository characterRepository,
final StoryStepRepository stepRepository, final StoryStepRepository stepRepository,
final ScenarioRepository scenarioRepository) { final ScenarioRepository scenarioRepository,
final SettingsService settingsService,
final OllamaClient ollamaClient) {
this.storyRepository = storyRepository; this.storyRepository = storyRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.stepRepository = stepRepository; this.stepRepository = stepRepository;
this.scenarioRepository = scenarioRepository; this.scenarioRepository = scenarioRepository;
this.settingsService = settingsService;
this.ollamaClient = ollamaClient;
} }
/** /**
@ -126,7 +135,8 @@ public class StoryService {
characterRepository.persist(ch); 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(); final StoryStepEntity step = new StoryStepEntity();
step.setStoryId(entity.getId()); step.setStoryId(entity.getId());
step.setStepNumber(1); step.setStepNumber(1);
@ -235,10 +245,13 @@ public class StoryService {
replaceCharacters(story, request.getCurrentCharacters()); replaceCharacters(story, request.getCurrentCharacters());
} }
final SettingsDto settings = settingsService.getMergedSettings(userId);
final int contextCharLimit = settings.getContextCharLimit();
List<StoryStepEntity> unmerged = stepRepository.findUnmergedByStory(storyId); List<StoryStepEntity> unmerged = stepRepository.findUnmergedByStory(storyId);
boolean compacted = false; boolean compacted = false;
while (estimateContextSize(story, unmerged, request.getDirection()) > CONTEXT_CHAR_LIMIT while (estimateContextSize(story, unmerged, request.getDirection()) > contextCharLimit
&& unmerged.size() > 1) { && unmerged.size() > 1) {
compacted = true; compacted = true;
final int half = Math.max(1, unmerged.size() / 2); final int half = Math.max(1, unmerged.size() / 2);
@ -253,7 +266,7 @@ public class StoryService {
story.setUpdatedAt(LocalDateTime.now()); story.setUpdatedAt(LocalDateTime.now());
final int nextNumber = stepRepository.maxStepNumber(storyId) + 1; 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(); final StoryStepEntity step = new StoryStepEntity();
step.setStoryId(storyId); step.setStoryId(storyId);
step.setStepNumber(nextNumber); step.setStepNumber(nextNumber);
@ -308,15 +321,50 @@ public class StoryService {
} }
/** /**
* AI stub: generates the next story segment from the current context. * Generates the next story segment via Ollama using the current scene, characters,
* In production this would call an LLM. * 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, private String generateAiContinuation(final StoryEntity story,
final List<StoryStepEntity> unmerged, final List<StoryStepEntity> unmerged,
final String direction) { final String direction,
return "[AI continuation] Direction: " + direction final SettingsDto settings) {
+ "\nScene: " + truncate(story.getCurrentSceneDescription(), 120) final StringBuilder prompt = new StringBuilder();
+ "\nUnmerged steps: " + unmerged.size(); 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. */ /** Truncates text for display in stubs. */

View File

@ -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<OllamaModelInfo> 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<String> 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<String> 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 + "/";
}
}

View File

@ -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<String, Object> 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<String, Object> getOptions() {
return options;
}
/**
* Sets the options map.
*
* @param options options map
*/
public void setOptions(final Map<String, Object> options) {
this.options = options;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<OllamaModelInfo> models = Collections.emptyList();
/**
* Returns the list of available models.
*
* @return list of model info
*/
public List<OllamaModelInfo> getModels() {
return models;
}
/**
* Sets the list of models.
*
* @param models list of model info
*/
@JsonProperty("models")
public void setModels(final List<OllamaModelInfo> models) {
this.models = models != null ? models : Collections.emptyList();
}
}

View File

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

View File

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

View File

@ -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<SettingEntity> {
/**
* 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<SettingEntity> 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);
}
}
}

View File

@ -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<OllamaUnavailableException> {
/** {@inheritDoc} */
@Override
public Response toResponse(final OllamaUnavailableException exception) {
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity(exception.getMessage())
.build();
}
}

View File

@ -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<de.neitzel.storyteller.fascade.model.OllamaModelInfo> availableModels = new ArrayList<>();
try {
final List<OllamaModelInfo> 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();
}
}

View File

@ -22,3 +22,8 @@ quarkus.http.auth.permission.protected.paths=/api/*
quarkus.http.auth.permission.protected.policy=authenticated quarkus.http.auth.permission.protected.policy=authenticated
quarkus.http.auth.permission.static.paths=/* quarkus.http.auth.permission.static.paths=/*
quarkus.http.auth.permission.static.policy=permit 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

View File

@ -253,6 +253,68 @@ databaseChangeLog:
constraints: constraints:
nullable: false 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: - changeSet:
id: 005-seed-admin id: 005-seed-admin
author: storyteller author: storyteller

View File

@ -10,6 +10,7 @@ tags:
- name: Users - name: Users
- name: Scenarios - name: Scenarios
- name: Stories - name: Stories
- name: Settings
paths: paths:
/auth/login: /auth/login:
post: post:
@ -152,6 +153,32 @@ paths:
responses: responses:
'204': '204':
description: Scenario deleted. 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: /stories:
get: get:
tags: [Stories] tags: [Stories]
@ -505,3 +532,36 @@ components:
items: items:
$ref: '#/components/schemas/CharacterDto' $ref: '#/components/schemas/CharacterDto'
description: Updated characters after compaction, if compacted is true. 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

View File

@ -7,6 +7,7 @@ import { ScenariosPage } from "./pages/ScenariosPage";
import { StoriesPage } from "./pages/StoriesPage"; import { StoriesPage } from "./pages/StoriesPage";
import { StoryWorkspacePage } from "./pages/StoryWorkspacePage"; import { StoryWorkspacePage } from "./pages/StoryWorkspacePage";
import { AdminUsersPage } from "./pages/AdminUsersPage"; import { AdminUsersPage } from "./pages/AdminUsersPage";
import { SettingsPage } from "./pages/SettingsPage";
/** /**
* Root application shell with navigation and route definitions. * Root application shell with navigation and route definitions.
@ -22,6 +23,7 @@ export default function App() {
<nav> <nav>
<Link to="/scenarios">Scenarios</Link> <Link to="/scenarios">Scenarios</Link>
<Link to="/stories">Stories</Link> <Link to="/stories">Stories</Link>
<Link to="/settings">Settings</Link>
{auth.isAdmin && <Link to="/admin/users">Users</Link>} {auth.isAdmin && <Link to="/admin/users">Users</Link>}
</nav> </nav>
)} )}
@ -43,6 +45,7 @@ export default function App() {
<Route path="/scenarios" element={<ProtectedRoute><ScenariosPage /></ProtectedRoute>} /> <Route path="/scenarios" element={<ProtectedRoute><ScenariosPage /></ProtectedRoute>} />
<Route path="/stories" element={<ProtectedRoute><StoriesPage /></ProtectedRoute>} /> <Route path="/stories" element={<ProtectedRoute><StoriesPage /></ProtectedRoute>} />
<Route path="/stories/:storyId" element={<ProtectedRoute><StoryWorkspacePage /></ProtectedRoute>} /> <Route path="/stories/:storyId" element={<ProtectedRoute><StoryWorkspacePage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/admin/users" element={<ProtectedRoute adminOnly><AdminUsersPage /></ProtectedRoute>} /> <Route path="/admin/users" element={<ProtectedRoute adminOnly><AdminUsersPage /></ProtectedRoute>} />
</Routes> </Routes>
</main> </main>

View File

@ -2,6 +2,7 @@ import {
AuthApi, AuthApi,
Configuration, Configuration,
ScenariosApi, ScenariosApi,
SettingsApi,
StoriesApi, StoriesApi,
UsersApi, UsersApi,
} from "./generated/src"; } from "./generated/src";
@ -34,6 +35,7 @@ export function getApiClient() {
auth: new AuthApi(new Configuration({ basePath: "/api" })), auth: new AuthApi(new Configuration({ basePath: "/api" })),
users: new UsersApi(config), users: new UsersApi(config),
scenarios: new ScenariosApi(config), scenarios: new ScenariosApi(config),
settings: new SettingsApi(config),
stories: new StoriesApi(config), stories: new StoriesApi(config),
}; };
} }

View File

@ -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<Settings | null>(null);
const [error, setError] = useState<string | null>(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 (
<section>
{error && <p className="error">{error}</p>}
<p>Loading settings...</p>
</section>
);
}
const noModels = settings.availableModels.length === 0;
return (
<section>
<div className="page-header">
<h2>Settings</h2>
</div>
{error && <p className="error">{error}</p>}
{saved && <p className="success">Settings saved.</p>}
{noModels && (
<p className="muted">
Ollama unreachable or no models. Check the base URL and ensure Ollama is running.{" "}
<button type="button" className="btn-sm" onClick={load}>Retry</button>
</p>
)}
<form className="card form-card settings-form" onSubmit={handleSave}>
<h3>Ollama</h3>
<label>
Base URL
{auth?.isAdmin && <span className="muted"> (admin only)</span>}
<input
type="url"
value={ollamaBaseUrl}
onChange={(e) => setOllamaBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
disabled={!auth?.isAdmin}
/>
</label>
<label>
Model
{noModels ? (
<input
type="text"
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
placeholder="e.g. llama3.2 (fetch models to see list)"
/>
) : (
<select
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
>
{settings.availableModels.map((m) => (
<option key={m.name} value={m.name}>{m.name}</option>
))}
{!settings.availableModels.some((m) => m.name === ollamaModel) && ollamaModel && (
<option value={ollamaModel}>{ollamaModel}</option>
)}
</select>
)}
</label>
<h3>Context</h3>
<label>
Context character limit
{auth?.isAdmin && <span className="muted"> (admin only)</span>}
<input
type="number"
min={1000}
max={200000}
step={1000}
value={contextCharLimit}
onChange={(e) => setContextCharLimit(Number(e.target.value))}
disabled={!auth?.isAdmin}
/>
</label>
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
<button type="button" onClick={load}>Reload</button>
</div>
</form>
</section>
);
}

View File

@ -110,6 +110,7 @@ legend { font-weight: 600; font-size: .9rem; }
/* Utilities */ /* Utilities */
.muted { color: var(--muted); font-size: .9rem; } .muted { color: var(--muted); font-size: .9rem; }
.error { color: var(--danger); margin-bottom: .75rem; font-weight: 500; } .error { color: var(--danger); margin-bottom: .75rem; font-weight: 500; }
.success { color: var(--success, #2e7d32); margin-bottom: .75rem; font-weight: 500; }
/* Login */ /* Login */
.page-center { .page-center {

View File

@ -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"} {"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"}

View File

@ -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;
}
}

View File

@ -2,9 +2,12 @@ package de.neitzel.storyteller.business;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; 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.ScenarioCharacterEntity;
import de.neitzel.storyteller.data.entity.ScenarioEntity; import de.neitzel.storyteller.data.entity.ScenarioEntity;
import de.neitzel.storyteller.data.entity.StoryCharacterEntity; import de.neitzel.storyteller.data.entity.StoryCharacterEntity;
@ -35,6 +38,8 @@ class StoryServiceTest {
private StoryCharacterRepository characterRepository; private StoryCharacterRepository characterRepository;
private StoryStepRepository stepRepository; private StoryStepRepository stepRepository;
private ScenarioRepository scenarioRepository; private ScenarioRepository scenarioRepository;
private SettingsService settingsService;
private OllamaClient ollamaClient;
private EntityManager entityManager; private EntityManager entityManager;
private StoryService storyService; private StoryService storyService;
@ -44,10 +49,19 @@ class StoryServiceTest {
characterRepository = Mockito.mock(StoryCharacterRepository.class); characterRepository = Mockito.mock(StoryCharacterRepository.class);
stepRepository = Mockito.mock(StoryStepRepository.class); stepRepository = Mockito.mock(StoryStepRepository.class);
scenarioRepository = Mockito.mock(ScenarioRepository.class); scenarioRepository = Mockito.mock(ScenarioRepository.class);
settingsService = Mockito.mock(SettingsService.class);
ollamaClient = Mockito.mock(OllamaClient.class);
entityManager = Mockito.mock(EntityManager.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); 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. */ /** Starting a story copies the scenario's scene and characters and generates an opening step. */
@ -145,7 +159,10 @@ class StoryServiceTest {
assertFalse(resp.getCompacted()); assertFalse(resp.getCompacted());
assertNotNull(resp.getStep()); assertNotNull(resp.getStep());
assertEquals(1, resp.getStep().getStepNumber()); assertEquals(1, resp.getStep().getStepNumber());
assertEquals("Generated opening text.", resp.getStep().getContent());
verify(stepRepository).persist(any(StoryStepEntity.class)); 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. */ /** Listing stories for a user delegates to repository. */

View File

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