a first working version
This commit is contained in:
parent
fa9d2d3136
commit
6c6a75e594
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<StoryStepEntity> 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<StoryStepEntity> 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. */
|
||||
|
||||
@ -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 + "/";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
<nav>
|
||||
<Link to="/scenarios">Scenarios</Link>
|
||||
<Link to="/stories">Stories</Link>
|
||||
<Link to="/settings">Settings</Link>
|
||||
{auth.isAdmin && <Link to="/admin/users">Users</Link>}
|
||||
</nav>
|
||||
)}
|
||||
@ -43,6 +45,7 @@ export default function App() {
|
||||
<Route path="/scenarios" element={<ProtectedRoute><ScenariosPage /></ProtectedRoute>} />
|
||||
<Route path="/stories" element={<ProtectedRoute><StoriesPage /></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>} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
149
src/main/web/src/pages/SettingsPage.tsx
Normal file
149
src/main/web/src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user